Ver Fonte

feat(report): 报告查看权限按成员级授权(管理员全可见 + 授权可见 + 普通成员仅本人)

- DB: 新增 report_member_permissions 表(family_id+target_member_id+grantee_user_id 唯一),
  DatabaseInitializer 迁移379 幂等建表;schema.sql 同步建表语句
- 后端新增 ReportMemberPermissionService/Mapper/Entity/Controller:
  isFamilyAdmin(按 family_members.is_admin 列)、canViewMemberReports(管理员/本人/授权)、
  visibleIdentityIds(null=管理员全可见)、grant/revoke/list/listGrantedUserIds
- 健康报告接口接入校验:report/list(memberId + 家庭视图)、report/detail、report/payload
- DAN 报告接口接入校验:dan-report/list、dan-report/{id}、dan-assessment/result/{id}、
  dan-assessment/my-results(家长视角改为按可见身份返回家庭DAN报告)
- AssessmentService.getByAppointmentId 兼容主键查询(原仅按 appointment_id 查,家长上传报告
  appointment_id=NULL 导致 /result/{id} 返回 null)
- FamilyMemberService.listMembers 改用 is_admin 列判定管理员(兜底 creatorId)
- 小程序 family-members.vue 新增「报告权限」弹窗:管理员授权/撤销成员查看权限;
  isAdmin 判定同步 is_admin 列语义;api.js 新增 5 个授权接口封装
Xiaogang Liao há 1 semana atrás
pai
commit
0e635510ab

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

@@ -3082,6 +3082,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移378: 创建 member_indicator_current 表(成员指标当前值 + 相对上次变化 delta)
         addMemberIndicatorCurrentTable();
+
+        // 迁移379: 创建 report_member_permissions 表(管理员授权某用户查看某成员的全部报告)
+        addReportMemberPermissionsTable();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -12467,9 +12470,33 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+
     // 迁移373: products 添加 tags 列(文章-商品标签关联推荐)
     private void addProductTagsColumn() {
         ensureColumn("products", "tags", "VARCHAR(500) DEFAULT '' COMMENT '商品标签 JSON 数组,用于文章-商品标签匹配推荐'");
         log.info("迁移373: products.tags 已确认存在");
     }
+
+
+    // 迁移379: 创建 report_member_permissions 表(报告成员级查看权限)
+    private void addReportMemberPermissionsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_member_permissions (" +
+                    "id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID'," +
+                    "family_id BIGINT NOT NULL COMMENT '家庭ID(隔离作用域)'," +
+                    "authorizer_user_id BIGINT NOT NULL COMMENT '授权人(家庭管理员)的用户ID'," +
+                    "target_member_id BIGINT NOT NULL COMMENT '被授权查看的成员ID(family_members.id)'," +
+                    "grantee_user_id BIGINT NOT NULL COMMENT '被授权用户的ID(能看报告的人)'," +
+                    "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '授权时间'," +
+                    "PRIMARY KEY (id)," +
+                    "UNIQUE KEY uk_perm (family_id, target_member_id, grantee_user_id)," +
+                    "KEY idx_target_member (target_member_id)," +
+                    "KEY idx_grantee_user (grantee_user_id)," +
+                    "KEY idx_family_id (family_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='报告成员级查看权限(管理员授权某用户查看某成员的全部报告)'");
+            log.info("迁移379: report_member_permissions 表就绪");
+        } catch (Exception e) {
+            log.warn("迁移379: 创建 report_member_permissions 表失败或已存在: {}", e.getMessage());
+        }
+    }
 }

+ 35 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/DanAssessmentController.java

