Pārlūkot izejas kodu

feat: DAN compare + indicator curve dynamic types + skipGaps + schema cleanup

- Backend: IndicatorTrendService deltaPct null-safety + skipGaps calc
- Backend: IndicatorController source-types endpoint
- Backend: schema.sql delete duplicate report_format L1829
- Backend: DanReportUploadService compareUploads(new) + DanReportController /compare
- Frontend: indicator-curve.vue reportTypeOptions dynamic (source-types) + fallback
- Frontend: indicator-curve.vue skipGaps >0 display + rank-empty tip
- Frontend: dan-assessment/comparison.vue + pages.json registration
- Frontend: utils/api.js getIndicatorSourceTypes wrapper
liaoxg 1 dienu atpakaļ
vecāks
revīzija
e8ca302cea

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/DanReportController.java

@@ -72,4 +72,12 @@ public class DanReportController {
         String dimension = (String) body.get("dimension");
         return uploadService.listByMember(memberId, dimension);
     }
+
+    /** DAN 报告两版对比接口(设计:两次上传按 uploaded_at 对齐 parsed/edited_items 算 delta) */
+    @PostMapping("/compare")
+    public Result<?> compare(@RequestBody Map<String, Object> params) {
+        Long uploadIdA = ParamUtils.getLong(params.get("uploadIdA"));
+        Long uploadIdB = ParamUtils.getLong(params.get("uploadIdB"));
+        return uploadService.compareUploads(uploadIdA, uploadIdB);
+    }
 }

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java

@@ -103,6 +103,18 @@ public class IndicatorController {
         return Result.success(indicatorTrendService.quickLookup(subjectId, category, page, pageSize, familyId));
     }
 
+    /** 成员已有指标时序的报告类型列表(曲线页类型筛选动态化) */
+    @PostMapping("/source-types")
+    public Result<?> sourceTypes(@RequestBody Map<String, Object> params,
+                                 @RequestAttribute("familyId") Long familyId) {
+        Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
+        Result<Void> check = checkSubjectBelongs(subjectId, familyId);
+        if (check != null) {
+            return check;
+        }
+        return Result.success(indicatorTrendService.listSourceTypes(subjectId));
+    }
+
     /** 指标关联查询(设计 6.4) */
     @PostMapping("/correlation/list")
     public Result<?> correlationList(@RequestBody Map<String, Object> params,

+ 130 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java

@@ -26,10 +26,13 @@ import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.text.SimpleDateFormat;
+import java.util.ArrayList;
 import java.util.Date;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import com.etotem.cfc.util.SortUtil;
 
 @Service
@@ -617,4 +620,131 @@ if (!DanReportUpload.DIM_MIND.equals(dimension) && !DanReportUpload.DIM_WISDOM.e
         }
         return Result.success(vo);
     }
