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

feat(distribution): virtual team management + unified invite system

- schema.sql: add distribution_systems + distribution_relations DDL
- DistributionService: add getUserTeams, getTeamTree, getUserPosition,
  getTeamMembers, updateMemberTeam methods
- AdminVirtualTeamController: 9 endpoints for virtual team CRUD + member mgmt
- cfc-web admin: VirtualTeamList.vue + VirtualTeamDetail.vue pages
- Router + sidebar: virtual team menu items under system config
Xiaogang Liao 4 месяцев назад
Родитель
Сommit
0f5a641f8e

+ 102 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminVirtualTeamController.java

@@ -0,0 +1,102 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.DistributionSystem;
+import com.etotem.cfc.service.DistributionService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+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 java.util.List;
+import java.util.Map;
+
+@Tag(name = "管理端-虚拟团队管理")
+@RestController
+@RequestMapping("/api/admin/virtual-team")
+public class AdminVirtualTeamController {
+
+    @Resource
+    private DistributionService distributionService;
+
+    @Operation(summary = "分销体系列表")
+    @PostMapping("/list")
+    public Result<List<DistributionSystem>> list() {
+        return distributionService.listSystems();
+    }
+
+    @Operation(summary = "创建分销体系")
+    @PostMapping("/create")
+    public Result<String> create(@RequestBody DistributionSystem system) {
+        return distributionService.createSystem(system);
+    }
+
+    @Operation(summary = "更新分销体系")
+    @PostMapping("/update")
+    public Result<String> update(@RequestBody DistributionSystem system) {
+        return distributionService.updateSystem(system);
+    }
+
+    @Operation(summary = "启用/禁用分销体系")
+    @PostMapping("/toggle")
+    public Result<String> toggle(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) return Result.error("id不能为空");
+        return distributionService.toggleSystem(id);
+    }
+
+    @Operation(summary = "团队成员列表(含用户信息)")
+    @PostMapping("/members")
+    public Result<List<Map<String, Object>>> members(@RequestBody Map<String, Object> params) {
+        Long systemId = params.get("systemId") != null ? Long.valueOf(params.get("systemId").toString()) : null;
+        if (systemId == null) return Result.error("systemId不能为空");
+        return distributionService.getTeamMembers(systemId);
+    }
+
+    @Operation(summary = "添加团队成员")
+    @PostMapping("/add-member")
+    public Result<Void> addMember(@RequestBody Map<String, Object> params) {
+        Long systemId = params.get("systemId") != null ? Long.valueOf(params.get("systemId").toString()) : null;
+        Long userId = params.get("userId") != null ? Long.valueOf(params.get("userId").toString()) : null;
+        Long parentId = params.get("parentId") != null ? Long.valueOf(params.get("parentId").toString()) : null;
+        if (systemId == null) return Result.error("systemId不能为空");
+        if (userId == null) return Result.error("userId不能为空");
+        return distributionService.addRelation(systemId, userId, parentId);
+    }
+
+    @Operation(summary = "移除团队成员")
+    @PostMapping("/remove-member")
+    public Result<String> removeMember(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) return Result.error("id不能为空");
+        return distributionService.removeRelation(id);
+    }
+
+    @Operation(summary = "调整成员位置/上级")
+    @PostMapping("/update-member")
+    public Result<String> updateMember(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        Long parentId = params.get("parentId") != null ? Long.valueOf(params.get("parentId").toString()) : null;
+        if (id == null) return Result.error("id不能为空");
+        return distributionService.updateMemberTeam(id, params.containsKey("parentId") ? parentId : null);
+    }
+
+    @Operation(summary = "用户所属团队列表")
+    @PostMapping("/user-teams")
+    public Result<List<DistributionSystem>> userTeams(@RequestBody Map<String, Object> params) {
+        Long userId = params.get("userId") != null ? Long.valueOf(params.get("userId").toString()) : null;
+        if (userId == null) return Result.error("userId不能为空");
+        return distributionService.getUserTeams(userId);
+    }
+
+    @Operation(summary = "团队树形结构")
+    @PostMapping("/tree")
+    public Result<List<Map<String, Object>>> tree(@RequestBody Map<String, Object> params) {
+        Long systemId = params.get("systemId") != null ? Long.valueOf(params.get("systemId").toString()) : null;
+        if (systemId == null) return Result.error("systemId不能为空");
+        return distributionService.getTeamTree(systemId);
+    }
+}