@@ -25,6 +25,12 @@ public class DanAssessmentController {
     @Resource
     private AssessmentService assessmentService;
 
+    @Resource
+    private com.etotem.cfc.mapper.FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private ReportMemberPermissionService reportMemberPermissionService;
+
     @Resource
     private AssessmentAppointmentService appointmentService;
 
@@ -175,8 +181,35 @@ public class DanAssessmentController {
 
     @Operation(summary = "获取测评报告详情")
     @PostMapping("/result/{id}")
-    public Result<DanAssessmentResult> getResult(@PathVariable Long id) {
-        return Result.success(assessmentService.getByAppointmentId(id));
+    public Result<DanAssessmentResult> getResult(
+            @PathVariable Long id,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        DanAssessmentResult result = assessmentService.getByAppointmentId(id);
+        if (result == null) {
+            return Result.success(null);
+        }
+        // 权限校验:本人 / 家庭管理员 / 被授权成员可见
+        Long memberId = result.getFamilyMemberId() != null ? result.getFamilyMemberId() : result.getChildId();
+        Long resultFamilyId = familyId;
+        if (resultFamilyId == null && memberId != null) {
+            com.etotem.cfc.entity.FamilyMember fm = familyMemberMapper.selectById(memberId);
+            if (fm != null) {
+                resultFamilyId = fm.getFamilyId();
+            }
+        }
+        if (memberId != null && resultFamilyId != null) {
+            if (!reportMemberPermissionService.canViewMemberReports(userId, memberId, resultFamilyId)) {
+                return Result.error("无权查看该测评报告");
+            }
+        } else {
+            boolean self = result.getTeacherId() != null && result.getTeacherId().equals(userId);
+            boolean admin = resultFamilyId != null && reportMemberPermissionService.isFamilyAdmin(userId, resultFamilyId);
+            if (!self && !admin) {
+                return Result.error("无权查看该测评报告");
+            }
+        }
+        return Result.success(result);
     }
 
     // ===== 预约 =====

+ 41 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/DanReportController.java

@@ -4,6 +4,7 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.DanReportEditDTO;
 import com.etotem.cfc.dto.DanReportUploadVO;
 import com.etotem.cfc.service.DanReportUploadService;
+import com.etotem.cfc.service.ReportMemberPermissionService;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
@@ -21,6 +22,9 @@ public class DanReportController {
     @Resource
     private DanReportUploadService uploadService;
 
+    @Resource
+    private ReportMemberPermissionService reportMemberPermissionService;
+
     // Phase 1: 上传并预览 PDF
     @PostMapping("/upload")
     public Result<Map<String, Object>> upload(
@@ -62,14 +66,49 @@ public class DanReportController {
     }
 
     @PostMapping("/{id}")
-    public Result<DanReportUploadVO> detail(@PathVariable("id") Long id) {
+    public Result<DanReportUploadVO> detail(
+            @PathVariable("id") Long id,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        // 先查记录用于取 familyMemberId 与 uploaderUserId
+        com.etotem.cfc.dto.DanReportUploadVO vo = uploadService.getDetailForPermission(id);
+        if (vo == null) {
+            return uploadService.getDetail(id);
+        }
+        Long member = vo.getFamilyMemberId();
+        if (member == null) {
+            // 无归属成员:仅上传者本人或家庭管理员可看
+            boolean self = vo.getUploaderUserId() != null && vo.getUploaderUserId().equals(userId);
+            if (!self && !reportMemberPermissionService.isFamilyAdmin(userId, vo.getFamilyId())) {
+                return com.etotem.cfc.common.Result.error("无权查看该DAN报告");
+            }
+            return uploadService.getDetail(id);
+        }
+        if (!reportMemberPermissionService.canViewMemberReports(userId, member, vo.getFamilyId())) {
+            return com.etotem.cfc.common.Result.error("无权查看该DAN报告");
+        }
         return uploadService.getDetail(id);
     }
 
     @PostMapping("/list")
-    public Result<List<DanReportUploadVO>> list(@RequestBody Map<String, Object> body) {
+    public Result<List<DanReportUploadVO>> list(
+            @RequestBody Map<String, Object> body,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
         Long memberId = ParamUtils.getLong(body.get("memberId"));
         String dimension = (String) body.get("dimension");
+        if (memberId == null) {
+            // 未指定成员:按可见身份过滤该家庭的全部DAN报告
+            java.util.Set<Long> visible = reportMemberPermissionService.visibleIdentityIds(userId, familyId);
+            if (visible == null) {
+                return uploadService.listByFamily(null, dimension); // 管理员全部可见
+            }
+            return uploadService.listByFamily(familyId, dimension, visible);
+        }
+        // 指定成员:校验权限
+        if (!reportMemberPermissionService.canViewMemberReports(userId, memberId, familyId)) {
+            return com.etotem.cfc.common.Result.success(new java.util.ArrayList<>());
+        }
         return uploadService.listByMember(memberId, dimension);
     }
 

+ 68 - 4
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -44,6 +44,7 @@ import com.etotem.cfc.service.ReportBlockAssembler;
 import com.etotem.cfc.service.ReportBlockService;
 import com.etotem.cfc.service.StorageService;
 import com.etotem.cfc.service.OnboardingService;
+import com.etotem.cfc.service.ReportMemberPermissionService;
 import com.etotem.cfc.service.TongueDiagnosisService;
 import com.etotem.cfc.service.BasicQuotaService;
 import com.etotem.cfc.service.BodyFatScaleService;
@@ -139,6 +140,9 @@ public class HealthReportController {
     @Resource
     private TongueDiagnosisService tongueDiagnosisService;
 
+    @Resource
+    private ReportMemberPermissionService reportMemberPermissionService;
+
     @Resource
     private FoodRecommendService foodRecommendService;
 
@@ -307,16 +311,36 @@ public class HealthReportController {
     @PostMapping("/report/list")
     public Result<List<HealthReport>> listReports(
             @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
             @RequestAttribute(value = "familyId", required = false) Long familyId) {
         // 返回条数:显式传 size 则截取前 size 条;不传则返回全部分页列表
         int size = params.get("size") != null ? Integer.parseInt(params.get("size").toString()) : 0;
         Long memberId = ParamUtils.getLong(params.get("memberId"));
         List<HealthReport> reports;
         if (memberId != null) {
+            // 成员级权限:管理员 / 本人 / 被授权成员可见,否则返回空列表
+            if (familyId == null || !reportMemberPermissionService.canViewMemberReports(userId, memberId, familyId)) {
+                return Result.success(new ArrayList<>());
+            }
             reports = healthReportService.getMemberReports(memberId, familyId);
         } else if (familyId != null) {
-            // 未指定 memberId 时,返回当前家庭的全部报告
-            reports = healthReportService.getFamilyReports(familyId);
+            // 未指定 memberId 时,返回当前家庭的全部报告(按权限过滤)
+            java.util.Set<Long> visible = reportMemberPermissionService.visibleIdentityIds(userId, familyId);
+            if (visible == null) {
+                // 管理员:全部可见
+                reports = healthReportService.getFamilyReports(familyId);
+            } else {
+                List<HealthReport> all = healthReportService.getFamilyReports(familyId);
+                reports = new ArrayList<>();
+                for (HealthReport r : all) {
+                    Long subj = r.getSubjectId();
+                    Long owner = r.getUserId();
+                    if ((subj != null && visible.contains(subj))
+                            || (subj == null && owner != null && visible.contains(owner))) {
+                        reports.add(r);
+                    }
+                }
+            }
         } else {
             // 无家庭信息时返回空列表,避免前端报错
             return Result.success(new ArrayList<>());
@@ -490,11 +514,32 @@ public class HealthReportController {
     @Operation(summary = "获取报告完整详情")
     @PostMapping("/report/detail")
     public Result<Map<String, Object>> getReportDetail(
-            @RequestBody Map<String, Object> params) {
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
         Long reportId = ParamUtils.getLong(params.get("reportId"));
         if (reportId == null) {
             return Result.error("reportId不能为空");
         }
+        HealthReport report = healthReportService.getReportById(reportId);
+        if (report == null) {
+            return Result.error("报告不存在");
+        }
+        // 权限校验:管理员 / 本人 / 被授权成员可见
+        Long reportFamilyId = report.getFamilyId() != null ? report.getFamilyId() : familyId;
+        if (reportFamilyId == null) {
+            return Result.error("无权查看该报告");
+        }
+        Long subjectId = report.getSubjectId();
+        if (subjectId == null) {
+            // 无归属成员:仅本人上传者或管理员可见
+            boolean self = report.getUserId() != null && report.getUserId().equals(userId);
+            if (!self && !reportMemberPermissionService.isFamilyAdmin(userId, reportFamilyId)) {
+                return Result.error("无权查看该报告");
+            }
+        } else if (!reportMemberPermissionService.canViewMemberReports(userId, subjectId, reportFamilyId)) {
+            return Result.error("无权查看该报告");
+        }
         Map<String, Object> detail = healthReportService.getReportDetail(reportId);
         return Result.success(detail);
     }
@@ -505,11 +550,30 @@ public class HealthReportController {
     @Operation(summary = "获取报告payload(编辑回填)")
     @PostMapping("/report/payload")
     public Result<Map<String, Object>> getReportPayload(
-            @RequestBody Map<String, Object> params) {
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
         Long reportId = ParamUtils.getLong(params.get("reportId"));
         if (reportId == null) {
             return Result.error("reportId不能为空");
         }
+        HealthReport report = healthReportService.getReportById(reportId);
+        if (report == null) {
+            return Result.error("报告不存在");
+        }
+        Long reportFamilyId = report.getFamilyId() != null ? report.getFamilyId() : familyId;
+        if (reportFamilyId == null) {
+            return Result.error("无权查看该报告");
+        }
+        Long subjectId = report.getSubjectId();
+        if (subjectId == null) {
+            boolean self = report.getUserId() != null && report.getUserId().equals(userId);
+            if (!self && !reportMemberPermissionService.isFamilyAdmin(userId, reportFamilyId)) {
+                return Result.error("无权查看该报告");
+            }
+        } else if (!reportMemberPermissionService.canViewMemberReports(userId, subjectId, reportFamilyId)) {
+            return Result.error("无权查看该报告");
+        }
         Map<String, Object> payload = healthReportService.getReportPayload(reportId);
         return Result.success(payload);
     }

+ 94 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ReportMemberPermissionController.java

@@ -0,0 +1,94 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.ReportMemberPermissionService;
+import com.etotem.cfc.util.ParamUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 报告成员级查看权限管理(家庭管理员操作)
+ * 端点前缀:/api/health/report/member-permission
+ */
+@Tag(name = "报告权限管理", description = "家庭管理员授权/撤销成员查看权限")
+@RestController
+@RequestMapping("/api/health/report/member-permission")
+public class ReportMemberPermissionController {
+
+    @Resource
+    private ReportMemberPermissionService reportMemberPermissionService;
+
+    @Operation(summary = "查询家庭全部授权记录")
+    @PostMapping("/list")
+    public Result<List<Map<String, Object>>> list(
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        try {
+            return Result.success(reportMemberPermissionService.listAllPermissions(userId, familyId));
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "查询某成员的已授权用户列表")
+    @PostMapping("/members/{memberId}")
+    public Result<List<Long>> listByMember(
+            @PathVariable("memberId") Long memberId,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        try {
+            return Result.success(reportMemberPermissionService.listGrantedUserIds(userId, familyId, memberId));
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "授权:让指定用户可查看某成员的报告")
+    @PostMapping("/grant")
+    public Result<Boolean> grant(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        Long targetMemberId = ParamUtils.getLong(params.get("targetMemberId"));
+        Long granteeUserId = ParamUtils.getLong(params.get("granteeUserId"));
+        if (targetMemberId == null || granteeUserId == null) {
+            return Result.error("targetMemberId 与 granteeUserId 不能为空");
+        }
+        try {
+            return Result.success(reportMemberPermissionService.grant(userId, familyId, targetMemberId, granteeUserId));
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "撤销授权")
+    @PostMapping("/revoke")
+    public Result<Boolean> revoke(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        Long targetMemberId = ParamUtils.getLong(params.get("targetMemberId"));
+        Long granteeUserId = ParamUtils.getLong(params.get("granteeUserId"));
+        if (targetMemberId == null || granteeUserId == null) {
+            return Result.error("targetMemberId 与 granteeUserId 不能为空");
+        }
+        try {
+            return Result.success(reportMemberPermissionService.revoke(userId, familyId, targetMemberId, granteeUserId));
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "查询用户可见的成员ID集合(null 表示管理员全部可见)")
+    @PostMapping("/visible-members")
+    public Result<java.util.Set<Long>> visibleMembers(
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        return Result.success(reportMemberPermissionService.visibleIdentityIds(userId, familyId));
+    }
+}

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportMemberPermission.java

@@ -0,0 +1,35 @@
+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;
+
+/**
+ * 报告成员级查看权限
+ * 家庭管理员授权「某用户」查看「某成员」的全部报告(健康报告 + DAN 测评报告)
+ */
+@Data
+@TableName("report_member_permissions")
+public class ReportMemberPermission implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 家庭ID(隔离作用域) */
+    private Long familyId;
+
+    /** 授权人(家庭管理员)的用户ID */
+    private Long authorizerUserId;
+
+    /** 被授权查看的成员ID(family_members.id) */
+    private Long targetMemberId;
+
+    /** 被授权用户的ID(能看报告的人) */
+    private Long granteeUserId;
+
+    private Date createdAt;
+}

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

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

+ 49 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentService.java

@@ -64,6 +64,15 @@ public class AssessmentService extends ServiceImpl<AssessmentMaterialMapper, Ass
     @Resource
     private com.etotem.cfc.mapper.ProductRecommendationLogMapper productRecommendationLogMapper;
 
+    @Resource
+    private com.etotem.cfc.mapper.FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private com.etotem.cfc.mapper.UserMapper userMapper;
+
+    @Resource
+    private ReportMemberPermissionService reportMemberPermissionService;
+
     public AssessmentMaterial getActiveMaterial() {
         LambdaQueryWrapper<AssessmentMaterial> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(AssessmentMaterial::getStatus, "active");
@@ -286,6 +295,12 @@ public class AssessmentService extends ServiceImpl<AssessmentMaterialMapper, Ass
     }
 
     public DanAssessmentResult getByAppointmentId(Long appointmentId) {
+        // 优先按主键查询(家长/教师通过 result/{id} 详情接口传入的可能是 dan_assessment_results.id)
+        DanAssessmentResult byId = danAssessmentResultMapper.selectById(appointmentId);
+        if (byId != null) {
+            return byId;
+        }
+        // 兼容旧语义:按 appointment_id 查
         LambdaQueryWrapper<DanAssessmentResult> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(DanAssessmentResult::getAppointmentId, appointmentId);
         wrapper.last("LIMIT 1");
@@ -297,6 +312,40 @@ public class AssessmentService extends ServiceImpl<AssessmentMaterialMapper, Ass
         if ("teacher".equals(role)) {
             return getResultsByGuide(userId);
         }
+        // 家庭管理员 / 普通成员:按可见身份过滤家庭DAN报告
+        if (userId != null) {
+            try {
+                com.etotem.cfc.entity.User user = userMapper.selectById(userId);
+                Long familyId = user != null ? user.getFamilyId() : null;
+                if (familyId != null) {
+                    java.util.Set<Long> visible = reportMemberPermissionService.visibleIdentityIds(userId, familyId);
+                    java.util.Set<Long> memberIds = new java.util.HashSet<>();
+                    if (visible == null) {
+                        // 管理员:家庭内所有成员 id + user_id
+                        java.util.List<com.etotem.cfc.entity.FamilyMember> members =
+                                familyMemberMapper.selectList(new LambdaQueryWrapper<com.etotem.cfc.entity.FamilyMember>()
+                                        .eq(com.etotem.cfc.entity.FamilyMember::getFamilyId, familyId));
+                        for (com.etotem.cfc.entity.FamilyMember m : members) {
+                            memberIds.add(m.getId());
+                            if (m.getUserId() != null) memberIds.add(m.getUserId());
+                        }
+                    } else {
+                        memberIds.addAll(visible);
+                    }
+                    if (memberIds.isEmpty()) {
+                        return new ArrayList<>();
+                    }
+                    LambdaQueryWrapper<DanAssessmentResult> q = new LambdaQueryWrapper<>();
+                    q.and(w -> w.in(DanAssessmentResult::getChildId, memberIds)
+                            .or().in(DanAssessmentResult::getFamilyMemberId, memberIds)
+                            .or().in(DanAssessmentResult::getTeacherId, memberIds));
+                    q.orderByDesc(DanAssessmentResult::getAssessmentDate);
+                    return danAssessmentResultMapper.selectList(q);
+                }
+            } catch (Exception e) {
+                log.warn("查询家庭DAN报告失败 userId={}: {}", userId, e.getMessage());
+            }
+        }
         return new ArrayList<>();
     }
 

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

@@ -558,6 +558,55 @@ if (!DanReportUpload.DIM_MIND.equals(dimension) && !DanReportUpload.DIM_WISDOM.e
         return Result.success(result);
     }
 
+    /**
+     * 供控制器做权限校验用:仅取记录本体(不做 blocks 拼装),避免重复查库
+     * 返回 null 表示记录不存在(控制器需 fallback 调 getDetail 以取得错误信息)
+     */
+    public DanReportUploadVO getDetailForPermission(Long uploadId) {
+        DanReportUpload upload = uploadMapper.selectById(uploadId);
+        if (upload == null || upload.getDeletedAt() != null) {
+            return null;
+        }
+        return DanReportUploadVO.fromEntity(upload);
+    }
+
+    /**
+     * 家庭级DAN报告列表(管理员,全部可见)
+     */
+    public Result<java.util.List<DanReportUploadVO>> listByFamily(Long familyId, String dimension) {
+        return doListByFamily(familyId, dimension, null);
+    }
+
+    /**
+     * 家庭级DAN报告列表(普通成员,按可见成员集合过滤)
+     */
+    public Result<java.util.List<DanReportUploadVO>> listByFamily(Long familyId, String dimension, java.util.Set<Long> visibleMemberIds) {
+        return doListByFamily(familyId, dimension, visibleMemberIds);
+    }
+
+    private Result<java.util.List<DanReportUploadVO>> doListByFamily(Long familyId, String dimension, java.util.Set<Long> visibleMemberIds) {
+        if (familyId == null) {
+            return Result.success(new java.util.ArrayList<>());
+        }
+        LambdaQueryWrapper<DanReportUpload> q = new LambdaQueryWrapper<>();
+        q.eq(DanReportUpload::getFamilyId, familyId)
+                .isNull(DanReportUpload::getDeletedAt)
+                .orderByDesc(DanReportUpload::getUploadedAt);
+        if (dimension != null && !dimension.isEmpty()) {
+            q.eq(DanReportUpload::getDimension, dimension);
+        }
+        if (visibleMemberIds != null) {
+            q.in(DanReportUpload::getFamilyMemberId, visibleMemberIds);
+        }
+        SortUtil.applySort(q);
+        java.util.List<DanReportUpload> list = uploadMapper.selectList(q);
+        java.util.List<DanReportUploadVO> result = new java.util.ArrayList<>();
+        for (DanReportUpload u : list) {
+            result.add(DanReportUploadVO.fromEntity(u));
+        }
+        return Result.success(result);
+    }
+
     // 丢弃草稿
     public Result<Void> discardUpload(Long uploadId, Long userId) {
         DanReportUpload upload = uploadMapper.selectById(uploadId);

+ 5 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -259,8 +259,10 @@ public class FamilyMemberService {
             vo.setId(fm.getId());
             vo.setSource("family_member");
             vo.setUserId(fm.getUserId());
-            // isAdmin:该成员是否为家庭创建者(仅创建者本人为 true)
-            vo.setIsAdmin(creatorId != null && creatorId.equals(fm.getUserId()));
+            // isAdmin:优先读 family_members.is_admin 列;该成员为家庭创建者时兜底 true
+            boolean memberIsAdmin = fm.getIsAdmin() != null && fm.getIsAdmin() == 1;
+            boolean isCreator = creatorId != null && creatorId.equals(fm.getUserId());
+            vo.setIsAdmin(memberIsAdmin || isCreator);
             vo.setNickname(resolveMemberNickname(fm));
             vo.setPhone(fm.getPhone());
             vo.setAvatar(fm.getAvatar());
@@ -353,6 +355,7 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
                 vo.setAge(calculateAge(p.getBirthday()));
             }
             vo.setGeneration(0);
+            // 家长账号:仅当其为家庭创建者时视为管理员(users 表无 is_admin 列)
             vo.setIsAdmin(creatorId != null && creatorId.equals(p.getId()));
             vo.setIsSelf(p.getId().equals(userId));
             // 家长账号均为可登录用户,不允许切换视角(与成员段"仅不可登录可切换"一致)

+ 325 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportMemberPermissionService.java

@@ -0,0 +1,325 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.ReportMemberPermission;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.ReportMemberPermissionMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+/**
+ * 报告成员级查看权限服务
+ *
+ * 模型:
+ *  - 家庭管理员(family_members.is_admin=1 或家庭创建者):可见家庭内全部成员的报告
+ *  - 普通成员:仅可见自己的报告;管理员可通过 report_member_permissions 授权某用户查看某成员的全部报告
+ *  - 授权按「成员」粒度:授权后该用户可查看该成员的所有报告(健康报告 + DAN 测评报告),含未来新报告
+ */
+@Service
+public class ReportMemberPermissionService {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportMemberPermissionService.class);
+
+    @Resource
+    private ReportMemberPermissionMapper permissionMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    /* ============ 管理员判定 ============ */
+
+    /**
+     * 判断用户是否为某家庭的管理员(family_members.is_admin=1 或该家庭创建者)
+     */
+    public boolean isFamilyAdmin(Long userId, Long familyId) {
+        if (userId == null || familyId == null) {
+            return false;
+        }
+        try {
+            Long count = familyMemberMapper.selectCount(
+                    new LambdaQueryWrapper<FamilyMember>()
+                            .eq(FamilyMember::getFamilyId, familyId)
+                            .eq(FamilyMember::getUserId, userId)
+                            .eq(FamilyMember::getIsAdmin, 1)
+                            .last("LIMIT 1"));
+            return count != null && count > 0;
+        } catch (Exception e) {
+            log.warn("判断家庭管理员失败 userId={}, familyId={}: {}", userId, familyId, e.getMessage());
+            return false;
+        }
+    }
+
+    /* ============ 可见性校验 ============ */
+
+    /**
+     * 用户是否可查看某成员的报告(管理员 / 本人 / 被授权)
+     *
+     * @param userId         查看者用户ID
+     * @param targetMemberId 被查看的成员ID(family_members.id)
+     * @param familyId       家庭ID(隔离作用域)
+     */
+    public boolean canViewMemberReports(Long userId, Long targetMemberId, Long familyId) {
+        if (userId == null || targetMemberId == null) {
+            return false;
+        }
+        // 1. 管理员:家庭内全部可见
+        if (familyId != null && isFamilyAdmin(userId, familyId)) {
+            return true;
+        }
+        // 2. 本人:看自己的报告
+        if (isOwnMember(userId, targetMemberId, familyId)) {
+            return true;
+        }
+        // 3. 被授权(targetMemberId 先归一化为 family_members.id,兼容 users.id 语义)
+        if (familyId != null) {
+            Long normalized = normalizeToMemberId(targetMemberId, familyId);
+            Long queryTarget = (normalized != null) ? normalized : targetMemberId;
+            Long count = permissionMapper.selectCount(
+                    new LambdaQueryWrapper<ReportMemberPermission>()
+                            .eq(ReportMemberPermission::getFamilyId, familyId)
+                            .eq(ReportMemberPermission::getTargetMemberId, queryTarget)
+                            .eq(ReportMemberPermission::getGranteeUserId, userId)
+                            .last("LIMIT 1"));
+            return count != null && count > 0;
+        }
+        return false;
+    }
+
+    /**
+     * 将成员ID归一化为 family_members.id:
+     *  - 已是 family_members.id 则原样返回
+     *  - 若是 users.id 则反查其在家庭内的 family_members 记录
+     *  - 查不到返回 null
+     */
+    private Long normalizeToMemberId(Long id, Long familyId) {
+        if (id == null) {
+            return null;
+        }
+        try {
+            FamilyMember m = familyMemberMapper.selectById(id);
+            if (m != null) {
+                return m.getId();
+            }
+            List<FamilyMember> byUser = familyMemberMapper.selectList(
+                    new LambdaQueryWrapper<FamilyMember>()
+                            .eq(FamilyMember::getUserId, id)
+                            .eq(FamilyMember::getFamilyId, familyId)
+                            .last("LIMIT 1"));
+            if (!byUser.isEmpty()) {
+                return byUser.get(0).getId();
+            }
+        } catch (Exception e) {
+            log.warn("归一化成员ID失败 id={}: {}", id, e.getMessage());
+        }
+        return null;
+    }
+
+    /**
+     * 判断用户是否为某成员的本人(family_members.user_id == userId)
+     * 兼容 targetMemberId 可能是 family_members.id 或 users.id 两种语义
+     */
+    private boolean isOwnMember(Long userId, Long targetMemberId, Long familyId) {
+        try {
+            // 直接匹配:family_members.id == targetMemberId 且 user_id == userId
+            List<FamilyMember> members = familyMemberMapper.selectList(
+                    new LambdaQueryWrapper<FamilyMember>()
+                            .eq(FamilyMember::getUserId, userId));
+            for (FamilyMember m : members) {
+                if (m.getId().equals(targetMemberId)) {
+                    return true;
+                }
+                // targetMemberId 可能是 users.id 语义
+                if (m.getUserId() != null && m.getUserId().equals(targetMemberId)) {
+                    return true;
+                }
+            }
+        } catch (Exception e) {
+            log.warn("判断本人成员失败 userId={}, targetMemberId={}: {}", userId, targetMemberId, e.getMessage());
+        }
+        return false;
+    }
+
+    /* ============ 授权管理(管理员操作) ============ */
+
+    /**
+     * 管理员授权:指定用户可查看指定成员的全部报告
+     */
+    public boolean grant(Long operatorUserId, Long familyId, Long targetMemberId, Long granteeUserId) {
+        // 仅管理员可授权
+        if (!isFamilyAdmin(operatorUserId, familyId)) {
+            throw new IllegalArgumentException("仅家庭管理员可进行报告授权");
+        }
+        if (targetMemberId == null || granteeUserId == null) {
+            throw new IllegalArgumentException("被授权成员与用户不能为空");
+        }
+        // 幂等:已存在则跳过
+        Long exists = permissionMapper.selectCount(
+                new LambdaQueryWrapper<ReportMemberPermission>()
+                        .eq(ReportMemberPermission::getFamilyId, familyId)
+                        .eq(ReportMemberPermission::getTargetMemberId, targetMemberId)
+                        .eq(ReportMemberPermission::getGranteeUserId, granteeUserId)
+                        .last("LIMIT 1"));
+        if (exists != null && exists > 0) {
+            return true;
+        }
+        ReportMemberPermission p = new ReportMemberPermission();
+        p.setFamilyId(familyId);
+        p.setAuthorizerUserId(operatorUserId);
+        p.setTargetMemberId(targetMemberId);
+        p.setGranteeUserId(granteeUserId);
+        p.setCreatedAt(new Date());
+        try {
+            permissionMapper.insert(p);
+            return true;
+        } catch (Exception e) {
+            log.warn("报告授权失败 familyId={}, targetMemberId={}, granteeUserId={}: {}",
+                    familyId, targetMemberId, granteeUserId, e.getMessage());
+            // 并发插入唯一键冲突:视为已授权
+            return true;
+        }
+    }
+
+    /**
+     * 管理员撤销授权
+     */
+    public boolean revoke(Long operatorUserId, Long familyId, Long targetMemberId, Long granteeUserId) {
+        if (!isFamilyAdmin(operatorUserId, familyId)) {
+            throw new IllegalArgumentException("仅家庭管理员可进行报告授权");
+        }
+        permissionMapper.delete(
+                new LambdaQueryWrapper<ReportMemberPermission>()
+                        .eq(ReportMemberPermission::getFamilyId, familyId)
+                        .eq(ReportMemberPermission::getTargetMemberId, targetMemberId)
+                        .eq(ReportMemberPermission::getGranteeUserId, granteeUserId));
+        return true;
+    }
+
+    /**
+     * 查询某成员的授权列表(管理员操作)
+     * 返回:已授权查看该成员的用户ID列表
+     */
+    public List<Long> listGrantedUserIds(Long operatorUserId, Long familyId, Long targetMemberId) {
+        if (!isFamilyAdmin(operatorUserId, familyId)) {
+            throw new IllegalArgumentException("仅家庭管理员可查询报告授权");
+        }
+        List<ReportMemberPermission> list = permissionMapper.selectList(
+                new LambdaQueryWrapper<ReportMemberPermission>()
+                        .eq(ReportMemberPermission::getFamilyId, familyId)
+                        .eq(ReportMemberPermission::getTargetMemberId, targetMemberId));
+        List<Long> ids = new ArrayList<>();
+        for (ReportMemberPermission p : list) {
+            ids.add(p.getGranteeUserId());
+        }
+        return ids;
+    }
+
+    /**
+     * 查询某家庭全部授权记录(管理员操作,前端渲染权限矩阵用)
+     */
+    public List<Map<String, Object>> listAllPermissions(Long operatorUserId, Long familyId) {
+        if (!isFamilyAdmin(operatorUserId, familyId)) {
+            throw new IllegalArgumentException("仅家庭管理员可查询报告授权");
+        }
+        List<ReportMemberPermission> list = permissionMapper.selectList(
+                new LambdaQueryWrapper<ReportMemberPermission>()
+                        .eq(ReportMemberPermission::getFamilyId, familyId)
+                        .orderByAsc(ReportMemberPermission::getTargetMemberId));
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (ReportMemberPermission p : list) {
+            Map<String, Object> m = new HashMap<>();
+            m.put("id", p.getId());
+            m.put("targetMemberId", p.getTargetMemberId());
+            m.put("granteeUserId", p.getGranteeUserId());
+            m.put("createdAt", p.getCreatedAt());
+            result.add(m);
+        }
+        return result;
+    }
+
+    /* ============ 列表过滤辅助 ============ */
+
+    /**
+     * 返回用户在家庭内「可见报告」的身份ID集合(含 family_members.id 与 users.id 两种语义):
+     *  - 管理员:返回 null,表示全部可见(调用方不做成员过滤)
+     *  - 普通成员:本人 userId + 本人成员ID + 被授权成员ID(含其绑定的 userId)
+     * 用于「家庭全部视图」按报告 subject_id/user_id 过滤。
+     */
+    public Set<Long> visibleIdentityIds(Long userId, Long familyId) {
+        if (familyId == null) {
+            return Collections.emptySet();
+        }
+        if (isFamilyAdmin(userId, familyId)) {
+            return null;
+        }
+        Set<Long> ids = new HashSet<>();
+        ids.add(userId); // users.id 语义(报告 user_id 直接匹配)
+        try {
+            List<FamilyMember> mine = familyMemberMapper.selectList(
+                    new LambdaQueryWrapper<FamilyMember>()
+                            .eq(FamilyMember::getFamilyId, familyId)
+                            .eq(FamilyMember::getUserId, userId));
+            for (FamilyMember m : mine) {
+                ids.add(m.getId());
+            }
+        } catch (Exception e) {
+            log.warn("查询本人成员失败 userId={}, familyId={}: {}", userId, familyId, e.getMessage());
+        }
+        List<ReportMemberPermission> grants = permissionMapper.selectList(
+                new LambdaQueryWrapper<ReportMemberPermission>()
+                        .eq(ReportMemberPermission::getFamilyId, familyId)
+                        .eq(ReportMemberPermission::getGranteeUserId, userId));
+        for (ReportMemberPermission p : grants) {
+            ids.add(p.getTargetMemberId());
+            try {
+                FamilyMember tm = familyMemberMapper.selectById(p.getTargetMemberId());
+                if (tm != null && tm.getUserId() != null) {
+                    ids.add(tm.getUserId());
+                }
+            } catch (Exception ignored) {
+            }
+        }
+        return ids;
+    }
+
+    /**
+     * 返回用户在家庭内「可见报告」的成员ID集合:
+     *  - 管理员:null 表示全部可见(调用方不做成员过滤)
+     *  - 普通成员:本人成员ID + 被授权的成员ID
+     */
+    public Set<Long> visibleMemberIds(Long userId, Long familyId) {
+        if (familyId == null) {
+            return Collections.emptySet();
+        }
+        Set<Long> result = new HashSet<>();
+        // 管理员:返回 null 语义(全部可见)
+        if (isFamilyAdmin(userId, familyId)) {
+            return null;
+        }
+        // 本人成员ID
+        try {
+            List<FamilyMember> mine = familyMemberMapper.selectList(
+                    new LambdaQueryWrapper<FamilyMember>()
+                            .eq(FamilyMember::getFamilyId, familyId)
+                            .eq(FamilyMember::getUserId, userId));
+            for (FamilyMember m : mine) {
+                result.add(m.getId());
+            }
+        } catch (Exception e) {
+            log.warn("查询本人成员失败 userId={}, familyId={}: {}", userId, familyId, e.getMessage());
+        }
+        // 被授权的成员ID
+        List<ReportMemberPermission> grants = permissionMapper.selectList(
+                new LambdaQueryWrapper<ReportMemberPermission>()
+                        .eq(ReportMemberPermission::getFamilyId, familyId)
+                        .eq(ReportMemberPermission::getGranteeUserId, userId));
+        for (ReportMemberPermission p : grants) {
+            result.add(p.getTargetMemberId());
+        }
+        return result;
+    }
+}

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

@@ -1859,6 +1859,20 @@ CREATE TABLE IF NOT EXISTS health_report_permissions (
     INDEX idx_user_id (user_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告查看权限表';
 
+-- 报告成员级查看权限表(管理员授权某用户查看某成员的全部报告)
+CREATE TABLE IF NOT EXISTS report_member_permissions (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    family_id BIGINT NOT NULL COMMENT '家庭ID(隔离作用域)',
+    authorizer_user_id BIGINT NOT NULL COMMENT '授权人(家庭管理员)的用户ID',
+    target_member_id BIGINT NOT NULL COMMENT '被授权查看的成员ID(family_members.id)',
+    grantee_user_id BIGINT NOT NULL COMMENT '被授权用户的ID(能看报告的人)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '授权时间',
+    UNIQUE KEY uk_perm (family_id, target_member_id, grantee_user_id),
+    INDEX idx_target_member (target_member_id),
+    INDEX idx_grantee_user (grantee_user_id),
+    INDEX idx_family_id (family_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告成员级查看权限(管理员授权某用户查看某成员的全部报告)';
+
 -- 健康指标明细表
 CREATE TABLE IF NOT EXISTS health_indicators (
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',

+ 275 - 2
cfc-frontend/pages/profile-extra/family-members.vue

@@ -106,10 +106,49 @@
           <button class="detail-action-btn action-questionnaire" @click="doQuestionnaire(detailMember)">关系问卷</button>
           <button class="detail-action-btn action-interaction" @click="recordInteraction(detailMember)">记录互动</button>
           <button class="detail-action-btn action-kick" @click="confirmKick(detailMember)" v-if="isAdmin && detailMember && !detailMember.isSelf">删除成员</button>
+          <button class="detail-action-btn action-perm" @click="openReportPermission(detailMember)" v-if="isAdmin && detailMember">报告权限</button>
        </view>
      </view>
     </view>
 
+    <!-- ===== 报告权限管理弹窗(管理员授权成员可查看其他成员报告) ===== -->
+    <view class="modal-mask" v-if="showPermModal" @click="closeReportPermission">
+      <view class="perm-modal-card" @click.stop>
+        <view class="perm-header">
+          <text class="perm-title">📋 报告查看权限</text>
+          <text class="perm-close" @click="closeReportPermission">×</text>
+        </view>
+        <view class="perm-subtitle">
+          管理「{{ permTargetMember && (permTargetMember.nickname || permTargetMember.name || '该成员') }}」的报告查看权限
+        </view>
+        <view class="perm-desc">被授权的成员可查看该成员的全部报告(健康 + DAN 测评)</view>
+
+        <!-- 已授权列表 -->
+        <view class="perm-section">
+          <text class="perm-section-title">已授权查看的成员</text>
+          <view class="perm-granted-list" v-if="permGrantedUsers.length > 0">
+            <view class="perm-granted-item" v-for="uid in permGrantedUsers" :key="uid">
+              <text class="perm-granted-name">{{ permUserNickname(uid) }}</text>
+              <text class="perm-revoke-btn" @click="revokePerm(uid)">撤销</text>
+            </view>
+          </view>
+          <view class="perm-empty" v-else>暂无授权(仅管理员可见)</view>
+        </view>
+
+        <!-- 添加授权 -->
+        <view class="perm-section">
+          <text class="perm-section-title">添加授权</text>
+          <view class="perm-add-row">
+            <picker :range="permMembers" range-key="label" @change="onPermGranteeChange">
+              <view class="perm-picker">{{ permGranteeLabel || '选择成员' }} ▾</view>
+            </picker>
+            <button class="perm-add-btn" :disabled="!permGranteeUserId || permGranting" @click="addPerm">授权</button>
+          </view>
+          <view class="perm-empty" v-if="permMembers.length === 0">家庭内暂无其他可登录成员</view>
+        </view>
+      </view>
+    </view>
+
     <!-- ===== 底部操作栏 ===== -->
     <view class="bottom-bar">
       <button class="bottom-btn btn-add" @click="onAddMember">+ 添加成员</button>
@@ -135,7 +174,11 @@ import {
   updateFamilyMember,
   getPendingJoinRequests,
   approveJoinRequest,
-  rejectJoinRequest, reportAppEnter
+  rejectJoinRequest,
+  getMemberGrantedUsers,
+  grantReportMemberPermission,
+  revokeReportMemberPermission,
+  reportAppEnter
 } from '../../utils/api.js'
 import store from '../../store/index.js'
 import GenerationPicker from '@/components/GenerationPicker.vue'
@@ -163,6 +206,15 @@ export default {
       joinRequests: [],
       shareCardImage: '',
 
+      // ===== 报告权限管理弹窗状态 =====
+      showPermModal: false,
+      permTargetMember: null,
+      permGrantedUsers: [],       // 已授权用户ID列表
+      permLoading: false,
+      permGranteeUserId: null,    // 待添加的被授权用户
+      permMembers: [],            // 可选被授权成员(有绑定userId的)
+      permGranting: false,
+
       }
   },
   computed: {
@@ -290,7 +342,8 @@ export default {
           this.familyId = data.familyId
           this.familyName = data.familyName
           this.creatorId = data.creatorId
-          // creatorId 来自后端 JSON(数字),userId 来自 storage(可能为字符串),用 String 归一避免 === 恒 false 导致管理员按钮不显示
+          // 管理员判定:优先本人成员记录 isAdmin(后端已改为 family_members.is_admin 列语义);
+          // 兜底兼容旧版:creatorId 来自后端 JSON(数字),userId 来自 storage(可能为字符串),用 String 归一避免 === 恒 false
           this.isAdmin = String(this.creatorId) === String(this.userId)
           var arr = data.members
           if (!(arr instanceof Array)) {
@@ -299,6 +352,15 @@ export default {
             arr = (legacy && legacy.code === 200 && legacy.data instanceof Array) ? legacy.data : []
           }
           this.members = arr || []
+          // 以本人成员记录的 isAdmin 为准(is_admin 列;多管理员场景下 creatorId 判断会漏掉非创建者管理员)
+          if (this.members && this.members.length) {
+            for (var i = 0; i < this.members.length; i++) {
+              var mm = this.members[i]
+              if (mm.isSelf && mm.isAdmin) {
+                this.isAdmin = true
+              }
+            }
+          }
           this.buildScoreMap(this.members)
         }
       } catch (e) {
@@ -330,6 +392,108 @@ export default {
       // 后端 updateMember 会自行验证是否关联登录账号/手机,若不可编辑会返回错误
       return member.source === 'family_member' || member.source === 'parent'
     },
+    // ===== 报告查看权限管理(管理员) =====
+    openReportPermission(member) {
+      var self = this
+      this.permTargetMember = member
+      this.permGranteeUserId = null
+      this.permGranting = false
+      this.showPermModal = true
+      // 可被授权的成员:家庭内除目标成员外、有绑定 userId 的成员
+      var opts = (this.members || []).filter(function(m) {
+        return m.userId && String(m.userId) !== String(self.userId)
+          && String(m.id) !== String(member.id)
+      })
+      this.permMembers = opts.map(function(m) {
+        return { label: (m.nickname || m.name || '成员') + '(ID ' + m.userId + ')', value: m.userId }
+      })
+      // 加载已授权列表
+      this.permLoading = true
+      this.permGrantedUsers = []
+      getMemberGrantedUsers(member.id).then(function(res) {
+        self.permLoading = false
+        if (res && res.code === 200) {
+          self.permGrantedUsers = (res.data || []).map(String)
+        } else {
+          uni.showToast({ title: (res && res.message) || '加载授权失败', icon: 'none' })
+        }
+      }).catch(function() {
+        self.permLoading = false
+        uni.showToast({ title: '加载授权失败', icon: 'none' })
+      })
+    },
+    closeReportPermission() {
+      this.showPermModal = false
+    },
+    permUserNickname(uid) {
+      var self = this
+      var found = (this.members || []).filter(function(m) {
+        return m.userId && String(m.userId) === String(uid)
+      })
+      return found.length > 0 ? (found[0].nickname || found[0].name || ('用户' + uid)) : ('用户' + uid)
+    },
+    onPermGranteeChange(e) {
+      var idx = Number(e.detail.value)
+      var opt = this.permMembers[idx]
+      this.permGranteeUserId = opt ? String(opt.value) : null
+    },
+    get permGranteeLabel() {
+      var self = this
+      var found = this.permMembers.filter(function(m) { return String(m.value) === String(self.permGranteeUserId) })
+      return found.length > 0 ? found[0].label : ''
+    },
+    addPerm() {
+      var self = this
+      if (!this.permTargetMember || !this.permGranteeUserId) {
+        uni.showToast({ title: '请选择成员', icon: 'none' })
+        return
+      }
+      this.permGranting = true
+      grantReportMemberPermission(this.permTargetMember.id, Number(this.permGranteeUserId)).then(function(res) {
+        self.permGranting = false
+        if (res && res.code === 200) {
+          uni.showToast({ title: '授权成功', icon: 'success' })
+          self.permGranteeUserId = null
+          // 刷新已授权列表
+          getMemberGrantedUsers(self.permTargetMember.id).then(function(r) {
+            if (r && r.code === 200) {
+              self.permGrantedUsers = (r.data || []).map(String)
+            }
+          })
+        } else {
+          uni.showToast({ title: (res && res.message) || '授权失败', icon: 'none' })
+        }
+      }).catch(function() {
+        self.permGranting = false
+        uni.showToast({ title: '授权失败', icon: 'none' })
+      })
+    },
+    revokePerm(uid) {
+      var self = this
+      var name = this.permUserNickname(uid)
+      uni.showModal({
+        title: '撤销授权',
+        content: '确定撤销「' + name + '」查看该成员报告权限吗?',
+        success: function(res) {
+          if (res.confirm) {
+            revokeReportMemberPermission(self.permTargetMember.id, Number(uid)).then(function(r) {
+              if (r && r.code === 200) {
+                uni.showToast({ title: '已撤销', icon: 'success' })
+                getMemberGrantedUsers(self.permTargetMember.id).then(function(rr) {
+                  if (rr && rr.code === 200) {
+                    self.permGrantedUsers = (rr.data || []).map(String)
+                  }
+                })
+              } else {
+                uni.showToast({ title: (r && r.message) || '撤销失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.showToast({ title: '撤销失败', icon: 'none' })
+            })
+          }
+        }
+      })
+    },
     onAddMember() {
       uni.navigateTo({ url: '/pages/family/add-member' })
     },
@@ -979,6 +1143,115 @@ export default {
 }
 
 /* ===== 操作按钮区(2列flex网格) ===== */
+.perm-modal-card {
+  width: 620rpx;
+  max-height: 78vh;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  padding: 32rpx 28rpx;
+  display: flex;
+  flex-direction: column;
+  overflow-y: auto;
+}
+.perm-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12rpx;
+}
+.perm-title {
+  font-size: 34rpx;
+  font-weight: 600;
+  color: #1E293B;
+}
+.perm-close {
+  font-size: 40rpx;
+  color: #94A3B8;
+  padding: 0 12rpx;
+}
+.perm-subtitle {
+  font-size: 26rpx;
+  color: #475569;
+  margin-bottom: 6rpx;
+}
+.perm-desc {
+  font-size: 22rpx;
+  color: #94A3B8;
+  margin-bottom: 20rpx;
+}
+.perm-section {
+  margin-bottom: 24rpx;
+}
+.perm-section-title {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #334155;
+  margin-bottom: 12rpx;
+  display: block;
+}
+.perm-granted-list {
+  border: 1rpx solid #E2E8F0;
+  border-radius: 16rpx;
+  padding: 8rpx 16rpx;
+}
+.perm-granted-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16rpx 4rpx;
+  border-bottom: 1rpx solid #F1F5F9;
+}
+.perm-granted-item:last-child {
+  border-bottom: none;
+}
+.perm-granted-name {
+  font-size: 26rpx;
+  color: #1E293B;
+}
+.perm-revoke-btn {
+  font-size: 24rpx;
+  color: #EF4444;
+  padding: 4rpx 12rpx;
+}
+.perm-empty {
+  font-size: 24rpx;
+  color: #94A3B8;
+  padding: 20rpx 0;
+  text-align: center;
+}
+.perm-add-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 16rpx;
+}
+.perm-picker {
+  flex: 1;
+  background: #F8FAFC;
+  border: 1rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  font-size: 26rpx;
+  color: #334155;
+}
+.perm-add-btn {
+  width: 140rpx;
+  background: #6366F1;
+  color: #FFFFFF;
+  font-size: 26rpx;
+  border-radius: 12rpx;
+  padding: 12rpx 0;
+}
+.perm-add-btn[disabled] {
+  background: #C7D2FE;
+  color: #EEF2FF;
+}
+.detail-action-btn.action-perm {
+  background: #F59E0B;
+  color: #FFFFFF;
+}
 .detail-actions {
   display: flex;
   flex-wrap: wrap;

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

@@ -3426,3 +3426,25 @@ export function getReminderConfigByUser(userId) {
 export function saveReminderConfigByUser(data) {
   return request('/api/admin/task/reminder-config/save', 'POST', data)
 }
+
+
+// ===================== 报告成员级查看权限(家庭管理员授权)=====================
+/** 查询家庭全部授权记录(管理员) */
+export const getReportMemberPermissions = () =>
+  request('/api/health/report/member-permission/list', 'POST', {})
+
+/** 查询某成员已授权用户列表(管理员) */
+export const getMemberGrantedUsers = (memberId) =>
+  request('/api/health/report/member-permission/members/' + memberId, 'POST', {})
+
+/** 授权:让指定用户可查看某成员的报告(管理员) */
+export const grantReportMemberPermission = (targetMemberId, granteeUserId) =>
+  request('/api/health/report/member-permission/grant', 'POST', { targetMemberId, granteeUserId })
+
+/** 撤销授权(管理员) */
+export const revokeReportMemberPermission = (targetMemberId, granteeUserId) =>
+  request('/api/health/report/member-permission/revoke', 'POST', { targetMemberId, granteeUserId })
+
+/** 查询当前用户可见成员ID集合(null=管理员全部可见) */
+export const getMyVisibleReportMembers = () =>
+  request('/api/health/report/member-permission/visible-members', 'POST', {})