Bladeren bron

提交代码

zlwu 1 jaar geleden
bovenliggende
commit
55457496b7

+ 11 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/controller/XgHolidayStayController.java

@@ -111,4 +111,15 @@ public class XgHolidayStayController extends BaseController {
     public TableDataInfo<StudentOptionVo> getStudentOptions(StudentOptionBo bo, PageQuery pageQuery) {
         return xgHolidayStayService.getStudentOptions(bo, pageQuery);
     }
+
+    /**
+     * 查询单人留宿有效数据
+     * 获取所有有效的假期留宿数据,筛选出在同一时间段内该宿舍只有一个学生留宿的记录
+     */
+    @SaCheckPermission("holidayStay:holidayStay:list")
+    @GetMapping("/singlePersonValidStay")
+    public R<List<XgHolidayStayVo>> getSinglePersonValidStay() {
+        List<XgHolidayStayVo> result = xgHolidayStayService.querySinglePersonValidStay();
+        return R.ok(result);
+    }
 }

+ 8 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/IXgHolidayStayService.java

@@ -77,4 +77,12 @@ public interface IXgHolidayStayService {
      */
     TableDataInfo<StudentOptionVo> getStudentOptions(StudentOptionBo bo, PageQuery pageQuery);
 
+    /**
+     * 查询单人留宿有效数据
+     * 获取所有有效的假期留宿数据,筛选出在同一时间段内该宿舍只有一个学生留宿的记录
+     *
+     * @return 单人留宿有效数据列表
+     */
+    List<XgHolidayStayVo> querySinglePersonValidStay();
+
 }

+ 515 - 18
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/service/impl/XgHolidayStayServiceImpl.java

@@ -22,6 +22,15 @@ import java.util.List;
 import java.util.Map;
 import java.util.Collection;
 import java.util.ArrayList;
