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

feat(supply-team): T38 团队邀请功能(邀请码生成/信息/凭码加入 + 前端邀请入口)

- supply_system_member 扩 invite_code 列(迁移427,ensureColumn 幂等 + schema.sql 同步)
- POST /api/admin/supply-team/invite-code:生成/获取我的团队邀请码(幂等复用,refresh 重置旧码)
- POST /api/admin/supply-team/invite/info:凭码查团队名/邀请人昵称头像/成员数(不泄露隐私)
- POST /api/admin/supply-team/invite/join:凭码加入(addMember active + hierarchy 挂邀请人为上级,inactive 可再申请)
- 团队成员区加「邀请成员」按钮 → 弹窗(邀请码+复制+刷新)
- 新增受邀确认页 pages/vendor/team-invite/team-invite.vue(展示邀请人/团队→加入→跳转我的团队)
- API_REFERENCE §4.60 + PROJECT-OVERVIEW 同步

规格:docs/superpowers/specs/2026-11-01-team-system-design.md §3.5/§4.2/§4.3(T38)
Sisyphus 1 неделя назад
Родитель
Сommit
5256f2f352

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

@@ -15043,6 +15043,10 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移426: 创建 attribution_reports 表失败(可能已存在): {}", e.getMessage());
         }
 
+        // 迁移427: supply_system_member 添加 invite_code 列(T38 团队邀请功能,2026-11-02)
+        // 每个成员一个个人专属邀请码;已生成复用,支持刷新重置旧码(旧码随之失效)
+        ensureColumn("supply_system_member", "invite_code",
+                "VARCHAR(32) DEFAULT NULL COMMENT '个人专属邀请码(团队邀请, T38)'");
 
     }
 

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

@@ -91,4 +91,36 @@ public class SupplyTeamController {
         Object relation = supplyCustomerRelationService.getRelationByFamily(systemId, familyId);
         return Result.success(relation);
     }
+
+    /**
+     * 生成/获取我的团队邀请码(幂等复用;refresh=true 重置旧码,T38 团队邀请)
+     */
+    @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"));
+        boolean refresh = Boolean.TRUE.equals(params.get("refresh"));
+        return supplySystemService.generateInviteCode(systemId, userId, refresh);
+    }
+
+    /**
+     * 凭邀请码查询邀请信息(受邀确认页展示:团队名/邀请人/成员数,不泄露隐私,T38)
+     */
+    @PostMapping("/invite/info")
+    public Result<Map<String, Object>> getInviteInfo(@RequestBody Map<String, Object> params) {
+        String inviteCode = (String) params.get("inviteCode");
+        return supplySystemService.getInviteInfo(inviteCode);
+    }
+
+    /**
+     * 凭邀请码加入团队(挂邀请人为上级;同一体系仅加入一次;inactive 可再申请,T38)
+     */
+    @PostMapping("/invite/join")
+    public Result<Map<String, Object>> joinByInviteCode(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        String inviteCode = (String) params.get("inviteCode");
+        return 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 邀请功能,2026-11-02) */
+    private String inviteCode;
 }

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

@@ -3,6 +3,8 @@ package com.etotem.cfc.service;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.SupplyHierarchy;
 import com.etotem.cfc.entity.SupplySystem;
 import com.etotem.cfc.entity.SupplySystemMember;
 import com.etotem.cfc.entity.User;
@@ -14,7 +16,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 +31,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 +210,182 @@ public class SupplySystemService extends ServiceImpl<SupplySystemMapper, SupplyS
         member.setRole(role);
         return supplySystemMemberMapper.updateById(member) > 0;
     }
