Explorar o código

feat(team): T38 团队邀请功能 — 邀请码生成/信息/凭码加入接口 + 前端邀请入口与受邀确认页

需求:廖总「在我的团队加一个邀请功能,邀请其他用户加入团队」
规格:docs/superpowers/specs/2026-11-01-team-system-design.md §3.5/§4.2/§4.3(任务 T38)

后端(cfc-backend)
- 迁移443:supply_system_member 扩 invite_code(幂等 ensureColumn + 唯一索引 uk_supply_member_invite_code),schema.sql 同步
- SupplySystemMember 实体扩 inviteCode 字段
- SupplyTeamController 新增 3 接口(@PostMapping + @Resource + @RequestAttribute("userId") + Result<T>):
  POST /api/admin/supply-team/invite-code  生成/获取我的邀请码(幂等复用,refresh=true 重置旧码,含越权防护)
  POST /api/admin/supply-team/invite/info   凭码查团队名/邀请人昵称头像/成员数(确认页用,不泄露隐私)
  POST /api/admin/supply-team/invite/join   凭码加入(addMember role=member/status=active + 挂邀请人为上级)
- SupplySystemService 新增 generateInviteCode/getInviteInfo/joinByInviteCode(邀请码唯一性校验、边界校验)
- SupplyHierarchyService 新增 ensureRelation:parentId=邀请人、level=父级level+1,已存在 inactive 关系则恢复 active(被移除后重新加入不产生重复行)

前端(cfc-frontend)
- pages/vendor/team-system/team-system.vue:团队成员区加「邀请成员」按钮 + 弹窗(邀请码/复制/刷新重置,小程序码可后置)
- 新增 pages/vendor/team-invite/team-invite.vue:受邀确认页(展示邀请人/团队 → 加入 → 成功态 → 跳转我的团队),已注册 pages.json
- utils/api.js 新增 getVendorInviteCode/getVendorInviteInfo/joinVendorTeamByInvite(各 URL 唯一规范入口)

边界:同一 (systemId,userId) 仅 active 一次;被移除后可再申请;role=member 不可自助变更;
邀请码永久有效+唯一;邀请不与佣金直接挂钩(结算走唯一负责人口径 §0.5)

文档:API_REFERENCE.md 新增 §4.58 + PROJECT-OVERVIEW.md 同步

验证:mvn compile BUILD SUCCESS;cfc-web npm run build 成功(仅存量 warning);
小程序 verify-vue-compile 全量 PASS(397/397)、audit-mp-antipatterns ERROR 0、node --check 通过
Agnes Agent hai 1 semana
pai
achega
4101e98ec9

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

@@ -3419,6 +3419,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 硬性要求:必须新增数据库迁移;存量数据保持 NULL,读取走年龄兜底,不做回填覆盖。
         ensureColumn("users", "is_child",
                 "TINYINT(1) NULL COMMENT '孩子身份标记: 1=孩子, 0=非孩子, NULL=未判定(按生日推算年龄<18兜底)'");
