李旭明 1 år sedan
förälder
incheckning
8dc6aab1a6
31 ändrade filer med 4859 tillägg och 0 borttagningar
  1. 80 0
      backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/SysTeacherController.java
  2. 78 0
      backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysTeacherBo.java
  3. 46 0
      backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysDeptTreeVo.java
  4. 72 0
      backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysTeacherVo.java
  5. 184 0
      backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysTeacherMapper.java
  6. 67 0
      backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISysTeacherService.java
  7. 246 0
      backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysTeacherServiceImpl.java
  8. 55 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/config/XgCacheConfig.java
  9. 1 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/controller/VolunteerMemberController.java
  10. 134 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/controller/XgStudentController.java
  11. 103 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/bo/XgStudentBo.java
  12. 45 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/vo/XgStudentTreeVo.java
  13. 105 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/vo/XgStudentVo.java
  14. 36 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/mapper/XgStudentMapper.java
  15. 64 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/IXgStudentService.java
  16. 73 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/impl/XgStudentCacheService.java
  17. 214 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/impl/XgStudentServiceImpl.java
  18. 56 0
      web/src/api/system/teacher/index.ts
  19. 111 0
      web/src/api/system/teacher/types.ts
  20. 56 0
      web/src/api/xg/student/index.ts
  21. 146 0
      web/src/api/xg/student/types.ts
  22. 238 0
      web/src/components/ActivitySelector/index.vue
  23. 260 0
      web/src/components/AdvisorManager/index.vue
  24. 226 0
      web/src/components/ClubSelector/index.vue
  25. 331 0
      web/src/components/CoOrganizerManager/index.vue
  26. 353 0
      web/src/components/InstructorManager/index.vue
  27. 92 0
      web/src/components/MaterialTypeSelector/index.vue
  28. 468 0
      web/src/components/StudentSelector/index.vue
  29. 456 0
      web/src/components/TeacherSelector/index.vue
  30. 210 0
      web/src/components/TeamMemberManager/index.vue
  31. 253 0
      web/src/components/VicePresidentManager/index.vue

+ 80 - 0
backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/SysTeacherController.java

@@ -0,0 +1,80 @@
+package org.dromara.system.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.common.web.core.BaseController;
+import org.dromara.system.domain.bo.SysTeacherBo;
+import org.dromara.system.domain.vo.SysTeacherVo;
+import org.dromara.system.domain.vo.SysDeptTreeVo;
+import org.dromara.system.service.ISysTeacherService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 教师信息管理
+ *
+ * @author Lion Li
+ * @date 2025-01-21
+ */
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/system/teacher")
+public class SysTeacherController extends BaseController {
+
+    private final ISysTeacherService sysTeacherService;
+
+    /**
+     * 查询教师信息列表
+     */
+    @SaCheckPermission("system:teacher:list")
+    @GetMapping("/list")
+    public TableDataInfo<SysTeacherVo> list(SysTeacherBo bo, PageQuery pageQuery) {
+        return sysTeacherService.queryPageList(bo, pageQuery);
+    }
+
+    /**
+     * 根据姓名搜索教师
+     */
+    @SaCheckPermission("system:teacher:list")
+    @GetMapping("/search")
+    public R<List<SysTeacherVo>> searchByName(@RequestParam String name, 
+                                               @RequestParam(defaultValue = "10") Integer limit) {
+        List<SysTeacherVo> list = sysTeacherService.searchByName(name, limit);
+        return R.ok(list);
+    }
+
+    /**
+     * 获取部门树形结构
+     */
+    @SaCheckPermission("system:teacher:list")
+    @GetMapping("/deptTree")
+    public R<List<SysDeptTreeVo>> getDeptTreeData() {
+        List<SysDeptTreeVo> tree = sysTeacherService.getDeptTreeData();
+        return R.ok(tree);
+    }
+
+    /**
+     * 根据部门ID获取教师列表
+     */
+    @SaCheckPermission("system:teacher:list")
+    @GetMapping("/dept/{deptId}")
+    public R<List<SysTeacherVo>> getTeachersByDeptId(@PathVariable Long deptId) {
+        List<SysTeacherVo> list = sysTeacherService.getTeachersByDeptId(deptId);
+        return R.ok(list);
+    }
+
+    /**
+     * 获取教师详细信息
+     */
+    @SaCheckPermission("system:teacher:query")
+    @GetMapping("/{userId}")
+    public R<SysTeacherVo> getInfo(@PathVariable Long userId) {
+        return R.ok(sysTeacherService.queryById(userId));
+    }
+} 

+ 78 - 0
backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysTeacherBo.java

@@ -0,0 +1,78 @@
+package org.dromara.system.domain.bo;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+
+/**
+ * 教师信息业务对象
+ *
+ * @author Lion Li
+ * @date 2025-01-21
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class SysTeacherBo extends BaseEntity {
+
+    /**
+     * 用户ID
+     */
+    private Long userId;
+
+    /**
+     * 用户账号
+     */
+    @Size(min = 0, max = 30, message = "用户账号长度不能超过{max}个字符")
+    private String userName;
+
+    /**
+     * 用户昵称(教师姓名)
+     */
+    @NotBlank(message = "教师姓名不能为空")
+    @Size(min = 0, max = 30, message = "用户昵称长度不能超过{max}个字符")
+    private String nickName;
+
+    /**
+     * 手机号码
+     */
+    @Size(min = 0, max = 11, message = "手机号码长度不能超过{max}个字符")
+    private String phonenumber;
+
+    /**
+     * 用户邮箱
+     */
+    @Size(min = 0, max = 50, message = "用户邮箱长度不能超过{max}个字符")
+    private String email;
+
+    /**
+     * 部门ID
+     */
+    private Long deptId;
+
+    /**
+     * 部门名称
+     */
+    private String deptName;
+
+    /**
+     * 用户性别
+     */
+    private String sex;
+
+    /**
+     * 帐号状态(0正常 1停用)
+     */
+    private String status;
+
+    /**
+     * 查询关键词(支持姓名、账号、手机号搜索)
+     */
+    private String searchKeyword;
+
+    /**
+     * 查询限制数量
+     */
+    private Integer limit;
+} 

+ 46 - 0
backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysDeptTreeVo.java

@@ -0,0 +1,46 @@
+package org.dromara.system.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 部门树形结构视图对象
+ *
+ * @author Lion Li
+ * @date 2025-01-21
+ */
+@Data
+public class SysDeptTreeVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 部门ID
+     */
+    private Long id;
+
+    /**
+     * 父部门ID
+     */
+    private Long parentId;
+
+    /**
+     * 部门名称
+     */
+    private String label;
+
+    /**
+     * 显示顺序
+     */
+    private Integer orderNum;
+
+    /**
+     * 子部门
+     */
+    private List<SysDeptTreeVo> children;
+
+} 

+ 72 - 0
backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysTeacherVo.java

@@ -0,0 +1,72 @@
+package org.dromara.system.domain.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 教师信息视图对象
+ *
+ * @author Lion Li
+ * @date 2025-01-21
+ */
+@Data
+public class SysTeacherVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 用户ID
+     */
+    private Long userId;
+
+    /**
+     * 用户账号
+     */
+    private String userName;
+
+    /**
+     * 用户昵称(教师姓名)
+     */
+    private String nickName;
+
+    /**
+     * 手机号码
+     */
+    private String phonenumber;
+
+    /**
+     * 用户邮箱
+     */
+    private String email;
+
+    /**
+     * 部门ID
+     */
+    private Long deptId;
+
+    /**
+     * 部门名称
+     */
+    private String deptName;
+
+    /**
+     * 用户性别
+     */
+    private String sex;
+
+    /**
+     * 帐号状态(0正常 1停用)
+     */
+    private String status;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+} 

+ 184 - 0
backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysTeacherMapper.java

@@ -0,0 +1,184 @@
+package org.dromara.system.mapper;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.toolkit.Constants;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.system.domain.SysUser;
+import org.dromara.system.domain.vo.SysTeacherVo;
+import org.dromara.system.domain.vo.SysDeptTreeVo;
+
+import java.util.List;
+
+/**
+ * 教师信息Mapper接口
+ *
+ * @author Lion Li
+ * @date 2025-01-21
+ */
+public interface SysTeacherMapper extends BaseMapperPlus<SysUser, SysTeacherVo> {
+
+    /**
+     * 分页查询教师信息列表
+     *
+     * @param page    分页对象
+     * @param wrapper 查询条件
+     * @return 教师信息分页列表
+     */
+    @Select("""
+        SELECT 
+            u.user_id,
+            u.user_name,
+            u.nick_name,
+            u.phonenumber,
+            u.email,
+            u.dept_id,
+            d.dept_name,
+            u.sex,
+            u.status,
+            u.create_time
+        FROM sys_user u
+        LEFT JOIN sys_dept d ON u.dept_id = d.dept_id
+        WHERE u.del_flag = '0' 
+        AND u.account_type = '1'
+        ${ew.customSqlSegment}
+        ORDER BY u.create_time DESC
+        """)
+    Page<SysTeacherVo> selectPageTeacherList(@Param("page") Page<SysTeacherVo> page, @Param(Constants.WRAPPER) Wrapper<SysUser> wrapper);
+
+    /**
+     * 查询教师信息列表
+     *
+     * @param wrapper 查询条件
+     * @return 教师信息列表
+     */
+    @Select("""
+        SELECT 
+            u.user_id,
+            u.user_name,
+            u.nick_name,
+            u.phonenumber,
+            u.email,
+            u.dept_id,
+            d.dept_name,
+            u.sex,
+            u.status,
+            u.create_time
+        FROM sys_user u
+        LEFT JOIN sys_dept d ON u.dept_id = d.dept_id
+        WHERE u.del_flag = '0' 
+        AND u.account_type = '1'
+        ${ew.customSqlSegment}
+        ORDER BY u.create_time DESC
+        """)
+    List<SysTeacherVo> selectTeacherList(@Param(Constants.WRAPPER) Wrapper<SysUser> wrapper);
+
+    /**
+     * 根据姓名搜索教师(模糊查询)
+     *
+     * @param name  教师姓名关键字
+     * @param limit 查询限制数量
+     * @return 教师信息列表
+     */
+    @Select("""
+        SELECT 
+            u.user_id,
+            u.user_name,
+            u.nick_name,
+            u.phonenumber,
+            u.email,
+            u.dept_id,
+            d.dept_name,
+            u.sex,
+            u.status,
+            u.create_time
+        FROM sys_user u
+        LEFT JOIN sys_dept d ON u.dept_id = d.dept_id
+        WHERE u.del_flag = '0' 
+        AND u.account_type = '1'
+        AND (u.nick_name LIKE CONCAT('%', #{name}, '%') 
+             OR u.user_name LIKE CONCAT('%', #{name}, '%')
+             OR u.phonenumber LIKE CONCAT('%', #{name}, '%'))
+        AND u.status = '0'
+        ORDER BY u.create_time DESC
+        LIMIT #{limit}
+        """)
+    List<SysTeacherVo> searchTeachersByName(@Param("name") String name, @Param("limit") Integer limit);
+
+    /**
+     * 根据部门ID获取教师列表
+     *
+     * @param deptId 部门ID
+     * @return 教师信息列表
+     */
+    @Select("""
+        SELECT 
+            u.user_id,
+            u.user_name,
+            u.nick_name,
+            u.phonenumber,
+            u.email,
+            u.dept_id,
+            d.dept_name,
+            u.sex,
+            u.status,
+            u.create_time
+        FROM sys_user u
+        LEFT JOIN sys_dept d ON u.dept_id = d.dept_id
+        WHERE u.del_flag = '0' 
+        AND u.account_type = '1'
+        AND u.dept_id = #{deptId}
+        AND u.status = '0'
+        ORDER BY u.create_time DESC
+        """)
+    List<SysTeacherVo> selectTeachersByDeptId(@Param("deptId") Long deptId);
+
+    /**
+     * 获取部门树形结构(只包含有教师的部门)
+     *
+     * @return 部门树信息集合
+     */
+    @Select("""
+        SELECT DISTINCT
+            d.dept_id as id,
+            d.parent_id,
+            d.dept_name as label,
+            d.order_num
+        FROM sys_dept d
+        INNER JOIN sys_user u ON d.dept_id = u.dept_id
+        WHERE d.del_flag = '0' 
+        AND d.status = '0'
+        AND u.del_flag = '0' 
+        AND u.account_type = '1'
+        AND u.status = '0'
+        ORDER BY d.order_num
+        """)
+    List<SysDeptTreeVo> selectDeptTreeWithTeachers();
+
+    /**
+     * 根据部门ID列表获取所有父级部门
+     *
+     * @param deptIds 部门ID列表
+     * @return 父级部门信息集合
+     */
+    @Select("""
+        <script>
+        SELECT DISTINCT
+            dept_id as id,
+            parent_id,
+            dept_name as label,
+            order_num
+        FROM sys_dept
+        WHERE del_flag = '0' 
+        AND status = '0'
+        AND dept_id IN
+        <foreach collection="deptIds" item="deptId" open="(" separator="," close=")">
+            #{deptId}
+        </foreach>
+        ORDER BY order_num
+        </script>
+        """)
+    List<SysDeptTreeVo> selectDeptsByIds(@Param("deptIds") List<Long> deptIds);
+} 

+ 67 - 0
backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISysTeacherService.java

@@ -0,0 +1,67 @@
+package org.dromara.system.service;
+
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.system.domain.bo.SysTeacherBo;
+import org.dromara.system.domain.vo.SysTeacherVo;
+import org.dromara.system.domain.vo.SysDeptTreeVo;
+
+import java.util.List;
+
+/**
+ * 教师信息管理 服务层
+ *
+ * @author Lion Li
+ * @date 2025-01-21
+ */
+public interface ISysTeacherService {
+
+    /**
+     * 分页查询教师信息列表
+     *
+     * @param bo        教师信息
+     * @param pageQuery 分页对象
+     * @return 教师信息集合
+     */
+    TableDataInfo<SysTeacherVo> queryPageList(SysTeacherBo bo, PageQuery pageQuery);
+
+    /**
+     * 根据姓名搜索教师
+     *
+     * @param name  教师姓名关键字
+     * @param limit 查询限制数量
+     * @return 教师信息列表
+     */
+    List<SysTeacherVo> searchByName(String name, Integer limit);
+
+    /**
+     * 获取部门树形结构
+     *
+     * @return 部门树信息集合
+     */
+    List<SysDeptTreeVo> getDeptTreeData();
+
+    /**
+     * 根据部门ID获取教师列表
+     *
+     * @param deptId 部门ID
+     * @return 教师信息列表
+     */
+    List<SysTeacherVo> getTeachersByDeptId(Long deptId);
+
+    /**
+     * 根据ID查询教师详细信息
+     *
+     * @param userId 用户ID
+     * @return 教师信息
+     */
+    SysTeacherVo queryById(Long userId);
+
+    /**
+     * 查询教师信息列表
+     *
+     * @param bo 教师信息
+     * @return 教师信息集合
+     */
+    List<SysTeacherVo> queryList(SysTeacherBo bo);
+} 

+ 246 - 0
backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysTeacherServiceImpl.java

