This commit is contained in:
李旭明 committed 2025-08-23 10:00:14 +08:00
1 parent 0db0a99c85
commit 8dc6aab1a6
31 files changed
+4859

No files matched your search

@@ -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));
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
@@ -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);
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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));
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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();
}
@@ -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);
}
@@ -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("清除所有学生相关缓存完成");
}
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
@@ -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
View File
@@ -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
View File
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>