+import java.util.stream.Collectors;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.Calendar;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import org.springframework.beans.BeanUtils;
 
 /**
  * 假期留宿管理Service业务层处理
@@ -165,30 +174,19 @@ public class XgHolidayStayServiceImpl implements IXgHolidayStayService {
 
             if (StringUtils.isNotBlank(statusType) && StringUtils.isNotBlank(currentTime)) {
                 if ("valid".equals(statusType)) {
-                    // 有效状态:当前时间在留宿开始时间和结束时间之间(包含边界)
-                    // 条件:applyStartDate <= currentTime AND applyEndDate >= currentTime
-                    // AND applyStartDate IS NOT NULL AND applyEndDate IS NOT NULL
-                    lqw.le(XgHolidayStay::getApplyStartDate, currentTime)
-                            .ge(XgHolidayStay::getApplyEndDate, currentTime)
-                            .isNotNull(XgHolidayStay::getApplyStartDate)
+                    // 有效状态:当前时间还没有超过留宿结束时间(包括预约状态)
+                    // 条件:applyEndDate >= currentTime AND applyEndDate IS NOT NULL
+                    lqw.ge(XgHolidayStay::getApplyEndDate, currentTime)
                             .isNotNull(XgHolidayStay::getApplyEndDate);
-                    log.debug(
-                            "添加有效状态过滤条件: apply_start_date <= {} AND apply_end_date >= {} AND start/end dates are not null",
-                            currentTime, currentTime);
+                    log.debug("添加有效状态过滤条件: apply_end_date >= {} AND end_date is not null", currentTime);
                 } else if ("invalid".equals(statusType)) {
-                    // 失效状态:当前时间不在留宿时间范围内
-                    // 条件:applyStartDate > currentTime OR applyEndDate < currentTime
-                    // OR applyStartDate IS NULL OR applyEndDate IS NULL
+                    // 失效状态:当前时间已超过留宿结束时间
+                    // 条件:applyEndDate < currentTime OR applyEndDate IS NULL
                     lqw.and(wrapper -> wrapper
-                            .gt(XgHolidayStay::getApplyStartDate, currentTime)
-                            .or()
                             .lt(XgHolidayStay::getApplyEndDate, currentTime)
                             .or()
-                            .isNull(XgHolidayStay::getApplyStartDate)
-                            .or()
                             .isNull(XgHolidayStay::getApplyEndDate));
-                    log.debug("添加失效状态过滤条件: apply_start_date > {} OR apply_end_date < {} OR start/end dates are null",
-                            currentTime, currentTime);
+                    log.debug("添加失效状态过滤条件: apply_end_date < {} OR end_date is null", currentTime);
                 }
             }
         }
@@ -258,4 +256,503 @@ public class XgHolidayStayServiceImpl implements IXgHolidayStayService {
         Page<StudentOptionVo> result = baseMapper.selectStudentOptions(pageQuery.build(), bo);
         return TableDataInfo.build(result);
     }
+
+    /**
+     * 查询单人留宿有效数据
+     * 获取所有有效的假期留宿数据,筛选出在同一时间段内该宿舍只有一个学生留宿的记录
+     *
+     * @return 单人留宿有效数据列表
+     */
+    @Override
+    public List<XgHolidayStayVo> querySinglePersonValidStay() {
+        // 1. 获取所有有效的假期留宿数据
+        XgHolidayStayBo bo = new XgHolidayStayBo();
+        Map<String, Object> params = bo.getParams();
+        if (params == null) {
+            params = new HashMap<>();
+            bo.setParams(params);
+        }
+
+        // 设置查询有效状态的留宿数据
+        String currentTime = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
+        params.put("statusType", "valid");
+        params.put("currentTime", currentTime);
+
+        List<XgHolidayStayVo> validStayList = queryList(bo);
+        log.info("查询到有效的假期留宿数据总数: {}", validStayList.size());
+
+        if (validStayList.isEmpty()) {
+            return new ArrayList<>();
+        }
+
+        // 2. 按房间分组,筛选出单人留宿的数据
+        List<XgHolidayStayVo> singlePersonStayList = new ArrayList<>();
+
+        // 按房间ID分组
+        Map<Long, List<XgHolidayStayVo>> roomGroupMap = validStayList.stream()
+                .filter(stay -> stay.getRoomId() != null)
+                .collect(Collectors.groupingBy(XgHolidayStayVo::getRoomId));
+
+        log.info("按房间分组后,涉及房间数量: {}", roomGroupMap.size());
+
+        // 遍历每个房间的留宿数据
+        for (Map.Entry<Long, List<XgHolidayStayVo>> entry : roomGroupMap.entrySet()) {
+            Long roomId = entry.getKey();
+            List<XgHolidayStayVo> roomStayList = entry.getValue();
+
+            log.debug("处理房间ID: {}, 该房间留宿人数: {}", roomId, roomStayList.size());
+
+            // 3. 对于每个房间,检查是否存在单人留宿的时间段
+            List<XgHolidayStayVo> singlePersonInRoom = findSinglePersonPeriodsInRoom(roomStayList);
+            singlePersonStayList.addAll(singlePersonInRoom);
+        }
+
+        log.info("筛选出单人留宿有效数据总数: {}", singlePersonStayList.size());
+
+        // 按ID倒序排序(作为时间排序的替代)
+        return singlePersonStayList.stream()
+                .sorted((a, b) -> {
+                    if (a.getId() == null && b.getId() == null)
+                        return 0;
+                    if (a.getId() == null)
+                        return 1;
+                    if (b.getId() == null)
+                        return -1;
+                    return b.getId().compareTo(a.getId());
+                })
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 检查房间内是否存在单人留宿的时间段
+     *
+     * @param roomStayList 同一房间内的留宿数据列表
+     * @return 单人留宿的数据列表
+     */
+    private List<XgHolidayStayVo> findSinglePersonPeriodsInRoom(List<XgHolidayStayVo> roomStayList) {
+        List<XgHolidayStayVo> singlePersonList = new ArrayList<>();
+
+        if (roomStayList == null || roomStayList.isEmpty()) {
+            return singlePersonList;
+        }
+
+        // 如果房间内只有一个人留宿,直接返回
+        if (roomStayList.size() == 1) {
+            singlePersonList.add(roomStayList.get(0));
+            log.debug("房间 {} 只有一个人留宿: {}", roomStayList.get(0).getRoomName(), roomStayList.get(0).getStudentName());
+            return singlePersonList;
+        }
+
+        // 按学生分组,处理每个学生的多个留宿记录
+        Map<Long, List<XgHolidayStayVo>> studentStaysMap = roomStayList.stream()
+            .collect(Collectors.groupingBy(XgHolidayStayVo::getStudentId));
+
+        // 为每个学生创建时间段
+        Map<Long, List<TimeSegment>> studentTimeSegments = new HashMap<>();
+        for (Map.Entry<Long, List<XgHolidayStayVo>> entry : studentStaysMap.entrySet()) {
+            Long studentId = entry.getKey();
+            List<XgHolidayStayVo> stays = entry.getValue();
+
+            List<TimeSegment> segments = new ArrayList<>();
+            for (XgHolidayStayVo stay : stays) {
+                if (stay.getApplyStartDate() != null && stay.getApplyEndDate() != null) {
+                    segments.add(new TimeSegment(stay.getApplyStartDate(), stay.getApplyEndDate(), studentId, stay));
+                }
+            }
+
+            // 合并同一学生的重叠时间段
+            List<TimeSegment> mergedSegments = mergeTimeSegments(segments);
+            studentTimeSegments.put(studentId, mergedSegments);
+        }
+
+        // 收集所有关键时间点
+        Set<Date> keyTimePoints = new TreeSet<>();
+        for (List<TimeSegment> segments : studentTimeSegments.values()) {
+            for (TimeSegment segment : segments) {
+                keyTimePoints.add(segment.getStartTime());
+                keyTimePoints.add(segment.getEndTime());
+            }
+        }
+
+        List<Date> sortedTimePoints = new ArrayList<>(keyTimePoints);
+        log.debug("房间 {} 关键时间点数量: {}", roomStayList.get(0).getRoomName(), sortedTimePoints.size());
+
+        // 分析每个时间段内的留宿人数
+        for (int i = 0; i < sortedTimePoints.size() - 1; i++) {
+            Date segmentStart = sortedTimePoints.get(i);
+            Date segmentEnd = sortedTimePoints.get(i + 1);
+
+            // 找出在这个时间段内留宿的所有学生
+            List<Long> studentsInSegment = new ArrayList<>();
+            Map<Long, XgHolidayStayVo> studentStayMap = new HashMap<>();
+
+            for (Map.Entry<Long, List<TimeSegment>> entry : studentTimeSegments.entrySet()) {
+                Long studentId = entry.getKey();
+                List<TimeSegment> segments = entry.getValue();
+
+                for (TimeSegment segment : segments) {
+                    // 检查时间段是否重叠(注意边界条件)
+                    if (segment.getStartTime().compareTo(segmentEnd) < 0 &&
+                        segment.getEndTime().compareTo(segmentStart) > 0) {
+                        studentsInSegment.add(studentId);
+                        studentStayMap.put(studentId, segment.getOriginalStay());
+                        break; // 一个学生在同一时间段内只能有一个记录
+                    }
+                }
+            }
+
+            // 如果只有一个学生,这是单人留宿时间段
+            if (studentsInSegment.size() == 1) {
+                Long singleStudentId = studentsInSegment.get(0);
+                XgHolidayStayVo originalStay = studentStayMap.get(singleStudentId);
+
+                // 创建单人留宿记录
+                XgHolidayStayVo singlePersonStay = createSinglePersonStayFromSegment(
+                    originalStay, segmentStart, segmentEnd);
+                singlePersonList.add(singlePersonStay);
+
+                log.debug("找到单人留宿时间段: {} ({} - {}) 学生: {}",
+                    originalStay.getRoomName(),
+                    new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(segmentStart),
+                    new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(segmentEnd),
+                    originalStay.getStudentName());
+            }
+        }
+
+        // 合并同一学生的连续单人留宿时间段
+        return mergeConsecutiveSinglePersonStays(singlePersonList);
+    }
+
+    /**
+     * 获取两个日期之间的所有日期(包含起始和结束日期)
+     *
+     * @param startDate  开始日期
+     * @param endDate    结束日期
+     * @param dateFormat 日期格式化器
+     * @return 日期字符串列表
+     */
+    private List<String> getDatesBetween(Date startDate, Date endDate, SimpleDateFormat dateFormat) {
+        List<String> dates = new ArrayList<>();
+
+        if (startDate == null || endDate == null) {
+            return dates;
+        }
+
+        Calendar calendar = Calendar.getInstance();
+        calendar.setTime(startDate);
+
+        // 只取日期部分,忽略时间
+        calendar.set(Calendar.HOUR_OF_DAY, 0);
+        calendar.set(Calendar.MINUTE, 0);
+        calendar.set(Calendar.SECOND, 0);
+        calendar.set(Calendar.MILLISECOND, 0);
+
+        Calendar endCalendar = Calendar.getInstance();
+        endCalendar.setTime(endDate);
+        endCalendar.set(Calendar.HOUR_OF_DAY, 0);
+        endCalendar.set(Calendar.MINUTE, 0);
+        endCalendar.set(Calendar.SECOND, 0);
+        endCalendar.set(Calendar.MILLISECOND, 0);
+
+        // 生成日期列表
+        while (!calendar.after(endCalendar)) {
+            dates.add(dateFormat.format(calendar.getTime()));
+            calendar.add(Calendar.DAY_OF_MONTH, 1);
+        }
+
+        return dates;
+    }
+
+    /**
+     * 创建单人留宿记录,调整时间范围为单人留宿的日期范围
+     *
+     * @param originalStay      原始留宿记录
+     * @param singlePersonDates 单人留宿的日期列表
+     * @param dateFormat        日期格式化器
+     * @return 调整后的单人留宿记录
+     */
+    private XgHolidayStayVo createSinglePersonStayRecord(XgHolidayStayVo originalStay, List<String> singlePersonDates,
+            SimpleDateFormat dateFormat) {
+        if (singlePersonDates.isEmpty()) {
+            return originalStay;
+        }
+
+        try {
+            // 排序日期列表
+            singlePersonDates.sort(String::compareTo);
+
+            // 获取单人留宿的开始和结束日期
+            String startDateStr = singlePersonDates.get(0);
+            String endDateStr = singlePersonDates.get(singlePersonDates.size() - 1);
+
+            // 克隆原始记录
+            XgHolidayStayVo singlePersonStay = new XgHolidayStayVo();
+            BeanUtils.copyProperties(originalStay, singlePersonStay);
+
+            // 调整时间范围为单人留宿的日期范围
+            Date adjustedStartDate = dateFormat.parse(startDateStr);
+            Date adjustedEndDate = dateFormat.parse(endDateStr);
+
+            // 结束日期设置为当天的23:59:59
+            Calendar endCalendar = Calendar.getInstance();
+            endCalendar.setTime(adjustedEndDate);
+            endCalendar.set(Calendar.HOUR_OF_DAY, 23);
+            endCalendar.set(Calendar.MINUTE, 59);
+            endCalendar.set(Calendar.SECOND, 59);
+            adjustedEndDate = endCalendar.getTime();
+
+            singlePersonStay.setApplyStartDate(adjustedStartDate);
+            singlePersonStay.setApplyEndDate(adjustedEndDate);
+
+            // 在备注中添加单人留宿信息
+            String originalRemark = singlePersonStay.getRemark() == null ? "" : singlePersonStay.getRemark();
+            String singlePersonInfo = String.format("【单人留宿】%s天(%s至%s)",
+                    singlePersonDates.size(), startDateStr, endDateStr);
+            singlePersonStay
+                    .setRemark(originalRemark.isEmpty() ? singlePersonInfo : originalRemark + " " + singlePersonInfo);
+
+            log.debug("为学生 {} 创建单人留宿记录: {} 天 ({} - {})",
+                    singlePersonStay.getStudentName(), singlePersonDates.size(), startDateStr, endDateStr);
+
+            return singlePersonStay;
+        } catch (Exception e) {
+            log.error("创建单人留宿记录失败", e);
+            return originalStay;
+        }
+    }
+
+    /**
+     * 检查两个留宿时间段是否重叠
+     *
+     * @param stay1 留宿记录1
+     * @param stay2 留宿记录2
+     * @return 是否重叠
+     */
+    private boolean isTimeOverlap(XgHolidayStayVo stay1, XgHolidayStayVo stay2) {
+        if (stay1.getApplyStartDate() == null || stay1.getApplyEndDate() == null ||
+                stay2.getApplyStartDate() == null || stay2.getApplyEndDate() == null) {
+            return false;
+        }
+
+        Date start1 = stay1.getApplyStartDate();
+        Date end1 = stay1.getApplyEndDate();
+        Date start2 = stay2.getApplyStartDate();
+        Date end2 = stay2.getApplyEndDate();
+
+        // 检查时间段是否重叠:两个时间段重叠当且仅当 start1 <= end2 && start2 <= end1
+        boolean overlap = start1.compareTo(end2) <= 0 && start2.compareTo(end1) <= 0;
+
+        if (overlap) {
+            log.debug("时间段重叠: {} ({} - {}) 与 {} ({} - {})",
+                    stay1.getStudentName(), start1, end1,
+                    stay2.getStudentName(), start2, end2);
+        }
+
+        return overlap;
+    }
+
+    /**
+     * 合并同一学生的重叠时间段
+     */
+    private List<TimeSegment> mergeTimeSegments(List<TimeSegment> segments) {
+        if (segments.isEmpty()) {
+            return new ArrayList<>();
+        }
+
+        // 按开始时间排序
+        segments.sort(Comparator.comparing(TimeSegment::getStartTime));
+
+        List<TimeSegment> merged = new ArrayList<>();
+        TimeSegment current = segments.get(0);
+
+        for (int i = 1; i < segments.size(); i++) {
+            TimeSegment next = segments.get(i);
+
+            // 如果时间段重叠或相邻,合并它们
+            if (current.getEndTime().compareTo(next.getStartTime()) >= 0) {
+                // 扩展当前时间段
+                if (next.getEndTime().compareTo(current.getEndTime()) > 0) {
+                    current = new TimeSegment(
+                        current.getStartTime(),
+                        next.getEndTime(),
+                        current.getStudentId(),
+                        current.getOriginalStay()
+                    );
+                }
+            } else {
+                // 不重叠,添加当前时间段到结果中
+                merged.add(current);
+                current = next;
+            }
+        }
+
+        // 添加最后一个时间段
+        merged.add(current);
+        return merged;
+    }
+
+    /**
+     * 从时间段创建单人留宿记录
+     */
+    private XgHolidayStayVo createSinglePersonStayFromSegment(XgHolidayStayVo template, Date startTime, Date endTime) {
+        XgHolidayStayVo singlePersonStay = new XgHolidayStayVo();
+
+        // 复制基本信息
+        BeanUtils.copyProperties(template, singlePersonStay);
+
+        // 设置ID为null,表示这是计算出来的记录
+        singlePersonStay.setId(null);
+
+        // 设置单人留宿的时间段
+        singlePersonStay.setApplyStartDate(startTime);
+        singlePersonStay.setApplyEndDate(endTime);
+
+        // 计算单人留宿天数
+        long timeDiff = endTime.getTime() - startTime.getTime();
+        long days = Math.max(1, timeDiff / (24 * 60 * 60 * 1000) + 1); // 至少1天
+
+        // 设置备注信息
+        String originalRemark = template.getRemark() != null ? template.getRemark() : "";
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+        String singlePersonRemark = String.format("单人留宿%d天 (%s至%s)",
+            days,
+            sdf.format(startTime),
+            sdf.format(endTime)
+        );
+
+        if (!originalRemark.isEmpty()) {
+            singlePersonStay.setRemark(originalRemark + " | " + singlePersonRemark);
+        } else {
+            singlePersonStay.setRemark(singlePersonRemark);
+        }
+
+        return singlePersonStay;
+    }
+
+    /**
+     * 合并同一学生的连续单人留宿时间段
+     */
+    private List<XgHolidayStayVo> mergeConsecutiveSinglePersonStays(List<XgHolidayStayVo> singlePersonStays) {
+        if (singlePersonStays.isEmpty()) {
+            return new ArrayList<>();
+        }
+
+        // 按学生ID和开始时间分组排序
+        Map<Long, List<XgHolidayStayVo>> studentStaysMap = singlePersonStays.stream()
+            .collect(Collectors.groupingBy(XgHolidayStayVo::getStudentId));
+
+        List<XgHolidayStayVo> mergedStays = new ArrayList<>();
+
+        for (Map.Entry<Long, List<XgHolidayStayVo>> entry : studentStaysMap.entrySet()) {
+            List<XgHolidayStayVo> stays = entry.getValue();
+
+            // 按开始时间排序
+            stays.sort(Comparator.comparing(XgHolidayStayVo::getApplyStartDate));
+
+            XgHolidayStayVo current = stays.get(0);
+
+            for (int i = 1; i < stays.size(); i++) {
+                XgHolidayStayVo next = stays.get(i);
+
+                // 如果时间段连续或重叠,合并它们
+                if (current.getApplyEndDate().compareTo(next.getApplyStartDate()) >= 0) {
+                    // 扩展当前时间段
+                    if (next.getApplyEndDate().compareTo(current.getApplyEndDate()) > 0) {
+                        current.setApplyEndDate(next.getApplyEndDate());
+                    }
+
+                    // 更新备注信息
+                    updateMergedRemark(current, next);
+                } else {
+                    // 不连续,添加当前时间段到结果中
+                    mergedStays.add(current);
+                    current = next;
+                }
+            }
+
+            // 添加最后一个时间段
+            mergedStays.add(current);
+        }
+
+        return mergedStays;
+    }
+
+    /**
+     * 更新合并后的备注信息
+     */
+    private void updateMergedRemark(XgHolidayStayVo current, XgHolidayStayVo next) {
+        // 重新计算合并后的天数
+        long timeDiff = current.getApplyEndDate().getTime() - current.getApplyStartDate().getTime();
+        long totalDays = Math.max(1, timeDiff / (24 * 60 * 60 * 1000) + 1);
+
+        // 更新备注
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+        String mergedRemark = String.format("单人留宿%d天 (%s至%s)",
+            totalDays,
+            sdf.format(current.getApplyStartDate()),
+            sdf.format(current.getApplyEndDate())
+        );
+
+        // 保留原始备注的非单人留宿信息
+        String originalPart = extractOriginalRemark(current.getRemark());
+        if (!originalPart.isEmpty()) {
+            current.setRemark(originalPart + " | " + mergedRemark);
+        } else {
+            current.setRemark(mergedRemark);
+        }
+    }
+
+    /**
+     * 提取原始备注信息(去除单人留宿相关的备注)
+     */
+    private String extractOriginalRemark(String remark) {
+        if (remark == null || remark.isEmpty()) {
+            return "";
+        }
+
+        // 如果包含单人留宿标记,提取原始部分
+        if (remark.contains(" | 单人留宿")) {
+            return remark.split(" \\| 单人留宿")[0].trim();
+        }
+
+        // 如果以单人留宿开头,返回空
+        if (remark.startsWith("单人留宿")) {
+            return "";
+        }
+
+        return remark;
+    }
+
+    /**
+     * 时间段内部类
+     */
+    private static class TimeSegment {
+        private Date startTime;
+        private Date endTime;
+        private Long studentId;
+        private XgHolidayStayVo originalStay;
+
+        public TimeSegment(Date startTime, Date endTime, Long studentId, XgHolidayStayVo originalStay) {
+            this.startTime = startTime;
+            this.endTime = endTime;
+            this.studentId = studentId;
+            this.originalStay = originalStay;
+        }
+
+        public Date getStartTime() {
+            return startTime;
+        }
+
+        public Date getEndTime() {
+            return endTime;
+        }
+
+        public Long getStudentId() {
+            return studentId;
+        }
+
+        public XgHolidayStayVo getOriginalStay() {
+            return originalStay;
+        }
+    }
 }