@@ -0,0 +1,246 @@
+package org.dromara.system.service.impl;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.constant.SystemConstants;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.system.domain.SysUser;
+import org.dromara.system.domain.bo.SysTeacherBo;
+import org.dromara.system.domain.vo.SysTeacherVo;
+import org.dromara.system.domain.vo.SysDeptTreeVo;
+import org.dromara.system.mapper.SysTeacherMapper;
+import org.dromara.system.service.ISysTeacherService;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 教师信息管理 服务实现
+ *
+ * @author Lion Li
+ * @date 2025-01-21
+ */
+@RequiredArgsConstructor
+@Service
+public class SysTeacherServiceImpl implements ISysTeacherService {
+
+    private final SysTeacherMapper baseMapper;
+
+    /**
+     * 分页查询教师信息列表
+     *
+     * @param bo        教师信息
+     * @param pageQuery 分页对象
+     * @return 教师信息集合
+     */
+    @Override
+    public TableDataInfo<SysTeacherVo> queryPageList(SysTeacherBo bo, PageQuery pageQuery) {
+        LambdaQueryWrapper<SysUser> lqw = buildQueryWrapper(bo);
+        Page<SysTeacherVo> page = baseMapper.selectPageTeacherList(pageQuery.build(), lqw);
+        return TableDataInfo.build(page);
+    }
+
+    /**
+     * 查询教师信息列表
+     *
+     * @param bo 教师信息
+     * @return 教师信息集合
+     */
+    @Override
+    public List<SysTeacherVo> queryList(SysTeacherBo bo) {
+        LambdaQueryWrapper<SysUser> lqw = buildQueryWrapper(bo);
+        return baseMapper.selectTeacherList(lqw);
+    }
+
+    /**
+     * 根据姓名搜索教师
+     *
+     * @param name  教师姓名关键字
+     * @param limit 查询限制数量
+     * @return 教师信息列表
+     */
+    @Override
+    public List<SysTeacherVo> searchByName(String name, Integer limit) {
+        if (StrUtil.isBlank(name)) {
+            return CollUtil.newArrayList();
+        }
+        if (limit == null || limit <= 0) {
+            limit = 10;
+        }
+        return baseMapper.searchTeachersByName(name, limit);
+    }
+
+    /**
+     * 获取部门树形结构
+     *
+     * @return 部门树信息集合
+     */
+    @Override
+    public List<SysDeptTreeVo> getDeptTreeData() {
+        // 获取有教师的部门
+        List<SysDeptTreeVo> teacherDepts = baseMapper.selectDeptTreeWithTeachers();
+        
+        // 补全父级部门
+        List<SysDeptTreeVo> completeDepts = completeParentDepts(teacherDepts);
+        
+        // 构建树形结构
+        return buildDeptTree(completeDepts, 0L);
+    }
+
+    /**
+     * 补全父级部门
+     *
+     * @param teacherDepts 有教师的部门列表
+     * @return 补全父级部门后的完整列表
+     */
+    private List<SysDeptTreeVo> completeParentDepts(List<SysDeptTreeVo> teacherDepts) {
+        Set<Long> allDeptIds = new HashSet<>();
+        Set<Long> parentIds = new HashSet<>();
+        
+        // 收集所有部门ID和父级ID
+        for (SysDeptTreeVo dept : teacherDepts) {
+            allDeptIds.add(dept.getId());
+            if (dept.getParentId() != null && dept.getParentId() != 0L) {
+                collectParentIds(dept.getParentId(), parentIds);
+            }
+        }
+        
+        // 找出缺失的父级部门ID
+        Set<Long> missingParentIds = new HashSet<>();
+        for (Long parentId : parentIds) {
+            if (!allDeptIds.contains(parentId)) {
+                missingParentIds.add(parentId);
+            }
+        }
+        
+        // 查询缺失的父级部门
+        List<SysDeptTreeVo> completeDepts = new ArrayList<>(teacherDepts);
+        if (!missingParentIds.isEmpty()) {
+            List<SysDeptTreeVo> parentDepts = baseMapper.selectDeptsByIds(new ArrayList<>(missingParentIds));
+            completeDepts.addAll(parentDepts);
+            
+            // 递归补全更上级的父级部门
+            List<SysDeptTreeVo> moreParents = completeParentDepts(parentDepts);
+            for (SysDeptTreeVo parent : moreParents) {
+                if (!allDeptIds.contains(parent.getId()) && !missingParentIds.contains(parent.getId())) {
+                    completeDepts.add(parent);
+                }
+            }
+        }
+        
+        return completeDepts;
+    }
+    
+    /**
+     * 递归收集所有父级部门ID
+     *
+     * @param parentId 父级部门ID
+     * @param parentIds 父级ID集合
+     */
+    private void collectParentIds(Long parentId, Set<Long> parentIds) {
+        if (parentId != null && parentId != 0L) {
+            parentIds.add(parentId);
+        }
+    }
+
+    /**
+     * 构建部门树形结构
+     *
+     * @param flatList 扁平部门列表
+     * @param parentId 父部门ID
+     * @return 树形结构列表
+     */
+    private List<SysDeptTreeVo> buildDeptTree(List<SysDeptTreeVo> flatList, Long parentId) {
+        List<SysDeptTreeVo> tree = new ArrayList<>();
+        
+        for (SysDeptTreeVo dept : flatList) {
+            if (ObjectUtil.equal(dept.getParentId(), parentId)) {
+                List<SysDeptTreeVo> children = buildDeptTree(flatList, dept.getId());
+                dept.setChildren(children);
+                tree.add(dept);
+            }
+        }
+        
+        return tree;
+    }
+
+    /**
+     * 根据部门ID获取教师列表
+     *
+     * @param deptId 部门ID
+     * @return 教师信息列表
+     */
+    @Override
+    public List<SysTeacherVo> getTeachersByDeptId(Long deptId) {
+        if (ObjectUtil.isNull(deptId)) {
+            return CollUtil.newArrayList();
+        }
+        return baseMapper.selectTeachersByDeptId(deptId);
+    }
+
+    /**
+     * 根据ID查询教师详细信息
+     *
+     * @param userId 用户ID
+     * @return 教师信息
+     */
+    @Override
+    public SysTeacherVo queryById(Long userId) {
+        if (ObjectUtil.isNull(userId)) {
+            return null;
+        }
+        LambdaQueryWrapper<SysUser> lqw = Wrappers.lambdaQuery();
+        lqw.eq(SysUser::getUserId, userId);
+        lqw.eq(SysUser::getDelFlag, SystemConstants.NORMAL);
+        lqw.eq(SysUser::getAccountType, "1"); // 教师账号类型
+        List<SysTeacherVo> list = baseMapper.selectTeacherList(lqw);
+        return CollUtil.isNotEmpty(list) ? list.get(0) : null;
+    }
+
+    /**
+     * 构建查询条件
+     *
+     * @param bo 教师信息业务对象
+     * @return 查询条件
+     */
+    private LambdaQueryWrapper<SysUser> buildQueryWrapper(SysTeacherBo bo) {
+        LambdaQueryWrapper<SysUser> lqw = Wrappers.lambdaQuery();
+        
+        // 基础条件:未删除的教师用户
+        lqw.eq(SysUser::getDelFlag, SystemConstants.NORMAL);
+        lqw.eq(SysUser::getAccountType, "1"); // 教师账号类型
+        
+        // 动态查询条件
+        lqw.eq(ObjectUtil.isNotNull(bo.getUserId()), SysUser::getUserId, bo.getUserId());
+        lqw.like(StrUtil.isNotBlank(bo.getUserName()), SysUser::getUserName, bo.getUserName());
+        lqw.like(StrUtil.isNotBlank(bo.getNickName()), SysUser::getNickName, bo.getNickName());
+        lqw.eq(StrUtil.isNotBlank(bo.getPhonenumber()), SysUser::getPhonenumber, bo.getPhonenumber());
+        lqw.like(StrUtil.isNotBlank(bo.getEmail()), SysUser::getEmail, bo.getEmail());
+        lqw.eq(ObjectUtil.isNotNull(bo.getDeptId()), SysUser::getDeptId, bo.getDeptId());
+        lqw.eq(StrUtil.isNotBlank(bo.getSex()), SysUser::getSex, bo.getSex());
+        lqw.eq(StrUtil.isNotBlank(bo.getStatus()), SysUser::getStatus, bo.getStatus());
+        
+        // 搜索关键词(支持姓名、账号、手机号)
+        if (StrUtil.isNotBlank(bo.getSearchKeyword())) {
+            String keyword = bo.getSearchKeyword();
+            lqw.and(wrapper -> wrapper
+                .like(SysUser::getNickName, keyword)
+                .or()
+                .like(SysUser::getUserName, keyword)
+                .or()
+                .like(SysUser::getPhonenumber, keyword)
+            );
+        }
+        
+        return lqw;
+    }
+} 

+ 55 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/config/XgCacheConfig.java

@@ -0,0 +1,55 @@
+package org.dromara.xg.config;
+
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.cache.RedisCacheConfiguration;
+import org.springframework.data.redis.cache.RedisCacheManager;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.RedisSerializationContext;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 学工模块缓存配置类
+ * 配置Redis缓存,提升学生数据查询性能
+ *
+ * @author AI Assistant
+ * @date 2025-01-21
+ */
+@Configuration
+@EnableCaching
+public class XgCacheConfig {
+
+    /**
+     * 配置学工模块Redis缓存管理器
+     */
+    @Bean("xgCacheManager")
+    public CacheManager xgCacheManager(RedisConnectionFactory factory) {
+        // 默认缓存配置
+        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
+            .entryTtl(Duration.ofMinutes(30)) // 默认30分钟过期
+            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
+            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
+            .disableCachingNullValues(); // 不缓存null值
+
+        // 特定缓存配置
+        Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
+        
+        // 学生树形数据缓存:缓存2小时,因为组织架构变化不频繁
+        cacheConfigurations.put("student_tree", defaultConfig.entryTtl(Duration.ofHours(2)));
+        
+        // 学生列表缓存:缓存15分钟,因为学生数据可能变化较频繁
+        cacheConfigurations.put("student_list", defaultConfig.entryTtl(Duration.ofMinutes(15)));
+
+        return RedisCacheManager.builder(factory)
+            .cacheDefaults(defaultConfig)
+            .withInitialCacheConfigurations(cacheConfigurations)
+            .build();
+    }
+} 

+ 1 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/controller/VolunteerMemberController.java

@@ -0,0 +1 @@
+ 

+ 134 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/controller/XgStudentController.java

@@ -0,0 +1,134 @@
+package org.dromara.xg.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.domain.R;
+import org.dromara.common.core.validate.AddGroup;
+import org.dromara.common.core.validate.EditGroup;
+import org.dromara.common.excel.utils.ExcelUtil;
+import org.dromara.common.idempotent.annotation.RepeatSubmit;
+import org.dromara.common.log.annotation.Log;
+import org.dromara.common.log.enums.BusinessType;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.common.web.core.BaseController;
+import org.dromara.xg.domain.bo.XgStudentBo;
+import org.dromara.xg.domain.vo.XgStudentVo;
+import org.dromara.xg.domain.vo.XgStudentTreeVo;
+import org.dromara.xg.service.IXgStudentService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 学生信息管理
+ *
+ * @author Lion Li
+ * @date 2025-01-21
+ */
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/xg/student")
+public class XgStudentController extends BaseController {
+
+    private final IXgStudentService xgStudentService;
+
+    /**
+     * 查询学生信息管理列表
+     */
+    @SaCheckPermission("xg:student:list")
+    @GetMapping("/list")
+    public TableDataInfo<XgStudentVo> list(XgStudentBo bo, PageQuery pageQuery) {
+        return xgStudentService.queryPageList(bo, pageQuery);
+    }
+
+    /**
+     * 根据姓名搜索学生
+     */
+    @SaCheckPermission("xg:student:list")
+    @GetMapping("/search")
+    public R<List<XgStudentVo>> searchByName(@RequestParam String name, 
+                                              @RequestParam(defaultValue = "10") Integer limit) {
+        List<XgStudentVo> list = xgStudentService.searchByName(name, limit);
+        return R.ok(list);
+    }
+
+    /**
+     * 获取学院专业班级树形结构
+     */
+    @SaCheckPermission("xg:student:list")
+    @GetMapping("/tree")
+    public R<List<XgStudentTreeVo>> getTreeData() {
+        List<XgStudentTreeVo> tree = xgStudentService.getTreeData();
+        return R.ok(tree);
+    }
+
+    /**
+     * 根据班级ID获取学生列表
+     */
+    @SaCheckPermission("xg:student:list")
+    @GetMapping("/class/{classId}")
+    public R<List<XgStudentVo>> getStudentsByClassId(@PathVariable Long classId) {
+        List<XgStudentVo> list = xgStudentService.getStudentsByClassId(classId);
+        return R.ok(list);
+    }
+
+    /**
+     * 导出学生信息管理列表
+     */
+    @SaCheckPermission("xg:student:export")
+    @Log(title = "学生信息管理", businessType = BusinessType.EXPORT)
+    @PostMapping("/export")
+    public void export(XgStudentBo bo, HttpServletResponse response) {
+        List<XgStudentVo> list = xgStudentService.queryList(bo);
+        ExcelUtil.exportExcel(list, "学生信息管理", XgStudentVo.class, response);
+    }
+
+    /**
+     * 获取学生信息管理详细信息
+     */
+    @SaCheckPermission("xg:student:query")
+    @GetMapping("/{id}")
+    public R<XgStudentVo> getInfo(@NotNull(message = "主键不能为空")
+                                  @PathVariable Long id) {
+        return R.ok(xgStudentService.queryById(id));
+    }
+
+    /**
+     * 新增学生信息管理
+     */
+    @SaCheckPermission("xg:student:add")
+    @Log(title = "学生信息管理", businessType = BusinessType.INSERT)
+    @RepeatSubmit()
+    @PostMapping()
+    public R<Void> add(@Validated(AddGroup.class) @RequestBody XgStudentBo bo) {
+        return toAjax(xgStudentService.insertByBo(bo));
+    }
+
+    /**
+     * 修改学生信息管理
+     */
+    @SaCheckPermission("xg:student:edit")
+    @Log(title = "学生信息管理", businessType = BusinessType.UPDATE)
+    @RepeatSubmit()
+    @PutMapping()
+    public R<Void> edit(@Validated(EditGroup.class) @RequestBody XgStudentBo bo) {
+        return toAjax(xgStudentService.updateByBo(bo));
+    }
+
+    /**
+     * 删除学生信息管理
+     */
+    @SaCheckPermission("xg:student:remove")
+    @Log(title = "学生信息管理", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public R<Void> remove(@NotEmpty(message = "主键不能为空")
+                          @PathVariable Long[] ids) {
+        return toAjax(xgStudentService.deleteWithValidByIds(List.of(ids), true));
+    }
+} 

+ 103 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/bo/XgStudentBo.java

@@ -0,0 +1,103 @@
+package org.dromara.xg.domain.bo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.core.validate.AddGroup;
+import org.dromara.common.core.validate.EditGroup;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+import org.dromara.xg.domain.XgStudentMsg;
+
+import java.util.Date;
+
+/**
+ * 学生信息业务对象 xg_student_msg
+ *
+ * @author Lion Li
+ * @date 2025-01-21
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@AutoMapper(target = XgStudentMsg.class, reverseConvertGenerate = false)
+public class XgStudentBo extends BaseEntity {
+
+    /**
+     * 主键ID(雪花ID)
+     */
+    @NotNull(message = "主键ID不能为空", groups = { EditGroup.class })
+    private Long id;
+
+    /**
+     * 学号
+     */
+    @NotBlank(message = "学号不能为空", groups = { AddGroup.class, EditGroup.class })
+    private String studentNo;
+
+    /**
+     * 姓名
+     */
+    @NotBlank(message = "姓名不能为空", groups = { AddGroup.class, EditGroup.class })
+    private String name;
+
+    /**
+     * 性别:0-男,1-女
+     */
+    private String gender;
+
+    /**
+     * 手机号码
+     */
+    private String phone;
+
+    /**
+     * 学院ID
+     */
+    private String collegeId;
+
+    /**
+     * 学院名称
+     */
+    private String collegeName;
+
+    /**
+     * 专业ID
+     */
+    private String majorId;
+
+    /**
+     * 专业名称
+     */
+    private String majorName;
+
+    /**
+     * 班级ID
+     */
+    private String classId;
+
+    /**
+     * 班级名称
+     */
+    private String className;
+
+    /**
+     * 年级
+     */
+    private String grade;
+
+    /**
+     * 学籍状态:1-在籍,2-休学,3-复学,4-退学,5-毕业
+     */
+    private String studentStatus;
+
+    /**
+     * 入学年份
+     */
+    private Integer enrollmentYear;
+
+    /**
+     * 入学日期
+     */
+    private Date enrollmentDate;
+} 

+ 45 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/vo/XgStudentTreeVo.java

@@ -0,0 +1,45 @@
+package org.dromara.xg.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 学生树形结构视图对象
+ *
+ * @author Lion Li
+ * @date 2025-01-21
+ */
+@Data
+public class XgStudentTreeVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 节点ID
+     */
+    private String id;
+
+    /**
+     * 节点名称
+     */
+    private String label;
+
+    /**
+     * 节点类型: college-学院, major-专业, class-班级
+     */
+    private String type;
+
+    /**
+     * 子节点
+     */
+    private List<XgStudentTreeVo> children;
+
+    /**
+     * 学生数量(仅班级节点有)
+     */
+    private Integer studentCount;
+} 