+
+        // 迁移444: T38 团队邀请功能 —— supply_system_member 增加个人专属邀请码(幂等,永久有效+唯一)
+        migrateSupplyMemberInviteCode();
     }
 
     /**
@@ -3674,6 +3677,33 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         }
     }
 
+    /**
+     * 迁移444: T38 团队邀请功能 —— supply_system_member 增加个人专属邀请码。
+     *
+     * <p>每个成员一个个人专属邀请码(永久有效 + 唯一),他人凭码加入团队后挂邀请人为上级。
+     * 幂等:列已存在则跳过;唯一索引已存在则跳过(存量行 invite_code 均为 NULL,
+     * MySQL 唯一索引允许多个 NULL,不影响历史数据)。</p>
+     */
+    private void migrateSupplyMemberInviteCode() {
+        if (!tableExists("supply_system_member")) {
+            log.warn("迁移444跳过:supply_system_member 表不存在");
+            return;
+        }
+        ensureColumn("supply_system_member", "invite_code",
+                "VARCHAR(50) DEFAULT NULL COMMENT '个人专属邀请码(永久有效+唯一)'");
+        try {
+            jdbcTemplate.execute("ALTER TABLE supply_system_member ADD UNIQUE INDEX uk_supply_member_invite_code (invite_code)");
+            log.info("迁移444: 已为 supply_system_member.invite_code 添加唯一索引");
+        } catch (Exception e) {
+            String msg = e.getMessage();
+            if (msg != null && (msg.contains("Duplicate") || msg.contains("already exists"))) {
+                log.info("迁移444: supply_system_member.invite_code 唯一索引已存在,跳过");
+            } else {
+                log.warn("迁移444: 添加 invite_code 唯一索引失败(忽略): {}", e.getMessage());
+            }
+        }
+    }
+
     private void migrateHealthDimensionsTable() {
         try {
             if (tableExists("health_dimension_score")) {

+ 59 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/SupplyTeamController.java

@@ -1,11 +1,13 @@
 package com.etotem.cfc.controller.admin;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.SupplySystem;
 import com.etotem.cfc.service.SupplyCustomerRelationService;
 import com.etotem.cfc.service.SupplySystemService;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import com.etotem.cfc.util.ParamUtils;
@@ -91,4 +93,61 @@ public class SupplyTeamController {
         Object relation = supplyCustomerRelationService.getRelationByFamily(systemId, familyId);
         return Result.success(relation);
     }
+
+    // ========== 邀请功能(T38:在我的团队邀请其他用户加入)==========
+
+    /**
+     * 生成/获取我的邀请码(幂等)。body: {systemId, refresh?};refresh=true 重置旧码。
+     * 返回 {inviteCode, systemId, teamName, shareText}。仅当前用户的团队成员可调用(越权防护)。
+     */
+    @PostMapping("/invite-code")
+    public Result<Map<String, Object>> getInviteCode(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long systemId = ParamUtils.getLong(params.get("systemId"));
+        if (systemId == null) {
+            return Result.error("systemId不能为空");
+        }
+        // 越权防护:仅体系的 admin/active 成员可生成自己的邀请码(T31)
+        if (!supplySystemService.isMemberOrAdmin(systemId, userId)) {
+            return Result.error("无权操作该团队");
+        }
+        boolean refresh = Boolean.TRUE.equals(ParamUtils.getBoolean(params.get("refresh")));
+        String code = supplySystemService.generateInviteCode(systemId, userId, refresh);
+        SupplySystem system = supplySystemService.getById(systemId);
+        Map<String, Object> data = new HashMap<>();
+        data.put("inviteCode", code);
+        data.put("systemId", systemId);
+        data.put("teamName", system != null ? system.getName() : "");
+        data.put("shareText", "邀请您加入「" + (system != null && system.getName() != null ? system.getName() : "我的团队")
+                + "」,我的专属邀请码:" + code);
+        return Result.success(data);
+    }
+
+    /**
+     * 邀请码信息(确认页展示:团队名/邀请人昵称头像/成员数,不泄露其他隐私)。body: {inviteCode}
+     */
+    @PostMapping("/invite/info")
+    public Result<Map<String, Object>> getInviteInfo(@RequestBody Map<String, Object> params) {
+        String inviteCode = (String) params.get("inviteCode");
+        if (inviteCode == null || inviteCode.trim().isEmpty()) {
+            return Result.error("邀请码不能为空");
+        }
+        return Result.success(supplySystemService.getInviteInfo(inviteCode));
+    }
+
+    /**
+     * 凭邀请码加入团队(挂邀请人为上级)。body: {inviteCode}
+     * 边界:同一 (systemId,userId) 仅 active 一次;被移除后可重新加入;role=member 不可自助变更。
+     */
+    @PostMapping("/invite/join")
+    public Result<Map<String, Object>> joinByInviteCode(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        String inviteCode = (String) params.get("inviteCode");
+        if (inviteCode == null || inviteCode.trim().isEmpty()) {
+            return Result.error("邀请码不能为空");
+        }
+        return Result.success(supplySystemService.joinByInviteCode(inviteCode, userId));
+    }
 }

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SupplySystemMember.java

@@ -17,4 +17,6 @@ public class SupplySystemMember implements Serializable {
     private String role;
     private String status;
     private Date joinedAt;
+    /** 个人专属邀请码(T38:邀请他人加入团队,唯一,永久有效,re-generate 可重置) */
+    private String inviteCode;
 }

+ 47 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/SupplyHierarchyService.java

@@ -97,6 +97,53 @@ public class SupplyHierarchyService extends ServiceImpl<SupplyHierarchyMapper, S
         return this.save(hierarchy);
     }
 
+    /**
+     * 确保成员在体系内存在一条 active 的上下级关系(T38 邀请加入专用)。
+     * 已存在(不论状态:active/inactive)则更新 parent/level 并恢复 active;不存在则新建。
+     * parentId=邀请人,level=父节点 level+1(父节点无 relation 时 level=1)。
+     * 与 addRelation 的区别:addRelation 仅新增且命中已存在关系即跳过(可能遗留 inactive 旧行),
+     * 本方法用于「被移除后重新加入」等场景,保证最终恰有一条 active 关系。
+     */
+    @Transactional
+    public SupplyHierarchy ensureRelation(Long systemId, Long parentId, Long memberId) {
+        if (parentId == null || parentId.equals(memberId)) {
+            throw new IllegalArgumentException("不能将自己设为上级");
+        }
+
+        SupplyHierarchy existing = this.getOne(new LambdaQueryWrapper<SupplyHierarchy>()
+                .eq(SupplyHierarchy::getSystemId, systemId)
+                .eq(SupplyHierarchy::getMemberId, memberId));
+
+        int level = 1;
+        SupplyHierarchy parentRelation = this.getOne(new LambdaQueryWrapper<SupplyHierarchy>()
+                .eq(SupplyHierarchy::getSystemId, systemId)
+                .eq(SupplyHierarchy::getMemberId, parentId)
+                .eq(SupplyHierarchy::getStatus, "active"));
+        if (parentRelation != null) {
+            level = parentRelation.getLevel() + 1;
+        }
+
+        if (existing != null) {
+            existing.setParentId(parentId);
+            existing.setLevel(level);
+            existing.setStatus("active");
+            existing.setUpdatedAt(new Date());
+            this.updateById(existing);
+            return existing;
+        }
+
+        SupplyHierarchy hierarchy = new SupplyHierarchy();
+        hierarchy.setSystemId(systemId);
+        hierarchy.setParentId(parentId);
+        hierarchy.setMemberId(memberId);
+        hierarchy.setLevel(level);
+        hierarchy.setStatus("active");
+        hierarchy.setCreatedAt(new Date());
+        hierarchy.setUpdatedAt(new Date());
+        this.save(hierarchy);
+        return hierarchy;
+    }
+
     @Transactional
     public boolean removeRelation(Long systemId, Long memberId) {
         LambdaQueryWrapper<SupplyHierarchy> wrapper = new LambdaQueryWrapper<>();

+ 157 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/SupplySystemService.java

@@ -14,7 +14,10 @@ import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.Random;
 import com.etotem.cfc.util.SortUtil;
 
 @Service
@@ -26,6 +29,9 @@ public class SupplySystemService extends ServiceImpl<SupplySystemMapper, SupplyS
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private SupplyHierarchyService supplyHierarchyService;
+
     public Page<SupplySystem> list(String keyword, String status, int page, int size) {
         Page<SupplySystem> pageParam = new Page<>(page, size);
         LambdaQueryWrapper<SupplySystem> wrapper = new LambdaQueryWrapper<>();
@@ -202,4 +208,155 @@ public class SupplySystemService extends ServiceImpl<SupplySystemMapper, SupplyS
         member.setRole(role);
         return supplySystemMemberMapper.updateById(member) > 0;
     }
+
+    // ========== 邀请功能(T38:在我的团队邀请其他用户加入)==========
+
+    /**
+     * 生成/获取当前成员的个人专属邀请码(T38)。
+     * refresh=false 且已有码 → 幂等复用;refresh=true(或空码)→ 重新生成并使旧码失效。
+     *
+     * @throws IllegalArgumentException 团队不存在/已停用/当前用户不是该团队成员
+     */
+    @Transactional
+    public String generateInviteCode(Long systemId, Long userId, boolean refresh) {
+        if (systemId == null || userId == null) {
+            throw new IllegalArgumentException("参数不完整");
+        }
+        SupplySystem system = this.getById(systemId);
+        if (system == null) {
+            throw new IllegalArgumentException("团队不存在");
+        }
+        if (!"active".equals(system.getStatus())) {
+            throw new IllegalArgumentException("团队已停用,无法生成邀请码");
+        }
+        SupplySystemMember member = getMemberBySystemAndUser(systemId, userId);
+        if (member == null) {
+            throw new IllegalArgumentException("您不是该团队成员,无法生成邀请码");
+        }
+        if (!refresh && member.getInviteCode() != null && !member.getInviteCode().isEmpty()) {
+            return member.getInviteCode();
+        }
+        String code = generateUniqueInviteCode();
+        member.setInviteCode(code);
+        supplySystemMemberMapper.updateById(member);
+        return code;
+    }
+
+    /**
+     * 邀请码信息(确认页展示:团队名/邀请人昵称头像/成员数,不泄露其他隐私)(T38)。
+     * 不校验当前用户身份——凭码即可查看(确认页在加入前使用)。
+     */
+    public Map<String, Object> getInviteInfo(String inviteCode) {
+        SupplySystemMember inviter = getMemberByInviteCode(inviteCode);
+        if (inviter == null || inviter.getSystemId() == null) {
+            throw new IllegalArgumentException("邀请码不存在或已失效");
+        }
+        Long systemId = inviter.getSystemId();
+        SupplySystem system = this.getById(systemId);
+        if (system == null) {
+            throw new IllegalArgumentException("邀请码不存在或已失效");
+        }
+        Map<String, Object> info = new HashMap<>();
+        info.put("systemId", systemId);
+        info.put("teamName", system.getName());
+        info.put("inviterId", inviter.getUserId());
+        User inviterUser = userMapper.selectById(inviter.getUserId());
+        info.put("inviterNickname", inviterUser != null ? inviterUser.getNickname() : "微信用户");
+        info.put("inviterAvatar", inviterUser != null ? inviterUser.getAvatar() : null);
+        Long memberCount = supplySystemMemberMapper.selectCount(new LambdaQueryWrapper<SupplySystemMember>()
+                .eq(SupplySystemMember::getSystemId, systemId)
+                .eq(SupplySystemMember::getStatus, "active"));
+        info.put("memberCount", memberCount != null ? memberCount : 0L);
+        info.put("active", "active".equals(system.getStatus()));
+        return info;
+    }
+
+    /**
+     * 凭邀请码加入团队(T38):
+     * 校验码有效 + 用户未 active 加入过该体系 → 成为 role=member/status=active 成员
+     * → supply_hierarchy 挂 parentId=邀请人 / level=父级level+1(邀请人=其上级,
+     * 成员子树整体结算给该成员,与 §0.5 负责人结算口径一致)。
+     * 边界:同一 (systemId,userId) 仅 active 一次(active 拒绝重复加入,inactive 可重新加入)。
+     *
+     * @return 加入后的团队/邀请人信息(同 getInviteInfo)
+     */
+    @Transactional
+    public Map<String, Object> joinByInviteCode(String inviteCode, Long userId) {
+        if (userId == null) {
+            throw new IllegalArgumentException("未登录");
+        }
+        SupplySystemMember inviter = getMemberByInviteCode(inviteCode);
+        if (inviter == null || inviter.getSystemId() == null) {
+            throw new IllegalArgumentException("邀请码不存在或已失效");
+        }
+        Long systemId = inviter.getSystemId();
+        SupplySystem system = this.getById(systemId);
+        if (system == null) {
+            throw new IllegalArgumentException("邀请码不存在或已失效");
+        }
+        if (!"active".equals(system.getStatus())) {
+            throw new IllegalArgumentException("团队已停用,暂无法加入");
+        }
+        // 同一 (systemId,userId) 仅加入一次;active → 拒绝,inactive(被移除)→ 重新激活
+        SupplySystemMember existing = getMemberBySystemAndUser(systemId, userId);
+        if (existing != null && "active".equals(existing.getStatus())) {
+            throw new IllegalArgumentException("您已在该团队中,无需重复加入");
+        }
+        SupplySystemMember member;
+        if (existing != null) {
+            existing.setRole("member");
+            existing.setStatus("active");
+            existing.setJoinedAt(new Date());
+            supplySystemMemberMapper.updateById(existing);
+            member = existing;
+        } else {
+            member = new SupplySystemMember();
+            member.setSystemId(systemId);
+            member.setUserId(userId);
+            member.setRole("member");
+            member.setStatus("active");
+            member.setJoinedAt(new Date());
+            supplySystemMemberMapper.insert(member);
+        }
+        // 挂上级:parentId=邀请人,level=父level+1(已存在 inactive 关系则恢复 active 并更新,避免重复行)
+        supplyHierarchyService.ensureRelation(systemId, inviter.getUserId(), userId);
+        return getInviteInfo(inviteCode);
+    }
+
+    private SupplySystemMember getMemberBySystemAndUser(Long systemId, Long userId) {
+        return supplySystemMemberMapper.selectOne(new LambdaQueryWrapper<SupplySystemMember>()
+                .eq(SupplySystemMember::getSystemId, systemId)
+                .eq(SupplySystemMember::getUserId, userId));
+    }
+
+    private SupplySystemMember getMemberByInviteCode(String inviteCode) {
+        if (inviteCode == null || inviteCode.trim().isEmpty()) {
+            return null;
+        }
+        return supplySystemMemberMapper.selectOne(new LambdaQueryWrapper<SupplySystemMember>()
+                .eq(SupplySystemMember::getInviteCode, inviteCode.trim()));
+    }
+
+    /**
+     * 生成全体系唯一邀请码:字符集去除易混淆字符(I/O/0/1),
+     * 幂等/重生成均校验唯一性(配合迁移444的 uk_supply_member_invite_code 唯一索引兜底)。
+     */
+    private String generateUniqueInviteCode() {
+        String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+        Random random = new Random();
+        for (int attempt = 0; attempt < 50; attempt++) {
+            StringBuilder sb = new StringBuilder();
+            for (int i = 0; i < 8; i++) {
+                sb.append(chars.charAt(random.nextInt(chars.length())));
+            }
+            String code = sb.toString();
+            Long count = supplySystemMemberMapper.selectCount(new LambdaQueryWrapper<SupplySystemMember>()
+                    .eq(SupplySystemMember::getInviteCode, code));
+            if (count == null || count == 0) {
+                return code;
+            }
+        }
+        // 极端碰撞兜底(唯一索引重试仍失败时),避免死循环
+        return "T" + System.currentTimeMillis();
+    }
 }

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

@@ -2818,7 +2818,9 @@ CREATE TABLE IF NOT EXISTS supply_system_member (
     role VARCHAR(20) NOT NULL DEFAULT 'member' COMMENT '角色: admin/member',
     status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active/inactive',
     joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    invite_code VARCHAR(50) DEFAULT NULL COMMENT '个人专属邀请码(永久有效+唯一,T38)',
     UNIQUE KEY uk_system_user (system_id, user_id),
+    UNIQUE KEY uk_supply_member_invite_code (invite_code),
     KEY idx_user_id (user_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体系成员';
 

+ 6 - 0
cfc-frontend/pages.json

@@ -759,6 +759,12 @@
           "style": {
             "navigationBarTitleText": "我的团队"
           }
+        },
+        {
+          "path": "team-invite/team-invite",
+          "style": {
+            "navigationBarTitleText": "加入团队"
+          }
         }
       ]
     },

