Ver Fonte

chore: auto bump version and changelog [skip ci]

iwt há 3 semanas atrás
pai
commit
dc444196d2

+ 78 - 0
cfc-backend/src/main/java/com/etotem/cfc/common/AdminOrgAccess.java

@@ -0,0 +1,78 @@
+package com.etotem.cfc.common;
+
+import com.etotem.cfc.entity.Organization;
+import com.etotem.cfc.service.OrganizationService;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 组织管理接口鉴权工具类。
+ *
+ * 规则(规格文档第五节):
+ * 1. 平台角色(admin/operator/finance)→ 放行,可指定任意 orgId
+ * 2. roles 含 org_admin:查其管理的活跃组织,取 orgId 与请求比较
+ *    —— 组织已停用 → 拒绝;orgId 不一致 → 拒绝
+ * 3. 其他 → 403
+ *
+ * 返回 null 表示通过,否则返回应直接回传的错误 Result。
+ */
+public final class AdminOrgAccess {
+
+    private AdminOrgAccess() {
+    }
+
+    public static Result<?> requireOrgAccess(Long requestOrgId, Long userId,
+                                             Object roleObj, Object rolesObj,
+                                             OrganizationService orgService) {
+        List<String> roles = extractRoles(roleObj, rolesObj);
+
+        // 1. 平台角色直接放行
+        if (AdminRoles.hasAnyPlatformRole(roles)) {
+            return null;
+        }
+
+        // 2. org_admin:查自己管理的组织
+        if (AdminRoles.isOrgAdmin(roles)) {
+            Organization org = orgService.getByAdminId(userId);
+            if (org == null) {
+                return Result.error(403, "您未关联任何组织");
+            }
+            if (!"active".equals(org.getStatus())) {
+                return Result.error(403, "组织已停用");
+            }
+            if (requestOrgId == null || !org.getId().equals(requestOrgId)) {
+                return Result.error(403, "无权访问该组织");
+            }
+            return null;
+        }
+
+        // 3. 其他角色拒绝
+        return Result.error(403, "无权限访问该接口");
+    }
+
+    /** 仅平台角色可访问(create/disable 等全平台管理操作) */
+    public static Result<?> requirePlatform(Object roleObj, Object rolesObj) {
+        List<String> roles = extractRoles(roleObj, rolesObj);
+        if (AdminRoles.hasAnyPlatformRole(roles)) {
+            return null;
+        }
+        return Result.error(403, "无权限访问该接口");
+    }
+
+    /** 提取 role/roles 为统一角色列表(与 AdminController.requirePlatformRole 同逻辑) */
+    private static List<String> extractRoles(Object roleObj, Object rolesObj) {
+        String role = roleObj != null ? String.valueOf(roleObj) : null;
+        if (rolesObj instanceof List) {
+            return new ArrayList<>((List<String>) rolesObj);
+        }
+        if (rolesObj instanceof String) {
+            return Collections.singletonList((String) rolesObj);
+        }
+        if (role != null) {
+            return Collections.singletonList(role);
+        }
+        return Collections.emptyList();
+    }
+}

+ 166 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminOrgController.java