+ 105 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/domain/vo/XgStudentVo.java

@@ -0,0 +1,105 @@
+package org.dromara.xg.domain.vo;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.dromara.xg.domain.XgStudentMsg;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 学生信息视图对象 xg_student_msg
+ *
+ * @author Lion Li
+ * @date 2025-01-21
+ */
+@Data
+@AutoMapper(target = XgStudentMsg.class)
+public class XgStudentVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 主键ID(雪花ID)
+     */
+    private Long id;
+
+    /**
+     * 学号
+     */
+    private String studentNo;
+
+    /**
+     * 姓名
+     */
+    private String name;
+
+    /**
+     * 性别:0-男,1-女
+     */
+    private String gender;
+
+    /**
+     * 手机号码
+     */
+    private String phone;
+
+    /**
+     * 学院ID
+     */
+    private String collegeId;
+
+    /**
+     * 学院名称
+     */
+    private String collegeName;
+
+    /**
+     * 专业ID
+     */
+    private String majorId;
+
+    /**
+     * 专业名称
+     */
+    private String majorName;
+
+    /**
+     * 班级ID
+     */
+    private String classId;
+
+    /**
+     * 班级名称
+     */
+    private String className;
+
+    /**
+     * 年级
+     */
+    private String grade;
+
+    /**
+     * 学籍状态:1-在籍,2-休学,3-复学,4-退学,5-毕业
+     */
+    private String studentStatus;
+
+    /**
+     * 入学年份
+     */
+    private Integer enrollmentYear;
+
+    /**
+     * 入学日期
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private Date enrollmentDate;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+} 

+ 36 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/mapper/XgStudentMapper.java

@@ -0,0 +1,36 @@
+package org.dromara.xg.mapper;
+
+import org.apache.ibatis.annotations.Select;
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.xg.domain.XgStudentMsg;
+import org.dromara.xg.domain.vo.XgStudentVo;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 学生信息管理Mapper接口
+ *
+ * @author Lion Li
+ * @date 2025-01-21
+ */
+public interface XgStudentMapper extends BaseMapperPlus<XgStudentMsg, XgStudentVo> {
+
+    /**
+     * 查询树形结构所需的最少数据
+     * 只查询构建树形结构必需的字段,避免全表查询,提升性能
+     */
+    @Select("SELECT DISTINCT " +
+            "college_id, college_name, " +
+            "major_id, major_name, " +
+            "class_id, class_name " +
+            "FROM xg_student_msg " +
+            "WHERE deleted = 0 " +
+            "AND student_status = '1' " +
+            "AND college_id IS NOT NULL " +
+            "AND major_id IS NOT NULL " +
+            "AND class_id IS NOT NULL " +
+            "ORDER BY college_name, major_name, class_name")
+    List<Map<String, Object>> selectTreeNodes();
+
+} 

+ 64 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/IXgStudentService.java

@@ -0,0 +1,64 @@
+package org.dromara.xg.service;
+
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.xg.domain.bo.XgStudentBo;
+import org.dromara.xg.domain.vo.XgStudentVo;
+import org.dromara.xg.domain.vo.XgStudentTreeVo;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 学生信息管理Service接口
+ *
+ * @author Lion Li
+ * @date 2025-01-21
+ */
+public interface IXgStudentService {
+
+    /**
+     * 查询学生信息管理
+     */
+    XgStudentVo queryById(Long id);
+
+    /**
+     * 查询学生信息管理列表
+     */
+    TableDataInfo<XgStudentVo> queryPageList(XgStudentBo bo, PageQuery pageQuery);
+
+    /**
+     * 查询学生信息管理列表
+     */
+    List<XgStudentVo> queryList(XgStudentBo bo);
+
+    /**
+     * 根据姓名搜索学生
+     */
+    List<XgStudentVo> searchByName(String name, Integer limit);
+
+    /**
+     * 获取学院专业班级树形结构
+     */
+    List<XgStudentTreeVo> getTreeData();
+
+    /**
+     * 根据班级ID获取学生列表
+     */
+    List<XgStudentVo> getStudentsByClassId(Long classId);
+
+    /**
+     * 新增学生信息管理
+     */
+    Boolean insertByBo(XgStudentBo bo);
+
+    /**
+     * 修改学生信息管理
+     */
+    Boolean updateByBo(XgStudentBo bo);
+
+    /**
+     * 校验并批量删除学生信息管理信息
+     */
+    Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
+} 

+ 73 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/impl/XgStudentCacheService.java

@@ -0,0 +1,73 @@
+package org.dromara.xg.service.impl;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.cache.CacheManager;
+import org.springframework.stereotype.Service;
+
+import java.util.Objects;
+
+/**
+ * 学生数据缓存清除服务
+ * 用于在学生数据更新时清除相关缓存,保证数据一致性
+ *
+ * @author AI Assistant
+ * @date 2025-01-21
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class XgStudentCacheService {
+
+    private final CacheManager cacheManager;
+
+    /**
+     * 清除学生树形数据缓存
+     * 当学院、专业、班级信息发生变化时调用
+     */
+    public void evictTreeCache() {
+        try {
+            Objects.requireNonNull(cacheManager.getCache("student_tree")).clear();
+            log.info("清除学生树形数据缓存成功");
+        } catch (Exception e) {
+            log.warn("清除学生树形数据缓存失败", e);
+        }
+    }
+
+    /**
+     * 清除指定班级的学生列表缓存
+     * 当该班级的学生信息发生变化时调用
+     */
+    public void evictStudentListCache(Long classId) {
+        try {
+            String cacheKey = "class_" + classId;
+            Objects.requireNonNull(cacheManager.getCache("student_list")).evict(cacheKey);
+            log.info("清除班级[{}]学生列表缓存成功", classId);
+        } catch (Exception e) {
+            log.warn("清除班级[{}]学生列表缓存失败", classId, e);
+        }
+    }
+
+    /**
+     * 清除所有学生列表缓存
+     * 当有大批量学生数据更新时调用
+     */
+    public void evictAllStudentListCache() {
+        try {
+            Objects.requireNonNull(cacheManager.getCache("student_list")).clear();
+            log.info("清除所有学生列表缓存成功");
+        } catch (Exception e) {
+            log.warn("清除所有学生列表缓存失败", e);
+        }
+    }
+
+    /**
+     * 清除所有相关缓存
+     * 当进行大规模数据更新或系统维护时调用
+     */
+    public void evictAllCache() {
+        evictTreeCache();
+        evictAllStudentListCache();
+        log.info("清除所有学生相关缓存完成");
+    }
+} 

+ 214 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/impl/XgStudentServiceImpl.java

@@ -0,0 +1,214 @@
+package org.dromara.xg.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.utils.MapstructUtils;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.mybatis.core.page.PageQuery;
+import org.dromara.common.mybatis.core.page.TableDataInfo;
+import org.dromara.xg.domain.XgStudentMsg;
+import org.dromara.xg.domain.bo.XgStudentBo;
+import org.dromara.xg.domain.vo.XgStudentTreeVo;
+import org.dromara.xg.domain.vo.XgStudentVo;
+import org.dromara.xg.mapper.XgStudentMapper;
+import org.dromara.xg.service.IXgStudentService;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 学生信息管理Service业务层处理
+ *
+ * @author Lion Li
+ * @date 2025-01-21
+ */
+@RequiredArgsConstructor
+@Service
+public class XgStudentServiceImpl implements IXgStudentService {
+
+    private final XgStudentMapper baseMapper;
+
+    /**
+     * 查询学生信息管理
+     */
+    @Override
+    public XgStudentVo queryById(Long id) {
+        return baseMapper.selectVoById(id);
+    }
+
+    /**
+     * 查询学生信息管理列表
+     */
+    @Override
+    public TableDataInfo<XgStudentVo> queryPageList(XgStudentBo bo, PageQuery pageQuery) {
+        LambdaQueryWrapper<XgStudentMsg> lqw = buildQueryWrapper(bo);
+        Page<XgStudentVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
+        return TableDataInfo.build(result);
+    }
+
+    /**
+     * 查询学生信息管理列表
+     */
+    @Override
+    public List<XgStudentVo> queryList(XgStudentBo bo) {
+        LambdaQueryWrapper<XgStudentMsg> lqw = buildQueryWrapper(bo);
+        return baseMapper.selectVoList(lqw);
+    }
+
+    /**
+     * 根据姓名搜索学生
+     */
+    @Override
+    public List<XgStudentVo> searchByName(String name, Integer limit) {
+        LambdaQueryWrapper<XgStudentMsg> lqw = Wrappers.lambdaQuery();
+        lqw.like(StringUtils.isNotBlank(name), XgStudentMsg::getName, name);
+        lqw.eq(XgStudentMsg::getStudentStatus, "1"); // 只查询在籍学生
+        lqw.last("LIMIT " + (limit != null ? limit : 10));
+        return baseMapper.selectVoList(lqw);
+    }
+
+    /**
+     * 获取学院专业班级树形结构
+     * 优化版本:使用专门的SQL查询,避免全表查询,提升性能
+     */
+    @Override
+    @Cacheable(value = "student_tree", key = "'tree_data'", unless = "#result == null || #result.isEmpty()")
+    public List<XgStudentTreeVo> getTreeData() {
+        // 使用专门的SQL查询获取树形结构所需的最少数据
+        List<Map<String, Object>> treeNodes = baseMapper.selectTreeNodes();
+        
+        // 构建树形结构
+        Map<String, XgStudentTreeVo> collegeMap = new LinkedHashMap<>();
+        Map<String, XgStudentTreeVo> majorMap = new LinkedHashMap<>();
+        Map<String, XgStudentTreeVo> classMap = new LinkedHashMap<>();
+        
+        for (Map<String, Object> node : treeNodes) {
+            String collegeId = (String) node.get("college_id");
+            String collegeName = (String) node.get("college_name");
+            String majorId = (String) node.get("major_id");
+            String majorName = (String) node.get("major_name");
+            String classId = (String) node.get("class_id");
+            String className = (String) node.get("class_name");
+            
+            // 学院节点
+            if (StringUtils.isNotBlank(collegeId) && !collegeMap.containsKey(collegeId)) {
+                XgStudentTreeVo college = new XgStudentTreeVo();
+                college.setId(collegeId);
+                college.setLabel(collegeName);
+                college.setType("college");
+                college.setChildren(new ArrayList<>());
+                collegeMap.put(collegeId, college);
+            }
+            
+            // 专业节点
+            String majorKey = collegeId + "_" + majorId;
+            if (StringUtils.isNotBlank(majorId) && !majorMap.containsKey(majorKey)) {
+                XgStudentTreeVo major = new XgStudentTreeVo();
+                major.setId(majorId);
+                major.setLabel(majorName);
+                major.setType("major");
+                major.setChildren(new ArrayList<>());
+                majorMap.put(majorKey, major);
+                
+                // 添加到学院下
+                if (collegeMap.containsKey(collegeId)) {
+                    collegeMap.get(collegeId).getChildren().add(major);
+                }
+            }
+            
+            // 班级节点
+            String classKey = majorKey + "_" + classId;
+            if (StringUtils.isNotBlank(classId) && !classMap.containsKey(classKey)) {
+                XgStudentTreeVo clazz = new XgStudentTreeVo();
+                clazz.setId(classId);
+                clazz.setLabel(className);
+                clazz.setType("class");
+                clazz.setChildren(new ArrayList<>());
+                classMap.put(classKey, clazz);
+                
+                // 添加到专业下
+                if (majorMap.containsKey(majorKey)) {
+                    majorMap.get(majorKey).getChildren().add(clazz);
+                }
+            }
+        }
+        
+        return new ArrayList<>(collegeMap.values());
+    }
+
+    /**
+     * 根据班级ID获取学生列表
+     */
+    @Override
+    @Cacheable(value = "student_list", key = "'class_' + #classId", unless = "#result == null || #result.isEmpty()")
+    public List<XgStudentVo> getStudentsByClassId(Long classId) {
+        LambdaQueryWrapper<XgStudentMsg> lqw = Wrappers.lambdaQuery();
+        lqw.eq(XgStudentMsg::getClassId, classId);
+        lqw.eq(XgStudentMsg::getStudentStatus, "1"); // 只查询在籍学生
+        lqw.orderByAsc(XgStudentMsg::getStudentNo); // 按学号排序
+        return baseMapper.selectVoList(lqw);
+    }
+
+    /**
+     * 新增学生信息管理
+     */
+    @Override
+    public Boolean insertByBo(XgStudentBo bo) {
+        XgStudentMsg add = MapstructUtils.convert(bo, XgStudentMsg.class);
+        validEntityBeforeSave(add);
+        boolean flag = baseMapper.insert(add) > 0;
+        if (flag) {
+            bo.setId(add.getId());
+        }
+        return flag;
+    }
+
+    /**
+     * 修改学生信息管理
+     */
+    @Override
+    public Boolean updateByBo(XgStudentBo bo) {
+        XgStudentMsg update = MapstructUtils.convert(bo, XgStudentMsg.class);
+        validEntityBeforeSave(update);
+        return baseMapper.updateById(update) > 0;
+    }
+
+    /**
+     * 保存前的数据校验
+     */
+    private void validEntityBeforeSave(XgStudentMsg entity) {
+        // TODO 做一些数据校验,如唯一约束
+    }
+
+    /**
+     * 批量删除学生信息管理
+     */
+    @Override
+    public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
+        if (isValid) {
+            // TODO 做一些业务上的校验,判断是否需要校验
+        }
+        return baseMapper.deleteBatchIds(ids) > 0;
+    }
+
+    private LambdaQueryWrapper<XgStudentMsg> buildQueryWrapper(XgStudentBo bo) {
+        Map<String, Object> params = bo.getParams();
+        LambdaQueryWrapper<XgStudentMsg> lqw = Wrappers.lambdaQuery();
+        lqw.like(StringUtils.isNotBlank(bo.getStudentNo()), XgStudentMsg::getStudentNo, bo.getStudentNo());
+        lqw.like(StringUtils.isNotBlank(bo.getName()), XgStudentMsg::getName, bo.getName());
+        lqw.eq(StringUtils.isNotBlank(bo.getGender()), XgStudentMsg::getGender, bo.getGender());
+        lqw.like(StringUtils.isNotBlank(bo.getPhone()), XgStudentMsg::getPhone, bo.getPhone());
+        lqw.eq(StringUtils.isNotBlank(bo.getCollegeId()), XgStudentMsg::getCollegeId, bo.getCollegeId());
+        lqw.eq(StringUtils.isNotBlank(bo.getMajorId()), XgStudentMsg::getMajorId, bo.getMajorId());
+        lqw.eq(StringUtils.isNotBlank(bo.getClassId()), XgStudentMsg::getClassId, bo.getClassId());
+        lqw.eq(StringUtils.isNotBlank(bo.getGrade()), XgStudentMsg::getGrade, bo.getGrade());
+        lqw.eq(StringUtils.isNotBlank(bo.getStudentStatus()), XgStudentMsg::getStudentStatus, bo.getStudentStatus());
+        lqw.eq(bo.getEnrollmentYear() != null, XgStudentMsg::getEnrollmentYear, bo.getEnrollmentYear());
+        lqw.eq(bo.getEnrollmentDate() != null, XgStudentMsg::getEnrollmentDate, bo.getEnrollmentDate());
+        return lqw;
+    }
+} 

+ 56 - 0
web/src/api/system/teacher/index.ts

