2026-07-28-report-management-plan.md 63 KB

统一报告管理中心 — 实施计划

Plan author: Sisyphus (orchestrator) Date: 2026-07-28 Spec: 2026-07-28-report-management-design.md

Goal: 新建 report_summary 汇总表 + 同步机制 + 统一查询API + 小程序/Web管理端报告管理页面。

Architecture: 汇总表 report_summary 通过 source_table + source_id 关联4类源表;Service 层 ReportSummaryService 负责即时同步 + 查询;@Scheduled 定时任务兜底;小程序 pages/profile/report-management.vue + Web ReportManagement.vue 双端覆盖。

Tech Stack: Spring Boot 2.7.18 + MyBatis-Plus + Java 8 + uni-app Vue 2 + Element UI

Global Constraints

  • 接口统一 @PostMapping,禁止 @GetMapping/@PutMapping/@DeleteMapping
  • 响应统一 Result<T> (code/message/data)
  • 小程序禁用可选链 ?.(用 && 替代)、禁用 CSS Grid
  • Vue 2 Options API,禁止 Composition API
  • 定时任务使用 Spring @Scheduled
  • 汇总表通过 UNIQUE KEY uk_source (source_table, source_id) 去重
  • 源表不修改,汇总表只做 INSERT ... ON DUPLICATE KEY UPDATE

Task 1: 数据库 — 建表 + 迁移

Files:

  • Create: cfc-backend/src/main/resources/schema.sql (append)
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