@@ -0,0 +1,166 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.AdminOrgAccess;
+import com.etotem.cfc.common.AdminRoles;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Organization;
+import com.etotem.cfc.service.OrganizationService;
+import com.etotem.cfc.service.UserSearchService;
+import com.etotem.cfc.util.ParamUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 组织管理接口(平台角色全量;org_admin 仅自己组织)。
+ */
+@RestController
+@RequestMapping("/api/admin/org")
+public class AdminOrgController {
+
+    @Resource
+    private OrganizationService organizationService;
+
+    @Resource
+    private UserSearchService userSearchService;
+
+    /** 组织列表(平台角色全量;org_admin 仅显示自己组织) */
+    @PostMapping("/list")
+    public Result<Page<Organization>> list(@RequestBody Map<String, Object> params,
+                                           @RequestAttribute(value = "userId", required = false) Long userId,
+                                           @RequestAttribute(value = "role", required = false) Object roleObj,
+                                           @RequestAttribute(value = "roles", required = false) Object rolesObj) {
+        String keyword = (String) params.get("keyword");
+        String type = (String) params.get("type");
+        String status = (String) params.get("status");
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 10;
+        // org_admin 视角:只看自己组织(忽略传入筛选)
+        Long scopeOrgId = null;
+        if (AdminRoles.isOrgAdminOnly(extractRoles(roleObj, rolesObj))) {
+            Organization own = organizationService.getByAdminId(userId);
+            scopeOrgId = own != null ? own.getId() : null;
+            if (scopeOrgId == null) {
+                Page<Organization> empty = new Page<>(page, size);
+                empty.setRecords(Collections.emptyList());
+                empty.setTotal(0);
+                return Result.success(empty);
+            }
+        }
+        return Result.success(organizationService.list(keyword, type, status, page, size, scopeOrgId));
+    }
+
+    /** 创建组织(同步创建管理员账号),仅平台角色 */
+    @PostMapping("/create")
+    public Result<Map<String, Object>> create(@RequestBody Map<String, Object> params,
+                                              @RequestAttribute(value = "role", required = false) Object roleObj,
+                                              @RequestAttribute(value = "roles", required = false) Object rolesObj) {
+        Result<?> denied = AdminOrgAccess.requirePlatform(roleObj, rolesObj);
+        if (denied != null) {
+            return (Result<Map<String, Object>>) denied;
+        }
+        String name = (String) params.get("name");
+        String type = (String) params.get("type");
+        String code = (String) params.get("code");
+        String adminPhone = (String) params.get("adminPhone");
+        String adminRealName = (String) params.get("adminRealName");
+        return Result.success(organizationService.create(name, type, code, adminPhone, adminRealName));
+    }
+
+    /** 更新组织信息(平台角色全量;org_admin 仅自己组织) */
+    @PostMapping("/update")
+    public Result<Void> update(@RequestBody Map<String, Object> params,
+                               @RequestAttribute(value = "userId", required = false) Long userId,
+                               @RequestAttribute(value = "role", required = false) Object roleObj,
+                               @RequestAttribute(value = "roles", required = false) Object rolesObj) {
+        Long id = ParamUtils.getLong(params.get("id"));
+        Result<?> denied = AdminOrgAccess.requireOrgAccess(id, userId, roleObj, rolesObj, organizationService);
+        if (denied != null) {
+            return (Result<Void>) denied;
+        }
+        String name = (String) params.get("name");
+        String type = (String) params.get("type");
+        String contactName = (String) params.get("contactName");
+        String contactPhone = (String) params.get("contactPhone");
+        String description = (String) params.get("description");
+        boolean success = organizationService.update(id, name, type, contactName, contactPhone, description);
+        return success ? Result.success(null) : Result.error("更新失败");
+    }
+
+    /** 停用/启用组织,仅平台角色 */
+    @PostMapping("/disable")
+    public Result<Void> disable(@RequestBody Map<String, Object> params,
+                                @RequestAttribute(value = "role", required = false) Object roleObj,
+                                @RequestAttribute(value = "roles", required = false) Object rolesObj) {
+        Result<?> denied = AdminOrgAccess.requirePlatform(roleObj, rolesObj);
+        if (denied != null) {
+            return (Result<Void>) denied;
+        }
+        Long id = ParamUtils.getLong(params.get("id"));
+        String status = (String) params.get("status");
+        boolean success = organizationService.disable(id, status);
+        return success ? Result.success(null) : Result.error("操作失败");
+    }
+
+    /** 组织详情(平台角色全量;org_admin 仅自己组织) */
+    @PostMapping("/detail")
+    public Result<Organization> detail(@RequestBody Map<String, Object> params,
+                                       @RequestAttribute(value = "userId", required = false) Long userId,
+                                       @RequestAttribute(value = "role", required = false) Object roleObj,
+                                       @RequestAttribute(value = "roles", required = false) Object rolesObj) {
+        Long id = ParamUtils.getLong(params.get("id"));
+        Result<?> denied = AdminOrgAccess.requireOrgAccess(id, userId, roleObj, rolesObj, organizationService);
+        if (denied != null) {
+            return (Result<Organization>) denied;
+        }
+        return Result.success(organizationService.detail(id));
+    }
+
+    /** 组织管理员获取自己所属的组织(登录首页跳转目标,停用返回错误) */
+    @PostMapping("/my-org")
+    public Result<Organization> myOrg(@RequestAttribute(value = "userId", required = false) Long userId,
+                                      @RequestAttribute(value = "role", required = false) Object roleObj,
+                                      @RequestAttribute(value = "roles", required = false) Object rolesObj) {
+        Organization org = organizationService.getMyOrg(userId);
+        if (org == null) {
+            return Result.error("您未关联任何组织");
+        }
+        if (!"active".equals(org.getStatus())) {
+            return Result.error("组织已停用");
+        }
+        return Result.success(org);
+    }
+
+    /** 按手机号/昵称搜索平台用户(添加成员时下拉选人用) */
+    @PostMapping("/user/search")
+    public Result<java.util.List<Map<String, Object>>> userSearch(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute(value = "role", required = false) Object roleObj,
+            @RequestAttribute(value = "roles", required = false) Object rolesObj) {
+        String keyword = (String) params.get("keyword");
+        String memberType = (String) params.get("memberType");
+        return Result.success(userSearchService.search(keyword, memberType));
+    }
+
+    private static List<String> extractRoles(Object roleObj, Object rolesObj) {
+        if (rolesObj instanceof List) {
+            return new ArrayList<>((List<String>) rolesObj);
+        }
+        if (rolesObj instanceof String) {
+            return Collections.singletonList((String) rolesObj);
+        }
+        if (roleObj != null) {
+            return Collections.singletonList(String.valueOf(roleObj));
+        }
+        return Collections.emptyList();
+    }
+}

+ 95 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminOrgMemberController.java

@@ -0,0 +1,95 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.AdminOrgAccess;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.OrgMemberVO;
+import com.etotem.cfc.service.OrganizationService;
+import com.etotem.cfc.service.OrgMemberService;
+import com.etotem.cfc.util.ParamUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 组织成员管理接口(平台角色全量;org_admin 仅自己组织)。
+ */
+@RestController
+@RequestMapping("/api/admin/org/member")
+public class AdminOrgMemberController {
+
+    @Resource
+    private OrgMemberService orgMemberService;
+
+    @Resource
+    private OrganizationService organizationService;
+
+    /** 成员列表(含家庭/订单摘要),默认只返回 status=active */
+    @PostMapping("/list")
+    public Result<List<OrgMemberVO>> memberList(@RequestBody Map<String, Object> params,
+                                                @RequestAttribute(value = "userId", required = false) Long userId,
+                                                @RequestAttribute(value = "role", required = false) Object roleObj,
+                                                @RequestAttribute(value = "roles", required = false) Object rolesObj) {
+        Long orgId = ParamUtils.getLong(params.get("orgId"));
+        Result<?> denied = AdminOrgAccess.requireOrgAccess(orgId, userId, roleObj, rolesObj, organizationService);
+        if (denied != null) {
+            return (Result<List<OrgMemberVO>>) denied;
+        }
+        return Result.success(orgMemberService.memberList(orgId));
+    }
+
+    /** 添加成员 */
+    @PostMapping("/add")
+    public Result<Void> addMember(@RequestBody Map<String, Object> params,
+                                  @RequestAttribute(value = "userId", required = false) Long userId,
+                                  @RequestAttribute(value = "role", required = false) Object roleObj,
+                                  @RequestAttribute(value = "roles", required = false) Object rolesObj) {
+        Long orgId = ParamUtils.getLong(params.get("orgId"));
+        Long memberUserId = ParamUtils.getLong(params.get("userId"));
+        String memberType = (String) params.get("memberType");
+        Result<?> denied = AdminOrgAccess.requireOrgAccess(orgId, userId, roleObj, rolesObj, organizationService);
+        if (denied != null) {
+            return (Result<Void>) denied;
+        }
+        boolean success = orgMemberService.addMember(orgId, memberUserId, memberType);
+        return success ? Result.success(null) : Result.error("添加成员失败");
+    }
+
+    /** 移除成员(软删,status=inactive) */
+    @PostMapping("/remove")
+    public Result<Void> removeMember(@RequestBody Map<String, Object> params,
+                                     @RequestAttribute(value = "userId", required = false) Long userId,
+                                     @RequestAttribute(value = "role", required = false) Object roleObj,
+                                     @RequestAttribute(value = "roles", required = false) Object rolesObj) {
+        Long orgId = ParamUtils.getLong(params.get("orgId"));
+        Long memberUserId = ParamUtils.getLong(params.get("userId"));
+        Result<?> denied = AdminOrgAccess.requireOrgAccess(orgId, userId, roleObj, rolesObj, organizationService);
+        if (denied != null) {
+            return (Result<Void>) denied;
+        }
+        boolean success = orgMemberService.removeMember(orgId, memberUserId);
+        return success ? Result.success(null) : Result.error("移除成员失败");
+    }
+
+    /** 变更成员类型(planner ↔ user),同时复活 inactive 成员为 active */
+    @PostMapping("/change-type")
+    public Result<Void> changeType(@RequestBody Map<String, Object> params,
+                                   @RequestAttribute(value = "userId", required = false) Long userId,
+                                   @RequestAttribute(value = "role", required = false) Object roleObj,
+                                   @RequestAttribute(value = "roles", required = false) Object rolesObj) {
+        Long orgId = ParamUtils.getLong(params.get("orgId"));
+        Long memberUserId = ParamUtils.getLong(params.get("userId"));
+        String memberType = (String) params.get("memberType");
+        Result<?> denied = AdminOrgAccess.requireOrgAccess(orgId, userId, roleObj, rolesObj, organizationService);
+        if (denied != null) {
+            return (Result<Void>) denied;
+        }
+        boolean success = orgMemberService.changeType(orgId, memberUserId, memberType);
+        return success ? Result.success(null) : Result.error("变更成员类型失败");
+    }
+}