@@ -0,0 +1,56 @@
+import request from '@/utils/request';
+import { AxiosPromise } from 'axios';
+import { TeacherVO, TeacherQuery, DeptTreeNode } from './types';
+import { TableDataInfo } from '@/api/types';
+
+/**
+ * 查询教师信息列表
+ */
+export function listTeacher(query: TeacherQuery): AxiosPromise<TableDataInfo<TeacherVO>> {
+  return request({
+    url: '/system/teacher/list',
+    method: 'get',
+    params: query
+  });
+}
+
+/**
+ * 获取教师详细信息
+ */
+export function getTeacher(userId: string | number): AxiosPromise<TeacherVO> {
+  return request({
+    url: '/system/teacher/' + userId,
+    method: 'get'
+  });
+}
+
+/**
+ * 根据姓名搜索教师
+ */
+export function searchTeacherByName(name: string, limit = 10): AxiosPromise<TeacherVO[]> {
+  return request({
+    url: '/system/teacher/search',
+    method: 'get',
+    params: { name, limit }
+  });
+}
+
+/**
+ * 获取部门树形结构
+ */
+export function getDeptTreeData(): AxiosPromise<DeptTreeNode[]> {
+  return request({
+    url: '/system/teacher/deptTree',
+    method: 'get'
+  });
+}
+
+/**
+ * 根据部门ID获取教师列表
+ */
+export function getTeachersByDeptId(deptId: string | number): AxiosPromise<TeacherVO[]> {
+  return request({
+    url: '/system/teacher/dept/' + deptId,
+    method: 'get'
+  });
+}

+ 111 - 0
web/src/api/system/teacher/types.ts

@@ -0,0 +1,111 @@
+import { PageQuery } from '@/api/types';
+
+/**
+ * 教师信息视图对象
+ */
+export interface TeacherVO {
+  /**
+   * 用户ID
+   */
+  userId: string | number;
+
+  /**
+   * 用户账号
+   */
+  userName: string;
+
+  /**
+   * 用户昵称(教师姓名)
+   */
+  nickName: string;
+
+  /**
+   * 手机号码
+   */
+  phonenumber: string;
+
+  /**
+   * 用户邮箱
+   */
+  email: string;
+
+  /**
+   * 部门ID
+   */
+  deptId: string | number;
+
+  /**
+   * 部门名称
+   */
+  deptName: string;
+
+  /**
+   * 用户性别
+   */
+  sex: string;
+
+  /**
+   * 帐号状态
+   */
+  status: string;
+
+  /**
+   * 创建时间
+   */
+  createTime: string;
+}
+
+/**
+ * 教师查询对象
+ */
+export interface TeacherQuery extends PageQuery {
+  /**
+   * 用户昵称(教师姓名)
+   */
+  nickName?: string;
+
+  /**
+   * 用户账号
+   */
+  userName?: string;
+
+  /**
+   * 手机号码
+   */
+  phonenumber?: string;
+
+  /**
+   * 部门ID
+   */
+  deptId?: string | number;
+
+  /**
+   * 帐号状态
+   */
+  status?: string;
+}
+
+/**
+ * 部门树形节点
+ */
+export interface DeptTreeNode {
+  /**
+   * 部门ID
+   */
+  id: string | number;
+
+  /**
+   * 部门名称
+   */
+  label: string;
+
+  /**
+   * 子部门
+   */
+  children?: DeptTreeNode[];
+
+  /**
+   * 教师数量
+   */
+  teacherCount?: number;
+}

+ 56 - 0
web/src/api/xg/student/index.ts

@@ -0,0 +1,56 @@
+import request from '@/utils/request';
+import { AxiosPromise } from 'axios';
+import { StudentVO, StudentQuery, StudentTreeNode } from './types';
+import { TableDataInfo } from '@/api/types';
+
+/**
+ * 查询学生信息列表
+ */
+export function listStudent(query: StudentQuery): AxiosPromise<TableDataInfo<StudentVO>> {
+  return request({
+    url: '/xg/student/list',
+    method: 'get',
+    params: query
+  });
+}
+
+/**
+ * 获取学生详细信息
+ */
+export function getStudent(id: string | number): AxiosPromise<StudentVO> {
+  return request({
+    url: '/xg/student/' + id,
+    method: 'get'
+  });
+}
+
+/**
+ * 根据姓名搜索学生
+ */
+export function searchStudentByName(name: string, limit = 10): AxiosPromise<StudentVO[]> {
+  return request({
+    url: '/xg/student/search',
+    method: 'get',
+    params: { name, limit }
+  });
+}
+
+/**
+ * 获取学院专业班级树形结构
+ */
+export function getStudentTreeData(): AxiosPromise<StudentTreeNode[]> {
+  return request({
+    url: '/xg/student/tree',
+    method: 'get'
+  });
+}
+
+/**
+ * 根据班级ID获取学生列表
+ */
+export function getStudentsByClassId(classId: string | number): AxiosPromise<StudentVO[]> {
+  return request({
+    url: '/xg/student/class/' + classId,
+    method: 'get'
+  });
+}

+ 146 - 0
web/src/api/xg/student/types.ts

@@ -0,0 +1,146 @@
+import { PageQuery } from '@/api/types';
+
+/**
+ * 学生信息视图对象
+ */
+export interface StudentVO {
+  /**
+   * 主键ID
+   */
+  id: string | number;
+
+  /**
+   * 学号
+   */
+  studentNo: string;
+
+  /**
+   * 姓名
+   */
+  name: string;
+
+  /**
+   * 性别
+   */
+  gender: string;
+
+  /**
+   * 手机号码
+   */
+  phone: string;
+
+  /**
+   * 院系ID
+   */
+  collegeId: string;
+
+  /**
+   * 院系名称
+   */
+  collegeName: string;
+
+  /**
+   * 专业ID
+   */
+  majorId: string;
+
+  /**
+   * 专业名称
+   */
+  majorName: string;
+
+  /**
+   * 班级ID
+   */
+  classId: string;
+
+  /**
+   * 班级名称
+   */
+  className: string;
+
+  /**
+   * 年级
+   */
+  grade: string;
+
+  /**
+   * 学籍状态
+   */
+  studentStatus: string;
+
+  /**
+   * 入学年份
+   */
+  enrollmentYear: number;
+}
+
+/**
+ * 学生查询对象
+ */
+export interface StudentQuery extends PageQuery {
+  /**
+   * 姓名
+   */
+  name?: string;
+
+  /**
+   * 学号
+   */
+  studentNo?: string;
+
+  /**
+   * 院系ID
+   */
+  collegeId?: string;
+
+  /**
+   * 专业ID
+   */
+  majorId?: string;
+
+  /**
+   * 班级ID
+   */
+  classId?: string;
+
+  /**
+   * 年级
+   */
+  grade?: string;
+
+  /**
+   * 学籍状态
+   */
+  studentStatus?: string;
+}
+
+/**
+ * 学院专业班级树形节点
+ */
+export interface StudentTreeNode {
+  /**
+   * 节点ID
+   */
+  id: string | number;
+
+  /**
+   * 节点名称
+   */
+  label: string;
+
+  /**
+   * 节点类型: college-学院, major-专业, class-班级
+   */
+  type: 'college' | 'major' | 'class';
+
+  /**
+   * 子节点
+   */
+  children?: StudentTreeNode[];
+
+  /**
+   * 学生数量(仅班级节点有)
+   */
+  studentCount?: number;
+}

+ 238 - 0
web/src/components/ActivitySelector/index.vue

@@ -0,0 +1,238 @@
+<template>
+  <div class="activity-selector">
+    <el-input
+      v-model="selectedActivityName"
+      placeholder="请选择活动"
+      readonly
+      @click="openSelector"
+      style="cursor: pointer"
+    >
+      <template #suffix>
+        <el-icon class="el-input__icon"><Search /></el-icon>
+      </template>
+    </el-input>
+
+    <!-- 活动选择对话框 -->
+    <el-dialog
+      v-model="dialogVisible"
+      title="选择活动"
+      width="800px"
+      append-to-body
+    >
+      <!-- 搜索框 -->
+      <div class="mb-4">
+        <el-input
+          v-model="searchKeyword"
+          placeholder="请输入活动名称搜索"
+          clearable
+          @input="handleSearch"
+          style="width: 300px"
+        >
+          <template #prefix>
+            <el-icon><Search /></el-icon>
+          </template>
+        </el-input>
+      </div>
+
+      <!-- 活动列表 -->
+      <el-table
+        v-loading="loading"
+        :data="activityList"
+        highlight-current-row
+        @current-change="handleCurrentChange"
+        style="width: 100%"
+        max-height="400px"
+      >
+        <el-table-column label="活动名称" prop="activityName" min-width="200" show-overflow-tooltip />
+        <el-table-column label="活动类型" prop="activityType" width="120" />
+        <el-table-column label="活动状态" prop="activityStatus" width="100">
+          <template #default="scope">
+            <el-tag :type="getStatusType(scope.row.activityStatus) as any">
+              {{ getStatusText(scope.row.activityStatus) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="开始时间" prop="startTime" width="160">
+          <template #default="scope">
+            <span v-if="scope.row.startTime">{{ formatDate(scope.row.startTime) }}</span>
+            <span v-else class="text-gray-400">未设置</span>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- 分页 -->
+      <div class="pagination-container mt-4">
+        <el-pagination
+          v-model:current-page="queryParams.pageNum"
+          v-model:page-size="queryParams.pageSize"
+          :total="total"
+          :page-sizes="[10, 20, 50]"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="getActivityList"
+          @current-change="getActivityList"
+        />
+      </div>
+
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="dialogVisible = false">取消</el-button>
+          <el-button type="primary" @click="confirmSelect" :disabled="!selectedActivity">确定</el-button>
+        </div>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Search } from '@element-plus/icons-vue';
+import { listStglActivity } from '@/api/stgl/activity';
+
+interface ActivityOption {
+  id: string | number;
+  activityName?: string;
+  activityType?: string;
+  activityStatus?: string;
+  startTime?: string;
+}
+
+interface Props {
+  modelValue?: string | number;
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: string | number): void;
+  (e: 'change', value: string | number): void;
+}
+
+const props = defineProps<Props>();
+const emit = defineEmits<Emits>();
+
+const dialogVisible = ref(false);
+const loading = ref(false);
+const searchKeyword = ref('');
+const activityList = ref<ActivityOption[]>([]);
+const total = ref(0);
+const selectedActivity = ref<ActivityOption | null>(null);
+const selectedActivityName = ref('');
+
+const queryParams = reactive({
+  pageNum: 1,
+  pageSize: 10,
+  activityName: ''
+});
+
+/** 打开选择器 */
+const openSelector = () => {
+  dialogVisible.value = true;
+  getActivityList();
+};
+
+/** 获取活动列表 */
+const getActivityList = async () => {
+  loading.value = true;
+  try {
+    const res = await listStglActivity(queryParams);
+    activityList.value = res.rows;
+    total.value = res.total;
+  } catch (error) {
+    console.error('获取活动列表失败:', error);
+  } finally {
+    loading.value = false;
+  }
+};
+
+/** 搜索处理 */
+const handleSearch = () => {
+  queryParams.activityName = searchKeyword.value;
+  queryParams.pageNum = 1;
+  getActivityList();
+};
+
+/** 表格当前行变化 */
+const handleCurrentChange = (val: ActivityOption) => {
+  selectedActivity.value = val;
+};
+
+/** 确认选择 */
+const confirmSelect = () => {
+  if (selectedActivity.value) {
+    const activityId = selectedActivity.value.id;
+    selectedActivityName.value = selectedActivity.value.activityName;
+    
+    emit('update:modelValue', activityId);
+    emit('change', activityId);
+    
+    dialogVisible.value = false;
+  }
+};
+
+/** 获取状态类型 */
+const getStatusType = (status: string): string => {
+  const statusMap: Record<string, string> = {
+    '1': 'info',     // 筹备中
+    '2': 'warning',  // 待审批
+    '3': 'success',  // 已通过
+    '4': 'primary',  // 进行中
+    '5': 'info',     // 已结束
+    '6': 'danger',   // 已取消
+    '7': 'danger'    // 已驳回
+  };
+  return statusMap[status] || 'info';
+};
+
+/** 获取状态文本 */
+const getStatusText = (status: string): string => {
+  const statusMap: Record<string, string> = {
+    '1': '筹备中',
+    '2': '待审批',
+    '3': '已通过',
+    '4': '进行中',
+    '5': '已结束',
+    '6': '已取消',
+    '7': '已驳回'
+  };
+  return statusMap[status] || '未知';
+};
+
+/** 格式化日期 */
+const formatDate = (dateStr: string): string => {
+  if (!dateStr) return '';
+  const date = new Date(dateStr);
+  return date.toLocaleDateString('zh-CN');
+};
+
+/** 监听外部值变化 */
+watch(() => props.modelValue, async (newVal) => {
+  if (newVal) {
+    try {
+      // 这里可以根据ID获取活动名称
+      const res = await listStglActivity({ 
+        pageNum: 1, 
+        pageSize: 1
+      });
+      if (res.rows && res.rows.length > 0) {
+        selectedActivityName.value = res.rows[0].activityName;
+      }
+    } catch (error) {
+      console.error('获取活动信息失败:', error);
+    }
+  } else {
+    selectedActivityName.value = '';
+  }
+}, { immediate: true });
+</script>
+
+<style scoped>
+.activity-selector {
+  width: 100%;
+}
+
+.pagination-container {
+  display: flex;
+  justify-content: center;
+}
+
+.dialog-footer {
+  text-align: right;
+}
+</style> 

+ 260 - 0
web/src/components/AdvisorManager/index.vue