+ 153 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DistributionService.java

@@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.DistributionRelation;
 import com.etotem.cfc.entity.DistributionRelation;
 import com.etotem.cfc.entity.DistributionSystem;
 import com.etotem.cfc.entity.DistributionSystem;
+import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.DistributionRelationMapper;
 import com.etotem.cfc.mapper.DistributionRelationMapper;
 import com.etotem.cfc.mapper.DistributionSystemMapper;
 import com.etotem.cfc.mapper.DistributionSystemMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
@@ -20,6 +22,9 @@ public class DistributionService {
     @Resource
     @Resource
     private DistributionRelationMapper distributionRelationMapper;
     private DistributionRelationMapper distributionRelationMapper;
 
 
+    @Resource
+    private UserMapper userMapper;
+
     public Result<String> createSystem(DistributionSystem system) {
     public Result<String> createSystem(DistributionSystem system) {
         system.setEnabled(true);
         system.setEnabled(true);
         system.setCreatedAt(new Date());
         system.setCreatedAt(new Date());
@@ -172,4 +177,152 @@ public class DistributionService {
             - (chain.size() >= 2 ? level2Amount : 0));
             - (chain.size() >= 2 ? level2Amount : 0));
         return Result.success(result);
         return Result.success(result);
     }
     }
+
+    public Result<List<DistributionSystem>> getUserTeams(Long userId) {
+        List<DistributionRelation> relations = distributionRelationMapper.selectList(
+            new LambdaQueryWrapper<DistributionRelation>()
+                .eq(DistributionRelation::getUserId, userId)
+        );
+        if (relations.isEmpty()) return Result.success(new ArrayList<>());
+
+        List<Long> systemIds = new ArrayList<>();
+        for (DistributionRelation r : relations) {
+            systemIds.add(r.getSystemId());
+        }
+        List<DistributionSystem> systems = distributionSystemMapper.selectList(
+            new LambdaQueryWrapper<DistributionSystem>()
+                .in(DistributionSystem::getId, systemIds)
+                .orderByDesc(DistributionSystem::getCreatedAt)
+        );
+        return Result.success(systems);
+    }
+
+    public Result<List<Map<String, Object>>> getTeamTree(Long systemId) {
+        DistributionSystem system = distributionSystemMapper.selectById(systemId);
+        if (system == null) return Result.error("分销体系不存在");
+
+        List<DistributionRelation> allRelations = distributionRelationMapper.selectList(
+            new LambdaQueryWrapper<DistributionRelation>()
+                .eq(DistributionRelation::getSystemId, systemId)
+                .orderByAsc(DistributionRelation::getDepth)
+                .orderByDesc(DistributionRelation::getCreatedAt)
+        );
+
+        Map<Long, List<Map<String, Object>>> childrenMap = new LinkedHashMap<>();
+        List<Map<String, Object>> roots = new ArrayList<>();
+
+        for (DistributionRelation r : allRelations) {
+            Map<String, Object> node = new LinkedHashMap<>();
+            node.put("id", r.getId());
+            node.put("userId", r.getUserId());
+            node.put("parentId", r.getParentId());
+            node.put("depth", r.getDepth());
+            node.put("createdAt", r.getCreatedAt());
+            node.put("children", new ArrayList<Map<String, Object>>());
+
+            if (r.getParentId() == null) {
+                roots.add(node);
+            } else {
+                childrenMap.computeIfAbsent(r.getParentId(), k -> new ArrayList<>()).add(node);
+            }
+        }
+
+        for (Map<String, Object> node : roots) {
+            buildTree(node, childrenMap);
+        }
+
+        return Result.success(roots);
+    }
+
+    @SuppressWarnings("unchecked")
+    private void buildTree(Map<String, Object> node, Map<Long, List<Map<String, Object>>> childrenMap) {
+        Long userId = (Long) node.get("userId");
+        List<Map<String, Object>> children = childrenMap.get(userId);
+        if (children != null) {
+            List<Map<String, Object>> nodeChildren = (List<Map<String, Object>>) node.get("children");
+            nodeChildren.addAll(children);
+            for (Map<String, Object> child : children) {
+                buildTree(child, childrenMap);
+            }
+        }
+    }
+
+    public Result<Map<String, Object>> getUserPosition(Long systemId, Long userId) {
+        DistributionRelation relation = distributionRelationMapper.selectOne(
+            new LambdaQueryWrapper<DistributionRelation>()
+                .eq(DistributionRelation::getSystemId, systemId)
+                .eq(DistributionRelation::getUserId, userId)
+        );
+        if (relation == null) return Result.error("用户不在该分销体系中");
+
+        List<DistributionRelation> children = distributionRelationMapper.selectList(
+            new LambdaQueryWrapper<DistributionRelation>()
+                .eq(DistributionRelation::getSystemId, systemId)
+                .eq(DistributionRelation::getParentId, userId)
+        );
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("systemId", systemId);
+        result.put("userId", userId);
+        result.put("parentId", relation.getParentId());
+        result.put("depth", relation.getDepth());
+        result.put("childCount", children.size());
+        result.put("joinedAt", relation.getCreatedAt());
+        return Result.success(result);
+    }
+
+    public Result<List<Map<String, Object>>> getTeamMembers(Long systemId) {
+        DistributionSystem system = distributionSystemMapper.selectById(systemId);
+        if (system == null) return Result.error("分销体系不存在");
+
+        List<DistributionRelation> relations = distributionRelationMapper.selectList(
+            new LambdaQueryWrapper<DistributionRelation>()
+                .eq(DistributionRelation::getSystemId, systemId)
+                .orderByAsc(DistributionRelation::getDepth)
+                .orderByDesc(DistributionRelation::getCreatedAt)
+        );
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (DistributionRelation r : relations) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("id", r.getId());
+            item.put("systemId", r.getSystemId());
+            item.put("userId", r.getUserId());
+            item.put("parentId", r.getParentId());
+            item.put("depth", r.getDepth());
+            item.put("createdAt", r.getCreatedAt());
+
+            User user = userMapper.selectById(r.getUserId());
+            if (user != null) {
+                item.put("nickname", user.getNickname());
+                item.put("avatar", user.getAvatar());
+                item.put("phone", user.getPhone());
+            }
+            result.add(item);
+        }
+        return Result.success(result);
+    }
+
+    public Result<String> updateMemberTeam(Long id, Long parentId) {
+        DistributionRelation relation = distributionRelationMapper.selectById(id);
+        if (relation == null) return Result.error("关系不存在");
+
+        if (parentId == null) {
+            relation.setParentId(null);
+            relation.setDepth(0);
+        } else {
+            DistributionRelation parent = distributionRelationMapper.selectOne(
+                new LambdaQueryWrapper<DistributionRelation>()
+                    .eq(DistributionRelation::getSystemId, relation.getSystemId())
+                    .eq(DistributionRelation::getUserId, parentId)
+            );
+            if (parent == null) return Result.error("上级节点不存在");
+            if (parentId.equals(relation.getUserId())) return Result.error("不能将自己设为上级");
+            relation.setParentId(parentId);
+            relation.setDepth(parent.getDepth() + 1);
+        }
+
+        distributionRelationMapper.updateById(relation);
+        return Result.success("更新成功");
+    }
 }
 }

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