+ 1 - 0
docs/开发文档/API/API文档总览.md

@@ -19,6 +19,7 @@
 - [宿舍管理员授权API](./宿舍管理员授权API.md)
 - [学生管理员授权关系API](./学生管理员授权关系API.md)
 - [学生销假管理API](./学生销假管理API.md)
+- [假期留宿管理API](./假期留宿管理API.md) - **重要修复:单人留宿计算逻辑**(2025-01-03)
 
 ### 1. 用户认证登录
 

+ 12 - 0
web/src/api/xg/xgss/holidayStay/holidayStay/index-sg.ts

@@ -80,6 +80,18 @@ export const getStudentOptions = (
   });
 };
 
+/**
+ * 查询单人留宿有效数据
+ * 获取所有有效的假期留宿数据,筛选出在同一时间段内该宿舍只有一个学生留宿的记录
+ * @returns 单人留宿有效数据列表
+ */
+export const getSinglePersonValidStay = (): AxiosPromise<HolidayStayVO[]> => {
+  return request({
+    url: '/holidayStay/holidayStay/singlePersonValidStay',
+    method: 'get'
+  });
+};
+
 /**
  * 获取校区选项列表
  * @returns 校区选项列表

+ 905 - 146
web/src/views/xg/xgss/holidayStay/holidayStay/index-sg.vue

@@ -1,25 +1,28 @@
 <template>
   <div class="p-2">
-    <transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
-      <div v-show="showSearch" class="mb-[10px]">
-        <el-card shadow="hover">
-          <el-form ref="queryFormRef" :model="queryParams" :inline="true">
-            <!-- <el-form-item label="删除标志" prop="deleted">
+    <!-- Tab页组件 -->
+    <el-tabs v-model="activeTab" @tab-click="handleTabClick" class="mb-4">
+      <el-tab-pane label="全部留宿数据" name="all">
+        <transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
+          <div v-show="showSearch" class="mb-[10px]">
+            <el-card shadow="hover">
+              <el-form ref="queryFormRef" :model="queryParams" :inline="true">
+                <!-- <el-form-item label="删除标志" prop="deleted">
               <el-input v-model="queryParams.deleted" placeholder="请输入删除标志" clearable @keyup.enter="handleQuery" />
             </el-form-item>
             <el-form-item label="学生ID,关联xg_student_msg.id" prop="studentId">
               <el-input v-model="queryParams.studentId" placeholder="请输入学生ID,关联xg_student_msg.id" clearable @keyup.enter="handleQuery" />
             </el-form-item> -->
-            <el-form-item label="学生姓名" prop="studentName">
-              <el-input v-model="queryParams.studentName" placeholder="请输入学生姓名" clearable @keyup.enter="handleQuery" />
-            </el-form-item>
-            <el-form-item label="状态" prop="status">
-              <el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
-                <el-option label="有效" value="valid" />
-                <el-option label="失效" value="invalid" />
-              </el-select>
-            </el-form-item>
-            <!-- <el-form-item label="学号" prop="studentNo">
+                <el-form-item label="学生姓名" prop="studentName">
+                  <el-input v-model="queryParams.studentName" placeholder="请输入学生姓名" clearable @keyup.enter="handleQuery" />
+                </el-form-item>
+                <el-form-item label="状态" prop="status">
+                  <el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
+                    <el-option label="有效" value="valid" />
+                    <el-option label="失效" value="invalid" />
+                  </el-select>
+                </el-form-item>
+                <!-- <el-form-item label="学号" prop="studentNo">
               <el-input v-model="queryParams.studentNo" placeholder="请输入学号" clearable @keyup.enter="handleQuery" />
             </el-form-item>
             <el-form-item label="班级ID" prop="classId">
@@ -132,122 +135,327 @@
             <el-form-item label="扩展字段3" prop="extField3">
               <el-input v-model="queryParams.extField3" placeholder="请输入扩展字段3" clearable @keyup.enter="handleQuery" />
             </el-form-item> -->
-            <el-form-item>
-              <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
-              <el-button icon="Refresh" @click="resetQuery">重置</el-button>
-            </el-form-item>
-          </el-form>
-        </el-card>
-      </div>
-    </transition>
-
-    <el-card shadow="never">
-      <template #header>
-        <el-row :gutter="10" class="mb8">
-          <el-col :span="1.5">
-            <el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['holidayStay:holidayStay:add']">新增</el-button>
-          </el-col>
-          <el-col :span="1.5">
-            <el-button type="success" plain icon="Edit" :disabled="single" @click="handleUpdate()" v-hasPermi="['holidayStay:holidayStay:edit']"
-              >修改</el-button
-            >
-          </el-col>
-          <!-- <el-col :span="1.5">
+                <el-form-item>
+                  <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
+                  <el-button icon="Refresh" @click="resetQuery">重置</el-button>
+                </el-form-item>
+              </el-form>
+            </el-card>
+          </div>
+        </transition>
+
+        <el-card shadow="never">
+          <template #header>
+            <el-row :gutter="10" class="mb8">
+              <el-col :span="1.5">
+                <el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['holidayStay:holidayStay:add']">新增</el-button>
+              </el-col>
+              <el-col :span="1.5">
+                <el-button type="success" plain icon="Edit" :disabled="single" @click="handleUpdate()" v-hasPermi="['holidayStay:holidayStay:edit']"
+                  >修改</el-button
+                >
+              </el-col>
+              <!-- <el-col :span="1.5">
             <el-button type="danger" plain icon="Delete" :disabled="multiple" @click="handleDelete()" v-hasPermi="['holidayStay:holidayStay:remove']"
               >删除</el-button
             >
           </el-col> -->
-          <!-- <el-col :span="1.5">
+              <!-- <el-col :span="1.5">
             <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['holidayStay:holidayStay:export']">导出</el-button>
           </el-col> -->
-          <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
-        </el-row>
-      </template>
-
-      <el-table v-loading="loading" border :data="holidayStayList" @selection-change="handleSelectionChange">
-        <el-table-column type="selection" width="55" align="center" />
-        <el-table-column label="主键ID" align="center" prop="id" v-if="false" />
-        <el-table-column label="删除标志" align="center" prop="deleted" v-if="false" />
-        <el-table-column label="学生ID,关联xg_student_msg.id" align="center" prop="studentId" v-if="false" />
-        <el-table-column label="学生姓名" align="center" prop="studentName" />
-        <el-table-column label="学号" align="center" prop="studentNo" />
-        <el-table-column label="班级ID" align="center" prop="classId" v-if="false" />
-        <el-table-column label="班级名称" align="center" prop="className" />
-        <el-table-column label="专业ID" align="center" prop="majorId" v-if="false" />
-        <el-table-column label="专业名称" align="center" prop="majorName" />
-        <el-table-column label="学院ID" align="center" prop="collegeId" v-if="false" />
-        <el-table-column label="学院名称" align="center" prop="collegeName" />
-        <el-table-column label="校区ID,关联xg_dorm_campus.id" align="center" prop="campusId" v-if="false" />
-        <el-table-column label="校区" align="center" prop="campusName" />
-        <el-table-column label="宿舍区ID,关联xg_dorm_area.id" align="center" prop="areaId" v-if="false" />
-        <el-table-column label="宿舍区" align="center" prop="areaName" />
-        <el-table-column label="宿舍楼ID,关联xg_dorm_building.id" align="center" prop="buildingId" v-if="false" />
-        <el-table-column label="宿舍楼" align="center" prop="buildingName" />
-        <el-table-column label="房间ID,关联xg_dorm_room.id" align="center" prop="roomId" v-if="false" />
-        <el-table-column label="房间名称" align="center" prop="roomName" />
-        <el-table-column label="床位ID,关联xg_dorm_bed.id" align="center" prop="bedId" v-if="false" />
-        <el-table-column label="床位名称" align="center" prop="bedName" />
-        <el-table-column label="假期类型" align="center" prop="holidayType">
-          <template #default="scope">
-            <dict-tag :options="xg_holiday_type" :value="scope.row.holidayType" />
-          </template>
-        </el-table-column>
-        <el-table-column label="留宿开始日期" align="center" prop="applyStartDate" width="180">
-          <template #default="scope">
-            <span>{{ parseTime(scope.row.applyStartDate, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
-          </template>
-        </el-table-column>
-        <el-table-column label="留宿结束日期" align="center" prop="applyEndDate" width="180">
-          <template #default="scope">
-            <span>{{ parseTime(scope.row.applyEndDate, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
+              <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
+            </el-row>
           </template>
-        </el-table-column>
-        <el-table-column label="留宿原因" align="center" prop="stayReason" v-if="false" />
-        <el-table-column label="留宿原因类型" align="center" prop="stayReasonType" v-if="false">
-          <template #default="scope">
-            <dict-tag :options="xg_stay_reason_type" :value="scope.row.stayReasonType" />
-          </template>
-        </el-table-column>
-        <el-table-column label="学生联系电话" align="center" prop="studentPhone" />
-        <el-table-column label="紧急联系人姓名" align="center" prop="emergencyContact" v-if="false" />
-        <el-table-column label="紧急联系人电话" align="center" prop="emergencyPhone" />
-        <el-table-column label="家长同意" align="center" prop="parentConsent" v-if="false">
-          <template #default="scope">
-            <dict-tag :options="sys_yesno" :value="scope.row.parentConsent" />
-          </template>
-        </el-table-column>
-        <el-table-column label="附件OSS文件ID,多个用逗号分隔" align="center" prop="attachmentOssIds" v-if="false" />
-        <el-table-column label="扩展字段1" align="center" prop="extField1" v-if="false" />
-        <el-table-column label="扩展字段2" align="center" prop="extField2" v-if="false" />
-        <el-table-column label="扩展字段3" align="center" prop="extField3" v-if="false" />
-        <el-table-column label="备注信息" align="center" prop="remark" />
-        <el-table-column label="状态" align="center" prop="status" width="90">
-          <template #default="scope">
-            <el-tag :type="getStatusType(scope.row)" size="small">
-              {{ getStatusText(scope.row) }}
-            </el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column label="操作" align="center" class-name="small-padding fixed-width" fixed="right" width="100">
-          <template #default="scope">
-            <el-tooltip content="修改" placement="top">
-              <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['holidayStay:holidayStay:edit']"></el-button>
-            </el-tooltip>
-            <el-tooltip content="删除" placement="top">
-              <el-button
-                link
-                type="danger"
-                icon="Delete"
-                @click="handleDelete(scope.row)"
-                v-hasPermi="['holidayStay:holidayStay:remove']"
-              ></el-button>
-            </el-tooltip>
+
+          <el-table v-loading="loading" border :data="holidayStayList" @selection-change="handleSelectionChange">
+            <el-table-column type="selection" width="55" align="center" />
+            <el-table-column label="主键ID" align="center" prop="id" v-if="false" />
+            <el-table-column label="删除标志" align="center" prop="deleted" v-if="false" />
+            <el-table-column label="学生ID,关联xg_student_msg.id" align="center" prop="studentId" v-if="false" />
+            <el-table-column label="学生姓名" align="center" prop="studentName" />
+            <el-table-column label="学号" align="center" prop="studentNo" />
+            <el-table-column label="班级ID" align="center" prop="classId" v-if="false" />
+            <el-table-column label="班级名称" align="center" prop="className" />
+            <el-table-column label="专业ID" align="center" prop="majorId" v-if="false" />
+            <el-table-column label="专业名称" align="center" prop="majorName" />
+            <el-table-column label="学院ID" align="center" prop="collegeId" v-if="false" />
+            <el-table-column label="学院名称" align="center" prop="collegeName" />
+            <el-table-column label="校区ID,关联xg_dorm_campus.id" align="center" prop="campusId" v-if="false" />
+            <el-table-column label="校区" align="center" prop="campusName" />
+            <el-table-column label="宿舍区ID,关联xg_dorm_area.id" align="center" prop="areaId" v-if="false" />
+            <el-table-column label="宿舍区" align="center" prop="areaName" />
+            <el-table-column label="宿舍楼ID,关联xg_dorm_building.id" align="center" prop="buildingId" v-if="false" />
+            <el-table-column label="宿舍楼" align="center" prop="buildingName" />
+            <el-table-column label="房间ID,关联xg_dorm_room.id" align="center" prop="roomId" v-if="false" />
+            <el-table-column label="房间名称" align="center" prop="roomName" />
+            <el-table-column label="床位ID,关联xg_dorm_bed.id" align="center" prop="bedId" v-if="false" />
+            <el-table-column label="床位名称" align="center" prop="bedName" />
+            <el-table-column label="假期类型" align="center" prop="holidayType">
+              <template #default="scope">
+                <dict-tag :options="xg_holiday_type" :value="scope.row.holidayType" />
+              </template>
+            </el-table-column>
+            <el-table-column label="留宿开始日期" align="center" prop="applyStartDate" width="180">
+              <template #default="scope">
+                <span>{{ parseTime(scope.row.applyStartDate, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="留宿结束日期" align="center" prop="applyEndDate" width="180">
+              <template #default="scope">
+                <span>{{ parseTime(scope.row.applyEndDate, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="留宿原因" align="center" prop="stayReason" v-if="false" />
+            <el-table-column label="留宿原因类型" align="center" prop="stayReasonType" v-if="false">
+              <template #default="scope">
+                <dict-tag :options="xg_stay_reason_type" :value="scope.row.stayReasonType" />
+              </template>
+            </el-table-column>
+            <el-table-column label="学生联系电话" align="center" prop="studentPhone" />
+            <el-table-column label="紧急联系人姓名" align="center" prop="emergencyContact" v-if="false" />
+            <el-table-column label="紧急联系人电话" align="center" prop="emergencyPhone" />
+            <el-table-column label="家长同意" align="center" prop="parentConsent" v-if="false">
+              <template #default="scope">
+                <dict-tag :options="sys_yesno" :value="scope.row.parentConsent" />
+              </template>
+            </el-table-column>
+            <el-table-column label="附件OSS文件ID,多个用逗号分隔" align="center" prop="attachmentOssIds" v-if="false" />
+            <el-table-column label="扩展字段1" align="center" prop="extField1" v-if="false" />
+            <el-table-column label="扩展字段2" align="center" prop="extField2" v-if="false" />
+            <el-table-column label="扩展字段3" align="center" prop="extField3" v-if="false" />
+            <el-table-column label="备注信息" align="center" prop="remark" />
+            <el-table-column label="状态" align="center" prop="status" width="90">
+              <template #default="scope">
+                <el-tag :type="getStatusType(scope.row)" size="small">
+                  {{ getStatusText(scope.row) }}
+                </el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" align="center" class-name="small-padding fixed-width" fixed="right" width="100">
+              <template #default="scope">
+                <el-tooltip content="修改" placement="top">
+                  <el-button
+                    link
+                    type="primary"
+                    icon="Edit"
+                    @click="handleUpdate(scope.row)"
+                    v-hasPermi="['holidayStay:holidayStay:edit']"
+                  ></el-button>
+                </el-tooltip>
+                <el-tooltip content="删除" placement="top">
+                  <el-button
+                    link
+                    type="danger"
+                    icon="Delete"
+                    @click="handleDelete(scope.row)"
+                    v-hasPermi="['holidayStay:holidayStay:remove']"
+                  ></el-button>
+                </el-tooltip>
+              </template>
+            </el-table-column>
+          </el-table>
+
+          <pagination
+            v-show="total > 0"
+            :total="total"
+            v-model:page="queryParams.pageNum"
+            v-model:limit="queryParams.pageSize"
+            @pagination="getList"
+          />
+        </el-card>
+      </el-tab-pane>
+
+      <!-- 单人留宿tab页 -->
+      <el-tab-pane label="单人留宿数据" name="singlePerson">
+        <el-card shadow="never">
+          <template #header>
+            <el-row :gutter="10" class="mb8">
+              <el-col :span="24">
+                <div class="flex items-center justify-between">
+                  <div class="flex items-center gap-4">
+                    <div class="text-lg font-medium">单人留宿有效数据</div>
+                    <el-radio-group v-model="singlePersonDisplayMode" @change="handleDisplayModeChange" size="small">
+                      <el-radio-button label="list">列表视图</el-radio-button>
+                      <el-radio-button label="group">学生分组</el-radio-button>
+                    </el-radio-group>
+                  </div>
+                  <div class="text-sm text-gray-500">
+                    共 {{ singlePersonStayList.length }} 条记录 ({{ Object.keys(groupedSinglePersonStayList).length }} 名学生)
+                    <el-button type="primary" icon="Refresh" @click="getSinglePersonValidStayData" class="ml-2">刷新</el-button>
+                  </div>
+                </div>
+              </el-col>
+            </el-row>
           </template>
-        </el-table-column>
-      </el-table>
 
-      <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
-    </el-card>
+          <!-- 列表视图:单人留宿数据表格 -->
+          <el-table
+            v-if="singlePersonDisplayMode === 'list'"
+            v-loading="singlePersonLoading"
+            border
+            :data="singlePersonStayList"
+            stripe
+            style="width: 100%"
+            table-layout="fixed"
+          >
+            <el-table-column label="序号" type="index" width="60" align="center" />
+            <el-table-column label="学生信息" align="center" min-width="160">
+              <template #default="scope">
+                <div class="student-info-cell">
+                  <div class="student-name">{{ scope.row.studentName }}</div>
+                  <div class="student-details">
+                    <span class="student-no">{{ scope.row.studentNo }}</span>
+                    <span class="divider">|</span>
+                    <span class="class-name">{{ scope.row.className }}</span>
+                  </div>
+                </div>
+              </template>
+            </el-table-column>
+            <el-table-column label="宿舍信息" align="center" min-width="140">
+              <template #default="scope">
+                <div class="dorm-info-cell">
+                  <div class="room-info">
+                    <el-tag type="success" size="small">{{ scope.row.roomName }}</el-tag>
+                    <span class="bed-name">{{ scope.row.bedName }}</span>
+                  </div>
+                  <div class="building-info">
+                    <span>{{ scope.row.buildingName }}</span>
+                  </div>
+                </div>
+              </template>
+            </el-table-column>
+            <el-table-column label="单人留宿时间段" align="center" min-width="250">
+              <template #default="scope">
+                <div class="time-period-cell">
+                  <div class="time-period-main">
+                    <el-icon class="time-icon"><Clock /></el-icon>
+                    <div class="time-range">
+                      <div class="start-time">{{ parseTime(scope.row.applyStartDate, '{y}-{m}-{d} {h}:{i}') }}</div>
+                      <div class="time-arrow">→</div>
+                      <div class="end-time">{{ parseTime(scope.row.applyEndDate, '{y}-{m}-{d} {h}:{i}') }}</div>
+                    </div>
+                  </div>
+                  <!-- <div class="time-duration" v-if="scope.row.remark">
+                    <el-tag type="warning" size="small" effect="plain">
+                      {{ extractSinglePersonInfo(scope.row.remark) }}
+                    </el-tag>
+                  </div> -->
+                </div>
+              </template>
+            </el-table-column>
+            <el-table-column label="假期类型" align="center" prop="holidayType" width="100">
+              <template #default="scope">
+                <dict-tag :options="xg_holiday_type" :value="scope.row.holidayType" />
+              </template>
+            </el-table-column>
+            <el-table-column label="留宿原因" align="center" min-width="120">
+              <template #default="scope">
+                <div class="reason-cell">
+                  <dict-tag :options="xg_stay_reason_type" :value="scope.row.stayReasonType" />
+                  <div class="reason-text" v-if="scope.row.stayReason" :title="scope.row.stayReason">
+                    {{ scope.row.stayReason.length > 20 ? scope.row.stayReason.substring(0, 20) + '...' : scope.row.stayReason }}
+                  </div>
+                </div>
+              </template>
+            </el-table-column>
+            <el-table-column label="联系方式" align="center" min-width="160">
+              <template #default="scope">
+                <div class="contact-cell">
+                  <div class="phone-info">
+                    <el-icon class="phone-icon"><Phone /></el-icon>
+                    <span>{{ scope.row.studentPhone }}</span>
+                  </div>
+                  <div class="emergency-phone" v-if="scope.row.emergencyPhone">
+                    <span class="emergency-label">紧急:</span>
+                    <span>{{ scope.row.emergencyPhone }}</span>
+                  </div>
+                </div>
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" align="center" class-name="small-padding fixed-width" fixed="right" width="100">
+              <template #default="scope">
+                <el-tooltip content="查看详情" placement="top">
+                  <el-button link type="primary" icon="View" @click="handleView(scope.row)"></el-button>
+                </el-tooltip>
+              </template>
+            </el-table-column>
+          </el-table>
+
+          <!-- 分组视图:按学生分组显示 -->
+          <div v-else-if="singlePersonDisplayMode === 'group'" v-loading="singlePersonLoading">
+            <div v-for="(studentStays, studentId) in groupedSinglePersonStayList" :key="studentId" class="student-group-card">
+              <!-- 学生信息头部 -->
+              <div class="student-group-header">
+                <div class="student-basic-info">
+                  <div class="student-avatar">
+                    <el-icon class="avatar-icon"><User /></el-icon>
+                  </div>
+                  <div class="student-info">
+                    <div class="student-name-title">{{ studentStays[0].studentName }}</div>
+                    <div class="student-meta">
+                      <span class="student-no-badge">{{ studentStays[0].studentNo }}</span>
+                      <span class="class-info">{{ studentStays[0].className }}</span>
+                      <span class="college-info">{{ studentStays[0].collegeName }}</span>
+                    </div>
+                  </div>
+                </div>
+                <div class="student-stats">
+                  <el-tag type="success" size="small">{{ studentStays.length }} 个时间段</el-tag>
+                </div>
+              </div>
+
+              <!-- 单人留宿时间段列表 -->
+              <div class="time-periods-list">
+                <div v-for="(stay, index) in studentStays" :key="stay.id || index" class="time-period-item">
+                  <div class="period-index">{{ index + 1 }}</div>
+                  <div class="period-content">
+                    <div class="period-time">
+                      <div class="time-range-display">
+                        <el-icon class="time-icon"><Clock /></el-icon>
+                        <span class="start-time">{{ parseTime(stay.applyStartDate, '{y}-{m}-{d} {h}:{i}') }}</span>
+                        <span class="time-arrow">→</span>
+                        <span class="end-time">{{ parseTime(stay.applyEndDate, '{y}-{m}-{d} {h}:{i}') }}</span>
+                      </div>
+                      <!-- <div class="duration-info" v-if="stay.remark">
+                        <el-tag type="warning" size="small" effect="plain">
+                          {{ extractSinglePersonInfo(stay.remark) }}
+                        </el-tag>
+                      </div> -->
+                    </div>
+                    <div class="period-details">
+                      <div class="detail-item">
+                        <span class="detail-label">宿舍:</span>
+                        <el-tag type="success" size="small">{{ stay.roomName }}</el-tag>
+                        <span class="bed-info">{{ stay.bedName }}</span>
+                      </div>
+                      <div class="detail-item">
+                        <span class="detail-label">假期:</span>
+                        <dict-tag :options="xg_holiday_type" :value="stay.holidayType" />
+                      </div>
+                      <div class="detail-item" v-if="stay.stayReasonType">
+                        <span class="detail-label">原因:</span>
+                        <dict-tag :options="xg_stay_reason_type" :value="stay.stayReasonType" />
+                      </div>
+                    </div>
+                  </div>
+                  <div class="period-actions">
+                    <el-button type="primary" link size="small" @click="handleView(stay)">
+                      <el-icon><View /></el-icon>
+                    </el-button>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <!-- 空数据提示 -->
+          <div v-if="!singlePersonLoading && singlePersonStayList.length === 0" class="text-center py-8">
+            <el-empty description="暂无单人留宿数据" />
+          </div>
+        </el-card>
+      </el-tab-pane>
+    </el-tabs>
+
     <!-- 添加或修改假期留宿管理对话框 -->
     <el-dialog :title="dialog.title" v-model="dialog.visible" width="1000px" append-to-body>
       <el-form ref="holidayStayFormRef" :model="form" :rules="rules" label-width="120px">
@@ -578,7 +786,7 @@ import { ref, reactive, toRefs, onMounted, getCurrentInstance, ComponentInternal
 import ElMessage from 'element-plus/es/components/message/index';
 import ElMessageBox from 'element-plus/es/components/message-box/index';
 import ElLoading from 'element-plus/es/components/loading/index';
-import { UploadFilled, Document, Loading, Download, Delete } from '@element-plus/icons-vue';
+import { UploadFilled, Document, Loading, Download, Delete, Clock, Phone, User, View } from '@element-plus/icons-vue';
 import { getToken } from '@/utils/auth';
 import ImagePreview from '@/components/ImagePreview/index.vue';
 import request from '@/utils/request';
@@ -594,7 +802,8 @@ import {
   getAreaOptions,
   getBuildingOptions,
   getRoomOptions,
-  getBedOptions
+  getBedOptions,
+  getSinglePersonValidStay
 } from '@/api/xg/xgss/holidayStay/holidayStay/index-sg';
 import {
   HolidayStayVO,
@@ -615,6 +824,9 @@ const { xg_holiday_type, xg_stay_reason_type, sys_yesno } = toRefs<any>(proxy?.u
 // 用户store - 用于获取当前登录用户信息,实现权限控制
 const userStore = useUserStore();
 
+// Tab页相关状态
+const activeTab = ref('all');
+
 const holidayStayList = ref<HolidayStayVO[]>([]);
 const buttonLoading = ref(false);
 const loading = ref(true);
@@ -624,6 +836,12 @@ const single = ref(true);
 const multiple = ref(true);
 const total = ref(0);
 
+// 单人留宿数据相关状态
+const singlePersonStayList = ref<HolidayStayVO[]>([]);
+const singlePersonLoading = ref(false);
+const singlePersonDisplayMode = ref('list'); // 'list' | 'group'
+const groupedSinglePersonStayList = ref<Record<string, HolidayStayVO[]>>({});
+
 // 学生选择器相关状态
 const studentOptions = ref<StudentOptionVO[]>([]);
 const studentLoading = ref(false);
@@ -847,16 +1065,16 @@ const getList = async () => {
     const currentTime = new Date().toISOString().slice(0, 19).replace('T', ' ');
 
     if (requestParams.status === 'valid') {
-      // 查询有效状态:当前时间在留宿开始时间和结束时间之间
-      // 条件:currentTime >= applyStartDate AND currentTime <= applyEndDate
+      // 查询有效状态:当前时间还没有超过留宿结束时间(包括预约状态)
+      // 条件:currentTime <= applyEndDate
       requestParams.params = {
         ...requestParams.params,
         statusType: 'valid',
         currentTime: currentTime
       };
     } else if (requestParams.status === 'invalid') {
-      // 查询失效状态:当前时间不在留宿时间范围内
-      // 条件:currentTime < applyStartDate OR currentTime > applyEndDate
+      // 查询失效状态:当前时间已超过留宿结束时间
+      // 条件:currentTime > applyEndDate
       requestParams.params = {
         ...requestParams.params,
         statusType: 'invalid',
@@ -935,6 +1153,44 @@ const handleUpdate = async (row?: HolidayStayVO) => {
   const res = await getHolidayStay(_id);
   Object.assign(form.value, res.data);
 
+  // 确保当前学生信息在选项中(修复学生选择器显示问题)
+  if (form.value.studentId && form.value.studentName && form.value.studentNo) {
+    const currentStudent: StudentOptionVO = {
+      id: form.value.studentId,
+      name: form.value.studentName,
+      studentNo: form.value.studentNo,
+      gender: '0', // 默认值,实际不影响显示
+      phone: form.value.studentPhone,
+      collegeId: form.value.collegeId,
+      collegeName: form.value.collegeName,
+      majorId: form.value.majorId,
+      majorName: form.value.majorName,
+      classId: form.value.classId,
+      className: form.value.className,
+      dormInfo: {
+        campusId: form.value.campusId,
+        campusName: form.value.campusName,
+        areaId: form.value.areaId,
+        areaName: form.value.areaName,
+        buildingId: form.value.buildingId,
+        buildingName: form.value.buildingName,
+        roomId: form.value.roomId,
+        roomName: form.value.roomName,
+        bedId: form.value.bedId,
+        bedName: form.value.bedName
+      }
+    };
+
+    // 检查是否已经存在,避免重复添加
+    const existingStudent = studentOptions.value.find((s) => s.id === form.value.studentId);
+    if (!existingStudent) {
+      studentOptions.value = [currentStudent];
+    } else {
+      // 更新现有学生信息
+      Object.assign(existingStudent, currentStudent);
+    }
+  }
+
   // 根据已有数据级联加载宿舍选项
   await loadCascadeOptionsForUpdate(form.value);
 
@@ -1001,7 +1257,7 @@ const remoteSearchStudents = async (query: string) => {
     const res = await getStudentOptions(params);
     // 根据实际接口返回格式调整数据获取方式
     console.log('学生搜索响应:', res);
-    studentOptions.value = res.rows || [];
+    studentOptions.value = (res as any).rows || [];
 
     // 调试:检查第一个学生的数据
     if (studentOptions.value.length > 0) {
@@ -1044,7 +1300,7 @@ const loadInitialStudents = async () => {
     const res = await getStudentOptions(params);
     // 根据实际接口返回格式调整数据获取方式
     console.log('初始学生数据响应:', res);
-    studentOptions.value = res.rows || [];
+    studentOptions.value = (res as any).rows || [];
 
     // 调试:检查第一个学生的数据
     if (studentOptions.value.length > 0) {
@@ -1395,38 +1651,28 @@ const loadCascadeOptionsForUpdate = async (formData: HolidayStayForm) => {
 
 /** 获取状态文本 */
 const getStatusText = (row: HolidayStayVO) => {
-  if (!row.applyStartDate || !row.applyEndDate) {
+  if (!row.applyEndDate) {
     return '未知';
   }
 
   const currentTime = new Date();
-  const startTime = new Date(row.applyStartDate);
   const endTime = new Date(row.applyEndDate);
 
-  // 有效条件:当前时间在留宿开始时间和结束时间之间(包含边界)
-  if (currentTime >= startTime && currentTime <= endTime) {
-    return '有效';
-  } else {
-    return '失效';
-  }
+  // 有效条件:当前时间还没有超过留宿结束时间(包括预约状态)
+  return currentTime > endTime ? '失效' : '有效';
 };
 
 /** 获取状态标签类型 */
 const getStatusType = (row: HolidayStayVO) => {
-  if (!row.applyStartDate || !row.applyEndDate) {
+  if (!row.applyEndDate) {
     return 'info';
   }
 
   const currentTime = new Date();
-  const startTime = new Date(row.applyStartDate);
   const endTime = new Date(row.applyEndDate);
 
-  // 有效条件:当前时间在留宿开始时间和结束时间之间(包含边界)
-  if (currentTime >= startTime && currentTime <= endTime) {
-    return 'success'; // 有效 - 绿色
-  } else {
-    return 'danger'; // 失效 - 红色
-  }
+  // 有效条件:当前时间还没有超过留宿结束时间(包括预约状态)
+  return currentTime > endTime ? 'danger' : 'success';
 };
 
 // 文件类型判断
@@ -1634,6 +1880,7 @@ const handleAttachmentData = async () => {
 
       // 将逗号分隔的字符串转换为数组
       const ossIdsArray = form.value.attachmentOssIds
+        .toString()
         .split(',')
         .map((id) => id.trim())
         .filter((id) => id);
@@ -1681,10 +1928,127 @@ const handleAttachmentData = async () => {
   }
 };
 
+/** Tab页切换处理 */
+const handleTabClick = (tab: any) => {
+  console.log('切换到tab:', tab.name);
+  if (tab.name === 'singlePerson') {
+    // 切换到单人留宿tab时,强制刷新数据
+    console.log('切换到单人留宿tab,开始加载数据...');
+    getSinglePersonValidStayData();
+  } else if (tab.name === 'all') {
+    // 切换到全部数据tab时,刷新列表
+    getList();
+  }
+};
+
+/** 获取单人留宿有效数据 */
+const getSinglePersonValidStayData = async () => {
+  singlePersonLoading.value = true;
+  try {
+    console.log('开始获取单人留宿有效数据...');
+    const res = await getSinglePersonValidStay();
+    console.log('单人留宿数据响应:', res);
+
+    if (res.code === 200) {
+      singlePersonStayList.value = res.data || [];
+      console.log('获取到单人留宿数据:', singlePersonStayList.value.length, '条');
+
+      // 按学生分组数据
+      groupStudentStayData();
+    } else {
+      console.error('获取单人留宿数据失败:', res.msg);
+      ElMessage.error(res.msg || '获取单人留宿数据失败');
+      singlePersonStayList.value = [];
+      groupedSinglePersonStayList.value = {};
+    }
+  } catch (error) {
+    console.error('获取单人留宿数据出错:', error);
+    ElMessage.error('获取单人留宿数据失败,请稍后重试');
+    singlePersonStayList.value = [];
+  } finally {
+    singlePersonLoading.value = false;
+  }
+};
+
+/** 查看详情 */
+const handleView = (row: HolidayStayVO) => {
+  // 复用编辑对话框进行查看,但设置为只读模式
+  handleUpdate(row);
+  // 可以在这里添加只读逻辑
+  console.log('查看留宿详情:', row);
+};
+
+/** 提取单人留宿信息 */
+const extractSinglePersonInfo = (remark: string) => {
+  if (!remark) return '';
+
+  // 查找单人留宿相关信息
+  const singlePersonRegex = /单人留宿(\d+天)/;
+  const match = remark.match(singlePersonRegex);
+
+  if (match) {
+    return match[1]; // 返回天数信息,如 "3天"
+  }
+
+  // 如果没有找到标准格式,尝试查找其他格式
+  if (remark.includes('单人留宿')) {
+    const parts = remark.split('单人留宿');
+    if (parts.length > 1) {
+      const info = parts[1].trim();
+      // 提取括号内的信息
+      const bracketMatch = info.match(/\(([^)]+)\)/);
+      if (bracketMatch) {
+        return bracketMatch[1];
+      }
+      // 提取前20个字符
+      return info.length > 20 ? info.substring(0, 20) + '...' : info;
+    }
+  }
+
+  return '';
+};
+
+/** 按学生分组数据 */
+const groupStudentStayData = () => {
+  const grouped: Record<string, HolidayStayVO[]> = {};
+
+  singlePersonStayList.value.forEach((stay) => {
+    const studentId = stay.studentId?.toString() || 'unknown';
+    if (!grouped[studentId]) {
+      grouped[studentId] = [];
+    }
+    grouped[studentId].push(stay);
+  });
+
+  // 按时间排序每个学生的留宿记录
+  Object.keys(grouped).forEach((studentId) => {
+    grouped[studentId].sort((a, b) => {
+      if (!a.applyStartDate || !b.applyStartDate) return 0;
+      return new Date(a.applyStartDate).getTime() - new Date(b.applyStartDate).getTime();
+    });
+  });
+
+  groupedSinglePersonStayList.value = grouped;
+  console.log('按学生分组完成:', Object.keys(grouped).length, '名学生');
+};
+
+/** 显示模式切换处理 */
+const handleDisplayModeChange = (mode: string) => {
+  console.log('切换显示模式:', mode);
+  singlePersonDisplayMode.value = mode;
+
+  // 如果是分组模式且还没有分组数据,重新分组
+  if (mode === 'group' && Object.keys(groupedSinglePersonStayList.value).length === 0) {
+    groupStudentStayData();
+  }
+};
+
 onMounted(() => {
   getList();
   // 加载校区选项
   loadCampusOptions();
+  // 预加载单人留宿数据,确保第一次点击tab时有数据
+  getSinglePersonValidStayData();
 });
 </script>
 
@@ -1955,4 +2319,399 @@ onMounted(() => {
   cursor: not-allowed;
   background: #f5f5f5;
 }
+
+/* 新增tab页相关样式 */
+.el-tabs {
+  background: #fff;
+  border-radius: 6px;
+}
+
+/* 确保单人留宿数据表格占满宽度 */
+.el-tab-pane .el-card {
+  width: 100%;
+}
+
+.el-tab-pane .el-card .el-table {
+  width: 100% !important;
+  table-layout: fixed !important;
+}
+
+/* 单人留宿数据tab页的表格容器 */
+.el-tab-pane[aria-labelledby='tab-singlePerson'] .el-card__body {
+  padding: 20px;
+  overflow-x: auto;
+}
+
+.el-tab-pane[aria-labelledby='tab-singlePerson'] .el-table {
+  min-width: 1000px;
+  width: 100% !important;
+}
+
+.el-tabs__header {
+  margin: 0 0 20px 0;
+  padding: 0 20px;
+  background: #f8f9fa;
+  border-radius: 6px 6px 0 0;
+}
+
+.el-tabs__nav-wrap {
+  padding: 10px 0;
+}
+
+:deep(.el-tabs__nav-scroll) {
+  margin-left: 50px !important;
+}
+
+.el-tab-pane {
+  min-height: 400px;
+}
+
+/* 单人留宿数据表格样式优化 */
+.student-info-cell {
+  text-align: left;
+}
+
+.student-name {
+  font-weight: 600;
+  color: #303133;
+  font-size: 14px;
+  margin-bottom: 2px;
+}
+
+.student-details {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 12px;
+  color: #606266;
+}
+
+.student-no {
+  background: #f0f9ff;
+  color: #0369a1;
+  padding: 1px 6px;
+  border-radius: 3px;
+  font-weight: 500;
+}
+
+.divider {
+  color: #d1d5db;
+}
+
+.class-name {
+  color: #6b7280;
+}
+
+.dorm-info-cell {
+  text-align: left;
+}
+
+.room-info {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 4px;
+}
+
+.bed-name {
+  font-size: 12px;
+  color: #6b7280;
+  background: #f9fafb;
+  padding: 2px 6px;
+  border-radius: 3px;
+}
+
+.building-info {
+  font-size: 12px;
+  color: #9ca3af;
+}
+
+.time-period-cell {
+  text-align: left;
+}
+
+.time-period-main {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 6px;
+}
+
+.time-icon {
+  color: #059669;
+  font-size: 16px;
+}
+
+.time-range {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 13px;
+}
+
+.start-time,
+.end-time {
+  color: #374151;
+  font-weight: 500;
+}
+
+.time-arrow {
+  color: #9ca3af;
+  font-weight: bold;
+}
+
+.time-duration {
+  margin-left: 24px;
+}
+
+.reason-cell {
+  text-align: left;
+}
+
+.reason-text {
+  font-size: 12px;
+  color: #6b7280;
+  margin-top: 4px;
+  line-height: 1.3;
+}
+
+.contact-cell {
+  text-align: left;
+}
+
+.phone-info {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  margin-bottom: 4px;
+  font-size: 13px;
+}
+
+.phone-icon {
+  color: #3b82f6;
+  font-size: 14px;
+}
+
+.emergency-phone {
+  font-size: 12px;
+  color: #6b7280;
+}
+
+.emergency-label {
+  color: #dc2626;
+  font-weight: 500;
+  margin-right: 4px;
+}
+
+/* 学生分组视图样式 */
+.student-group-card {
+  background: #fff;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  margin-bottom: 16px;
+  overflow: hidden;
+  transition: all 0.3s ease;
+}
+
+.student-group-card:hover {
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+  border-color: #d1d5db;
+}
+
+.student-group-header {
+  background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
+  padding: 16px 20px;
+  border-bottom: 1px solid #e5e7eb;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.student-basic-info {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.student-avatar {
+  width: 40px;
+  height: 40px;
+  background: linear-gradient(135deg, #3b82f6, #1d4ed8);
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: white;
+}
+
+.avatar-icon {
+  font-size: 20px;
+}
+
+.student-info {
+  flex: 1;
+}
+
+.student-name-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #111827;
+  margin-bottom: 4px;
+}
+
+.student-meta {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 12px;
+}
+
+.student-no-badge {
+  background: #dbeafe;
+  color: #1e40af;
+  padding: 2px 8px;
+  border-radius: 12px;
+  font-weight: 500;
+}
+
+.class-info {
+  color: #6b7280;
+}
+
+.college-info {
+  color: #9ca3af;
+}
+
+.student-stats {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.time-periods-list {
+  padding: 0;
+}
+
+.time-period-item {
+  display: flex;
+  align-items: flex-start;
+  gap: 16px;
+  padding: 16px 20px;
+  border-bottom: 1px solid #f3f4f6;
+  transition: all 0.2s ease;
+}
+
+.time-period-item:last-child {
+  border-bottom: none;
+}
+
+.time-period-item:hover {
+  background: #f9fafb;
+}
+
+.period-index {
+  width: 24px;
+  height: 24px;
+  background: #e5e7eb;
+  color: #6b7280;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 12px;
+  font-weight: 600;
+  flex-shrink: 0;
+  margin-top: 4px;
+}
+
+.period-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.period-time {
+  margin-bottom: 12px;
+}
+
+.time-range-display {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 6px;
+}
+
+.time-range-display .time-icon {
+  color: #059669;
+  font-size: 16px;
+}
+
+.time-range-display .start-time,
+.time-range-display .end-time {
+  font-size: 14px;
+  font-weight: 500;
+  color: #374151;
+}
+
+.time-range-display .time-arrow {
+  color: #9ca3af;
+  font-weight: bold;
+}
+
+.duration-info {
+  margin-left: 24px;
+}
+
+.period-details {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16px;
+}
+
+.detail-item {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 13px;
+}
+
+.detail-label {
+  color: #6b7280;
+  font-weight: 500;
+  min-width: 40px;
+}
+
+.bed-info {
+  color: #9ca3af;
+  font-size: 12px;
+  margin-left: 4px;
+}
+
+.period-actions {
+  display: flex;
+  align-items: flex-start;
+  padding-top: 4px;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+  .student-group-header {
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 12px;
+  }
+
+  .time-period-item {
+    flex-direction: column;
+    gap: 12px;
+  }
+
+  .period-details {
+    flex-direction: column;
+    gap: 8px;
+  }
+
+  .period-actions {
+    align-self: flex-end;
+  }
+}
 </style>

+ 43 - 4
web/src/views/xg/xgss/holidayStay/holidayStay/index.vue

@@ -723,7 +723,7 @@ const data = reactive<PageData<HolidayStayForm, HolidayStayQuery>>({
     extField1: undefined,
     extField2: undefined,
     extField3: undefined,
-    status: undefined,
+    status: 'valid', // 默认显示有效状态的记录
     params: {}
   },
   rules: {
@@ -769,7 +769,7 @@ const getList = async () => {
   const requestParams = { ...queryParams.value };
 
   // 添加创建人过滤条件 - 班主任只能看到自己创建的数据
-  requestParams.createBy = userStore.userId;
+  (requestParams as any).createBy = userStore.userId;
 
   // 根据状态设置时间过滤条件
   if (requestParams.status) {
@@ -855,6 +855,44 @@ const handleUpdate = async (row?: HolidayStayVO) => {
   const res = await getHolidayStay(_id);
   Object.assign(form.value, res.data);
 
+  // 确保当前学生信息在选项中(修复学生选择器显示问题)
+  if (form.value.studentId && form.value.studentName && form.value.studentNo) {
+    const currentStudent: StudentOptionVO = {
+      id: form.value.studentId,
+      name: form.value.studentName,
+      studentNo: form.value.studentNo,
+      gender: '0', // 默认值,实际不影响显示
+      phone: form.value.studentPhone,
+      collegeId: form.value.collegeId,
+      collegeName: form.value.collegeName,
+      majorId: form.value.majorId,
+      majorName: form.value.majorName,
+      classId: form.value.classId,
+      className: form.value.className,
+      dormInfo: {
+        campusId: form.value.campusId,
+        campusName: form.value.campusName,
+        areaId: form.value.areaId,
+        areaName: form.value.areaName,
+        buildingId: form.value.buildingId,
+        buildingName: form.value.buildingName,
+        roomId: form.value.roomId,
+        roomName: form.value.roomName,
+        bedId: form.value.bedId,
+        bedName: form.value.bedName
+      }
+    };
+
+    // 检查是否已经存在,避免重复添加
+    const existingStudent = studentOptions.value.find((s) => s.id === form.value.studentId);
+    if (!existingStudent) {
+      studentOptions.value = [currentStudent];
+    } else {
+      // 更新现有学生信息
+      Object.assign(existingStudent, currentStudent);
+    }
+  }
+
   // 根据已有数据级联加载宿舍选项
   await loadCascadeOptionsForUpdate(form.value);
 
@@ -921,7 +959,7 @@ const remoteSearchStudents = async (query: string) => {
     const res = await getStudentOptions(params);
     // 根据实际接口返回格式调整数据获取方式
     console.log('学生搜索响应:', res);
-    studentOptions.value = res.rows || [];
+    studentOptions.value = (res as any).rows || [];
 
     // 调试:检查第一个学生的数据
     if (studentOptions.value.length > 0) {
@@ -964,7 +1002,7 @@ const loadInitialStudents = async () => {
     const res = await getStudentOptions(params);
     // 根据实际接口返回格式调整数据获取方式
     console.log('初始学生数据响应:', res);
-    studentOptions.value = res.rows || [];
+    studentOptions.value = (res as any).rows || [];
 
     // 调试:检查第一个学生的数据
     if (studentOptions.value.length > 0) {
@@ -1542,6 +1580,7 @@ const handleAttachmentData = async () => {
 
       // 将逗号分隔的字符串转换为数组
       const ossIdsArray = form.value.attachmentOssIds
+        .toString()
         .split(',')
         .map((id) => id.trim())
         .filter((id) => id);