@@ -0,0 +1,260 @@
+<template>
+  <div class="advisor-manager">
+    <div class="manager-header">
+      <span class="title">指导老师管理</span>
+      <el-button type="primary" size="small" @click="addAdvisor">
+        <el-icon><Plus /></el-icon>
+        添加指导老师
+      </el-button>
+    </div>
+
+    <div v-if="advisors.length === 0" class="empty-tip">暂无指导老师,点击上方按钮添加</div>
+
+    <div v-else class="advisor-list">
+      <div v-for="(advisor, index) in advisors" :key="`advisor-${index}-${advisor.teacher?.userId || 'new'}`" class="advisor-item">
+        <el-row :gutter="2" align="middle">
+          <el-col :span="5">
+            <el-form-item label="姓名" :prop="`advisors.${index}.name`">
+              <TeacherSelector v-model="advisor.teacher" placeholder="选择指导老师" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="联系电话" :prop="`advisors.${index}.phone`">
+              <el-input v-model="advisor.phone" placeholder="联系电话" readonly />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6" style="margin-left: -20px">
+            <el-form-item label="所在部门" :prop="`advisors.${index}.department`">
+              <el-input v-model="advisor.dept" placeholder="所在部门" readonly />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6" style="margin-left: -25px">
+            <el-form-item label="邮箱" :prop="`advisors.${index}.email`">
+              <el-input v-model="advisor.email" placeholder="邮箱" readonly />
+            </el-form-item>
+          </el-col>
+          <el-col
+            :span="1"
+            style="text-align: center; display: flex; align-items: flex-start; justify-content: center; margin-top: -14px; margin-left: 10px"
+          >
+            <el-button type="danger" size="small" circle @click="removeAdvisor(index)">
+              <el-icon><Delete /></el-icon>
+            </el-button>
+          </el-col>
+        </el-row>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, nextTick, computed } from 'vue';
+import { Plus, Delete } from '@element-plus/icons-vue';
+import TeacherSelector from '@/components/TeacherSelector/index.vue';
+import { TeacherVO } from '@/api/system/teacher/types';
+
+export interface AdvisorVO {
+  teacher: TeacherVO | null;
+  name: string;
+  phone: string;
+  dept: string;
+  email: string;
+}
+
+interface Props {
+  modelValue: AdvisorVO[];
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: AdvisorVO[]): void;
+  (e: 'change', value: AdvisorVO[]): void;
+}
+
+const props = defineProps<Props>();
+const emit = defineEmits<Emits>();
+
+const advisors = ref<AdvisorVO[]>([]);
+const updateLock = ref(false);
+
+// 计算属性:生成当前数据的hash值,用于检测变化
+const currentHash = computed(() => {
+  return JSON.stringify(
+    advisors.value.map((advisor) => ({
+      userId: advisor.teacher?.userId,
+      name: advisor.name,
+      phone: advisor.phone,
+      dept: advisor.dept,
+      email: advisor.email
+    }))
+  );
+});
+
+// 监听外部值变化
+watch(
+  () => props.modelValue,
+  (newValue) => {
+    if (!updateLock.value) {
+      updateLock.value = true;
+      nextTick(() => {
+        advisors.value = newValue.map((item) => ({ ...item }));
+        updateLock.value = false;
+      });
+    }
+  },
+  { immediate: true }
+);
+
+// 监听内部值变化 - 使用hash比较避免深度比较
+watch(currentHash, (newHash) => {
+  if (!updateLock.value) {
+    updateLock.value = true;
+    // 延迟emit,避免在TabPane环境下的立即更新冲突
+    nextTick(() => {
+      const newValue = advisors.value.map((advisor) => ({ ...advisor }));
+      emit('update:modelValue', newValue);
+      // 重新添加change事件,但添加防护机制
+      emit('change', newValue);
+      updateLock.value = false;
+    });
+  }
+});
+
+// 添加指导老师
+const addAdvisor = () => {
+  // 使用函数式更新,确保响应性
+  advisors.value = [
+    ...advisors.value,
+    {
+      teacher: null,
+      name: '',
+      phone: '',
+      dept: '',
+      email: ''
+    }
+  ];
+};
+
+// 删除指导老师
+const removeAdvisor = (index: number) => {
+  // 使用函数式更新,确保响应性
+  advisors.value = advisors.value.filter((_, i) => i !== index);
+};
+
+// 监听teacher选择变化,自动填充相关信息
+watch(
+  () => advisors.value.map((advisor) => advisor.teacher),
+  (newTeachers, oldTeachers) => {
+    console.log('AdvisorManager - teacher选择变化监听器触发');
+    console.log('newTeachers:', newTeachers);
+    console.log('oldTeachers:', oldTeachers);
+
+    newTeachers.forEach((teacher, index) => {
+      const oldTeacher = oldTeachers?.[index];
+
+      console.log(`检查第${index}个指导老师:`, {
+        newTeacher: teacher,
+        oldTeacher: oldTeacher,
+        hasChanged: teacher && (!oldTeacher || teacher.userId !== oldTeacher?.userId)
+      });
+
+      // 检查是否是新选择的老师或者老师发生了变化
+      if (teacher && (!oldTeacher || teacher.userId !== oldTeacher.userId)) {
+        console.log('选择了新的指导老师:', teacher);
+        const advisor = advisors.value[index];
+        if (advisor) {
+          // 自动填充老师信息
+          advisor.name = teacher.nickName || '';
+          advisor.phone = teacher.phonenumber || '';
+          advisor.dept = teacher.deptName || '';
+          advisor.email = teacher.email || '';
+
+          console.log('自动填充的信息:', {
+            name: advisor.name,
+            phone: advisor.phone,
+            dept: advisor.dept,
+            email: advisor.email
+          });
+        }
+      } else if (!teacher && oldTeacher) {
+        // 清空选择时重置所有信息
+        console.log('清空指导老师选择');
+        const advisor = advisors.value[index];
+        if (advisor) {
+          advisor.name = '';
+          advisor.phone = '';
+          advisor.dept = '';
+          advisor.email = '';
+        }
+      }
+    });
+  },
+  { deep: true, immediate: false }
+);
+</script>
+
+<style scoped>
+.advisor-manager {
+  width: 100%;
+}
+
+.manager-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+}
+
+.title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.empty-tip {
+  text-align: center;
+  color: #909399;
+  padding: 40px 0;
+  font-size: 14px;
+}
+
+.advisor-list {
+  space-y: 16px;
+}
+
+.advisor-item {
+  padding: 16px 8px;
+  background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
+  border-radius: 8px;
+  margin-bottom: 16px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+  transition: all 0.3s ease;
+}
+
+/* 让姓名字段往左移,保持宽度一致 */
+.advisor-item :deep(.el-col:first-child .el-form-item) {
+  margin-left: -65px !important;
+}
+
+.advisor-item :deep(.el-col:nth-child(2) .el-form-item) {
+  margin-left: -20px !important;
+}
+
+/* 针对联系电话字段 */
+.advisor-item :deep(.el-col:nth-child(2) .el-input) {
+  width: 120% !important;
+}
+
+/* 针对所在部门字段 */
+.advisor-item :deep(.el-col:nth-child(3) .el-input) {
+  width: 120% !important;
+}
+
+.advisor-item:hover {
+  box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
+  transform: translateY(-2px);
+}
+
+.advisor-item:last-child {
+  margin-bottom: 0;
+}
+</style>

+ 226 - 0
web/src/components/ClubSelector/index.vue

@@ -0,0 +1,226 @@
+<template>
+  <div class="club-selector">
+    <el-select 
+      :model-value="modelValue" 
+      @update:model-value="handleChange"
+      :placeholder="placeholder"
+      :clearable="clearable"
+      :disabled="disabled"
+      :size="size"
+      filterable
+      remote
+      :remote-method="remoteMethod"
+      :loading="loading"
+      value-key="uniqueKey"
+      style="width: 100%"
+    >
+      <el-option
+        v-for="option in options"
+        :key="`${option.relatedType}-${option.relatedClubId}`"
+        :label="formatOptionLabel(option)"
+        :value="option"
+      >
+        <div class="option-content">
+          <span class="option-name">{{ option.relatedClubName }}</span>
+          <span class="option-type">{{ getTypeLabel(option.relatedType) }}</span>
+        </div>
+      </el-option>
+    </el-select>
+
+    <!-- 详细选择对话框 -->
+    <el-dialog v-model="dialogVisible" title="选择社团/志愿者队伍" width="800px" append-to-body>
+      <el-tabs v-model="activeTab" type="card">
+        <el-tab-pane label="社团" name="club">
+          <el-table 
+            :data="clubOptions" 
+            @row-click="handleRowClick"
+            highlight-current-row
+            height="400"
+          >
+            <el-table-column type="selection" width="55" align="center" />
+            <el-table-column label="社团名称" prop="relatedClubName" />
+            <el-table-column label="类型" width="80" align="center">
+              <template #default>
+                <el-tag type="primary" size="small">社团</el-tag>
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-tab-pane>
+        <el-tab-pane label="志愿者队伍" name="volunteer">
+          <el-table 
+            :data="volunteerOptions" 
+            @row-click="handleRowClick"
+            highlight-current-row
+            height="400"
+          >
+            <el-table-column type="selection" width="55" align="center" />
+            <el-table-column label="队伍名称" prop="relatedClubName" />
+            <el-table-column label="类型" width="100" align="center">
+              <template #default>
+                <el-tag type="success" size="small">志愿者队伍</el-tag>
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-tab-pane>
+      </el-tabs>
+      
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="dialogVisible = false">取消</el-button>
+          <el-button type="primary" @click="confirmSelection">确定</el-button>
+        </div>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, computed } from 'vue';
+import { getClubOptions, getVolunteerTeamOptions } from '@/api/stgl/activity';
+import { ClubOption } from '@/api/stgl/activity/types';
+
+interface Props {
+  modelValue?: ClubOption | null;
+  placeholder?: string;
+  clearable?: boolean;
+  disabled?: boolean;
+  size?: 'large' | 'default' | 'small';
+  showDialog?: boolean; // 是否显示详细选择对话框
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: ClubOption | null): void;
+  (e: 'change', value: ClubOption | null): void;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  placeholder: '请选择社团/志愿者队伍',
+  clearable: true,
+  disabled: false,
+  size: 'default',
+  showDialog: false
+});
+
+const emit = defineEmits<Emits>();
+
+const loading = ref(false);
+const dialogVisible = ref(false);
+const activeTab = ref('club');
+const options = ref<ClubOption[]>([]);
+const clubOptions = ref<ClubOption[]>([]);
+const volunteerOptions = ref<ClubOption[]>([]);
+const selectedOption = ref<ClubOption | null>(null);
+
+// 格式化选项标签
+const formatOptionLabel = (option: ClubOption): string => {
+  const typeText = option.relatedType === 1 ? '社团' : '志愿者队伍';
+  return `${option.relatedClubName} (${typeText})`;
+};
+
+// 获取类型标签
+const getTypeLabel = (type: number): string => {
+  return type === 1 ? '社团' : '志愿者队伍';
+};
+
+// 处理值变化
+const handleChange = (value: ClubOption | null) => {
+  emit('update:modelValue', value);
+  emit('change', value);
+};
+
+// 远程搜索方法
+const remoteMethod = async (query: string) => {
+  if (query) {
+    loading.value = true;
+    try {
+      // 根据查询条件过滤选项
+      const filteredOptions = [...clubOptions.value, ...volunteerOptions.value].filter(option =>
+        option.relatedClubName.toLowerCase().includes(query.toLowerCase())
+      );
+      options.value = filteredOptions.map(item => ({
+        ...item,
+        uniqueKey: item.uniqueKey || `${item.relatedType}-${item.relatedClubId}`
+      }));
+    } catch (error) {
+      console.error('搜索失败:', error);
+    } finally {
+      loading.value = false;
+    }
+  } else {
+    options.value = [...clubOptions.value, ...volunteerOptions.value];
+  }
+};
+
+// 处理行点击
+const handleRowClick = (row: ClubOption) => {
+  selectedOption.value = row;
+};
+
+// 确认选择
+const confirmSelection = () => {
+  if (selectedOption.value) {
+    handleChange(selectedOption.value);
+    dialogVisible.value = false;
+  }
+};
+
+// 加载选项数据
+const loadOptions = async () => {
+  loading.value = true;
+  try {
+    const [clubRes, volunteerRes] = await Promise.all([
+      getClubOptions(),
+      getVolunteerTeamOptions()
+    ]);
+    
+    // 为每个选项添加唯一标识
+    clubOptions.value = (clubRes.data || []).map(item => ({
+      ...item,
+      uniqueKey: `${item.relatedType}-${item.relatedClubId}`
+    }));
+    volunteerOptions.value = (volunteerRes.data || []).map(item => ({
+      ...item,
+      uniqueKey: `${item.relatedType}-${item.relatedClubId}`
+    }));
+    options.value = [...clubOptions.value, ...volunteerOptions.value];
+  } catch (error) {
+    console.error('加载选项失败:', error);
+  } finally {
+    loading.value = false;
+  }
+};
+
+onMounted(() => {
+  loadOptions();
+});
+</script>
+
+<style scoped>
+.club-selector {
+  width: 100%;
+}
+
+.option-content {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  width: 100%;
+}
+
+.option-name {
+  flex: 1;
+  font-weight: 500;
+}
+
+.option-type {
+  font-size: 12px;
+  color: #909399;
+  margin-left: 8px;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+}
+</style> 

+ 331 - 0
web/src/components/CoOrganizerManager/index.vue

@@ -0,0 +1,331 @@
+<template>
+  <div class="co-organizer-manager">
+    <div class="header">
+      <span class="title">承办方信息</span>
+      <el-button type="primary" size="small" @click="handleAdd">
+        <el-icon><Plus /></el-icon>
+        添加承办方
+      </el-button>
+    </div>
+
+    <div v-if="coOrganizers.length === 0" class="empty-state">
+      <el-empty description="暂无承办方信息" :image-size="80" />
+    </div>
+
+    <div v-else class="organizer-list">
+      <div 
+        v-for="(organizer, index) in coOrganizers" 
+        :key="index"
+        class="organizer-item"
+      >
+        <div class="organizer-content">
+          <div class="organizer-info">
+            <div class="info-row">
+              <span class="label">承办方名称:</span>
+              <span class="value">{{ organizer.name || '未填写' }}</span>
+              <span class="label" v-if="organizer.contact">联系人:</span>
+              <span class="value" v-if="organizer.contact">{{ organizer.contact }}</span>
+              <span class="label" v-if="organizer.phone">联系电话:</span>
+              <span class="value" v-if="organizer.phone">{{ organizer.phone }}</span>
+            </div>
+          </div>
+          <div class="organizer-actions">
+            <el-button 
+              type="danger" 
+              circle 
+              size="small" 
+              @click="handleDelete(index)"
+              title="删除"
+            >
+              <el-icon><Delete /></el-icon>
+            </el-button>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 添加/编辑承办方对话框 -->
+    <el-dialog 
+      v-model="dialogVisible" 
+      :title="dialogTitle"
+      width="500px" 
+      append-to-body
+    >
+      <el-form 
+        ref="formRef" 
+        :model="currentOrganizer" 
+        :rules="rules" 
+        label-width="100px"
+      >
+        <el-form-item label="承办方名称" prop="name">
+          <el-input 
+            v-model="currentOrganizer.name" 
+            placeholder="请输入承办方名称" 
+            maxlength="100"
+            show-word-limit
+          />
+        </el-form-item>
+
+        <el-form-item label="联系人" prop="contact">
+          <el-input 
+            v-model="currentOrganizer.contact" 
+            placeholder="请输入联系人姓名" 
+            maxlength="50"
+          />
+        </el-form-item>
+        <el-form-item label="联系电话" prop="phone">
+          <el-input 
+            v-model="currentOrganizer.phone" 
+            placeholder="请输入联系电话" 
+            maxlength="20"
+          />
+        </el-form-item>
+      </el-form>
+      
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="handleCancel">取消</el-button>
+          <el-button type="primary" @click="handleConfirm">确定</el-button>
+        </div>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch } from 'vue';
+import { Plus, Delete } from '@element-plus/icons-vue';
+import { CoOrganizerInfo } from '@/api/stgl/activity/types';
+import type { FormInstance, FormRules } from 'element-plus';
+
+interface Props {
+  modelValue: CoOrganizerInfo[];
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: CoOrganizerInfo[]): void;
+  (e: 'change', value: CoOrganizerInfo[]): void;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  modelValue: () => []
+});
+
+const emit = defineEmits<Emits>();
+
+const dialogVisible = ref(false);
+const editIndex = ref(-1);
+const formRef = ref<FormInstance>();
+
+const coOrganizers = ref<CoOrganizerInfo[]>([...props.modelValue]);
+
+// 当前编辑的承办方
+const currentOrganizer = ref<CoOrganizerInfo>({
+  name: '',
+  contact: '',
+  phone: ''
+});
+
+// 对话框标题
+const dialogTitle = computed(() => {
+  return editIndex.value >= 0 ? '编辑承办方' : '添加承办方';
+});
+
+// 表单验证规则
+const rules: FormRules = {
+  name: [
+    { required: true, message: '请输入承办方名称', trigger: 'blur' },
+    { min: 1, max: 100, message: '承办方名称长度在1到100个字符', trigger: 'blur' }
+  ],
+
+  contact: [
+    { max: 50, message: '联系人姓名长度不能超过50个字符', trigger: 'blur' }
+  ],
+  phone: [
+    { max: 20, message: '联系电话长度不能超过20个字符', trigger: 'blur' },
+    { pattern: /^[0-9\-\s\+\(\)]*$/, message: '请输入正确的电话号码格式', trigger: 'blur' }
+  ]
+};
+
+// 监听props变化
+watch(
+  () => props.modelValue,
+  (newValue) => {
+    coOrganizers.value = [...newValue];
+  },
+  { deep: true }
+);
+
+// 监听内部数据变化,同步到父组件
+watch(
+  coOrganizers,
+  (newValue) => {
+    emit('update:modelValue', newValue);
+    emit('change', newValue);
+  },
+  { deep: true }
+);
+
+// 添加承办方
+const handleAdd = () => {
+  editIndex.value = -1;
+  currentOrganizer.value = {
+    name: '',
+    contact: '',
+    phone: ''
+  };
+  dialogVisible.value = true;
+};
+
+
+
+// 删除承办方
+const handleDelete = (index: number) => {
+  ElMessageBox.confirm('确定要删除这个承办方吗?', '提示', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    type: 'warning'
+  }).then(() => {
+    coOrganizers.value.splice(index, 1);
+    ElMessage.success('删除成功');
+  }).catch(() => {
+    // 用户取消删除
+  });
+};
+
+// 确认添加/编辑
+const handleConfirm = () => {
+  if (!formRef.value) return;
+  
+  formRef.value.validate((valid) => {
+    if (valid) {
+      // 保存当前操作类型和数据,避免状态重置后丢失
+      const isEdit = editIndex.value >= 0;
+      const currentData = { ...currentOrganizer.value };
+      const currentIndex = editIndex.value;
+      
+      // 立即关闭弹窗,避免后续操作影响弹窗状态
+      dialogVisible.value = false;
+      
+      // 重置编辑状态
+      editIndex.value = -1;
+      
+      // 重置表单
+      formRef.value?.resetFields();
+      
+      // 延迟一帧更新数据,确保弹窗已完全关闭
+      setTimeout(() => {
+        if (isEdit) {
+          // 编辑模式
+          coOrganizers.value[currentIndex] = currentData;
+          ElMessage.success('编辑成功');
+        } else {
+          // 添加模式
+          coOrganizers.value.push(currentData);
+          ElMessage.success('添加成功');
+        }
+      }, 0);
+    }
+  });
+};
+
+// 取消操作
+const handleCancel = () => {
+  // 立即关闭弹窗
+  dialogVisible.value = false;
+  
+  // 重置编辑状态
+  editIndex.value = -1;
+  
+  // 重置表单
+  formRef.value?.resetFields();
+};
+</script>
+
+<style scoped>
+.co-organizer-manager {
+  width: 100%;
+}
+
+.header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
+.title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.empty-state {
+  text-align: center;
+  padding: 40px 20px;
+  border: 1px dashed #dcdfe6;
+  border-radius: 4px;
+  background-color: #fafafa;
+}
+
+.organizer-list {
+  space-y: 12px;
+}
+
+.organizer-item {
+  border: 1px solid #e4e7ed;
+  border-radius: 6px;
+  padding: 16px;
+  background-color: #fafafa;
+  margin-bottom: 12px;
+  transition: all 0.3s ease;
+}
+
+.organizer-item:hover {
+  border-color: #409eff;
+  box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
+}
+
+.organizer-content {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+}
+
+.organizer-info {
+  flex: 1;
+}
+
+.info-row {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 16px;
+}
+
+.label {
+  font-size: 13px;
+  color: #606266;
+  margin-right: 4px;
+}
+
+.value {
+  font-size: 13px;
+  color: #303133;
+  font-weight: 500;
+  margin-right: 12px;
+}
+
+.organizer-actions {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  margin-left: 16px;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+}
+</style> 

