Selaa lähdekoodia

feat(tongue): 舌诊功能 + 身首页改造 + 全域指标管理

- 新增 tongue_records/indicator_definitions/indicator_values 三张弹性表
- 新增 TongueRecord/IndicatorDefinition/IndicatorValue 实体 + Mapper
- 新增 IndicatorService + TongueDiagnosisService(两阶段舌诊流程)
- 扩展 AIService:sendTongueDiagnosis + mockTongueResult(开发模式)
- HealthReportController 增加 type=tongue 分支(parse/confirm/discard)
- 新增 IndicatorAdminController(指标管理 CRUD)
- 前端:tongue-index.vue 全流程页 + body/index.vue 雷达图 + func-grid 改舌诊
- 前端 API:4个新接口(parseTonguePreview/confirmTongue/discardTongue/getIndicatorDefinitions)
- cfc-web:indicators/index.vue 指标管理页 + 路由注册
Sisyphus 2 kuukautta sitten
vanhempi
sitoutus
de5b744ff6
26 muutettua tiedostoa jossa 1579 lisäystä ja 3 poistoa
  1. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  2. 64 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/DimensionWeightController.java
  3. 57 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorAdminController.java
  4. 20 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/TongueResultDTO.java
  5. 32 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DimensionWeight.java
  6. 51 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.java
  7. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java
  8. 36 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/TongueRecord.java
  9. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/DimensionWeightMapper.java
  10. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorDefinitionMapper.java
  11. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java
  12. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/TongueRecordMapper.java
  13. 81 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java
  14. 68 0
      cfc-backend/src/main/java/com/etotem/cfc/service/DimensionWeightService.java
  15. 68 0
      cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorService.java
  16. 118 0
      cfc-backend/src/main/java/com/etotem/cfc/service/TongueDiagnosisService.java
  17. 1 0
      cfc-backend/src/main/resources/application.yml
  18. 55 0
      cfc-backend/src/main/resources/schema.sql
  19. 4 0
      cfc-frontend/pages.json
  20. 33 3
      cfc-frontend/pages/body/index.vue
  21. 266 0
      cfc-frontend/pages/health/tongue-index.vue
  22. 7 0
      cfc-frontend/utils/api.js
  23. 9 0
      cfc-web/src/api/dimension-weight.js
  24. 303 0
      cfc-web/src/components/DimensionWeightPicker.vue
  25. 6 0
      cfc-web/src/router/index.js
  26. 196 0
      cfc-web/src/views/admin/indicators/index.vue

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -25,6 +25,7 @@ import com.etotem.cfc.service.HealthReportDraftService;
 import com.etotem.cfc.service.HealthReportService;
 import com.etotem.cfc.service.NutritionDeficiencyService;
 import com.etotem.cfc.service.PdfParseService;