+
+    /** DAN 报告两版对比:按 uploaded_at 先后排序,较早的为基准。
+     * 返回 parsed/edited items 差异列表(数值 delta + 新增/删除标记)。
+     */
+    public Result<Map<String, Object>> compareUploads(Long uploadIdA, Long uploadIdB) {
+        if (uploadIdA == null || uploadIdB == null) {
+            return Result.error("缺少 uploadIdA 或 uploadIdB");
+        }
+        DanReportUpload uploadA = uploadMapper.selectById(uploadIdA);
+        DanReportUpload uploadB = uploadMapper.selectById(uploadIdB);
+        if (uploadA == null || uploadB == null) {
+            return Result.error("上传记录不存在");
+        }
+        if (!uploadA.getFamilyMemberId().equals(uploadB.getFamilyMemberId())) {
+            return Result.error("两次上传必须属于同一成员");
+        }
+        // 以 uploaded_at 先后顺序排序:较早的为基准
+        DanReportUpload first = uploadA.getUploadedAt() != null && uploadB.getUploadedAt() != null
+                && uploadA.getUploadedAt().before(uploadB.getUploadedAt()) ? uploadA : uploadB;
+        DanReportUpload second = first == uploadA ? uploadB : uploadA;
+        // 使用 edited_* 优先,否则 parsed_*
+        String firstItems = first.getEditedItems() != null ? first.getEditedItems() : first.getParsedItems();
+        String firstSummary = first.getEditedSummary() != null ? first.getEditedSummary() : first.getParsedSummary();
+        String secondItems = second.getEditedItems() != null ? second.getEditedItems() : second.getParsedItems();
+        String secondSummary = second.getEditedSummary() != null ? second.getEditedSummary() : second.getParsedSummary();
+        List<Map<String, Object>> diff = diffDeltas(firstItems, secondItems, first, second);
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("uploadIdA", uploadA.getId());
+        result.put("uploadIdB", uploadB.getId());
+        result.put("firstUploadId", first.getId());
+        result.put("secondUploadId", second.getId());
+        result.put("orderBy", "uploaded_at_asc");
+        result.put("deltas", diff);
+        result.put("firstSummary", firstSummary);
+        result.put("secondSummary", secondSummary);
+        return Result.success(result);
+    }
+
+    /** 指定两次上传的 items 差异:以 code 对齐,数值算 delta,单边项标 added/removed */
+    private List<Map<String, Object>> diffDeltas(String itemsJson1, String itemsJson2,
+                                                 DanReportUpload first, DanReportUpload second) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        if (itemsJson1 == null || itemsJson2 == null) {
+            return result;
+        }
+        List<Map<String, Object>> list1, list2;
+        try {
+            list1 = objectMapper.readValue(itemsJson1,
+                    new TypeReference<List<Map<String, Object>>>() {});
+            list2 = objectMapper.readValue(itemsJson2,
+                    new TypeReference<List<Map<String, Object>>>() {});
+        } catch (Exception e) {
+            return result;
+        }
+        if (list1.isEmpty() || list2.isEmpty()) {
+            return result;
+        }
+        // 以 code 为键建立映射(code 唯一标识一项指标)
+        Map<String, Map<String, Object>> map1 = new LinkedHashMap<>();
+        for (Map<String, Object> item : list1) {
+            String code = item.get("code") != null ? String.valueOf(item.get("code")) : null;
+            if (code != null) map1.put(code, item);
+        }
+        Map<String, Map<String, Object>> map2 = new LinkedHashMap<>();
+        for (Map<String, Object> item : list2) {
+            String code = item.get("code") != null ? String.valueOf(item.get("code")) : null;
+            if (code != null) map2.put(code, item);
+        }
+        // 合并所有 codes(保持先出现顺序 + 新增 codes)
+        LinkedHashSet<String> codes = new LinkedHashSet<>();
+        for (Map<String, Object> item : list1) {
+            String code = item.get("code") != null ? String.valueOf(item.get("code")) : null;
+            if (code != null) codes.add(code);
+        }
+        for (Map<String, Object> item : list2) {
+            String code = item.get("code") != null ? String.valueOf(item.get("code")) : null;
+            if (code != null) codes.add(code);
+        }
+        for (String code : codes) {
+            Map<String, Object> m1 = map1.get(code);
+            Map<String, Object> m2 = map2.get(code);
+            if (m1 != null && m2 != null) {
+                Map<String, Object> delta = new LinkedHashMap<>();
+                delta.put("status", "modified");
+                delta.put("code", code);
+                delta.put("name1", m1.get("name"));
+                delta.put("name2", m2.get("name"));
+                delta.put("category1", m1.get("category"));
+                delta.put("category2", m2.get("category"));
+                Object v1 = m1.get("value");
+                Object v2 = m2.get("value");
+                if (v1 instanceof Number && v2 instanceof Number) {
+                    double d1 = ((Number) v1).doubleValue();
+                    double d2 = ((Number) v2).doubleValue();
+                    delta.put("value1", v1);
+                    delta.put("value2", v2);
+                    delta.put("valueDelta", Math.round((d2 - d1) * 10.0) / 10.0);
+                    delta.put("change", Math.abs(d2 - d1) > 0.001 ? "increase" : "no_change");
+                } else {
+                    delta.put("value1", v1);
+                    delta.put("value2", v2);
+                    delta.put("valueDelta", null);
+                    delta.put("change", "text_changed");
+                }
+                result.add(delta);
+            } else if (m1 == null) {
+                Map<String, Object> added = new LinkedHashMap<>();
+                added.put("status", "added");
+                added.put("code", code);
+                added.put("name2", m2.get("name"));
+                added.put("value2", m2.get("value"));
+                added.put("category2", m2.get("category"));
+                added.put("from", second.getUploadedAt() != null ? second.getUploadedAt().toString() : "unknown");
+                result.add(added);
+            } else {
+                Map<String, Object> removed = new LinkedHashMap<>();
+                removed.put("status", "removed");
+                removed.put("code", code);
+                removed.put("name1", m1.get("name"));
+                removed.put("value1", m1.get("value"));
+                removed.put("category1", m1.get("category"));
+                removed.put("from", first.getUploadedAt() != null ? first.getUploadedAt().toString() : "unknown");
+                result.add(removed);
+            }
+        }
+        return result;
+    }
 }