+ 104 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminOrgStatsController.java

@@ -0,0 +1,104 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.AdminOrgAccess;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.OrganizationService;
+import com.etotem.cfc.service.OrgStatsService;
+import com.etotem.cfc.util.ParamUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 组织统计大屏接口(平台角色全量;org_admin 仅自己组织)。
+ */
+@RestController
+@RequestMapping("/api/admin/org/stats")
+public class AdminOrgStatsController {
+
+    @Resource
+    private OrgStatsService orgStatsService;
+
+    @Resource
+    private OrganizationService organizationService;
+
+    /** 总览卡片:成员总数/规划师数/家庭数/总订单/总消费 */
+    @PostMapping("/overview")
+    public Result<Map<String, Object>> overview(@RequestBody Map<String, Object> params,
+                                                @RequestAttribute(value = "userId", required = false) Long userId,
+                                                @RequestAttribute(value = "role", required = false) Object roleObj,
+                                                @RequestAttribute(value = "roles", required = false) Object rolesObj) {
+        Long orgId = ParamUtils.getLong(params.get("orgId"));
+        Result<?> denied = AdminOrgAccess.requireOrgAccess(orgId, userId, roleObj, rolesObj, organizationService);
+        if (denied != null) {
+            return (Result<Map<String, Object>>) denied;
+        }
+        return Result.success(orgStatsService.overview(orgId));
+    }
+
+    /** 家庭维度:活跃家庭数/任务完成率/打卡率 */
+    @PostMapping("/families")
+    public Result<Map<String, Object>> families(@RequestBody Map<String, Object> params,
+                                                @RequestAttribute(value = "userId", required = false) Long userId,
+                                                @RequestAttribute(value = "role", required = false) Object roleObj,
+                                                @RequestAttribute(value = "roles", required = false) Object rolesObj) {
+        Long orgId = ParamUtils.getLong(params.get("orgId"));
+        Result<?> denied = AdminOrgAccess.requireOrgAccess(orgId, userId, roleObj, rolesObj, organizationService);
+        if (denied != null) {
+            return (Result<Map<String, Object>>) denied;
+        }
+        return Result.success(orgStatsService.families(orgId,
+                (String) params.get("startDate"), (String) params.get("endDate")));
+    }
+
+    /** 活跃度趋势(按天):任务完成/打卡/报告上传 */
+    @PostMapping("/active")
+    public Result<List<Map<String, Object>>> active(@RequestBody Map<String, Object> params,
+                                                    @RequestAttribute(value = "userId", required = false) Long userId,
+                                                    @RequestAttribute(value = "role", required = false) Object roleObj,
+                                                    @RequestAttribute(value = "roles", required = false) Object rolesObj) {
+        Long orgId = ParamUtils.getLong(params.get("orgId"));
+        Result<?> denied = AdminOrgAccess.requireOrgAccess(orgId, userId, roleObj, rolesObj, organizationService);
+        if (denied != null) {
+            return (Result<List<Map<String, Object>>>) denied;
+        }
+        return Result.success(orgStatsService.active(orgId,
+                (String) params.get("startDate"), (String) params.get("endDate")));
+    }
+
+    /** 营收:订单金额/会员订阅/佣金,按天分组 */
+    @PostMapping("/revenue")
+    public Result<List<Map<String, Object>>> revenue(@RequestBody Map<String, Object> params,
+                                                     @RequestAttribute(value = "userId", required = false) Long userId,
+                                                     @RequestAttribute(value = "role", required = false) Object roleObj,
+                                                     @RequestAttribute(value = "roles", required = false) Object rolesObj) {
+        Long orgId = ParamUtils.getLong(params.get("orgId"));
+        Result<?> denied = AdminOrgAccess.requireOrgAccess(orgId, userId, roleObj, rolesObj, organizationService);
+        if (denied != null) {
+            return (Result<List<Map<String, Object>>>) denied;
+        }
+        return Result.success(orgStatsService.revenue(orgId,
+                (String) params.get("startDate"), (String) params.get("endDate")));
+    }
+
+    /** 趋势图:活跃成员数/订单数/收入(分),按天分组 */
+    @PostMapping("/trend")
+    public Result<List<Map<String, Object>>> trend(@RequestBody Map<String, Object> params,
+                                                   @RequestAttribute(value = "userId", required = false) Long userId,
+                                                   @RequestAttribute(value = "role", required = false) Object roleObj,
+                                                   @RequestAttribute(value = "roles", required = false) Object rolesObj) {
+        Long orgId = ParamUtils.getLong(params.get("orgId"));
+        Result<?> denied = AdminOrgAccess.requireOrgAccess(orgId, userId, roleObj, rolesObj, organizationService);
+        if (denied != null) {
+            return (Result<List<Map<String, Object>>>) denied;
+        }
+        return Result.success(orgStatsService.trend(orgId,
+                (String) params.get("startDate"), (String) params.get("endDate")));
+    }
+}

