Browse Source

feat(report): 新增报告模板管理 & 采集训练器

后端:
- 新增 report_template 表,支持按报告类型配置固定展示块 + 自由显示
- ReportBlockAssembler 集成模板逻辑:优先按 fixedBlocks 顺序渲染,未覆盖指标自动追加
- 自学习生成类型时自动创建空模板(isActive=0)
- report_unknown_upload 新增 annotation 列(管理员标注训练数据)

前端:
- ReportTemplateManage.vue:模板列表 + 可视化编辑器(固定块增删/排序、自由显示开关、实时预览)
- ReportCollectorTrainer.vue:聚类列表 + 报告标注 + LLM解析预览 + 模板渲染预览
- 路由 & 菜单:报告解析系统下新增「报告采集训练」「报告模板管理」

文档:更新 API_REFERENCE.md,新增报告模板接口说明
liaoxg 4 weeks ago
parent
commit
77d9b05333

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -9009,6 +9009,26 @@ private void runMigration100() {
 		// 迁移251: 健康饮食组挂载 报告管理/报告审核/应季食材(《角色权限与菜单归类整合方案》第二部分,幂等)
 		migrateHealthMenuMount();
 
+		// 迁移252: 创建 report_template 表(报告展示模板配置)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_template (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"name VARCHAR(100) NOT NULL COMMENT '模板名称', " +
+				"report_type VARCHAR(50) NOT NULL COMMENT '报告类型: gut_flora/dan/physical_exam/tongue', " +
+				"config JSON NOT NULL COMMENT '模板配置JSON: fixedBlocks+freeDisplay', " +
+				"is_active TINYINT DEFAULT 1 COMMENT '0=停用 1=启用', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"UNIQUE KEY uk_report_type (report_type)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告展示模板'");
+			log.info("已创建report_template表");
+		} catch (Exception e) {
+			log.warn("report_template表可能已存在: {}", e.getMessage());
+		}
+
+		// 迁移253: report_unknown_upload 添加 annotation 列(管理员标注训练数据)
+		ensureColumn("report_unknown_upload", "annotation", "TEXT COMMENT '管理员标注: 手动指定类型+确认提取结果JSON'");
+
 	}
 
 	/**

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java

@@ -43,6 +43,9 @@ public class ReportParserAdminController {
     @Resource
     private ReportImportService reportImportService;
 
+    @Resource
+    private ReportTemplateMapper reportTemplateMapper;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
 
     // ==================== 报告类型管理 ====================
@@ -273,6 +276,21 @@ public class ReportParserAdminController {
             cluster.setUpdatedAt(new Date());
             unknownClusterMapper.updateById(cluster);
 
+            // 自动创建空模板
+            try {
+                com.etotem.cfc.entity.ReportTemplate tpl = new com.etotem.cfc.entity.ReportTemplate();
+                tpl.setReportType(typeId);
+                tpl.setName(type.getDisplayName());
+                tpl.setConfig("{\"fixedBlocks\":[],\"freeDisplay\":{\"enabled\":true,\"groupName\":\"其他指标\"}}");
+                tpl.setIsActive(0);
+                tpl.setCreatedAt(new Date());
+                tpl.setUpdatedAt(new Date());
+                reportTemplateMapper.insert(tpl);
+                log.info("已自动创建空模板 reportType={}", typeId);
+            } catch (Exception e) {
+                log.warn("自动创建模板失败 typeId={}: {}", typeId, e.getMessage());
+            }
+
             Map<String, Object> result = new LinkedHashMap<>();
             result.put("typeId", typeId);
             result.put("displayName", type.getDisplayName());

+ 68 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportTemplateController.java

@@ -0,0 +1,68 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.ReportTemplate;
+import com.etotem.cfc.service.ReportTemplateService;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/report-template")
+public class ReportTemplateController {
+
+    @Resource
+    private ReportTemplateService reportTemplateService;
+
+    @PostMapping("/list")
+    public Result<List<ReportTemplate>> list(@RequestBody Map<String, Object> params) {
+        String reportType = (String) params.get("reportType");
+        Integer isActive = params.get("isActive") != null ? (Integer) params.get("isActive") : null;
+        return Result.success(reportTemplateService.list(reportType, isActive));
+    }
+
+    @PostMapping("/get")
+    public Result<ReportTemplate> get(@RequestParam String reportType) {
+        ReportTemplate t = reportTemplateService.getByReportType(reportType);
+        return Result.success(t != null ? t : new ReportTemplate());
+    }
+
+    @PostMapping("/save")
+    public Result<ReportTemplate> save(@RequestBody ReportTemplate template) {
+        if (template.getName() == null || template.getName().trim().isEmpty()) {
+            return Result.error("模板名称不能为空");
+        }
+        if (template.getReportType() == null || template.getReportType().trim().isEmpty()) {
+            return Result.error("报告类型不能为空");
+        }
+        return Result.success(reportTemplateService.save(template));
+    }
+
+    @PostMapping("/delete")
+    public Result<Void> delete(@RequestParam Long id) {
+        reportTemplateService.delete(id);
+        return Result.success(null);
+    }
+
+    @PostMapping("/toggle")
+    public Result<Void> toggle(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? ((Number) params.get("id")).longValue() : null;
+        Integer isActive = params.get("isActive") != null ? (Integer) params.get("isActive") : 1;
+        if (id == null) return Result.error("id 不能为空");
+        reportTemplateService.toggle(id, isActive);
+        return Result.success(null);
+    }
+
+    @PostMapping("/indicators")
+    public Result<List<IndicatorDefinition>> indicators(@RequestBody Map<String, Object> params) {
+        String domain = (String) params.get("domain");
+        return Result.success(reportTemplateService.getAvailableIndicators(domain));
+    }
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportTemplate.java

@@ -0,0 +1,29 @@
+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("report_template")
+public class ReportTemplate implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String name;
+
+    private String reportType;
+
+    private String config;
+
+    private Integer isActive;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

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

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

+ 238 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/ReportBlockAssembler.java

@@ -1,12 +1,15 @@
 package com.etotem.cfc.service;
 
 import com.etotem.cfc.dto.ParsedReportPayload;
+import com.etotem.cfc.entity.ReportTemplate;
+import com.etotem.cfc.service.ReportTemplateService;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
+import javax.annotation.Resource;
+import java.util.*;
 
 /**
  * 报告通用展示块组装器 — 解析结果 → blocks JSON 结构
@@ -15,6 +18,9 @@ import java.util.Map;
 @Service
 public class ReportBlockAssembler {
 
+    @Resource
+    private ReportTemplateService reportTemplateService;
+
     private static final java.util.Set<String> IMPORTANT_LEVELS =
             new java.util.HashSet<>(java.util.Arrays.asList("需注意", "注意", "高风险", "异常"));
 
@@ -23,6 +29,212 @@ public class ReportBlockAssembler {
         return level != null && IMPORTANT_LEVELS.contains(level);
     }
 
+    // ==================== 模板驱动组装 ====================
+
+    /**
+     * 根据报告类型+模板配置组装 blocks。
+     * 若该 reportType 有启用模板,优先使用模板 fixedBlocks;
+     * freeDisplay 开启时,将未覆盖的指标追加到末尾。
+     */
+    public List<Map<String, Object>> assembleWithTemplate(String reportType, ParsedReportPayload.Payload payload) {
+        if (payload == null) return new ArrayList<>();
+
+        ReportTemplate template = reportTemplateService.getByReportType(reportType);
+        if (template == null || template.getIsActive() == null || template.getIsActive() != 1) {
+            // 无模板或已停用 → 使用默认逻辑
+            if ("gut_flora".equals(reportType)) {
+                return assembleGutFlora(payload);
+            }
+            return new ArrayList<>();
+        }
+
+        return parseAndAssemble(template.getConfig(), reportType, payload);
+    }
+
+    private List<Map<String, Object>> parseAndAssemble(String configJson, String reportType, ParsedReportPayload.Payload payload) {
+        List<Map<String, Object>> blocks = new ArrayList<>();
+        ObjectMapper mapper = new ObjectMapper();
+        List<Map<String, Object>> fixedBlocks;
+        Map<String, Object> freeDisplay;
+
+        try {
+            JsonNode root = mapper.readTree(configJson);
+            fixedBlocks = toList(root.get("fixedBlocks"));
+            freeDisplay = toMap(root.get("freeDisplay"));
+        } catch (Exception e) {
+            // JSON 解析失败 → fallback
+            if ("gut_flora".equals(reportType)) return assembleGutFlora(payload);
+            return new ArrayList<>();
+        }
+
+        // 收集已使用的 indicator codes(按 category 或 name 匹配)
+        Set<String> usedCodes = new HashSet<>();
+        Set<String> usedCategories = new HashSet<>();
+
+        // 按 fixedBlocks 顺序组装
+        for (Map<String, Object> fb : fixedBlocks) {
+            String type = str(fb.get("type"));
+            if ("score".equals(type)) {
+                blocks.add(assembleScoreBlock(fb, payload));
+            } else if ("indicator".equals(type)) {
+                @SuppressWarnings("unchecked")
+                List<String> codes = (List<String>) fb.get("indicatorCodes");
+                if (codes != null) codes.forEach(usedCodes::add);
+                String groupLabel = str(fb.get("groupLabel"));
+                blocks.add(assembleIndicatorBlock(fb, payload, groupLabel, usedCodes));
+            } else if ("list".equals(type)) {
+                blocks.add(assembleListBlock(fb, payload));
+            } else if ("text".equals(type)) {
+                blocks.add(textBlock(str(fb.get("title")), str(fb.get("content"))));
+            }
+        }
+
+        // 自由显示:追加未覆盖的指标(按 category 去重)
+        boolean freeEnabled = freeDisplay != null && Boolean.TRUE.equals(freeDisplay.get("enabled"));
+        if (freeEnabled && payload.getIndicators() != null && !payload.getIndicators().isEmpty()) {
+            @SuppressWarnings("unchecked")
+            List<ParsedReportPayload.Indicator> allIndicators = payload.getIndicators();
+            List<Map<String, Object>> freeItems = new ArrayList<>();
+            Set<String> skippedCategories = new HashSet<>();
+            for (Map<String, Object> fb : fixedBlocks) {
+                if ("indicator".equals(fb.get("type"))) {
+                    @SuppressWarnings("unchecked")
+                    List<String> names = (List<String>) fb.get("indicatorNames");
+                    if (names != null) {
+                        for (ParsedReportPayload.Indicator ind : allIndicators) {
+                            if (names.contains(ind.getIndicatorName()) && ind.getCategory() != null) {
+                                skippedCategories.add(ind.getCategory());
+                            }
+                        }
+                    }
+                }
+            }
+            for (ParsedReportPayload.Indicator ind : allIndicators) {
+                if (skippedCategories.contains(ind.getCategory())) continue;
+                Map<String, Object> item = new LinkedHashMap<>();
+                item.put("name", ind.getIndicatorName());
+                item.put("value", ind.getIndicatorValue());
+                item.put("unit", ind.getUnit());
+                item.put("refRange", ind.getRefRange());
+                item.put("status", ind.getStatus());
+                freeItems.add(item);
+            }
+            if (!freeItems.isEmpty()) {
+                String groupName = str(freeDisplay.get("groupName"));
+                if (groupName == null || groupName.isEmpty()) groupName = "其他指标";
+                Map<String, Object> block = new LinkedHashMap<>();
+                block.put("type", "indicator");
+                block.put("title", groupName);
+                block.put("items", freeItems);
+                blocks.add(block);
+            }
+        }
+
+        return blocks;
+    }
+
+    private Map<String, Object> assembleScoreBlock(Map<String, Object> fb, ParsedReportPayload.Payload payload) {
+        List<Map<String, Object>> items = new ArrayList<>();
+        @SuppressWarnings("unchecked")
+        List<String> fields = (List<String>) fb.get("items");
+        if (fields == null) return textBlock(str(fb.get("title")), "");
+        ParsedReportPayload.Summary s = payload.getSummary();
+        if (s == null) s = new ParsedReportPayload.Summary();
+        for (String field : fields) {
+            Integer val = null;
+            switch (field) {
+                case "overallScore": val = s.getOverallScore(); break;
+                case "gutHealthScore": val = s.getGutHealthScore(); break;
+                case "chronicDiseaseScore": val = s.getChronicDiseaseScore(); break;
+                case "nutritionScore": val = s.getNutritionScore(); break;
+                default: continue;
+            }
+            if (val != null) items.add(scoreItem(field, String.valueOf(val)));
+        }
+        if (items.isEmpty()) return null;
+        Map<String, Object> block = new LinkedHashMap<>();
+        block.put("type", "score");
+        block.put("title", str(fb.get("title")));
+        block.put("items", items);
+        return block;
+    }
+
+    private Map<String, Object> assembleIndicatorBlock(Map<String, Object> fb, ParsedReportPayload.Payload payload,
+                                                         String groupLabel, Set<String> usedCodes) {
+        @SuppressWarnings("unchecked")
+        List<String> names = (List<String>) fb.get("indicatorNames");
+        List<Map<String, Object>> items = new ArrayList<>();
+        if (payload.getIndicators() == null) return null;
+        for (ParsedReportPayload.Indicator ind : payload.getIndicators()) {
+            if (names != null && names.contains(ind.getIndicatorName())) {
+                items.add(indicatorItem(ind));
+            }
+        }
+        if (items.isEmpty()) return null;
+        Map<String, Object> block = new LinkedHashMap<>();
+        block.put("type", "indicator");
+        block.put("title", groupLabel != null ? groupLabel : str(fb.get("title")));
+        block.put("items", items);
+        return block;
+    }
+
+    private Map<String, Object> assembleListBlock(Map<String, Object> fb, ParsedReportPayload.Payload payload) {
+        String listKey = str(fb.get("listKey"));
+        List<Map<String, Object>> items = new ArrayList<>();
+        Map<String, Object> block = new LinkedHashMap<>();
+        block.put("type", "list");
+        block.put("title", str(fb.get("title")));
+
+        if ("gutFlora".equals(listKey) || "flora".equals(listKey)) {
+            block.put("columns", Arrays.asList(col("name", "菌种"), col("value", "数值"), col("range", "正常范围"), col("status", "状态")));
+            if (payload.getGutFlora() != null) addFloraItems(items, payload.getGutFlora());
+            if (payload.getPathogenGenus() != null) addFloraItems(items, payload.getPathogenGenus());
+            if (payload.getPathogenDetection() != null) addFloraItems(items, payload.getPathogenDetection());
+        } else if ("probiotic".equals(listKey)) {
+            block.put("columns", Arrays.asList(col("name", "菌种"), col("value", "数值"), col("range", "正常范围"), col("status", "状态")));
+            if (payload.getProbioticSpecies() != null) {
+                for (ParsedReportPayload.Flora f : payload.getProbioticSpecies()) {
+                    Map<String, Object> m = new LinkedHashMap<>();
+                    m.put("name", f.getBacteriaName());
+                    m.put("value", f.getBacteriaValue());
+                    m.put("range", f.getNormalRange());
+                    m.put("status", f.getPopulationLevel() != null ? f.getPopulationLevel() : f.getLevel());
+                    items.add(m);
+                }
+            }
+        } else if ("taxonomy".equals(listKey)) {
+            // taxonomy sub-keys
+            block.put("columns", Arrays.asList(col("name", "名称"), col("value", "丰度")));
+            if (payload.getTaxonomyClass() != null) for (ParsedReportPayload.Flora f : payload.getTaxonomyClass()) items.add(taxItem(f));
+            if (payload.getTaxonomyOrder() != null) for (ParsedReportPayload.Flora f : payload.getTaxonomyOrder()) items.add(taxItem(f));
+            if (payload.getTaxonomyFamily() != null) for (ParsedReportPayload.Flora f : payload.getTaxonomyFamily()) items.add(taxItem(f));
+            if (payload.getTaxonomyGenus() != null) for (ParsedReportPayload.Flora f : payload.getTaxonomyGenus()) items.add(taxItem(f));
+            if (payload.getTaxonomySpecies() != null) for (ParsedReportPayload.Flora f : payload.getTaxonomySpecies()) items.add(taxItem(f));
+        } else if ("food".equals(listKey)) {
+            block.put("columns", Arrays.asList(col("name", "食材"), col("category", "类别"), col("score", "推荐指数")));
+            if (payload.getFoods() != null) {
+                for (ParsedReportPayload.FoodItem f : payload.getFoods()) {
+                    Map<String, Object> m = new LinkedHashMap<>();
+                    m.put("name", f.getName());
+                    m.put("category", f.getCategory());
+                    m.put("score", f.getScore());
+                    items.add(m);
+                }
+            }
+        }
+
+        if (items.isEmpty()) return null;
+        block.put("items", items);
+        return block;
+    }
+
+    private Map<String, Object> taxItem(ParsedReportPayload.Flora f) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("name", f.getBacteriaName());
+        m.put("value", f.getBacteriaValue());
+        return m;
+    }
+
     /** 编辑链路:第一套键 Map → 标准 Payload(第二套键)
      *  支持嵌套结构 {summary:{...}, diseaseRisks:[...]} 与旧版平铺结构 */
     public ParsedReportPayload.Payload fromMap(Map<String, Object> map) {
@@ -289,4 +501,26 @@ public class ReportBlockAssembler {
     private String str(Object v) {
         return v == null ? null : v.toString();
     }
+
+    private List<Map<String, Object>> toList(JsonNode node) {
+        if (node == null || node.isNull()) return new ArrayList<>();
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (JsonNode n : node) {
+            Map<String, Object> m = new LinkedHashMap<>();
+            n.fields().forEachRemaining(e -> m.put(e.getKey(), e.getValue().toString()));
+            result.add(m);
+        }
+        return result;
+    }
+
+    private Map<String, Object> toMap(JsonNode node) {
+        if (node == null || node.isNull()) return null;
+        Map<String, Object> m = new LinkedHashMap<>();
+        node.fields().forEachRemaining(e -> {
+            if (e.getValue().isBoolean()) m.put(e.getKey(), e.getValue().asBoolean());
+            else if (e.getValue().isNumber()) m.put(e.getKey(), e.getValue().asDouble());
+            else m.put(e.getKey(), e.getValue().asText());
+        });
+        return m;
+    }
 }

+ 78 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportTemplateService.java

@@ -0,0 +1,78 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.ReportTemplate;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.ReportTemplateMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class ReportTemplateService {
+
+    @Resource
+    private ReportTemplateMapper reportTemplateMapper;
+
+    @Resource
+    private IndicatorDefinitionMapper indicatorDefinitionMapper;
+
+    public List<ReportTemplate> list(String reportType, Integer isActive) {
+        LambdaQueryWrapper<ReportTemplate> qw = new LambdaQueryWrapper<>();
+        if (reportType != null && !reportType.isEmpty()) {
+            qw.eq(ReportTemplate::getReportType, reportType);
+        }
+        if (isActive != null) {
+            qw.eq(ReportTemplate::getIsActive, isActive);
+        }
+        qw.orderByDesc(ReportTemplate::getId);
+        return reportTemplateMapper.selectList(qw);
+    }
+
+    public ReportTemplate getByReportType(String reportType) {
+        return reportTemplateMapper.selectOne(
+                new LambdaQueryWrapper<ReportTemplate>()
+                        .eq(ReportTemplate::getReportType, reportType));
+    }
+
+    public ReportTemplate save(ReportTemplate template) {
+        if (template.getId() != null) {
+            template.setUpdatedAt(new Date());
+            reportTemplateMapper.updateById(template);
+        } else {
+            template.setIsActive(template.getIsActive() != null ? template.getIsActive() : 1);
+            template.setConfig(template.getConfig() != null ? template.getConfig() : "{}");
+            template.setCreatedAt(new Date());
+            template.setUpdatedAt(new Date());
+            reportTemplateMapper.insert(template);
+        }
+        return template;
+    }
+
+    public void delete(Long id) {
+        reportTemplateMapper.deleteById(id);
+    }
+
+    public void toggle(Long id, Integer isActive) {
+        ReportTemplate t = reportTemplateMapper.selectById(id);
+        if (t != null) {
+            t.setIsActive(isActive);
+            t.setUpdatedAt(new Date());
+            reportTemplateMapper.updateById(t);
+        }
+    }
+
+    /** 获取可选指标列表,按领域过滤 */
+    public List<IndicatorDefinition> getAvailableIndicators(String domain) {
+        LambdaQueryWrapper<IndicatorDefinition> qw = new LambdaQueryWrapper<>();
+        if (domain != null && !domain.isEmpty()) {
+            qw.eq(IndicatorDefinition::getDomain, domain);
+        }
+        qw.eq(IndicatorDefinition::getStatus, 1);
+        qw.orderByAsc(IndicatorDefinition::getSortOrder);
+        return indicatorDefinitionMapper.selectList(qw);
+    }
+}

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

@@ -4447,10 +4447,23 @@ CREATE TABLE IF NOT EXISTS report_unknown_upload (
     file_url VARCHAR(500) DEFAULT NULL COMMENT '文件路径',
     file_hash VARCHAR(64) DEFAULT NULL COMMENT '文件哈希',
     llm_result TEXT COMMENT 'LLM解析结果JSON',
+    annotation TEXT COMMENT '管理员标注: 手动指定类型+确认提取结果JSON',
     uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     INDEX idx_unknown_cluster (cluster_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='未知报告上传明细表';
 
+-- ========== 报告展示模板表 ==========
+CREATE TABLE IF NOT EXISTS report_template (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    name VARCHAR(100) NOT NULL COMMENT '模板名称',
+    report_type VARCHAR(50) NOT NULL COMMENT '报告类型: gut_flora/dan/physical_exam/tongue',
+    config JSON NOT NULL COMMENT '模板配置JSON: fixedBlocks+freeDisplay',
+    is_active TINYINT DEFAULT 1 COMMENT '0=停用 1=启用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_report_type (report_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告展示模板';
+
 -- 报告解析器导入记录表
 CREATE TABLE IF NOT EXISTS report_parser_import (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 26 - 0
cfc-web/src/api/reportTemplate.js

@@ -0,0 +1,26 @@
+import request from '@/utils/request'
+
+// 报告模板管理
+export function getReportTemplates(params) {
+  return request({ url: '/api/admin/report-template/list', method: 'post', data: params })
+}
+
+export function saveReportTemplate(data) {
+  return request({ url: '/api/admin/report-template/save', method: 'post', data })
+}
+
+export function deleteReportTemplate(id) {
+  return request({ url: '/api/admin/report-template/delete', method: 'post', params: { id } })
+}
+
+export function toggleReportTemplate(data) {
+  return request({ url: '/api/admin/report-template/toggle', method: 'post', data })
+}
+
+export function getReportTemplateByType(reportType) {
+  return request({ url: '/api/admin/report-template/get', method: 'post', params: { reportType } })
+}
+
+export function getAvailableIndicators(params) {
+  return request({ url: '/api/admin/report-template/indicators', method: 'post', data: params })
+}

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

@@ -743,6 +743,18 @@ const routes = [
         component: () => import('@/views/admin/ReportParserImport.vue'),
         meta: { title: '解析器导入管理', perm: 'config:report-parser' }
       },
+      {
+        path: 'report-collector-trainer',
+        name: 'ReportCollectorTrainer',
+        component: () => import('@/views/admin/ReportCollectorTrainer.vue'),
+        meta: { title: '报告采集训练', perm: 'config:report-parser' }
+      },
+      {
+        path: 'report-template',
+        name: 'ReportTemplateManage',
+        component: () => import('@/views/admin/ReportTemplateManage.vue'),
+        meta: { title: '报告模板管理', perm: 'config:report-parser' }
+      },
       {
         path: 'report-unknown-clusters',
         name: 'ReportUnknownCluster',

+ 2 - 0
cfc-web/src/views/Layout.vue

@@ -251,6 +251,8 @@ export default {
             { path: '/report-parser-import', label: '解析器导入', icon: 'el-icon-upload2', perm: 'config:report-parser' },
             { path: '/report-unknown-clusters', label: '未知报告审核', icon: 'el-icon-question', perm: 'config:report-parser' },
             { path: '/report-auto-learn', label: '报告自学习', icon: 'el-icon-magic-stick', perm: 'config:report-parser' },
+            { path: '/report-collector-trainer', label: '报告采集训练', icon: 'el-icon-film', perm: 'config:report-parser' },
+            { path: '/report-template', label: '报告模板管理', icon: 'el-icon-setting', perm: 'config:report-parser' },
             { path: '/langgraph-admin', label: 'LangGraph 管理', icon: 'el-icon-cpu', perm: 'config:report-parser' },
           ]},
 

+ 268 - 0
cfc-web/src/views/admin/ReportCollectorTrainer.vue

@@ -0,0 +1,268 @@
+<template>
+  <div class="report-trainer admin-page">
+    <el-row :gutter="16">
+      <!-- 左侧:聚类列表 -->
+      <el-col :span="8">
+        <el-card>
+          <div slot="header">
+            <span>报告采集训练</span>
+            <el-select v-model="statusFilter" size="small" style="width:120px;float:right" @change="fetchClusters">
+              <el-option label="全部" value="" />
+              <el-option label="收集中" value="collecting" />
+              <el-option label="可生成" value="ready" />
+              <el-option label="已生成" value="generated" />
+            </el-select>
+          </div>
+          <el-table :data="clusters" v-loading="loading" border stripe :max-height="tableHeight" @row-click="selectCluster">
+            <el-table-column prop="id" label="ID" width="50" />
+            <el-table-column prop="clusterKey" label="聚类键" width="120" show-overflow-tooltip />
+            <el-table-column prop="reportCount" label="报告数" width="70" />
+            <el-table-column label="状态" width="80">
+              <template slot-scope="{ row }">
+                <el-tag :type="clusterStatusType(row.status)" size="small">{{ clusterStatusText(row.status) }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="80" fixed="right">
+              <template slot-scope="{ row }">
+                <el-button size="mini" type="text" @click.stop="handleGenerate(row)" v-if="row.status==='ready'">生成</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-card>
+      </el-col>
+
+      <!-- 右侧:详情面板 -->
+      <el-col :span="16">
+        <el-card v-if="currentCluster">
+          <div slot="header">
+            <span>{{ currentCluster.clusterKey }}({{ currentCluster.reportCount }} 份报告)</span>
+            <el-tag :type="clusterStatusType(currentCluster.status)" size="small" style="margin-left:8px">{{ clusterStatusText(currentCluster.status) }}</el-tag>
+          </div>
+
+          <el-table :data="uploads" v-loading="uploadLoading" border stripe :max-height="300" @row-click="selectUpload">
+            <el-table-column prop="id" label="ID" width="50" />
+            <el-table-column label="文件" min-width="200">
+              <template slot-scope="{ row }">
+                <a :href="row.fileUrl" target="_blank">{{ row.fileUrl }}</a>
+              </template>
+            </el-table-column>
+            <el-table-column prop="uploadedAt" label="上传时间" width="170">
+              <template slot-scope="{ row }">{{ formatTime(row.uploadedAt) }}</template>
+            </el-table-column>
+            <el-table-column label="标注" width="100">
+              <template slot-scope="{ row }">
+                <el-tag v-if="row.annotation" size="small" type="success">已标注</el-tag>
+                <el-tag v-else size="small" type="info">未标注</el-tag>
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-card>
+        <el-card v-else>
+          <div style="text-align:center;padding:80px;color:#bbb">
+            <i class="el-icon-document" style="font-size:48px"></i>
+            <p style="margin-top:16px">请从左侧选择一个聚类</p>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 标注对话框 -->
+    <el-dialog :title="'标注报告 #' + (selectedUpload ? selectedUpload.id : '')" :visible.sync="annotateVisible" width="800px" :close-on-click-modal="false">
+      <div v-loading="annotateLoading">
+        <el-descriptions :column="2" border size="small" v-if="selectedUpload">
+          <el-descriptions-item label="文件">{{ selectedUpload.fileUrl }}</el-descriptions-item>
+          <el-descriptions-item label="上传时间">{{ formatTime(selectedUpload.uploadedAt) }}</el-descriptions-item>
+        </el-descriptions>
+
+        <el-divider content-position="left">LLM 解析结果</el-divider>
+        <pre class="llm-result">{{ formatJson(selectedLlmResult) }}</pre>
+
+        <el-divider content-position="left">模板预览</el-divider>
+        <div v-if="previewBlocks.length" class="trainer-preview">
+          <div v-for="(block, bi) in previewBlocks" :key="bi" class="trainer-block">
+            <div class="trainer-block-title">{{ block.title }}</div>
+            <div v-if="block.type === 'score'" class="trainer-score">
+              <span v-for="item in block.items" :key="item.label" class="trainer-score-item">{{ item.label }}: {{ item.value }}</span>
+            </div>
+            <div v-else-if="block.type === 'indicator'" class="trainer-indicator">
+              <div v-for="(item, ii) in block.items" :key="ii" class="trainer-ind-item">
+                <span class="t-ind-name">{{ item.name }}</span>
+                <span class="t-ind-val">{{ item.value }} {{ item.unit }}</span>
+              </div>
+            </div>
+            <div v-else class="trainer-text">{{ block.content || '(空)' }}</div>
+          </div>
+        </div>
+        <div v-else class="empty-hint">暂无模板预览(请先保存该报告类型的模板)</div>
+
+        <el-divider content-position="left">手动指定报告类型</el-divider>
+        <el-form label-width="100px">
+          <el-form-item label="报告类型">
+            <el-select v-model="annotateForm.typeId" placeholder="选择类型" style="width:100%" @change="onTypeChange">
+              <el-option v-for="t in allTypes" :key="t.typeId" :label="t.displayName + ' (' + t.typeId + ')'" :value="t.typeId" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="确认标注">
+            <el-switch v-model="annotateForm.confirmed" :active-value="true" :inactive-value="false" />
+            <span class="hint-text">确认后作为训练数据纳入</span>
+          </el-form-item>
+        </el-form>
+      </div>
+      <div slot="footer">
+        <el-button @click="annotateVisible = false">取消</el-button>
+        <el-button type="primary" :loading="savingAnnotation" @click="saveAnnotation">保存标注</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getUnknownClusters, listUploads, generateTypeFromCluster } from '@/api/reportParser'
+import { getReportTemplates } from '@/api/reportTemplate'
+
+export default {
+  name: 'ReportCollectorTrainer',
+  data() {
+    return {
+      loading: false,
+      uploadLoading: false,
+      annotateLoading: false,
+      savingAnnotation: false,
+      statusFilter: '',
+      clusters: [],
+      currentCluster: null,
+      uploads: [],
+      selectedUpload: null,
+      selectedLlmResult: null,
+      annotateVisible: false,
+      annotateForm: { typeId: '', confirmed: false },
+      allTypes: [],
+      previewBlocks: []
+    }
+  },
+  computed: {
+    tableHeight() {
+      return window.innerHeight - 200
+    }
+  },
+  mounted() { this.fetchClusters() },
+  methods: {
+    fetchClusters() {
+      this.loading = true
+      getUnknownClusters({ page: 1, size: 100, status: this.statusFilter || undefined }).then(res => {
+        this.clusters = res.data && res.data.records ? res.data.records : (res.data || [])
+      }).catch(() => {}).finally(() => { this.loading = false })
+    },
+    fetchAllTypes() {
+      getReportTemplates({}).then(res => {
+        this.allTypes = (res.data || []).map(t => ({ typeId: t.reportType, displayName: t.name }))
+      }).catch(() => {})
+    },
+    selectCluster(row) {
+      this.currentCluster = row
+      this.uploads = []
+      this.selectedUpload = null
+      this.uploadLoading = true
+      listUploads({ clusterId: row.id }).then(res => {
+        this.uploads = res.data || []
+      }).catch(() => {}).finally(() => { this.uploadLoading = false })
+    },
+    selectUpload(row) {
+      this.selectedUpload = row
+      try {
+        this.selectedLlmResult = row.llmResult ? JSON.parse(row.llmResult) : {}
+      } catch (e) {
+        this.selectedLlmResult = {}
+      }
+      this.annotateForm = { typeId: '', confirmed: false }
+      this.previewBlocks = []
+      this.annotateVisible = true
+      this.fetchAllTypes()
+    },
+    onTypeChange() {
+      // 根据选定类型预览模板渲染效果
+      this.previewBlocks = this.renderPreview(this.annotateForm.typeId, this.selectedLlmResult)
+    },
+    renderPreview(reportType, llmResult) {
+      // 简化的预览渲染
+      if (!reportType) return []
+      var blocks = []
+      var payload = llmResult.payload || llmResult
+      if (payload.summary) {
+        var items = []
+        if (payload.summary.overallScore != null) items.push({ label: '综合', value: payload.summary.overallScore })
+        if (payload.summary.gutHealthScore != null) items.push({ label: '菌群健康', value: payload.summary.gutHealthScore })
+        if (items.length) blocks.push({ type: 'score', title: '健康评分', items: items })
+      }
+      if (payload.indicators && payload.indicators.length) {
+        blocks.push({ type: 'indicator', title: '指标详情', items: payload.indicators })
+      }
+      return blocks
+    },
+    handleGenerate(row) {
+      this.$confirm('确定由此聚类生成报告类型?', '提示', { type: 'warning' }).then(() => {
+        generateTypeFromCluster({ clusterId: row.id }).then(res => {
+          this.$message.success('类型已生成: ' + (res.data && res.data.typeId))
+          this.fetchClusters()
+        }).catch(() => { this.$message.error('生成失败') })
+      }).catch(() => {})
+    },
+    saveAnnotation() {
+      if (!this.selectedUpload) return
+      this.savingAnnotation = true
+      var annotation = JSON.stringify({
+        typeId: this.annotateForm.typeId,
+        confirmed: this.annotateForm.confirmed,
+        llmResult: this.selectedLlmResult
+      })
+      // 通过 report-parser/unknown-uploads/update 更新标注
+      this.$http.post('/api/admin/report-parser/unknown-updates/update', {
+        id: this.selectedUpload.id,
+        annotation: annotation
+      }).then(res => {
+        if (res.code === 200) {
+          this.$message.success('标注已保存')
+          this.annotateVisible = false
+          this.selectCluster(this.currentCluster)
+        } else {
+          this.$message.error(res.message || '保存失败')
+        }
+      }).catch(() => { this.$message.error('保存失败') }).finally(() => { this.savingAnnotation = false })
+    },
+    clusterStatusType(s) {
+      if (s === 'ready') return 'warning'
+      if (s === 'generated' || s === 'imported') return 'success'
+      return 'info'
+    },
+    clusterStatusText(s) {
+      var map = { collecting: '收集中', ready: '可生成', generated: '已生成', imported: '已导入' }
+      return map[s] || s
+    },
+    formatJson(str) {
+      if (!str) return ''
+      try { return JSON.stringify(JSON.parse(str), null, 2) } catch (e) { return str }
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      return t.replace('T', ' ').substring(0, 19)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.report-trainer { padding: 16px; }
+.llm-result { background: #f5f7fa; padding: 12px; border-radius: 4px; font-size: 13px; max-height: 300px; overflow: auto; white-space: pre-wrap; }
+.hint-text { margin-left: 8px; color: #999; font-size: 12px; }
+.trainer-preview { border: 1px solid #ebeef5; border-radius: 8px; padding: 12px; margin-top: 8px; }
+.trainer-block { margin-bottom: 12px; }
+.trainer-block-title { font-weight: bold; font-size: 14px; color: #303133; margin-bottom: 6px; }
+.trainer-score { display: flex; flex-wrap: wrap; gap: 8px; }
+.trainer-score-item { background: #f0f9ff; padding: 4px 12px; border-radius: 4px; font-size: 13px; }
+.trainer-indicator { max-height: 200px; overflow-y: auto; }
+.trainer-ind-item { display: flex; padding: 4px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px; }
+.t-ind-name { flex: 1; color: #333; }
+.t-ind-val { color: #666; margin-left: 12px; }
+.trainer-text { font-size: 13px; color: #555; line-height: 1.6; }
+.empty-hint { color: #bbb; text-align: center; padding: 20px; }
+</style>

+ 428 - 0
cfc-web/src/views/admin/ReportTemplateManage.vue

@@ -0,0 +1,428 @@
+<template>
+  <div class="report-template-page admin-page">
+    <!-- Tab 1: 模板列表 -->
+    <el-card v-show="activeTab === 'list'">
+      <div slot="header">
+        <span>报告模板管理</span>
+        <el-button size="small" type="primary" @click="openEditor(null)">+ 新增模板</el-button>
+      </div>
+      <el-table :data="templates" v-loading="loading" border stripe :max-height="tableHeight">
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="name" label="模板名称" min-width="160" />
+        <el-table-column prop="reportType" label="报告类型" width="140">
+          <template slot-scope="{ row }">{{ typeLabel(row.reportType) }}</template>
+        </el-table-column>
+        <el-table-column label="状态" width="80">
+          <template slot-scope="{ row }">
+            <el-tag :type="row.isActive === 1 ? 'success' : 'info'" size="small">
+              {{ row.isActive === 1 ? '启用' : '停用' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="updatedAt" label="更新时间" width="170">
+          <template slot-scope="{ row }">{{ formatTime(row.updatedAt) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="200" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button size="mini" @click="openEditor(row)">编辑</el-button>
+            <el-button size="mini" :type="row.isActive === 1 ? 'warning' : 'success'" @click="handleToggle(row)">
+              {{ row.isActive === 1 ? '停用' : '启用' }}
+            </el-button>
+            <el-button size="mini" type="danger" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- Tab 2: 模板编辑器 -->
+    <el-card v-show="activeTab === 'editor'">
+      <div slot="header">
+        <span>{{ isEdit ? '编辑模板' : '新增模板' }}</span>
+        <el-button size="small" @click="activeTab = 'list'">返回列表</el-button>
+      </div>
+
+      <el-form ref="form" :model="form" label-width="110px" style="max-width:900px">
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="模板名称" required>
+              <el-input v-model="form.name" placeholder="如:肠道菌群标准报告" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="报告类型" required>
+              <el-select v-model="form.reportType" placeholder="选择报告类型" style="width:100%">
+                <el-option label="肠道菌群 (gut_flora)" value="gut_flora" />
+                <el-option label="DAN测评 (dan)" value="dan" />
+                <el-option label="体检报告 (physical_exam)" value="physical_exam" />
+                <el-option label="舌诊报告 (tongue)" value="tongue" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <!-- 固定块编辑区 -->
+        <el-divider content-position="left">固定展示块(按顺序渲染)</el-divider>
+        <div class="block-list">
+          <div v-for="(block, bIdx) in form.fixedBlocks" :key="bIdx" class="block-item">
+            <div class="block-header">
+              <span class="block-type-tag">{{ blockTypeLabel(block.type) }}</span>
+              <span class="block-title-display">{{ block.title || '(无标题)' }}</span>
+              <el-button size="mini" type="text" class="block-move-up" @click="moveBlock(bIdx, -1)" :disabled="bIdx === 0">↑</el-button>
+              <el-button size="mini" type="text" class="block-move-dn" @click="moveBlock(bIdx, 1)" :disabled="bIdx === form.fixedBlocks.length - 1">↓</el-button>
+              <el-button size="mini" type="text" class="block-del" @click="removeBlock(bIdx)">删除</el-button>
+            </div>
+            <div class="block-body">
+              <!-- score 块配置 -->
+              <template v-if="block.type === 'score'">
+                <el-form-item label="分数字段">
+                  <el-checkbox-group v-model="block.scoreFields">
+                    <el-checkbox label="overallScore">综合评分</el-checkbox>
+                    <el-checkbox label="gutHealthScore">菌群健康</el-checkbox>
+                    <el-checkbox label="chronicDiseaseScore">慢病控制</el-checkbox>
+                    <el-checkbox label="nutritionScore">营养均衡</el-checkbox>
+                  </el-checkbox-group>
+                </el-form-item>
+              </template>
+
+              <!-- indicator 块配置 -->
+              <template v-if="block.type === 'indicator'">
+                <el-form-item label="分组标签">
+                  <el-input v-model="block.groupLabel" placeholder="如:血脂指标" style="width:200px" />
+                </el-form-item>
+                <el-form-item label="指标名称">
+                  <el-select v-model="block.indicatorNames" multiple filterable placeholder="搜索指标名称" style="width:100%">
+                    <el-option v-for="ind in indicatorList" :key="ind.name" :label="ind.name" :value="ind.name" />
+                  </el-select>
+                </el-form-item>
+              </template>
+
+              <!-- list 块配置 -->
+              <template v-if="block.type === 'list'">
+                <el-form-item label="列表来源">
+                  <el-select v-model="block.listKey" style="width:200px">
+                    <el-option label="菌种详情" value="flora" />
+                    <el-option label="益生菌种" value="probiotic" />
+                    <el-option label="分类学" value="taxonomy" />
+                    <el-option label="食物推荐" value="food" />
+                  </el-select>
+                </el-form-item>
+                <el-form-item label="块标题">
+                  <el-input v-model="block.title" placeholder="块显示标题" style="width:200px" />
+                </el-form-item>
+              </template>
+
+              <!-- text 块配置 -->
+              <template v-if="block.type === 'text'">
+                <el-form-item label="标题">
+                  <el-input v-model="block.title" style="width:200px" />
+                </el-form-item>
+                <el-form-item label="内容">
+                  <el-input v-model="block.content" type="textarea" :rows="3" />
+                </el-form-item>
+              </template>
+            </div>
+          </div>
+        </div>
+
+        <el-button size="small" type="dashed" @click="addBlock" style="margin-top:8px">+ 添加固定块</el-button>
+
+        <!-- 自由显示配置 -->
+        <el-divider content-position="left">自由显示配置</el-divider>
+        <el-form-item label="启用自由显示">
+          <el-switch v-model="form.freeDisplay.enabled" :active-value="true" :inactive-value="false" />
+          <span class="hint-text">开启后,未配置在固定块的指标将追加到末尾</span>
+        </el-form-item>
+        <el-form-item label="追加分组名" v-if="form.freeDisplay.enabled">
+          <el-input v-model="form.freeDisplay.groupName" placeholder="如:其他指标" style="width:200px" />
+        </el-form-item>
+
+        <el-form-item>
+          <el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
+          <el-button @click="handlePreview">预览效果</el-button>
+        </el-formItem>
+      </el-form>
+    </el-card>
+
+    <!-- 预览对话框 -->
+    <el-dialog title="模板预览" :visible.sync="previewVisible" width="700px">
+      <div v-loading="previewLoading">
+        <div v-for="(block, bi) in previewBlocks" :key="bi" class="preview-block">
+          <div class="preview-block-title">{{ block.title }}</div>
+          <!-- score -->
+          <div v-if="block.type === 'score'" class="preview-score">
+            <div v-for="item in block.items" :key="item.label" class="preview-score-item">
+              <span>{{ item.label }}: {{ item.value }}</span>
+            </div>
+          </div>
+          <!-- indicator -->
+          <div v-else-if="block.type === 'indicator'" class="preview-indicator">
+            <div v-for="(item, ii) in block.items" :key="ii" class="preview-ind-item">
+              <span class="ind-name">{{ item.name }}</span>
+              <span class="ind-value">{{ item.value }} {{ item.unit }}</span>
+              <span class="ind-status" :class="'status-' + item.status">{{ item.status }}</span>
+            </div>
+          </div>
+          <!-- list -->
+          <div v-else-if="block.type === 'list'" class="preview-list">
+            <table class="preview-table">
+              <thead>
+                <tr v-for="(col, ci) in block.columns" :key="ci">
+                  <th>{{ col.label }}</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="(item, li) in block.items" :key="li">
+                  <td v-for="(col, ci) in block.columns" :key="ci">{{ item[col.key] || '--' }}</td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+          <!-- text -->
+          <div v-else class="preview-text">{{ block.content }}</div>
+        </div>
+        <div v-if="!previewBlocks.length" class="empty-hint">暂无数据,请先保存模板</div>
+      </div>
+      <div slot="footer">
+        <el-button @click="previewVisible = false">关闭</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getReportTemplates, saveReportTemplate, deleteReportTemplate, toggleReportTemplate, getAvailableIndicators } from '@/api/reportTemplate'
+
+export default {
+  name: 'ReportTemplateManage',
+    components: {},
+  data() {
+    return {
+      activeTab: 'list',
+      loading: false,
+      saving: false,
+      templates: [],
+      indicatorList: [],
+      isEdit: false,
+      previewVisible: false,
+      previewLoading: false,
+      previewBlocks: [],
+      form: this.emptyForm()
+    }
+  },
+  computed: {
+    tableHeight() {
+      return window.innerHeight - 280
+    }
+  },
+  mounted() { this.fetchTemplates() },
+  methods: {
+    emptyForm() {
+      return {
+        id: null,
+        name: '',
+        reportType: 'gut_flora',
+        fixedBlocks: [
+          { type: 'score', title: '健康评分', scoreFields: ['overallScore', 'gutHealthScore', 'chronicDiseaseScore'] },
+          { type: 'indicator', title: '指标', groupLabel: '血脂指标', indicatorNames: [] }
+        ],
+        freeDisplay: { enabled: true, groupName: '其他指标' }
+      }
+    },
+    fetchTemplates() {
+      this.loading = true
+      getReportTemplates({}).then(res => {
+        this.templates = res.data || []
+      }).finally(() => { this.loading = false })
+    },
+    fetchIndicators() {
+      getAvailableIndicators({ domain: '' }).then(res => {
+        this.indicatorList = res.data || []
+      }).catch(() => {})
+    },
+    typeLabel(rt) {
+      const map = { gut_flora: '肠道菌群', dan: 'DAN测评', physical_exam: '体检报告', tongue: '舌诊' }
+      return map[rt] || rt
+    },
+    blockTypeLabel(t) {
+      const map = { score: '评分', indicator: '指标', list: '列表', text: '文本' }
+      return map[t] || t
+    },
+    openEditor(row) {
+      this.fetchIndicators()
+      if (row) {
+        this.isEdit = true
+        try {
+          var cfg = JSON.parse(row.config || '{}')
+          this.form = {
+            id: row.id,
+            name: row.name,
+            reportType: row.reportType,
+            fixedBlocks: cfg.fixedBlocks || [],
+            freeDisplay: cfg.freeDisplay || { enabled: true, groupName: '其他指标' }
+          }
+        } catch (e) {
+          this.form = this.emptyForm()
+        }
+      } else {
+        this.isEdit = false
+        this.form = this.emptyForm()
+      }
+      this.activeTab = 'editor'
+    },
+    addBlock() {
+      this.form.fixedBlocks.push({ type: 'indicator', title: '新块', groupLabel: '', indicatorNames: [] })
+    },
+    removeBlock(idx) {
+      this.form.fixedBlocks.splice(idx, 1)
+    },
+    moveBlock(idx, dir) {
+      var list = this.form.fixedBlocks
+      var newIdx = idx + dir
+      if (newIdx < 0 || newIdx >= list.length) return
+      var tmp = list[idx]
+      list[idx] = list[newIdx]
+      list[newIdx] = tmp
+    },
+    handleSave() {
+      if (!this.form.name || !this.form.reportType) {
+        this.$message.error('请填写名称和报告类型')
+        return
+      }
+      this.saving = true
+      var config = {
+        fixedBlocks: this.form.fixedBlocks.map(function(b) {
+          var block = { type: b.type, title: b.title || '' }
+          if (b.type === 'score') block.items = b.scoreFields || []
+          else if (b.type === 'indicator') { block.indicatorNames = b.indicatorNames || []; block.groupLabel = b.groupLabel || '' }
+          else if (b.type === 'list') { block.listKey = b.listKey || ''; block.title = b.title || '' }
+          else if (b.type === 'text') { block.content = b.content || '' }
+          return block
+        }),
+        freeDisplay: this.form.freeDisplay
+      }
+      var payload = Object.assign({}, this.form, { config: JSON.stringify(config) })
+      delete payload.fixedBlocks
+      delete payload.freeDisplay
+      saveReportTemplate(payload).then(res => {
+        this.$message.success('保存成功')
+        this.activeTab = 'list'
+        this.fetchTemplates()
+      }).catch(() => { this.$message.error('保存失败') }).finally(() => { this.saving = false })
+    },
+    handleDelete(row) {
+      this.$confirm('确定删除该模板?', '提示', { type: 'warning' }).then(() => {
+        deleteReportTemplate(row.id).then(() => {
+          this.$message.success('删除成功')
+          this.fetchTemplates()
+        }).catch(() => { this.$message.error('删除失败') })
+      }).catch(function() {})
+    },
+    handleToggle(row) {
+      toggleReportTemplate({ id: row.id, isActive: row.isActive === 1 ? 0 : 1 }).then(() => {
+        this.fetchTemplates()
+      }).catch(() => { this.$message.error('操作失败') })
+    },
+    handlePreview() {
+      this.previewLoading = true
+      this.previewVisible = true
+      // 模拟预览:用默认 gut_flora payload 渲染
+      var mockPayload = {
+        summary: { overallScore: 85, gutHealthScore: 78, chronicDiseaseScore: 90, nutritionScore: 72 },
+        indicators: [
+          { indicatorName: '总胆固醇', value: '5.2', unit: 'mmol/L', refRange: '3.1-5.2', status: 'normal', category: '血脂' },
+          { indicatorName: '甘油三酯', value: '1.8', unit: 'mmol/L', refRange: '0.56-1.7', status: 'high', category: '血脂' },
+          { indicatorName: '血红蛋白', value: '135', unit: 'g/L', refRange: '115-150', status: 'normal', category: '血常规' }
+        ],
+        gutFlora: [{ bacteriaName: '双歧杆菌', bacteriaValue: '4.2', normalRange: '3-6', populationLevel: 'normal' }],
+        diseaseRisks: [{ diseaseName: '心血管疾病', riskValue: '12%', riskLevel: 'low' }]
+      }
+      setTimeout(() => {
+        this.previewBlocks = this.renderPreview(this.form.fixedBlocks, mockPayload)
+        this.previewLoading = false
+      }, 300)
+    },
+    renderPreview(blocks, payload) {
+      var result = []
+      for (var i = 0; i < blocks.length; i++) {
+        var b = blocks[i]
+        if (b.type === 'score') {
+          var items = []
+          var s = payload.summary || {}
+          var fieldMap = { overallScore: '综合', gutHealthScore: '菌群健康', chronicDiseaseScore: '慢病控制', nutritionScore: '营养均衡' }
+          if (b.items) {
+            for (var j = 0; j < b.items.length; j++) {
+              var f = b.items[j]
+              if (s[f] != null) items.push({ label: fieldMap[f] || f, value: s[f] })
+            }
+          }
+          if (items.length > 0) result.push({ type: 'score', title: b.title, items: items })
+        } else if (b.type === 'indicator') {
+          var filtered = (payload.indicators || []).filter(function(ind) {
+            return b.indicatorNames && b.indicatorNames.indexOf(ind.indicatorName) >= 0
+          })
+          if (filtered.length > 0) result.push({ type: 'indicator', title: b.groupLabel || b.title, items: filtered })
+        } else if (b.type === 'list') {
+          if (b.listKey === 'flora' && payload.gutFlora && payload.gutFlora.length) {
+            result.push({ type: 'list', title: b.title, columns: [{ key: 'bacteriaName', label: '菌种' }, { key: 'bacteriaValue', label: '数值' }, { key: 'normalRange', label: '正常范围' }, { key: 'populationLevel', label: '状态' }], items: payload.gutFlora })
+          }
+        } else if (b.type === 'text') {
+          result.push({ type: 'text', title: b.title, content: b.content })
+        }
+      }
+      // 自由显示
+      if (this.form.freeDisplay && this.form.freeDisplay.enabled) {
+        var remaining = (payload.indicators || []).filter(function(ind) {
+          for (var j = 0; j < blocks.length; j++) {
+            if (blocks[j].type === 'indicator' && blocks[j].indicatorNames && blocks[j].indicatorNames.indexOf(ind.indicatorName) >= 0) return false
+          }
+          return true
+        })
+        if (remaining.length > 0) {
+          result.push({ type: 'indicator', title: this.form.freeDisplay.groupName || '其他指标', items: remaining })
+        }
+      }
+      return result
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      return t.replace('T', ' ').substring(0, 19)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.report-template-page { padding: 20px; }
+.block-list { border: 1px solid #ebeef5; border-radius: 4px; margin-bottom: 12px; }
+.block-item { border-bottom: 1px solid #f0f0f0; padding: 12px 16px; }
+.block-item:last-child { border-bottom: none; }
+.block-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
+.drag-handle { cursor: move; color: #bbb; font-size: 16px; }
+.block-type-tag { background: #f0f9ff; color: #2c7be5; padding: 2rpx 8rpx; border-radius: 4px; font-size: 12px; }
+.block-title-display { color: #333; font-size: 14px; }
+.block-del { margin-left: auto; }
+.hint-text { margin-left: 12px; color: #999; font-size: 12px; }
+.block-list { border: 1px solid #ebeef5; border-radius: 4px; margin-bottom: 12px; }
+.block-item { border-bottom: 1px solid #f0f0f0; padding: 12px 16px; }
+.block-item:last-child { border-bottom: none; }
+.block-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
+.block-type-tag { background: #f0f9ff; color: #2c7be5; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
+.block-title-display { color: #333; font-size: 14px; }
+.block-del { margin-left: auto; }
+.block-move-up, .block-move-dn { color: #909399; }
+.preview-block { background: #fff; border: 1px solid #eee; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
+.preview-block-title { font-weight: bold; font-size: 15px; margin-bottom: 10px; color: #303133; }
+.preview-score-item { display: inline-block; background: #f5f7fa; padding: 4px 12px; margin: 4px; border-radius: 4px; }
+.preview-ind-item { display: flex; align-items: center; padding: 6px 0; border-bottom: 1px solid #f0f0f0; }
+.ind-name { flex: 1; font-size: 13px; }
+.ind-value { font-size: 13px; color: #666; margin: 0 12px; }
+.ind-status { font-size: 12px; padding: 2px 8px; border-radius: 10px; }
+.status-normal { background: #e8f5e9; color: #2e7d32; }
+.status-high { background: #ffebee; color: #c62828; }
+.status-low { background: #fff3e0; color: #e65100; }
+.preview-table { width: 100%; border-collapse: collapse; font-size: 13px; }
+.preview-table th, .preview-table td { border: 1px solid #eee; padding: 6px 10px; text-align: left; }
+.preview-table th { background: #f5f7fa; font-weight: 600; }
+.preview-text { font-size: 14px; color: #555; line-height: 1.7; white-space: pre-wrap; }
+.empty-hint { color: #bbb; text-align: center; padding: 40px; }
+</style>

+ 27 - 2
docs/superpowers/api/API_REFERENCE.md

@@ -402,6 +402,7 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `/api/admin/package-templates/*` | 套餐模板 |
 | `/api/admin/product/*` | 商品管理 |
 | `/api/admin/report-parser/*` | 报告解析配置 |
+| `/api/admin/report-template/*` | 报告展示模板管理 |
 | `/api/admin/supply-*` | 供应链相关 |
 | `/api/admin/system/*` | 系统配置 |
 | `/api/admin/unlock-gates/*` | 通关配置 |
@@ -419,7 +420,31 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/notification/list` | 通知列表 |
 | `POST /api/notification/read` | 标记已读 |
 
-### 4.15 其他接口
+### 4.15 报告模板管理(`/api/admin/report-template`)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/admin/report-template/list` | 模板列表,支持 `reportType`/`isActive` 过滤 |
+| `POST /api/admin/report-template/get` | 按 reportType 获取模板,参数 `reportType` |
+| `POST /api/admin/report-template/save` | 保存模板(创建或更新),body 含 `id/name/reportType/config` |
+| `POST /api/admin/report-template/delete` | 删除模板,参数 `id` |
+| `POST /api/admin/report-template/toggle` | 启停模板,body `{ id, isActive }` |
+| `POST /api/admin/report-template/indicators` | 获取可选指标列表,支持 `domain` 过滤 |
+
+**config JSON 结构:**
+```json
+{
+  "fixedBlocks": [
+    { "type": "score", "title": "健康评分", "items": ["overallScore","gutHealthScore"] },
+    { "type": "indicator", "title": "血脂指标", "groupLabel": "血脂", "indicatorNames": ["总胆固醇","甘油三酯"] },
+    { "type": "list", "title": "菌种详情", "listKey": "flora" },
+    { "type": "text", "title": "评语", "content": "..." }
+  ],
+  "freeDisplay": { "enabled": true, "groupName": "其他指标" }
+}
+```
+
+### 4.16 其他接口
 
 | 路径 | 说明 |
 |------|------|
@@ -467,4 +492,4 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 
 ---
 
-*文档最后更新:2026-08-18*
+*文档最后更新:2026-08-22*

+ 212 - 0
docs/superpowers/specs/feature-report-template-and-trainer.md

@@ -0,0 +1,212 @@
+# 报告指标模板管理 & 采集训练器
+
+**优先级:** P1
+**预计工时:** 1天
+**创建日期:** 2026-08-22
+
+---
+
+## 一、背景
+
+小程序端报告页面已实现「固定模板 + 自由显示」展示模式:
+- **固定模板**:`report_blocks` 表中预置的 blocks(score/indicator/list/text),由 `ReportBlockAssembler` 生成
+- **自由显示**:解析 payload 中未出现在固定模板的指标,自动追加到末尾
+
+目前后台缺少**模板配置管理**和**采集训练**能力,导致新报告类型无法灵活配置展示方式。
+
+---
+
+## 二、用户故事
+
+| 角色 | 故事 | 验收标准 |
+|------|------|---------|
+| 管理员 | 为不同报告类型配置展示模板(固定块顺序+自由显示开关) | 能创建/编辑/启停模板;模板影响小程序报告渲染 |
+| 管理员 | 在未知报告聚类中手动标注报告类型,作为采集器训练数据 | 能看到 LLM 解析结果;能选类型;能预览模板渲染效果 |
+| 系统 | 新报告类型首次发现时自动建定义(无指纹),积累3份后生成指纹+模板 | 新类型自动创建 `report_template` 空模板;ready 状态聚类可一键生成 |
+
+---
+
+## 三、数据库设计
+
+### 3.1 新建表 `report_template`
+
+```sql
+CREATE TABLE IF NOT EXISTS report_template (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    name VARCHAR(100) NOT NULL COMMENT '模板名称',
+    report_type VARCHAR(50) NOT NULL COMMENT '报告类型: gut_flora/dan/physical_exam/tongue',
+    config JSON NOT NULL COMMENT '模板配置JSON',
+    is_active TINYINT DEFAULT 1 COMMENT '0=停用 1=启用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_report_type (report_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告展示模板';
+```
+
+### 3.2 Config JSON 结构
+
+```json
+{
+  "fixedBlocks": [
+    {
+      "type": "score",
+      "title": "健康评分",
+      "items": ["overallScore", "gutHealthScore", "chronicDiseaseScore"],
+      "order": 1
+    },
+    {
+      "type": "indicator",
+      "title": "血脂指标",
+      "indicatorCodes": ["total_cholesterol", "triglyceride", "hdl"],
+      "groupLabel": "血脂",
+      "order": 2
+    }
+  ],
+  "freeDisplay": {
+    "enabled": true,
+    "groupName": "其他指标"
+  }
+}
+```
+
+### 3.3 新增列 `report_unknown_upload.annotation`
+
+```sql
+ALTER TABLE report_unknown_upload ADD COLUMN annotation TEXT COMMENT '管理员标注: 手动指定类型+确认提取结果JSON';
+```
+
+---
+
+## 四、后端实现
+
+### 4.1 新增文件
+
+| 文件 | 说明 |
+|------|------|
+| `entity/ReportTemplate.java` | 模板实体 |
+| `mapper/ReportTemplateMapper.java` | MyBatis-Plus Mapper |
+| `service/ReportTemplateService.java` | 业务逻辑 |
+| `controller/admin/ReportTemplateController.java` | REST 接口 |
+
+### 4.2 接口清单
+
+```
+POST /api/admin/report-template/list           分页列表
+POST /api/admin/report-template/save           保存(创建或更新)
+POST /api/admin/report-template/delete         删除
+POST /api/admin/report-template/toggle         启用/停用
+POST /api/admin/report-template/get            按reportType获取
+POST /api/admin/report-template/indicators     获取可选指标列表(用于编辑器)
+```
+
+### 4.3 修改现有文件
+
+**`ReportBlockAssembler.java`** — 新增方法:
+```java
+public List<Map<String, Object>> assembleWithTemplate(String reportType, Object payload) {
+    // 1. 查询 template config
+    // 2. 按 fixedBlocks 顺序组装
+    // 3. 如果 freeDisplay.enabled,追加未配置指标
+}
+```
+
+**`ReportParserAdminController.java`** — 修改 `generateType()`:
+- 生成类型后,自动插入一条 `report_template` 记录(config 为空)
+
+**`DatabaseInitializer.java`** — 添加迁移:
+- CREATE TABLE report_template
+
+**`schema.sql`** — 追加 CREATE TABLE + ALTER TABLE report_unknown_upload
+
+---
+
+## 五、前端实现
+
+### 5.1 新增文件
+
+| 文件 | 说明 |
+|------|------|
+| `src/api/reportTemplate.js` | API 封装 |
+| `src/views/admin/ReportTemplateManage.vue` | 模板管理页 |
+| `src/views/admin/ReportCollectorTrainer.vue` | 采集训练器页 |
+
+### 5.2 ReportTemplateManage.vue 功能
+
+- Tab 1 - 模板列表:表格(名称/类型/状态),新增/编辑按钮
+- Tab 2 - 模板编辑器(弹窗):
+  - 基础信息(名称、报告类型下拉)
+  - 固定块编辑器:
+    - 块列表(可拖拽排序)
+    - 每种块类型的配置表单:
+      - score 块:选择要展示的分数字段
+      - indicator 块:选择指标编码(多选+排序)、分组标签
+      - list 块:配置 columns
+      - text 块:标题+内容
+  - 自由显示配置:开关 + 分组名 + 插入位置
+  - 保存后实时更新
+
+### 5.3 ReportCollectorTrainer.vue 功能
+
+- 左侧:未知报告聚类列表(状态筛选:collecting/ready/generated)
+- 右侧:详情面板
+  - 选中聚类 → 显示上传明细列表
+  - 点击某条上传记录 → 显示:
+    - PDF 预览(iframe embed fileUrl)
+    - LLM 解析结果(JSON 格式化展示)
+    - 类型选择下拉(所有报告类型)
+    - 模板预览(基于选定类型+解析结果,渲染 blocks 预览)
+    - 标注保存按钮(保存 admin 确认的类型和标注)
+  - 当 reportCount >= 3 且 status = ready 时,显示「生成类型」按钮
+
+---
+
+## 六、路由 & 菜单更新
+
+### 6.1 路由 `router/index.js`
+
+```js
+{
+  path: 'report-template',
+  name: 'ReportTemplateManage',
+  component: () => import('@/views/admin/ReportTemplateManage.vue'),
+  meta: { title: '报告模板管理', perm: 'config:report-parser' }
+},
+{
+  path: 'report-collector-trainer',
+  name: 'ReportCollectorTrainer',
+  component: () => import('@/views/admin/ReportCollectorTrainer.vue'),
+  meta: { title: '报告采集训练', perm: 'config:report-parser' }
+}
+```
+
+### 6.2 菜单 `Layout.vue`(报告解析系统子菜单追加)
+
+```
+报告采集训练  ─  el-icon-film  perm: config:report-parser
+报告模板管理  ─  el-icon-setting perm: config:report-parser
+```
+
+---
+
+## 七、API Reference 文档更新
+
+在 `docs/superpowers/api/API_REFERENCE.md` 的「报告解析系统」章节追加:
+
+| 路径 | 方法 | 说明 |
+|------|------|------|
+| `/api/admin/report-template/list` | POST | 模板列表 |
+| `/api/admin/report-template/save` | POST | 保存模板 |
+| `/api/admin/report-template/delete` | POST | 删除模板 |
+| `/api/admin/report-template/toggle` | POST | 启停模板 |
+| `/api/admin/report-template/get` | POST | 按类型获取 |
+| `/api/admin/report-template/indicators` | POST | 获取可选指标 |
+
+---
+
+## 八、验证步骤
+
+1. `mvn clean compile` 通过
+2. 后端启动后访问 `/api/admin/report-template/list` 返回空列表
+3. 新增模板 → 编辑固定块 → 保存 → 验证 config JSON 结构
+4. 小程序报告页面加载时,使用模板 blocks 渲染
+5. 采集训练器页面:查看聚类 → 选择报告 → 预览模板渲染 → 保存标注