+ 353 - 0
web/src/components/InstructorManager/index.vue

@@ -0,0 +1,353 @@
+<template>
+  <div class="instructor-manager">
+    <div class="header">
+      <span class="title">指导老师信息</span>
+      <el-button type="primary" size="small" @click="handleAdd">
+        <el-icon><Plus /></el-icon>
+        添加指导老师
+      </el-button>
+    </div>
+
+    <div v-if="instructors.length === 0" class="empty-state">
+      <el-empty description="暂无指导老师信息" :image-size="80" />
+    </div>
+
+    <div v-else class="instructor-list">
+      <div 
+        v-for="(instructor, index) in instructors" 
+        :key="index"
+        class="instructor-item"
+      >
+        <div class="instructor-content">
+          <div class="instructor-info">
+            <div class="info-row">
+              <span class="label">老师姓名:</span>
+              <span class="value">{{ instructor.name || '未填写' }}</span>
+            </div>
+            <div class="info-row" v-if="instructor.dept">
+              <span class="label">所属部门:</span>
+              <span class="value">{{ instructor.dept }}</span>
+            </div>
+            <div class="info-row" v-if="instructor.phone">
+              <span class="label">联系电话:</span>
+              <span class="value">{{ instructor.phone }}</span>
+            </div>
+            <div class="info-row" v-if="instructor.email">
+              <span class="label">邮箱:</span>
+              <span class="value">{{ instructor.email }}</span>
+            </div>
+            <div class="info-row" v-if="instructor.hours">
+              <span class="label">指导学时:</span>
+              <span class="value">{{ instructor.hours }} 学时</span>
+            </div>
+          </div>
+          <div class="instructor-actions">
+            <el-button type="primary" link size="small" @click="handleEdit(index)">
+              <el-icon><Edit /></el-icon>
+              编辑
+            </el-button>
+            <el-button type="danger" link size="small" @click="handleDelete(index)">
+              <el-icon><Delete /></el-icon>
+              删除
+            </el-button>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 添加/编辑指导老师对话框 -->
+    <el-dialog 
+      v-model="dialogVisible" 
+      :title="dialogTitle"
+      width="500px" 
+      append-to-body
+    >
+      <el-form 
+        ref="formRef" 
+        :model="currentInstructor" 
+        :rules="rules" 
+        label-width="100px"
+      >
+        <el-form-item label="老师姓名" prop="name">
+          <el-input 
+            v-model="currentInstructor.name" 
+            placeholder="请输入老师姓名" 
+            maxlength="50"
+            show-word-limit
+          />
+        </el-form-item>
+        <el-form-item label="所属部门" prop="dept">
+          <el-input 
+            v-model="currentInstructor.dept" 
+            placeholder="请输入所属部门" 
+            maxlength="100"
+          />
+        </el-form-item>
+        <el-form-item label="联系电话" prop="phone">
+          <el-input 
+            v-model="currentInstructor.phone" 
+            placeholder="请输入联系电话" 
+            maxlength="20"
+          />
+        </el-form-item>
+        <el-form-item label="邮箱" prop="email">
+          <el-input 
+            v-model="currentInstructor.email" 
+            placeholder="请输入邮箱地址" 
+            maxlength="100"
+          />
+        </el-form-item>
+        <el-form-item label="指导学时" prop="hours">
+          <el-input-number 
+            v-model="currentInstructor.hours" 
+            :min="0" 
+            :max="9999" 
+            placeholder="请输入指导学时"
+            style="width: 100%"
+          />
+        </el-form-item>
+      </el-form>
+      
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="handleCancel">取消</el-button>
+          <el-button type="primary" @click="handleConfirm">确定</el-button>
+        </div>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch } from 'vue';
+import { Plus, Edit, Delete } from '@element-plus/icons-vue';
+import { InstructorInfo } from '@/api/stgl/activity/types';
+import type { FormInstance, FormRules } from 'element-plus';
+
+interface Props {
+  modelValue: InstructorInfo[];
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: InstructorInfo[]): void;
+  (e: 'change', value: InstructorInfo[]): void;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  modelValue: () => []
+});
+
+const emit = defineEmits<Emits>();
+
+const dialogVisible = ref(false);
+const editIndex = ref(-1);
+const formRef = ref<FormInstance>();
+
+const instructors = ref<InstructorInfo[]>([...props.modelValue]);
+
+// 当前编辑的指导老师
+const currentInstructor = ref<InstructorInfo>({
+  name: '',
+  dept: '',
+  phone: '',
+  email: '',
+  hours: undefined
+});
+
+// 对话框标题
+const dialogTitle = computed(() => {
+  return editIndex.value >= 0 ? '编辑指导老师' : '添加指导老师';
+});
+
+// 表单验证规则
+const rules: FormRules = {
+  name: [
+    { required: true, message: '请输入老师姓名', trigger: 'blur' },
+    { min: 1, max: 50, message: '老师姓名长度在1到50个字符', trigger: 'blur' }
+  ],
+  dept: [
+    { max: 100, message: '所属部门长度不能超过100个字符', trigger: 'blur' }
+  ],
+  phone: [
+    { max: 20, message: '联系电话长度不能超过20个字符', trigger: 'blur' },
+    { pattern: /^[0-9\-\s\+\(\)]*$/, message: '请输入正确的电话号码格式', trigger: 'blur' }
+  ],
+  email: [
+    { max: 100, message: '邮箱长度不能超过100个字符', trigger: 'blur' },
+    { type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
+  ],
+  hours: [
+    { type: 'number', min: 0, max: 9999, message: '指导学时必须在0到9999之间', trigger: 'blur' }
+  ]
+};
+
+// 监听props变化
+watch(
+  () => props.modelValue,
+  (newValue) => {
+    instructors.value = [...newValue];
+  },
+  { deep: true }
+);
+
+// 监听内部数据变化,同步到父组件
+watch(
+  instructors,
+  (newValue) => {
+    emit('update:modelValue', newValue);
+    emit('change', newValue);
+  },
+  { deep: true }
+);
+
+// 添加指导老师
+const handleAdd = () => {
+  editIndex.value = -1;
+  currentInstructor.value = {
+    name: '',
+    dept: '',
+    phone: '',
+    email: '',
+    hours: undefined
+  };
+  dialogVisible.value = true;
+};
+
+// 编辑指导老师
+const handleEdit = (index: number) => {
+  editIndex.value = index;
+  currentInstructor.value = { ...instructors.value[index] };
+  dialogVisible.value = true;
+};
+
+// 删除指导老师
+const handleDelete = (index: number) => {
+  ElMessageBox.confirm('确定要删除这位指导老师吗?', '提示', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    type: 'warning'
+  }).then(() => {
+    instructors.value.splice(index, 1);
+    ElMessage.success('删除成功');
+  }).catch(() => {
+    // 用户取消删除
+  });
+};
+
+// 确认添加/编辑
+const handleConfirm = async () => {
+  if (!formRef.value) return;
+  
+  await formRef.value.validate((valid) => {
+    if (valid) {
+      if (editIndex.value >= 0) {
+        // 编辑模式
+        instructors.value[editIndex.value] = { ...currentInstructor.value };
+        ElMessage.success('编辑成功');
+      } else {
+        // 添加模式
+        instructors.value.push({ ...currentInstructor.value });
+        ElMessage.success('添加成功');
+      }
+      dialogVisible.value = false;
+    }
+  });
+};
+
+// 取消操作
+const handleCancel = () => {
+  dialogVisible.value = false;
+  formRef.value?.resetFields();
+};
+</script>
+
+<style scoped>
+.instructor-manager {
+  width: 100%;
+}
+
+.header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
+.title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.empty-state {
+  text-align: center;
+  padding: 40px 20px;
+  border: 1px dashed #dcdfe6;
+  border-radius: 4px;
+  background-color: #fafafa;
+}
+
+.instructor-list {
+  space-y: 12px;
+}
+
+.instructor-item {
+  border: 1px solid #e4e7ed;
+  border-radius: 6px;
+  padding: 16px;
+  background-color: #fafafa;
+  margin-bottom: 12px;
+  transition: all 0.3s ease;
+}
+
+.instructor-item:hover {
+  border-color: #409eff;
+  box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
+}
+
+.instructor-content {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+}
+
+.instructor-info {
+  flex: 1;
+}
+
+.info-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 8px;
+}
+
+.info-row:last-child {
+  margin-bottom: 0;
+}
+
+.label {
+  font-size: 13px;
+  color: #606266;
+  min-width: 80px;
+  margin-right: 8px;
+}
+
+.value {
+  font-size: 13px;
+  color: #303133;
+  font-weight: 500;
+}
+
+.instructor-actions {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  margin-left: 16px;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+}
+</style> 

+ 92 - 0
web/src/components/MaterialTypeSelector/index.vue

@@ -0,0 +1,92 @@
+<template>
+  <div class="material-type-selector">
+    <div class="type-options">
+      <el-checkbox-group v-model="selectedTypes" @change="handleChange">
+        <el-checkbox 
+          v-for="option in materialTypes" 
+          :key="option.value" 
+          :label="option.value"
+          class="type-checkbox"
+        >
+          {{ option.label }}
+        </el-checkbox>
+      </el-checkbox-group>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+interface Props {
+  modelValue?: string;
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: string): void;
+  (e: 'change', value: string): void;
+}
+
+const props = defineProps<Props>();
+const emit = defineEmits<Emits>();
+
+const { proxy } = getCurrentInstance() as ComponentInternalInstance;
+const { stgl_material_type } = toRefs<any>(
+  proxy?.useDict('stgl_material_type')
+);
+
+const selectedTypes = ref<string[]>([]);
+
+// 材料类型选项
+const materialTypes = computed(() => {
+  return stgl_material_type.value || [];
+});
+
+/** 处理选择变化 */
+const handleChange = (value: string[]) => {
+  const result = value.join(',');
+  emit('update:modelValue', result);
+  emit('change', result);
+};
+
+/** 监听外部值变化 */
+watch(() => props.modelValue, (newVal) => {
+  if (newVal) {
+    selectedTypes.value = newVal.split(',').filter(item => item.trim());
+  } else {
+    selectedTypes.value = [];
+  }
+}, { immediate: true });
+</script>
+
+<style scoped>
+.material-type-selector {
+  width: 100%;
+}
+
+.type-options {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px 20px;
+  align-items: center;
+}
+
+.type-checkbox {
+  margin: 0;
+  white-space: nowrap;
+}
+
+:deep(.el-checkbox__label) {
+  padding-left: 8px;
+  font-size: 14px;
+  color: #606266;
+}
+
+:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
+  background-color: #409eff;
+  border-color: #409eff;
+}
+
+:deep(.el-checkbox__input.is-checked + .el-checkbox__label) {
+  color: #409eff;
+  font-weight: 500;
+}
+</style> 

+ 468 - 0
web/src/components/StudentSelector/index.vue

