Эх сурвалжийг харах

feat(admin): 后台家庭成员改为仅区分管理员/成员

- family_members 新增 is_admin 列(迁移288),回填家庭创建者为管理员
- AdminController.getFamilyMembers 返回统一 members 列表(合并 family_members + users 家长),保留 parents/children 兼容字段
- 新增 setMemberAdminRole 接口:设/撤管理员,users 来源成员自动建记录持久化角色
- Families.vue 查看成员弹窗合并为单一列表 + 角色标签/操作按钮
E2E Test Bot 2 сар өмнө
parent
commit
03b963299a

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

@@ -10330,5 +10330,16 @@ public class DatabaseInitializer implements CommandLineRunner {
         } catch (Exception e) {
             // 索引已存在,忽略
         }
+
+        // 迁移293: family_members 添加 is_admin 列(后台家庭成员仅区分管理员/成员,不再区分家长/孩子)
+        ensureColumn("family_members", "is_admin", "TINYINT(1) DEFAULT 0 COMMENT '是否家庭管理员: 1=管理员, 0=普通成员'");
+        // 回填:将每个家庭的创建者(families.creator_id 匹配 family_members.user_id)设为管理员
+        try {
+            jdbcTemplate.execute("UPDATE family_members fm JOIN families f ON fm.family_id = f.id " +
+                    "SET fm.is_admin = 1 WHERE fm.user_id = f.creator_id AND (fm.is_admin IS NULL OR fm.is_admin = 0)");
+            log.info("迁移293: 已回填家庭创建者为管理员");
+        } catch (Exception e) {
+            log.warn("迁移293: 回填家庭创建者为管理员失败: {}", e.getMessage());
+        }
     }
 }

+ 135 - 8
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java

@@ -609,26 +609,153 @@ public class AdminController {
             return Result.error("家庭不存在");
         }
 
-        // 查询家长
+        // 统一成员列表:family_members(含创建者)+ users 中 role=parent 且未落入 family_members 的成员
+        // 仅区分「管理员 / 成员」,不区分家长 / 孩子
+        LambdaQueryWrapper<FamilyMember> memberWrapper = new LambdaQueryWrapper<>();
+        memberWrapper.eq(FamilyMember::getFamilyId, id);
+        SortUtil.applySort(memberWrapper);
+        java.util.List<FamilyMember> familyMembers = familyMemberMapper.selectList(memberWrapper);
+
+        // 收集已落入 family_members 的 user_id,避免与 users 家长重复
+        java.util.Set<Long> coveredUserIds = new java.util.HashSet<>();
+        for (FamilyMember fm : familyMembers) {
+            if (fm.getUserId() != null) {
+                coveredUserIds.add(fm.getUserId());
+            }
+        }
+
+        // 通过邀请码加入、未写入 family_members 的家长
         LambdaQueryWrapper<User> parentWrapper = new LambdaQueryWrapper<>();
         parentWrapper.eq(User::getFamilyId, id).eq(User::getRole, "parent");
         SortUtil.applySort(parentWrapper);
         java.util.List<User> parents = userMapper.selectList(parentWrapper);
 