+ 299 - 0
cfc-frontend/pages/vendor/team-invite/team-invite.vue

@@ -0,0 +1,299 @@
+<template>
+  <view class="page-team-invite" :style="{ '--font-scale': fontScale }">
+    <!-- 邀请码为空:手动输入 -->
+    <view class="invite-input-section" v-if="!inviteInfo">
+      <view class="invite-input-card">
+        <text class="invite-input-title">输入邀请码</text>
+        <text class="invite-input-tip">好友的专属邀请码,凭码加入其团队成为下级成员</text>
+        <input
+          class="invite-input"
+          v-model="inputCode"
+          placeholder="请输入 8 位邀请码"
+          placeholder-class="invite-input-placeholder"
+          :maxlength="50"
+        />
+        <button class="invite-btn invite-btn-primary" :disabled="!inputCode" @click="loadInviteInfo">查询邀请</button>
+      </view>
+    </view>
+
+    <!-- 加载中 -->
+    <view class="invite-loading" v-if="loading">
+      <text>正在查询邀请信息…</text>
+    </view>
+
+    <!-- 邀请信息展示 -->
+    <view class="invite-info-section" v-if="inviteInfo && !loading">
+      <view class="invite-team-card">
+        <view class="invite-avatar">
+          <image v-if="inviteInfo.inviterAvatar" class="invite-avatar-img" :src="inviteInfo.inviterAvatar" mode="aspectFill"></image>
+          <text v-else class="invite-avatar-placeholder">👤</text>
+        </view>
+        <view class="invite-team-info">
+          <text class="invite-inviter-name">{{ inviteInfo.inviterNickname || '微信用户' }} 邀请你</text>
+          <text class="invite-team-name">加入「{{ inviteInfo.teamName || '我的团队' }}」</text>
+          <text class="invite-team-meta">当前团队 {{ inviteInfo.memberCount || 0 }} 人</text>
+        </view>
+      </view>
+
+      <view class="invite-tip-box">
+        <text class="invite-tip-text">加入后你将作为 {{ inviteInfo.inviterNickname || '邀请人' }} 的下级成员,参与团队协作与结算(结算走团队负责人口径,平台不拆分)</text>
+      </view>
+
+      <!-- 已加入态 -->
+      <view class="invite-success" v-if="joined">
+        <text class="invite-success-icon">✅</text>
+        <text class="invite-success-title">加入成功</text>
+        <text class="invite-success-sub">你已加入「{{ inviteInfo.teamName || '我的团队' }}」</text>
+        <button class="invite-btn invite-btn-primary" @click="goToMyTeam">查看我的团队</button>
+      </view>
+
+      <!-- 未加入:加入按钮 -->
+      <button v-else class="invite-btn invite-btn-primary invite-btn-join" @click="joinTeam">加入团队</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getVendorInviteInfo, joinVendorTeamByInvite } from '@/utils/api.js'
+import fontScaleMixin from '@/mixins/font-scale.js'
+
+export default {
+  mixins: [fontScaleMixin],
+  data() {
+    return {
+      inputCode: '',        // 手动输入的邀请码
+      inviteInfo: null,     // 邀请信息 {teamName, inviterNickname, inviterAvatar, memberCount}
+      loading: false,
+      joined: false
+    }
+  },
+
+  onLoad(options) {
+    // 支持从分享链接/小程序码带码进入:?code=XXX 或 ?inviteCode=XXX
+    var code = (options && (options.code || options.inviteCode)) || ''
+    if (code) {
+      this.inputCode = code
+      this.loadInviteInfo()
+    }
+  },
+
+  methods: {
+    // 凭码查询邀请信息(确认页展示)
+    async loadInviteInfo() {
+      var code = (this.inputCode || '').trim()
+      if (!code) {
+        uni.showToast({ title: '请输入邀请码', icon: 'none' })
+        return
+      }
+      this.loading = true
+      try {
+        const res = await getVendorInviteInfo(code)
+        this.inviteInfo = res.data || null
+        if (!this.inviteInfo) {
+          uni.showToast({ title: '邀请码无效', icon: 'none' })
+        }
+      } catch (e) {
+        this.inviteInfo = null
+        uni.showToast({ title: (e && e.message) || '邀请码无效', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    },
+
+    // 凭码加入团队
+    async joinTeam() {
+      if (this.joined) return
+      var code = (this.inputCode || '').trim()
+      if (!code) {
+        uni.showToast({ title: '请输入邀请码', icon: 'none' })
+        return
+      }
+      try {
+        const res = await joinVendorTeamByInvite(code)
+        if (res && res.data) {
+          this.inviteInfo = res.data
+        }
+        this.joined = true
+        uni.showToast({ title: '加入成功', icon: 'success' })
+      } catch (e) {
+        uni.showToast({ title: (e && e.message) || '加入失败', icon: 'none' })
+      }
+    },
+
+    // 跳转我的团队
+    goToMyTeam() {
+      uni.redirectTo({
+        url: '/pages/vendor/team-system/team-system'
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss">
+.page-team-invite {
+  background-color: $uni-primary-pale;
+  min-height: 100vh;
+  padding: 40rpx 32rpx;
+  box-sizing: border-box;
+}
+
+.invite-input-card,
+.invite-team-card {
+  background-color: $app-surface;
+  border-radius: 24rpx;
+  padding: 32rpx;
+  box-shadow: 0 2rpx 12rpx rgba(249, 115, 22, 0.08);
+}
+
+.invite-input-title {
+  display: block;
+  font-size: calc(34rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $facade-text-strong;
+  margin-bottom: 12rpx;
+}
+
+.invite-input-tip {
+  display: block;
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+  margin-bottom: 24rpx;
+}
+
+.invite-input {
+  height: 88rpx;
+  background-color: $uni-primary-pale;
+  border-radius: 16rpx;
+  padding: 0 24rpx;
+  font-size: calc(30rpx * var(--font-scale, 1));
+  color: $facade-text-strong;
+  margin-bottom: 24rpx;
+  box-sizing: border-box;
+}
+
+.invite-input-placeholder {
+  color: $app-text-subtle;
+}
+
+.invite-loading {
+  text-align: center;
+  padding: 80rpx 0;
+  color: $app-text-subtle;
+  font-size: calc(26rpx * var(--font-scale, 1));
+}
+
+.invite-team-card {
+  display: flex;
+  align-items: center;
+  margin-bottom: 24rpx;
+}
+
+.invite-avatar {
+  margin-right: 24rpx;
+}
+
+.invite-avatar-img {
+  width: 108rpx;
+  height: 108rpx;
+  border-radius: 50%;
+}
+
+.invite-avatar-placeholder {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 108rpx;
+  height: 108rpx;
+  border-radius: 50%;
+  background-color: $facade-fill-blue-ghost;
+  font-size: calc(48rpx * var(--font-scale, 1));
+}
+
+.invite-team-info {
+  flex: 1;
+}
+
+.invite-inviter-name {
+  display: block;
+  font-size: calc(30rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $facade-text-strong;
+  margin-bottom: 8rpx;
+}
+
+.invite-team-name {
+  display: block;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-body;
+  margin-bottom: 8rpx;
+}
+
+.invite-team-meta {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+}
+
+.invite-tip-box {
+  background-color: $app-surface;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 32rpx;
+}
+
+.invite-tip-text {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+  line-height: 1.6;
+}
+
+.invite-btn {
+  height: 88rpx;
+  line-height: 88rpx;
+  border-radius: 24rpx;
+  font-size: calc(30rpx * var(--font-scale, 1));
+  border: none;
+  padding: 0;
+}
+
+.invite-btn-primary {
+  background-color: $brand-primary;
+  color: $app-surface;
+}
+
+.invite-btn-primary[disabled] {
+  opacity: 0.5;
+}
+
+.invite-btn-join {
+  width: 100%;
+}
+
+.invite-success {
+  background-color: $app-surface;
+  border-radius: 24rpx;
+  padding: 48rpx 32rpx;
+  text-align: center;
+}
+
+.invite-success-icon {
+  display: block;
+  font-size: calc(72rpx * var(--font-scale, 1));
+  margin-bottom: 16rpx;
+}
+
+.invite-success-title {
+  display: block;
+  font-size: calc(34rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $facade-text-strong;
+  margin-bottom: 12rpx;
+}
+
+.invite-success-sub {
+  display: block;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-body;
+  margin-bottom: 40rpx;
+}
+</style>

+ 225 - 3
cfc-frontend/pages/vendor/team-system/team-system.vue

@@ -89,7 +89,10 @@
     <view class="section">
       <view class="section-header">
         <text class="section-title">团队成员</text>
-        <text class="section-action" @click="goToMembers">查看全部</text>
+        <view class="section-actions">
+          <text class="section-action" @click="goToMembers">查看全部</text>
+          <text class="section-action section-action-invite" @click="openInviteDialog">邀请成员</text>
+        </view>
       </view>
       <view class="member-list">
         <view class="member-item" v-for="member in teamMembers.slice(0, 5)" :key="member.userId">
@@ -141,11 +144,35 @@
       <button class="btn-manage" @click="goToManage">团队管理</button>
       <button class="btn-customers" @click="goToCustomers">客户列表</button>
     </view>
+
+    <!-- 邀请成员弹窗(T38:生成/复制个人专属邀请码;小程序码可后置) -->
+    <view class="invite-mask" v-if="showInviteDialog" @click="closeInviteDialog">
+      <view class="invite-dialog" @click.stop>
+        <view class="invite-header">
+          <text class="invite-title">邀请成员加入团队</text>
+          <text class="invite-close" @click="closeInviteDialog">✕</text>
+        </view>
+        <view class="invite-body">
+          <text class="invite-tip">邀请码永久有效,好友凭码加入后成为你的下级成员</text>
+          <view class="invite-code-box">
+            <text class="invite-code" v-if="inviteInfo && inviteInfo.inviteCode">{{ inviteInfo.inviteCode }}</text>
+            <text class="invite-code-placeholder" v-else>正在生成邀请码…</text>
+          </view>
+          <view class="invite-btns">
+            <button class="invite-btn invite-btn-primary" :disabled="!inviteInfo || !inviteInfo.inviteCode" @click="copyInviteCode">复制邀请码</button>
+            <button class="invite-btn invite-btn-ghost" @click="refreshInviteCode">刷新邀请码</button>
+          </view>
+          <text class="invite-share-text" v-if="inviteInfo && inviteInfo.shareText">{{ inviteInfo.shareText }}</text>
+          <text class="invite-qr-hint">小程序码可后置,当前通过复制邀请码分享</text>
+          <text class="invite-join-entry" @click="goToInviteJoin">收到邀请码?点这里加入团队</text>
+        </view>
+      </view>
+    </view>
   </view>
 </template>
 
 <script>
-import { getVendorSystemInfo, getVendorHierarchyInfo, getVendorTeamMembers, getVendorTeamCustomers, getVendorTeamList } from '@/utils/api.js'
+import { getVendorSystemInfo, getVendorHierarchyInfo, getVendorTeamMembers, getVendorTeamCustomers, getVendorTeamList, getVendorInviteCode } from '@/utils/api.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
@@ -163,7 +190,9 @@ export default {
       },
       teamMembers: [],
       teamCustomers: [],
-      customerTotal: 0   // 「客户归属」真实总数(后端 total,避免分页截断)
+      customerTotal: 0,   // 「客户归属」真实总数(后端 total,避免分页截断)
+      showInviteDialog: false,  // 邀请成员弹窗显隐(T38)
+      inviteInfo: null           // 我的邀请码信息 {inviteCode, teamName, shareText}
     }
   },
 
@@ -256,6 +285,7 @@ export default {
       this.hierarchyInfo = null
       this.teamMembers = []
       this.teamCustomers = []
+      this.inviteInfo = null
       this.loadTeamData()
     },
 
@@ -364,6 +394,63 @@ export default {
       uni.navigateTo({
         url: url
       })
+    },
+
+    // ===== 邀请成员(T38)=====
+
+    // 打开邀请弹窗:首次进入自动生成/获取我的邀请码
+    async openInviteDialog() {
+      if (!this.currentSystemId) {
+        uni.showToast({ title: '请先选择团队', icon: 'none' })
+        return
+      }
+      this.showInviteDialog = true
+      if (!this.inviteInfo || !this.inviteInfo.inviteCode) {
+        await this.loadInviteCode(false)
+      }
+    },
+
+    // 生成/获取我的邀请码(refresh=true 重置旧码)
+    async loadInviteCode(refresh) {
+      try {
+        const res = await getVendorInviteCode({
+          systemId: this.currentSystemId,
+          refresh: !!refresh
+        })
+        this.inviteInfo = res.data || null
+      } catch (e) {
+        // 生成失败(如非团队成员/团队停用):关闭弹窗并提示
+        this.showInviteDialog = false
+        uni.showToast({ title: (e && e.message) || '获取邀请码失败', icon: 'none' })
+      }
+    },
+
+    closeInviteDialog() {
+      this.showInviteDialog = false
+    },
+
+    copyInviteCode() {
+      if (!this.inviteInfo || !this.inviteInfo.inviteCode) return
+      uni.setClipboardData({
+        data: this.inviteInfo.inviteCode,
+        success: function () {
+          uni.showToast({ title: '邀请码已复制', icon: 'none' })
+        }
+      })
+    },
+
+    async refreshInviteCode() {
+      await this.loadInviteCode(true)
+      if (this.inviteInfo && this.inviteInfo.inviteCode) {
+        uni.showToast({ title: '已刷新,旧码失效', icon: 'none' })
+      }
+    },
+
+    // 进入受邀确认页(可手动粘贴邀请码)
+    goToInviteJoin() {
+      uni.navigateTo({
+        url: '/pages/vendor/team-invite/team-invite'
+      })
     }
   }
 }
@@ -500,6 +587,141 @@ export default {
   color: $brand-primary;
 }
 
+.section-actions {
+  display: flex;
+  align-items: center;
+}
+
+.section-action-invite {
+  margin-left: 24rpx;
+  padding: 6rpx 20rpx;
+  border-radius: 999rpx;
+  background-color: $brand-primary;
+  color: $app-surface;
+}
+
+/* 邀请成员弹窗(T38) */
+.invite-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(0, 0, 0, 0.45);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 999;
+}
+
+.invite-dialog {
+  width: 620rpx;
+  max-width: 86%;
+  background-color: $app-surface;
+  border-radius: 24rpx;
+  padding: 32rpx;
+  box-sizing: border-box;
+}
+
+.invite-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 24rpx;
+}
+
+.invite-title {
+  font-size: calc(32rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $facade-text-strong;
+}
+
+.invite-close {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+  padding: 0 12rpx;
+}
+
+.invite-tip {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+  display: block;
+  margin-bottom: 20rpx;
+}
+
+.invite-code-box {
+  background-color: $uni-primary-pale;
+  border-radius: 16rpx;
+  padding: 32rpx 20rpx;
+  text-align: center;
+  margin-bottom: 24rpx;
+}
+
+.invite-code {
+  font-size: calc(44rpx * var(--font-scale, 1));
+  font-weight: bold;
+  letter-spacing: 6rpx;
+  color: $brand-primary;
+}
+
+.invite-code-placeholder {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+}
+
+.invite-btns {
+  display: flex;
+  margin-bottom: 20rpx;
+}
+
+.invite-btn {
+  flex: 1;
+  height: 76rpx;
+  line-height: 76rpx;
+  border-radius: 20rpx;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  border: none;
+  margin: 0 8rpx;
+  padding: 0;
+}
+
+.invite-btn-primary {
+  background-color: $brand-primary;
+  color: $app-surface;
+}
+
+.invite-btn-primary[disabled] {
+  opacity: 0.5;
+}
+
+.invite-btn-ghost {
+  background-color: $app-surface;
+  color: $brand-primary;
+  border: 2rpx solid $brand-primary;
+}
+
+.invite-share-text {
+  display: block;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+  line-height: 1.6;
+  margin-bottom: 12rpx;
+}
+
+.invite-qr-hint {
+  display: block;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+  margin-bottom: 16rpx;
+}
+
+.invite-join-entry {
+  display: block;
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $brand-primary;
+  text-align: center;
+}
+
 /* 层级关系 */
 .hierarchy-grid {
   display: flex;

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

@@ -3496,6 +3496,20 @@ export const getVendorTeamCustomers = (params) =>
 export const bindVendorCustomer = (params) =>
   request('/api/admin/supply-team/bind-customer', 'POST', params)
 
+// T38 团队邀请功能:邀请码生成/信息查询/凭码加入
+// 规范入口(singular per URL):
+/** 生成/获取我的邀请码:{systemId, refresh?},返回 {inviteCode, teamName, shareText} */
+export const getVendorInviteCode = (params) =>
+  request('/api/admin/supply-team/invite-code', 'POST', params)
+
+/** 邀请码信息(确认页展示团队名/邀请人):{inviteCode} */
+export const getVendorInviteInfo = (inviteCode) =>
+  request('/api/admin/supply-team/invite/info', 'POST', { inviteCode })
+
+/** 凭邀请码加入团队:{inviteCode} */
+export const joinVendorTeamByInvite = (inviteCode) =>
+  request('/api/admin/supply-team/invite/join', 'POST', { inviteCode })
+
 // ===================== 跨小程序单点登录(SSO:cfc ↔ train)=====================
 /** 签发跳转到 train 的一次性票据。返回 { ticket, target } */
 export const createSsoTicket = (target) =>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
docs/superpowers/PROJECT-OVERVIEW.md


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

@@ -2503,3 +2503,37 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
 - 读不到配置 → 回落 AVG(保证兼容)
 
 *文档更新:2026-10-31(家庭数据汇总方式配置(后端):family_metric_agg_config 表 + 配置 CRUD 4 接口 + 珍珠图/章鱼图家庭汇集 2 接口 + 五维聚合读配置)*
+
+---
+
+## 4.58 供应商团队邀请(`/api/admin/supply-team/*`)—— T38
+
+> **需求来源**:廖总 2026-11-02「在我的团队加一个邀请功能,邀请其他用户加入团队」。规格:`docs/superpowers/specs/2026-11-01-team-system-design.md` §3.5 / §4.2 / §4.3(任务 T38)。
+>
+> **语义**:团队成员 = **邀请加入**(与 `referral_tree` 推广返佣是两套独立模型,不在此范围)。凭邀请码加入者成为**团队成员**(非客户),且以邀请人为**上级**(`supply_hierarchy.parent_id=邀请人`,`level=父级level+1`)→ 成员子树 = 该成员自己的团队,与 §0.5「唯一负责人整体结算、平台不拆分」口径一致。
+>
+> **数据模型**:`supply_system_member` 扩列 `invite_code VARCHAR(50)`(个人专属邀请码,迁移444 幂等 `ensureColumn` + 唯一索引 `uk_supply_member_invite_code`;schema.sql 已同步)。
+
+### 接口列表(3 个,全部 `@PostMapping`)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/admin/supply-team/invite-code` | 生成/获取**我的**邀请码(幂等)。body:`{ systemId, refresh? }`;`refresh=true` 重置旧码(旧码立即失效)。返回 `{ inviteCode, systemId, teamName, shareText }`。**越权防护**:须为该体系的 admin/active 成员 |
+| `POST /api/admin/supply-team/invite/info` | 邀请码信息(**确认页**展示)。body:`{ inviteCode }` → `{ systemId, teamName, inviterId, inviterNickname, inviterAvatar, memberCount, active }`。仅展示团队名/邀请人昵称头像/成员数,**不泄露其他隐私** |
+| `POST /api/admin/supply-team/invite/join` | 凭邀请码**加入团队**(挂邀请人为上级)。body:`{ inviteCode }` → 加入后的团队/邀请人信息(同 invite/info 结构) |
+
+### 边界规则
+
+- **同一 `(systemId, userId)` 仅加入一次**:已 active 加入 → 报错「您已在该团队中,无需重复加入」;被移除(`status=inactive`)→ **可再次申请**(自动恢复 active)。
+- **自助加入即生效**(默认口径),团队负责人可随时移除;`role=member` **不可自助变更**(沿用 `member/change-role` 管理员权限)。
+- **邀请码永久有效 + 唯一**(唯一索引兜底);`refresh` 可重置为新码。
+- **邀请不与资金/佣金直接挂钩**:结算走唯一负责人口径(§0.5),平台不拆分团队内部分配。
+- 团队 `status != active` 时不可生成邀请码、不可加入。
+
+### 前端
+
+- `cfc-frontend/pages/vendor/team-system/team-system.vue`:团队成员区新增「邀请成员」按钮 → 弹窗(邀请码 + 复制 + 刷新重置;小程序码可后置)。
+- `cfc-frontend/pages/vendor/team-invite/team-invite.vue`(新增,已注册 `pages.json`):受邀确认页——展示邀请人/团队 → 点「加入团队」→ 成功态 → 跳转「我的团队」;支持 `?code=XXX` 带参进入,也可手动输入邀请码。
+- `cfc-frontend/utils/api.js`:`getVendorInviteCode` / `getVendorInviteInfo` / `joinVendorTeamByInvite`(各 URL 唯一规范入口)。
+
+*文档更新:2026-11-02(T38 团队邀请功能:supply_system_member 扩 invite_code + 邀请码生成/信息/凭码加入 3 接口 + 前端邀请入口与受邀确认页)*

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio