Просмотр исходного кода

feat: 家庭邀请二维码功能(小程序码+令牌+落地页)

新增后端:
- FamilyInvitation entity + mapper(邀请令牌)
- FamilyInvitationService(生成/校验/接受/合并/转让)
- 5个API端点:/generate, /validate, /accept, /check-family, /transfer-admin
- WechatService.generateWxacode() 小程序码生成(降级ZXing QR)
- DatabaseInitializer 添加 family_invitations 表迁移

新增小程序端:
- pages/invite/join.vue(扫码落地页:登录+身份选择+合并)
- App.vue handleScene() 处理 wxacode 场景值
- api.js 新增 5 个 API 方法
- pages.json 注册邀请页路由

数据流:扫码 → scene=invite={token} → App.vue解析 → 跳转join页
→ 登录 → 选身份 → 调/accept → 加入成功
User 4 месяцев назад
Родитель
Сommit
c37c1cad1f

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

@@ -2098,6 +2098,26 @@ log.info("已添加template_id列到tasks表");
             log.warn("检查/添加activities表checkin_points列失败: {}", e.getMessage());
         }
 
+        // 迁移: 创建family_invitations表(家庭邀请令牌)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_invitations (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
+                    "token VARCHAR(64) NOT NULL UNIQUE COMMENT '邀请令牌(UUID)', " +
+                    "created_by BIGINT NOT NULL COMMENT '创建人用户ID', " +
+                    "status VARCHAR(16) DEFAULT 'active' COMMENT '状态: active/used/expired/revoked', " +
+                    "expires_at DATETIME COMMENT '过期时间', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "INDEX idx_family_id (family_id), " +
+                    "INDEX idx_token (token), " +
+                    "INDEX idx_status (status)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭邀请令牌'");
+            log.info("已创建family_invitations表");
+        } catch (Exception e) {
+            log.warn("创建family_invitations表失败: {}", e.getMessage());
+        }
+
         log.info("数据库迁移完成");
     }
 

+ 145 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyInviteController.java

@@ -1,14 +1,26 @@
 package com.etotem.cfc.controller.family;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.FamilyStatusDTO;
+import com.etotem.cfc.dto.InviteAcceptDTO;
+import com.etotem.cfc.dto.InviteGenerateDTO;
+import com.etotem.cfc.dto.InviteValidateDTO;
+import com.etotem.cfc.dto.TransferAdminDTO;
 import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.FamilyInvitationService;
 import com.etotem.cfc.service.QrCodeService;
+import com.etotem.cfc.service.WechatService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
-import org.springframework.web.bind.annotation.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
 
 import javax.annotation.Resource;
 import javax.servlet.http.HttpServletRequest;