@@ -0,0 +1,468 @@
+<template>
+  <div class="student-selector">
+    <!-- 学生选择输入框 -->
+    <el-input v-model="displayValue" :placeholder="placeholder" :disabled="disabled" clearable @input="handleSearch" @clear="handleClear">
+      <template #suffix>
+        <el-icon class="search-icon" @click="openDialog">
+          <Search />
+        </el-icon>
+      </template>
+    </el-input>
+
+    <!-- 搜索建议下拉 -->
+    <div v-if="showSuggestions && suggestions.length > 0" class="suggestions-dropdown">
+      <div v-for="student in suggestions" :key="student.id" class="suggestion-item" @click="selectStudent(student)">
+        <div class="student-info">
+          <span class="student-name">{{ student.name }}</span>
+          <span class="student-no">{{ student.studentNo }}</span>
+        </div>
+        <div class="student-detail">{{ student.collegeName }} - {{ student.majorName }} - {{ student.className }}</div>
+      </div>
+    </div>
+
+    <!-- 学生选择弹窗 -->
+    <el-dialog
+      v-model="dialogVisible"
+      title="选择学生"
+      width="800px"
+      :close-on-click-modal="false"
+      :append-to-body="true"
+      :modal-append-to-body="true"
+      destroy-on-close
+      :z-index="5000"
+    >
+      <div class="selector-content">
+        <!-- 左侧树形结构 -->
+        <div class="tree-panel">
+          <el-tree ref="treeRef" :data="treeData" :props="treeProps" node-key="id" :expand-on-click-node="false" @node-click="handleTreeNodeClick">
+            <template #default="{ node, data }">
+              <span class="tree-node">
+                <span>{{ node.label }}</span>
+                <span v-if="data.studentCount" class="student-count">({{ data.studentCount }}人)</span>
+              </span>
+            </template>
+          </el-tree>
+        </div>
+
+        <!-- 右侧学生列表 -->
+        <div class="student-panel">
+          <div v-if="!selectedClassId" class="empty-tip">请选择左侧班级查看学生列表</div>
+          <div v-else>
+            <div class="panel-header">
+              <span>{{ selectedClassName }} - 学生列表</span>
+              <el-input v-model="studentSearchText" placeholder="搜索学生姓名或学号" size="small" style="width: 200px" clearable>
+                <template #prefix>
+                  <el-icon><Search /></el-icon>
+                </template>
+              </el-input>
+            </div>
+            <div class="student-list">
+              <div
+                v-for="student in filteredStudents"
+                :key="student.id"
+                class="student-item"
+                :class="{ selected: selectedStudent?.id === student.id }"
+                @click="selectCurrentStudent(student)"
+              >
+                <div class="student-info">
+                  <span class="student-name">{{ student.name }}</span>
+                  <span class="student-no">{{ student.studentNo }}</span>
+                </div>
+                <div class="student-detail">性别:{{ student.gender === '0' ? '男' : '女' }} | 电话:{{ student.phone || '暂无' }}</div>
+              </div>
+              <div v-if="filteredStudents.length === 0" class="empty-tip">暂无学生数据</div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="closeDialog">取消</el-button>
+          <el-button type="primary" :disabled="!selectedStudent" @click="confirmSelect"> 确定 </el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
+import { ElMessage } from 'element-plus';
+import { Search } from '@element-plus/icons-vue';
+import { searchStudentByName, getStudentTreeData, getStudentsByClassId } from '@/api/xg/student';
+import { StudentVO, StudentTreeNode } from '@/api/xg/student/types';
+
+interface Props {
+  modelValue: StudentVO | null;
+  placeholder?: string;
+  disabled?: boolean;
+  showStudentNo?: boolean; // 是否在显示名称中包含学号,默认为true
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: StudentVO | null): void;
+  (e: 'change', value: StudentVO | null): void;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  placeholder: '请选择学生',
+  disabled: false,
+  showStudentNo: true
+});
+
+const emit = defineEmits<Emits>();
+
+// 响应式数据
+const displayValue = ref('');
+const suggestions = ref<StudentVO[]>([]);
+const showSuggestions = ref(false);
+const dialogVisible = ref(false);
+const treeData = ref<StudentTreeNode[]>([]);
+const selectedClassId = ref<string | number>('');
+const selectedClassName = ref('');
+const classStudents = ref<StudentVO[]>([]);
+const studentSearchText = ref('');
+const selectedStudent = ref<StudentVO | null>(null);
+const searchTimer = ref<NodeJS.Timeout | null>(null);
+
+// 计算属性
+const filteredStudents = computed(() => {
+  if (!studentSearchText.value) return classStudents.value;
+  const searchText = studentSearchText.value.toLowerCase();
+  return classStudents.value.filter(
+    (student) => student.name.toLowerCase().includes(searchText) || student.studentNo.toLowerCase().includes(searchText)
+  );
+});
+
+// 树形结构配置
+const treeProps = {
+  children: 'children',
+  label: 'label'
+};
+
+// 监听外部值变化
+watch(
+  () => props.modelValue,
+  (newValue) => {
+    if (newValue) {
+      displayValue.value = props.showStudentNo ? `${newValue.name}(${newValue.studentNo})` : newValue.name;
+    } else {
+      displayValue.value = '';
+    }
+  },
+  { immediate: true }
+);
+
+// 搜索学生
+const handleSearch = (value: string) => {
+  if (searchTimer.value) {
+    clearTimeout(searchTimer.value);
+  }
+
+  if (!value.trim()) {
+    suggestions.value = [];
+    showSuggestions.value = false;
+    return;
+  }
+
+  searchTimer.value = setTimeout(async () => {
+    try {
+      const response = await searchStudentByName(value.trim());
+      suggestions.value = response.data;
+      showSuggestions.value = true;
+    } catch (error) {
+      console.error('搜索学生失败:', error);
+    }
+  }, 300);
+};
+
+// 选择搜索建议中的学生
+const selectStudent = (student: StudentVO) => {
+  displayValue.value = props.showStudentNo ? `${student.name}(${student.studentNo})` : student.name;
+  suggestions.value = [];
+  showSuggestions.value = false;
+  emit('update:modelValue', student);
+  emit('change', student);
+};
+
+// 清除选择
+const handleClear = () => {
+  suggestions.value = [];
+  showSuggestions.value = false;
+  emit('update:modelValue', null);
+  emit('change', null);
+};
+
+// 打开弹窗
+const openDialog = () => {
+  if (props.disabled) return;
+
+  // 防止重复打开
+  if (dialogVisible.value) return;
+
+  // 先隐藏建议下拉,避免冲突
+  showSuggestions.value = false;
+
+  // 在TabPane环境下添加延迟,避免闪烁
+  const isInTabPane = document.querySelector('.el-tab-pane[aria-hidden="false"]');
+
+  if (isInTabPane) {
+    // 增加延迟时间,确保TabPane状态稳定
+    setTimeout(() => {
+      if (!dialogVisible.value) {
+        // 再次检查状态
+        dialogVisible.value = true;
+        nextTick(() => {
+          loadTreeData();
+        });
+      }
+    }, 100);
+  } else {
+    nextTick(() => {
+      dialogVisible.value = true;
+      loadTreeData();
+    });
+  }
+};
+
+// 加载树形数据
+const loadTreeData = async () => {
+  selectedStudent.value = props.modelValue || null;
+
+  // 加载树形数据
+  if (treeData.value.length === 0) {
+    try {
+      const response = await getStudentTreeData();
+      treeData.value = response.data;
+    } catch (error) {
+      ElMessage.error('加载学院专业信息失败');
+      console.error('加载树形数据失败:', error);
+    }
+  }
+};
+
+// 树节点点击
+const handleTreeNodeClick = async (data: StudentTreeNode) => {
+  if (data.type === 'class') {
+    selectedClassId.value = data.id;
+    selectedClassName.value = data.label;
+    studentSearchText.value = '';
+
+    try {
+      const response = await getStudentsByClassId(data.id);
+      classStudents.value = response.data;
+    } catch (error) {
+      ElMessage.error('加载学生列表失败');
+      console.error('加载学生列表失败:', error);
+    }
+  }
+};
+
+// 选择当前学生
+const selectCurrentStudent = (student: StudentVO) => {
+  selectedStudent.value = student;
+};
+
+// 关闭弹窗并清理状态
+const closeDialog = () => {
+  // 立即设置为false,避免延迟导致的问题
+  dialogVisible.value = false;
+
+  // 强制触发响应式更新,确保UI状态同步
+  nextTick(() => {
+    if (dialogVisible.value !== false) {
+      dialogVisible.value = false;
+    }
+  });
+};
+
+// 确认选择
+const confirmSelect = () => {
+  if (selectedStudent.value) {
+    displayValue.value = props.showStudentNo ? `${selectedStudent.value.name}(${selectedStudent.value.studentNo})` : selectedStudent.value.name;
+
+    // 先触发事件,让父组件处理数据更新
+    emit('update:modelValue', selectedStudent.value);
+    emit('change', selectedStudent.value);
+
+    // 使用nextTick确保所有响应式更新完成后再关闭弹窗
+    nextTick(() => {
+      closeDialog();
+    });
+  }
+};
+
+// 清理弹窗状态(不设置dialogVisible)
+const cleanupDialogState = () => {
+  selectedClassId.value = '';
+  selectedClassName.value = '';
+  selectedStudent.value = null;
+  classStudents.value = [];
+  studentSearchText.value = '';
+};
+
+// 监听弹窗关闭事件
+watch(dialogVisible, (newVal) => {
+  if (!newVal) {
+    // 延迟清理状态,避免关闭动画期间的闪烁
+    setTimeout(() => {
+      cleanupDialogState();
+    }, 300);
+  }
+});
+
+// 全局点击事件处理
+const handleGlobalClick = (e: Event) => {
+  const target = e.target as HTMLElement;
+  // 只在建议下拉显示时才处理点击外部事件
+  if (showSuggestions.value && !target.closest('.student-selector')) {
+    showSuggestions.value = false;
+  }
+};
+
+// 组件生命周期
+onMounted(() => {
+  document.addEventListener('click', handleGlobalClick);
+});
+
+onUnmounted(() => {
+  document.removeEventListener('click', handleGlobalClick);
+  // 清理定时器
+  if (searchTimer.value) {
+    clearTimeout(searchTimer.value);
+  }
+});
+</script>
+
+<style scoped>
+.student-selector {
+  position: relative;
+}
+
+.search-icon {
+  cursor: pointer;
+  color: #606266;
+}
+
+.search-icon:hover {
+  color: #409eff;
+}
+
+.suggestions-dropdown {
+  position: absolute;
+  top: 100%;
+  left: 0;
+  right: 0;
+  background: white;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
+  z-index: 1000;
+  max-height: 200px;
+  overflow-y: auto;
+}
+
+.suggestion-item {
+  padding: 8px 12px;
+  cursor: pointer;
+  border-bottom: 1px solid #f5f7fa;
+}
+
+.suggestion-item:hover {
+  background: #f5f7fa;
+}
+
+.suggestion-item:last-child {
+  border-bottom: none;
+}
+
+.student-info {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.student-name {
+  font-weight: 500;
+  color: #303133;
+}
+
+.student-no {
+  font-size: 12px;
+  color: #909399;
+}
+
+.student-detail {
+  font-size: 12px;
+  color: #606266;
+  margin-top: 2px;
+}
+
+.selector-content {
+  display: flex;
+  height: 400px;
+}
+
+.tree-panel {
+  width: 300px;
+  border-right: 1px solid #ebeef5;
+  padding-right: 12px;
+  overflow-y: auto;
+}
+
+.student-panel {
+  flex: 1;
+  padding-left: 12px;
+  display: flex;
+  flex-direction: column;
+}
+
+.tree-node {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+}
+
+.student-count {
+  font-size: 12px;
+  color: #909399;
+}
+
+.panel-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+  padding-bottom: 8px;
+  border-bottom: 1px solid #ebeef5;
+}
+
+.student-list {
+  flex: 1;
+  overflow-y: auto;
+}
+
+.student-item {
+  padding: 8px 12px;
+  cursor: pointer;
+  border-radius: 4px;
+  margin-bottom: 4px;
+}
+
+.student-item:hover {
+  background: #f5f7fa;
+}
+
+.student-item.selected {
+  background: #e7f4ff;
+  border: 1px solid #b3d8ff;
+}
+
+.empty-tip {
+  text-align: center;
+  color: #909399;
+  padding: 40px 0;
+}
+</style>

+ 456 - 0
web/src/components/TeacherSelector/index.vue

@@ -0,0 +1,456 @@
+<template>
+  <div class="teacher-selector">
+    <!-- 教师选择输入框 -->
+    <el-input v-model="displayValue" :placeholder="placeholder" :disabled="disabled" clearable @input="handleSearch" @clear="handleClear">
+      <template #suffix>
+        <el-icon class="search-icon" @click="openDialog">
+          <Search />
+        </el-icon>
+      </template>
+    </el-input>
+
+    <!-- 搜索建议下拉 -->
+    <div v-if="showSuggestions && suggestions.length > 0" class="suggestions-dropdown">
+      <div v-for="teacher in suggestions" :key="teacher.userId" class="suggestion-item" @click="selectTeacher(teacher)">
+        <div class="teacher-info">
+          <span class="teacher-name">{{ teacher.nickName }}</span>
+          <span class="teacher-phone">{{ teacher.phonenumber || '暂无电话' }}</span>
+        </div>
+        <div class="teacher-detail">
+          {{ teacher.deptName }}
+        </div>
+      </div>
+    </div>
+
+    <!-- 教师选择弹窗 -->
+    <el-dialog
+      v-model="dialogVisible"
+      title="选择教师"
+      width="800px"
+      :close-on-click-modal="false"
+      :append-to-body="true"
+      :modal-append-to-body="true"
+      destroy-on-close
+    >
+      <div class="selector-content">
+        <!-- 左侧部门树形结构 -->
+        <div class="tree-panel">
+          <el-tree
+            ref="treeRef"
+            :data="treeData"
+            :props="treeProps"
+            node-key="id"
+            :expand-on-click-node="false"
+            :default-expand-all="true"
+            @node-click="handleTreeNodeClick"
+          >
+            <template #default="{ node, data }">
+              <span class="tree-node">
+                <span>{{ node.label }}</span>
+                <span v-if="data.teacherCount" class="teacher-count">({{ data.teacherCount }}人)</span>
+              </span>
+            </template>
+          </el-tree>
+        </div>
+
+        <!-- 右侧教师列表 -->
+        <div class="teacher-panel">
+          <div v-if="!selectedDeptId" class="empty-tip">请选择左侧部门查看教师列表</div>
+          <div v-else>
+            <div class="panel-header">
+              <span>{{ selectedDeptName }} - 教师列表</span>
+              <el-input v-model="teacherSearchText" placeholder="搜索教师姓名或账号" size="small" style="width: 200px" clearable>
+                <template #prefix>
+                  <el-icon><Search /></el-icon>
+                </template>
+              </el-input>
+            </div>
+            <div class="teacher-list">
+              <div
+                v-for="teacher in filteredTeachers"
+                :key="teacher.userId"
+                class="teacher-item"
+                :class="{ selected: selectedTeacher?.userId === teacher.userId }"
+                @click="selectCurrentTeacher(teacher)"
+              >
+                <div class="teacher-info">
+                  <span class="teacher-name">{{ teacher.nickName }}</span>
+                  <span class="teacher-account">{{ teacher.userName }}</span>
+                </div>
+                <div class="teacher-detail">
+                  性别:{{ teacher.sex === '0' ? '男' : teacher.sex === '1' ? '女' : '未知' }} | 电话:{{ teacher.phonenumber || '暂无' }} | 邮箱:{{
+                    teacher.email || '暂无'
+                  }}
+                </div>
+              </div>
+              <div v-if="filteredTeachers.length === 0" class="empty-tip">暂无教师数据</div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="closeDialog">取消</el-button>
+          <el-button type="primary" :disabled="!selectedTeacher" @click="confirmSelect"> 确定 </el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
+import { ElMessage } from 'element-plus';
+import { Search } from '@element-plus/icons-vue';
+import { searchTeacherByName, getDeptTreeData, getTeachersByDeptId } from '@/api/system/teacher';
+import { TeacherVO, DeptTreeNode } from '@/api/system/teacher/types';
+
+interface Props {
+  modelValue?: TeacherVO | null;
+  placeholder?: string;
+  disabled?: boolean;
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: TeacherVO | null): void;
+  (e: 'change', value: TeacherVO | null): void;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  placeholder: '请输入教师姓名或点击搜索图标选择',
+  disabled: false
+});
+
+const emit = defineEmits<Emits>();
+
+// 响应式数据
+const displayValue = ref('');
+const suggestions = ref<TeacherVO[]>([]);
+const showSuggestions = ref(false);
+const dialogVisible = ref(false);
+const treeRef = ref();
+const treeData = ref<DeptTreeNode[]>([]);
+const selectedDeptId = ref<string | number>('');
+const selectedDeptName = ref('');
+const deptTeachers = ref<TeacherVO[]>([]);
+const teacherSearchText = ref('');
+const selectedTeacher = ref<TeacherVO | null>(null);
+const searchTimer = ref<NodeJS.Timeout | null>(null);
+
+// 计算属性
+const filteredTeachers = computed(() => {
+  if (!teacherSearchText.value) return deptTeachers.value;
+  const searchText = teacherSearchText.value.toLowerCase();
+  return deptTeachers.value.filter(
+    (teacher) => teacher.nickName.toLowerCase().includes(searchText) || teacher.userName.toLowerCase().includes(searchText)
+  );
+});
+
+// 树形结构配置
+const treeProps = {
+  children: 'children',
+  label: 'label'
+};
+
+// 监听外部值变化
+watch(
+  () => props.modelValue,
+  (newValue) => {
+    if (newValue) {
+      displayValue.value = newValue.nickName;
+    } else {
+      displayValue.value = '';
+    }
+  },
+  { immediate: true }
+);
+
+// 搜索教师
+const handleSearch = (value: string) => {
+  if (searchTimer.value) {
+    clearTimeout(searchTimer.value);
+  }
+
+  if (!value.trim()) {
+    suggestions.value = [];
+    showSuggestions.value = false;
+    return;
+  }
+
+  searchTimer.value = setTimeout(async () => {
+    try {
+      const response = await searchTeacherByName(value.trim());
+      suggestions.value = response.data;
+      showSuggestions.value = true;
+    } catch (error) {
+      console.error('搜索教师失败:', error);
+    }
+  }, 300);
+};
+
+// 选择搜索建议中的教师
+const selectTeacher = (teacher: TeacherVO) => {
+  console.log('TeacherSelector - selectTeacher:', teacher);
+  displayValue.value = teacher.nickName;
+  suggestions.value = [];
+  showSuggestions.value = false;
+  emit('update:modelValue', teacher);
+  emit('change', teacher);
+};
+
+// 清除选择
+const handleClear = () => {
+  suggestions.value = [];
+  showSuggestions.value = false;
+  emit('update:modelValue', null);
+  emit('change', null);
+};
+
+// 打开弹窗
+const openDialog = () => {
+  // 防止重复打开
+  if (dialogVisible.value) return;
+
+  // 先隐藏建议下拉,避免冲突
+  showSuggestions.value = false;
+
+  // 在TabPane环境下添加延迟,避免闪烁
+  const isInTabPane = document.querySelector('.el-tab-pane[aria-hidden="false"]');
+
+  if (isInTabPane) {
+    // 增加延迟时间,确保TabPane状态稳定
+    setTimeout(() => {
+      if (!dialogVisible.value) {
+        // 再次检查状态
+        dialogVisible.value = true;
+        nextTick(() => {
+          loadDeptTree();
+        });
+      }
+    }, 100);
+  } else {
+    nextTick(() => {
+      dialogVisible.value = true;
+      loadDeptTree();
+    });
+  }
+};
+
+// 加载部门树形数据
+const loadDeptTree = async () => {
+  try {
+    const response = await getDeptTreeData();
+    treeData.value = response.data;
+  } catch (error) {
+    ElMessage.error('加载部门信息失败');
+    console.error('加载树形数据失败:', error);
+  }
+};
+
+// 树节点点击
+const handleTreeNodeClick = async (data: DeptTreeNode) => {
+  selectedDeptId.value = data.id;
+  selectedDeptName.value = data.label;
+  teacherSearchText.value = '';
+
+  try {
+    const response = await getTeachersByDeptId(data.id);
+    deptTeachers.value = response.data;
+  } catch (error) {
+    ElMessage.error('加载教师列表失败');
+    console.error('加载教师列表失败:', error);
+  }
+};
+
+// 选择当前教师
+const selectCurrentTeacher = (teacher: TeacherVO) => {
+  selectedTeacher.value = teacher;
+};
+
+// 确认选择
+const confirmSelect = () => {
+  if (selectedTeacher.value) {
+    console.log('TeacherSelector - confirmSelect:', selectedTeacher.value);
+    displayValue.value = selectedTeacher.value.nickName;
+    emit('update:modelValue', selectedTeacher.value);
+    emit('change', selectedTeacher.value);
+    closeDialog();
+  }
+};
+
+// 关闭弹窗并清理状态
+const closeDialog = () => {
+  dialogVisible.value = false;
+  // 延迟清理状态,避免关闭动画期间的闪烁
+  setTimeout(() => {
+    selectedDeptId.value = '';
+    selectedDeptName.value = '';
+    selectedTeacher.value = null;
+    deptTeachers.value = [];
+    teacherSearchText.value = '';
+  }, 300);
+};
+
+// 监听弹窗关闭事件
+watch(dialogVisible, (newVal) => {
+  if (!newVal) {
+    closeDialog();
+  }
+});
+
+// 全局点击事件处理器
+const handleGlobalClick = (e: Event) => {
+  const target = e.target as HTMLElement;
+  // 只有在建议下拉显示时才处理,避免干扰弹窗
+  if (showSuggestions.value && !target.closest('.teacher-selector')) {
+    showSuggestions.value = false;
+  }
+};
+
+// 组件挂载时添加事件监听器
+onMounted(() => {
+  document.addEventListener('click', handleGlobalClick);
+});
+
+// 组件卸载时移除事件监听器
+onUnmounted(() => {
+  document.removeEventListener('click', handleGlobalClick);
+  if (searchTimer.value) {
+    clearTimeout(searchTimer.value);
+  }
+});
+</script>
+
+<style scoped>
+.teacher-selector {
+  position: relative;
+}
+
+.search-icon {
+  cursor: pointer;
+  color: #606266;
+}
+
+.search-icon:hover {
+  color: #409eff;
+}
+
+.suggestions-dropdown {
+  position: absolute;
+  top: 100%;
+  left: 0;
+  right: 0;
+  background: white;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
+  z-index: 1000;
+  max-height: 200px;
+  overflow-y: auto;
+}
+
+.suggestion-item {
+  padding: 8px 12px;
+  cursor: pointer;
+  border-bottom: 1px solid #f5f7fa;
+}
+
+.suggestion-item:hover {
+  background: #f5f7fa;
+}
+
+.suggestion-item:last-child {
+  border-bottom: none;
+}
+
+.teacher-info {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.teacher-name {
+  font-weight: 500;
+  color: #303133;
+}
+
+.teacher-phone,
+.teacher-account {
+  font-size: 12px;
+  color: #909399;
+}
+
+.teacher-detail {
+  font-size: 12px;
+  color: #606266;
+  margin-top: 2px;
+}
+
+.selector-content {
+  display: flex;
+  height: 400px;
+}
+
+.tree-panel {
+  width: 300px;
+  border-right: 1px solid #ebeef5;
+  padding-right: 12px;
+  overflow-y: auto;
+}
+
+.teacher-panel {
+  flex: 1;
+  padding-left: 12px;
+  display: flex;
+  flex-direction: column;
+}
+
+.tree-node {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+}
+
+.teacher-count {
+  font-size: 12px;
+  color: #909399;
+}
+
+.panel-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+  padding-bottom: 8px;
+  border-bottom: 1px solid #ebeef5;
+}
+
+.teacher-list {
+  flex: 1;
+  overflow-y: auto;
+}
+
+.teacher-item {
+  padding: 8px 12px;
+  cursor: pointer;
+  border-radius: 4px;
+  margin-bottom: 4px;
+}
+
+.teacher-item:hover {
+  background: #f5f7fa;
+}
+
+.teacher-item.selected {
+  background: #e7f4ff;
+  border: 1px solid #b3d8ff;
+}
+
+.empty-tip {
+  text-align: center;
+  color: #909399;
+  padding: 40px 0;
+}
+</style>