+
+    // ========== 团队邀请功能(T38,2026-11-02) ==========
+
+    /**
+     * 生成/获取我的邀请码(幂等:已生成复用,refresh=true 重置旧码使旧码失效)。
+     * 仅体系 admin/active 成员可生成;返回邀请码 + 团队名(供分享文案)。
+     */
+    @Transactional
+    public Result<Map<String, Object>> generateInviteCode(Long systemId, Long userId, boolean refresh) {
+        if (systemId == null) {
+            return Result.error("systemId不能为空");
+        }
+        if (userId == null) {
+            return Result.error("未登录");
+        }
+        if (!isMemberOrAdmin(systemId, userId)) {
+            return Result.error("您不是该团队成员,无法生成邀请码");
+        }
+        SupplySystem system = this.getById(systemId);
+        if (system == null) {
+            return Result.error("团队不存在");
+        }
+
+        SupplySystemMember member = supplySystemMemberMapper.selectOne(
+            new LambdaQueryWrapper<SupplySystemMember>()
+                .eq(SupplySystemMember::getSystemId, systemId)
+                .eq(SupplySystemMember::getUserId, userId));
+        if (member == null) {
+            return Result.error("您不是该团队成员,无法生成邀请码");
+        }
+
+        String code = member.getInviteCode();
+        if (refresh || code == null || code.trim().isEmpty()) {
+            code = generateUniqueInviteCode();
+            member.setInviteCode(code);
+            supplySystemMemberMapper.updateById(member);
+        }
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("systemId", systemId);
+        data.put("inviteCode", code);
+        data.put("teamName", system.getName());
+        return Result.success(data);
+    }
+
+    /**
+     * 凭邀请码查询邀请信息(确认页展示用):团队名、邀请人昵称/头像、成员数。
+     * 不泄露其他隐私(不返回邀请人手机号等)。
+     */
+    public Result<Map<String, Object>> getInviteInfo(String inviteCode) {
+        if (inviteCode == null || inviteCode.trim().isEmpty()) {
+            return Result.error("邀请码不能为空");
+        }
+        SupplySystemMember inviter = supplySystemMemberMapper.selectOne(
+            new LambdaQueryWrapper<SupplySystemMember>()
+                .eq(SupplySystemMember::getInviteCode, inviteCode.trim()));
+        if (inviter == null || !"active".equals(inviter.getStatus())) {
+            return Result.error("邀请码不存在或已失效");
+        }
+        return Result.success(buildInviteInfo(inviter));
+    }
+
+    /**
+     * 凭邀请码加入团队:校验码有效 + 用户未加入过该体系 →
+     * ① addMember(systemId,userId,role=member,status=active) ② addRelation(systemId, inviterId, userId)
+     * 挂 parentId=邀请人 / level=父level+1。被移除(inactive)后可再申请。
+     */
+    @Transactional
+    public Result<Map<String, Object>> joinByInviteCode(String inviteCode, Long userId) {
+        if (inviteCode == null || inviteCode.trim().isEmpty()) {
+            return Result.error("邀请码不能为空");
+        }
+        if (userId == null) {
+            return Result.error("未登录");
+        }
+        SupplySystemMember inviter = supplySystemMemberMapper.selectOne(
+            new LambdaQueryWrapper<SupplySystemMember>()
+                .eq(SupplySystemMember::getInviteCode, inviteCode.trim()));
+        if (inviter == null || !"active".equals(inviter.getStatus())) {
+            return Result.error("邀请码不存在或已失效");
+        }
+        Long systemId = inviter.getSystemId();
+
+        // 校验用户未加入过该体系(inactive 可再申请:复活成员记录)
+        SupplySystemMember existing = supplySystemMemberMapper.selectOne(
+            new LambdaQueryWrapper<SupplySystemMember>()
+                .eq(SupplySystemMember::getSystemId, systemId)
+                .eq(SupplySystemMember::getUserId, userId));
+        if (existing != null && "active".equals(existing.getStatus())) {
+            return Result.error("您已在该团队中,无需重复加入");
+        }
+        if (existing != null) {
+            existing.setStatus("active");
+            existing.setRole("member");
+            existing.setJoinedAt(new Date());
+            supplySystemMemberMapper.updateById(existing);
+        } else {
+            SupplySystemMember 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 旧关系复活
+        // 注:supply_hierarchy 唯一键为 (system_id, parent_id, child_id),历史数据可能同一 child 多行,
+        // 故用 getOne(wrapper, false) 取首行而不抛 TooManyResultsException
+        SupplyHierarchy existingRel = supplyHierarchyService.getOne(
+            new LambdaQueryWrapper<SupplyHierarchy>()
+                .eq(SupplyHierarchy::getSystemId, systemId)
+                .eq(SupplyHierarchy::getMemberId, userId), false);
+        if (existingRel != null) {
+            int level = 1;
+            SupplyHierarchy parentRel = supplyHierarchyService.getOne(
+                new LambdaQueryWrapper<SupplyHierarchy>()
+                    .eq(SupplyHierarchy::getSystemId, systemId)
+                    .eq(SupplyHierarchy::getMemberId, inviter.getUserId())
+                    .eq(SupplyHierarchy::getStatus, "active"), false);
+            if (parentRel != null) {
+                level = parentRel.getLevel() + 1;
+            }
+            existingRel.setParentId(inviter.getUserId());
+            existingRel.setLevel(level);
+            existingRel.setStatus("active");
+            existingRel.setUpdatedAt(new Date());
+            supplyHierarchyService.updateById(existingRel);
+        } else {
+            supplyHierarchyService.addRelation(systemId, inviter.getUserId(), userId);
+        }
+
+        return Result.success(buildInviteInfo(inviter));
+    }
+
+    /**
+     * 组装邀请信息(确认页/加入结果共用)
+     */
+    private Map<String, Object> buildInviteInfo(SupplySystemMember inviter) {
+        Map<String, Object> data = new HashMap<>();
+        data.put("systemId", inviter.getSystemId());
+        SupplySystem system = this.getById(inviter.getSystemId());
+        data.put("teamName", system != null ? system.getName() : null);
+
+        User inviterUser = userMapper.selectById(inviter.getUserId());
+        data.put("inviterUserId", inviter.getUserId());
+        data.put("inviterNickname", inviterUser != null ? inviterUser.getNickname() : null);
+        data.put("inviterAvatar", inviterUser != null ? inviterUser.getAvatar() : null);
+
+        Long memberCount = supplySystemMemberMapper.selectCount(
+            new LambdaQueryWrapper<SupplySystemMember>()
+                .eq(SupplySystemMember::getSystemId, inviter.getSystemId())
+                .eq(SupplySystemMember::getStatus, "active"));
+        data.put("memberCount", memberCount != null ? memberCount : 0L);
+        return data;
+    }
+
+    /**
+     * 生成全局唯一的 8 位邀请码(字符集去掉易混淆的 0/O/1/I)
+     */
+    private String generateUniqueInviteCode() {
+        String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+        Random random = new Random();
+        for (int attempt = 0; attempt < 20; attempt++) {
+            StringBuilder code = new StringBuilder();
+            for (int i = 0; i < 8; i++) {
+                code.append(chars.charAt(random.nextInt(chars.length())));
+            }
+            Long cnt = supplySystemMemberMapper.selectCount(
+                new LambdaQueryWrapper<SupplySystemMember>()
+                    .eq(SupplySystemMember::getInviteCode, code.toString()));
+            if (cnt == null || cnt == 0) {
+                return code.toString();
+            }
+        }
+        // 极端冲突兜底
+        return "INV" + System.currentTimeMillis();
+    }
 }

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