+ 77 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java

@@ -107,6 +107,14 @@ public class IndicatorTrendService {
         }
         List<IndicatorValue> rows = valueMapper.selectList(qw.orderByAsc(IndicatorValue::getReportDate));
 
+        // 该成员(+报告类型)全部观测日期全集:用于计算"相邻两次对比中间缺失的报告期数"
+        java.util.List<java.sql.Date> allDates = rows.stream()
+                .map(IndicatorValue::getReportDate)
+                .filter(d -> d != null)
+                .distinct()
+                .sorted()
+                .collect(Collectors.toList());
+
         // 按 definitionId 分组
         Map<Long, List<IndicatorValue>> byDef = rows.stream()
                 .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
@@ -124,6 +132,23 @@ public class IndicatorTrendService {
             if ("adjacent".equals(scope)) {
                 before = sorted.get(sorted.size() - 2);
             }
+            // skipGaps:before/after 之间存在报告日期、但该指标缺失的期数(跨期跳比标记)
+            // before 在 adjacent 分支被重新赋值,lambda 需 final 副本
+            long skipGaps = 0;
+            if ("adjacent".equals(scope)
+                    && before.getReportDate() != null && after.getReportDate() != null) {
+                final IndicatorValue beforeF = before;
+                final IndicatorValue afterF = after;
+                long reportsBetween = allDates.stream()
+                        .filter(d -> d.after(beforeF.getReportDate()) && d.before(afterF.getReportDate()))
+                        .count();
+                long obsBetween = sorted.stream()
+                        .filter(v -> v.getReportDate() != null
+                                && v.getReportDate().after(beforeF.getReportDate())
+                                && v.getReportDate().before(afterF.getReportDate()))
+                        .count();
+                skipGaps = Math.max(0, reportsBetween - obsBetween);
+            }
             IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
                     id -> definitionMapper.selectById(id));
             String defCategory = def != null ? def.getCategory() : null;
@@ -136,9 +161,10 @@ public class IndicatorTrendService {
                 continue;
             }
             BigDecimal delta = av.subtract(bv);
+            // 基数为 0 时百分比无意义(如 0 → 0.5 显示 +0.5 而非误导性的 0%),置 null 由前端降级为只显示 Δ
             BigDecimal deltaPct = bv.compareTo(BigDecimal.ZERO) != 0
                     ? delta.multiply(BigDecimal.valueOf(100)).divide(bv, 1, RoundingMode.HALF_UP)
-                    : BigDecimal.ZERO;
+                    : null;
             String movement = "flat";
             if (delta.compareTo(BigDecimal.ZERO) > 0) movement = "up";
             else if (delta.compareTo(BigDecimal.ZERO) < 0) movement = "down";
