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
@PostMapping,禁止 @GetMapping/@PutMapping/@DeleteMappingResult<T> (code/message/data)?.(用 && 替代)、禁用 CSS Grid@ScheduledUNIQUE KEY uk_source (source_table, source_id) 去重INSERT ... ON DUPLICATE KEY UPDATEFiles:
cfc-backend/src/main/resources/schema.sql (append)cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.javaProduces: report_summary 表创建 + 幂等迁移
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 验证编译通过
Files:
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportSummary.javacfc-backend/src/main/java/com/etotem/cfc/mapper/ReportSummaryMapper.javacfc-backend/src/main/resources/mapper/ReportSummaryMapper.xmlProduces: 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 验证编译通过
Files:
cfc-backend/src/main/java/com/etotem/cfc/dto/ReportSummaryVO.javacfc-backend/src/main/java/com/etotem/cfc/dto/ReportListQueryDTO.javacfc-backend/src/main/java/com/etotem/cfc/dto/ReportSyncResultDTO.javaProduces: 列表查询入参 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 验证
Files:
cfc-backend/src/main/java/com/etotem/cfc/service/ReportSummaryService.javaConsumes: 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()查询。
mvn clean compile 验证编译通过Files:
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.javacfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.javacfc-backend/src/main/java/com/etotem/cfc/service/InsurancePolicyService.javafamily_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);
}
⚠️ 需确认
HealthReportService的createReport方法是否在保存后能拿到 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_report 或 FamilyFortuneReport 的 INSERT/UPDATE),在写入后调用 reportSummaryService.syncFromFamilyFortuneReport(report)。
[ ] Step 5: mvn clean compile 验证
Files:
cfc-backend/src/main/java/com/etotem/cfc/controller/ReportSummaryController.javaConsumes: 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 验证
Files:
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportSummaryAdminController.javacfc-backend/src/main/java/com/etotem/cfc/task/ReportSummarySyncTask.javaConsumes: 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 验证
Files:
cfc-frontend/pages/profile/components/ProfileMenu.vueProduces: 在「个人」分组添加「📋 报告管理」菜单项
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: 确认修改正确,无语法错误
Files:
cfc-frontend/pages/profile/report-management.vuecfc-frontend/utils/api.js (添加 API 函数)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-weixin 在 cfc-frontend/ 目录)
Files:
cfc-frontend/pages/profile/report-detail.vueConsumes: Task 9 (API 函数)
Produces: 统一报告详情页(有文件显示预览/下载,无文件显示结构化数据,支持分享)
[ ] Step 1: 在 utils/api.js 中确保 getReportSummaryDetail 和 downloadReportSummary 已定义(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 编译验证
Files:
cfc-web/src/views/admin/ReportManagement.vuecfc-web/src/views/Layout.vue (菜单入口)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中菜单数据的具体结构(可能是一个数组或对象),根据现有模式适配。
cd cfc-web && npm run serve 验证cd cfc-backend && mvn clean compile — 后端编译通过fullSync() 能正确处理已删除的源记录(标记为 discarded)?. 可选链、禁用 CSS Grid — 全部使用 && 和 flexbox| 文件 | 操作 | 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 |
| 文件 | 操作 | 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 |
| 文件 | 操作 | Task |
|---|---|---|
src/views/admin/ReportManagement.vue |
新建 | 11 |
src/views/Layout.vue |
修改 | 11 |
src/router/index.js |
修改 | 11 |