@@ -2818,6 +2818,7 @@ 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(32) DEFAULT NULL COMMENT '个人专属邀请码(团队邀请, T38)',
     UNIQUE KEY uk_system_user (system_id, user_id),
     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": "加入团队"
+          }
         }
       ]
     },

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

@@ -0,0 +1,354 @@
+<template>
+  <view class="page-team-invite">
+    <!-- 加载中 -->
+    <view class="status-box" v-if="status === 'loading'">
+      <text class="status-text">正在查询邀请信息...</text>
+    </view>
+
+    <!-- 查询失败 / 邀请码无效 -->
+    <view class="status-box" v-else-if="status === 'invalid'">
+      <text class="status-icon">😔</text>
+      <text class="status-title">{{ errorMsg || '邀请码无效' }}</text>
+      <view class="code-input-row">
+        <input class="code-input" v-model="inviteCode" placeholder="请输入邀请码" placeholder-class="code-input-ph" />
+        <button class="btn-query" @click="loadInviteInfo">查询</button>
+      </view>
+    </view>
+
+    <!-- 邀请信息确认 -->
+    <block v-else-if="status === 'confirm'">
+      <view class="card">
+        <text class="card-title">邀请你加入团队</text>
+        <view class="inviter-row">
+          <image v-if="inviteInfo.inviterAvatar" class="inviter-avatar" :src="inviteInfo.inviterAvatar" mode="aspectFill"></image>
+          <view v-else class="inviter-avatar inviter-avatar-placeholder">👤</view>
+          <view class="inviter-info">
+            <text class="inviter-name">{{ inviteInfo.inviterNickname || '团队成员' }}</text>
+            <text class="inviter-label">邀请人</text>
+          </view>
+        </view>
+        <view class="info-list">
+          <view class="info-row">
+            <text class="info-label">团队名称</text>
+            <text class="info-value">{{ inviteInfo.teamName || '未命名团队' }}</text>
+          </view>
+          <view class="info-row">
+            <text class="info-label">团队人数</text>
+            <text class="info-value">{{ inviteInfo.memberCount || 0 }} 人</text>
+          </view>
+        </view>
+        <text class="join-tip">加入后你将成为团队成员,并自动成为「{{ inviteInfo.inviterNickname || '邀请人' }}」的下级</text>
+        <button class="btn-join" :loading="joining" @click="joinTeam">加入团队</button>
+        <text class="btn-cancel" @click="goBack">暂不加入</text>
+      </view>
+    </block>
+
+    <!-- 加入成功 -->
+    <view class="card" v-else-if="status === 'success'">
+      <text class="success-icon">🎉</text>
+      <text class="success-title">加入成功</text>
+      <text class="success-desc">你已加入「{{ inviteInfo.teamName || '团队' }}」,成为团队成员</text>
+      <button class="btn-join" @click="goToMyTeam">进入我的团队</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getVendorInviteInfo, joinVendorTeamByInvite } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      inviteCode: '',        // 邀请码(链接带入或手动输入)
+      inviteInfo: {},        // 邀请信息 { systemId, teamName, inviterNickname, inviterAvatar, memberCount }
+      status: 'loading',     // loading | invalid | confirm | success
+      errorMsg: '',
+      joining: false
+    }
+  },
+
+  onLoad(options) {
+    if (options && options.inviteCode) {
+      this.inviteCode = decodeURIComponent(options.inviteCode)
+    }
+    if (this.inviteCode) {
+      this.loadInviteInfo()
+    } else {
+      this.status = 'invalid'
+      this.errorMsg = '请输入邀请码'
+    }
+  },
+
+  methods: {
+    // 查询邀请信息(确认页)
+    async loadInviteInfo() {
+      if (!this.inviteCode) {
+        this.status = 'invalid'
+        this.errorMsg = '请输入邀请码'
+        return
+      }
+      this.status = 'loading'
+      try {
+        const res = await getVendorInviteInfo(this.inviteCode)
+        if (res && res.code === 200 && res.data) {
+          this.inviteInfo = res.data
+          this.status = 'confirm'
+        } else {
+          this.status = 'invalid'
+          this.errorMsg = (res && res.message) || '邀请码不存在或已失效'
+        }
+      } catch (e) {
+        console.error('查询邀请信息失败:', e)
+        this.status = 'invalid'
+        this.errorMsg = '查询失败,请稍后重试'
+      }
+    },
+
+    // 凭邀请码加入团队
+    async joinTeam() {
+      if (this.joining) return
+      this.joining = true
+      try {
+        const res = await joinVendorTeamByInvite(this.inviteCode)
+        if (res && res.code === 200) {
+          if (res.data) {
+            this.inviteInfo = res.data
+          }
+          this.status = 'success'
+        } else {
+          uni.showToast({
+            title: (res && res.message) || '加入失败',
+            icon: 'none'
+          })
+        }
+      } catch (e) {
+        console.error('加入团队失败:', e)
+        uni.showToast({ title: '加入失败,请稍后重试', icon: 'none' })
+      } finally {
+        this.joining = false
+      }
+    },
+
+    // 暂不加入:返回上一页
+    goBack() {
+      uni.navigateBack({
+        fail: () => {
+          uni.switchTab({ path: '/pages/index/index' })
+        }
+      })
+    },
+
+    // 加入成功后进入「我的团队」
+    goToMyTeam() {
+      uni.redirectTo({
+        url: '/pages/vendor/team-system/team-system',
+        fail: () => {
+          uni.switchTab({ path: '/pages/index/index' })
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss">
+.page-team-invite {
+  background-color: $uni-primary-pale;
+  min-height: 100vh;
+  padding: $uni-space-page;
+  box-sizing: border-box;
+}
+
+/* 状态区(加载中 / 邀请码无效) */
+.status-box {
+  margin-top: 120rpx;
+  text-align: center;
+}
+
+.status-icon {
+  font-size: calc(96rpx * var(--font-scale, 1));
+  display: block;
+  margin-bottom: 24rpx;
+}
+
+.status-title {
+  display: block;
+  font-size: calc(30rpx * var(--font-scale, 1));
+  color: $facade-text-body;
+  margin-bottom: 32rpx;
+}
+
+.status-text {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+}
+
+.code-input-row {
+  display: flex;
+  align-items: center;
+}
+
+.code-input {
+  flex: 1;
+  height: 80rpx;
+  padding: 0 24rpx;
+  background-color: $app-surface;
+  border-radius: 20rpx;
+  font-size: calc(28rpx * var(--font-scale, 1));
+  color: $facade-text-strong;
+  box-sizing: border-box;
+}
+
+.code-input-ph {
+  color: $app-text-subtle;
+  font-size: calc(26rpx * var(--font-scale, 1));
+}
+
+.btn-query {
+  width: 160rpx;
+  height: 80rpx;
+  line-height: 80rpx;
+  margin-left: 20rpx;
+  padding: 0;
+  border-radius: 20rpx;
+  background-color: $brand-primary;
+  color: $app-surface;
+  font-size: calc(28rpx * var(--font-scale, 1));
+  border: none;
+}
+
+/* 邀请信息卡片 */
+.card {
+  margin-top: 60rpx;
+  padding: 40rpx 32rpx;
+  background-color: $app-surface;
+  border-radius: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
+  text-align: center;
+}
+
+.card-title {
+  display: block;
+  font-size: calc(34rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $facade-text-strong;
+  margin-bottom: 32rpx;
+}
+
+/* 邀请人 */
+.inviter-row {
+  display: flex;
+  align-items: center;
+  padding: 24rpx;
+  background-color: $uni-primary-pale;
+  border-radius: 16rpx;
+  margin-bottom: 32rpx;
+}
+
+.inviter-avatar {
+  width: 88rpx;
+  height: 88rpx;
+  border-radius: 50%;
+  margin-right: 20rpx;
+}
+
+.inviter-avatar-placeholder {
+  background-color: $facade-fill-blue-ghost;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: calc(40rpx * var(--font-scale, 1));
+}
+
+.inviter-info {
+  flex: 1;
+  text-align: left;
+}
+
+.inviter-name {
+  display: block;
+  font-size: calc(30rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $facade-text-strong;
+}
+
+.inviter-label {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+}
+
+/* 信息列表 */
+.info-list {
+  padding: 8rpx 0;
+}
+
+.info-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 20rpx 0;
+  border-bottom: 1rpx solid $brand-primary-light-2;
+}
+
+.info-label {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+}
+
+.info-value {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-strong;
+  max-width: 380rpx;
+  text-align: right;
+}
+
+.join-tip {
+  display: block;
+  margin: 28rpx 0 8rpx;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+  line-height: 1.6;
+}
+
+/* 按钮 */
+.btn-join {
+  margin-top: 32rpx;
+  height: 88rpx;
+  line-height: 88rpx;
+  border-radius: 24rpx;
+  background-color: $brand-primary;
+  color: $app-surface;
+  font-size: calc(30rpx * var(--font-scale, 1));
+  font-weight: bold;
+  border: none;
+}
+
+.btn-cancel {
+  display: block;
+  margin-top: 24rpx;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+}
+
+/* 加入成功 */
+.success-icon {
+  font-size: calc(96rpx * var(--font-scale, 1));
+  display: block;
+  margin-bottom: 24rpx;
+}
+
+.success-title {
+  display: block;
+  font-size: calc(36rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $facade-text-strong;
+  margin-bottom: 16rpx;
+}
+
+.success-desc {
+  display: block;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-body;
+  line-height: 1.6;
+}
+</style>

+ 205 - 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="openInviteModal">邀请成员</text>
+          <text class="section-action section-action-gap" @click="goToMembers">查看全部</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,32 @@
       <button class="btn-manage" @click="goToManage">团队管理</button>
       <button class="btn-customers" @click="goToCustomers">客户列表</button>
     </view>
+
+    <!-- 邀请成员弹窗(T38:个人专属邀请码 + 复制 + 刷新重置) -->
+    <view v-if="showInviteModal" class="modal-mask" @click="closeInviteModal">
+      <view class="modal" @click.stop>
+        <view class="modal-header">
+          <text class="modal-title">邀请成员</text>
+          <button class="btn-close" @click="closeInviteModal">×</button>
+        </view>
+        <view class="invite-body">
+          <text class="invite-desc">将邀请码分享给朋友,对方凭码加入「{{ inviteTeamName || '我的团队' }}」</text>
+          <view class="invite-code-box">
+            <text class="invite-code-text">{{ inviteCode || '---- ----' }}</text>
+          </view>
+          <text class="invite-tip">邀请码永久有效;被邀请人加入后自动成为你的下级</text>
+          <view class="invite-btns">
+            <button class="btn-invite btn-copy" :loading="inviteLoading" @click="copyInviteCode">复制邀请码</button>
+            <button class="btn-invite btn-refresh" :loading="inviteLoading" @click="refreshInviteCode">刷新重置</button>
+          </view>
+        </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 +187,12 @@ export default {
       },
       teamMembers: [],
       teamCustomers: [],
-      customerTotal: 0   // 「客户归属」真实总数(后端 total,避免分页截断)
+      customerTotal: 0,   // 「客户归属」真实总数(后端 total,避免分页截断)
+      // 邀请成员弹窗(T38)
+      showInviteModal: false,
+      inviteCode: '',
+      inviteTeamName: '',
+      inviteLoading: false
     }
   },
 
@@ -364,6 +393,63 @@ export default {
       uni.navigateTo({
         url: url
       })
+    },
+
+    // ===== 邀请成员(T38,2026-11-02) =====
+
+    // 打开邀请弹窗:拉取/生成我的邀请码(幂等)
+    openInviteModal() {
+      if (!this.currentSystemId) {
+        uni.showToast({ title: '请先选择团队', icon: 'none' })
+        return
+      }
+      this.showInviteModal = true
+      this.inviteCode = ''
+      this.inviteTeamName = (this.systemInfo && this.systemInfo.name) || ''
+      this.loadInviteCode(false)
+    },
+
+    // 关闭邀请弹窗
+    closeInviteModal() {
+      this.showInviteModal = false
+    },
+
+    // 加载邀请码(refresh=false 幂等复用;refresh=true 重置旧码)
+    async loadInviteCode(refresh) {
+      if (this.inviteLoading) return
+      this.inviteLoading = true
+      try {
+        const res = await getVendorInviteCode(this.currentSystemId, refresh)
+        if (res && res.code === 200) {
+          this.inviteCode = res.data.inviteCode || ''
+          this.inviteTeamName = res.data.teamName || this.inviteTeamName
+          if (refresh) {
+            uni.showToast({ title: '邀请码已刷新', icon: 'none' })
+          }
+        } else if (res && res.message) {
+          uni.showToast({ title: res.message, icon: 'none' })
+        }
+      } catch (e) {
+        console.error('加载邀请码失败:', e)
+      } finally {
+        this.inviteLoading = false
+      }
+    },
+
+    // 刷新重置邀请码(旧码随之失效)
+    refreshInviteCode() {
+      this.loadInviteCode(true)
+    },
+
+    // 复制邀请码(微信自带"内容已复制"提示,与站内既有 setClipboardData 用法一致)
+    copyInviteCode() {
+      if (!this.inviteCode) {
+        uni.showToast({ title: '邀请码未生成', icon: 'none' })
+        return
+      }
+      uni.setClipboardData({
+        data: this.inviteCode
+      })
     }
   }
 }
@@ -500,6 +586,122 @@ export default {
   color: $brand-primary;
 }
 
+.section-actions {
+  display: flex;
+  align-items: center;
+}
+
+.section-action-gap {
+  margin-left: 24rpx;
+}
+
+/* 邀请成员弹窗(T38) */
+.modal-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  z-index: 1000;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.modal {
+  width: 620rpx;
+  background-color: $app-surface;
+  border-radius: 24rpx;
+  overflow: hidden;
+}
+
+.modal-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 28rpx 32rpx;
+  border-bottom: 1rpx solid $brand-primary-light-2;
+}
+
+.modal-title {
+  font-size: calc(30rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $facade-text-strong;
+}
+
+.btn-close {
+  width: 56rpx;
+  height: 56rpx;
+  line-height: 52rpx;
+  padding: 0;
+  border-radius: 50%;
+  background-color: $uni-primary-pale;
+  color: $facade-text-body;
+  font-size: calc(32rpx * var(--font-scale, 1));
+  border: none;
+}
+
+.invite-body {
+  padding: 32rpx;
+}
+
+.invite-desc {
+  display: block;
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-body;
+  line-height: 1.6;
+}
+
+.invite-code-box {
+  margin: 28rpx 0 16rpx;
+  padding: 32rpx 16rpx;
+  background-color: $uni-primary-pale;
+  border-radius: 16rpx;
+  text-align: center;
+}
+
+.invite-code-text {
+  font-size: calc(44rpx * var(--font-scale, 1));
+  font-weight: bold;
+  letter-spacing: 8rpx;
+  color: $brand-primary;
+}
+
+.invite-tip {
+  display: block;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+  line-height: 1.6;
+}
+
+.invite-btns {
+  display: flex;
+  margin-top: 28rpx;
+}
+
+.btn-invite {
+  flex: 1;
+  height: 76rpx;
+  line-height: 76rpx;
+  border-radius: 20rpx;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  border: none;
+  margin: 0;
+}
+
+.btn-copy {
+  background-color: $brand-primary;
+  color: $app-surface;
+}
+
+.btn-refresh {
+  margin-left: 20rpx;
+  background-color: $app-surface;
+  color: $brand-primary;
+  border: 2rpx solid $brand-primary;
+}
+
 /* 层级关系 */
 .hierarchy-grid {
   display: flex;

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

@@ -3496,6 +3496,23 @@ export const getVendorTeamCustomers = (params) =>
 export const bindVendorCustomer = (params) =>
   request('/api/admin/supply-team/bind-customer', 'POST', params)
 
+// ===== 团队邀请功能(T38,2026-11-02):邀请码生成/查询/凭码加入 =====
+
+/** 生成/获取我的团队邀请码(幂等复用;refresh=true 重置旧码) */
+export const getVendorInviteCode = (systemId, refresh) => {
+  const data = { systemId }
+  if (refresh) data.refresh = true
+  return request('/api/admin/supply-team/invite-code', 'POST', data)
+}
+
+/** 凭邀请码查询邀请信息(受邀确认页展示:团队名/邀请人/成员数) */
+export const getVendorInviteInfo = (inviteCode) =>
+  request('/api/admin/supply-team/invite/info', 'POST', { inviteCode })
+
+/** 凭邀请码加入团队(挂邀请人为上级) */
+export const joinVendorTeamByInvite = (inviteCode) =>
+  request('/api/admin/supply-team/invite/join', 'POST', { inviteCode })
+
 // ===================== 跨小程序单点登录(SSO:cfc ↔ train)=====================
 /** 签发跳转到 train 的一次性票据。返回 { ticket, target } */
 export const createSsoTicket = (target) =>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
docs/superpowers/PROJECT-OVERVIEW.md


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

@@ -2503,3 +2503,30 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
 - 读不到配置 → 回落 AVG(保证兼容)
 
 *文档更新:2026-10-31(家庭数据汇总方式配置(后端):family_metric_agg_config 表 + 配置 CRUD 4 接口 + 珍珠图/章鱼图家庭汇集 2 接口 + 五维聚合读配置)*
+
+---
+
+## 4.60 供应团队邀请功能(`SupplyTeamController`,前缀 `/api/admin/supply-team`)—— T38
+
+> **T38 规格 §3.5(2026-11-02 廖总需求)**:在「我的团队」加邀请功能,邀请其他用户加入团队。
+> 供应商体系成员本为管理员 `member/add` 直接添加,现补齐邀请缺口:每个成员生成个人专属邀请码,他人凭码加入后自动成为其下级(`supply_hierarchy.parentId=邀请人`),形成「每个成员都有自己的团队」(§0.5 结算口径)。
+> 与 `DistributionService.generateInviteCode/registerByInviteCode`(虚拟体系)是两套独立邀请模型,**仅复用码生成/凭码注册手法**,互不影响。
+> 结算不直接挂钩邀请(平台只向唯一负责人结算,成员间分配平台不负责)。
+
+**数据变更**:`supply_system_member` 扩列 `invite_code VARCHAR(32)`(迁移427,幂等 `ensureColumn` + 同步 `schema.sql`)。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/admin/supply-team/invite-code` | 生成/获取我的团队邀请码(幂等:已生成复用;body `{ systemId, refresh? }`,`refresh=true` 重置旧码使旧码失效;需体系 admin/active 成员)。返回 `{ systemId, inviteCode, teamName }` |
+| `POST /api/admin/supply-team/invite/info` | 凭邀请码查询邀请信息(受邀确认页展示,不泄露隐私)。body `{ inviteCode }` → `{ systemId, teamName, inviterUserId, inviterNickname, inviterAvatar, memberCount }` |
+| `POST /api/admin/supply-team/invite/join` | 凭邀请码加入团队。body `{ inviteCode }`(登录用户)→ 校验码有效 + 未加入过该体系 → `addMember(systemId,userId,role=member,status=active)` + `addRelation(systemId, inviterId, userId)` 挂 `parentId=邀请人 / level=父level+1`。inactive(被移除)成员可再申请复活。返回同 `invite/info` |
+
+**边界**:
+- 同一 `(systemId, userId)` 仅加入一次(`addMember` 已有 existing 检查;inactive 复活而非重复插入);
+- role=member 不可自助变更(沿用 `member/change-role` 管理员权限);
+- 邀请码默认永久有效 + 唯一(8 位,字符集去易混淆 0/O/1/I),可后续加过期时间列;
+- 默认自助加入即生效(负责人可移除),不做负责人确认。
+
+**前端**:`cfc-frontend/pages/vendor/team-system/team-system.vue` 团队成员区「邀请成员」按钮 → 弹窗(邀请码 + 复制 + 刷新重置);新增受邀确认页 `pages/vendor/team-invite/team-invite.vue`(展示邀请人/团队 → 加入 → 跳转我的团队)。
+
+*文档更新:2026-11-02(T38 供应团队邀请功能:supply_system_member 扩 invite_code(迁移427)+ 邀请码生成/信息/凭码加入 3 接口 + 前端邀请入口与受邀确认页)*

Некоторые файлы не были показаны из-за большого количества измененных файлов