-        // 查询孩子
-        LambdaQueryWrapper<FamilyMember> childWrapper = new LambdaQueryWrapper<>();
-        childWrapper.eq(FamilyMember::getFamilyId, id);
-        SortUtil.applySort(childWrapper);
-        java.util.List<FamilyMember> children = familyMemberMapper.selectList(childWrapper);
+        Long creatorId = family.getCreatorId();
+        java.util.List<Map<String, Object>> members = new ArrayList<>();
+
+        // family_members 来源成员
+        for (FamilyMember fm : familyMembers) {
+            boolean isAdmin = (fm.getIsAdmin() != null && fm.getIsAdmin() == 1)
+                    || (creatorId != null && creatorId.equals(fm.getUserId()));
+            Map<String, Object> m = new HashMap<>();
+            m.put("memberId", fm.getId());
+            m.put("userId", fm.getUserId());
+            m.put("source", "family_member");
+            m.put("nickname", fm.getNickname());
+            m.put("phone", fm.getPhone());
+            m.put("avatar", fm.getAvatar());
+            m.put("gender", fm.getGender());
+            if (fm.getBirthday() != null) {
+                m.put("age", fm.getAge());
+            }
+            m.put("totalPoints", fm.getTotalPoints());
+            m.put("isAdmin", isAdmin);
+            m.put("role", isAdmin ? "admin" : "member");
+            m.put("createdAt", fm.getCreatedAt());
+            members.add(m);
+        }
+
+        // users 来源家长(未落入 family_members)
+        for (User u : parents) {
+            if (coveredUserIds.contains(u.getId())) {
+                continue;
+            }
+            boolean isAdmin = creatorId != null && creatorId.equals(u.getId());
+            Map<String, Object> m = new HashMap<>();
+            m.put("memberId", null);
+            m.put("userId", u.getId());
+            m.put("source", "user");
+            m.put("nickname", u.getNickname());
+            m.put("phone", u.getPhone());
+            m.put("avatar", u.getAvatar());
+            m.put("gender", u.getGender());
+            m.put("totalPoints", u.getTotalPoints() != null ? u.getTotalPoints() : 0);
+            m.put("isAdmin", isAdmin);
+            m.put("role", isAdmin ? "admin" : "member");
+            m.put("createdAt", u.getCreatedAt());
+            members.add(m);
+        }
 
         Map<String, Object> result = new HashMap<>();
         result.put("family", family);
-        result.put("parents", parents);
-        result.put("children", children);
+        result.put("members", members);
+
+        // 兼容字段:TeacherMessages.vue 依赖 parents/children 结构,沿用旧查询
+        java.util.List<User> compatParents = new ArrayList<>();
+        LambdaQueryWrapper<User> parentCompatWrapper = new LambdaQueryWrapper<>();
+        parentCompatWrapper.eq(User::getFamilyId, id).eq(User::getRole, "parent");
+        SortUtil.applySort(parentCompatWrapper);
+        compatParents.addAll(userMapper.selectList(parentCompatWrapper));
+
+        java.util.List<FamilyMember> compatChildren = new ArrayList<>();
+        LambdaQueryWrapper<FamilyMember> childCompatWrapper = new LambdaQueryWrapper<>();
+        childCompatWrapper.eq(FamilyMember::getFamilyId, id);
+        SortUtil.applySort(childCompatWrapper);
+        compatChildren.addAll(familyMemberMapper.selectList(childCompatWrapper));
+
+        result.put("parents", compatParents);
+        result.put("children", compatChildren);
 
         return Result.success(result);
     }
 
