Просмотр исходного кода

Merge remote-tracking branch 'origin/cfclub' into cfclub

E2E Test Bot 1 неделя назад
Родитель
Сommit
214de1e05e

+ 270 - 0
cfc-frontend/components/IndicatorTrendArrow.vue

@@ -0,0 +1,270 @@
+<template>
+  <view class="it-anchor">
+    <!-- 触发区:有箭头才渲染;点击弹出曲线浮层 -->
+    <view
+      v-if="arrowText"
+      class="indicator-trend-arrow"
+      @tap.stop="openPopup"
+    >
+      <text :class="arrowCssClass">{{ arrowText }}</text>
+      <text v-if="showLatest && effectiveLatestText" class="it-a-latest">{{ effectiveLatestText }}</text>
+    </view>
+
+    <!-- 曲线弹层 -->
+    <view v-if="popupVisible" class="it-popup-mask" @tap.stop="closePopup">
+      <view class="it-popup-card" @tap.stop>
+        <view class="it-popup-head">
+          <text class="it-popup-title">{{ titleText }}</text>
+          <text v-if="effectiveLatestText" class="it-popup-latest">最新:{{ effectiveLatestText }}</text>
+          <text class="it-popup-close" @tap.stop="closePopup">✕</text>
+        </view>
+        <view class="it-popup-body">
+          <view v-if="!hasTrend" class="it-empty">
+            <text v-if="!item" class="it-empty-note">暂无趋势数据</text>
+            <text v-else class="it-empty-note">仅一次记录,暂无趋势</text>
+          </view>
+          <TrendChart
+            v-else
+            :series="series"
+            mode="line"
+            :ref-low="item.refLow"
+            :ref-high="item.refHigh"
+            :all-period-dates="allPeriodDates"
+            :show-values="false"
+          />
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getIndicatorTrend } from '@/utils/api'
+import { resolveChange } from '@/utils/indicator-change'
+import TrendChart from '@/pages/health/components/TrendChart.vue'
+
+export default {
+  name: 'IndicatorTrendArrow',
+  components: { TrendChart },
+  props: {
+    /** 用户 id(必传) */
+    subjectId: { type: [Number, String], required: true },
+    /** 指标定义 id(必传) */
+    definitionId: { type: [Number, String], required: true },
+    /** 报告来源类型(可选,透传给后端) */
+    sourceType: { type: String, default: '' },
+    /** 外部已加载的 trend 数据,传入则零请求 */
+    trend: { type: Object, default: null },
+    /** 最新值文本(可选,供弹层标题/内联展示;缺省时回退 item.latest) */
+    latestText: { type: String, default: '' },
+    /** 是否在内联触发区同时展示最新值(默认 true;宿主已展示值时传 false 避免重复) */
+    showLatest: { type: Boolean, default: true }
+  },
+  data: function() {
+    return {
+      item: this.trend,
+      popupVisible: false,
+      loading: false
+    }
+  },
+  computed: {
+    /** 方向箭头:movement up→↑ down→↓ flat→→;不可用时返回 ''
+     *  注:箭头判定只依赖 change.movement(quicklook 行 item 无 points,
+     *      不能因 points.length<2 而拦截箭头);openPopup 时若无 points,
+     *      弹窗内已降级为「暂无趋势数据」,不会崩。 */
+    arrowText: function() {
+      if (!this.item || !this.item.change) return ''
+      var ch = this.item.change
+      if (ch.comparable === false) return ''
+      var mv = ch.movement
+      if (mv === 'up') return '↑'
+      if (mv === 'down') return '↓'
+      if (mv === 'flat') return '→'
+      return ''
+    },
+    /** 箭头颜色类(消费后端 trendCode,前端不自判改善/恶化) */
+    arrowCssClass: function() {
+      if (!this.item || !this.item.change) return 'gray-text'
+      var r = resolveChange(this.item.change.trendCode, this.item.change.movement, this.item.change.trendLabel)
+      return r.colorClass
+    },
+    /** 是否有可画的 trend(>=1 期) */
+    hasTrend: function() {
+      return !!(this.item && this.item.points && this.item.points.length >= 1)
+    },
+    /** series 构造:与 indicator-timeline.vue series 口径一致 */
+    series: function() {
+      if (!this.item || !Array.isArray(this.item.points)) return []
+      return [{
+        name: this.item.name || '',
+        color: '#F97316',
+        points: this.item.points.map(function(p) {
+          return {
+            reportDate: String(p.reportDate).slice(0, 10),
+            value: p.value,
+            numericValue: p.numericValue,
+            unit: p.unit,
+            dateSource: p.dateSource
+          }
+        })
+      }]
+    },
+    /** X 轴日期全集(points 日期 + missingPeriods 日期合并去重升序) */
+    allPeriodDates: function() {
+      if (!this.item) return []
+      var set = {}
+      var pts = this.item.points || []
+      for (var i = 0; i < pts.length; i++) {
+        var d = pts[i] && pts[i].reportDate
+        if (d) set[String(d).slice(0, 10)] = true
+      }
+      var misses = this.item.missingPeriods || []
+      for (var j = 0; j < misses.length; j++) {
+        var md = misses[j] && misses[j].reportDate
+        if (md) set[String(md).slice(0, 10)] = true
+      }
+      var keys = Object.keys(set)
+      keys.sort(function(a, b) { return a < b ? -1 : (a > b ? 1 : 0) })
+      return keys
+    },
+    titleText: function() {
+      if (!this.item) return ''
+      var n = this.item.name || ''
+      var u = this.item.unit ? (' (' + this.item.unit + ')') : ''
+      return n + u
+    },
+    /** 最新值展示文本:优先 prop,缺省回退 item.latest(弹层标题/内联共用) */
+    effectiveLatestText: function() {
+      if (this.latestText) return this.latestText
+      if (!this.item || !this.item.latest) return ''
+      var v = this.item.latest
+      if (v.numericValue !== undefined && v.numericValue !== null) return String(v.numericValue)
+      return v.value || ''
+    }
+  },
+  watch: {
+    trend: function(val) { this.item = val }
+  },
+  mounted: function() {
+    var self = this
+    var hasPoints = !!(this.item && this.item.points && this.item.points.length > 0)
+    // 已有 trend prop 且含 points:零请求直接消费
+    if (this.item && hasPoints) return
+    var sid = Number(this.subjectId)
+    var did = Number(this.definitionId)
+    if (isNaN(sid) || isNaN(did)) return
+    // 无 trend 或 trend 缺 points(quicklook 行 item):自拉补全,供箭头/弹窗曲线使用
+    this.loading = true
+    getIndicatorTrend({
+      subjectId: sid,
+      definitionIds: [did],
+      sourceType: this.sourceType || undefined
+    }).then(function(res) {
+      self.loading = false
+      var full = (res.data && res.data.items && res.data.items[0]) || null
+      // 已拿到完整 trend(含 points)时整体替换;仅当请求失败(full 为空)才保留行 item 的 change 供箭头展示
+      if (full) { self.item = full }
+    }).catch(function() {
+      self.loading = false
+    })
+  },
+  methods: {
+    openPopup: function() {
+      this.popupVisible = true
+    },
+    closePopup: function() {
+      this.popupVisible = false
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+/* 锚点容器:默认行内,不打断宿主布局 */
+.it-anchor {
+  display: inline;
+}
+/* 箭头触发区:30rpx 字号,行内对齐指标值展示 */
+.indicator-trend-arrow {
+  display: inline-flex;
+  align-items: center;
+  margin-left: 6rpx;
+  vertical-align: baseline;
+}
+.indicator-trend-arrow text {
+  font-size: 30rpx;
+  line-height: 1;
+}
+.it-a-latest {
+  font-size: calc(20rpx * var(--font-scale, 1));
+  color: $facade-text-body;
+  margin-left: 6rpx;
+}
+
+/* 遮罩:固定全屏,深色半透明 */
+.it-popup-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 999;
+  background: rgba(0, 0, 0, 0.55);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+/* 卡片:居中,圆角,内边距对齐指标页风格 */
+.it-popup-card {
+  width: 85vw;
+  max-height: 80vh;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  display: flex;
+  flex-direction: column;
+  box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.18);
+}
+.it-popup-head {
+  display: flex;
+  flex-direction: row;
+  align-items: baseline;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+}
+.it-popup-title {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  font-weight: 700;
+  color: #111827;
+  flex: 1;
+}
+.it-popup-latest {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-body;
+  margin-right: 16rpx;
+}
+.it-popup-close {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  color: #9ca3af;
+  padding: 4rpx 8rpx;
+}
+.it-popup-body {
+  flex: 1;
+  min-height: 0;
+}
+.it-empty {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 200rpx;
+}
+.it-empty-note {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: #9ca3af;
+}
+
+/* 颜色类:全局未定义时兜底;scoped 下不影响其他组件 */
+.green-text { color: #2BA24C; }
+.red-text   { color: #E64340; }
+.gray-text  { color: #999; }
+</style>

+ 17 - 2
cfc-frontend/pages/health/indicator-quicklook.vue

@@ -22,7 +22,16 @@
       <view class="ql-item" v-for="item in items" :key="getItemKey(item)" @tap="goTimeline(item)">
         <view class="ql-name-row">
           <text class="ql-name">{{ item.name }}</text>
-          <text :class="'ql-trend ' + item._cssClass">{{ item._arrow }} {{ item._trendLabel }}</text>
+          <view class="ql-trend-row">
+            <indicator-trend-arrow
+              :subject-id="currentMemberId"
+              :definition-id="item.definitionId"
+              :trend="item"
+              :latest-text="item._latestText || ''"
+              :show-latest="false"
+            />
+            <text :class="'ql-trend ' + item._cssClass">{{ item._trendLabel }}</text>
+          </view>
         </view>
         <view class="ql-value-row">
           <text class="ql-value">{{ item.value }}{{ item.unit || '' }}</text>
@@ -66,11 +75,12 @@ import { getIndicatorQuickLookup } from '@/utils/api'
 import { getFamilyMemberList } from '@/utils/api'
 import { resolveChange } from '@/utils/indicator-change'
 import IndicatorChangeRow from '@/pages/health/components/IndicatorChangeRow.vue'
+import IndicatorTrendArrow from '@/components/IndicatorTrendArrow.vue'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
-  components: { IndicatorChangeRow },
+  components: { IndicatorChangeRow, IndicatorTrendArrow },
   data: function() { return { members: [], currentMemberId: null, categories: [], activeCategory: '', items: [], dimensionScores: [], page: 1, pageSize: 30, totalPages: 1, _onLoadFired: false, _loading: false } },
   computed: {
     memberNames: function() { return this.members.map(function(m) { return m.name || m.nickname || '' }) },
@@ -123,6 +133,9 @@ export default {
           it._cssClass = st.colorClass
           it._arrow = st.arrow
           it._trendLabel = st.trendLabel
+          it._latestText = (it.numericValue !== undefined && it.numericValue !== null)
+            ? String(it.numericValue)
+            : (it.value || '')
           it._statusClass = 'status-' + (it._cssClass || 'normal')
           it._dateText = it.latestReportDate ? String(it.latestReportDate).slice(0, 10) : '--'
           // IndicatorChangeRow 入参(预计算,避免模板内联对象字面量)
@@ -164,6 +177,8 @@ export default {
 .ql-name-row { display: flex; justify-content: space-between; margin-bottom: 8rpx; }
 .ql-name { font-size: calc(26rpx * var(--font-scale, 1)); }
 .ql-trend { font-size: calc(20rpx * var(--font-scale, 1)); color: $facade-text-body; }
+.ql-trend-row { display: flex; flex-direction: row; align-items: center; }
+.ql-trend-row .it-anchor { display: inline-flex; align-items: center; }
 .ql-value-row { display: flex; justify-content: space-between; margin-bottom: 8rpx; }
 .ql-value { font-size: calc(24rpx * var(--font-scale, 1)); }
 .ql-ref { font-size: calc(16rpx * var(--font-scale, 1)); color: $facade-text-muted; }

+ 10 - 1
cfc-frontend/pages/health/indicator-timeline.vue

@@ -29,6 +29,14 @@
       </view>
       <view class="head-value-row">
         <text class="head-value">{{ latestValueText }}</text>
+        <indicator-trend-arrow
+          v-if="chartMode !== 'pair'"
+          :subject-id="currentMemberId"
+          :definition-id="item.definitionId"
+          :trend="item"
+          :latest-text="latestValueText"
+          :show-latest="false"
+        />
         <text class="head-unit" v-if="item.unit">{{ item.unit }}</text>
         <text class="head-count">共 {{ pointsCount }} 期</text>
       </view>
@@ -143,11 +151,12 @@ import { consumeChartMode, buildAllPeriodDates, buildPeriodRows, formatPeriodDat
 import IndicatorIncompleteBanner from '@/pages/health/components/IndicatorIncompleteBanner.vue'
 import IndicatorDegrade from '@/pages/health/components/IndicatorDegrade.vue'
 import TrendChart from '@/pages/health/components/TrendChart.vue'
+import IndicatorTrendArrow from '@/components/IndicatorTrendArrow.vue'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
-  components: { IndicatorIncompleteBanner, IndicatorDegrade, TrendChart },
+  components: { IndicatorIncompleteBanner, IndicatorDegrade, TrendChart, IndicatorTrendArrow },
   data: function() {
     return {
       members: [],