@@ -165,6 +191,7 @@ public class IndicatorTrendService {
             item.put("after", afterMap);
             item.put("delta", delta);
             item.put("deltaPct", deltaPct);
+            item.put("skipGaps", skipGaps);
             item.put("movement", movement);
             item.put("trendLabel", trendLabel);
             items.add(item);
@@ -194,6 +221,55 @@ public class IndicatorTrendService {
         return data;
     }
 
+    /** 该成员已有指标时序的报告类型(indicator_values.source_type 去重,曲线页类型筛选动态来源) */
+    public List<Map<String, Object>> listSourceTypes(Long subjectId) {
+        List<Map<String, Object>> items = new ArrayList<>();
+        if (subjectId == null) {
+            return items;
+        }
+        List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .isNotNull(IndicatorValue::getSourceType)
+                .isNotNull(IndicatorValue::getReportDate)
+                .select(IndicatorValue::getSourceType));
+        Set<String> types = new LinkedHashSet<>();
+        for (IndicatorValue v : rows) {
+            String t = v.getSourceType();
+            if (t != null && !t.trim().isEmpty()) {
+                types.add(t.trim());
+            }
+        }
+        for (String t : types) {
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("sourceType", t);
+            m.put("label", sourceTypeLabel(t));
+            items.add(m);
+        }
+        return items;
+    }
+
+    private String sourceTypeLabel(String t) {
+        if (t == null) {
+            return t;
+        }
+        if (t.startsWith("gut_")) {
+            return "肠道菌群";
+        }
+        if ("food_recommend".equals(t)) {
+            return "食材推荐";
+        }
+        if (t.startsWith("dan_")) {
+            return "DAN 测评";
+        }
+        if ("physical_exam".equals(t)) {
+            return "体检";
+        }
+        if ("tongue".equals(t)) {
+            return "舌诊";
+        }
+        return t;
+    }
+
     /** 指标速查(设计 6.3):分类分页 + 五维/七维评分小节 */
     public Map<String, Object> quickLookup(Long subjectId, String category, Integer page,
                                            Integer pageSize, Long familyId) {

+ 0 - 2
cfc-backend/src/main/resources/schema.sql

@@ -1190,7 +1190,6 @@ CREATE TABLE IF NOT EXISTS products (
     visible_roles JSON DEFAULT NULL COMMENT '可见角色: ["parent","child","teacher","admin"],NULL=全部可见',
     min_visible_level VARCHAR(50) DEFAULT NULL COMMENT '最低可见会员级别编码(FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME,NULL=全部可见)',
     min_discount_level VARCHAR(50) DEFAULT NULL COMMENT '享受会员价所需的最低会员级别编码(FREE/FAMILY/PREMIUM/LIFETIME,NULL=所有会员均可)',
-    vendor_visible TINYINT(1) DEFAULT NULL COMMENT '是否仅供应商体系内用户可见(列表接口生效,直接分享可 bypass)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
@@ -1827,7 +1826,6 @@ CREATE TABLE IF NOT EXISTS health_reports (
     gut_type VARCHAR(50) COMMENT '肠型',
     file_url VARCHAR(500) COMMENT '报告文件URL',
     file_hash VARCHAR(64) DEFAULT NULL COMMENT '文件MD5哈希,用于重复上传检测',
-    report_format VARCHAR(32) DEFAULT NULL COMMENT '报告格式: pdf/image',
     status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/archived',
     remark TEXT COMMENT '备注',
     proxy_uploader_id BIGINT COMMENT '代上传人ID(规划师/营养师)',

+ 8 - 1
cfc-frontend/pages.json

@@ -486,6 +486,13 @@
             "navigationBarTitleText": "DAN 测评报告",
             "navigationStyle": "custom"
           }
+        },
+        {
+          "path": "comparison",
+          "style": {
+            "navigationBarTitleText": "DAN 报告对比",
+            "navigationStyle": "custom"
+          }
         }
       ]
     },