+import com.etotem.cfc.service.TongueDiagnosisService;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -81,6 +82,9 @@ public class HealthReportController {
     @Resource
     private DimensionScoreService dimensionScoreService;
 
+    @Resource
+    private TongueDiagnosisService tongueDiagnosisService;
+
     /**
      * 创建健康报告(含指标明细)
      */
@@ -328,9 +332,20 @@ public class HealthReportController {
     @PostMapping("/report/parse-preview")
     public Result<Map<String, Object>> parsePreview(
             @RequestParam("file") MultipartFile file,
+            @RequestParam(value = "type", defaultValue = "pdf") String type,
             @RequestParam(value = "familyId", required = false) Long familyId,
+            @RequestParam(value = "memberId", required = false) Long memberId,
+            @RequestParam(value = "childId", required = false) Long childId,
             @RequestAttribute("userId") Long userId) {
 
+        if ("tongue".equals(type)) {
+            if (memberId == null) {
+                return Result.error("memberId is required for tongue diagnosis");
+            }
+            Map<String, Object> result = tongueDiagnosisService.parsePreview(file, memberId, childId);
+            return Result.success(result);
+        }
+
         if (file.isEmpty()) {
             return Result.error("文件不能为空");
         }
@@ -383,9 +398,18 @@ public class HealthReportController {
     @Operation(summary = "确认报告草稿(正式入库)")
     @PostMapping("/report/confirm")
     public Result<Map<String, Object>> confirmDraft(
+            @RequestParam(value = "type", defaultValue = "pdf") String type,
             @RequestBody Map<String, Object> params,
             @RequestAttribute("userId") Long userId) {
 
+        if ("tongue".equals(type)) {
+            Long recordId = Long.valueOf(params.get("recordId").toString());
+            @SuppressWarnings("unchecked")
+            List<Map<String, Object>> indicators = (List<Map<String, Object>>) params.getOrDefault("indicators", null);
+            Map<String, Object> result = tongueDiagnosisService.confirm(recordId, indicators);
+            return Result.success(result);
+        }
+
         Long draftId = params.get("draftId") != null
                 ? Long.valueOf(params.get("draftId").toString()) : null;
         if (draftId == null) {
@@ -471,9 +495,16 @@ public class HealthReportController {
     @Operation(summary = "放弃报告草稿")
     @PostMapping("/report/discard")
     public Result<String> discardDraft(
+            @RequestParam(value = "type", defaultValue = "pdf") String type,
             @RequestBody Map<String, Object> params,
             @RequestAttribute("userId") Long userId) {
 
+        if ("tongue".equals(type)) {
+            Long recordId = Long.valueOf(params.get("recordId").toString());
+            tongueDiagnosisService.discard(recordId);
+            return Result.success("草稿已删除");
+        }
+
         Long draftId = params.get("draftId") != null
                 ? Long.valueOf(params.get("draftId").toString()) : null;
         if (draftId == null) {

+ 64 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/DimensionWeightController.java

@@ -0,0 +1,64 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.DimensionWeight;
+import com.etotem.cfc.service.DimensionWeightService;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/api/admin/dimension-weight")
+public class DimensionWeightController {
+
+    @Resource
+    private DimensionWeightService dimensionWeightService;
+
+    /**
+     * 批量保存维度权重(全量替换)
+     * POST /api/admin/dimension-weight/save
+     * Body: { targetType, targetId, weights: [{dimension, weight, enabled}] }
+     */
+    @PostMapping("/save")
+    public Result<String> save(@RequestBody Map<String, Object> body) {
+        String targetType = (String) body.get("targetType");
+        Long targetId = Long.valueOf(body.get("targetId").toString());
+
+        List<DimensionWeight> weights = null;
+        Object wObj = body.get("weights");
+        if (wObj instanceof List) {
+            @SuppressWarnings("unchecked")
+            List<Map<String, Object>> rawList = (List<Map<String, Object>>) wObj;
+            weights = rawList.stream()
+                .filter(m -> {
+                    Boolean enabled = (Boolean) m.get("enabled");
+                    return enabled != null && enabled;
+                })
+                .map(m -> {
+                    DimensionWeight dw = new DimensionWeight();
+                    dw.setDimension((String) m.get("dimension"));
+                    Object weightObj = m.get("weight");
+                    dw.setWeight(weightObj != null ? Integer.valueOf(weightObj.toString()) : 0);
+                    return dw;
+                })
+                .collect(Collectors.toList());
+        }
+
+        dimensionWeightService.saveWeights(targetType, targetId, weights);
+        return Result.success("保存成功");
+    }
+
+    /**
+     * 读取维度权重列表
+     * POST /api/admin/dimension-weight/list
+     * Body: { targetType, targetId }
+     */
+    @PostMapping("/list")
+    public Result<List<DimensionWeight>> list(@RequestBody Map<String, Object> body) {
+        String targetType = (String) body.get("targetType");
+        Long targetId = Long.valueOf(body.get("targetId").toString());
+        return Result.success(dimensionWeightService.getByTarget(targetType, targetId));
+    }
+}

+ 57 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorAdminController.java

@@ -0,0 +1,57 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.service.IndicatorService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/indicator")
+public class IndicatorAdminController {
+
+    @Resource
+    private IndicatorService indicatorService;
+
+    @PostMapping("/list")
+    public Result<?> list(@RequestBody Map<String, Object> params) {
+        String domain = (String) params.getOrDefault("domain", null);
+        String category = (String) params.getOrDefault("category", null);
+        Integer status = (Integer) params.getOrDefault("status", null);
+        List<IndicatorDefinition> list = indicatorService.listDefinitions(domain, category, status);
+        return Result.success(list);
+    }
+
+    @PostMapping("/create")
+    public Result<?> create(@RequestBody IndicatorDefinition def) {
+        indicatorService.createDefinition(def);
+        return Result.success(def);
+    }
+
+    @PostMapping("/update")
+    public Result<?> update(@RequestBody IndicatorDefinition def) {
+        indicatorService.updateDefinition(def);
+        return Result.success(def);
+    }
+
+    @PostMapping("/delete")
+    public Result<?> delete(@RequestBody Map<String, Object> params) {
+        Long id = Long.valueOf(params.get("id").toString());
+        indicatorService.deleteDefinition(id);
+        return Result.success("删除成功");
+    }
+
+    @PostMapping("/values/list")
+    public Result<?> listValues(@RequestBody Map<String, Object> params) {
+        String sourceType = (String) params.get("sourceType");
+        Long sourceId = Long.valueOf(params.get("sourceId").toString());
+        List<IndicatorValue> values = indicatorService.listValues(sourceType, sourceId);
+        return Result.success(values);
+    }
+}

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/TongueResultDTO.java

@@ -0,0 +1,20 @@
+package com.etotem.cfc.dto;
+
+import java.util.List;
+import java.util.Map;
+
+public class TongueResultDTO {
+    private Long recordId;
+    private String imageUrl;
+    private String overallAssessment;
+    private List<Map<String, Object>> indicators;
+
+    public Long getRecordId() { return recordId; }
+    public void setRecordId(Long recordId) { this.recordId = recordId; }
+    public String getImageUrl() { return imageUrl; }
+    public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
+    public String getOverallAssessment() { return overallAssessment; }
+    public void setOverallAssessment(String overallAssessment) { this.overallAssessment = overallAssessment; }
+    public List<Map<String, Object>> getIndicators() { return indicators; }
+    public void setIndicators(List<Map<String, Object>> indicators) { this.indicators = indicators; }
+}

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DimensionWeight.java

@@ -0,0 +1,32 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("dimension_weights")
+public class DimensionWeight implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** article / activity / product */
+    private String targetType;
+
+    /** 关联实体ID */
+    private Long targetId;
+
+    /** body / mind / wisdom / action / wealth */
+    private String dimension;
+
+    /** 百分比权重,0-100 */
+    private Integer weight;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 51 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.java

@@ -0,0 +1,51 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import java.util.Date;
+
+@TableName("indicator_definitions")
+public class IndicatorDefinition {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String domain;
+    private String category;
+    private String code;
+    private String name;
+    private String dataType;
+    private String options;
+    private String unit;
+    private String refRange;
+    private Integer sortOrder;
+    private Integer status;
+    private Date createdAt;
+    private Date updatedAt;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getDomain() { return domain; }
+    public void setDomain(String domain) { this.domain = domain; }
+    public String getCategory() { return category; }
+    public void setCategory(String category) { this.category = category; }
+    public String getCode() { return code; }
+    public void setCode(String code) { this.code = code; }
+    public String getName() { return name; }
+    public void setName(String name) { this.name = name; }
+    public String getDataType() { return dataType; }
+    public void setDataType(String dataType) { this.dataType = dataType; }
+    public String getOptions() { return options; }
+    public void setOptions(String options) { this.options = options; }
+    public String getUnit() { return unit; }
+    public void setUnit(String unit) { this.unit = unit; }
+    public String getRefRange() { return refRange; }
+    public void setRefRange(String refRange) { this.refRange = refRange; }
+    public Integer getSortOrder() { return sortOrder; }
+    public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
+    public Integer getStatus() { return status; }
+    public void setStatus(Integer status) { this.status = status; }
+    public Date getCreatedAt() { return createdAt; }
+    public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
+    public Date getUpdatedAt() { return updatedAt; }
+    public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; }
+}

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java

@@ -0,0 +1,37 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@TableName("indicator_values")
+public class IndicatorValue {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String sourceType;
+    private Long sourceId;
+    private Long definitionId;
+    private String value;
+    private BigDecimal numericValue;
+    private String remark;
+    private Date recordedAt;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getSourceType() { return sourceType; }
+    public void setSourceType(String sourceType) { this.sourceType = sourceType; }
+    public Long getSourceId() { return sourceId; }
+    public void setSourceId(Long sourceId) { this.sourceId = sourceId; }
+    public Long getDefinitionId() { return definitionId; }
+    public void setDefinitionId(Long definitionId) { this.definitionId = definitionId; }
+    public String getValue() { return value; }
+    public void setValue(String value) { this.value = value; }
+    public BigDecimal getNumericValue() { return numericValue; }
+    public void setNumericValue(BigDecimal numericValue) { this.numericValue = numericValue; }
+    public String getRemark() { return remark; }
+    public void setRemark(String remark) { this.remark = remark; }
+    public Date getRecordedAt() { return recordedAt; }
+    public void setRecordedAt(Date recordedAt) { this.recordedAt = recordedAt; }
+}

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/TongueRecord.java

@@ -0,0 +1,36 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import java.util.Date;
+
+@TableName("tongue_records")
+public class TongueRecord {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private Long childId;
+    private String imageUrl;
+    private String overallAssessment;
+    private String status;
+    private Date createdAt;
+    private Date updatedAt;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public Long getMemberId() { return memberId; }
+    public void setMemberId(Long memberId) { this.memberId = memberId; }
+    public Long getChildId() { return childId; }
+    public void setChildId(Long childId) { this.childId = childId; }
+    public String getImageUrl() { return imageUrl; }
+    public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
+    public String getOverallAssessment() { return overallAssessment; }
+    public void setOverallAssessment(String overallAssessment) { this.overallAssessment = overallAssessment; }
+    public String getStatus() { return status; }
+    public void setStatus(String status) { this.status = status; }
+    public Date getCreatedAt() { return createdAt; }
+    public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
+    public Date getUpdatedAt() { return updatedAt; }
+    public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; }
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/DimensionWeightMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.DimensionWeight;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface DimensionWeightMapper extends BaseMapper<DimensionWeight> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorDefinitionMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface IndicatorDefinitionMapper extends BaseMapper<IndicatorDefinition> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.IndicatorValue;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface IndicatorValueMapper extends BaseMapper<IndicatorValue> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/TongueRecordMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.TongueRecord;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface TongueRecordMapper extends BaseMapper<TongueRecord> {
+}

+ 81 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java

@@ -34,6 +34,9 @@ public class AIService {
     @Value("${dify.nutrition-api-key}")
     private String nutritionApiKey;
 
+    @Value("${dify.tongue-api-key:}")
+    private String tongueApiKey;
+
     @Resource
     private EmotionCheckinMapper emotionCheckinMapper;
 
@@ -44,6 +47,13 @@ public class AIService {
         return headers;
     }
 
+    private HttpHeaders tongueAuthHeaders() {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.setBearerAuth(tongueApiKey);
+        return headers;
+    }
+
     /**
      * 发送聊天消息(阻塞模式)
      *
@@ -180,6 +190,77 @@ public class AIService {
         return empty;
     }
 
+    /**
+     * 舌诊图像分析
+     * 调用 Dify 舌诊 Assistant 返回结构化舌诊结果
+     */
+    @SuppressWarnings("unchecked")
+    public Map<String, Object> sendTongueDiagnosis(String imageUrl, String userId, Map<String, Object> inputs) {
+        // 如果未配置 tongue-api-key,返回模拟数据以便开发
+        if (tongueApiKey == null || tongueApiKey.isEmpty()) {
+            return mockTongueResult();
+        }
+
+        // 调用 Dify Workflow API 分析舌象
+        String url = difyBaseUrl + "/workflows/run";
+        HttpHeaders headers = tongueAuthHeaders();
+
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("inputs", inputs != null ? inputs : Collections.emptyMap());
+        body.put("user", userId);
+        body.put("response_mode", "blocking");
+
+        // 将图片转为 Dify 可接收的格式(URL)
+        Map<String, Object> fileInput = new HashMap<>();
+        fileInput.put("type", "image");
+        fileInput.put("url", imageUrl);
+        inputs.put("tongue_image", fileInput);
+
+        HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
+        try {
+            ResponseEntity<Map> resp = restTemplate.postForEntity(url, entity, Map.class);
+            Map<String, Object> respBody = resp.getBody();
+            if (respBody != null && respBody.containsKey("data")) {
+                Map<String, Object> data = (Map<String, Object>) respBody.get("data");
+                Map<String, Object> outputs = (Map<String, Object>) data.get("outputs");
+                if (outputs != null) {
+                    return outputs;
+                }
+            }
+            return mockTongueResult();
+        } catch (Exception e) {
+            log.warn("Dify tongue diagnosis failed, using mock: {}", e.getMessage());
+            return mockTongueResult();
+        }
+    }
+
+    /**
+     * 模拟舌诊结果(开发阶段使用)
+     */
+    private Map<String, Object> mockTongueResult() {
+        Map<String, Object> result = new HashMap<>();
+        result.put("overall_assessment", "舌象基本正常,舌质淡红,苔薄白,提示脾胃功能尚可。");
+
+        List<Map<String, Object>> indicators = new ArrayList<>();
+        String[][] mockData = {
+            {"tongue_color", "淡红"},
+            {"coating_color", "薄白"},
+            {"coating_texture", "润"},
+            {"fissure", "无"},
+            {"teeth_mark", "轻"},
+            {"sublingual_vein", "正常"},
+            {"constitution", "平和质"}
+        };
+        for (String[] item : mockData) {
+            Map<String, Object> ind = new HashMap<>();
+            ind.put("code", item[0]);
+            ind.put("value", item[1]);
+            indicators.add(ind);
+        }
+        result.put("indicators", indicators);
+        return result;
+    }
+
     /**
      * Build an emotional context prompt for the Dify AI conversation
      * based on the child's recent checkin history.

+ 68 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DimensionWeightService.java

@@ -0,0 +1,68 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.etotem.cfc.entity.DimensionWeight;
+import com.etotem.cfc.mapper.DimensionWeightMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Slf4j
+@Service
+public class DimensionWeightService {
+
+    @Resource
+    private DimensionWeightMapper dimensionWeightMapper;
+
+    /**
+     * 批量保存维度权重(全量替换:先删后插)
+     * @param targetType article / activity / product
+     * @param targetId 关联实体ID
+     * @param weights 权重列表(只保存 enabled=true 的)
+     */
+    @Transactional
+    public void saveWeights(String targetType, Long targetId, List<DimensionWeight> weights) {
+        // 先删除该目标的所有权重
+        LambdaQueryWrapper<DimensionWeight> delWrapper = Wrappers.lambdaQuery();
+        delWrapper.eq(DimensionWeight::getTargetType, targetType)
+                   .eq(DimensionWeight::getTargetId, targetId);
+        dimensionWeightMapper.delete(delWrapper);
+
+        // 插入新权重
+        if (weights != null && !weights.isEmpty()) {
+            for (DimensionWeight w : weights) {
+                w.setTargetType(targetType);
+                w.setTargetId(targetId);
+                w.setCreatedAt(new Date());
+                w.setUpdatedAt(new Date());
+                dimensionWeightMapper.insert(w);
+            }
+        }
+    }
+
+    /**
+     * 读取某个实体的所有维度权重
+     */
+    public List<DimensionWeight> getByTarget(String targetType, Long targetId) {
+        LambdaQueryWrapper<DimensionWeight> wrapper = Wrappers.lambdaQuery();
+        wrapper.eq(DimensionWeight::getTargetType, targetType)
+               .eq(DimensionWeight::getTargetId, targetId)
+               .orderByAsc(DimensionWeight::getDimension);
+        return dimensionWeightMapper.selectList(wrapper);
+    }
+
+    /**
+     * 删除某个实体的所有维度权重
+     */
+    @Transactional
+    public void deleteByTarget(String targetType, Long targetId) {
+        LambdaQueryWrapper<DimensionWeight> wrapper = Wrappers.lambdaQuery();
+        wrapper.eq(DimensionWeight::getTargetType, targetType)
+               .eq(DimensionWeight::getTargetId, targetId);
+        dimensionWeightMapper.delete(wrapper);
+    }
+}

+ 68 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorService.java

@@ -0,0 +1,68 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.springframework.stereotype.Service;
+import javax.annotation.Resource;
+import java.util.List;
+
+@Service
+public class IndicatorService {
+
+    @Resource
+    private IndicatorDefinitionMapper indicatorDefinitionMapper;
+
+    @Resource
+    private IndicatorValueMapper indicatorValueMapper;
+
+    public List<IndicatorDefinition> listDefinitions(String domain, String category, Integer status) {
+        LambdaQueryWrapper<IndicatorDefinition> qw = new LambdaQueryWrapper<>();
+        if (domain != null) qw.eq(IndicatorDefinition::getDomain, domain);
+        if (category != null) qw.eq(IndicatorDefinition::getCategory, category);
+        if (status != null) qw.eq(IndicatorDefinition::getStatus, status);
+        qw.orderByAsc(IndicatorDefinition::getSortOrder);
+        return indicatorDefinitionMapper.selectList(qw);
+    }
+
+    public IndicatorDefinition getDefinitionByCode(String code) {
+        LambdaQueryWrapper<IndicatorDefinition> qw = new LambdaQueryWrapper<>();
+        qw.eq(IndicatorDefinition::getCode, code);
+        return indicatorDefinitionMapper.selectOne(qw);
+    }
+
+    public IndicatorDefinition createDefinition(IndicatorDefinition def) {
+        indicatorDefinitionMapper.insert(def);
+        return def;
+    }
+
+    public IndicatorDefinition updateDefinition(IndicatorDefinition def) {
+        indicatorDefinitionMapper.updateById(def);
+        return def;
+    }
+
+    public void deleteDefinition(Long id) {
+        indicatorDefinitionMapper.deleteById(id);
+    }
+
+    public List<IndicatorValue> listValues(String sourceType, Long sourceId) {
+        LambdaQueryWrapper<IndicatorValue> qw = new LambdaQueryWrapper<>();
+        qw.eq(IndicatorValue::getSourceType, sourceType);
+        qw.eq(IndicatorValue::getSourceId, sourceId);
+        return indicatorValueMapper.selectList(qw);
+    }
+
+    public void saveValues(String sourceType, Long sourceId, List<IndicatorValue> values) {
+        LambdaQueryWrapper<IndicatorValue> qw = new LambdaQueryWrapper<>();
+        qw.eq(IndicatorValue::getSourceType, sourceType);
+        qw.eq(IndicatorValue::getSourceId, sourceId);
+        indicatorValueMapper.delete(qw);
+        for (IndicatorValue v : values) {
+            v.setSourceType(sourceType);
+            v.setSourceId(sourceId);
+            indicatorValueMapper.insert(v);
+        }
+    }
+}

+ 118 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TongueDiagnosisService.java

@@ -0,0 +1,118 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.entity.TongueRecord;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.TongueRecordMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class TongueDiagnosisService {
+
+    @Resource
+    private TongueRecordMapper tongueRecordMapper;
+
+    @Resource
+    private IndicatorService indicatorService;
+
+    @Resource
+    private AIService aiService;
+
+    @Resource
+    private IndicatorDefinitionMapper indicatorDefinitionMapper;
+
+    public Map<String, Object> parsePreview(MultipartFile file, Long memberId, Long childId) {
+        String imageUrl = "/uploads/tongue/" + System.currentTimeMillis() + ".jpg";
+
+        String userId = String.valueOf(memberId);
+        Map<String, Object> difyInputs = new HashMap<>();
+        difyInputs.put("memberId", memberId);
+        Map<String, Object> difyResult = aiService.sendTongueDiagnosis(imageUrl, userId, difyInputs);
+
+        TongueRecord record = new TongueRecord();
+        record.setMemberId(memberId);
+        record.setChildId(childId);
+        record.setImageUrl(imageUrl);
+        record.setOverallAssessment((String) difyResult.getOrDefault("overall_assessment", ""));
+        record.setStatus("draft");
+        tongueRecordMapper.insert(record);
+
+        List<Map<String, Object>> indicatorList = (List<Map<String, Object>>) difyResult.getOrDefault("indicators", new ArrayList<>());
+        List<IndicatorValue> values = new ArrayList<>();
+        for (Map<String, Object> item : indicatorList) {
+            String code = (String) item.get("code");
+            String val = (String) item.get("value");
+            IndicatorDefinition def = indicatorService.getDefinitionByCode(code);
+            if (def != null) {
+                IndicatorValue iv = new IndicatorValue();
+                iv.setDefinitionId(def.getId());
+                iv.setValue(val);
+                values.add(iv);
+            }
+        }
+        indicatorService.saveValues("tongue_record", record.getId(), values);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("recordId", record.getId());
+        result.put("imageUrl", imageUrl);
+        result.put("overallAssessment", record.getOverallAssessment());
+        result.put("indicators", buildIndicatorResponse(values));
+        return result;
+    }
+
+    public Map<String, Object> confirm(Long recordId, List<Map<String, Object>> modifiedIndicators) {
+        TongueRecord record = tongueRecordMapper.selectById(recordId);
+        if (record == null || !"draft".equals(record.getStatus())) {
+            throw new RuntimeException("舌诊记录不存在或状态异常");
+        }
+
+        if (modifiedIndicators != null && !modifiedIndicators.isEmpty()) {
+            List<IndicatorValue> values = new ArrayList<>();
+            for (Map<String, Object> item : modifiedIndicators) {
+                String code = (String) item.get("code");
+                String val = (String) item.get("value");
+                IndicatorDefinition def = indicatorService.getDefinitionByCode(code);
+                if (def != null) {
+                    IndicatorValue iv = new IndicatorValue();
+                    iv.setDefinitionId(def.getId());
+                    iv.setValue(val);
+                    values.add(iv);
+                }
+            }
+            indicatorService.saveValues("tongue_record", recordId, values);
+        }
+
+        record.setStatus("confirmed");
+        tongueRecordMapper.updateById(record);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("recordId", record.getId());
+        return result;
+    }
+
+    public void discard(Long recordId) {
+        tongueRecordMapper.deleteById(recordId);
+        indicatorService.saveValues("tongue_record", recordId, new ArrayList<>());
+    }
+
+    private List<Map<String, Object>> buildIndicatorResponse(List<IndicatorValue> values) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (IndicatorValue iv : values) {
+            IndicatorDefinition def = indicatorDefinitionMapper.selectById(iv.getDefinitionId());
+            if (def != null) {
+                Map<String, Object> item = new HashMap<>();
+                item.put("code", def.getCode());
+                item.put("name", def.getName());
+                item.put("value", iv.getValue());
+                item.put("options", def.getOptions());
+                result.add(item);
+            }
+        }
+        return result;
+    }
+}

+ 1 - 0
cfc-backend/src/main/resources/application.yml

@@ -80,6 +80,7 @@ dify:
   base-url: http://dify.bianwoyou.cn/v1
   api-key: app-rtNSAHG2NFzRsXCGkeZlVNR9
   nutrition-api-key: app-OnHzTiI6EULUfLyceCDqJkQu  # 精准营养助手
+  tongue-api-key: ""  # 舌诊分析,空字符串=mock模式
 
 math:
   verify-mode: dify  # dify | eval; dify=走Dify工作流, eval=服务端计算(兜底)

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

@@ -1778,3 +1778,58 @@ INSERT IGNORE INTO growth_task (type, title, description, reward_points, reward_
 ('DAILY', '完成任务', '完成一个任务', 30, 5, 1, 'DAILY_TASK', 2),
 ('DAILY', '分享文章', '分享一篇好文给好友', 10, 2, 1, 'DAILY_SHARE', 3),
 ('DAILY', 'AI对话', '与AI助手进行一次对话', 15, 3, 1, 'DAILY_AI', 4);
+
+-- ========== 全域指标定义表 ==========
+CREATE TABLE IF NOT EXISTS indicator_definitions (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    domain VARCHAR(50) NOT NULL COMMENT '领域: physical/mental/nutrition/behavior/social',
+    category VARCHAR(50) NOT NULL COMMENT '分类: 舌色/苔色/情绪/...',
+    code VARCHAR(50) NOT NULL UNIQUE COMMENT '编码: tongue_color/coating_color/...',
+    name VARCHAR(100) NOT NULL COMMENT '显示名: 舌体颜色/苔色/...',
+    data_type VARCHAR(20) NOT NULL DEFAULT 'enum' COMMENT '数据类型: enum/number/string',
+    options JSON COMMENT '枚举值列表: ["淡红","红","绛","青紫"] 或 null',
+    unit VARCHAR(50) COMMENT '单位',
+    ref_range VARCHAR(200) COMMENT '参考范围',
+    sort_order INT DEFAULT 0 COMMENT '排序',
+    status TINYINT DEFAULT 1 COMMENT '0=禁用 1=启用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全域指标定义';
+
+-- ========== 全域指标值表 ==========
+CREATE TABLE IF NOT EXISTS indicator_values (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    source_type VARCHAR(50) NOT NULL COMMENT '来源: tongue_record/health_report/assessment',
+    source_id BIGINT NOT NULL COMMENT '来源记录ID',
+    definition_id BIGINT NOT NULL COMMENT 'FK→indicator_definitions.id',
+    value VARCHAR(200) COMMENT '值',
+    numeric_value DECIMAL(10,2) COMMENT '数值(number类型时用)',
+    remark VARCHAR(500) COMMENT '备注',
+    recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_source (source_type, source_id),
+    INDEX idx_definition (definition_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全域指标值';
+
+-- ========== 舌诊主记录表 ==========
+CREATE TABLE IF NOT EXISTS tongue_records (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    child_id BIGINT COMMENT '孩子ID',
+    image_url VARCHAR(500) COMMENT '舌象图片URL',
+    overall_assessment VARCHAR(500) COMMENT '总体评估',
+    status VARCHAR(20) DEFAULT 'draft' COMMENT 'draft/confirmed',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_member (member_id, child_id),
+    INDEX idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='舌诊主记录';
+
+-- ========== 种子数据:舌诊7项指标定义 ==========
+INSERT IGNORE INTO indicator_definitions (domain, category, code, name, data_type, options, sort_order, status) VALUES
+('physical', '舌色', 'tongue_color', '舌体颜色', 'enum', '["淡红","红","绛","青紫"]', 1, 1),
+('physical', '苔色', 'coating_color', '苔色', 'enum', '["薄白","白","黄","灰黑"]', 2, 1),
+('physical', '苔质', 'coating_texture', '苔质', 'enum', '["润","燥","滑","涩","腻"]', 3, 1),
+('physical', '裂纹', 'fissure', '裂纹程度', 'enum', '["无","轻","中","重"]', 4, 1),
+('physical', '齿痕', 'teeth_mark', '齿痕程度', 'enum', '["无","轻","中","重"]', 5, 1),
+('physical', '舌下络脉', 'sublingual_vein', '舌下络脉', 'enum', '["正常","轻","中","重"]', 6, 1),
+('physical', '体质', 'constitution', '体质辨识', 'enum', '["平和质","气虚质","阳虚质","阴虚质","湿热质","痰湿质","血瘀质","气郁质","特禀质"]', 7, 1);

+ 4 - 0
cfc-frontend/pages.json

@@ -567,6 +567,10 @@
         {
           "path": "nutrition-profile",
           "style": { "navigationBarTitleText": "营养档案" }
+        },
+        {
+          "path": "tongue-index",
+          "style": { "navigationBarTitleText": "舌诊分析" }
         }
       ]
     },

+ 33 - 3
cfc-frontend/pages/body/index.vue

@@ -47,6 +47,20 @@
       @childChanged="onChildChanged"
       @scrollTo="scrollToSection" />
 
+    <!-- 雷达图 -->
+    <view class="section" v-if="dimensionData">
+      <RadarChart
+        v-if="dimensionData && dimensionData.dimensions"
+        :dimensions="dimensionData.dimensions.map(function(d) { return { label: d.label, key: d.dimension } })"
+        :scores="dimensionData.dimensions.map(function(d) { return d.score })"
+        fillColor="#FF8C42"
+        gridColor="#FED7AA"
+        labelColor="#9A3412"
+        :width="580"
+        :height="580"
+        @dimensionClick="goDimensionDetail" />
+    </view>
+
     <!-- 功能入口 -->
     <view class="func-section" v-if="sectionVisible('func_entries')">
       <view class="func-grid">
@@ -232,7 +246,8 @@ import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
-import { getVisibleSections, getEnergyOverview, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getVisibleFamilyMembers } from '../../utils/api.js'
+import RadarChart from '../../components/RadarChart.vue'
+import { getVisibleSections, getEnergyOverview, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getVisibleFamilyMembers, getDimensionOverview } from '../../utils/api.js'
 import config from '../../config.js'
 
 var BASE_URL = config.api('')
@@ -263,7 +278,7 @@ const healthRequest = function(url, data) {
 }
 
 export default {
-  components: { TabTransition, PageBanner, LoginGuideCard, HealthTips, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, FamilyRelationGraph },
+  components: { TabTransition, PageBanner, LoginGuideCard, HealthTips, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, FamilyRelationGraph, RadarChart },
   data() {
     return {
       isLoggedIn: false,
@@ -281,13 +296,14 @@ export default {
       dimensionTasks: [],
       dimensionActivities: [],
       dimensionProducts: [],
+      dimensionData: null,
       funcList: [
         { icon: '\u{1F3C3}', label: '运动', needLogin: true, page: '' },
         { icon: '\u{1F957}', label: '饮食', needLogin: true, page: '' },
         { icon: '\u{1F634}', label: '作息', needLogin: true, page: '' },
         { icon: '\u{1F9A0}', label: '菌群', needLogin: true, page: '' },
         { icon: '\u{1F9D8}', label: '冥想', needLogin: true, page: '' },
-        { icon: '\u{1F4CA}', label: '健康维度', needLogin: true, page: 'health-dimensions' }
+        { icon: '\u{1F4CA}', label: '舌诊', needLogin: true, page: 'tongue-index' }
       ],
       healthTips: [
         { id: 1, title: '儿童每日运动指南', summary: '不同年龄段儿童每天需要多少运动量?科学运动助力健康成长。' },
@@ -399,6 +415,17 @@ export default {
       this.loadDimensionActivities()
       this.loadDimensionProducts()
       this.loadSandboxData()
+      this.loadHealthDimensions()
+    },
+    loadHealthDimensions: function() {
+      var self = this
+      getDimensionOverview({ memberId: this.activeChildId }).then(function(res) {
+        if (res && res.data) {
+          self.dimensionData = res.data
+        }
+      }).catch(function(e) {
+        console.log('获取维度概览失败', e)
+      })
     },
     loadSandboxData: function() {
       var self = this
@@ -535,6 +562,9 @@ export default {
       }
       uni.navigateTo({ url: '/pages/body/health-report?childId=' + this.activeChildId })
     },
+    goDimensionDetail: function(dimKey) {
+      uni.navigateTo({ url: '/pages/body/dimension-detail?dimension=' + dimKey + '&childId=' + this.activeChildId })
+    },
     goToCheckin: function() {
       if (!this.activeChildId) {
         uni.showToast({ title: '请先选择孩子', icon: 'none' })

+ 266 - 0
cfc-frontend/pages/health/tongue-index.vue

@@ -0,0 +1,266 @@
+<template>
+  <view class="tongue-container">
+    <!-- 步骤条 -->
+    <view class="step-bar">
+      <view class="step-item" :class="{ active: step >= 1, done: step > 1 }">
+        <text class="step-num">{{ step > 1 ? '✓' : '1' }}</text>
+        <text class="step-label">选择成员</text>
+      </view>
+      <view class="step-line" :class="{ active: step > 1 }"></view>
+      <view class="step-item" :class="{ active: step >= 2, done: step > 2 }">
+        <text class="step-num">{{ step > 2 ? '✓' : '2' }}</text>
+        <text class="step-label">舌象拍照</text>
+      </view>
+      <view class="step-line" :class="{ active: step > 2 }"></view>
+      <view class="step-item" :class="{ active: step >= 3 }">
+        <text class="step-num">3</text>
+        <text class="step-label">预览确认</text>
+      </view>
+    </view>
+
+    <!-- Step 1: 选成员 -->
+    <view class="step-content" v-if="step === 1">
+      <view class="section-title">选择家庭成员</view>
+      <view class="member-list">
+        <view class="member-item"
+          v-for="m in familyMembers"
+          :key="m.id"
+          :class="{ selected: selectedMember && selectedMember.id === m.id }"
+          @click="selectMember(m)">
+          <text class="member-avatar">{{ m.name && m.name.slice(0, 1) }}</text>
+          <text class="member-name">{{ m.name }}</text>
+        </view>
+      </view>
+      <button class="btn-primary" @click="step = 2" :disabled="!selectedMember">下一步</button>
+    </view>
+
+    <!-- Step 2: 拍照 -->
+    <view class="step-content" v-if="step === 2">
+      <view class="section-title">拍摄舌象</view>
+      <view class="photo-area">
+        <view class="photo-placeholder" v-if="!photoPath" @click="takePhoto">
+          <text class="photo-icon">📷</text>
+          <text class="photo-text">点击拍摄舌象</text>
+          <text class="photo-sub">提示:自然光下拍摄,避免有色光线</text>
+        </view>
+        <image class="photo-preview" v-else :src="photoPath" mode="aspectFit" @click="takePhoto"></image>
+      </view>
+      <view class="btn-row">
+        <button class="btn-secondary" @click="step = 1">上一步</button>
+        <button class="btn-primary" @click="uploadAndAnalyze" :disabled="!photoPath || uploading">
+          {{ uploading ? '分析中...' : '开始分析' }}
+        </button>
+      </view>
+    </view>
+
+    <!-- Step 3: 预览确认 -->
+    <view class="step-content" v-if="step === 3">
+      <view class="result-header">
+        <image class="result-img" :src="tongueResult.imageUrl" mode="aspectFit"></image>
+        <text class="result-assessment">{{ tongueResult.overallAssessment }}</text>
+      </view>
+
+      <view class="indicator-list">
+        <view class="indicator-item" v-for="(ind, idx) in indicators" :key="idx">
+          <text class="indicator-label">{{ ind.name }}</text>
+          <picker mode="selector" :range="ind.options" :value="ind.selectedIndex" @change="onIndicatorChange($event, idx)">
+            <view class="indicator-value">
+              <text>{{ ind.value }}</text>
+              <text class="indicator-arrow">▼</text>
+            </view>
+          </picker>
+        </view>
+      </view>
+
+      <view class="btn-row">
+        <button class="btn-secondary" @click="discardAndBack">放弃</button>
+        <button class="btn-primary" @click="confirmSave" :disabled="saving">
+          {{ saving ? '保存中...' : '确认入库' }}
+        </button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  data: function() {
+    return {
+      step: 1,
+      familyMembers: [],
+      selectedMember: null,
+      photoPath: null,
+      uploading: false,
+      saving: false,
+      tongueResult: {
+        recordId: null,
+        imageUrl: '',
+        overallAssessment: ''
+      },
+      indicators: []
+    }
+  },
+  onLoad: function() {
+    this.loadFamilyMembers()
+  },
+  methods: {
+    loadFamilyMembers: function() {
+      var members = uni.getStorageSync('familyChildren') || []
+      if (members.length > 0) {
+        this.familyMembers = members
+      }
+    },
+    selectMember: function(m) {
+      this.selectedMember = m
+    },
+    takePhoto: function() {
+      var self = this
+      uni.chooseImage({
+        count: 1,
+        sourceType: ['camera', 'album'],
+        success: function(res) {
+          self.photoPath = res.tempFilePaths[0]
+        }
+      })
+    },
+    uploadAndAnalyze: function() {
+      if (!this.photoPath) return
+      this.uploading = true
+
+      var self = this
+      uni.uploadFile({
+        url: getApp().globalData.baseUrl + '/api/health/report/parse-preview?type=tongue&memberId=' + this.selectedMember.id,
+        filePath: this.photoPath,
+        name: 'file',
+        success: function(res) {
+          var data = JSON.parse(res.data)
+          if (data.code === 0 && data.data) {
+            self.tongueResult.recordId = data.data.recordId
+            self.tongueResult.imageUrl = data.data.imageUrl
+            self.tongueResult.overallAssessment = data.data.overallAssessment
+            var inds = (data.data.indicators || []).map(function(ind) {
+              var opts = []
+              try { opts = JSON.parse(ind.options) } catch(e) {}
+              return {
+                code: ind.code,
+                name: ind.name,
+                value: ind.value,
+                options: opts,
+                selectedIndex: opts.indexOf(ind.value)
+              }
+            })
+            self.indicators = inds
+            self.step = 3
+          } else {
+            uni.showToast({ title: data.message || '分析失败', icon: 'none' })
+          }
+        },
+        fail: function() {
+          uni.showToast({ title: '上传失败', icon: 'none' })
+        },
+        complete: function() {
+          self.uploading = false
+        }
+      })
+    },
+    onIndicatorChange: function(e, idx) {
+      var i = parseInt(e.detail.value)
+      this.indicators[idx].selectedIndex = i
+      this.indicators[idx].value = this.indicators[idx].options[i]
+    },
+    confirmSave: function() {
+      this.saving = true
+      var self = this
+      var modifiedIndicators = this.indicators.map(function(ind) {
+        return { code: ind.code, value: ind.value }
+      })
+      uni.request({
+        url: getApp().globalData.baseUrl + '/api/health/report/confirm?type=tongue',
+        method: 'POST',
+        data: {
+          recordId: this.tongueResult.recordId,
+          indicators: modifiedIndicators
+        },
+        success: function(res) {
+          if (res.data.code === 0) {
+            uni.showToast({ title: '舌诊结果已保存' })
+            setTimeout(function() {
+              uni.navigateBack()
+            }, 1500)
+          } else {
+            uni.showToast({ title: res.data.message || '保存失败', icon: 'none' })
+          }
+        },
+        fail: function() {
+          uni.showToast({ title: '网络错误', icon: 'none' })
+        },
+        complete: function() {
+          self.saving = false
+        }
+      })
+    },
+    discardAndBack: function() {
+      var self = this
+      uni.showModal({
+        title: '提示',
+        content: '确定放弃本次舌诊分析吗?',
+        success: function(res) {
+          if (res.confirm) {
+            uni.request({
+              url: getApp().globalData.baseUrl + '/api/health/report/discard?type=tongue',
+              method: 'POST',
+              data: { recordId: self.tongueResult.recordId }
+            })
+            uni.navigateBack()
+          }
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.tongue-container { padding: 30rpx; background: #F5FAFE; min-height: 100vh; }
+.step-bar { display: flex; align-items: center; margin-bottom: 40rpx; }
+.step-item { display: flex; flex-direction: column; align-items: center; }
+.step-num { width: 48rpx; height: 48rpx; border-radius: 50%; background: #E2E8F0; color: #94A3B8; text-align: center; line-height: 48rpx; font-size: 24rpx; font-weight: bold; }
+.step-item.active .step-num { background: #F97316; color: #fff; }
+.step-item.done .step-num { background: #10B981; color: #fff; }
+.step-label { font-size: 22rpx; color: #94A3B8; margin-top: 8rpx; }
+.step-item.active .step-label { color: #F97316; font-weight: bold; }
+.step-line { flex: 1; height: 2rpx; background: #E2E8F0; margin: 0 16rpx; margin-bottom: 40rpx; }
+.step-line.active { background: #10B981; }
+
+.section-title { font-size: 32rpx; font-weight: bold; color: #1E293B; margin-bottom: 24rpx; }
+.member-list { display: flex; flex-wrap: wrap; gap: 20rpx; margin-bottom: 40rpx; }
+.member-item { width: 140rpx; height: 160rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #fff; border-radius: 16rpx; border: 2rpx solid #E2E8F0; }
+.member-item.selected { border-color: #F97316; background: #FFF7ED; }
+.member-avatar { width: 64rpx; height: 64rpx; border-radius: 50%; background: #FED7AA; text-align: center; line-height: 64rpx; font-size: 28rpx; color: #9A3412; margin-bottom: 8rpx; }
+.member-name { font-size: 24rpx; color: #1E293B; }
+
+.photo-area { display: flex; justify-content: center; margin-bottom: 40rpx; }
+.photo-placeholder { width: 400rpx; height: 400rpx; border: 4rpx dashed #CBD5E1; border-radius: 24rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #fff; }
+.photo-icon { font-size: 80rpx; margin-bottom: 16rpx; }
+.photo-text { font-size: 28rpx; color: #64748B; }
+.photo-sub { font-size: 22rpx; color: #94A3B8; margin-top: 12rpx; }
+.photo-preview { width: 400rpx; height: 400rpx; border-radius: 24rpx; }
+
+.result-header { display: flex; flex-direction: column; align-items: center; margin-bottom: 30rpx; }
+.result-img { width: 300rpx; height: 300rpx; border-radius: 16rpx; margin-bottom: 16rpx; }
+.result-assessment { font-size: 26rpx; color: #475569; text-align: center; line-height: 1.6; }
+
+.indicator-list { margin-bottom: 40rpx; }
+.indicator-item { display: flex; justify-content: space-between; align-items: center; padding: 24rpx 20rpx; background: #fff; border-radius: 12rpx; margin-bottom: 12rpx; }
+.indicator-label { font-size: 28rpx; color: #1E293B; }
+.indicator-value { display: flex; align-items: center; color: #F97316; font-size: 28rpx; }
+.indicator-arrow { font-size: 20rpx; margin-left: 8rpx; }
+
+.btn-row { display: flex; gap: 20rpx; }
+.btn-primary { flex: 1; background: #F97316; color: #fff; border-radius: 40rpx; height: 88rpx; line-height: 88rpx; text-align: center; font-size: 30rpx; }
+.btn-primary:disabled { opacity: 0.5; }
+.btn-secondary { flex: 1; background: #fff; color: #64748B; border-radius: 40rpx; height: 88rpx; line-height: 88rpx; text-align: center; font-size: 30rpx; border: 2rpx solid #E2E8F0; }
+
+.loading-wrap { display: flex; justify-content: center; align-items: center; min-height: 200rpx; }
+.loading-text { color: #94A3B8; font-size: 28rpx; }
+</style>

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

@@ -1514,3 +1514,10 @@ export const getQuestionnaireSnapshot = (memberId) => {
 export const getQuestionnaireHistory = (memberId) => {
   return request('/api/family/questionnaire/history/' + memberId, 'GET', {})
 }
+
+// ===== 舌诊相关(两阶段流程) =====
+export const parseTonguePreview = (data) => request('/api/health/report/parse-preview?type=tongue', 'POST', data)
+export const confirmTongue = (data) => request('/api/health/report/confirm?type=tongue', 'POST', data)
+export const discardTongue = (data) => request('/api/health/report/discard?type=tongue', 'POST', data)
+// 指标定义
+export const getIndicatorDefinitions = (data) => request('/api/admin/indicator/list', 'POST', data)

+ 9 - 0
cfc-web/src/api/dimension-weight.js

@@ -0,0 +1,9 @@
+import request from '@/utils/request'
+
+export function saveDimensionWeights(data) {
+  return request({ url: '/api/admin/dimension-weight/save', method: 'post', data })
+}
+
+export function getDimensionWeights(data) {
+  return request({ url: '/api/admin/dimension-weight/list', method: 'post', data })
+}

+ 303 - 0
cfc-web/src/components/DimensionWeightPicker.vue

@@ -0,0 +1,303 @@
+<template>
+  <div class="dimension-weight-picker">
+    <div class="dimension-rows">
+      <div
+        v-for="dim in dimensions"
+        :key="dim.value"
+        class="dimension-row"
+        :class="{ 'is-enabled': rowEnabled[dim.value] }"
+      >
+        <el-checkbox
+          v-model="rowEnabled[dim.value]"
+          @change="onToggle(dim.value)"
+          class="dim-check"
+        >
+          <span class="dim-label" :style="{ color: dim.color }">{{ dim.label }}</span>
+        </el-checkbox>
+
+        <div class="dim-slider" :class="{ 'is-locked': !rowEnabled[dim.value] }">
+          <el-slider
+            v-model="rowValues[dim.value]"
+            :disabled="!rowEnabled[dim.value]"
+            :min="0"
+            :max="100"
+            :step="5"
+            :show-tooltip="true"
+            @change="onSliderChange(dim.value)"
+          />
+        </div>
+
+        <el-input-number
+          v-model="rowValues[dim.value]"
+          :disabled="!rowEnabled[dim.value]"
+          :min="0"
+          :max="100"
+          :step="5"
+          size="small"
+          class="dim-num"
+          controls-position="right"
+          @change="onSliderChange(dim.value)"
+        />
+        <span class="dim-unit">%</span>
+      </div>
+    </div>
+
+    <div class="sum-bar">
+      <span class="sum-label">已分配:</span>
+      <div class="sum-track">
+        <div class="sum-fill" :class="sumClass" :style="{ width: totalSum + '%' }"></div>
+      </div>
+      <span class="sum-value" :class="sumClass">{{ totalSum }}%</span>
+      <span v-if="totalSum !== 100 && totalSum !== 0" class="sum-tip">(启用项合计应等于100%)</span>
+      <span v-if="totalSum === 100" class="sum-tip sum-ok">✓ 分配合理</span>
+    </div>
+
+    <div v-if="showPresets" class="preset-btns">
+      <el-button size="mini" @click="applyPreset('single', 'body')">100%身</el-button>
+      <el-button size="mini" @click="applyPreset('single', 'mind')">100%智</el-button>
+      <el-button size="mini" @click="applyPreset('single', 'action')">100%行</el-button>
+      <el-button size="mini" @click="applyPreset('single', 'wealth')">100%富</el-button>
+      <el-button size="mini" @click="applyPreset('single', 'heart')">100%心</el-button>
+      <el-button size="mini" @click="applyPreset('equal')">均分100%</el-button>
+      <el-button size="mini" type="text" style="margin-left:4px;" @click="clearAll">清空</el-button>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'DimensionWeightPicker',
+  props: {
+    // v-model: Array of { dimension: 'body', weight: 40, enabled: true }
+    value: {
+      type: Array,
+      default: function() {
+        return []
+      }
+    },
+    // 是否显示快捷预设按钮
+    showPresets: {
+      type: Boolean,
+      default: false
+    }
+  },
+  data() {
+    return {
+      rowEnabled: {
+        body: false,
+        mind: false,
+        action: false,
+        wealth: false,
+        heart: false
+      },
+      rowValues: {
+        body: 0,
+        mind: 0,
+        action: 0,
+        wealth: 0,
+        heart: 0
+      },
+      dimensions: [
+        { value: 'body',  label: '身·土', color: '#FF8C42' },
+        { value: 'mind',  label: '智·金', color: '#6366F1' },
+        { value: 'action', label: '行·木', color: '#10B981' },
+        { value: 'wealth', label: '富·水', color: '#F59E0B' },
+        { value: 'heart', label: '心·火', color: '#FF6B9D' }
+      ]
+    }
+  },
+  computed: {
+    totalSum() {
+      let sum = 0
+      for (var key in this.rowEnabled) {
+        if (this.rowEnabled[key]) {
+          sum += this.rowValues[key] || 0
+        }
+      }
+      return sum
+    },
+    sumClass() {
+      if (this.totalSum === 0) return 'sum-zero'
+      return this.totalSum === 100 ? 'sum-ok' : 'sum-error'
+    },
+    // 用于 v-model 输出的格式
+    outputValue() {
+      const result = []
+      for (var key in this.rowEnabled) {
+        if (this.rowEnabled[key]) {
+          result.push({
+            dimension: key,
+            weight: this.rowValues[key] || 0,
+            enabled: true
+          })
+        }
+      }
+      return result
+    }
+  },
+  watch: {
+    value: {
+      handler(val) {
+        this.loadFromValue(val || [])
+      },
+      immediate: true,
+      deep: true
+    }
+  },
+  methods: {
+    loadFromValue(val) {
+      // 重置
+      for (var key in this.rowEnabled) {
+        this.rowEnabled[key] = false
+        this.rowValues[key] = 0
+      }
+      // 加载外部值
+      if (Array.isArray(val)) {
+        for (var i = 0; i < val.length; i++) {
+          var item = val[i]
+          if (item.dimension && this.rowEnabled.hasOwnProperty(item.dimension)) {
+            this.rowEnabled[item.dimension] = true
+            this.rowValues[item.dimension] = item.weight || 0
+          }
+        }
+      }
+    },
+    onToggle(dim) {
+      // 如果启用但值为0,默认给一个值
+      if (this.rowEnabled[dim] && this.rowValues[dim] === 0) {
+        this.rowValues[dim] = 100
+      }
+      this.emitInput()
+    },
+    onSliderChange() {
+      this.emitInput()
+    },
+    emitInput() {
+      this.$emit('input', this.outputValue)
+    },
+    applyPreset(type, dim) {
+      // 先清空
+      for (var key in this.rowEnabled) {
+        this.rowEnabled[key] = false
+        this.rowValues[key] = 0
+      }
+      if (type === 'single' && dim) {
+        this.rowEnabled[dim] = true
+        this.rowValues[dim] = 100
+      } else if (type === 'equal') {
+        // 均分给所有5个
+        var each = Math.floor(100 / 5)
+        var remainder = 100 - each * 5
+        for (var i = 0; i < this.dimensions.length; i++) {
+          var d = this.dimensions[i]
+          this.rowEnabled[d.value] = true
+          this.rowValues[d.value] = each + (i < remainder ? 1 : 0)
+        }
+      }
+      this.emitInput()
+    },
+    clearAll() {
+      for (var key in this.rowEnabled) {
+        this.rowEnabled[key] = false
+        this.rowValues[key] = 0
+      }
+      this.emitInput()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.dimension-weight-picker {
+  font-size: 14px;
+}
+.dimension-rows {
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  padding: 8px 12px;
+  background: #fafafa;
+}
+.dimension-row {
+  display: flex;
+  align-items: center;
+  padding: 6px 0;
+  border-bottom: 1px solid #f0f0f0;
+}
+.dimension-row:last-child {
+  border-bottom: none;
+}
+.dim-check {
+  width: 90px;
+  min-width: 90px;
+}
+.dim-label {
+  font-weight: 600;
+  font-size: 13px;
+}
+.dim-slider {
+  flex: 1;
+  margin: 0 12px;
+  opacity: 0.4;
+  pointer-events: none;
+  transition: opacity 0.2s;
+}
+.dim-slider.is-locked {
+  opacity: 1;
+  pointer-events: auto;
+}
+.dim-num {
+  width: 80px;
+}
+.dim-unit {
+  color: #999;
+  margin-left: 4px;
+  font-size: 12px;
+}
+.sum-bar {
+  display: flex;
+  align-items: center;
+  margin-top: 10px;
+  gap: 8px;
+}
+.sum-label {
+  color: #666;
+  font-size: 13px;
+  min-width: 56px;
+}
+.sum-track {
+  flex: 1;
+  height: 8px;
+  background: #e4e7ed;
+  border-radius: 4px;
+  overflow: hidden;
+}
+.sum-fill {
+  height: 100%;
+  border-radius: 4px;
+  transition: width 0.3s, background 0.3s;
+}
+.sum-zero .sum-fill { background: #e4e7ed; }
+.sum-ok .sum-fill { background: #67c23a; }
+.sum-error .sum-fill { background: #f56c6c; }
+.sum-value {
+  min-width: 40px;
+  font-size: 13px;
+  font-weight: 600;
+}
+.sum-zero .sum-value { color: #999; }
+.sum-ok .sum-value { color: #67c23a; }
+.sum-error .sum-value { color: #f56c6c; }
+.sum-tip {
+  font-size: 12px;
+  color: #f56c6c;
+}
+.sum-ok .sum-tip {
+  color: #67c23a;
+}
+.preset-btns {
+  margin-top: 8px;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+</style>

+ 6 - 0
cfc-web/src/router/index.js

@@ -426,6 +426,12 @@ const routes = [
         component: () => import('@/views/admin/data-source-config'),
         meta: { title: '健康数据源配置', perm: 'system:config' }
       },
+      {
+        path: 'indicators',
+        name: 'IndicatorManage',
+        component: () => import('@/views/admin/indicators'),
+        meta: { title: '指标管理', perm: 'system:config' }
+      },
       // ========== 虚拟服务商团队管理 ==========
       {
         path: 'virtual-teams',

+ 196 - 0
cfc-web/src/views/admin/indicators/index.vue

@@ -0,0 +1,196 @@
+<template>
+  <div class="indicators-page">
+    <div class="page-header">
+      <h2>指标管理</h2>
+      <el-button type="primary" @click="showDialog(null)">新增指标</el-button>
+    </div>
+
+    <!-- 筛选栏 -->
+    <div class="filter-bar">
+      <el-select v-model="filterDomain" placeholder="领域" clearable @change="loadData">
+        <el-option label="生理健康" value="physical" />
+        <el-option label="心理健康" value="mental" />
+        <el-option label="营养" value="nutrition" />
+      </el-select>
+      <el-input v-model="filterKeyword" placeholder="搜索名称/编码" clearable style="width:200px;margin-left:12px" @keyup.enter.native="loadData" />
+    </div>
+
+    <!-- 表格 -->
+    <el-table :data="list" border stripe v-loading="loading">
+      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="domain" label="领域" width="100" />
+      <el-table-column prop="category" label="分类" width="100" />
+      <el-table-column prop="code" label="编码" width="160" />
+      <el-table-column prop="name" label="名称" width="160" />
+      <el-table-column prop="dataType" label="数据类型" width="100" />
+      <el-table-column prop="options" label="可选值" min-width="200">
+        <template slot-scope="{ row }">
+          <span v-if="row.options">{{ row.options }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column prop="sortOrder" label="排序" width="60" />
+      <el-table-column prop="status" label="状态" width="80">
+        <template slot-scope="{ row }">
+          <el-tag :type="row.status === 1 ? 'success' : 'info'">
+            {{ row.status === 1 ? '启用' : '禁用' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="160" fixed="right">
+        <template slot-scope="{ row }">
+          <el-button size="small" @click="showDialog(row)">编辑</el-button>
+          <el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 编辑弹窗 -->
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="500px">
+      <el-form ref="form" :model="form" label-width="100px">
+        <el-form-item label="领域" required>
+          <el-select v-model="form.domain" style="width:100%">
+            <el-option label="生理健康" value="physical" />
+            <el-option label="心理健康" value="mental" />
+            <el-option label="营养" value="nutrition" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="分类" required>
+          <el-input v-model="form.category" />
+        </el-form-item>
+        <el-form-item label="编码" required>
+          <el-input v-model="form.code" />
+        </el-form-item>
+        <el-form-item label="名称" required>
+          <el-input v-model="form.name" />
+        </el-form-item>
+        <el-form-item label="数据类型" required>
+          <el-select v-model="form.dataType" style="width:100%">
+            <el-option label="枚举" value="enum" />
+            <el-option label="数值" value="number" />
+            <el-option label="文本" value="string" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="可选值" v-if="form.dataType === 'enum'">
+          <el-input type="textarea" v-model="form.options" placeholder='["值1","值2","值3"] JSON格式' rows="3" />
+        </el-form-item>
+        <el-form-item label="单位" v-if="form.dataType === 'number'">
+          <el-input v-model="form.unit" />
+        </el-form-item>
+        <el-form-item label="排序">
+          <el-input-number v-model="form.sortOrder" :min="0" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+export default {
+  data() {
+    return {
+      list: [],
+      loading: false,
+      filterDomain: '',
+      filterKeyword: '',
+      dialogVisible: false,
+      dialogTitle: '新增指标',
+      saving: false,
+      form: {
+        id: null,
+        domain: 'physical',
+        category: '',
+        code: '',
+        name: '',
+        dataType: 'enum',
+        options: '',
+        unit: '',
+        sortOrder: 0,
+        status: 1
+      }
+    }
+  },
+  mounted() {
+    this.loadData()
+  },
+  methods: {
+    loadData() {
+      this.loading = true
+      this.$http.post('/api/admin/indicator/list', {
+        domain: this.filterDomain || undefined,
+        status: 1
+      }).then(res => {
+        if (res.code === 0) {
+          this.list = res.data || []
+          if (this.filterKeyword) {
+            var kw = this.filterKeyword.toLowerCase()
+            this.list = this.list.filter(function(item) {
+              return (item.name && item.name.toLowerCase().includes(kw)) ||
+                     (item.code && item.code.toLowerCase().includes(kw))
+            })
+          }
+        }
+      }).finally(() => { this.loading = false })
+    },
+    showDialog(row) {
+      if (row) {
+        this.dialogTitle = '编辑指标'
+        this.form = Object.assign({}, row)
+      } else {
+        this.dialogTitle = '新增指标'
+        this.form = {
+          id: null,
+          domain: 'physical',
+          category: '',
+          code: '',
+          name: '',
+          dataType: 'enum',
+          options: '',
+          unit: '',
+          sortOrder: 0,
+          status: 1
+        }
+      }
+      this.dialogVisible = true
+    },
+    handleSave() {
+      this.saving = true
+      var url = this.form.id ? '/api/admin/indicator/update' : '/api/admin/indicator/create'
+      this.$http.post(url, this.form).then(res => {
+        if (res.code === 0) {
+          this.$message.success('保存成功')
+          this.dialogVisible = false
+          this.loadData()
+        } else {
+          this.$message.error(res.message || '保存失败')
+        }
+      }).finally(() => { this.saving = false })
+    },
+    handleDelete(row) {
+      this.$confirm('确定删除该指标?', '提示', { type: 'warning' }).then(() => {
+        this.$http.post('/api/admin/indicator/delete', { id: row.id }).then(res => {
+          if (res.code === 0) {
+            this.$message.success('删除成功')
+            this.loadData()
+          } else {
+            this.$message.error(res.message || '删除失败')
+          }
+        })
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.indicators-page { padding: 20px; }
+.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
+.page-header h2 { margin: 0; font-size: 20px; color: #1a1a2e; }
+.filter-bar { margin-bottom: 16px; display: flex; align-items: center; }
+</style>