@@ -21,6 +33,8 @@ import java.util.Map;
 @RequestMapping("/api/family/invite")
 public class FamilyInviteController {
 
+    private static final Logger log = LoggerFactory.getLogger(FamilyInviteController.class);
+
     @Resource
     private FamilyMapper familyMapper;
 
@@ -30,6 +44,12 @@ public class FamilyInviteController {
     @Resource
     private QrCodeService qrCodeService;
 
+    @Resource
+    private FamilyInvitationService familyInvitationService;
+
+    @Resource
+    private WechatService wechatService;
+
     /**
      * 生成家庭邀请二维码
      * 返回: base64 QR 码图片 + 邀请码 + 家庭名称
@@ -118,6 +138,130 @@ public class FamilyInviteController {
         return Result.success("加入家庭成功");
     }
 
+    /**
+     * 生成家庭邀请(含小程序二维码)
+     */
+    @Operation(summary = "生成家庭邀请")
+    @PostMapping("/generate")
+    public Result<InviteGenerateDTO> generateInvite(HttpServletRequest request) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getFamilyId() == null) {
+            return Result.error("用户没有家庭");
+        }
+
+        String token;
+        try {
+            token = familyInvitationService.generateInvitation(user.getFamilyId(), userId);
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+
+        String qrBase64;
+        try {
+            qrBase64 = wechatService.generateWxacode("invite=" + token, "pages/invite/join");
+        } catch (Exception e) {
+            log.warn("生成小程序码失败,降级为普通二维码: {}", e.getMessage());
+            qrBase64 = qrCodeService.generateQrCodeBase64(token);
+        }
+
+        Family family = familyMapper.selectById(user.getFamilyId());
+
+        InviteGenerateDTO result = new InviteGenerateDTO();
+        result.setToken(token);
+        result.setQrCodeBase64(qrBase64);
+        result.setFamilyName(family != null ? family.getName() : null);
+        result.setFamilyId(user.getFamilyId());
+        return Result.success(result);
+    }
+
+    /**
+     * 校验邀请令牌有效性
+     */
+    @Operation(summary = "校验邀请令牌")
+    @PostMapping("/validate")
+    public Result<InviteValidateDTO> validateInvite(@RequestBody Map<String, String> body) {
+        String token = body.get("token");
+        if (token == null || token.trim().isEmpty()) {
+            return Result.error("邀请令牌不能为空");
+        }
+
+        try {
+            InviteValidateDTO dto = familyInvitationService.validateInvitation(token);
+            return Result.success(dto);
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 接受家庭邀请
+     */
+    @Operation(summary = "接受家庭邀请")
+    @PostMapping("/accept")
+    public Result<String> acceptInvite(HttpServletRequest request,
+                                       @RequestBody InviteAcceptDTO dto) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+
+        try {
+            familyInvitationService.acceptInvitation(userId, dto.getToken(), dto.getFamilyRole(), dto.getMergeFamily());
+            return Result.success("加入家庭成功");
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 查询当前用户的家庭状态
+     */
+    @Operation(summary = "查询家庭状态")
+    @PostMapping("/check-family")
+    public Result<FamilyStatusDTO> checkFamilyStatus(HttpServletRequest request) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+
+        try {
+            FamilyStatusDTO dto = familyInvitationService.checkUserFamilyStatus(userId);
+            return Result.success(dto);
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 转让家庭管理员
+     */
+    @Operation(summary = "转让家庭管理员")
+    @PostMapping("/transfer-admin")
+    public Result<String> transferAdmin(HttpServletRequest request,
+                                        @RequestBody TransferAdminDTO dto) {
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getFamilyId() == null) {
+            return Result.error("用户没有家庭");
+        }
+
+        try {
+            familyInvitationService.transferAdmin(user.getFamilyId(), userId, dto.getNewAdminId());
+            return Result.success("转让成功");
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
     private Long getUserId(HttpServletRequest request) {
         Object userIdObj = request.getAttribute("userId");
         if (userIdObj != null) {

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyStatusDTO.java

@@ -0,0 +1,12 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class FamilyStatusDTO {
+    private Boolean inFamily;
+    private Long familyId;
+    private String familyName;
+    private Boolean isAdmin;       // 是否为家庭管理员
+    private Integer memberCount;
+}

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/InviteAcceptDTO.java

@@ -0,0 +1,10 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class InviteAcceptDTO {
+    private String token;          // 邀请令牌
+    private String familyRole;     // 家长身份: 爸爸/妈妈/爷爷/奶奶/姥爷/姥姥/其他
+    private Boolean mergeFamily;   // 如果已有家庭,是否合并(默认为false则拒绝加入)
+}

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/InviteGenerateDTO.java

@@ -0,0 +1,11 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class InviteGenerateDTO {
+    private String token;
+    private String qrCodeBase64;
+    private String familyName;
+    private Long familyId;
+}

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/InviteValidateDTO.java

@@ -0,0 +1,12 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class InviteValidateDTO {
+    private String token;
+    private Long familyId;
+    private String familyName;
+    private String inviterName;     // 邀请人昵称
+    private Integer memberCount;
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/TransferAdminDTO.java

@@ -0,0 +1,8 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+@Data
+public class TransferAdminDTO {
+    private Long newAdminId;       // 新管理员用户ID
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyInvitation.java

@@ -0,0 +1,31 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("family_invitations")
+public class FamilyInvitation implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long familyId;
+
+    private String token;          // UUID 唯一标识
+
+    private Long createdBy;        // 创建人用户ID
+
+    private String status;         // active/used/expired/revoked
+
+    private Date expiresAt;        // 过期时间
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

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

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

+ 233 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyInvitationService.java

@@ -0,0 +1,233 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.FamilyStatusDTO;
+import com.etotem.cfc.dto.InviteValidateDTO;
+import com.etotem.cfc.entity.Child;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.FamilyInvitation;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ChildMapper;
+import com.etotem.cfc.mapper.FamilyInvitationMapper;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+import java.util.UUID;
+
+@Service
+public class FamilyInvitationService {
+
+    @Resource
+    private FamilyInvitationMapper familyInvitationMapper;
+
+    @Resource
+    private FamilyMapper familyMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private ChildMapper childMapper;
+
+    public InviteValidateDTO validateInvitation(String token) {
+        FamilyInvitation invitation = familyInvitationMapper.selectOne(
+                new LambdaQueryWrapper<FamilyInvitation>()
+                        .eq(FamilyInvitation::getToken, token)
+                        .eq(FamilyInvitation::getStatus, "active")
+                        .and(w -> w.isNull(FamilyInvitation::getExpiresAt)
+                                .or().gt(FamilyInvitation::getExpiresAt, new Date()))
+        );
+        if (invitation == null) {
+            throw new RuntimeException("邀请链接无效或已过期");
+        }
+
+        Family family = familyMapper.selectById(invitation.getFamilyId());
+        if (family == null) {
+            throw new RuntimeException("家庭不存在");
+        }
+
+        User inviter = userMapper.selectById(invitation.getCreatedBy());
+        Long memberCount = userMapper.selectCount(
+                new LambdaQueryWrapper<User>()
+                        .eq(User::getFamilyId, invitation.getFamilyId())
+        );
+
+        InviteValidateDTO dto = new InviteValidateDTO();
+        dto.setToken(token);
+        dto.setFamilyId(invitation.getFamilyId());
+        dto.setFamilyName(family.getName());
+        dto.setInviterName(inviter != null ? inviter.getNickname() : null);
+        dto.setMemberCount(memberCount.intValue());
+        return dto;
+    }
+
+    public String generateInvitation(Long familyId, Long userId) {
+        Family family = familyMapper.selectById(familyId);
+        if (family == null) {
+            throw new RuntimeException("家庭不存在");
+        }
+
+        FamilyInvitation invitation = new FamilyInvitation();
+        invitation.setFamilyId(familyId);
+        invitation.setToken(UUID.randomUUID().toString().replace("-", ""));
+        invitation.setCreatedBy(userId);
+        invitation.setStatus("active");
+        invitation.setExpiresAt(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000L));
+        invitation.setCreatedAt(new Date());
+        invitation.setUpdatedAt(new Date());
+        familyInvitationMapper.insert(invitation);
+
+        return invitation.getToken();
+    }
+
+    public String acceptInvitation(Long userId, String token, String familyRole, Boolean mergeFamily) {
+        FamilyInvitation invitation = familyInvitationMapper.selectOne(
+                new LambdaQueryWrapper<FamilyInvitation>()
+                        .eq(FamilyInvitation::getToken, token)
+                        .eq(FamilyInvitation::getStatus, "active")
+                        .and(w -> w.isNull(FamilyInvitation::getExpiresAt)
+                                .or().gt(FamilyInvitation::getExpiresAt, new Date()))
+        );
+        if (invitation == null) {
+            throw new RuntimeException("邀请链接无效或已过期");
+        }
+
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            throw new RuntimeException("用户不存在");
+        }
+
+        Family family = familyMapper.selectById(invitation.getFamilyId());
+        if (family == null) {
+            throw new RuntimeException("家庭不存在");
+        }
+
+        if (user.getFamilyId() != null) {
+            if (mergeFamily == null || !mergeFamily) {
+                throw new RuntimeException("您已加入其他家庭,如需加入请选择合并家庭");
+            }
+            mergeIntoFamily(userId, invitation.getFamilyId());
+        }
+
+        user.setFamilyId(invitation.getFamilyId());
+        if (familyRole != null) {
+            user.setFamilyRole(familyRole);
+        }
+
+        if (family.getCreatorId() == null) {
+            family.setCreatorId(userId);
+            user.setIsFamilyAdmin(true);
+            familyMapper.updateById(family);
+        }
+
+        user.setUpdatedAt(new Date());
+        userMapper.updateById(user);
+
+        invitation.setStatus("used");
+        invitation.setUpdatedAt(new Date());
+        familyInvitationMapper.updateById(invitation);
+
+        return "加入家庭成功";
+    }
+
+    public void mergeIntoFamily(Long userId, Long targetFamilyId) {
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            throw new RuntimeException("用户不存在");
+        }
+        Long oldFamilyId = user.getFamilyId();
+        if (oldFamilyId == null) {
+            throw new RuntimeException("用户尚未加入任何家庭");
+        }
+
+        user.setFamilyId(targetFamilyId);
+        user.setUpdatedAt(new Date());
+        userMapper.updateById(user);
+
+        List<Child> children = childMapper.selectList(
+                new LambdaQueryWrapper<Child>()
+                        .eq(Child::getFamilyId, oldFamilyId)
+        );
+        for (Child child : children) {
+            child.setFamilyId(targetFamilyId);
+            child.setUpdatedAt(new Date());
+            childMapper.updateById(child);
+        }
+
+        if (!oldFamilyId.equals(targetFamilyId)) {
+            Long memberCount = userMapper.selectCount(
+                    new LambdaQueryWrapper<User>()
+                            .eq(User::getFamilyId, oldFamilyId)
+            );
+            if (memberCount == 0) {
+                familyMapper.deleteById(oldFamilyId);
+            }
+        }
+    }
+
+    public FamilyStatusDTO checkUserFamilyStatus(Long userId) {
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            throw new RuntimeException("用户不存在");
+        }
+
+        FamilyStatusDTO dto = new FamilyStatusDTO();
+        if (user.getFamilyId() == null) {
+            dto.setInFamily(false);
+            return dto;
+        }
+
+        Family family = familyMapper.selectById(user.getFamilyId());
+        if (family == null) {
+            dto.setInFamily(false);
+            return dto;
+        }
+
+        Long memberCount = userMapper.selectCount(
+                new LambdaQueryWrapper<User>()
+                        .eq(User::getFamilyId, user.getFamilyId())
+        );
+
+        dto.setInFamily(true);
+        dto.setFamilyId(family.getId());
+        dto.setFamilyName(family.getName());
+        dto.setIsAdmin(user.getIsFamilyAdmin() != null && user.getIsFamilyAdmin());
+        dto.setMemberCount(memberCount.intValue());
+        return dto;
+    }
+
+    public void transferAdmin(Long familyId, Long currentUserId, Long newAdminId) {
+        Family family = familyMapper.selectById(familyId);
+        if (family == null) {
+            throw new RuntimeException("家庭不存在");
+        }
+
+        if (family.getCreatorId() == null || !family.getCreatorId().equals(currentUserId)) {
+            throw new RuntimeException("只有家庭创建者才能转让管理员权限");
+        }
+
+        User newAdmin = userMapper.selectById(newAdminId);
+        if (newAdmin == null || !familyId.equals(newAdmin.getFamilyId())) {
+            throw new RuntimeException("新管理员不是该家庭成员");
+        }
+
+        User currentUser = userMapper.selectById(currentUserId);
+        if (currentUser != null) {
+            currentUser.setIsFamilyAdmin(false);
+            currentUser.setUpdatedAt(new Date());
+            userMapper.updateById(currentUser);
+        }
+
+        family.setCreatorId(newAdminId);
+        family.setUpdatedAt(new Date());
+        familyMapper.updateById(family);
+
+        newAdmin.setIsFamilyAdmin(true);
+        newAdmin.setUpdatedAt(new Date());
+        userMapper.updateById(newAdmin);
+    }
+}

+ 55 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/WechatService.java

@@ -5,11 +5,16 @@ import com.alibaba.fastjson.JSONObject;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
 import org.springframework.web.client.RestTemplate;
 import com.etotem.cfc.service.api.WechatServiceInterface;
 
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.UUID;
@@ -149,4 +154,54 @@ public class WechatService implements WechatServiceInterface {
             throw new RuntimeException("获取access_token异常");
         }
     }
+
+    /**
+     * 生成小程序二维码(wxacode)
+     * @param scene 场景参数(最大32个可见字符)
+     * @param page 小程序页面路径
+     * @return 二维码图片的base64编码字符串
+     */
+    public String generateWxacode(String scene, String page) {
+        if (testMode) {
+            log.info("测试模式:模拟generateWxacode返回");
+            return "";
+        }
+        try {
+            String accessToken = getAccessToken();
+            String url = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=" + accessToken;
+
+            Map<String, Object> requestBody = new HashMap<>();
+            requestBody.put("scene", scene);
+            requestBody.put("page", page);
+            requestBody.put("check_path", false);
+            requestBody.put("env_version", "trial"); // use "trial" for dev, "release" for production
+
+            org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
+            headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
+            org.springframework.http.HttpEntity<String> entity = new org.springframework.http.HttpEntity<>(
+                    com.alibaba.fastjson.JSON.toJSONString(requestBody), headers);
+
+            ResponseEntity<byte[]> response = restTemplate.exchange(url,
+                    org.springframework.http.HttpMethod.POST, entity, byte[].class);
+
+            byte[] body = response.getBody();
+            if (body == null || body.length == 0) {
+                throw new RuntimeException("生成二维码失败:返回为空");
+            }
+
+            // Check if response is an error JSON (starts with {)
+            String bodyStr = new String(body, StandardCharsets.UTF_8);
+            if (bodyStr.contains("errcode") && !bodyStr.contains("\"errcode\":0")) {
+                com.alibaba.fastjson.JSONObject errorJson = com.alibaba.fastjson.JSON.parseObject(bodyStr);
+                log.error("生成wxacode失败: {}", errorJson.toJSONString());
+                throw new RuntimeException("生成二维码失败: " + errorJson.getString("errmsg"));
+            }
+
+            // Encode to base64
+            return Base64.getEncoder().encodeToString(body);
+        } catch (Exception e) {
+            log.error("生成wxacode异常", e);
+            throw new RuntimeException("生成二维码异常: " + e.getMessage());
+        }
+    }
 }

+ 24 - 5
cfc-frontend/App.vue

@@ -4,8 +4,11 @@ import Vue from 'vue'
 Vue.prototype.$config = config
 
 export default {
-  onLaunch: function() {
-    console.log('App Launch')
+  onLaunch: function(options) {
+    console.log('App Launch', options)
+    // 处理小程序码场景值(从 wxacode 扫码进入)
+    this.handleScene(options)
+    
     // TabBar 始终显示 5 个底签,不因登录状态隐藏
     const token = uni.getStorageSync('token')
     if (token) {
@@ -15,9 +18,12 @@ export default {
       this.tryAutoLogin()
     }
   },
-  onShow: function() {
-    console.log('App Show')
-    // 每次 app 展示时同步 storage → Vuex(防止 reLaunch/navigateTo 后 Vuex 未更新的问题)
+  onShow: function(options) {
+    console.log('App Show', options)
+    // 每次 app 展示时也检查场景值(小程序码可能从冷启动后的 onShow 传入)
+    this.handleScene(options)
+    
+    // 同步 storage → Vuex(防止 reLaunch/navigateTo 后 Vuex 未更新的问题)
     var token = uni.getStorageSync('token')
     if (token && token !== this.$store.state.token) {
       this.$store.commit('setToken', token)
@@ -84,6 +90,19 @@ export default {
           fail: reject
         })
       })
+    },
+    // 处理小程序码场景值,跳转到邀请页
+    handleScene(options) {
+      if (!options || !options.query) return
+      var scene = options.query.scene
+      if (!scene) return
+      var sceneDecoded = decodeURIComponent(scene)
+      if (sceneDecoded.indexOf('invite=') === 0) {
+        var token = sceneDecoded.substring(7)
+        uni.navigateTo({
+          url: '/pages/invite/join?token=' + token
+        })
+      }
     }
   }
 }

+ 8 - 0
cfc-frontend/pages.json

@@ -209,6 +209,14 @@
         "navigationBarTitleText": "AI 家庭助手",
         "navigationStyle": "custom"
       }
+    },
+    // ===== 邀请加入 =====
+    {
+      "path": "pages/invite/join",
+      "style": {
+        "navigationBarTitleText": "加入家庭",
+        "navigationStyle": "custom"
+      }
     }
   ],
   "subPackages": [

+ 371 - 0
cfc-frontend/pages/discover/activity-detail/activity-detail.vue

@@ -0,0 +1,371 @@
+<template>
+  <view class="container">
+    <!-- 加载状态 -->
+    <view v-if="loading" class="loading-wrap">
+      <view class="loading-spinner"></view>
+      <text class="loading-text">加载中...</text>
+    </view>
+
+    <!-- 错误状态 -->
+    <view v-else-if="error" class="error-wrap">
+      <text class="error-icon">📋</text>
+      <text class="error-text">{{ errorMsg }}</text>
+      <button class="retry-btn" @click="loadDetail(activityId)">重新加载</button>
+    </view>
+
+    <!-- 活动内容 -->
+    <scroll-view v-else-if="activity.id" scroll-y class="content">
+      <!-- 封面图 -->
+      <image
+        v-if="activity.coverImage"
+        class="cover"
+        :src="activity.coverImage"
+        mode="widthFix"
+      />
+      <image v-else class="cover placeholder-cover" src="/static/default-activity.png" mode="aspectFill" />
+
+      <view class="info-section">
+        <text class="activity-title">{{ activity.title }}</text>
+
+        <!-- 活动状态标签 -->
+        <view class="status-row">
+          <text class="status-tag" :class="'status-' + (activity.status || 'upcoming')">{{ statusText(activity.status) }}</text>
+        </view>
+
+        <!-- 活动元信息 -->
+        <view class="meta-list">
+          <view class="meta-item" v-if="activity.startTime">
+            <text class="meta-icon">🕐</text>
+            <text class="meta-text">{{ activity.startTime }}{{ activity.endTime ? ' ~ ' + activity.endTime : '' }}</text>
+          </view>
+          <view class="meta-item" v-if="activity.location">
+            <text class="meta-icon">📍</text>
+            <text class="meta-text">{{ activity.location }}</text>
+          </view>
+          <view class="meta-item" v-if="activity.maxParticipants">
+            <text class="meta-icon">👥</text>
+            <text class="meta-text">{{ activity.currentParticipants || 0 }} / {{ activity.maxParticipants }} 人</text>
+          </view>
+          <view class="meta-item">
+            <text class="meta-icon">💰</text>
+            <text class="meta-text">{{ activity.priceLabel || (activity.price > 0 ? '¥' + activity.price : '免费') }}</text>
+          </view>
+        </view>
+
+        <!-- 活动简介 -->
+        <view class="desc-section" v-if="activity.description">
+          <text class="section-title">活动介绍</text>
+          <text class="desc-text">{{ activity.description }}</text>
+        </view>
+      </view>
+
+      <!-- 底部操作栏 -->
+      <view class="bottom-bar">
+        <view class="price-info">
+          <text class="bottom-type">活动</text>
+          <text class="bottom-vendor" v-if="activity.vendorName">{{ activity.vendorName }}</text>
+        </view>
+        <view class="action-btns">
+          <button
+            class="btn-checkin"
+            :class="{ 'btn-checkin-done': checkinDone, 'btn-checkin-loading': checkinLoading }"
+            :disabled="checkinLoading || checkinDone"
+            @click="onCheckin"
+          >
+            <text v-if="checkinLoading">签到中...</text>
+            <text v-else-if="checkinDone">已签到 ✓</text>
+            <text v-else>签到</text>
+          </button>
+        </view>
+      </view>
+    </scroll-view>
+
+    <!-- 空状态 -->
+    <view v-else class="empty-wrap">
+      <text class="empty-text">活动不存在或已下架</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getActivityDetail, activityCheckin } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      activityId: null,
+      activity: {},
+      loading: false,
+      error: false,
+      errorMsg: '',
+      checkinLoading: false,
+      checkinDone: false,
+      checkinPoints: 0
+    }
+  },
+  onLoad: function(options) {
+    if (options && options.id) {
+      this.activityId = options.id
+      this.loadDetail(options.id)
+    } else {
+      this.error = true
+      this.errorMsg = '缺少活动ID'
+    }
+
+    // 检查是否已经签到(从路由参数获取)
+    if (options && options.checkinDone === '1') {
+      this.checkinDone = true
+    }
+  },
+  methods: {
+    loadDetail: function(id) {
+      var self = this
+      self.loading = true
+      self.error = false
+      getActivityDetail(id).then(function(res) {
+        self.loading = false
+        if (res && res.code === 200 && res.data) {
+          self.activity = res.data
+        } else {
+          self.error = true
+          self.errorMsg = (res && res.message) || '加载失败'
+        }
+      }).catch(function(err) {
+        self.loading = false
+        self.error = true
+        self.errorMsg = '网络异常,请稍后重试'
+      })
+    },
+    statusText: function(status) {
+      var map = { draft: '草稿', published: '进行中', ended: '已结束' }
+      return map[status] || '未知'
+    },
+    onCheckin: function() {
+      var self = this
+      if (self.checkinLoading || self.checkinDone) return
+
+      var childId = uni.getStorageSync('currentChildId')
+      if (!childId) {
+        uni.showToast({ title: '请选择孩子身份', icon: 'none' })
+        return
+      }
+
+      self.checkinLoading = true
+      activityCheckin({ id: self.activity.id, childId: childId }).then(function(res) {
+        self.checkinLoading = false
+        if (res && res.code === 200 && res.data) {
+          if (res.data.alreadyCompleted) {
+            self.checkinDone = true
+            uni.showToast({ title: '已签到', icon: 'success' })
+          } else if (res.data.pointsEarned && res.data.pointsEarned > 0) {
+            self.checkinDone = true
+            self.checkinPoints = res.data.pointsEarned
+            uni.showToast({ title: '签到成功 +' + res.data.pointsEarned + '积分', icon: 'success' })
+          } else {
+            self.checkinDone = true
+            uni.showToast({ title: '签到成功', icon: 'success' })
+          }
+        } else {
+          var msg = (res && res.message) || '签到失败'
+          uni.showToast({ title: msg, icon: 'none' })
+        }
+      }).catch(function(err) {
+        self.checkinLoading = false
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #FFF7ED;
+}
+
+/* 加载 / 错误 / 空状态 */
+.loading-wrap,
+.error-wrap,
+.empty-wrap {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 120rpx 40rpx;
+}
+.loading-spinner {
+  width: 60rpx;
+  height: 60rpx;
+  border: 4rpx solid #FDE68A;
+  border-top-color: #F97316;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+  margin-bottom: 20rpx;
+}
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+.loading-text,
+.error-text,
+.empty-text {
+  font-size: 28rpx;
+  color: #999;
+  margin-bottom: 20rpx;
+}
+.error-icon {
+  font-size: 60rpx;
+  margin-bottom: 16rpx;
+}
+.retry-btn {
+  background: #F97316;
+  color: #fff;
+  border: none;
+  border-radius: 40rpx;
+  padding: 12rpx 40rpx;
+  font-size: 26rpx;
+  line-height: 1.5;
+}
+
+/* 内容区 */
+.content {
+  height: 100vh;
+  padding-bottom: 120rpx;
+}
+.cover {
+  width: 100%;
+  display: block;
+}
+.placeholder-cover {
+  height: 400rpx;
+  background: #f0f0f0;
+}
+
+/* 信息区 */
+.info-section {
+  padding: 30rpx;
+}
+.activity-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #1f2937;
+  line-height: 1.4;
+}
+.status-row {
+  margin-top: 16rpx;
+}
+.status-tag {
+  display: inline-block;
+  font-size: 22rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 8rpx;
+}
+.status-draft { background: #F5F5F5; color: #999; }
+.status-published { background: #F0FDF4; color: #22C55E; }
+.status-ended { background: #F5F5F5; color: #999; }
+.status-upcoming { background: #FFF7ED; color: #F97316; }
+
+/* 元信息列表 */
+.meta-list {
+  margin-top: 24rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+}
+.meta-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.meta-item:last-child {
+  margin-bottom: 0;
+}
+.meta-icon {
+  font-size: 28rpx;
+  margin-right: 12rpx;
+  width: 32rpx;
+  text-align: center;
+}
+.meta-text {
+  font-size: 26rpx;
+  color: #4b5563;
+  flex: 1;
+  line-height: 1.4;
+}
+
+/* 活动介绍 */
+.desc-section {
+  margin-top: 30rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+}
+.section-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1f2937;
+  display: block;
+  margin-bottom: 12rpx;
+}
+.desc-text {
+  font-size: 26rpx;
+  color: #4b5563;
+  line-height: 1.6;
+}
+
+/* 底部操作栏 */
+.bottom-bar {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: #fff;
+  padding: 20rpx 30rpx;
+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  box-shadow: 0 -2rpx 12rpx rgba(0,0,0,0.06);
+}
+.price-info {
+  display: flex;
+  flex-direction: column;
+}
+.bottom-type {
+  font-size: 22rpx;
+  color: #999;
+}
+.bottom-vendor {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 4rpx;
+}
+.action-btns {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+}
+.btn-checkin {
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #fff;
+  border: none;
+  border-radius: 40rpx;
+  padding: 16rpx 48rpx;
+  font-size: 28rpx;
+  font-weight: 600;
+  line-height: 1.5;
+  min-width: 160rpx;
+  text-align: center;
+}
+.btn-checkin:active {
+  opacity: 0.85;
+}
+.btn-checkin-done {
+  background: #22C55E;
+}
+.btn-checkin-loading {
+  opacity: 0.7;
+}
+</style>

+ 508 - 0
cfc-frontend/pages/invite/join.vue

@@ -0,0 +1,508 @@
+<template>
+  <view class="page">
+    <!-- 加载中 -->
+    <view class="loading" v-if="loading">
+      <view class="loading-icon">⏳</view>
+      <text class="loading-text">加载中...</text>
+    </view>
+
+    <!-- 邀请信息卡片 -->
+    <view class="invite-card" v-if="!loading && inviteInfo">
+      <view class="invite-header">
+        <view class="invite-icon">🏠</view>
+        <text class="invite-title">家庭邀请</text>
+      </view>
+
+      <view class="invite-body">
+        <text class="family-name">{{ inviteInfo.familyName }}</text>
+        <text class="inviter" v-if="inviteInfo.inviterName">
+          邀请人:{{ inviteInfo.inviterName }}
+        </text>
+        <text class="member-count">
+          已有 {{ inviteInfo.memberCount }} 位家庭成员
+        </text>
+      </view>
+
+      <!-- 未登录:显示登录按钮 -->
+      <view class="action-area" v-if="!isLoggedIn">
+        <button class="btn btn-primary" @tap="handleWechatLogin" :disabled="loginLoading">
+          <text v-if="!loginLoading">微信一键登录并加入家庭</text>
+          <text v-else>登录中...</text>
+        </button>
+        <text class="tip-text">登录后将自动加入此家庭</text>
+      </view>
+
+      <!-- 已登录:显示关系选择 -->
+      <view class="action-area" v-if="isLoggedIn && !accepted">
+        <!-- 已有家庭时显示合并选项 -->
+        <view class="family-warning" v-if="familyStatus && familyStatus.inFamily">
+          <text class="warning-icon">⚠️</text>
+          <text class="warning-text">
+            您当前已加入「{{ familyStatus.familyName }}」家庭,加入新家庭可选择合并(成员将一起迁移)或拒绝
+          </text>
+        </view>
+
+        <!-- 关系选择 -->
+        <view class="form-group">
+          <text class="form-label">您在家庭中的身份</text>
+          <picker @change="onFamilyRoleChange" :value="familyRoleIndex" :range="familyRoleOptions">
+            <view class="picker-select">
+              <text>{{ familyRoleOptions[familyRoleIndex] || '请选择您的身份' }}</text>
+              <text class="picker-arrow">›</text>
+            </view>
+          </picker>
+        </view>
+
+        <!-- 已有家庭时显示合并勾选框 -->
+        <view class="form-group" v-if="familyStatus && familyStatus.inFamily">
+          <label class="checkbox-label" @tap="toggleMerge">
+            <view class="checkbox" :class="{ checked: mergeFamily }">
+              <text v-if="mergeFamily">✓</text>
+            </view>
+            <text>合并家庭(我的家庭成员将一起迁入新家庭)</text>
+          </label>
+        </view>
+
+        <button class="btn btn-primary" @tap="handleAccept" :disabled="acceptLoading">
+          <text v-if="!acceptLoading">加入家庭</text>
+          <text v-else>加入中...</text>
+        </button>
+      </view>
+
+      <!-- 加入成功 -->
+      <view class="success-area" v-if="accepted">
+        <view class="success-icon">✅</view>
+        <text class="success-text">加入成功!</text>
+        <text class="success-sub">欢迎加入「{{ inviteInfo.familyName }}」</text>
+        <button class="btn btn-primary" @tap="goHome">进入首页</button>
+      </view>
+    </view>
+
+    <!-- 邀请无效 -->
+    <view class="error-card" v-if="!loading && error">
+      <view class="error-icon">❌</view>
+      <text class="error-text">{{ error }}</text>
+      <button class="btn btn-outline" @tap="goHome">返回首页</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { validateInvitation, acceptInvitation, checkUserFamily } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      token: '',
+      loading: true,
+      loginLoading: false,
+      acceptLoading: false,
+      isLoggedIn: false,
+      inviteInfo: null,
+      familyStatus: null,
+      familyRoleIndex: 0,
+      mergeFamily: false,
+      accepted: false,
+      error: '',
+      familyRoleOptions: ['爸爸', '妈妈', '爷爷', '奶奶', '姥爷', '姥姥', '其他']
+    }
+  },
+  onLoad(query) {
+    // 获取 token:优先从 query 参数,其次从场景值
+    var token = query.token || query.inviteToken || ''
+    if (!token) {
+      // 检查场景值(从小程序码扫码进入)
+      var scene = query.scene
+      if (scene) {
+        var sceneDecoded = decodeURIComponent(scene)
+        if (sceneDecoded.indexOf('invite=') === 0) {
+          token = sceneDecoded.substring(7)
+        }
+      }
+    }
+    if (!token) {
+      this.error = '邀请链接无效'
+      this.loading = false
+      return
+    }
+    this.token = token
+    this.checkLoginStatus()
+    this.validateToken()
+  },
+  onShow() {
+    // 从登录页返回时重新检查登录状态
+    if (this.token) {
+      this.checkLoginStatus()
+      if (!this.inviteInfo) {
+        this.validateToken()
+      }
+    }
+  },
+  methods: {
+    checkLoginStatus() {
+      var token = uni.getStorageSync('token')
+      this.isLoggedIn = !!token
+      if (this.isLoggedIn) {
+        this.checkFamily()
+      }
+    },
+    async validateToken() {
+      this.loading = true
+      try {
+        var res = await validateInvitation(this.token)
+        if (res && res.data) {
+          this.inviteInfo = res.data
+        } else {
+          this.error = '邀请已失效'
+        }
+      } catch (e) {
+        this.error = e.message || '邀请验证失败'
+      }
+      this.loading = false
+    },
+    async checkFamily() {
+      try {
+        var res = await checkUserFamily()
+        if (res && res.data) {
+          this.familyStatus = res.data
+          // 如果已经在目标家庭,直接显示成功
+          if (this.familyStatus.inFamily && this.familyStatus.familyId === this.inviteInfo.familyId) {
+            this.accepted = true
+          }
+        }
+      } catch (e) {
+        console.log('检查家庭状态失败', e)
+      }
+    },
+    async handleWechatLogin() {
+      this.loginLoading = true
+      try {
+        var that = this
+        // 微信登录
+        var loginRes = await new Promise(function(resolve, reject) {
+          uni.login({
+            provider: 'weixin',
+            success: function(res) {
+              resolve(res)
+            },
+            fail: function(err) {
+              reject(err)
+            }
+          })
+        })
+
+        var code = loginRes.code
+        if (!code) {
+          uni.showToast({ title: '登录失败', icon: 'none' })
+          this.loginLoading = false
+          return
+        }
+
+        // 调起微信登录
+        var { wechatLogin } = require('@/utils/api.js')
+        var res = await wechatLogin(code, {})
+        if (res && res.data) {
+          // 保存登录信息
+          var data = res.data
+          uni.setStorageSync('token', data.token)
+          uni.setStorageSync('userId', data.userId)
+          uni.setStorageSync('role', data.role)
+          uni.setStorageSync('familyId', data.familyId)
+          uni.setStorageSync('currentRole', data.role)
+          uni.setStorageSync('isSwitchedChild', false)
+          uni.setStorageSync('isSwitchedTeacher', false)
+          if (data.openid) {
+            uni.setStorageSync('openid', data.openid)
+          }
+          that.$store && that.$store.commit('setToken', data.token)
+
+          this.isLoggedIn = true
+          uni.showToast({ title: '登录成功', icon: 'success' })
+          // 重新检查家庭状态
+          await this.checkFamily()
+        }
+      } catch (e) {
+        uni.showToast({ title: '登录失败', icon: 'none' })
+        console.log('微信登录失败', e)
+      }
+      this.loginLoading = false
+    },
+    onFamilyRoleChange(e) {
+      this.familyRoleIndex = e.detail.value
+    },
+    toggleMerge() {
+      this.mergeFamily = !this.mergeFamily
+    },
+    async handleAccept() {
+      var familyRole = this.familyRoleOptions[this.familyRoleIndex] || ''
+      if (!familyRole) {
+        uni.showToast({ title: '请选择您在家庭中的身份', icon: 'none' })
+        return
+      }
+
+      this.acceptLoading = true
+      try {
+        var res = await acceptInvitation(this.token, familyRole, this.mergeFamily)
+        if (res && res.code === 200) {
+          this.accepted = true
+          // 更新缓存的 familyId
+          uni.setStorageSync('familyId', this.inviteInfo.familyId)
+          uni.showToast({ title: '加入家庭成功', icon: 'success' })
+        } else {
+          uni.showToast({ title: res.message || '加入失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: e.message || '加入失败', icon: 'none' })
+      }
+      this.acceptLoading = false
+    },
+    goHome() {
+      uni.reLaunch({ url: '/pages/index/index' })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  padding: 32rpx;
+  min-height: 100vh;
+  background: linear-gradient(135deg, #FFF7ED 0%, #FEF3C7 100%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 60vh;
+}
+
+.loading-icon {
+  font-size: 80rpx;
+  margin-bottom: 20rpx;
+}
+
+.loading-text {
+  color: #666;
+  font-size: 28rpx;
+}
+
+.invite-card {
+  width: 100%;
+  max-width: 600rpx;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  padding: 40rpx;
+  box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.08);
+  margin-top: 10vh;
+}
+
+.invite-header {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-bottom: 32rpx;
+}
+
+.invite-icon {
+  font-size: 80rpx;
+  margin-bottom: 16rpx;
+}
+
+.invite-title {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+
+.invite-body {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-bottom: 40rpx;
+  padding: 24rpx;
+  background: #FFF7ED;
+  border-radius: 16rpx;
+}
+
+.family-name {
+  font-size: 40rpx;
+  font-weight: 700;
+  color: #F97316;
+  margin-bottom: 12rpx;
+}
+
+.inviter {
+  font-size: 28rpx;
+  color: #64748B;
+  margin-bottom: 8rpx;
+}
+
+.member-count {
+  font-size: 24rpx;
+  color: #94A3B8;
+}
+
+.action-area {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+
+.family-warning {
+  display: flex;
+  align-items: flex-start;
+  padding: 20rpx;
+  background: #FEF3C7;
+  border-radius: 12rpx;
+  gap: 12rpx;
+}
+
+.warning-icon {
+  font-size: 32rpx;
+  flex-shrink: 0;
+}
+
+.warning-text {
+  font-size: 24rpx;
+  color: #92400E;
+  line-height: 1.5;
+}
+
+.form-group {
+  margin-bottom: 20rpx;
+}
+
+.form-label {
+  display: block;
+  font-size: 28rpx;
+  color: #374151;
+  margin-bottom: 12rpx;
+  font-weight: 500;
+}
+
+.picker-select {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 24rpx 28rpx;
+  background: #F9FAFB;
+  border: 2rpx solid #E5E7EB;
+  border-radius: 16rpx;
+  font-size: 28rpx;
+  color: #1E293B;
+}
+
+.picker-arrow {
+  font-size: 36rpx;
+  color: #9CA3AF;
+}
+
+.checkbox-label {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  font-size: 26rpx;
+  color: #4B5563;
+}
+
+.checkbox {
+  width: 36rpx;
+  height: 36rpx;
+  border: 2rpx solid #D1D5DB;
+  border-radius: 8rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.checkbox.checked {
+  background: #F97316;
+  border-color: #F97316;
+  color: #FFFFFF;
+  font-size: 24rpx;
+  font-weight: 700;
+}
+
+.btn {
+  width: 100%;
+  padding: 28rpx;
+  border-radius: 16rpx;
+  font-size: 30rpx;
+  font-weight: 600;
+  text-align: center;
+  border: none;
+}
+
+.btn-primary {
+  background: linear-gradient(135deg, #F97316, #EA580C);
+  color: #FFFFFF;
+}
+
+.btn-primary:disabled {
+  opacity: 0.6;
+}
+
+.btn-outline {
+  background: #FFFFFF;
+  color: #F97316;
+  border: 2rpx solid #F97316;
+}
+
+.tip-text {
+  font-size: 24rpx;
+  color: #94A3B8;
+  text-align: center;
+}
+
+.success-area {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 16rpx;
+}
+
+.success-icon {
+  font-size: 80rpx;
+}
+
+.success-text {
+  font-size: 36rpx;
+  font-weight: 700;
+  color: #16A34A;
+}
+
+.success-sub {
+  font-size: 28rpx;
+  color: #6B7280;
+  margin-bottom: 24rpx;
+}
+
+.error-card {
+  width: 100%;
+  max-width: 600rpx;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  padding: 60rpx 40rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 20rpx;
+  margin-top: 10vh;
+}
+
+.error-icon {
+  font-size: 80rpx;
+}
+
+.error-text {
+  font-size: 30rpx;
+  color: #6B7280;
+  text-align: center;
+}
+</style>

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

@@ -822,6 +822,33 @@ export const acceptFamilyInviteByCode = (inviteCode) => {
   return request('/api/family/invite/accept-by-code', 'POST', { inviteCode })
 }
 
+// ===== 家庭邀请(令牌版,支持扫码流) =====
+
+// 生成家庭邀请(含小程序码或二维码)
+export const generateFamilyInvite = () => {
+  return request('/api/family/invite/generate', 'POST')
+}
+
+// 验证邀请令牌
+export const validateInvitation = (token) => {
+  return request('/api/family/invite/validate', 'POST', { token })
+}
+
+// 接受邀请(加入家庭)
+export const acceptInvitation = (token, familyRole, mergeFamily) => {
+  return request('/api/family/invite/accept', 'POST', { token, familyRole, mergeFamily: !!mergeFamily })
+}
+
+// 检查当前用户家庭状态
+export const checkUserFamily = () => {
+  return request('/api/family/invite/check-family', 'POST')
+}
+
+// 转让家庭管理员
+export const transferFamilyAdmin = (newAdminId) => {
+  return request('/api/family/invite/transfer-admin', 'POST', { newAdminId })
+}
+
 // ===== AI 聊天 =====
 export const aiSendMessage = (data) => {
   return request('/api/ai/chat/send', 'POST', data)