@@ -1824,7 +1831,7 @@
         {
           "path": "message-history",
           "style": {
-            "navigationBarTitleText": "消息历史"
+            "navigationBarTitleText": ""
           }
         }
       ]

+ 198 - 0
cfc-frontend/pages/dan-assessment/comparison.vue

@@ -0,0 +1,198 @@
+<template>
+  <view class="page">
+    <view class="filter-bar">
+      <view class="filter-item">{{ currentMemberName }} ▾</view>
+      <picker :range="reportIds" @change="onReportIdChange">
+        <view class="filter-item">{{ currentReportIdLabel }}</view>
+      </picker>
+      <picker :range="[ 'uploadIdA', 'uploadIdB' ]" @change="onUploadIdChange">
+        <view class="filter-item">{{ currentUploadLabel }}</view>
+      </picker>
+    </view>
+
+    <view v-if="loading" class="loading-wrap">
+      <text class="loading-text">加载中...</text>
+    </view>
+
+    <view v-if="error" class="error-wrap">
+      <text class="error-text">{{ error }}</text>
+    </view>
+
+    <view v-if="comparison" class="comparison-wrap">
+      <view class="section-title">DAN 报告对比</view>
+
+      <view class="comparison-info">
+        <view class="info-item">
+          <text class="label">首次上传</text>
+          <text class="value">{{ firstUploadLabel }}</text>
+        </view>
+        <view class="info-item">
+          <text class="label">最近上传</text>
+          <text class="value">{{ secondUploadLabel }}</text>
+        </view>
+      </view>
+
+      <view v-if="deltas && deltas.length > 0" class="deltas-list">
+        <view v-for="(delta, di) in deltas" :key="di" class="delta-item">
+          <view :class="delta.status === 'added' ? 'delta-added' : delta.status === 'removed' ? 'delta-removed' : 'delta-modified'">
+            <text class="delta-code">{{ delta.code || '—' }}</text>
+            <view class="delta-detail">
+              <text v-if="delta.status === 'added'">新增</text>
+              <text v-if="delta.status === 'removed'>删除</text>
+              <text v-if="delta.status === 'modified'">修改</text>
+              <text class="delta-name">{{ delta.name1 || '' }} → {{ delta.name2 || '' }}</text>
+              <text v-if="delta.change">({{ delta.change }})</text>
+              <text v-if="delta.valueDelta !== null">Δ:{{ delta.valueDelta }}</text>
+              <text v-if="delta.categoryChanged">类别变化</text>
+            </view>
+          </view>
+        </view>
+        <view v-else class="empty-tip">两次上传无变化差异</view>
+      </view>
+
+      <view v-else class="empty-tip">暂无对比数据</view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getFamilyMemberList } from '@/utils/api'
+import request from '@/utils/request'
+
+export default {
+  components: {},
+  data() {
+    return {
+      memberId: null,
+      reportIds: [],
+      currentReportId: null,
+      currentMemberName: '',
+      currentReportIdLabel: '选择报告',
+      currentUploadLabel: '选择上传',
+      uploadIdA: null,
+      uploadIdB: null,
+      loading: true,
+      error: null,
+      comparison: null
+    }
+  },
+  computed: {
+    memberNames() {
+      return this.memberId ? this.members.map(m => m.name || m.nickname || '').filter(Boolean) : []
+    }
+  },
+  onLoad(options) {
+    var self = this
+    // 从 query 获取 memberId,或默认取当前登录成员
+    if (options.memberId) {
+      self.memberId = parseInt(options.memberId)
+    }
+    self.loadMembers()
+    self.loadReportIds()
+  },
+  methods: {
+    loadMembers() {
+      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.memberId && arr.length > 0) {
+          self.memberId = arr[0].id
+        }
+        self.loadReportIds()
+      })
+    },
+    loadReportIds() {
+      var self = this
+      if (!self.memberId) {
+        self.reportIds = []
+        self.currentReportIdLabel = '选择成员后加载'
+        return
+      }
+      request('/api/dan-report/list', 'POST', { memberId: self.memberId }).then(function(res) {
+        self.reportIds = (res.data || []).map(function(u) { return String(u.id) })
+        if (self.reportIds.length > 0) {
+          self.currentReportId = self.reportIds[0]
+          self.onReportIdChange(self.reportIds[0])
+        }
+      }).catch(function(e) {
+        self.error = '获取报告列表失败'
+        console.error(e)
+      })
+    },
+    onReportIdChange(value) {
+      this.currentReportId = value
+      this.loadComparison()
+    },
+    onUploadIdChange(value, uploadType) {
+      if (uploadType === 'A') {
+        this.uploadIdA = value
+      } else {
+        this.uploadIdB = value
+      }
+      this.loadComparison()
+    },
+    loadComparison() {
+      var self = this
+      if (!self.uploadIdA || !self.uploadIdB) {
+        self.comparison = null
+        return
+      }
+      self.loading = true
+      request('/api/dan-report/compare', 'POST', { uploadIdA: self.uploadIdA, uploadIdB: self.uploadIdB }).then(function(res) {
+        self.loading = false
+        if (res.code !== 200) {
+          self.error = res.message || '对比失败'
+          return
+        }
+        self.comparison = res.data
+        self.error = null
+      }).catch(function(e) {
+        self.loading = false
+        self.error = '对比异常'
+        console.error(e)
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background: #F5F9FC;
+  padding: 24rpx;
+  box-sizing: border-box;
+}
+.filter-bar {
+  display: flex;
+  flex-direction: row;
+  margin-bottom: 24rpx;
+}
+.filter-item {
+  flex: 1;
+  background: #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; }
+
+/* 对比区 */
+.comparison-wrap { background: #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; }
+.info-item { display: flex; align-items: center; margin-bottom: 16rpx; }
+.label { font-size: 24rpx; color: #64748B; margin-right: 12rpx; }
+.value { font-size: 24rpx; color: #1E293B; }
+.deltas-list { margin-top: 24rpx; }
+.delta-item { padding: 12rpx 0; border-bottom: 1rpx solid #E2E8F0; display: flex; align-items: center; }
+.delta-code { flex: 1; font-size: 24rpx; color: #64748B; }
+.delta-added { color: #32CD32; }
+.delta-removed { color: #FF6B6B; }
+.delta-modified { color: #FFA500; }
+.empty-tip { padding: 60rpx 0; text-align: center; font-size: 26rpx; color: #94A3B8; }
+</style>

+ 1 - 1
cfc-frontend/pages/health/indicator-curve.vue

@@ -192,7 +192,7 @@ export default {
       var self = this
       if (!this.currentMemberId) return
       // food_recommend 无时序数据:FoodItem 为评分型数据,NormalizeIndicatorPipeline 不写 indicator_values,
-// 此处回退为全量指标参与曲线/排名(有意为之,非 bug)
+      // 此处回退为全量指标参与曲线/排名(有意为之,非 bug)
       var reportType = (!this.activeReportType || this.activeReportType === 'food_recommend') ? null : this.activeReportType
       getIndicatorChangeRanking({
         subjectId: this.currentMemberId,

+ 2 - 0
cfc-frontend/utils/api.js

@@ -3405,3 +3405,5 @@ export const getIndicatorChangeRanking = (params) => request('/api/indicator/cha
 export const getIndicatorQuickLookup = (params) => request('/api/indicator/quick-lookup', 'POST', params || {})
 /** 指标关联查询:{ subjectId, definitionId }(本期仅封装,展示二期) */
 export const getIndicatorCorrelationList = (params) => request('/api/indicator/correlation/list', 'POST', params || {})
+/** 成员已有指标时序的报告类型列表(曲线页类型筛选动态化):{ subjectId } → [{ sourceType, label }] */
+export const getIndicatorSourceTypes = (params) => request('/api/indicator/source-types', 'POST', params || {})