@@ -1742,6 +1742,33 @@ CREATE TABLE IF NOT EXISTS referral_leaderboard (
     INDEX idx_week_rank (week_start, referral_count DESC)
     INDEX idx_week_rank (week_start, referral_count DESC)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推广排行榜周记录';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推广排行榜周记录';
 
 
+-- =============================================
+-- 虚拟服务商团队(分销体系)
+-- =============================================
+
+CREATE TABLE IF NOT EXISTS `distribution_systems` (
+    `id` BIGINT AUTO_INCREMENT PRIMARY KEY,
+    `name` VARCHAR(100) NOT NULL COMMENT '团队名称',
+    `description` VARCHAR(500) COMMENT '团队描述',
+    `owner_id` BIGINT COMMENT '创建人(服务商用户ID)',
+    `profit_rate` INT DEFAULT 0 COMMENT '利润分成比例(bps, 如2000=20%)',
+    `enabled` TINYINT(1) DEFAULT 1 COMMENT '是否启用',
+    `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+    `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='虚拟服务商团队(分销体系)';
+
+CREATE TABLE IF NOT EXISTS `distribution_relations` (
+    `id` BIGINT AUTO_INCREMENT PRIMARY KEY,
+    `system_id` BIGINT NOT NULL COMMENT '所属团队ID',
+    `user_id` BIGINT NOT NULL COMMENT '服务商用户ID',
+    `parent_id` BIGINT COMMENT '上级服务商用户ID',
+    `depth` INT DEFAULT 0 COMMENT '层级深度(0=root)',
+    `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_system (system_id),
+    INDEX idx_user (user_id),
+    INDEX idx_parent (parent_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队-服务商关系(同一人在不同团队可处于不同位置)';
+
 -- =============================================
 -- =============================================
 -- 成长任务种子数据
 -- 成长任务种子数据
 -- =============================================
 -- =============================================

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

@@ -669,3 +669,85 @@ export function deleteRelationshipType(id) {
     data: { id }
     data: { id }
   })
   })
 }
 }
+
+// ========== 虚拟服务商团队管理 API ==========
+
+export function listVirtualTeams(params) {
+  return request({
+    url: '/api/admin/virtual-team/list',
+    method: 'post',
+    data: params
+  })
+}
+
+export function createVirtualTeam(data) {
+  return request({
+    url: '/api/admin/virtual-team/create',
+    method: 'post',
+    data
+  })
+}
+
+export function updateVirtualTeam(data) {
+  return request({
+    url: '/api/admin/virtual-team/update',
+    method: 'post',
+    data
+  })
+}
+
+export function toggleVirtualTeam(id) {
+  return request({
+    url: '/api/admin/virtual-team/toggle',
+    method: 'post',
+    data: { id }
+  })
+}
+
+export function getVirtualTeamMembers(systemId) {
+  return request({
+    url: '/api/admin/virtual-team/members',
+    method: 'post',
+    data: { systemId }
+  })
+}
+
+export function addVirtualTeamMember(data) {
+  return request({
+    url: '/api/admin/virtual-team/add-member',
+    method: 'post',
+    data
+  })
+}
+
+export function removeVirtualTeamMember(id) {
+  return request({
+    url: '/api/admin/virtual-team/remove-member',
+    method: 'post',
+    data: { id }
+  })
+}
+
+export function updateVirtualTeamMember(data) {
+  return request({
+    url: '/api/admin/virtual-team/update-member',
+    method: 'post',
+    data
+  })
+}
+
+export function getVirtualTeamTree(systemId) {
+  return request({
+    url: '/api/admin/virtual-team/tree',
+    method: 'post',
+    data: { systemId }
+  })
+}
+
+export function searchUsers(params) {
+  return request({
+    url: '/api/admin/user/search',
+    method: 'post',
+    data: params
+  })
+}

+ 14 - 0
cfc-web/src/router/index.js

@@ -425,6 +425,20 @@ const routes = [
         name: 'HealthDataSource',
         name: 'HealthDataSource',
         component: () => import('@/views/admin/data-source-config'),
         component: () => import('@/views/admin/data-source-config'),
         meta: { title: '健康数据源配置', perm: 'system:config' }
         meta: { title: '健康数据源配置', perm: 'system:config' }
+      },
+      // ========== 虚拟服务商团队管理 ==========
+      {
+        path: 'virtual-teams',
+        name: 'VirtualTeamList',
+        component: () => import('@/views/admin/VirtualTeamList'),
+        meta: { title: '虚拟团队管理', perm: 'system:config' }
+      },
+      {
+        path: 'virtual-team-detail/:systemId',
+        name: 'VirtualTeamDetail',
+        component: () => import('@/views/admin/VirtualTeamDetail'),
+        meta: { title: '团队成员管理', perm: 'system:config' },
+        props: true
       }
       }
     ]
     ]
   }
   }

+ 1 - 0
cfc-web/src/views/Layout.vue

@@ -241,6 +241,7 @@ export default {
             { path: '/zodiac-configs', label: '星座配置', icon: 'el-icon-s-management', perm: 'system:config' },
             { path: '/zodiac-configs', label: '星座配置', icon: 'el-icon-s-management', perm: 'system:config' },
             { path: '/bazi-configs', label: '八字配置', icon: 'el-icon-s-management', perm: 'system:config' },
             { path: '/bazi-configs', label: '八字配置', icon: 'el-icon-s-management', perm: 'system:config' },
             { path: '/blood-type-configs', label: '血型配置', icon: 'el-icon-s-management', perm: 'system:config' },
             { path: '/blood-type-configs', label: '血型配置', icon: 'el-icon-s-management', perm: 'system:config' },
+            { path: '/virtual-teams', label: '虚拟团队', icon: 'el-icon-s-custom', perm: 'system:config' },
           ]},
           ]},
       ],
       ],
       profileDialogVisible: false,
       profileDialogVisible: false,

+ 278 - 0
cfc-web/src/views/admin/VirtualTeamDetail.vue

@@ -0,0 +1,278 @@
+<template>
+  <div class="virtual-team-detail">
+    <el-card>
+      <div slot="header">
+        <el-button size="small" icon="el-icon-back" @click="goBack">返回</el-button>
+        <span style="margin-left: 12px">团队成员管理 - {{ teamName }}</span>
+        <el-button style="float: right" type="primary" size="small" @click="showAddDialog">+ 添加成员</el-button>
+      </div>
+
+      <div class="team-info" v-loading="infoLoading">
+        <el-descriptions :column="3" border size="small">
+          <el-descriptions-item label="团队名称">{{ teamInfo.name || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="描述">{{ teamInfo.description || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="利润率">{{ teamInfo.profitRate != null ? teamInfo.profitRate + '%' : '-' }}</el-descriptions-item>
+        </el-descriptions>
+      </div>
+    </el-card>
+
+    <el-card style="margin-top: 16px">
+      <div slot="header">
+        <span>成员列表</span>
+      </div>
+      <el-table :data="members" stripe v-loading="membersLoading">
+        <el-table-column prop="id" label="关系ID" width="80"></el-table-column>
+        <el-table-column prop="userId" label="用户ID" width="100"></el-table-column>
+        <el-table-column label="用户昵称" width="140">
+          <template slot-scope="scope">
+            <div style="display: flex; align-items: center; gap: 6px">
+              <el-avatar v-if="scope.row.userAvatar" :size="24" :src="scope.row.userAvatar"></el-avatar>
+              <span>{{ scope.row.userNickname || '-' }}</span>
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column prop="depth" label="层级" width="80"></el-table-column>
+        <el-table-column label="上级" width="140">
+          <template slot-scope="scope">
+            {{ getParentName(scope.row) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="200">
+          <template slot-scope="scope">
+            <el-button size="mini" @click="showReassignDialog(scope.row)">变更上级</el-button>
+            <el-button size="mini" type="danger" @click="handleRemove(scope.row)">移除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-dialog :visible.sync="addDialogVisible" title="添加成员" width="500px" :close-on-click-modal="false">
+      <el-form :model="addForm" label-width="80px">
+        <el-form-item label="用户">
+          <el-select
+            v-model="addForm.userId"
+            filterable
+            remote
+            reserve-keyword
+            placeholder="搜索用户(姓名/手机号)"
+            :remote-method="searchUsers"
+            :loading="userSearchLoading"
+            style="width: 100%"
+          >
+            <el-option
+              v-for="u in userSearchResults"
+              :key="u.id"
+              :label="(u.nickname || u.username || '') + ' (ID:' + u.id + ')'"
+              :value="u.id"
+            ></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="上级">
+          <el-select v-model="addForm.parentId" placeholder="无上级(根节点)" clearable style="width: 100%">
+            <el-option
+              v-for="m in members"
+              :key="m.id"
+              :label="(m.userNickname || 'ID:' + m.userId) + ' (层级:' + m.depth + ')'"
+              :value="m.id"
+            ></el-option>
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="addDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="addSubmitting" @click="handleAddMember">添加</el-button>
+      </div>
+    </el-dialog>
+
+    <el-dialog :visible.sync="reassignDialogVisible" title="变更上级" width="500px" :close-on-click-modal="false">
+      <el-form :model="reassignForm" label-width="80px">
+        <el-form-item label="成员">
+          <span>{{ reassignForm.memberName }}</span>
+        </el-form-item>
+        <el-form-item label="新上级">
+          <el-select v-model="reassignForm.parentId" placeholder="无上级(根节点)" clearable style="width: 100%">
+            <el-option
+              v-for="m in reassignCandidates"
+              :key="m.id"
+              :label="(m.userNickname || 'ID:' + m.userId) + ' (层级:' + m.depth + ')'"
+              :value="m.id"
+            ></el-option>
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="reassignDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="reassignSubmitting" @click="handleReassign">确认</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  listVirtualTeams,
+  getVirtualTeamMembers,
+  addVirtualTeamMember,
+  removeVirtualTeamMember,
+  updateVirtualTeamMember,
+  searchUsers
+} from '@/api/admin'
+
+export default {
+  name: 'VirtualTeamDetail',
+  props: {
+    systemId: { type: [String, Number], required: true }
+  },
+  data() {
+    return {
+      teamInfo: {},
+      infoLoading: false,
+      members: [],
+      membersLoading: false,
+      addDialogVisible: false,
+      addSubmitting: false,
+      addForm: { userId: null, parentId: null },
+      userSearchLoading: false,
+      userSearchResults: [],
+      reassignDialogVisible: false,
+      reassignSubmitting: false,
+      reassignForm: { id: null, parentId: null, memberName: '' }
+    }
+  },
+  computed: {
+    teamName() {
+      return this.teamInfo.name || '加载中...'
+    },
+    reassignCandidates() {
+      var self = this
+      return this.members.filter(function(m) {
+        return m.id !== self.reassignForm.id
+      })
+    }
+  },
+  created() {
+    this.loadTeamInfo()
+    this.loadMembers()
+  },
+  methods: {
+    goBack() {
+      this.$router.push('/virtual-teams')
+    },
+    getParentName(row) {
+      if (!row.parentId) return '无(根节点)'
+      var parent = this.members.find(function(m) { return m.id === row.parentId })
+      if (parent) return parent.userNickname || ('ID:' + parent.userId)
+      return 'ID:' + row.parentId
+    },
+    async loadTeamInfo() {
+      this.infoLoading = true
+      try {
+        var res = await listVirtualTeams({})
+        var list = res.data || []
+        var id = parseInt(this.systemId)
+        this.teamInfo = list.find(function(t) { return t.id === id }) || {}
+      } catch (e) {
+        console.error(e)
+      } finally {
+        this.infoLoading = false
+      }
+    },
+    async loadMembers() {
+      this.membersLoading = true
+      try {
+        var res = await getVirtualTeamMembers(this.systemId)
+        this.members = res.data || []
+      } catch (e) {
+        console.error(e)
+      } finally {
+        this.membersLoading = false
+      }
+    },
+    showAddDialog() {
+      this.addForm = { userId: null, parentId: null }
+      this.userSearchResults = []
+      this.addDialogVisible = true
+    },
+    async searchUsers(query) {
+      if (!query || query.length < 1) {
+        this.userSearchResults = []
+        return
+      }
+      this.userSearchLoading = true
+      try {
+        var res = await searchUsers({ keyword: query })
+        this.userSearchResults = res.data || []
+      } catch (e) {
+        this.userSearchResults = []
+      } finally {
+        this.userSearchLoading = false
+      }
+    },
+    async handleAddMember() {
+      if (!this.addForm.userId) {
+        this.$message.warning('请选择用户')
+        return
+      }
+      this.addSubmitting = true
+      try {
+        await addVirtualTeamMember({
+          systemId: this.systemId,
+          userId: this.addForm.userId,
+          parentId: this.addForm.parentId
+        })
+        this.$message.success('添加成功')
+        this.addDialogVisible = false
+        this.loadMembers()
+      } catch (e) {
+        this.$message.error('添加失败')
+      } finally {
+        this.addSubmitting = false
+      }
+    },
+    async handleRemove(row) {
+      var hasChildren = this.members.some(function(m) { return m.parentId === row.id })
+      if (hasChildren) {
+        this.$message.warning('该成员有下级成员,请先变更下级的上级再移除')
+        return
+      }
+      try {
+        await this.$confirm('确定移除成员"' + (row.userNickname || row.userId) + '"吗?', '提示', { type: 'warning' })
+        await removeVirtualTeamMember(row.id)
+        this.$message.success('移除成功')
+        this.loadMembers()
+      } catch (e) {
+        if (e !== 'cancel') this.$message.error('移除失败')
+      }
+    },
+    showReassignDialog(row) {
+      this.reassignForm = {
+        id: row.id,
+        parentId: row.parentId,
+        memberName: row.userNickname || ('ID:' + row.userId)
+      }
+      this.reassignDialogVisible = true
+    },
+    async handleReassign() {
+      this.reassignSubmitting = true
+      try {
+        await updateVirtualTeamMember({
+          id: this.reassignForm.id,
+          parentId: this.reassignForm.parentId
+        })
+        this.$message.success('上级变更成功')
+        this.reassignDialogVisible = false
+        this.loadMembers()
+      } catch (e) {
+        this.$message.error('变更失败')
+      } finally {
+        this.reassignSubmitting = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.virtual-team-detail { padding: 20px; }
+.team-info { margin-bottom: 0; }
+</style>

+ 209 - 0
cfc-web/src/views/admin/VirtualTeamList.vue

@@ -0,0 +1,209 @@
+<template>
+  <div class="virtual-team-list">
+    <el-card>
+      <div slot="header">
+        <span>虚拟服务商团队</span>
+        <el-button style="float: right" type="primary" size="small" @click="handleCreate">+ 创建团队</el-button>
+      </div>
+
+      <div class="toolbar">
+        <el-input
+          v-model="searchKeyword"
+          placeholder="搜索团队名称"
+          prefix-icon="el-icon-search"
+          size="small"
+          style="width: 240px"
+          clearable
+          @clear="loadData"
+          @keyup.enter.native="loadData"
+        ></el-input>
+        <el-button size="small" type="primary" @click="loadData">查询</el-button>
+      </div>
+
+      <el-table :data="pagedData" stripe v-loading="loading">
+        <el-table-column prop="id" label="ID" width="80"></el-table-column>
+        <el-table-column prop="name" label="团队名称" width="160"></el-table-column>
+        <el-table-column prop="description" label="描述" show-overflow-tooltip></el-table-column>
+        <el-table-column prop="ownerId" label="负责人ID" width="100"></el-table-column>
+        <el-table-column prop="profitRate" label="利润率" width="100">
+          <template slot-scope="scope">
+            {{ scope.row.profitRate != null ? (scope.row.profitRate + '%') : '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="enabled" label="状态" width="80">
+          <template slot-scope="scope">
+            <el-tag :type="scope.row.enabled ? 'success' : 'info'" size="mini">
+              {{ scope.row.enabled ? '启用' : '禁用' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createdAt" label="创建时间" width="170">
+          <template slot-scope="scope">
+            {{ formatDate(scope.row.createdAt) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="220">
+          <template slot-scope="scope">
+            <el-button size="mini" @click="handleEdit(scope.row)">编辑</el-button>
+            <el-button
+              size="mini"
+              :type="scope.row.enabled ? 'warning' : 'success'"
+              @click="handleToggle(scope.row)"
+            >{{ scope.row.enabled ? '禁用' : '启用' }}</el-button>
+            <el-button size="mini" type="primary" @click="viewMembers(scope.row)">成员</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination
+        v-if="filteredData.length > pageSize"
+        style="margin-top: 16px; text-align: right"
+        background
+        layout="prev, pager, next, total"
+        :total="filteredData.length"
+        :page-size="pageSize"
+        :current-page.sync="currentPage"
+      ></el-pagination>
+    </el-card>
+
+    <el-dialog :visible.sync="dialogVisible" :title="isEdit ? '编辑团队' : '创建团队'" width="500px" :close-on-click-modal="false">
+      <el-form :model="form" :rules="formRules" ref="teamForm" label-width="80px">
+        <el-form-item label="团队名称" prop="name">
+          <el-input v-model="form.name" placeholder="请输入团队名称"></el-input>
+        </el-form-item>
+        <el-form-item label="描述" prop="description">
+          <el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入团队描述"></el-input>
+        </el-form-item>
+        <el-form-item label="利润率" prop="profitRate">
+          <el-input-number v-model="form.profitRate" :min="0" :max="100" :precision="1" :step="0.5" style="width: 200px"></el-input-number>
+          <span style="margin-left: 8px; color: #909399">%</span>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  listVirtualTeams,
+  createVirtualTeam,
+  updateVirtualTeam,
+  toggleVirtualTeam
+} from '@/api/admin'
+
+export default {
+  name: 'VirtualTeamList',
+  data() {
+    return {
+      teams: [],
+      loading: false,
+      submitting: false,
+      searchKeyword: '',
+      currentPage: 1,
+      pageSize: 15,
+      dialogVisible: false,
+      isEdit: false,
+      form: this.getEmptyForm(),
+      formRules: {
+        name: [{ required: true, message: '请输入团队名称', trigger: 'blur' }]
+      }
+    }
+  },
+  computed: {
+    filteredData() {
+      if (!this.searchKeyword) return this.teams
+      var kw = this.searchKeyword.toLowerCase()
+      return this.teams.filter(function(t) {
+        return t.name && t.name.toLowerCase().indexOf(kw) !== -1
+      })
+    },
+    pagedData() {
+      var start = (this.currentPage - 1) * this.pageSize
+      return this.filteredData.slice(start, start + this.pageSize)
+    }
+  },
+  created() {
+    this.loadData()
+  },
+  methods: {
+    getEmptyForm() {
+      return { id: null, name: '', description: '', profitRate: 0 }
+    },
+    formatDate(val) {
+      if (!val) return '-'
+      return val.replace('T', ' ').substring(0, 19)
+    },
+    async loadData() {
+      this.loading = true
+      try {
+        var res = await listVirtualTeams({})
+        this.teams = res.data || []
+      } catch (e) {
+        console.error(e)
+      } finally {
+        this.loading = false
+      }
+    },
+    handleCreate() {
+      this.isEdit = false
+      this.form = this.getEmptyForm()
+      this.dialogVisible = true
+      this.$nextTick(function() {
+        if (this.$refs.teamForm) this.$refs.teamForm.clearValidate()
+      }.bind(this))
+    },
+    handleEdit(row) {
+      this.isEdit = true
+      this.form = { id: row.id, name: row.name, description: row.description, profitRate: row.profitRate || 0 }
+      this.dialogVisible = true
+      this.$nextTick(function() {
+        if (this.$refs.teamForm) this.$refs.teamForm.clearValidate()
+      }.bind(this))
+    },
+    async handleToggle(row) {
+      var action = row.enabled ? '禁用' : '启用'
+      try {
+        await this.$confirm('确定' + action + '团队"' + row.name + '"吗?', '提示', { type: 'warning' })
+        await toggleVirtualTeam(row.id)
+        this.$message.success(action + '成功')
+        this.loadData()
+      } catch (e) {
+        if (e !== 'cancel') this.$message.error(action + '失败')
+      }
+    },
+    viewMembers(row) {
+      this.$router.push('/virtual-team-detail/' + row.id)
+    },
+    async handleSubmit() {
+      this.$refs.teamForm.validate(function(valid) {
+        if (!valid) return
+      }.bind(this))
+      if (!this.form.name) return
+      this.submitting = true
+      try {
+        if (this.isEdit) {
+          await updateVirtualTeam(this.form)
+        } else {
+          await createVirtualTeam(this.form)
+        }
+        this.$message.success('保存成功')
+        this.dialogVisible = false
+        this.loadData()
+      } catch (e) {
+        this.$message.error('保存失败')
+      } finally {
+        this.submitting = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.virtual-team-list { padding: 20px; }
+.toolbar { margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
+</style>