+    @PostMapping("/families/{id}/members/set-admin")
+    public Result<Boolean> setMemberAdminRole(@PathVariable Long id,
+                                              @RequestBody Map<String, Object> body,
+                                              @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Family family = familyMapper.selectById(id);
+        if (family == null) {
+            return Result.error("家庭不存在");
+        }
+        Long memberId = ParamUtils.getLong(body.get("memberId"));
+        Long userId = ParamUtils.getLong(body.get("userId"));
+        boolean setAdmin = Boolean.TRUE.equals(body.get("isAdmin"));
+
+        FamilyMember target = null;
+        if (memberId != null) {
+            target = familyMemberMapper.selectById(memberId);
+            if (target == null || !id.equals(target.getFamilyId())) {
+                return Result.error("成员不存在或不属于该家庭");
+            }
+        } else if (userId != null) {
+            // users 来源成员:在 family_members 中查找或创建记录,以便持久化管理员角色
+            LambdaQueryWrapper<FamilyMember> q = new LambdaQueryWrapper<FamilyMember>()
+                    .eq(FamilyMember::getFamilyId, id)
+                    .eq(FamilyMember::getUserId, userId)
+                    .last("LIMIT 1");
+            target = familyMemberMapper.selectOne(q);
+            if (target == null) {
+                User u = userMapper.selectById(userId);
+                if (u == null || !id.equals(u.getFamilyId())) {
+                    return Result.error("用户不存在或不属于该家庭");
+                }
+                target = new FamilyMember();
+                target.setFamilyId(id);
+                target.setUserId(userId);
+                target.setNickname(u.getNickname() != null ? u.getNickname() : "成员");
+                target.setPhone(u.getPhone());
+                target.setAvatar(u.getAvatar());
+                target.setGender(u.getGender());
+                target.setGeneration(0);
+                target.setShowToFamily(1);
+                target.setTotalPoints(0);
+                target.setCreatedAt(new Date());
+                target.setUpdatedAt(new Date());
+                familyMemberMapper.insert(target);
+            }
+        } else {
+            return Result.error("缺少成员标识");
+        }
+
+        target.setIsAdmin(setAdmin ? 1 : 0);
+        target.setUpdatedAt(new Date());
+        familyMemberMapper.updateById(target);
+        return Result.success(true);
+    }
+
     @PostMapping("/families/{id}/update")
     public Result<Boolean> updateFamily(@PathVariable Long id, @RequestBody Family family,
                                         @RequestAttribute("role") String role) {

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMember.java

@@ -132,6 +132,9 @@ public class FamilyMember implements Serializable {
     /** 周末晚餐同餐: 1=同餐 */
     private Integer mealWithDinnerWeekend;
 
+    /** 是否家庭管理员: 1=管理员, 0=普通成员 */
+    private Integer isAdmin;
+
     /**
      * 是否男性
      */

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

@@ -2057,6 +2057,7 @@ CREATE TABLE IF NOT EXISTS family_members (
     birthday         DATE         COMMENT '出生日期',
     generation INT COMMENT '辈分值(0=家庭创建者,+n=向上,-n=向下)',
     is_spouse TINYINT(1) DEFAULT 0 COMMENT '是否配偶(同辈中)',
+    is_admin TINYINT(1) DEFAULT 0 COMMENT '是否家庭管理员: 1=管理员, 0=普通成员',
     show_to_family INT DEFAULT 1 COMMENT '对家庭成员可见: 1=可见, 0=不可见',
     total_points INT DEFAULT 0 COMMENT '总积分',
     -- 孩子专属字段(原children表合并)

+ 8 - 0
cfc-web/src/api/admin.js

@@ -121,6 +121,14 @@ export function getFamilyMembers(id) {
   })
 }
 
+export function setMemberAdminRole(familyId, memberId, isAdmin) {
+  return request({
+    url: `/api/admin/families/${familyId}/members/set-admin`,
+    method: 'post',
+    data: { memberId, isAdmin }
+  })
+}
+
 // 任务模板管理
 export function getTaskTemplateList(params) {
   return request({

+ 47 - 30
cfc-web/src/views/Families.vue

@@ -99,7 +99,7 @@
     <el-dialog
       title="家庭成员"
       :visible.sync="membersDialogVisible"
-      width="800px"
+      width="900px"
     >
       <div v-if="membersData.family">
         <el-descriptions :column="2" border style="margin-bottom: 20px">
@@ -107,43 +107,51 @@
           <el-descriptions-item label="邀请码">{{ membersData.family.inviteCode }}</el-descriptions-item>
         </el-descriptions>
 
-        <el-divider content-position="left">家长</el-divider>
-        <el-table
-          :data="membersData.parents"
-          border
-          style="width: 100%; margin-bottom: 20px"
-          :max-height="300"
-          v-if="membersData.parents && membersData.parents.length > 0"
-        >
-          <el-table-column prop="id" label="ID" width="80"></el-table-column>
-          <el-table-column prop="nickname" label="昵称" width="150"></el-table-column>
-          <el-table-column prop="realName" label="真实姓名" width="120"></el-table-column>
-          <el-table-column prop="phone" label="手机号" width="150"></el-table-column>
-        </el-table>
-        <el-empty v-else description="暂无家长" :image-size="60"></el-empty>
-
-        <el-divider content-position="left">孩子</el-divider>
+        <el-divider content-position="left">成员列表(仅区分管理员 / 成员)</el-divider>
         <el-table
-          :data="membersData.children"
+          :data="membersData.members"
           border
           style="width: 100%"
-          :max-height="300"
-          v-if="membersData.children && membersData.children.length > 0"
+          :max-height="350"
+          v-if="membersData.members && membersData.members.length > 0"
         >
-          <el-table-column prop="id" label="ID" width="80"></el-table-column>
           <el-table-column prop="nickname" label="昵称" width="150"></el-table-column>
-          <el-table-column prop="totalPoints" label="总积分" width="100">
+          <el-table-column prop="phone" label="手机号" width="150"></el-table-column>
+          <el-table-column prop="gender" label="性别" width="80">
+            <template slot-scope="scope">
+              {{ scope.row.gender === 'male' ? '男' : scope.row.gender === 'female' ? '女' : '-' }}
+            </template>
+          </el-table-column>
+          <el-table-column prop="age" label="年龄" width="80">
+            <template slot-scope="scope">
+              {{ scope.row.age || '-' }}
+            </template>
+          </el-table-column>
+          <el-table-column prop="totalPoints" label="总积分" width="90">
             <template slot-scope="scope">
               <el-tag type="success">{{ scope.row.totalPoints || 0 }}</el-tag>
             </template>
           </el-table-column>
-          <el-table-column prop="createdAt" label="创建时间">
+          <el-table-column label="角色" width="100">
+            <template slot-scope="scope">
+              <el-tag :type="scope.row.role === 'admin' ? 'danger' : 'info'" size="small">
+                {{ scope.row.role === 'admin' ? '管理员' : '成员' }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="140">
             <template slot-scope="scope">
-              {{ formatDate(scope.row.createdAt) }}
+              <el-button
+                size="mini"
+                :type="scope.row.role === 'admin' ? '' : 'primary'"
+                @click="toggleAdmin(scope.row)"
+              >
+                {{ scope.row.role === 'admin' ? '撤销管理员' : '设为管理员' }}
+              </el-button>
             </template>
           </el-table-column>
         </el-table>
-        <el-empty v-else description="暂无孩子" :image-size="60"></el-empty>
+        <el-empty v-else description="暂无成员" :image-size="60"></el-empty>
       </div>
     </el-dialog>
 
@@ -274,7 +282,7 @@
 </template>
 
 <script>
-import { getFamilyList, getFamilyMembers, updateFamily, deleteFamily } from '@/api/admin'
+import { getFamilyList, getFamilyMembers, updateFamily, deleteFamily, setMemberAdminRole } from '@/api/admin'
 import { getCouponList, issueFamilyCoupon, getFamilyCoupons, getFamilyCouponUsage } from '@/api/coupon'
 
 export default {
@@ -310,8 +318,7 @@ export default {
       membersDialogVisible: false,
       membersData: {
         family: null,
-        parents: [],
-        children: []
+        members: []
       },
       couponDialogVisible: false,
       issueDialogVisible: false,
@@ -439,8 +446,7 @@ export default {
         const res = await getFamilyMembers(familyId)
         this.membersData = {
           family: res.data.family,
-          parents: res.data.parents || [],
-          children: res.data.children || []
+          members: res.data.members || []
         }
         this.membersDialogVisible = true
       } catch (error) {
@@ -449,6 +455,17 @@ export default {
         this.loading = false
       }
     },
+    async toggleAdmin(row) {
+      if (!this.membersData.family) return
+      const targetIsAdmin = row.role !== 'admin'
+      try {
+        await setMemberAdminRole(this.membersData.family.id, row.memberId, targetIsAdmin)
+        this.$message.success(targetIsAdmin ? '已设为管理员' : '已撤销管理员')
+        await this.handleViewMembersById(this.membersData.family.id)
+      } catch (error) {
+        this.$message.error(error.message || '操作失败')
+      }
+    },
     async handleIssueCoupon(row) {
       this.couponForm = {
         familyId: row.id,