+ 0 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/OrgMemberService.java

@@ -5,9 +5,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.etotem.cfc.common.DataMaskUtil;
 import com.etotem.cfc.dto.OrgMemberVO;
 import com.etotem.cfc.entity.OrgMember;
-import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.OrgMemberMapper;
-import com.etotem.cfc.mapper.UserMapper;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -24,9 +22,6 @@ import java.util.stream.Collectors;
 @Service
 public class OrgMemberService extends ServiceImpl<OrgMemberMapper, OrgMember> {
 
-    @Resource
-    private UserMapper userMapper;
-
     @Resource
     private OrgMemberMapper orgMemberMapper;
 

+ 362 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/OrgStatsService.java

@@ -0,0 +1,362 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.OrgMember;
+import com.etotem.cfc.mapper.OrgMemberMapper;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 组织统计大屏(5 个口径,规格文档 4.3 节)。
+ *
+ * 性能约束:所有统计先取 org_members 活跃成员 user_id 集合,再批量 IN 查询
+ * 由数据库聚合(GROUP BY / COUNT / SUM),禁止 per-member 循环查询。
+ */
+@Service
+public class OrgStatsService {
+
+    @Resource
+    private OrgMemberMapper orgMemberMapper;
+
+    @Resource
+    private JdbcTemplate jdbcTemplate;
+
+    /** 总览卡片:成员总数/规划师数/普通用户数/家庭数/总订单/总消费(分) */
+    public Map<String, Object> overview(Long orgId) {
+        List<Long> memberIds = getActiveMemberIds(orgId);
+        Map<String, Object> result = new HashMap<>();
+        if (memberIds.isEmpty()) {
+            result.put("memberCount", 0);
+            result.put("plannerCount", 0);
+            result.put("userCount", 0);
+            result.put("familyCount", 0);
+            result.put("totalOrders", 0);
+            result.put("totalConsumption", 0L);
+            return result;
+        }
+        String in = buildIn(memberIds.size());
+
+        result.put("memberCount", memberIds.size());
+        // 规划师数/普通用户数:org_members 按 member_type 分组
+        Map<String, Integer> typeCount = jdbcTemplate.query(
+                "SELECT member_type, COUNT(*) AS cnt FROM org_members " +
+                        "WHERE org_id = ? AND status = 'active' GROUP BY member_type",
+                rs -> {
+                    Map<String, Integer> m = new HashMap<>();
+                    while (rs.next()) {
+                        m.put(rs.getString("member_type"), rs.getInt("cnt"));
+                    }
+                    return m;
+                }, orgId);
+        result.put("plannerCount", typeCount.getOrDefault("planner", 0));
+        result.put("userCount", typeCount.getOrDefault("user", 0));
+
+        // 家庭数:families.creator_id IN (memberIds) 去重
+        Integer familyCount = jdbcTemplate.queryForObject(
+                "SELECT COUNT(DISTINCT creator_id) FROM families WHERE creator_id IN (" + in + ")",
+                Integer.class, memberIds.toArray());
+        result.put("familyCount", familyCount != null ? familyCount : 0);
+
+        // 总订单/总消费:product_orders PAID/COMPLETED
+        Map<String, Object> orderAgg = jdbcTemplate.query(
+                "SELECT COUNT(*) AS cnt, COALESCE(SUM(total_amount),0) AS total " +
+                        "FROM product_orders " +
+                        "WHERE buyer_id IN (" + in + ") AND status IN ('PAID','COMPLETED')",
+                rs -> {
+                    Map<String, Object> m = new HashMap<>();
+                    if (rs.next()) {
+                        m.put("cnt", rs.getInt("cnt"));
+                        m.put("total", rs.getLong("total"));
+                    } else {
+                        m.put("cnt", 0);
+                        m.put("total", 0L);
+                    }
+                    return m;
+                }, memberIds.toArray());
+        result.put("totalOrders", orderAgg.get("cnt"));
+        result.put("totalConsumption", orderAgg.get("total"));
+        return result;
+    }
+
+    /** 家庭维度:活跃家庭数/成员任务完成率/打卡率 */
+    public Map<String, Object> families(Long orgId, String startDate, String endDate) {
+        List<Long> memberIds = getActiveMemberIds(orgId);
+        Map<String, Object> result = new HashMap<>();
+        if (memberIds.isEmpty()) {
+            result.put("activeFamilyCount", 0);
+            result.put("taskCompletionRate", 0);
+            result.put("checkinRate", 0);
+            return result;
+        }
+        String in = buildIn(memberIds.size());
+        Date start = parseDate(startDate);
+        Date end = parseDate(endDate);
+
+        // 活跃家庭数:families.creator_id IN (memberIds) 且 updated_at >= 近30天
+        Integer activeFamily = jdbcTemplate.queryForObject(
+                "SELECT COUNT(DISTINCT creator_id) FROM families " +
+                        "WHERE creator_id IN (" + in + ") AND updated_at >= ?",
+                Integer.class, prepend(memberIds, start));
+        result.put("activeFamilyCount", activeFamily != null ? activeFamily : 0);
+
+        // 任务完成率:近30天 completed 任务数 ÷ 同范围任务总数
+        Long completed = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM tasks " +
+                        "WHERE status = 'completed' AND completed_at BETWEEN ? AND ?",
+                Long.class, start, end);
+        Long allTasks = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM tasks WHERE completed_at BETWEEN ? AND ?",
+                Long.class, start, end);
+        long completedOrDefault = completed != null ? completed : 0L;
+        long allOrDefault = allTasks != null ? allTasks : 0L;
+        result.put("taskCompletionRate", allOrDefault > 0 ? Math.round(completedOrDefault * 100.0 / allOrDefault) : 0);
+
+        // 打卡率:近30天有任意打卡(daily_checkin/health_checkins/emotion_checkin)的成员数 ÷ 成员总数
+        Integer checkinMembers = jdbcTemplate.queryForObject(
+                "SELECT COUNT(DISTINCT user_id) FROM (" +
+                        "  SELECT user_id FROM health_checkins WHERE created_at >= ? " +
+                        "  UNION " +
+                        "  SELECT fm.user_id FROM daily_checkin dc " +
+                        "    JOIN family_members fm ON fm.id = dc.member_id " +
+                        "    WHERE dc.created_at >= ? AND fm.user_id IN (" + in + ") " +
+                        "  UNION " +
+                        "  SELECT ec.user_id FROM emotion_checkin ec WHERE ec.created_at >= ? AND ec.user_id IS NOT NULL " +
+                        ") t WHERE t.user_id IS NOT NULL",
+                Integer.class, start, start, start);
+        result.put("checkinRate", memberIds.size() > 0
+                ? Math.round((checkinMembers != null ? checkinMembers : 0) * 100.0 / memberIds.size()) : 0);
+        return result;
+    }
+
+    /** 活跃度趋势(按天分组):任务完成/打卡/报告上传 */
+    public List<Map<String, Object>> active(Long orgId, String startDate, String endDate) {
+        List<Long> memberIds = getActiveMemberIds(orgId);
+        if (memberIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        String in = buildIn(memberIds.size());
+        Date start = parseDate(startDate);
+        Date end = parseDate(endDate);
+
+        // 任务完成(tasks 无直接 userId,用 creator_id 近似;按 completed_at 分组)
+        List<Map<String, Object>> taskDays = jdbcTemplate.query(
+                "SELECT DATE(completed_at) AS day, COUNT(*) AS cnt FROM tasks " +
+                        "WHERE status = 'completed' AND completed_at BETWEEN ? AND ? " +
+                        "GROUP BY DATE(completed_at)",
+                (rs, i) -> {
+                    Map<String, Object> m = new HashMap<>();
+                    m.put("day", rs.getString("day"));
+                    m.put("taskCompleted", rs.getInt("cnt"));
+                    return m;
+                }, start, end);
+
+        // 打卡(daily_checkin + health_checkins + emotion_checkin,按天分组)
+        List<Map<String, Object>> checkinDays = jdbcTemplate.query(
+                "SELECT day, SUM(cnt) AS cnt FROM (" +
+                        "  SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM health_checkins " +
+                        "    WHERE created_at BETWEEN ? AND ? GROUP BY DATE(created_at) " +
+                        "  UNION ALL " +
+                        "  SELECT DATE(dc.created_at) AS day, COUNT(*) AS cnt FROM daily_checkin dc " +
+                        "    JOIN family_members fm ON fm.id = dc.member_id " +
+                        "    WHERE fm.user_id IN (" + in + ") AND dc.created_at BETWEEN ? AND ? " +
+                        "    GROUP BY DATE(dc.created_at) " +
+                        "  UNION ALL " +
+                        "  SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM emotion_checkin " +
+                        "    WHERE created_at BETWEEN ? AND ? GROUP BY DATE(created_at) " +
+                        ") t GROUP BY day",
+                (rs, i) -> {
+                    Map<String, Object> m = new HashMap<>();
+                    m.put("day", rs.getString("day"));
+                    m.put("checkins", rs.getInt("cnt"));
+                    return m;
+                }, start, end, start, end, start, end);
+
+        // 报告上传
+        List<Map<String, Object>> reportDays = jdbcTemplate.query(
+                "SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM dan_report_uploads " +
+                        "WHERE uploader_user_id IN (" + in + ") AND created_at BETWEEN ? AND ? " +
+                        "GROUP BY DATE(created_at)",
+                (rs, i) -> {
+                    Map<String, Object> m = new HashMap<>();
+                    m.put("day", rs.getString("day"));
+                    m.put("reports", rs.getInt("cnt"));
+                    return m;
+                }, memberIds.toArray(), start, end);
+
+        // 合并为按天 Map(day -> {taskCompleted, checkins, reports})
+        Map<String, Map<String, Object>> merged = new HashMap<>();
+        mergeDay(merged, taskDays, "taskCompleted");
+        mergeDay(merged, checkinDays, "checkins");
+        mergeDay(merged, reportDays, "reports");
+        return new ArrayList<>(merged.values());
+    }
+
+    /** 营收:订单金额/会员订阅/佣金,按天分组 */
+    public List<Map<String, Object>> revenue(Long orgId, String startDate, String endDate) {
+        List<Long> memberIds = getActiveMemberIds(orgId);
+        if (memberIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        String in = buildIn(memberIds.size());
+        Date start = parseDate(startDate);
+        Date end = parseDate(endDate);
+
+        List<Map<String, Object>> orderDays = jdbcTemplate.query(
+                "SELECT DATE(paid_at) AS day, COALESCE(SUM(total_amount),0) AS total " +
+                        "FROM product_orders " +
+                        "WHERE buyer_id IN (" + in + ") AND status IN ('PAID','COMPLETED') " +
+                        "AND paid_at BETWEEN ? AND ? GROUP BY DATE(paid_at)",
+                (rs, i) -> {
+                    Map<String, Object> m = new HashMap<>();
+                    m.put("day", rs.getString("day"));
+                    m.put("orderAmount", rs.getLong("total"));
+                    return m;
+                }, memberIds.toArray(), start, end);
+
+        List<Map<String, Object>> subDays = jdbcTemplate.query(
+                "SELECT DATE(paid_at) AS day, COALESCE(SUM(amount),0) AS total " +
+                        "FROM member_subscription_order " +
+                        "WHERE status = 'paid' AND paid_at BETWEEN ? AND ? GROUP BY DATE(paid_at)",
+                (rs, i) -> {
+                    Map<String, Object> m = new HashMap<>();
+                    m.put("day", rs.getString("day"));
+                    m.put("subAmount", rs.getLong("total"));
+                    return m;
+                }, start, end);
+
+        List<Map<String, Object>> commissionDays = jdbcTemplate.query(
+                "SELECT DATE(created_at) AS day, COALESCE(SUM(commission_amount),0) AS total " +
+                        "FROM commission_records " +
+                        "WHERE referrer_id IN (" + in + ") AND created_at BETWEEN ? AND ? " +
+                        "GROUP BY DATE(created_at)",
+                (rs, i) -> {
+                    Map<String, Object> m = new HashMap<>();
+                    m.put("day", rs.getString("day"));
+                    m.put("commission", rs.getLong("total"));
+                    return m;
+                }, memberIds.toArray(), start, end);
+
+        Map<String, Map<String, Object>> merged = new HashMap<>();
+        mergeDay(merged, orderDays, "orderAmount");
+        mergeDay(merged, subDays, "subAmount");
+        mergeDay(merged, commissionDays, "commission");
+        return new ArrayList<>(merged.values());
+    }
+
+    /** 趋势图:活跃成员数/订单数/收入(分),按天分组 */
+    public List<Map<String, Object>> trend(Long orgId, String startDate, String endDate) {
+        List<Long> memberIds = getActiveMemberIds(orgId);
+        if (memberIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        String in = buildIn(memberIds.size());
+        Date start = parseDate(startDate);
+        Date end = parseDate(endDate);
+
+        // 活跃成员数(当日有任一行为去重):任务完成 creator + 打卡 user + 订单 buyer + 报告 uploader
+        List<Map<String, Object>> activeDays = jdbcTemplate.query(
+                "SELECT day, COUNT(DISTINCT uid) AS cnt FROM (" +
+                        "  SELECT DATE(completed_at) AS day, creator_id AS uid FROM tasks " +
+                        "    WHERE status = 'completed' AND completed_at BETWEEN ? AND ? " +
+                        "  UNION ALL " +
+                        "  SELECT DATE(created_at) AS day, user_id AS uid FROM health_checkins " +
+                        "    WHERE created_at BETWEEN ? AND ? AND user_id IN (" + in + ") " +
+                        "  UNION ALL " +
+                        "  SELECT DATE(paid_at) AS day, buyer_id AS uid FROM product_orders " +
+                        "    WHERE paid_at BETWEEN ? AND ? AND buyer_id IN (" + in + ") " +
+                        "  UNION ALL " +
+                        "  SELECT DATE(created_at) AS day, uploader_user_id AS uid FROM dan_report_uploads " +
+                        "    WHERE created_at BETWEEN ? AND ? AND uploader_user_id IN (" + in + ") " +
+                        ") t WHERE t.uid IS NOT NULL GROUP BY day",
+                (rs, i) -> {
+                    Map<String, Object> m = new HashMap<>();
+                    m.put("day", rs.getString("day"));
+                    m.put("activeMembers", rs.getInt("cnt"));
+                    return m;
+                }, start, end, start, end, start, end, start, end);
+
+        List<Map<String, Object>> orderDays = jdbcTemplate.query(
+                "SELECT DATE(paid_at) AS day, COUNT(*) AS cnt, COALESCE(SUM(total_amount),0) AS total " +
+                        "FROM product_orders " +
+                        "WHERE buyer_id IN (" + in + ") AND status IN ('PAID','COMPLETED') " +
+                        "AND paid_at BETWEEN ? AND ? GROUP BY DATE(paid_at)",
+                (rs, i) -> {
+                    Map<String, Object> m = new HashMap<>();
+                    m.put("day", rs.getString("day"));
+                    m.put("orderCount", rs.getInt("cnt"));
+                    m.put("income", rs.getLong("total"));
+                    return m;
+                }, memberIds.toArray(), start, end);
+
+        Map<String, Map<String, Object>> merged = new HashMap<>();
+        mergeDay(merged, activeDays, "activeMembers");
+        mergeDay(merged, orderDays, "orderCount");
+        // 把 orderDays 的 income 并入同 day
+        for (Map<String, Object> od : orderDays) {
+            String day = String.valueOf(od.get("day"));
+            merged.computeIfAbsent(day, k -> new HashMap<>()).put("income", od.get("income"));
+        }
+        return new ArrayList<>(merged.values());
+    }
+
+    // ========== 辅助 ==========
+
+    private List<Long> getActiveMemberIds(Long orgId) {
+        LambdaQueryWrapper<OrgMember> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(OrgMember::getOrgId, orgId);
+        wrapper.eq(OrgMember::getStatus, "active");
+        List<OrgMember> members = orgMemberMapper.selectList(wrapper);
+        return members.stream().map(OrgMember::getUserId).distinct().collect(Collectors.toList());
+    }
+
+    private void mergeDay(Map<String, Map<String, Object>> merged,
+                          List<Map<String, Object>> rows, String field) {
+        for (Map<String, Object> row : rows) {
+            String day = String.valueOf(row.get("day"));
+            merged.computeIfAbsent(day, k -> new HashMap<>()).put(field, row.get(field));
+        }
+    }
+
+    private String buildIn(int cnt) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < cnt; i++) {
+            if (i > 0) {
+                sb.append(",");
+            }
+            sb.append("?");
+        }
+        return sb.toString();
+    }
+
+    private Object[] prepend(List<Long> list, Object extra) {
+        Object[] arr = new Object[list.size() + 1];
+        arr[0] = extra;
+        for (int i = 0; i < list.size(); i++) {
+            arr[i + 1] = list.get(i);
+        }
+        return arr;
+    }
+
+    private Date parseDate(String date) {
+        if (date == null || date.isEmpty()) {
+            // 默认近30天
+            return new Date(System.currentTimeMillis() - 30L * 24 * 3600 * 1000);
+        }
+        try {
+            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+            return sdf.parse(date.length() > 10 ? date.substring(0, 10) : date);
+        } catch (Exception e) {
+            throw new IllegalArgumentException("日期格式错误,应为 yyyy-MM-dd: " + date);
+        }
+    }
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/OrganizationService.java

@@ -34,8 +34,17 @@ public class OrganizationService extends ServiceImpl<OrganizationMapper, Organiz
     private UserMapper userMapper;
 
     public Page<Organization> list(String keyword, String type, String status, int page, int size) {
+        return list(keyword, type, status, page, size, null);
+    }
+
+    /** 带范围过滤的列表:scopeOrgId 非空时仅返回该组织(供 org_admin 只看自己组织) */
+    public Page<Organization> list(String keyword, String type, String status, int page, int size,
+                                   Long scopeOrgId) {
         Page<Organization> pageParam = new Page<>(page, size);
         LambdaQueryWrapper<Organization> wrapper = new LambdaQueryWrapper<>();
+        if (scopeOrgId != null) {
+            wrapper.eq(Organization::getId, scopeOrgId);
+        }
         if (keyword != null && !keyword.isEmpty()) {
             wrapper.like(Organization::getName, keyword);
         }

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-537e488ae4eb19e71ba5cc076ab8f83bafa3b444
+6896edb7ca82cd22dbcad8e1dfc213f069604b9d

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1757",
+  "version": "1.0.1758",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1757",
+      "version": "1.0.1758",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1758",
+  "version": "1.0.1759",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 6 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,12 @@
 
 ---
 
+## v1.0.1759 (2026-10-22)
+
+### 样式
+- C-15 SystemPromptManagement/Detail/Tasks/PackageAudit/GuidePackages 内联 style 归零
+
+
 ## v1.0.1758 (2026-10-22)
 
 ### Bug 修复

+ 7 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1758
+> 当前版本: v1.0.1759
 
 ## 历史版本
 
@@ -8,6 +8,12 @@
 
 ---
 
+## v1.0.1759 (2026-10-22)
+
+### 样式
+- C-15 SystemPromptManagement/Detail/Tasks/PackageAudit/GuidePackages 内联 style 归零
+
+
 ## v1.0.1758 (2026-10-22)
 
 ### Bug 修复

+ 64 - 0
cfc-web/src/api/org.js

@@ -0,0 +1,64 @@
+import request from '@/utils/request';
+
+// 组织管理 API(13 个端点)
+
+// 组织列表
+export function orgList(params) {
+  return request({ url: '/api/admin/org/list', method: 'post', data: params });
+}
+// 组织详情
+export function orgDetail(params) {
+  return request({ url: '/api/admin/org/detail', method: 'post', data: params });
+}
+// 创建组织
+export function orgCreate(params) {
+  return request({ url: '/api/admin/org/create', method: 'post', data: params });
+}
+// 更新组织
+export function orgUpdate(params) {
+  return request({ url: '/api/admin/org/update', method: 'post', data: params });
+}
+// 停用/启用组织
+export function orgDisable(params) {
+  return request({ url: '/api/admin/org/disable', method: 'post', data: params });
+}
+// 组织管理员获取自己所属组织
+export function orgMy(params) {
+  return request({ url: '/api/admin/org/my-org', method: 'post', data: params || {} });
+}
+
+// 搜索平台用户(添加成员下拉选人)
+export function orgUserSearch(params) {
+  return request({ url: '/api/admin/org/user/search', method: 'post', data: params });
+}
+
+// ---- 成员管理 ----
+export function orgMemberList(params) {
+  return request({ url: '/api/admin/org/member/list', method: 'post', data: params });
+}
+export function orgMemberAdd(params) {
+  return request({ url: '/api/admin/org/member/add', method: 'post', data: params });
+}
+export function orgMemberRemove(params) {
+  return request({ url: '/api/admin/org/member/remove', method: 'post', data: params });
+}
+export function orgMemberChangeType(params) {
+  return request({ url: '/api/admin/org/member/change-type', method: 'post', data: params });
+}
+
+// ---- 统计大屏 ----
+export function orgStatsOverview(params) {
+  return request({ url: '/api/admin/org/stats/overview', method: 'post', data: params });
+}
+export function orgStatsFamilies(params) {
+  return request({ url: '/api/admin/org/stats/families', method: 'post', data: params });
+}
+export function orgStatsActive(params) {
+  return request({ url: '/api/admin/org/stats/active', method: 'post', data: params });
+}
+export function orgStatsRevenue(params) {
+  return request({ url: '/api/admin/org/stats/revenue', method: 'post', data: params });
+}
+export function orgStatsTrend(params) {
+  return request({ url: '/api/admin/org/stats/trend', method: 'post', data: params });
+}

+ 170 - 0
cfc-web/src/views/admin/org/List.vue

@@ -0,0 +1,170 @@
+<template>
+  <div class="org-list">
+    <el-card>
+      <PageHeader slot="header" title="组织管理">
+        <el-button v-if="isPlatform" type="primary" size="small" @click="$router.push('/org/create')">+ 新建组织</el-button>
+      </PageHeader>
+
+      <el-form :inline="true" style="margin-bottom: 15px;">
+        <el-form-item>
+          <el-input v-model="searchKeyword" placeholder="搜索组织名称/编号" clearable @clear="loadData" @keyup.enter.native="loadData">
+            <i slot="prefix" class="el-icon-search"></i>
+          </el-input>
+        </el-form-item>
+        <el-form-item>
+          <el-select v-model="searchType" placeholder="类型" clearable style="width: 120px" @change="loadData">
+            <el-option label="企业" value="enterprise"></el-option>
+            <el-option label="机构" value="institution"></el-option>
+            <el-option label="其他" value="other"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-select v-model="searchStatus" placeholder="状态" clearable style="width: 120px" @change="loadData">
+            <el-option label="启用" value="active"></el-option>
+            <el-option label="停用" value="disabled"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="loadData" :loading="loading">查询</el-button>
+        </el-form-item>
+      </el-form>
+
+      <div class="adm-table-wrap">
+        <el-table :data="list" stripe v-loading="loading" height="100%">
+          <el-table-column prop="id" label="ID" width="80"></el-table-column>
+          <el-table-column prop="name" label="组织名称" min-width="160"></el-table-column>
+          <el-table-column prop="type" label="类型" width="100">
+            <template slot-scope="scope">
+              {{ typeLabel(scope.row.type) }}
+            </template>
+          </el-table-column>
+          <el-table-column prop="code" label="编号" width="140"></el-table-column>
+          <el-table-column prop="contactName" label="联系人" width="100"></el-table-column>
+          <el-table-column label="状态" width="100">
+            <template slot-scope="scope">
+              <StatusTag :type="scope.row.status === 'active' ? 'success' : 'danger'"
+                         :text="scope.row.status === 'active' ? '启用' : '停用'" />
+            </template>
+          </el-table-column>
+          <el-table-column prop="createdAt" label="创建时间" width="170">
+            <template slot-scope="scope">
+              {{ formatTime(scope.row.createdAt) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="200" fixed="right">
+            <template slot-scope="scope">
+              <el-button size="mini" type="primary" @click="viewDetail(scope.row)">详情</el-button>
+              <el-button v-if="isPlatform" size="mini" @click="openEdit(scope.row)">编辑</el-button>
+              <el-button v-if="isPlatform && scope.row.status === 'active'" size="mini" type="warning" @click="toggleStatus(scope.row, 'disabled')">
+                停用
+              </el-button>
+              <el-button v-if="isPlatform && scope.row.status === 'disabled'" size="mini" type="success" @click="toggleStatus(scope.row, 'active')">
+                启用
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+
+      <el-pagination
+        v-if="total > 0"
+        style="margin-top: 15px; text-align: right"
+        background
+        layout="total, prev, pager, next"
+        :total="total"
+        :page-size="pageSize"
+        :current-page="page"
+        @current-change="handlePageChange">
+      </el-pagination>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import ListStateMixin from '@/mixins/ListStateMixin'
+import { orgList, orgDisable } from '@/api/org'
+
+export default {
+  name: 'OrgList',
+  mixins: [ListStateMixin],
+  computed: {
+    isPlatform() {
+      const roles = this.$store.state.user.roles
+      return roles.some(r => ['admin', 'operator', 'finance'].indexOf(r) >= 0)
+    },
+    tableHeight() {
+      return window.innerHeight - 300
+    }
+  },
+  data() {
+    return {
+      list: [],
+      loading: false,
+      searchKeyword: '',
+      searchType: '',
+      searchStatus: '',
+      page: 1,
+      pageSize: 10,
+      total: 0
+    }
+  },
+  created() {
+    this.loadData()
+  },
+  methods: {
+    loadList() {
+      this.loadData()
+    },
+    typeLabel(t) {
+      const map = { enterprise: '企业', institution: '机构', other: '其他' }
+      return t ? map[t] : '-'
+    },
+    async loadData() {
+      this.loading = true
+      try {
+        const params = {
+          keyword: this.searchKeyword,
+          type: this.searchType,
+          status: this.searchStatus,
+          page: this.page,
+          size: this.pageSize
+        }
+        const res = await orgList(params)
+        if (res.data) {
+          this.list = res.data.records || []
+          this.total = res.data.total || 0
+        }
+      } catch (e) {
+        this.$message.error('加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    handlePageChange(val) {
+      this.page = val
+      this.loadData()
+    },
+    openEdit(row) {
+      this.$router.push('/org/edit/' + row.id)
+    },
+    viewDetail(row) {
+      this.$router.push('/org/' + row.id)
+    },
+    async toggleStatus(row, status) {
+      try {
+        await orgDisable({ id: row.id, status })
+        this.$message.success('状态已更新')
+        this.loadData()
+      } catch (e) {
+        this.$message.error('操作失败')
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.org-list {
+  padding: 20px;
+}
+</style>

+ 52 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -1870,3 +1870,55 @@ Web 管理端定义活动海报模板(迁移381 `activity_poster_templates` 
 前端使用:`activity-detail.vue` 加载详情后并行拉取;报名成功/取消报名后重新拉取。区块仅当 `activity.requireRegistration === 1` 时展示,含统计列、进度条(限人数时)与最近报名人列表。
 
 *文档更新:2026-10-22(活动详情页新增报名情况)*
+
+---
+
+## 4.48 组织管理(机构/企业组织)
+
+平台角色(admin/operator/finance)可管理所有组织并查看全量统计;组织管理员(`users.roles` 含 `org_admin`)登录管理端后默认跳转自身组织统计大屏,仅能访问本组织数据。实现方案见规格 `docs/superpowers/specs/2026-10-22-org-admin-management-design.md`。
+
+**数据模型:**
+- `organizations`:组织主表(name/type/code/admin_user_id/contact_*/description/status)
+- `org_members`:组织成员关联表(org_id/user_id/member_type/role_in_org/status),唯一键 `(org_id, user_id)`
+
+**权限规则:**
+- 平台角色(admin/operator/finance)→ 全量访问
+- `org_admin` → 仅能访问自身组织(`organizations.admin_user_id = userId` 且 `status='active'`),返回 403「无权访问该组织」/「组织已停用」
+- 组织管理员身份登录复用现有 `/api/admin/auth/login` 流(`AdminRoles.RESIDENT_ROLES` 新增 `org_admin`)
+
+### 接口列表(13 个,全部 `@PostMapping`)
+
+| 路径 | 说明 |
+|---|---|
+| `/api/admin/org/list` | 组织列表(分页/筛选;org_admin 只返回自己组织) |
+| `/api/admin/org/create` | 创建组织(同步创建管理员账号;仅平台角色) |
+| `/api/admin/org/update` | 更新组织信息 |
+| `/api/admin/org/disable` | 停用/启用组织(仅平台角色) |
+| `/api/admin/org/detail` | 组织详情 |
+| `/api/admin/org/my-org` | 组织管理员获取自身组织(登录跳转目标) |
+| `/api/admin/org/user/search` | 按手机号/昵称搜索平台用户(添加成员下拉选人) |
+| `/api/admin/org/member/list` | 成员列表(含家庭数/订单数/消费额摘要) |
+| `/api/admin/org/member/add` | 添加成员(软删成员复活为 active 并更新类型) |
+| `/api/admin/org/member/remove` | 移除成员(软删 status=inactive) |
+| `/api/admin/org/member/change-type` | 变更成员类型(planner ↔ user,复活 inactive) |
+| `/api/admin/org/stats/overview` | 总览卡片(成员/规划师/家庭/订单/消费) |
+| `/api/admin/org/stats/families` | 家庭维度(活跃家庭/任务完成率/打卡率) |
+| `/api/admin/org/stats/active` | 活跃度趋势(任务完成/打卡/报告上传,按天) |
+| `/api/admin/org/stats/revenue` | 营收(订单金额/会员订阅/佣金,按天) |
+| `/api/admin/org/stats/trend` | 趋势图(活跃成员数/订单数/收入,按天) |
+
+**请求参数:** 成员/统计接口统一从 body 取 `orgId`;stats 可选 `startDate`/`endDate`(`yyyy-MM-dd`,默认近 30 天)。
+
+**统计口径:**
+- 成员范围:`org_members` 中 `status='active'` 的 `user_id` 集合
+- 家庭数:`families.creator_id IN (成员userId)` 去重计数
+- 订单:`product_orders.buyer_id IN (成员userId) AND status IN ('PAID','COMPLETED')`
+- 消费:上述订单 `total_amount` 求和(单位分)
+- 打卡:`health_checkins.user_id` + `daily_checkin`(经 `family_members.user_id` 关联)+ `emotion_checkin`
+- 报告:`dan_report_uploads.uploader_user_id`
+- 佣金:`commission_records.referrer_id`(受益人)`commission_amount` 求和
+- 订阅:`member_subscription_order.status='paid'`
+
+**前端入口:** cfc-web 新增 `/org` 路由四页面(List/Form/Detail/Dashboard);菜单「组织管理」挂在系统配置分组下。组织管理员登录后自动跳 `/org/:id/dashboard`,不展示组织列表页。
+
+*文档更新:2026-10-22(组织管理功能上线)*