Produces: report_summary 表创建 + 幂等迁移

  • Step 1: 在 cfc-backend/src/main/resources/schema.sql 末尾追加 CREATE TABLE IF NOT EXISTS report_summary 语句(使用 spec §2.1 的 SQL)
  • [ ] Step 2: 在 DatabaseInitializer.runMigrations() 末尾添加建表迁移,使用 try-catch 包裹(表已存在则忽略):

    // 创建统一报告汇总表(report_summary)
    try {
    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_summary (" +
            "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " +
            "report_type VARCHAR(32) NOT NULL COMMENT '报告类型', " +
            "source_table VARCHAR(32) NOT NULL COMMENT '来源表', " +
            "source_id BIGINT NOT NULL COMMENT '来源表记录ID', " +
            "user_id BIGINT NOT NULL COMMENT '创建者用户ID', " +
            "family_id BIGINT COMMENT '家庭ID', " +
            "subject_id BIGINT COMMENT '报告主体ID', " +
            "subject_name VARCHAR(50) COMMENT '报告主体名称', " +
            "title VARCHAR(200) COMMENT '报告标题', " +
            "summary TEXT COMMENT '摘要', " +
            "file_url VARCHAR(500) COMMENT '文件URL', " +
            "file_size BIGINT COMMENT '文件大小', " +
            "file_name VARCHAR(200) COMMENT '文件名', " +
            "has_file TINYINT DEFAULT 0 COMMENT '是否有文件', " +
            "overall_score INT COMMENT '综合评分', " +
            "report_date DATE COMMENT '报告日期', " +
            "status VARCHAR(20) COMMENT '状态', " +
            "detail_route VARCHAR(100) COMMENT '详情页路由', " +
            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
            "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
            "INDEX idx_user_id (user_id), " +
            "INDEX idx_family_id (family_id), " +
            "INDEX idx_subject_id (subject_id), " +
            "INDEX idx_report_type (report_type), " +
            "INDEX idx_report_date (report_date), " +
            "INDEX idx_status (status), " +
            "UNIQUE KEY uk_source (source_table, source_id)" +
            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一报告汇总表'");
    log.info("已创建 report_summary 表");
    } catch (Exception e) {
    log.info("report_summary 表已存在,跳过创建");
    }
    
  • [ ] Step 3: 在 schema.sql 末尾(ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一报告汇总表'; 后)添加注释 -- report_summary 表已由 DatabaseInitializer 迁移创建

  • [ ] Step 4: cd cfc-backend && mvn clean compile 验证编译通过


Task 2: 后端 — Entity + Mapper

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/entity/ReportSummary.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportSummaryMapper.java
  • Create: cfc-backend/src/main/resources/mapper/ReportSummaryMapper.xml

Produces: MyBatis-Plus 实体 + Mapper + XML 映射

  • [ ] Step 1: 创建 ReportSummary.java — 使用 Lombok @Data@TableName("report_summary")@TableId(type = IdType.AUTO)。字段与 spec §2.1 一致:

    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_summary")
    public class ReportSummary implements Serializable {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String reportType;
    private String sourceTable;
    private Long sourceId;
    private Long userId;
    private Long familyId;
    private Long subjectId;
    private String subjectName;
    private String title;
    private String summary;
    private String fileUrl;
    private Long fileSize;
    private String fileName;
    private Integer hasFile;
    private Integer overallScore;
    private Date reportDate;
    private String status;
    private String detailRoute;
    private Date createdAt;
    private Date updatedAt;
    }
    
  • [ ] Step 2: 创建 ReportSummaryMapper.java — 继承 BaseMapper<ReportSummary>

    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.ReportSummary;
    
    public interface ReportSummaryMapper extends BaseMapper<ReportSummary> {
    }
    
  • [ ] Step 3: 创建 ReportSummaryMapper.xml — 参考 ActivityFeedbackMapper.xml 的 resultMap 模式(虽然这里字段名与数据库列名驼峰一致,XML 可为空结构):

    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="com.etotem.cfc.mapper.ReportSummaryMapper">
    <resultMap id="baseMap" type="com.etotem.cfc.entity.ReportSummary">
        <id column="id" property="id"/>
        <result column="report_type" property="reportType"/>
        <result column="source_table" property="sourceTable"/>
        <result column="source_id" property="sourceId"/>
        <result column="user_id" property="userId"/>
        <result column="family_id" property="familyId"/>
        <result column="subject_id" property="subjectId"/>
        <result column="subject_name" property="subjectName"/>
        <result column="title" property="title"/>
        <result column="summary" property="summary"/>
        <result column="file_url" property="fileUrl"/>
        <result column="file_size" property="fileSize"/>
        <result column="file_name" property="fileName"/>
        <result column="has_file" property="hasFile"/>
        <result column="overall_score" property="overallScore"/>
        <result column="report_date" property="reportDate"/>
        <result column="status" property="status"/>
        <result column="detail_route" property="detailRoute"/>
        <result column="created_at" property="createdAt"/>
        <result column="updated_at" property="updatedAt"/>
    </resultMap>
    </mapper>
    
  • [ ] Step 4: cd cfc-backend && mvn clean compile 验证编译通过


Task 3: 后端 — DTO/VO

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/dto/ReportSummaryVO.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/dto/ReportListQueryDTO.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/dto/ReportSyncResultDTO.java

Produces: 列表查询入参 DTO、列表 VO、同步结果 DTO

  • [ ] Step 1: 创建 ReportListQueryDTO.java

    package com.etotem.cfc.dto;
    
    import lombok.Data;
    
    @Data
    public class ReportListQueryDTO {
    private Integer page = 1;
    private Integer size = 20;
    private Long userId;
    private String type;       // gut_flora|physical_exam|dan|insurance|fortune|all
    private String keyword;
    private Long subjectId;
    private String startDate;
    private String endDate;
    }
    
  • [ ] Step 2: 创建 ReportSummaryVO.java — 展示用,添加 reportTypeName(中文类型名):

    package com.etotem.cfc.dto;
    
    import lombok.Data;
    import java.util.Date;
    
    @Data
    public class ReportSummaryVO {
    private Long id;
    private String reportType;
    private String reportTypeName;
    private String sourceTable;
    private Long sourceId;
    private String title;
    private String subjectName;
    private String summary;
    private String fileUrl;
    private Long fileSize;
    private String fileName;
    private Boolean hasFile;
    private Integer overallScore;
    private String reportDate;
    private String status;
    private String detailRoute;
    private String detailParams;  // JSON 字符串,前端用
    private Date createdAt;
    
    public static String getReportTypeName(String reportType) {
        if (reportType == null) return "未知";
        switch (reportType) {
            case "gut_flora": return "菌群检测";
            case "physical_exam": return "体检报告";
            case "dan": return "DAN测评";
            case "insurance": return "保单";
            case "fortune": return "家庭周报";
            default: return reportType;
        }
    }
    }
    
  • [ ] Step 3: 创建 ReportSyncResultDTO.java

    package com.etotem.cfc.dto;
    
    import lombok.Data;
    
    @Data
    public class ReportSyncResultDTO {
    private int newCount;
    private int updateCount;
    private int discardedCount;
    private int errorCount;
    private String errorMsg;
    }
    
  • [ ] Step 4: mvn clean compile 验证


Task 4: 后端 — ReportSummaryService(核心)

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/service/ReportSummaryService.java

Consumes: Task 1 (表存在), Task 2 (Mapper), Task 3 (DTO)

Produces: sync*() 各源表同步方法 + pageList() 分页查询 + getDetail() + adminPageList() + fullSync()

  • [ ] Step 1: 创建 ReportSummaryService.java。注入 Mapper:

    package com.etotem.cfc.service;
    
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
    import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
    import com.etotem.cfc.dto.ReportListQueryDTO;
    import com.etotem.cfc.dto.ReportSummaryVO;
    import com.etotem.cfc.dto.ReportSyncResultDTO;
    import com.etotem.cfc.entity.ReportSummary;
    import com.etotem.cfc.entity.*;
    import com.etotem.cfc.mapper.*;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Service;
    
    import javax.annotation.Resource;
    import java.util.ArrayList;
    import java.util.Date;
    import java.util.List;
    
    @Service
    @Slf4j
    public class ReportSummaryService {
    
    @Resource
    private ReportSummaryMapper reportSummaryMapper;
    
    @Resource
    private HealthReportMapper healthReportMapper;
    
    @Resource
    private DanReportUploadMapper danReportUploadMapper;
    
    @Resource
    private InsurancePolicyMapper insurancePolicyMapper;
    
    @Resource
    private UserMapper userMapper;
    
    @Resource
    private FamilyMemberMapper familyMemberMapper;
    }
    
  • [ ] Step 2: 实现 syncFromHealthReport(HealthReport report) — 按 spec §2.2 映射:

    public void syncFromHealthReport(HealthReport report) {
    ReportSummary existing = reportSummaryMapper.selectOne(
        new LambdaQueryWrapper<ReportSummary>()
            .eq(ReportSummary::getSourceTable, "health_reports")
            .eq(ReportSummary::getSourceId, report.getId())
    );
    
    ReportSummary summary = new ReportSummary();
    summary.setReportType(report.getReportType());
    summary.setSourceTable("health_reports");
    summary.setSourceId(report.getId());
    summary.setUserId(report.getUserId());
    summary.setFamilyId(report.getFamilyId());
    summary.setSubjectId(report.getSubjectId());
    if (report.getSubjectId() != null) {
        User subject = userMapper.selectById(report.getSubjectId());
        summary.setSubjectName(subject != null ? subject.getNickname() : "");
    }
    String typeLabel = "gut_flora".equals(report.getReportType()) ? "菌群检测报告" : "体检报告";
    String name = summary.getSubjectName() != null && !summary.getSubjectName().isEmpty() ? summary.getSubjectName() : "用户";
    summary.setTitle(name + "的" + typeLabel);
    summary.setFileUrl(report.getFileUrl());
    summary.setHasFile(report.getFileUrl() != null && !report.getFileUrl().isEmpty() ? 1 : 0);
    summary.setOverallScore(report.getOverallScore());
    summary.setReportDate(report.getReportDate());
    summary.setStatus(report.getStatus());
    summary.setDetailRoute("gut_flora".equals(report.getReportType())
        ? "/pages/health/gut-flora-detail"
        : "/pages/health/physical-exam-detail");
    
    if (existing != null) {
        summary.setId(existing.getId());
        reportSummaryMapper.updateById(summary);
    } else {
        reportSummaryMapper.insert(summary);
    }
    }
    
  • [ ] Step 3: 实现 syncFromDanReport(DanReportUpload upload)

    public void syncFromDanReport(DanReportUpload upload) {
    ReportSummary existing = reportSummaryMapper.selectOne(
        new LambdaQueryWrapper<ReportSummary>()
            .eq(ReportSummary::getSourceTable, "dan_report_uploads")
            .eq(ReportSummary::getSourceId, upload.getId())
    );
    
    ReportSummary summary = new ReportSummary();
    summary.setReportType("dan");
    summary.setSourceTable("dan_report_uploads");
    summary.setSourceId(upload.getId());
    summary.setUserId(upload.getUploaderUserId());
    summary.setFamilyId(upload.getFamilyId());
    summary.setSubjectId(upload.getFamilyMemberId());
    
    FamilyMember member = familyMemberMapper.selectById(upload.getFamilyMemberId());
    summary.setSubjectName(member != null ? member.getMemberName() : "");
    
    String dimLabel = "mind".equals(upload.getDimension()) ? "心智" : "智慧";
    String name = summary.getSubjectName() != null && !summary.getSubjectName().isEmpty() ? summary.getSubjectName() : "成员";
    summary.setTitle(name + "的" + dimLabel + "测评");
    
    summary.setFileUrl(upload.getFileUrl());
    summary.setHasFile(upload.getFileUrl() != null && !upload.getFileUrl().isEmpty() ? 1 : 0);
    
    // 从 dan_assessment_results 关联查 overall_score
    if (upload.getLinkedResultId() != null) {
        DanAssessmentResult result = danAssessmentResultMapper.selectById(upload.getLinkedResultId());
        if (result != null) summary.setOverallScore(result.getOverallScore());
    }
    
    summary.setReportDate(upload.getUploadedAt());
    String status = upload.getDraftStatus();
    if ("confirmed".equals(status)) status = "confirmed";
    else if ("pending".equals(status)) status = "active";
    else if ("discarded".equals(status)) status = "discarded";
    else status = "active";
    summary.setStatus(status);
    summary.setDetailRoute("/pages/dan-assessment/report-upload");
    
    if (existing != null) {
        summary.setId(existing.getId());
        reportSummaryMapper.updateById(summary);
    } else {
        reportSummaryMapper.insert(summary);
    }
    }
    
  • [ ] Step 4: 实现 syncFromInsurancePolicy(InsurancePolicy policy)

    public void syncFromInsurancePolicy(InsurancePolicy policy) {
    ReportSummary existing = reportSummaryMapper.selectOne(
        new LambdaQueryWrapper<ReportSummary>()
            .eq(ReportSummary::getSourceTable, "insurance_policies")
            .eq(ReportSummary::getSourceId, policy.getId())
    );
    
    ReportSummary summary = new ReportSummary();
    summary.setReportType("insurance");
    summary.setSourceTable("insurance_policies");
    summary.setSourceId(policy.getId());
    summary.setUserId(policy.getUserId());
    summary.setSubjectId(policy.getChildId());
    summary.setSubjectName(policy.getInsuredPerson());
    summary.setTitle(policy.getPolicyName());
    String company = policy.getInsuranceCompany() != null ? policy.getInsuranceCompany() : "未知公司";
    String type = policy.getPolicyType() != null ? policy.getPolicyType() : "保险";
    Integer sum = policy.getSumInsured() != null ? policy.getSumInsured() : 0;
    summary.setSummary(company + " · " + type + " · 保额 ¥" + sum / 100);
    summary.setHasFile(0);
    summary.setReportDate(policy.getStartDate());
    summary.setStatus(policy.getStatus());
    summary.setDetailRoute("/pages/wealth-sub/insurance-list");
    
    if (existing != null) {
        summary.setId(existing.getId());
        reportSummaryMapper.updateById(summary);
    } else {
        reportSummaryMapper.insert(summary);
    }
    }
    
  • [ ] Step 5: 实现 syncFromFamilyFortuneReport(FamilyFortuneReport report)

    public void syncFromFamilyFortuneReport(FamilyFortuneReport report) {
    ReportSummary existing = reportSummaryMapper.selectOne(
        new LambdaQueryWrapper<ReportSummary>()
            .eq(ReportSummary::getSourceTable, "family_fortune_report")
            .eq(ReportSummary::getSourceId, report.getId())
    );
    
    ReportSummary summary = new ReportSummary();
    summary.setReportType("fortune");
    summary.setSourceTable("family_fortune_report");
    summary.setSourceId(report.getId());
    summary.setFamilyId(Long.valueOf(report.getFamilyId()));
    
    FamilyMember member = familyMemberMapper.selectOne(
        new LambdaQueryWrapper<FamilyMember>()
            .eq(FamilyMember::getFamilyId, report.getFamilyId())
            .eq(FamilyMember::getRole, "parent")
            .last("LIMIT 1")
    );
    summary.setUserId(member != null ? member.getUserId() : null);
    
    String element = report.getElement() != null ? report.getElement() : "天盘";
    summary.setTitle(element + "天盘周报");
    summary.setSummary(report.getLuckyDirection() != null ? report.getLuckyDirection() : "");
    summary.setFileUrl(report.getPdfPath());
    summary.setFileSize(report.getFileSize());
    summary.setHasFile(report.getPdfPath() != null && !report.getPdfPath().isEmpty() ? 1 : 0);
    summary.setReportDate(report.getCreatedAt());
    summary.setStatus("active");
    summary.setDetailRoute("/pages/tianpan/index");
    
    if (existing != null) {
        summary.setId(existing.getId());
        reportSummaryMapper.updateById(summary);
    } else {
        reportSummaryMapper.insert(summary);
    }
    }
    
  • [ ] Step 6: 实现 markDiscarded(String sourceTable, Long sourceId) — 源表删除时调用:

    public void markDiscarded(String sourceTable, Long sourceId) {
    reportSummaryMapper.update(
        null,
        new LambdaUpdateWrapper<ReportSummary>()
            .eq(ReportSummary::getSourceTable, sourceTable)
            .eq(ReportSummary::getSourceId, sourceId)
            .set(ReportSummary::getStatus, "discarded")
    );
    }
    
  • [ ] Step 7: 实现 pageList(ReportListQueryDTO query) — 小程序端分页查询,过滤当前用户 + 家庭:

    public Page<ReportSummaryVO> pageList(ReportListQueryDTO query) {
    Page<ReportSummary> page = new Page<>(query.getPage(), query.getSize());
    
    LambdaQueryWrapper<ReportSummary> wrapper = new LambdaQueryWrapper<>();
    wrapper.eq(ReportSummary::getUserId, query.getUserId());
    wrapper.ne(ReportSummary::getStatus, "discarded");
    
    if ("all".equals(query.getType()) || query.getType() == null || query.getType().isEmpty()) {
        // 不限类型
    } else {
        wrapper.eq(ReportSummary::getReportType, query.getType());
    }
    
    if (query.getKeyword() != null && !query.getKeyword().isEmpty()) {
        wrapper.and(w -> w
            .like(ReportSummary::getTitle, query.getKeyword())
            .or().like(ReportSummary::getSubjectName, query.getKeyword())
        );
    }
    
    if (query.getSubjectId() != null) {
        wrapper.eq(ReportSummary::getSubjectId, query.getSubjectId());
    }
    
    wrapper.orderByDesc(ReportSummary::getReportDate);
    reportSummaryMapper.selectPage(page, wrapper);
    
    Page<ReportSummaryVO> voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
    List<ReportSummaryVO> voList = new ArrayList<>();
    for (ReportSummary s : page.getRecords()) {
        ReportSummaryVO vo = new ReportSummaryVO();
        vo.setId(s.getId());
        vo.setReportType(s.getReportType());
        vo.setReportTypeName(ReportSummaryVO.getReportTypeName(s.getReportType()));
        vo.setTitle(s.getTitle());
        vo.setSubjectName(s.getSubjectName());
        vo.setSummary(s.getSummary());
        vo.setFileUrl(s.getFileUrl());
        vo.setFileSize(s.getFileSize());
        vo.setFileName(s.getFileName());
        vo.setHasFile(s.getHasFile() != null && s.getHasFile() == 1);
        vo.setOverallScore(s.getOverallScore());
        vo.setReportDate(s.getReportDate() != null ? s.getReportDate().toString() : "");
        vo.setStatus(s.getStatus());
        vo.setDetailRoute(s.getDetailRoute());
        vo.setCreatedAt(s.getCreatedAt());
        voList.add(vo);
    }
    voPage.setRecords(voList);
    return voPage;
    }
    
  • [ ] Step 8: 实现 getDetail(Long summaryId) — 单条详情:

    public ReportSummaryVO getDetail(Long summaryId) {
    ReportSummary s = reportSummaryMapper.selectById(summaryId);
    if (s == null) return null;
    
    ReportSummaryVO vo = new ReportSummaryVO();
    vo.setId(s.getId());
    vo.setReportType(s.getReportType());
    vo.setReportTypeName(ReportSummaryVO.getReportTypeName(s.getReportType()));
    vo.setTitle(s.getTitle());
    vo.setSubjectName(s.getSubjectName());
    vo.setSummary(s.getSummary());
    vo.setFileUrl(s.getFileUrl());
    vo.setFileSize(s.getFileSize());
    vo.setFileName(s.getFileName());
    vo.setHasFile(s.getHasFile() != null && s.getHasFile() == 1);
    vo.setOverallScore(s.getOverallScore());
    vo.setReportDate(s.getReportDate() != null ? s.getReportDate().toString() : "");
    vo.setStatus(s.getStatus());
    vo.setDetailRoute(s.getDetailRoute());
    vo.setCreatedAt(s.getCreatedAt());
    return vo;
    }
    
  • [ ] Step 9: 实现 adminPageList(ReportListQueryDTO query) — 管理端,不限 userId:

    public Page<ReportSummaryVO> adminPageList(ReportListQueryDTO query) {
    Page<ReportSummary> page = new Page<>(query.getPage(), query.getSize());
    LambdaQueryWrapper<ReportSummary> wrapper = new LambdaQueryWrapper<>();
    wrapper.ne(ReportSummary::getStatus, "discarded");
    
    if ("all".equals(query.getType()) || query.getType() == null || query.getType().isEmpty()) {
        // 不限
    } else {
        wrapper.eq(ReportSummary::getReportType, query.getType());
    }
    
    if (query.getKeyword() != null && !query.getKeyword().isEmpty()) {
        wrapper.and(w -> w
            .like(ReportSummary::getTitle, query.getKeyword())
            .or().like(ReportSummary::getSubjectName, query.getKeyword())
            .or().like(ReportSummary::getSummary, query.getKeyword())
        );
    }
    
    if (query.getStartDate() != null && !query.getStartDate().isEmpty()) {
        wrapper.ge(ReportSummary::getReportDate, query.getStartDate());
    }
    if (query.getEndDate() != null && !query.getEndDate().isEmpty()) {
        wrapper.le(ReportSummary::getReportDate, query.getEndDate());
    }
    
    wrapper.orderByDesc(ReportSummary::getReportDate);
    reportSummaryMapper.selectPage(page, wrapper);
    
    Page<ReportSummaryVO> voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
    List<ReportSummaryVO> voList = new ArrayList<>();
    for (ReportSummary rs : page.getRecords()) {
        ReportSummaryVO vo = new ReportSummaryVO();
        vo.setId(rs.getId());
        vo.setReportType(rs.getReportType());
        vo.setReportTypeName(ReportSummaryVO.getReportTypeName(rs.getReportType()));
        vo.setTitle(rs.getTitle());
        vo.setSubjectName(rs.getSubjectName());
        vo.setSummary(rs.getSummary());
        vo.setHasFile(rs.getHasFile() != null && rs.getHasFile() == 1);
        vo.setOverallScore(rs.getOverallScore());
        vo.setReportDate(rs.getReportDate() != null ? rs.getReportDate().toString() : "");
        vo.setStatus(rs.getStatus());
        vo.setCreatedAt(rs.getCreatedAt());
        voList.add(vo);
    }
    voPage.setRecords(voList);
    return voPage;
    }
    
  • [ ] Step 10: 实现 fullSync() — 全量校准,供定时任务和手动触发调用:

    public ReportSyncResultDTO fullSync() {
    ReportSyncResultDTO result = new ReportSyncResultDTO();
    result.setNewCount(0);
    result.setUpdateCount(0);
    result.setDiscardedCount(0);
    result.setErrorCount(0);
    
    try {
        // 1. 同步 health_reports
        List<HealthReport> healthReports = healthReportMapper.selectList(new LambdaQueryWrapper<HealthReport>());
        List<ReportSummary> existingHealth = reportSummaryMapper.selectList(
            new LambdaQueryWrapper<ReportSummary>().eq(ReportSummary::getSourceTable, "health_reports"));
        for (HealthReport hr : healthReports) {
            try {
                ReportSummary existing = reportSummaryMapper.selectOne(
                    new LambdaQueryWrapper<ReportSummary>()
                        .eq(ReportSummary::getSourceTable, "health_reports")
                        .eq(ReportSummary::getSourceId, hr.getId()));
                if (existing == null) {
                    syncFromHealthReport(hr);
                    result.setNewCount(result.getNewCount() + 1);
                } else {
                    syncFromHealthReport(hr);
                    result.setUpdateCount(result.getUpdateCount() + 1);
                }
            } catch (Exception e) {
                result.setErrorCount(result.getErrorCount() + 1);
                log.error("同步 health_reports 记录失败 id={}", hr.getId(), e);
            }
        }
        // 标记已删除的
        for (ReportSummary es : existingHealth) {
            if (healthReportMapper.selectById(es.getSourceId()) == null) {
                markDiscarded("health_reports", es.getSourceId());
                result.setDiscardedCount(result.getDiscardedCount() + 1);
            }
        }
    } catch (Exception e) {
        log.error("全量同步 health_reports 失败", e);
        result.setErrorCount(result.getErrorCount() + 1);
    }
    
    // 2. 同步 dan_report_uploads
    try {
        List<DanReportUpload> danUploads = danReportUploadMapper.selectList(new LambdaQueryWrapper<DanReportUpload>());
        List<ReportSummary> existingDan = reportSummaryMapper.selectList(
            new LambdaQueryWrapper<ReportSummary>().eq(ReportSummary::getSourceTable, "dan_report_uploads"));
        for (DanReportUpload du : danUploads) {
            try {
                ReportSummary existing = reportSummaryMapper.selectOne(
                    new LambdaQueryWrapper<ReportSummary>()
                        .eq(ReportSummary::getSourceTable, "dan_report_uploads")
                        .eq(ReportSummary::getSourceId, du.getId()));
                if (existing == null) {
                    syncFromDanReport(du);
                    result.setNewCount(result.getNewCount() + 1);
                } else {
                    syncFromDanReport(du);
                    result.setUpdateCount(result.getUpdateCount() + 1);
                }
            } catch (Exception e) {
                result.setErrorCount(result.getErrorCount() + 1);
                log.error("同步 dan_report_uploads 失败 id={}", du.getId(), e);
            }
        }
        for (ReportSummary es : existingDan) {
            if (danReportUploadMapper.selectById(es.getSourceId()) == null) {
                markDiscarded("dan_report_uploads", es.getSourceId());
                result.setDiscardedCount(result.getDiscardedCount() + 1);
            }
        }
    } catch (Exception e) {
        log.error("全量同步 dan_report_uploads 失败", e);
        resultsetErrorCount(result.getErrorCount() + 1);
    }
    
    // 3. 同步 insurance_policies
    try {
        List<InsurancePolicy> policies = insurancePolicyMapper.selectList(new LambdaQueryWrapper<InsurancePolicy>());
        List<ReportSummary> existingIns = reportSummaryMapper.selectList(
            new LambdaQueryWrapper<ReportSummary>().eq(ReportSummary::getSourceTable, "insurance_policies"));
        for (InsurancePolicy p : policies) {
            try {
                ReportSummary existing = reportSummaryMapper.selectOne(
                    new LambdaQueryWrapper<ReportSummary>()
                        .eq(ReportSummary::getSourceTable, "insurance_policies")
                        .eq(ReportSummary::getSourceId, p.getId()));
                if (existing == null) {
                    syncFromInsurancePolicy(p);
                    result.setNewCount(result.getNewCount() + 1);
                } else {
                    syncFromInsurancePolicy(p);
                    result.setUpdateCount(result.getUpdateCount() + 1);
                }
            } catch (Exception e) {
                result.setErrorCount(result.getErrorCount() + 1);
                log.error("同步 insurance_policies 失败 id={}", p.getId(), e);
            }
        }
        for (ReportSummary es : existingIns) {
            if (insurancePolicyMapper.selectById(es.getSourceId()) == null) {
                markDiscarded("insurance_policies", es.getSourceId());
                result.setDiscardedCount(result.getDiscardedCount() + 1);
            }
        }
    } catch (Exception e) {
        log.error("全量同步 insurance_policies 失败", e);
        result.setErrorCount(result.getErrorCount() + 1);
    }
    
    // 4. 同步 family_fortune_report(无标准 Mapper,用 JdbcTemplate 查询)
    try {
        // 注意:FamilyFortuneReport 无 Mapper,使用 jdbcTemplate 查询
        String sql = "SELECT id, family_id, element, lucky_direction, pdf_path, file_size, created_at FROM family_fortune_report";
        // 实际实现需用 JdbcTemplate 查询并调用 syncFromFamilyFortuneReport
        // 此步骤在 task 实现时补充 JdbcTemplate 注入和完整实现
    } catch (Exception e) {
        log.error("全量同步 family_fortune_report 失败", e);
        result.setErrorCount(result.getErrorCount() + 1);
    }
    
    log.info("全量同步完成: new={}, update={}, discarded={}, error={}",
        result.getNewCount(), result.getUpdateCount(), result.getDiscardedCount(), result.getErrorCount());
    return result;
    }
    

⚠️ fullSync()family_fortune_report 的同步需额外注入 JdbcTemplate(因该表无 MyBatis Mapper)。实现时添加 @Resource private JdbcTemplate jdbcTemplate; 并用 jdbcTemplate.query() 查询。

  • Step 11: mvn clean compile 验证编译通过

Task 5: 后端 — 注入 sync 调用到各源 Service

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/service/InsurancePolicyService.java
  • Find: FamilyFortuneReport 相关 Service(搜索 family_fortune_report 写入逻辑)

Consumes: Task 4 (ReportSummaryService)

Produces: 源表 CRUD 末尾自动同步到汇总表

  • [ ] Step 1: 在 HealthReportService 中注入 ReportSummaryService

    @Resource
    private ReportSummaryService reportSummaryService;
    

createReport() 方法末尾(healthReportMapper.insert(report) 之后)添加:

try {
    reportSummaryService.syncFromHealthReport(report);
} catch (Exception e) {
    log.warn("同步报告摘要失败 reportId={}", report.getId(), e);
}

⚠️ 需确认 HealthReportServicecreateReport 方法是否在保存后能拿到 report 的 id(MyBatis-Plus insert 后 id 会自动回填)。

  • [ ] Step 2: 在 DanReportUploadService.confirmUpload() 方法末尾(确认保存后)添加:

    try {
    reportSummaryService.syncFromDanReport(upload);
    } catch (Exception e) {
    log.warn("同步DAN报告摘要失败 uploadId={}", uploadId, e);
    }
    

DanReportUploadService 也需注入 ReportSummaryService

  • [ ] Step 3: 在 InsurancePolicyService.createPolicy() 末尾添加:

    try {
    reportSummaryService.syncFromInsurancePolicy(policy);
    } catch (Exception e) {
    log.warn("同步保单摘要失败 policyId={}", policy.getId(), e);
    }
    

InsurancePolicyService.updatePolicy() 末尾也添加同样的 sync 调用。

InsurancePolicyService.deletePolicy() 中,将物理删除改为标记废弃(可选,保持物理删除但同步标记):

try {
    reportSummaryService.markDiscarded("insurance_policies", id);
} catch (Exception e) {
    log.warn("标记保单废弃失败 policyId={}", id, e);
}

注入 @Resource private ReportSummaryService reportSummaryService;

  • [ ] Step 4: 查找 family_fortune_report 表的写入逻辑(搜索 family_fortune_reportFamilyFortuneReport 的 INSERT/UPDATE),在写入后调用 reportSummaryService.syncFromFamilyFortuneReport(report)

  • [ ] Step 5: mvn clean compile 验证


Task 6: 后端 — ReportSummaryController(小程序端 API)

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/controller/ReportSummaryController.java

Consumes: Task 4 (ReportSummaryService)

Produces: POST /api/report-summary/list, POST /api/report-summary/detail/{id}, POST /api/report-summary/download/{id}

  • [ ] Step 1: 创建 ReportSummaryController.java

    package com.etotem.cfc.controller;
    
    import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.dto.ReportListQueryDTO;
    import com.etotem.cfc.dto.ReportSummaryVO;
    import com.etotem.cfc.service.ReportSummaryService;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.web.bind.annotation.PathVariable;
    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 javax.servlet.http.HttpServletRequest;
    
    @Slf4j
    @RestController
    @RequestMapping("/api/report-summary")
    public class ReportSummaryController {
    
    @Resource
    private ReportSummaryService reportSummaryService;
    
    @PostMapping("/list")
    public Result<Object> list(@RequestBody ReportListQueryDTO query, HttpServletRequest request) {
        Long userId = (Long) request.getAttribute("userId");
        if (userId == null) return Result.error("用户未登录");
        query.setUserId(userId);
    
        Page<ReportSummaryVO> page = reportSummaryService.pageList(query);
        return Result.success(page);
    }
    
    @PostMapping("/detail/{id}")
    public Result<ReportSummaryVO> detail(@PathVariable Long id, HttpServletRequest request) {
        Long userId = (Long) request.getAttribute("userId");
        if (userId == null) return Result.error("用户未登录");
    
        ReportSummaryVO vo = reportSummaryService.getDetail(id);
        if (vo == null) return Result.error("报告不存在");
        return Result.success(vo);
    }
    
    @PostMapping("/download/{id}")
    public Result<Object> download(@PathVariable Long id, HttpServletRequest request) {
        Long userId = (Long) request.getAttribute("userId");
        if (userId == null) return Result.error("用户未登录");
    
        ReportSummaryVO vo = reportSummaryService.getDetail(id);
        if (vo == null) return Result.error("报告不存在");
        if (!vo.getHasFile()) return Result.error("该报告无文件可下载");
    
        Object data = new Object() {
            public String getFileUrl() { return vo.getFileUrl(); }
            public String getFileName() { return vo.getFileName(); }
            public Long getFileSize() { return vo.getFileSize(); }
        };
        return Result.success(data);
    }
    }
    
  • [ ] Step 2: mvn clean compile 验证


Task 7: 后端 — Admin Controller + Sync Task

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportSummaryAdminController.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/task/ReportSummarySyncTask.java

Consumes: Task 4 (ReportSummaryService)

Produces: 管理端 API + 定时任务

  • [ ] Step 1: 创建 ReportSummaryAdminController.java

    package com.etotem.cfc.controller.admin;
    
    import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.dto.ReportListQueryDTO;
    import com.etotem.cfc.dto.ReportSummaryVO;
    import com.etotem.cfc.dto.ReportSyncResultDTO;
    import com.etotem.cfc.service.ReportSummaryService;
    import lombok.extern.slf4j.Slf4j;
    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;
    
    @Slf4j
    @RestController
    @RequestMapping("/api/admin/report-summary")
    public class ReportSummaryAdminController {
    
    @Resource
    private ReportSummaryService reportSummaryService;
    
    @PostMapping("/list")
    public Result<Object> adminList(@RequestBody ReportListQueryDTO query) {
        Page<ReportSummaryVO> page = reportSummaryService.adminPageList(query);
        return Result.success(page);
    }
    
    @PostMapping("/sync")
    public Result<ReportSyncResultDTO> sync() {
        ReportSyncResultDTO result = reportSummaryService.fullSync();
        String msg = "同步完成: 新增" + result.getNewCount() +
                     "条, 更新" + result.getUpdateCount() +
                     "条, 废弃" + result.getDiscardedCount() +
                     "条, 错误" + result.getErrorCount() + "条";
        return Result.success(msg, result);
    }
    }
    
  • [ ] Step 2: 创建 ReportSummarySyncTask.java — 参考 OrderTimeoutCancelTask.java@Scheduled 模式:

    package com.etotem.cfc.task;
    
    import com.etotem.cfc.dto.ReportSyncResultDTO;
    import com.etotem.cfc.service.ReportSummaryService;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.scheduling.annotation.Scheduled;
    import org.springframework.stereotype.Component;
    
    import javax.annotation.Resource;
    
    @Slf4j
    @Component
    public class ReportSummarySyncTask {
    
    @Resource
    private ReportSummaryService reportSummaryService;
    
    /**
     * 每日凌晨 2:00 全量校准汇总表
     */
    @Scheduled(cron = "0 0 2 * * ?")
    public void scheduledSync() {
        log.info("开始定时同步报告汇总表...");
        try {
            ReportSyncResultDTO result = reportSummaryService.fullSync();
            log.info("定时同步完成: new={}, update={}, discarded={}, error={}",
                result.getNewCount(), result.getUpdateCount(),
                result.getDiscardedCount(), result.getErrorCount());
        } catch (Exception e) {
            log.error("定时同步失败", e);
        }
    }
    }
    
  • [ ] Step 3: mvn clean compile 验证


Task 8: 小程序前端 — ProfileMenu 入口

Files:

  • Modify: cfc-frontend/pages/profile/components/ProfileMenu.vue

Produces: 在「个人」分组添加「📋 报告管理」菜单项

  • Step 1: 在 ProfileMenu.vue 的「个人」分组(menu-group 内)添加菜单项。参考现有 goToDailyTasks 模式:

pages/profile/components/ProfileMenu.vue 的「个人」分组的 </view> 之前(第 22 行 </view> 之后、第 23 行 </view> 之前),添加:

<view class="menu-item" @click="goToReportManagement">
  <text>📋 报告管理</text>
  <text class="arrow">›</text>
</view>

位置:在 goToOnboarding</view> 之后,</view>(个人分组结束)之前。即第 21 行 </view> 之后。

  • [ ] Step 2: 在 ProfileMenu.vue 的 methods 中添加导航方法:

    goToReportManagement() {
    uni.navigateTo({ url: '/pages/profile/report-management' })
    }
    
  • [ ] Step 3: 确认修改正确,无语法错误


Task 9: 小程序前端 — report-management.vue 主页面

Files:

  • Create: cfc-frontend/pages/profile/report-management.vue
  • Modify: cfc-frontend/utils/api.js (添加 API 函数)
  • Modify: cfc-frontend/pages.json (注册新页面)

Produces: 报告管理页面(类型Tab + 搜索 + 卡片列表 + 分页)

  • [ ] Step 1: 在 utils/api.js 末尾添加 API 函数:

    export const getReportSummaryList = (params) => request('/api/report-summary/list', 'POST', params)
    export const getReportSummaryDetail = (id) => request('/api/report-summary/detail/' + id, 'POST', {})
    export const downloadReportSummary = (id) => request('/api/report-summary/download/' + id, 'POST', {})
    
  • [ ] Step 2: 在 pages.json 中注册新页面。找到 pages/profile 所在的 subPackage(搜索 "root": "pages/profile"),在其 pages 数组中添加:

    {
    "path": "report-management",
    "style": { "navigationBarTitleText": "报告管理" }
    },
    {
    "path": "report-detail",
    "style": { "navigationBarTitleText": "报告详情" }
    }
    
  • [ ] Step 3: 创建 pages/profile/report-management.vue

    <template>
    <view class="container">
    <!-- 类型 Tab -->
    <view class="tab-bar">
      <view
        v-for="(tab, idx) in tabs"
        :key="idx"
        class="tab-item"
        :class="{ 'tab-active': activeTab === idx }"
        @click="activeTab = idx; loadData()"
      >
        <text>{{ tab.label }}</text>
      </view>
    </view>
    
    <!-- 搜索栏 -->
    <view class="search-bar">
      <input
        class="search-input"
        v-model="keyword"
        placeholder="搜索报告标题或成员名称..."
        @confirm="loadData()"
      />
    </view>
    
    <!-- 报告列表 -->
    <scroll-view scroll-y class="report-list" @scrolltolower="loadMore">
      <view
        v-for="item in reports"
        :key="item.id"
        class="report-card"
        :class="'card-' + getCardClass(item.reportType)"
        @click="goToDetail(item)"
      >
        <view class="card-left">
          <view class="type-badge" :class="'badge-' + getCardClass(item.reportType)">
            <text>{{ item.reportTypeName }}</text>
          </view>
          <text class="card-title">{{ item.title }}</text>
          <view class="card-meta">
            <text class="subject-name">{{ item.subjectName }}</text>
            <text class="date-text">{{ formatDate(item.reportDate) }}</text>
          </view>
        </view>
        <view class="card-right">
          <text v-if="item.overallScore !== null && item.overallScore !== undefined" class="score">{{ item.overallScore }}<text class="score-unit">分</text></text>
          <text v-else class="score-text">{{ item.hasFile ? '有文件' : '结构化数据' }}</text>
          <text class="arrow">›</text>
        </view>
      </view>
    
      <view v-if="reports.length === 0 && !loading" class="empty">
        <text class="empty-icon">📋</text>
        <text class="empty-title">暂无报告</text>
      </view>
    </scroll-view>
    </view>
    </template>
    
    <script>
    import { getReportSummaryList } from '../../utils/api.js'
    
    export default {
    data() {
    return {
      tabs: [
        { label: '全部', type: 'all' },
        { label: '菌群', type: 'gut_flora' },
        { label: '体检', type: 'physical_exam' },
        { label: 'DAN', type: 'dan' },
        { label: '保单', type: 'insurance' },
        { label: '周报', type: 'fortune' }
      ],
      activeTab: 0,
      keyword: '',
      reports: [],
      page: 1,
      loading: false,
      hasMore: true
    }
    },
    onLoad() {
    this.loadData()
    },
    onPullDownRefresh() {
    this.page = 1
    this.reports = []
    this.hasMore = true
    this.loadData()
    },
    methods: {
    loadData() {
      var self = this
      if (self.loading) return
      if (!self.hasMore) return
      self.loading = true
      var params = {
        page: self.page,
        size: 20,
        type: self.tabs[self.activeTab].type,
        keyword: self.keyword
      }
      getReportSummaryList(params).then(function(res) {
        var list = res && res.data && res.data.records ? res.data.records : []
        if (self.page === 1) {
          self.reports = list
        } else {
          self.reports = self.reports.concat(list)
        }
        self.hasMore = list.length >= 20
        self.page++
      }).catch(function(e) {
        console.log('获取报告列表失败', e)
        uni.showToast({ title: '加载失败', icon: 'none' })
      }).finally(function() {
        self.loading = false
        uni.stopPullDownRefresh()
      })
    },
    loadMore() {
      if (!this.loading && this.hasMore) {
        this.loadData()
      }
    },
    goToDetail(item) {
      uni.navigateTo({
        url: '/pages/profile/report-detail?id=' + item.id + '&title=' + encodeURIComponent(item.title)
      })
    },
    getCardClass(reportType) {
      return reportType || 'default'
    },
    formatDate(dateStr) {
      if (!dateStr) return '--'
      try {
        var d = new Date(dateStr)
        var y = d.getFullYear()
        var m = ('' + (d.getMonth() + 1)).padStart(2, '0')
        var day = ('' + d.getDate()).padStart(2, '0')
        return y + '-' + m + '-' + day
      } catch (e) {
        return dateStr
      }
    }
    }
    }
    </script>
    
    <style scoped>
    .container {
    min-height: 100vh;
    background: #f5f7fa;
    display: flex;
    flex-direction: column;
    }
    
    .tab-bar {
    display: flex;
    flex-direction: row;
    background: #fff;
    padding: 20rpx 0;
    border-bottom: 1rpx solid #eee;
    overflow-x: auto;
    white-space: nowrap;
    }
    
    .tab-item {
    padding: 12rpx 24rpx;
    font-size: 26rpx;
    color: #666;
    margin: 0 8rpx;
    border-radius: 20rpx;
    }
    
    .tab-active {
    background: #5B9BD5;
    color: #fff;
    }
    
    .search-bar {
    padding: 16rpx 24rpx;
    background: #fff;
    }
    
    .search-input {
    background: #f5f5f5;
    border-radius: 12rpx;
    padding: 16rpx 24rpx;
    font-size: 26rpx;
    }
    
    .report-list {
    flex: 1;
    padding: 16rpx 24rpx;
    }
    
    .report-card {
    background: #fff;
    border-radius: 16rpx;
    padding: 24rpx;
    margin-bottom: 16rpx;
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
    }
    
    .card-left {
    display: flex;
    flex-direction: column;
    flex: 1;
    min-width: 0;
    }
    
    .type-badge {
    display: inline-block;
    font-size: 20rpx;
    padding: 4rpx 16rpx;
    border-radius: 12rpx;
    align-self: flex-start;
    margin-bottom: 8rpx;
    }
    
    .badge-gut_flora { background: #E8F5E9; color: #2E7D32; }
    .badge-physical_exam { background: #E3F2FD; color: #1565C0; }
    .badge-dan { background: #F3E5F5; color: #7B1FA2; }
    .badge-insurance { background: #FFF3E0; color: #E65100; }
    .badge-fortune { background: #FCE4EC; color: #C62828; }
    
    .card-title {
    font-size: 28rpx;
    font-weight: bold;
    color: #333;
    margin-bottom: 8rpx;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    }
    
    .card-meta {
    display: flex;
    flex-direction: row;
    align-items: center;
    }
    
    .subject-name {
    font-size: 24rpx;
    color: #888;
    margin-right: 16rpx;
    }
    
    .date-text {
    font-size: 24rpx;
    color: #aaa;
    }
    
    .card-right {
    display: flex;
    flex-direction: row;
    align-items: center;
    gap: 8rpx;
    margin-left: 16rpx;
    }
    
    .score {
    font-size: 32rpx;
    font-weight: bold;
    color: #333;
    }
    
    .score-unit {
    font-size: 20rpx;
    color: #999;
    font-weight: normal;
    margin-left: 4rpx;
    }
    
    .score-text {
    font-size: 24rpx;
    color: #888;
    }
    
    .arrow {
    font-size: 32rpx;
    color: #ccc;
    }
    
    .empty {
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 100rpx 0;
    }
    
    .empty-icon {
    font-size: 80rpx;
    margin-bottom: 20rpx;
    }
    
    .empty-title {
    font-size: 28rpx;
    color: #999;
    }
    </style>
    
  • [ ] Step 4: 编译验证(npm run build:mp-weixincfc-frontend/ 目录)


Task 10: 小程序前端 — report-detail.vue 统一详情视图

Files:

  • Create: cfc-frontend/pages/profile/report-detail.vue

Consumes: Task 9 (API 函数)

Produces: 统一报告详情页(有文件显示预览/下载,无文件显示结构化数据,支持分享)

  • [ ] Step 1: 在 utils/api.js 中确保 getReportSummaryDetaildownloadReportSummary 已定义(Task 9 Step 1 已添加)

  • [ ] Step 2: 创建 pages/profile/report-detail.vue

    <template>
    <view class="container">
    <!-- 报告头部 -->
    <view class="report-header">
      <view class="type-badge" :class="'badge-' + getBadgeClass(detail.reportType)">
        <text>{{ detail.reportTypeName || '报告' }}</text>
      </view>
      <text class="report-title">{{ detail.title }}</text>
      <view class="report-meta">
        <text class="meta-item">{{ detail.subjectName }}</text>
        <text class="meta-item">{{ formatDate(detail.reportDate) }}</text>
      </view>
      <view class="score-section" v-if="detail.overallScore !== null && detail.overallScore !== undefined">
        <text class="score">{{ detail.overallScore }}</text>
        <text class="score-label">综合评分</text>
      </view>
    </view>
    
    <!-- 摘要 -->
    <view class="summary-section" v-if="detail.summary">
      <text class="section-title">报告摘要</text>
      <text class="summary-text">{{ detail.summary }}</text>
    </view>
    
    <!-- 文件操作区 -->
    <view class="action-section" v-if="detail.hasFile">
      <button class="action-btn" @click="downloadReport">📥 下载报告</button>
      <button class="action-btn secondary" @click="goToOriginalDetail">📄 查看原始详情</button>
    </view>
    
    <!-- 无文件 -->
    <view class="action-section" v-else>
      <button class="action-btn secondary" @click="goToOriginalDetail">📄 查看原始详情</button>
    </view>
    </view>
    </template>
    
    <script>
    import { getReportSummaryDetail, downloadReportSummary } from '../../utils/api.js'
    
    export default {
    data() {
    return {
      reportId: null,
      detail: {}
    }
    },
    onLoad(options) {
    if (options && options.id) {
      this.reportId = parseInt(options.id)
      this.loadDetail()
    }
    },
    onShareAppMessage() {
    return {
      title: '浠艾福报告',
      path: '/pages/profile/report-detail?id=' + this.reportId
    }
    },
    methods: {
    loadDetail() {
      var self = this
      getReportSummaryDetail(self.reportId).then(function(res) {
        if (res && res.data) {
          self.detail = res.data
          uni.setNavigationBarTitle({ title: self.detail.title || '报告详情' })
        }
      }).catch(function(e) {
        console.log('加载详情失败', e)
        uni.showToast({ title: '加载失败', icon: 'none' })
      })
    },
    downloadReport() {
      var self = this
      uni.showLoading({ title: '下载中...' })
      downloadReportSummary(self.reportId).then(function(res) {
        if (res && res.data && res.data.fileUrl) {
          var url = res.data.fileUrl
          var name = res.data.fileName || 'report'
          uni.downloadFile({
            url: url,
            filePath: wx.env.USER_DATA_PATH + '/' + name,
            success: function(res2) {
              uni.saveFileToDisk({
                filePath: res2.filePath,
                success: function() {
                  uni.showToast({ title: '保存成功', icon: 'success' })
                },
                fail: function() {
                  uni.showToast({ title: '保存失败', icon: 'none' })
                }
              })
            },
            fail: function() {
              uni.showToast({ title: '下载失败', icon: 'none' })
            },
            complete: function() {
              uni.hideLoading()
            }
          })
        }
      }).catch(function(e) {
        uni.hideLoading()
        uni.showToast({ title: '下载失败', icon: 'none' })
      })
    },
    goToOriginalDetail() {
      var route = this.detail.detailRoute || ''
      if (route) {
        var url = route + (this.detail.sourceId ? '?reportId=' + this.detail.sourceId : '')
        uni.navigateTo({ url: url })
      } else {
        uni.showToast({ title: '暂无详情页面', icon: 'none' })
      }
    },
    getBadgeClass(reportType) {
      return reportType || 'default'
    },
    formatDate(dateStr) {
      if (!dateStr) return '--'
      return dateStr
    }
    }
    }
    </script>
    
    <style scoped>
    .container {
    min-height: 100vh;
    background: #f5f7fa;
    padding: 24rpx;
    }
    
    .report-header {
    background: #fff;
    border-radius: 16rpx;
    padding: 32rpx 24rpx;
    box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
    margin-bottom: 16rpx;
    }
    
    .type-badge {
    display: inline-block;
    font-size: 22rpx;
    padding: 6rpx 20rpx;
    border-radius: 12rpx;
    margin-bottom: 16rpx;
    }
    
    .badge-gut_flora { background: #E8F5E9; color: #2E7D32; }
    .badge-physical_exam { background: #E3F2FD; color: #1565C0; }
    .badge-dan { background: #F3E5F5; color: #7B1FA2; }
    .badge-insurance { background: #FFF3E0; color: #E65100; }
    .badge-fortune { background: #FCE4EC; color: #C62828; }
    
    .report-title {
    font-size: 32rpx;
    font-weight: bold;
    color: #333;
    display: block;
    margin-bottom: 12rpx;
    }
    
    .report-meta {
    display: flex;
    flex-direction: row;
    margin-bottom: 16rpx;
    }
    
    .meta-item {
    font-size: 24rpx;
    color: #888;
    margin-right: 24rpx;
    }
    
    .score-section {
    display: flex;
    flex-direction: row;
    align-items: baseline;
    }
    
    .score {
    font-size: 56rpx;
    font-weight: bold;
    color: #5B9BD5;
    }
    
    .score-label {
    font-size: 24rpx;
    color: #888;
    margin-left: 12rpx;
    }
    
    .summary-section {
    background: #fff;
    border-radius: 16rpx;
    padding: 24rpx;
    box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
    margin-bottom: 16rpx;
    }
    
    .section-title {
    font-size: 28rpx;
    font-weight: bold;
    color: #333;
    display: block;
    margin-bottom: 12rpx;
    }
    
    .summary-text {
    font-size: 26rpx;
    color: #666;
    line-height: 1.6;
    }
    
    .action-section {
    background: #fff;
    border-radius: 16rpx;
    padding: 24rpx;
    box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
    }
    
    .action-btn {
    background: #5B9BD5;
    color: #fff;
    font-size: 28rpx;
    border-radius: 12rpx;
    margin-bottom: 16rpx;
    }
    
    .action-btn.secondary {
    background: #f0f0f0;
    color: #333;
    margin-bottom: 0;
    }
    </style>
    
  • [ ] Step 3: npm run build:mp-weixin 编译验证


Task 11: Web管理端 — ReportManagement.vue

Files:

  • Create: cfc-web/src/views/admin/ReportManagement.vue
  • Modify: cfc-web/src/views/Layout.vue (菜单入口)
  • Modify: cfc-web/src/router/index.js (路由)

Produces: 管理端报告管理页面(表格 + 筛选 + 分页)

  • [ ] Step 1: 创建 cfc-web/src/views/admin/ReportManagement.vue

    <template>
    <div class="report-management">
    <el-card>
      <div class="filter-bar">
        <el-select v-model="query.type" placeholder="报告类型" clearable style="width:140px">
          <el-option label="全部" value=""></el-option>
          <el-option label="菌群检测" value="gut_flora"></el-option>
          <el-option label="体检报告" value="physical_exam"></el-option>
          <el-option label="DAN测评" value="dan"></el-option>
          <el-option label="保单" value="insurance"></el-option>
          <el-option label="家庭周报" value="fortune"></el-option>
        </el-select>
        <el-input v-model="query.keyword" placeholder="搜索标题/名称" style="width:200px" @keyup.enter.native="loadData"></el-input>
        <el-date-picker
          v-model="dateRange"
          type="daterange"
          range-separator="至"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          style="width:240px"
        ></el-date-picker>
        <el-button type="primary" @click="loadData">查询</el-button>
        <el-button @click="doSync">同步数据</el-button>
      </div>
    
      <el-table :data="tableData" border stripe v-loading="loading" style="margin-top:16px">
        <el-table-column prop="id" label="ID" width="80"></el-table-column>
        <el-table-column label="报告类型" width="100">
          <template slot-scope="scope">
            <el-tag size="small" :type="getTypeTag(scope.row.reportType)">{{ scope.row.reportTypeName }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="title" label="标题"></el-table-column>
        <el-table-column prop="subjectName" label="报告主体" width="120"></el-table-column>
        <el-table-column prop="reportDate" label="报告日期" width="120"></el-table-column>
        <el-table-column prop="overallScore" label="综合评分" width="100">
          <template slot-scope="scope">
            <span v-if="scope.row.overallScore">{{ scope.row.overallScore }}</span>
            <span v-else>-</span>
          </template>
        </el-table-column>
        <el-table-column label="状态" width="100">
          <template slot-scope="scope">
            <el-tag size="small">{{ scope.row.status }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="createdAt" label="创建时间" width="160"></el-table-column>
        <el-table-column label="操作" width="120">
          <template slot-scope="scope">
            <el-button size="mini" type="text" @click="viewDetail(scope.row.id)">详情</el-button>
          </template>
        </el-table-column>
      </el-table>
    
      <el-pagination
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
        :current-page="query.page"
        :page-size="query.size"
        :total="total"
        layout="total, prev, pager, next"
        style="margin-top:16px"
      ></el-pagination>
    </el-card>
    </div>
    </template>
    
    <script>
    import axios from 'axios'
    
    export default {
    name: 'ReportManagement',
    data() {
    return {
      tableData: [],
      total: 0,
      loading: false,
      query: {
        page: 1,
        size: 20,
        type: '',
        keyword: ''
      },
      dateRange: []
    }
    },
    created() {
    this.loadData()
    },
    methods: {
    loadData() {
      var self = this
      self.loading = true
      var params = {
        page: self.query.page,
        size: self.query.size,
        type: self.query.type,
        keyword: self.query.keyword
      }
      if (self.dateRange && self.dateRange.length === 2) {
        params.startDate = self.dateRange[0].format ? self.dateRange[0].format('YYYY-MM-DD') : self.dateRange[0]
        params.endDate = self.dateRange[1].format ? self.dateRange[1].format('YYYY-MM-DD') : self.dateRange[1]
      }
      axios.post('/api/admin/report-summary/list', params, {
        headers: { Authorization: 'Bearer ' + localStorage.getItem('token') }
      }).then(function(res) {
        if (res.data && res.data.data) {
          self.tableData = res.data.data.records || []
          self.total = res.data.data.total || 0
        }
      }).catch(function(e) {
        console.error(e)
      }).finally(function() {
        self.loading = false
      })
    },
    handleSizeChange(size) {
      this.query.size = size
      this.query.page = 1
      this.loadData()
    },
    handleCurrentChange(page) {
      this.query.page = page
      this.loadData()
    },
    doSync() {
      var self = this
      axios.post('/api/admin/report-summary/sync', {}, {
        headers: { Authorization: 'Bearer ' + localStorage.getItem('token') }
      }).then(function(res) {
        if (res.data && res.data.code === 200) {
          self.$message.success('同步完成: ' + res.data.message)
          self.loadData()
        }
      }).catch(function(e) {
        self.$message.error('同步失败')
      })
    },
    viewDetail(id) {
      var self = this
      axios.post('/api/report-summary/detail/' + id, {}, {
        headers: { Authorization: 'Bearer ' + localStorage.getItem('token') }
      }).then(function(res) {
        if (res.data && res.data.data) {
          self.$alert(
            '<p>标题: ' + res.data.data.title + '</p>' +
            '<p>类型: ' + res.data.data.reportTypeName + '</p>' +
            '<p>主体: ' + res.data.data.subjectName + '</p>' +
            '<p>日期: ' + res.data.data.reportDate + '</p>' +
            '<p>评分: ' + (res.data.data.overallScore || '-') + '</p>' +
            '<p>状态: ' + res.data.data.status + '</p>',
            '报告详情',
            { dangerouslyUseHTMLString: true }
          )
        }
      })
    },
    getTypeTag(type) {
      var map = { gut_flora: 'success', physical_exam: 'primary', dan: 'warning', insurance: 'info', fortune: 'danger' }
      return map[type] || 'info'
    }
    }
    }
    </script>
    
    <style scoped>
    .report-management { padding: 16px; }
    .filter-bar {
    display: flex;
    flex-direction: row;
    align-items: center;
    gap: 12px;
    flex-wrap: wrap;
    }
    </style>
    
  • [ ] Step 2: 在 cfc-web/src/router/index.js 中添加路由:

    {
    path: 'report-management',
    name: 'ReportManagement',
    component: () => import('@/views/admin/ReportManagement.vue'),
    meta: { title: '报告管理', perm: 'report:management' }
    }
    
  • [ ] Step 3: 在 cfc-web/src/views/Layout.vue 左侧菜单中添加菜单项,参考现有菜单结构(搜索 health-reports 找到分组位置),添加:

    // 在 menuItems 数组中找到合适位置(如"健康"或"系统管理"分组),添加:
    {
    path: '/admin/report-management',
    title: '报告管理',
    icon: 'el-icon-document',
    perm: 'report:management'
    }
    

⚠️ 需确认 Layout.vue 中菜单数据的具体结构(可能是一个数组或对象),根据现有模式适配。

  • Step 4: cd cfc-web && npm run serve 验证

最终验证

  • cd cfc-backend && mvn clean compile — 后端编译通过
  • 检查所有 4 个源 Service 的 sync 调用已正确注入
  • 小程序端:从「我的」页点击「报告管理」→ 看到报告列表 → 点击卡片 → 看到详情
  • Web 管理端:从菜单点击「报告管理」→ 看到表格 → 点击「同步数据」→ 看到同步结果
  • fullSync() 能正确处理已删除的源记录(标记为 discarded)
  • 小程序禁用 ?. 可选链、禁用 CSS Grid — 全部使用 && 和 flexbox

文件清单汇总

后端 (cfc-backend)

文件 操作 Task
entity/ReportSummary.java 新建 2
mapper/ReportSummaryMapper.java 新建 2
mapper/ReportSummaryMapper.xml 新建 2
dto/ReportSummaryVO.java 新建 3
dto/ReportListQueryDTO.java 新建 3
dto/ReportSyncResultDTO.java 新建 3
service/ReportSummaryService.java 新建 4
controller/ReportSummaryController.java 新建 6
controller/admin/ReportSummaryAdminController.java 新建 7
task/ReportSummarySyncTask.java 新建 7
config/DatabaseInitializer.java 修改 1
resources/schema.sql 修改 1
service/HealthReportService.java 修改 5
service/DanReportUploadService.java 修改 5
service/InsurancePolicyService.java 修改 5

小程序 (cfc-frontend)

文件 操作 Task
pages/profile/report-management.vue 新建 9
pages/profile/report-detail.vue 新建 10
pages/profile/components/ProfileMenu.vue 修改 8
utils/api.js 修改 9
pages.json 修改 9

Web管理端 (cfc-web)

文件 操作 Task
src/views/admin/ReportManagement.vue 新建 11
src/views/Layout.vue 修改 11
src/router/index.js 修改 11