+ 210 - 0
web/src/components/TeamMemberManager/index.vue

@@ -0,0 +1,210 @@
+<template>
+  <div class="team-member-manager">
+    <div class="member-list">
+      <div v-for="(member, index) in memberList" :key="index" class="member-item">
+        <el-card shadow="hover" class="member-card">
+          <template #header>
+            <div class="card-header">
+              <span>成员 {{ index + 1 }}</span>
+              <el-button
+                type="danger"
+                size="small"
+                icon="Delete"
+                circle
+                @click="removeMember(index)"
+                :disabled="memberList.length <= 1"
+              />
+            </div>
+          </template>
+          
+          <el-form :model="member" label-width="80px" size="small">
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="姓名" required>
+                  <el-input v-model="member.studentName" placeholder="请输入姓名" clearable />
+                </el-form-item>
+              </el-col>
+              <el-col :span="12">
+                <el-form-item label="学号" required>
+                  <el-input v-model="member.studentNo" placeholder="请输入学号" clearable />
+                </el-form-item>
+              </el-col>
+            </el-row>
+            
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="性别">
+                  <el-select v-model="member.gender" placeholder="请选择性别" style="width: 100%">
+                    <el-option label="男" value="0" />
+                    <el-option label="女" value="1" />
+                  </el-select>
+                </el-form-item>
+              </el-col>
+              <el-col :span="12">
+                <el-form-item label="联系电话">
+                  <el-input v-model="member.phone" placeholder="请输入联系电话" clearable />
+                </el-form-item>
+              </el-col>
+            </el-row>
+            
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="院系">
+                  <el-input v-model="member.department" placeholder="请输入院系" clearable />
+                </el-form-item>
+              </el-col>
+              <el-col :span="12">
+                <el-form-item label="专业">
+                  <el-input v-model="member.major" placeholder="请输入专业" clearable />
+                </el-form-item>
+              </el-col>
+            </el-row>
+            
+            <el-row :gutter="20">
+              <el-col :span="12">
+                <el-form-item label="班级">
+                  <el-input v-model="member.className" placeholder="请输入班级" clearable />
+                </el-form-item>
+              </el-col>
+              <el-col :span="12">
+                <el-form-item label="邮箱">
+                  <el-input v-model="member.email" placeholder="请输入邮箱" clearable />
+                </el-form-item>
+              </el-col>
+            </el-row>
+          </el-form>
+        </el-card>
+      </div>
+    </div>
+    
+    <div class="add-member">
+      <el-button type="primary" icon="Plus" @click="addMember">添加成员</el-button>
+    </div>
+  </div>
+</template>
+
+<script setup name="TeamMemberManager" lang="ts">
+import { ref, watch, nextTick } from 'vue';
+import type { TeamMemberInfo } from '@/api/stgl/activityregisterhall/types';
+
+interface Props {
+  modelValue: TeamMemberInfo[];
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: TeamMemberInfo[]): void;
+  (e: 'change', value: TeamMemberInfo[]): void;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  modelValue: () => []
+});
+
+const emit = defineEmits<Emits>();
+
+// 成员列表
+const memberList = ref<TeamMemberInfo[]>([]);
+
+// 初始化成员列表
+const initMemberList = () => {
+  if (props.modelValue && props.modelValue.length > 0) {
+    memberList.value = [...props.modelValue];
+  } else {
+    // 默认至少有一个成员
+    memberList.value = [createEmptyMember()];
+  }
+};
+
+// 创建空成员对象
+const createEmptyMember = (): TeamMemberInfo => ({
+  userId: '',
+  studentNo: '',
+  studentName: '',
+  gender: '',
+  phone: '',
+  email: '',
+  department: '',
+  major: '',
+  className: '',
+  isLeader: 0
+});
+
+// 添加成员
+const addMember = () => {
+  memberList.value.push(createEmptyMember());
+  emitChange();
+};
+
+// 删除成员
+const removeMember = (index: number) => {
+  if (memberList.value.length > 1) {
+    memberList.value.splice(index, 1);
+    emitChange();
+  }
+};
+
+// 发送变更事件
+const emitChange = () => {
+  emit('update:modelValue', memberList.value);
+  emit('change', memberList.value);
+};
+
+// 监听成员列表变化
+watch(
+  memberList,
+  () => {
+    emitChange();
+  },
+  { deep: true }
+);
+
+// 监听外部传入的值变化
+watch(
+  () => props.modelValue,
+  (newValue) => {
+    if (JSON.stringify(newValue) !== JSON.stringify(memberList.value)) {
+      initMemberList();
+    }
+  },
+  { immediate: true }
+);
+
+// 初始化
+nextTick(() => {
+  initMemberList();
+});
+</script>
+
+<style scoped>
+.team-member-manager {
+  .member-list {
+    margin-bottom: 20px;
+    
+    .member-item {
+      margin-bottom: 16px;
+      
+      &:last-child {
+        margin-bottom: 0;
+      }
+    }
+    
+    .member-card {
+      border-radius: 8px;
+      
+      .card-header {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        font-weight: 600;
+        color: #303133;
+      }
+    }
+  }
+  
+  .add-member {
+    text-align: center;
+    padding: 16px 0;
+    border-top: 1px dashed #e4e7ed;
+  }
+}
+</style> 

+ 253 - 0
web/src/components/VicePresidentManager/index.vue

@@ -0,0 +1,253 @@
+<template>
+  <div class="vice-president-manager">
+    <div class="manager-header">
+      <span class="title">{{ title }}</span>
+      <el-button type="primary" size="small" @click="addVicePresident">
+        <el-icon><Plus /></el-icon>
+        {{ addButtonText }}
+      </el-button>
+    </div>
+
+    <div v-if="vicePresidents.length === 0" class="empty-tip">{{ emptyTip }}</div>
+
+    <div v-else class="vice-president-list">
+      <div v-for="(vp, index) in vicePresidents" :key="index" class="vice-president-item">
+        <el-row :gutter="10" align="middle">
+          <el-col :span="4">
+            <el-form-item label="姓名" :prop="`vicePresidents.${index}.name`">
+              <StudentSelector
+                v-model="vp.student"
+                :placeholder="selectPlaceholder"
+                :showStudentNo="false"
+                @change="(student) => handleStudentChange(index, student)"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="4">
+            <el-form-item label="联系方式" :prop="`vicePresidents.${index}.phone`">
+              <el-input v-model="vp.phone" placeholder="联系方式" readonly />
+            </el-form-item>
+          </el-col>
+          <el-col :span="4">
+            <el-form-item label="院系" :prop="`vicePresidents.${index}.dept`">
+              <el-input v-model="vp.dept" placeholder="院系" readonly />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="专业" :prop="`vicePresidents.${index}.major`">
+              <el-input v-model="vp.major" placeholder="专业" readonly />
+            </el-form-item>
+          </el-col>
+          <el-col :span="4">
+            <el-form-item label="班级" :prop="`vicePresidents.${index}.class`">
+              <el-input v-model="vp.class" placeholder="班级" readonly />
+            </el-form-item>
+          </el-col>
+          <el-col
+            :span="1"
+            style="text-align: center; display: flex; align-items: flex-start; justify-content: center; margin-top: -14px; margin-left: 10px"
+          >
+            <el-button type="danger" size="small" circle @click="removeVicePresident(index)">
+              <el-icon><Delete /></el-icon>
+            </el-button>
+          </el-col>
+        </el-row>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, nextTick } from 'vue';
+import { Plus, Delete } from '@element-plus/icons-vue';
+import StudentSelector from '@/components/StudentSelector/index.vue';
+import { StudentVO } from '@/api/xg/student/types';
+
+export interface VicePresidentVO {
+  student: StudentVO | null;
+  name: string;
+  dept: string;
+  major: string;
+  class: string;
+  phone: string;
+}
+
+interface Props {
+  modelValue: VicePresidentVO[];
+  title?: string;
+  addButtonText?: string;
+  emptyTip?: string;
+  selectPlaceholder?: string;
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: VicePresidentVO[]): void;
+  (e: 'change', value: VicePresidentVO[]): void;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  title: '副社长管理',
+  addButtonText: '添加副社长',
+  emptyTip: '暂无副社长,点击上方按钮添加',
+  selectPlaceholder: '选择副社长'
+});
+const emit = defineEmits<Emits>();
+
+const vicePresidents = ref<VicePresidentVO[]>([]);
+
+// 防止递归更新的锁
+const updateLock = ref(false);
+
+// 监听外部值变化
+watch(
+  () => props.modelValue,
+  (newValue) => {
+    if (updateLock.value) return;
+    updateLock.value = true;
+    vicePresidents.value = [...newValue];
+    nextTick(() => {
+      updateLock.value = false;
+    });
+  },
+  { immediate: true, deep: true }
+);
+
+// 监听内部值变化,发出change事件给父组件
+watch(
+  vicePresidents,
+  (newValue) => {
+    if (updateLock.value) return;
+    updateLock.value = true;
+    emit('change', newValue);
+    nextTick(() => {
+      updateLock.value = false;
+    });
+  },
+  { deep: true }
+);
+
+// 添加副社长
+const addVicePresident = () => {
+  vicePresidents.value.push({
+    student: null,
+    name: '',
+    dept: '',
+    major: '',
+    class: '',
+    phone: ''
+  });
+};
+
+// 删除副社长
+const removeVicePresident = (index: number) => {
+  vicePresidents.value.splice(index, 1);
+};
+
+// 学生选择变化
+const handleStudentChange = (index: number, student: StudentVO | null) => {
+  const vp = vicePresidents.value[index];
+  if (student) {
+    vp.student = student;
+    vp.name = student.name;
+    vp.dept = student.collegeName;
+    vp.major = student.majorName;
+    vp.class = student.className;
+    vp.phone = student.phone;
+  } else {
+    vp.student = null;
+    vp.name = '';
+    vp.dept = '';
+    vp.major = '';
+    vp.class = '';
+    vp.phone = '';
+  }
+};
+</script>
+
+<style scoped>
+.vice-president-manager {
+  width: 100%;
+}
+
+.manager-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+}
+
+.title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.empty-tip {
+  text-align: center;
+  color: #909399;
+  padding: 40px 0;
+  font-size: 14px;
+}
+
+.vice-president-list {
+  space-y: 16px;
+}
+
+.vice-president-item {
+  padding: 16px 8px;
+  background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
+  border-radius: 8px;
+  margin-bottom: 16px;
+  border: 1px solid #e4e7ed;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+  transition: all 0.3s ease;
+}
+
+/* 让姓名、联系方式、院系等字段往左移,保持宽度一致 */
+.vice-president-item :deep(.el-col:first-child .el-form-item) {
+  margin-left: -65px !important;
+}
+
+.vice-president-item :deep(.el-col:nth-child(2) .el-form-item) {
+  margin-left: -50px !important;
+}
+
+.vice-president-item :deep(.el-col:nth-child(3) .el-form-item) {
+  margin-left: -75px !important;
+}
+
+.vice-president-item :deep(.el-col:nth-child(4) .el-form-item) {
+  margin-left: -75px !important;
+}
+
+.vice-president-item :deep(.el-col:nth-child(5) .el-form-item) {
+  margin-left: -75px !important;
+}
+
+/* 备用选择器,确保样式生效 */
+.vice-president-item .el-col:nth-child(2) .el-form-item {
+  margin-left: -50px !important;
+}
+
+.vice-president-item .el-col:nth-child(3) .el-form-item {
+  margin-left: -75px !important;
+}
+
+.vice-president-item .el-col:nth-child(4) .el-form-item {
+  margin-left: -75px !important;
+}
+
+.vice-president-item .el-col:nth-child(5) .el-form-item {
+  margin-left: -75px !important;
+}
+
+.vice-president-item:hover {
+  border-color: #409eff;
+  box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
+  transform: translateY(-2px);
+}
+
+.vice-president-item:last-child {
+  margin-bottom: 0;
+}
+</style>