新增分析:实现管理驾驶舱聚合接口与可视化

This commit is contained in:
wangzhibiao committed 2026-09-20 20:36:30 +08:00
1 parent 992791b7bd
commit f17526b409
23 files changed
+3077 -23

No files matched your search

@@ -0,0 +1,83 @@
package com.ruoyi.web.controller.train;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.train.domain.analysis.AttendanceChartsVo;
import com.ruoyi.train.domain.analysis.AttendanceFilterDto;
import com.ruoyi.train.domain.analysis.AttendanceSummaryVo;
import com.ruoyi.train.domain.analysis.DashboardChartsVo;
import com.ruoyi.train.domain.analysis.DashboardOverviewVo;
import com.ruoyi.train.domain.analysis.Student360Vo;
import com.ruoyi.train.service.ITrainAnalysisService;
/**
* 数据分析与可视化 Controller
*/
@RestController
@RequestMapping("/train/analysis")
public class TrainAnalysisController extends BaseController
{
@Autowired
private ITrainAnalysisService analysisService;
/**
* 获取管理驾驶舱宏观概览与业务提醒
*/
@PreAuthorize("@ss.hasPermi('train:analysis:dashboard')")
@GetMapping("/dashboard/overview")
public AjaxResult getDashboardOverview()
{
DashboardOverviewVo vo = analysisService.getDashboardOverview();
return success(vo);
}
/**
* 获取管理驾驶舱图表数据
*/
@PreAuthorize("@ss.hasPermi('train:analysis:dashboard')")
@GetMapping("/dashboard/charts")
public AjaxResult getDashboardCharts()
{
DashboardChartsVo vo = analysisService.getDashboardCharts();
return success(vo);
}
/**
* 获取考勤专题分析汇总 KPI
*/
@PreAuthorize("@ss.hasPermi('train:analysis:attendance')")
@GetMapping("/attendance/summary")
public AjaxResult getAttendanceSummary(AttendanceFilterDto filter)
{
AttendanceSummaryVo vo = analysisService.getAttendanceSummary(filter);
return success(vo);
}
/**
* 获取考勤专题分析图表集合
*/
@PreAuthorize("@ss.hasPermi('train:analysis:attendance')")
@GetMapping("/attendance/charts")
public AjaxResult getAttendanceCharts(AttendanceFilterDto filter)
{
AttendanceChartsVo vo = analysisService.getAttendanceCharts(filter);
return success(vo);
}
/**
* 获取指定学员 360 客观全景画像 (复用现有 train:portrait:query 权限)
*/
@PreAuthorize("@ss.hasPermi('train:portrait:query')")
@GetMapping("/portrait/student-360/{studentId}")
public AjaxResult getStudent360(@PathVariable("studentId") String studentId)
{
Student360Vo vo = analysisService.getStudent360(studentId);
return success(vo);
}
}
@@ -0,0 +1,55 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.*;
import java.lang.reflect.Method;
import org.junit.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import com.ruoyi.train.domain.analysis.AttendanceFilterDto;
/**
* 数据分析与可视化 Controller 契约与权限边界测试
*/
public class TrainAnalysisControllerContractTest
{
@Test
public void shouldExposeAllFiveAnalyticsEndpointsAndCorrectPermissions() throws Exception
{
// 1. Controller 根路径
RequestMapping root = TrainAnalysisController.class.getAnnotation(RequestMapping.class);
assertNotNull(root);
assertEquals("/train/analysis", root.value()[0]);
// 2. 驾驶舱概览
Method overview = TrainAnalysisController.class.getMethod("getDashboardOverview");
assertNotNull(overview.getAnnotation(GetMapping.class));
assertEquals("/dashboard/overview", overview.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:analysis:dashboard')", overview.getAnnotation(PreAuthorize.class).value());
// 3. 驾驶舱图表
Method dashCharts = TrainAnalysisController.class.getMethod("getDashboardCharts");
assertNotNull(dashCharts.getAnnotation(GetMapping.class));
assertEquals("/dashboard/charts", dashCharts.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:analysis:dashboard')", dashCharts.getAnnotation(PreAuthorize.class).value());
// 4. 考勤汇总 KPI
Method attSummary = TrainAnalysisController.class.getMethod("getAttendanceSummary", AttendanceFilterDto.class);
assertNotNull(attSummary.getAnnotation(GetMapping.class));
assertEquals("/attendance/summary", attSummary.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:analysis:attendance')", attSummary.getAnnotation(PreAuthorize.class).value());
// 5. 考勤图表集合
Method attCharts = TrainAnalysisController.class.getMethod("getAttendanceCharts", AttendanceFilterDto.class);
assertNotNull(attCharts.getAnnotation(GetMapping.class));
assertEquals("/attendance/charts", attCharts.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:analysis:attendance')", attCharts.getAnnotation(PreAuthorize.class).value());
// 6. 学员 360 画像 (复用现有 train:portrait:query)
Method s360 = TrainAnalysisController.class.getMethod("getStudent360", String.class);
assertNotNull(s360.getAnnotation(GetMapping.class));
assertEquals("/portrait/student-360/{studentId}", s360.getAnnotation(GetMapping.class).value()[0]);
assertEquals("@ss.hasPermi('train:portrait:query')", s360.getAnnotation(PreAuthorize.class).value());
}
}
@@ -0,0 +1,52 @@
package com.ruoyi.train.domain.analysis;
import java.io.Serializable;
/**
* 考勤异常学员明细 DTO
*/
public class AbnormalStudentDto implements Serializable
{
private static final long serialVersionUID = 1L;
private String studentId;
private String studentName;
private String planId;
private String planName;
private Long absentCount;
private Long lateCount;
private Long earlyLeaveCount;
private Long leaveCount;
private Long noCheckinCount;
private Long totalAbnormalCount;
public String getStudentId() { return studentId; }
public void setStudentId(String studentId) { this.studentId = studentId; }
public String getStudentName() { return studentName; }
public void setStudentName(String studentName) { this.studentName = studentName; }
public String getPlanId() { return planId; }
public void setPlanId(String planId) { this.planId = planId; }
public String getPlanName() { return planName; }
public void setPlanName(String planName) { this.planName = planName; }
public Long getAbsentCount() { return absentCount; }
public void setAbsentCount(Long absentCount) { this.absentCount = absentCount; }
public Long getLateCount() { return lateCount; }
public void setLateCount(Long lateCount) { this.lateCount = lateCount; }
public Long getEarlyLeaveCount() { return earlyLeaveCount; }
public void setEarlyLeaveCount(Long earlyLeaveCount) { this.earlyLeaveCount = earlyLeaveCount; }
public Long getLeaveCount() { return leaveCount; }
public void setLeaveCount(Long leaveCount) { this.leaveCount = leaveCount; }
public Long getNoCheckinCount() { return noCheckinCount; }
public void setNoCheckinCount(Long noCheckinCount) { this.noCheckinCount = noCheckinCount; }
public Long getTotalAbnormalCount() { return totalAbnormalCount; }
public void setTotalAbnormalCount(Long totalAbnormalCount) { this.totalAbnormalCount = totalAbnormalCount; }
}
@@ -0,0 +1,42 @@
package com.ruoyi.train.domain.analysis;
import java.io.Serializable;
import java.util.List;
/**
* 考勤专题分析图表集合 VO
*/
public class AttendanceChartsVo implements Serializable
{
private static final long serialVersionUID = 1L;
/** 1. 每日考勤趋势折线图 */
private List<DailyTrendDto> dailyTrend;
/** 2. 考勤状态环形分布 (正常, 迟到, 早退, 请假, 明确缺勤, 无签到记录) */
private List<NameValueDto> statusDistribution;
/** 3. 培训计划出勤对比 (横向柱状图 Top 10) */
private List<CompareItemDto> planComparison;
/** 4. 课程考勤对比 (横向柱状图 Top 10) */
private List<CompareItemDto> courseComparison;
/** 5. 异常学员 Top 10 列表与柱图 */
private List<AbnormalStudentDto> abnormalStudentTop10;
public List<DailyTrendDto> getDailyTrend() { return dailyTrend; }
public void setDailyTrend(List<DailyTrendDto> dailyTrend) { this.dailyTrend = dailyTrend; }
public List<NameValueDto> getStatusDistribution() { return statusDistribution; }
public void setStatusDistribution(List<NameValueDto> statusDistribution) { this.statusDistribution = statusDistribution; }
public List<CompareItemDto> getPlanComparison() { return planComparison; }
public void setPlanComparison(List<CompareItemDto> planComparison) { this.planComparison = planComparison; }
public List<CompareItemDto> getCourseComparison() { return courseComparison; }
public void setCourseComparison(List<CompareItemDto> courseComparison) { this.courseComparison = courseComparison; }
public List<AbnormalStudentDto> getAbnormalStudentTop10() { return abnormalStudentTop10; }
public void setAbnormalStudentTop10(List<AbnormalStudentDto> abnormalStudentTop10) { this.abnormalStudentTop10 = abnormalStudentTop10; }
}
@@ -0,0 +1,35 @@
package com.ruoyi.train.domain.analysis;
import java.io.Serializable;
/**
* 考勤专题分析通用查询入参 DTO
*/
public class AttendanceFilterDto implements Serializable
{
private static final long serialVersionUID = 1L;
/** 开始日期 (格式: yyyy-MM-dd) */
private String startDate;
/** 结束日期 (格式: yyyy-MM-dd) */
private String endDate;
/** 培训计划ID */
private String planId;
/** 课程ID */
private String courseId;
public String getStartDate() { return startDate; }
public void setStartDate(String startDate) { this.startDate = startDate; }
public String getEndDate() { return endDate; }
public void setEndDate(String endDate) { this.endDate = endDate; }
public String getPlanId() { return planId; }
public void setPlanId(String planId) { this.planId = planId; }
public String getCourseId() { return courseId; }
public void setCourseId(String courseId) { this.courseId = courseId; }
}
@@ -0,0 +1,47 @@
package com.ruoyi.train.domain.analysis;
import java.io.Serializable;
/**
* 考勤专题分析汇总 KPI VO
*/
public class AttendanceSummaryVo implements Serializable
{
private static final long serialVersionUID = 1L;
/** 累计已开课次 */
private Long lessonTotal;
/** 应到总人次 */
private Long expectedAttendTotal;
/** 正常出勤人次 */
private Long normalAttendTotal;
/** 异常考勤人次 (迟到/早退/请假/缺勤/无签到记录) */
private Long abnormalAttendTotal;
/** 综合出勤率 (百分比,如 92.5) */
private Double attendanceRate;
/** 业务口径状态警示提示 */
private String metricDefinitionNotice = "BUSINESS_METRIC_DEFINITION_PENDING_CONFIRMATION: 当前出勤率分母采用开课所属班级参训学员应到人次,业务最终标准待甲方确认";
public Long getLessonTotal() { return lessonTotal; }
public void setLessonTotal(Long lessonTotal) { this.lessonTotal = lessonTotal; }
public Long getExpectedAttendTotal() { return expectedAttendTotal; }
public void setExpectedAttendTotal(Long expectedAttendTotal) { this.expectedAttendTotal = expectedAttendTotal; }
public Long getNormalAttendTotal() { return normalAttendTotal; }
public void setNormalAttendTotal(Long normalAttendTotal) { this.normalAttendTotal = normalAttendTotal; }
public Long getAbnormalAttendTotal() { return abnormalAttendTotal; }
public void setAbnormalAttendTotal(Long abnormalAttendTotal) { this.abnormalAttendTotal = abnormalAttendTotal; }
public Double getAttendanceRate() { return attendanceRate; }
public void setAttendanceRate(Double attendanceRate) { this.attendanceRate = attendanceRate; }
public String getMetricDefinitionNotice() { return metricDefinitionNotice; }
public void setMetricDefinitionNotice(String metricDefinitionNotice) { this.metricDefinitionNotice = metricDefinitionNotice; }
}
@@ -0,0 +1,48 @@
package com.ruoyi.train.domain.analysis;
import java.io.Serializable;
/**
* 计划或课程横向对比项 DTO
*/
public class CompareItemDto implements Serializable
{
private static final long serialVersionUID = 1L;
private String id;
private String name;
private Long expectedCount;
private Long normalCount;
private Long abnormalCount;
private Double attendanceRate;
public CompareItemDto() {}
public CompareItemDto(String id, String name, Long expectedCount, Long normalCount, Long abnormalCount, Double attendanceRate)
{
this.id = id;
this.name = name;
this.expectedCount = expectedCount;
this.normalCount = normalCount;
this.abnormalCount = abnormalCount;
this.attendanceRate = attendanceRate;
}
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Long getExpectedCount() { return expectedCount; }
public void setExpectedCount(Long expectedCount) { this.expectedCount = expectedCount; }
public Long getNormalCount() { return normalCount; }
public void setNormalCount(Long normalCount) { this.normalCount = normalCount; }
public Long getAbnormalCount() { return abnormalCount; }
public void setAbnormalCount(Long abnormalCount) { this.abnormalCount = abnormalCount; }
public Double getAttendanceRate() { return attendanceRate; }
public void setAttendanceRate(Double attendanceRate) { this.attendanceRate = attendanceRate; }
}
@@ -0,0 +1,38 @@
package com.ruoyi.train.domain.analysis;
import java.io.Serializable;
/**
* 每日考勤趋势 DTO
*/
public class DailyTrendDto implements Serializable
{
private static final long serialVersionUID = 1L;
private String date;
private Long normalCount;
private Long abnormalCount;
private Double attendanceRate;
public DailyTrendDto() {}
public DailyTrendDto(String date, Long normalCount, Long abnormalCount, Double attendanceRate)
{
this.date = date;
this.normalCount = normalCount;
this.abnormalCount = abnormalCount;
this.attendanceRate = attendanceRate;
}
public String getDate() { return date; }
public void setDate(String date) { this.date = date; }
public Long getNormalCount() { return normalCount; }
public void setNormalCount(Long normalCount) { this.normalCount = normalCount; }
public Long getAbnormalCount() { return abnormalCount; }
public void setAbnormalCount(Long abnormalCount) { this.abnormalCount = abnormalCount; }
public Double getAttendanceRate() { return attendanceRate; }
public void setAttendanceRate(Double attendanceRate) { this.attendanceRate = attendanceRate; }
}
@@ -0,0 +1,60 @@
package com.ruoyi.train.domain.analysis;
import java.io.Serializable;
import java.util.List;
/**
* 管理驾驶舱图表数据集合 VO
*/
public class DashboardChartsVo implements Serializable
{
private static final long serialVersionUID = 1L;
/** 1. 近30天考勤走势:日期横坐标 */
private List<String> trendDates;
/** 近30天考勤走势:正常签到人次 */
private List<Long> trendNormalCounts;
/** 近30天考勤走势:异常考勤人次 */
private List<Long> trendAbnormalCounts;
/** 2. 培训计划参训/报到对比:计划名称列表 (Top 10) */
private List<String> planComparisonNames;
/** 培训计划参训人数 */
private List<Long> planEnrolledCounts;
/** 培训计划报到人数 */
private List<Long> planReportedCounts;
/** 3. 考勤状态全局构成 (正常, 迟到, 早退, 请假, 明确缺勤, 无签到记录) */
private List<NameValueDto> attendanceStatusDistribution;
/** 4. 教学场地使用频次与课时 Top 5 */
private List<VenueUsageDto> venueUsageTop5;
public List<String> getTrendDates() { return trendDates; }
public void setTrendDates(List<String> trendDates) { this.trendDates = trendDates; }
public List<Long> getTrendNormalCounts() { return trendNormalCounts; }
public void setTrendNormalCounts(List<Long> trendNormalCounts) { this.trendNormalCounts = trendNormalCounts; }
public List<Long> getTrendAbnormalCounts() { return trendAbnormalCounts; }
public void setTrendAbnormalCounts(List<Long> trendAbnormalCounts) { this.trendAbnormalCounts = trendAbnormalCounts; }
public List<String> getPlanComparisonNames() { return planComparisonNames; }
public void setPlanComparisonNames(List<String> planComparisonNames) { this.planComparisonNames = planComparisonNames; }
public List<Long> getPlanEnrolledCounts() { return planEnrolledCounts; }
public void setPlanEnrolledCounts(List<Long> planEnrolledCounts) { this.planEnrolledCounts = planEnrolledCounts; }
public List<Long> getPlanReportedCounts() { return planReportedCounts; }
public void setPlanReportedCounts(List<Long> planReportedCounts) { this.planReportedCounts = planReportedCounts; }
public List<NameValueDto> getAttendanceStatusDistribution() { return attendanceStatusDistribution; }
public void setAttendanceStatusDistribution(List<NameValueDto> attendanceStatusDistribution) { this.attendanceStatusDistribution = attendanceStatusDistribution; }
public List<VenueUsageDto> getVenueUsageTop5() { return venueUsageTop5; }
public void setVenueUsageTop5(List<VenueUsageDto> venueUsageTop5) { this.venueUsageTop5 = venueUsageTop5; }
}
@@ -0,0 +1,90 @@
package com.ruoyi.train.domain.analysis;
import java.io.Serializable;
import java.util.Map;
/**
* 管理驾驶舱宏观概览 VO
*/
public class DashboardOverviewVo implements Serializable
{
private static final long serialVersionUID = 1L;
/** 学员总数 */
private Long studentTotal;
/** 教师总数 */
private Long teacherTotal;
/** 课程总数 */
private Long courseTotal;
/** 培训计划总数 */
private Long planTotal;
/** 累计排课课次 */
private Long lessonTotal;
/** 累计参训人数 */
private Long enrollmentTotal;
/** 累计报到人数 */
private Long reportTotal;
/** 当前在宿人数 */
private Long accommodationCurrent;
/** 培训计划状态分布 (未开始, 进行中, 已结束, 已取消) */
private Map<String, Long> planStatusCounts;
/** 待办提醒:异常考勤人次 */
private Long abnormalAttendanceCount;
/** 待办提醒:待报到人数 */
private Long pendingReportCount;
/** 待办提醒:住宿预分配人数 */
private Long preallocatedAccommodationCount;
/** 待办提醒:门禁异常授权数 */
private Long abnormalAuthCount;
public Long getStudentTotal() { return studentTotal; }
public void setStudentTotal(Long studentTotal) { this.studentTotal = studentTotal; }
public Long getTeacherTotal() { return teacherTotal; }
public void setTeacherTotal(Long teacherTotal) { this.teacherTotal = teacherTotal; }
public Long getCourseTotal() { return courseTotal; }
public void setCourseTotal(Long courseTotal) { this.courseTotal = courseTotal; }
public Long getPlanTotal() { return planTotal; }
public void setPlanTotal(Long planTotal) { this.planTotal = planTotal; }
public Long getLessonTotal() { return lessonTotal; }
public void setLessonTotal(Long lessonTotal) { this.lessonTotal = lessonTotal; }
public Long getEnrollmentTotal() { return enrollmentTotal; }
public void setEnrollmentTotal(Long enrollmentTotal) { this.enrollmentTotal = enrollmentTotal; }
public Long getReportTotal() { return reportTotal; }
public void setReportTotal(Long reportTotal) { this.reportTotal = reportTotal; }
public Long getAccommodationCurrent() { return accommodationCurrent; }
public void setAccommodationCurrent(Long accommodationCurrent) { this.accommodationCurrent = accommodationCurrent; }
public Map<String, Long> getPlanStatusCounts() { return planStatusCounts; }
public void setPlanStatusCounts(Map<String, Long> planStatusCounts) { this.planStatusCounts = planStatusCounts; }
public Long getAbnormalAttendanceCount() { return abnormalAttendanceCount; }
public void setAbnormalAttendanceCount(Long abnormalAttendanceCount) { this.abnormalAttendanceCount = abnormalAttendanceCount; }
public Long getPendingReportCount() { return pendingReportCount; }
public void setPendingReportCount(Long pendingReportCount) { this.pendingReportCount = pendingReportCount; }
public Long getPreallocatedAccommodationCount() { return preallocatedAccommodationCount; }
public void setPreallocatedAccommodationCount(Long preallocatedAccommodationCount) { this.preallocatedAccommodationCount = preallocatedAccommodationCount; }
public Long getAbnormalAuthCount() { return abnormalAuthCount; }
public void setAbnormalAuthCount(Long abnormalAuthCount) { this.abnormalAuthCount = abnormalAuthCount; }
}
@@ -0,0 +1,28 @@
package com.ruoyi.train.domain.analysis;
import java.io.Serializable;
/**
* 通用名称数值键值对 DTO
*/
public class NameValueDto implements Serializable
{
private static final long serialVersionUID = 1L;
private String name;
private Long value;
public NameValueDto() {}
public NameValueDto(String name, Long value)
{
this.name = name;
this.value = value;
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Long getValue() { return value; }
public void setValue(Long value) { this.value = value; }
}
@@ -0,0 +1,161 @@
package com.ruoyi.train.domain.analysis;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 学员 360 客观全景画像 VO
*/
public class Student360Vo implements Serializable
{
private static final long serialVersionUID = 1L;
/* ========== 1. 基础主档 (敏感信息脱敏) ========== */
private String studentId;
private String studentName;
private String gender;
private String genderName;
@JsonFormat(pattern = "yyyy-MM-dd")
private Date birth;
private String phone;
private String email;
private String orgName;
private String idCardMasked;
private String userBindingStatus;
private Integer status;
/* ========== 2. 培训履历时间轴 ========== */
private List<StudentEnrollmentTimelineDto> enrollmentTimeline;
/* ========== 3. 考勤客观纪律画像 ========== */
private Long expectedAttendCount;
private Long normalCount;
private Long lateCount;
private Long earlyCount;
private Long leaveCount;
private Long absentCount;
private Long noCheckinCount;
private Double attendanceRate;
private String metricDefinitionNotice = "BUSINESS_METRIC_DEFINITION_PENDING_CONFIRMATION: 个人出勤率采用学员所属班级已开课次应到数为分母,待甲方最终确认";
/* ========== 4. 成绩状态 (严格不造假) ========== */
private Boolean hasScoreData = false;
private String scoreStatusDesc = "暂无成绩数据 (V2成绩表待考务录入)";
/* ========== 5. 自主学习状态 (严格不造假) ========== */
private Boolean hasStudyData = false;
private String studyStatusDesc = "暂无自主学习记录 / 待外部平台数据同步";
/* ========== 6. 住宿与门禁客观状态 ========== */
private Boolean hasAccommodation = false;
private String dormId;
private String roomName;
private String bedNo;
private Integer accommodationStatus;
private String accommodationStatusName;
private String authStatus;
private String authStatusName;
public String getStudentId() { return studentId; }
public void setStudentId(String studentId) { this.studentId = studentId; }
public String getStudentName() { return studentName; }
public void setStudentName(String studentName) { this.studentName = studentName; }
public String getGender() { return gender; }
public void setGender(String gender) { this.gender = gender; }
public String getGenderName() { return genderName; }
public void setGenderName(String genderName) { this.genderName = genderName; }
public Date getBirth() { return birth; }
public void setBirth(Date birth) { this.birth = birth; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getOrgName() { return orgName; }
public void setOrgName(String orgName) { this.orgName = orgName; }
public String getIdCardMasked() { return idCardMasked; }
public void setIdCardMasked(String idCardMasked) { this.idCardMasked = idCardMasked; }
public String getUserBindingStatus() { return userBindingStatus; }
public void setUserBindingStatus(String userBindingStatus) { this.userBindingStatus = userBindingStatus; }
public Integer getStatus() { return status; }
public void setStatus(Integer status) { this.status = status; }
public List<StudentEnrollmentTimelineDto> getEnrollmentTimeline() { return enrollmentTimeline; }
public void setEnrollmentTimeline(List<StudentEnrollmentTimelineDto> enrollmentTimeline) { this.enrollmentTimeline = enrollmentTimeline; }
public Long getExpectedAttendCount() { return expectedAttendCount; }
public void setExpectedAttendCount(Long expectedAttendCount) { this.expectedAttendCount = expectedAttendCount; }
public Long getNormalCount() { return normalCount; }
public void setNormalCount(Long normalCount) { this.normalCount = normalCount; }
public Long getLateCount() { return lateCount; }
public void setLateCount(Long lateCount) { this.lateCount = lateCount; }
public Long getEarlyCount() { return earlyCount; }
public void setEarlyCount(Long earlyCount) { this.earlyCount = earlyCount; }
public Long getLeaveCount() { return leaveCount; }
public void setLeaveCount(Long leaveCount) { this.leaveCount = leaveCount; }
public Long getAbsentCount() { return absentCount; }
public void setAbsentCount(Long absentCount) { this.absentCount = absentCount; }
public Long getNoCheckinCount() { return noCheckinCount; }
public void setNoCheckinCount(Long noCheckinCount) { this.noCheckinCount = noCheckinCount; }
public Double getAttendanceRate() { return attendanceRate; }
public void setAttendanceRate(Double attendanceRate) { this.attendanceRate = attendanceRate; }
public String getMetricDefinitionNotice() { return metricDefinitionNotice; }
public void setMetricDefinitionNotice(String metricDefinitionNotice) { this.metricDefinitionNotice = metricDefinitionNotice; }
public Boolean getHasScoreData() { return hasScoreData; }
public void setHasScoreData(Boolean hasScoreData) { this.hasScoreData = hasScoreData; }
public String getScoreStatusDesc() { return scoreStatusDesc; }
public void setScoreStatusDesc(String scoreStatusDesc) { this.scoreStatusDesc = scoreStatusDesc; }
public Boolean getHasStudyData() { return hasStudyData; }
public void setHasStudyData(Boolean hasStudyData) { this.hasStudyData = hasStudyData; }
public String getStudyStatusDesc() { return studyStatusDesc; }
public void setStudyStatusDesc(String studyStatusDesc) { this.studyStatusDesc = studyStatusDesc; }
public Boolean getHasAccommodation() { return hasAccommodation; }
public void setHasAccommodation(Boolean hasAccommodation) { this.hasAccommodation = hasAccommodation; }
public String getDormId() { return dormId; }
public void setDormId(String dormId) { this.dormId = dormId; }
public String getRoomName() { return roomName; }
public void setRoomName(String roomName) { this.roomName = roomName; }
public String getBedNo() { return bedNo; }
public void setBedNo(String bedNo) { this.bedNo = bedNo; }
public Integer getAccommodationStatus() { return accommodationStatus; }
public void setAccommodationStatus(Integer accommodationStatus) { this.accommodationStatus = accommodationStatus; }
public String getAccommodationStatusName() { return accommodationStatusName; }
public void setAccommodationStatusName(String accommodationStatusName) { this.accommodationStatusName = accommodationStatusName; }
public String getAuthStatus() { return authStatus; }
public void setAuthStatus(String authStatus) { this.authStatus = authStatus; }
public String getAuthStatusName() { return authStatusName; }
public void setAuthStatusName(String authStatusName) { this.authStatusName = authStatusName; }
}
@@ -0,0 +1,63 @@
package com.ruoyi.train.domain.analysis;
import java.io.Serializable;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 学员参训履历时间轴 DTO
*/
public class StudentEnrollmentTimelineDto implements Serializable
{
private static final long serialVersionUID = 1L;
private Long enrollmentId;
private String planId;
private String planName;
private Integer enrollmentStatus;
private String enrollmentStatusName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date joinTime;
private Integer reportWay;
private String reportWayName;
private Integer verifyResult;
private String verifyResultName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date reportTime;
public Long getEnrollmentId() { return enrollmentId; }
public void setEnrollmentId(Long enrollmentId) { this.enrollmentId = enrollmentId; }
public String getPlanId() { return planId; }
public void setPlanId(String planId) { this.planId = planId; }
public String getPlanName() { return planName; }
public void setPlanName(String planName) { this.planName = planName; }
public Integer getEnrollmentStatus() { return enrollmentStatus; }
public void setEnrollmentStatus(Integer enrollmentStatus) { this.enrollmentStatus = enrollmentStatus; }
public String getEnrollmentStatusName() { return enrollmentStatusName; }
public void setEnrollmentStatusName(String enrollmentStatusName) { this.enrollmentStatusName = enrollmentStatusName; }
public Date getJoinTime() { return joinTime; }
public void setJoinTime(Date joinTime) { this.joinTime = joinTime; }
public Integer getReportWay() { return reportWay; }
public void setReportWay(Integer reportWay) { this.reportWay = reportWay; }
public String getReportWayName() { return reportWayName; }
public void setReportWayName(String reportWayName) { this.reportWayName = reportWayName; }
public Integer getVerifyResult() { return verifyResult; }
public void setVerifyResult(Integer verifyResult) { this.verifyResult = verifyResult; }
public String getVerifyResultName() { return verifyResultName; }
public void setVerifyResultName(String verifyResultName) { this.verifyResultName = verifyResultName; }
public Date getReportTime() { return reportTime; }
public void setReportTime(Date reportTime) { this.reportTime = reportTime; }
}
@@ -0,0 +1,38 @@
package com.ruoyi.train.domain.analysis;
import java.io.Serializable;
/**
* 场地使用频次与课时 DTO
*/
public class VenueUsageDto implements Serializable
{
private static final long serialVersionUID = 1L;
/** 场地名称(教室名称或实训场地名称) */
private String venueName;
/** 使用频次(排课次数) */
private Long usageCount;
/** 累计占用课时(小时数,保留1位小数) */
private Double totalHours;
public VenueUsageDto() {}
public VenueUsageDto(String venueName, Long usageCount, Double totalHours)
{
this.venueName = venueName;
this.usageCount = usageCount;
this.totalHours = totalHours;
}
public String getVenueName() { return venueName; }
public void setVenueName(String venueName) { this.venueName = venueName; }
public Long getUsageCount() { return usageCount; }
public void setUsageCount(Long usageCount) { this.usageCount = usageCount; }
public Double getTotalHours() { return totalHours; }
public void setTotalHours(Double totalHours) { this.totalHours = totalHours; }
}
@@ -0,0 +1,72 @@
package com.ruoyi.train.mapper;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.train.domain.analysis.AttendanceFilterDto;
/**
* 数据分析与可视化专用聚合 Mapper (严格在数据库层完成 COUNT/SUM/GROUP BY)
*/
public interface TrainAnalysisMapper
{
/* ==================== 1. 管理驾驶舱 ==================== */
Long countStudents();
Long countTeachers();
Long countCourses();
Long countPlans();
Long countLessons();
Long countEnrollments();
Long countReports();
Long countCurrentAccommodations();
List<Map<String, Object>> selectPlanStatusDistribution();
Long countAbnormalAttendance();
Long countPendingReports();
Long countPreallocatedAccommodations();
Long countAbnormalDormAuths();
List<Map<String, Object>> selectAttendanceTrend30Days();
List<Map<String, Object>> selectPlanEnrollmentReportComparisonTop10();
List<Map<String, Object>> selectAttendanceStatusDistribution();
Long countTotalExpectedAttendanceForPastLessons();
List<Map<String, Object>> selectVenueUsageTop5();
/* ==================== 2. 考勤专题分析 ==================== */
Long countFilteredLessons(@Param("dto") AttendanceFilterDto dto);
Long countFilteredExpectedAttendance(@Param("dto") AttendanceFilterDto dto);
Long countFilteredNormalAttendance(@Param("dto") AttendanceFilterDto dto);
Long countFilteredAbnormalAttendance(@Param("dto") AttendanceFilterDto dto);
List<Map<String, Object>> selectFilteredDailyTrend(@Param("dto") AttendanceFilterDto dto);
List<Map<String, Object>> selectFilteredStatusDistribution(@Param("dto") AttendanceFilterDto dto);
List<Map<String, Object>> selectPlanAttendanceComparison(@Param("dto") AttendanceFilterDto dto);
List<Map<String, Object>> selectCourseAttendanceComparison(@Param("dto") AttendanceFilterDto dto);
List<Map<String, Object>> selectAbnormalStudentTop10(@Param("dto") AttendanceFilterDto dto);
/* ==================== 3. 学员 360 画像 ==================== */
Map<String, Object> selectStudentBaseProfile(@Param("studentId") String studentId);
List<Map<String, Object>> selectStudentEnrollmentTimeline(@Param("studentId") String studentId);
List<Map<String, Object>> selectStudentAttendanceStats(@Param("studentId") String studentId);
Long countStudentExpectedAttendance(@Param("studentId") String studentId);
Map<String, Object> selectStudentAccommodationAuth(@Param("studentId") String studentId);
}
@@ -0,0 +1,29 @@
package com.ruoyi.train.service;
import com.ruoyi.train.domain.analysis.AttendanceChartsVo;
import com.ruoyi.train.domain.analysis.AttendanceFilterDto;
import com.ruoyi.train.domain.analysis.AttendanceSummaryVo;
import com.ruoyi.train.domain.analysis.DashboardChartsVo;
import com.ruoyi.train.domain.analysis.DashboardOverviewVo;
import com.ruoyi.train.domain.analysis.Student360Vo;
/**
* 数据分析与可视化服务接口
*/
public interface ITrainAnalysisService
{
/** 获取管理驾驶舱宏观概览与业务提醒 */
DashboardOverviewVo getDashboardOverview();
/** 获取管理驾驶舱图表集合 */
DashboardChartsVo getDashboardCharts();
/** 获取考勤专题分析汇总 KPI */
AttendanceSummaryVo getAttendanceSummary(AttendanceFilterDto filter);
/** 获取考勤专题分析图表集合 */
AttendanceChartsVo getAttendanceCharts(AttendanceFilterDto filter);
/** 获取指定学员 360 客观全景画像 */
Student360Vo getStudent360(String studentId);
}
@@ -0,0 +1,564 @@
package com.ruoyi.train.service.impl;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.domain.analysis.*;
import com.ruoyi.train.mapper.TrainAnalysisMapper;
import com.ruoyi.train.service.ITrainAnalysisService;
/**
* 数据分析与可视化服务实现类
*/
@Service
public class TrainAnalysisServiceImpl implements ITrainAnalysisService
{
private static final Logger log = LoggerFactory.getLogger(TrainAnalysisServiceImpl.class);
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@Autowired
private TrainAnalysisMapper analysisMapper;
@Override
public DashboardOverviewVo getDashboardOverview()
{
DashboardOverviewVo vo = new DashboardOverviewVo();
vo.setStudentTotal(analysisMapper.countStudents());
vo.setTeacherTotal(analysisMapper.countTeachers());
vo.setCourseTotal(analysisMapper.countCourses());
vo.setPlanTotal(analysisMapper.countPlans());
vo.setLessonTotal(analysisMapper.countLessons());
vo.setEnrollmentTotal(analysisMapper.countEnrollments());
vo.setReportTotal(analysisMapper.countReports());
vo.setAccommodationCurrent(analysisMapper.countCurrentAccommodations());
// 计划状态分布
Map<String, Long> statusMap = new LinkedHashMap<>();
statusMap.put("notStarted", 0L);
statusMap.put("ongoing", 0L);
statusMap.put("ended", 0L);
statusMap.put("cancelled", 0L);
List<Map<String, Object>> planStatuses = analysisMapper.selectPlanStatusDistribution();
if (planStatuses != null)
{
for (Map<String, Object> map : planStatuses)
{
String code = String.valueOf(map.get("status_code"));
Long cnt = ((Number) map.get("count_num")).longValue();
if ("0".equals(code)) statusMap.put("notStarted", cnt);
else if ("1".equals(code)) statusMap.put("ongoing", cnt);
else if ("2".equals(code)) statusMap.put("ended", cnt);
else if ("3".equals(code)) statusMap.put("cancelled", cnt);
}
}
vo.setPlanStatusCounts(statusMap);
// 业务待办提醒
vo.setAbnormalAttendanceCount(analysisMapper.countAbnormalAttendance());
vo.setPendingReportCount(analysisMapper.countPendingReports());
vo.setPreallocatedAccommodationCount(analysisMapper.countPreallocatedAccommodations());
vo.setAbnormalAuthCount(analysisMapper.countAbnormalDormAuths());
return vo;
}
@Override
public DashboardChartsVo getDashboardCharts()
{
DashboardChartsVo vo = new DashboardChartsVo();
// 1. 近 30 天考勤走势
List<Map<String, Object>> trendList = analysisMapper.selectAttendanceTrend30Days();
List<String> trendDates = new ArrayList<>();
List<Long> normalCounts = new ArrayList<>();
List<Long> abnormalCounts = new ArrayList<>();
if (trendList != null)
{
for (Map<String, Object> row : trendList)
{
trendDates.add(String.valueOf(row.get("stat_date")));
normalCounts.add(row.get("normal_count") != null ? ((Number) row.get("normal_count")).longValue() : 0L);
abnormalCounts.add(row.get("abnormal_count") != null ? ((Number) row.get("abnormal_count")).longValue() : 0L);
}
}
vo.setTrendDates(trendDates);
vo.setTrendNormalCounts(normalCounts);
vo.setTrendAbnormalCounts(abnormalCounts);
// 2. 计划参训与报到对比 (Top 10)
List<Map<String, Object>> planCompList = analysisMapper.selectPlanEnrollmentReportComparisonTop10();
List<String> planNames = new ArrayList<>();
List<Long> enrolledList = new ArrayList<>();
List<Long> reportedList = new ArrayList<>();
if (planCompList != null)
{
for (Map<String, Object> row : planCompList)
{
planNames.add(String.valueOf(row.get("plan_name")));
enrolledList.add(row.get("enrolled_count") != null ? ((Number) row.get("enrolled_count")).longValue() : 0L);
reportedList.add(row.get("reported_count") != null ? ((Number) row.get("reported_count")).longValue() : 0L);
}
}
vo.setPlanComparisonNames(planNames);
vo.setPlanEnrolledCounts(enrolledList);
vo.setPlanReportedCounts(reportedList);
// 3. 考勤状态全局构成 (正常, 迟到, 早退, 请假, 明确缺勤, 无签到记录)
List<NameValueDto> statusList = new ArrayList<>();
Map<String, Long> statusCountMap = new HashMap<>();
List<Map<String, Object>> rawStatus = analysisMapper.selectAttendanceStatusDistribution();
long loggedTotal = 0L;
if (rawStatus != null)
{
for (Map<String, Object> row : rawStatus)
{
String code = String.valueOf(row.get("status_code"));
long cnt = row.get("count_num") != null ? ((Number) row.get("count_num")).longValue() : 0L;
statusCountMap.put(code, cnt);
loggedTotal += cnt;
}
}
statusList.add(new NameValueDto("正常出勤", statusCountMap.getOrDefault("1", 0L)));
statusList.add(new NameValueDto("迟到", statusCountMap.getOrDefault("2", 0L)));
statusList.add(new NameValueDto("早退", statusCountMap.getOrDefault("3", 0L)));
statusList.add(new NameValueDto("请假", statusCountMap.getOrDefault("4", 0L)));
statusList.add(new NameValueDto("明确缺勤", statusCountMap.getOrDefault("0", 0L)));
// 计算“无签到记录”
Long totalExpected = analysisMapper.countTotalExpectedAttendanceForPastLessons();
if (totalExpected != null && totalExpected > loggedTotal)
{
statusList.add(new NameValueDto("无签到记录", totalExpected - loggedTotal));
}
else
{
statusList.add(new NameValueDto("无签到记录", 0L));
}
vo.setAttendanceStatusDistribution(statusList);
// 4. 场地使用频次与课时 Top 5
List<VenueUsageDto> venueList = new ArrayList<>();
List<Map<String, Object>> rawVenues = analysisMapper.selectVenueUsageTop5();
if (rawVenues != null)
{
for (Map<String, Object> row : rawVenues)
{
String name = String.valueOf(row.get("venue_name"));
long cnt = row.get("usage_count") != null ? ((Number) row.get("usage_count")).longValue() : 0L;
double hours = row.get("total_hours") != null ? ((Number) row.get("total_hours")).doubleValue() : 0.0;
venueList.add(new VenueUsageDto(name, cnt, hours));
}
}
vo.setVenueUsageTop5(venueList);
return vo;
}
@Override
public AttendanceSummaryVo getAttendanceSummary(AttendanceFilterDto filter)
{
AttendanceFilterDto normalized = normalizeFilter(filter);
AttendanceSummaryVo vo = new AttendanceSummaryVo();
Long lessonTotal = analysisMapper.countFilteredLessons(normalized);
Long expectedTotal = analysisMapper.countFilteredExpectedAttendance(normalized);
Long normalTotal = analysisMapper.countFilteredNormalAttendance(normalized);
Long abnormalLogged = analysisMapper.countFilteredAbnormalAttendance(normalized);
long exp = expectedTotal != null ? expectedTotal : 0L;
long norm = normalTotal != null ? normalTotal : 0L;
long abnLogged = abnormalLogged != null ? abnormalLogged : 0L;
// 异常人次 = 已打卡的异常(迟到/早退/请假/缺勤) + (应到 - 打卡总数)
long totalLogged = norm + abnLogged;
long unlogged = (exp > totalLogged) ? (exp - totalLogged) : 0L;
long totalAbnormal = abnLogged + unlogged;
vo.setLessonTotal(lessonTotal != null ? lessonTotal : 0L);
vo.setExpectedAttendTotal(exp);
vo.setNormalAttendTotal(norm);
vo.setAbnormalAttendTotal(totalAbnormal);
if (exp > 0)
{
double rate = BigDecimal.valueOf(norm * 100.0 / exp)
.setScale(1, RoundingMode.HALF_UP)
.doubleValue();
vo.setAttendanceRate(rate);
}
else
{
vo.setAttendanceRate(0.0);
}
return vo;
}
@Override
public AttendanceChartsVo getAttendanceCharts(AttendanceFilterDto filter)
{
AttendanceFilterDto normalized = normalizeFilter(filter);
AttendanceChartsVo vo = new AttendanceChartsVo();
// 1. 每日走势
List<DailyTrendDto> dailyList = new ArrayList<>();
List<Map<String, Object>> rawDaily = analysisMapper.selectFilteredDailyTrend(normalized);
if (rawDaily != null)
{
for (Map<String, Object> row : rawDaily)
{
String date = String.valueOf(row.get("stat_date"));
long norm = row.get("normal_count") != null ? ((Number) row.get("normal_count")).longValue() : 0L;
long abn = row.get("abnormal_count") != null ? ((Number) row.get("abnormal_count")).longValue() : 0L;
long sum = norm + abn;
double rate = (sum > 0) ? BigDecimal.valueOf(norm * 100.0 / sum).setScale(1, RoundingMode.HALF_UP).doubleValue() : 0.0;
dailyList.add(new DailyTrendDto(date, norm, abn, rate));
}
}
vo.setDailyTrend(dailyList);
// 2. 状态分布环形图
List<NameValueDto> statusList = new ArrayList<>();
Map<String, Long> statusMap = new HashMap<>();
List<Map<String, Object>> rawStatus = analysisMapper.selectFilteredStatusDistribution(normalized);
long logged = 0L;
if (rawStatus != null)
{
for (Map<String, Object> row : rawStatus)
{
String code = String.valueOf(row.get("status_code"));
long cnt = row.get("count_num") != null ? ((Number) row.get("count_num")).longValue() : 0L;
statusMap.put(code, cnt);
logged += cnt;
}
}
statusList.add(new NameValueDto("正常出勤", statusMap.getOrDefault("1", 0L)));
statusList.add(new NameValueDto("迟到", statusMap.getOrDefault("2", 0L)));
statusList.add(new NameValueDto("早退", statusMap.getOrDefault("3", 0L)));
statusList.add(new NameValueDto("请假", statusMap.getOrDefault("4", 0L)));
statusList.add(new NameValueDto("明确缺勤", statusMap.getOrDefault("0", 0L)));
Long expTotal = analysisMapper.countFilteredExpectedAttendance(normalized);
if (expTotal != null && expTotal > logged)
{
statusList.add(new NameValueDto("无签到记录", expTotal - logged));
}
else
{
statusList.add(new NameValueDto("无签到记录", 0L));
}
vo.setStatusDistribution(statusList);
// 3. 培训计划对比 (Top 10)
List<CompareItemDto> planCompare = new ArrayList<>();
List<Map<String, Object>> rawPlan = analysisMapper.selectPlanAttendanceComparison(normalized);
if (rawPlan != null)
{
for (Map<String, Object> row : rawPlan)
{
String id = String.valueOf(row.get("id"));
String name = String.valueOf(row.get("name"));
long norm = row.get("normal_count") != null ? ((Number) row.get("normal_count")).longValue() : 0L;
long abn = row.get("abnormal_count") != null ? ((Number) row.get("abnormal_count")).longValue() : 0L;
long sum = norm + abn;
double rate = (sum > 0) ? BigDecimal.valueOf(norm * 100.0 / sum).setScale(1, RoundingMode.HALF_UP).doubleValue() : 0.0;
planCompare.add(new CompareItemDto(id, name, sum, norm, abn, rate));
}
}
vo.setPlanComparison(planCompare);
// 4. 课程考勤对比 (Top 10)
List<CompareItemDto> courseCompare = new ArrayList<>();
List<Map<String, Object>> rawCourse = analysisMapper.selectCourseAttendanceComparison(normalized);
if (rawCourse != null)
{
for (Map<String, Object> row : rawCourse)
{
String id = String.valueOf(row.get("id"));
String name = String.valueOf(row.get("name"));
long norm = row.get("normal_count") != null ? ((Number) row.get("normal_count")).longValue() : 0L;
long abn = row.get("abnormal_count") != null ? ((Number) row.get("abnormal_count")).longValue() : 0L;
long sum = norm + abn;
double rate = (sum > 0) ? BigDecimal.valueOf(norm * 100.0 / sum).setScale(1, RoundingMode.HALF_UP).doubleValue() : 0.0;
courseCompare.add(new CompareItemDto(id, name, sum, norm, abn, rate));
}
}
vo.setCourseComparison(courseCompare);
// 5. 异常学员 Top 10
List<AbnormalStudentDto> abnormalTop10 = new ArrayList<>();
List<Map<String, Object>> rawAbnormal = analysisMapper.selectAbnormalStudentTop10(normalized);
if (rawAbnormal != null)
{
for (Map<String, Object> row : rawAbnormal)
{
AbnormalStudentDto dto = new AbnormalStudentDto();
dto.setStudentId(String.valueOf(row.get("student_id")));
dto.setStudentName(String.valueOf(row.get("student_name")));
dto.setPlanId(String.valueOf(row.get("plan_id")));
dto.setPlanName(String.valueOf(row.get("plan_name")));
dto.setAbsentCount(row.get("absent_count") != null ? ((Number) row.get("absent_count")).longValue() : 0L);
dto.setLateCount(row.get("late_count") != null ? ((Number) row.get("late_count")).longValue() : 0L);
dto.setEarlyLeaveCount(row.get("early_count") != null ? ((Number) row.get("early_count")).longValue() : 0L);
dto.setLeaveCount(row.get("leave_count") != null ? ((Number) row.get("leave_count")).longValue() : 0L);
dto.setNoCheckinCount(0L);
dto.setTotalAbnormalCount(row.get("total_abnormal_count") != null ? ((Number) row.get("total_abnormal_count")).longValue() : 0L);
abnormalTop10.add(dto);
}
}
vo.setAbnormalStudentTop10(abnormalTop10);
return vo;
}
@Override
public Student360Vo getStudent360(String studentId)
{
if (studentId == null || studentId.trim().isEmpty())
{
throw new ServiceException("学员学号不能为空");
}
Student360Vo vo = new Student360Vo();
vo.setStudentId(studentId);
// 1. 基础主档 (脱敏)
Map<String, Object> base = analysisMapper.selectStudentBaseProfile(studentId);
if (base == null || base.isEmpty())
{
throw new ServiceException("未查询到学号为 " + studentId + " 的学员主档");
}
vo.setStudentName(String.valueOf(base.get("student_name")));
String gender = String.valueOf(base.get("gender"));
vo.setGender(gender);
vo.setGenderName("0".equals(gender) ? "男" : ("1".equals(gender) ? "女" : "未知"));
if (base.get("birth") != null)
{
vo.setBirth((Date) base.get("birth"));
}
vo.setPhone(base.get("phone") != null ? String.valueOf(base.get("phone")) : "-");
vo.setEmail(base.get("email") != null ? String.valueOf(base.get("email")) : "-");
vo.setOrgName(base.get("org_name") != null ? String.valueOf(base.get("org_name")) : "-");
String last4 = base.get("id_card_last4") != null ? String.valueOf(base.get("id_card_last4")) : "";
vo.setIdCardMasked(last4.isEmpty() ? "未登记" : "**************" + last4);
vo.setUserBindingStatus(String.valueOf(base.get("user_binding_status")));
vo.setStatus(base.get("status") != null ? ((Number) base.get("status")).intValue() : 1);
// 2. 参训履历时间轴
List<StudentEnrollmentTimelineDto> timeline = new ArrayList<>();
List<Map<String, Object>> rawTimeline = analysisMapper.selectStudentEnrollmentTimeline(studentId);
if (rawTimeline != null)
{
for (Map<String, Object> row : rawTimeline)
{
StudentEnrollmentTimelineDto dto = new StudentEnrollmentTimelineDto();
dto.setEnrollmentId(((Number) row.get("enrollment_id")).longValue());
dto.setPlanId(String.valueOf(row.get("plan_id")));
dto.setPlanName(String.valueOf(row.get("plan_name")));
Integer enrStatus = row.get("enrollment_status") != null ? ((Number) row.get("enrollment_status")).intValue() : 0;
dto.setEnrollmentStatus(enrStatus);
dto.setEnrollmentStatusName(formatEnrollmentStatus(enrStatus));
if (row.get("join_time") != null) dto.setJoinTime((Date) row.get("join_time"));
if (row.get("report_way") != null)
{
int rw = ((Number) row.get("report_way")).intValue();
dto.setReportWay(rw);
dto.setReportWayName(formatReportWay(rw));
}
if (row.get("verify_result") != null)
{
int vr = ((Number) row.get("verify_result")).intValue();
dto.setVerifyResult(vr);
dto.setVerifyResultName(formatVerifyResult(vr));
}
if (row.get("report_time") != null) dto.setReportTime((Date) row.get("report_time"));
timeline.add(dto);
}
}
vo.setEnrollmentTimeline(timeline);
// 3. 考勤画像
Map<String, Long> checkinMap = new HashMap<>();
List<Map<String, Object>> rawCheckin = analysisMapper.selectStudentAttendanceStats(studentId);
long logged = 0L;
if (rawCheckin != null)
{
for (Map<String, Object> row : rawCheckin)
{
String code = String.valueOf(row.get("status_code"));
long cnt = row.get("count_num") != null ? ((Number) row.get("count_num")).longValue() : 0L;
checkinMap.put(code, cnt);
logged += cnt;
}
}
Long expected = analysisMapper.countStudentExpectedAttendance(studentId);
long exp = expected != null ? expected : 0L;
long norm = checkinMap.getOrDefault("1", 0L);
long unlogged = (exp > logged) ? (exp - logged) : 0L;
vo.setExpectedAttendCount(exp);
vo.setNormalCount(norm);
vo.setLateCount(checkinMap.getOrDefault("2", 0L));
vo.setEarlyCount(checkinMap.getOrDefault("3", 0L));
vo.setLeaveCount(checkinMap.getOrDefault("4", 0L));
vo.setAbsentCount(checkinMap.getOrDefault("0", 0L));
vo.setNoCheckinCount(unlogged);
if (exp > 0)
{
double rate = BigDecimal.valueOf(norm * 100.0 / exp).setScale(1, RoundingMode.HALF_UP).doubleValue();
vo.setAttendanceRate(rate);
}
else
{
vo.setAttendanceRate(0.0);
}
// 4. 成绩状态 (严格不造假)
vo.setHasScoreData(false);
vo.setScoreStatusDesc("暂无成绩数据 (V2成绩表待考务录入)");
// 5. 自主学习状态 (严格不造假)
vo.setHasStudyData(false);
vo.setStudyStatusDesc("暂无自主学习记录 / 待外部平台数据同步");
// 6. 住宿与门禁状态
Map<String, Object> dorm = analysisMapper.selectStudentAccommodationAuth(studentId);
if (dorm != null && !dorm.isEmpty())
{
vo.setHasAccommodation(true);
vo.setDormId(dorm.get("dorm_id") != null ? String.valueOf(dorm.get("dorm_id")) : "-");
vo.setRoomName(dorm.get("room_name") != null ? String.valueOf(dorm.get("room_name")) : "宿舍");
vo.setBedNo(dorm.get("bed_no") != null ? String.valueOf(dorm.get("bed_no")) : "-");
Integer accStatus = dorm.get("accommodation_status") != null ? ((Number) dorm.get("accommodation_status")).intValue() : 0;
vo.setAccommodationStatus(accStatus);
vo.setAccommodationStatusName(accStatus == 1 ? "已入住" : (accStatus == 0 ? "预分配" : "已退宿"));
String authStatus = dorm.get("auth_status") != null ? String.valueOf(dorm.get("auth_status")) : "NONE";
vo.setAuthStatus(authStatus);
vo.setAuthStatusName(formatAuthStatus(authStatus));
}
else
{
vo.setHasAccommodation(false);
vo.setAccommodationStatusName("未安排住宿");
vo.setAuthStatusName("无门禁授权");
}
return vo;
}
private AttendanceFilterDto normalizeFilter(AttendanceFilterDto filter)
{
AttendanceFilterDto dto = new AttendanceFilterDto();
if (filter != null)
{
dto.setPlanId(filter.getPlanId() != null && !filter.getPlanId().trim().isEmpty() ? filter.getPlanId().trim() : null);
dto.setCourseId(filter.getCourseId() != null && !filter.getCourseId().trim().isEmpty() ? filter.getCourseId().trim() : null);
dto.setStartDate(filter.getStartDate() != null && !filter.getStartDate().trim().isEmpty() ? filter.getStartDate().trim() : null);
dto.setEndDate(filter.getEndDate() != null && !filter.getEndDate().trim().isEmpty() ? filter.getEndDate().trim() : null);
}
// 日期处理:默认近 30 天,最大限制 365 天
LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(30);
try
{
if (dto.getEndDate() != null)
{
end = LocalDate.parse(dto.getEndDate(), DATE_FORMATTER);
}
if (dto.getStartDate() != null)
{
start = LocalDate.parse(dto.getStartDate(), DATE_FORMATTER);
}
}
catch (Exception e)
{
log.warn("日期格式解析失败,自动回退默认近30天", e);
end = LocalDate.now();
start = end.minusDays(30);
}
// 校验范围最大 365 天
if (start.isAfter(end))
{
LocalDate tmp = start;
start = end;
end = tmp;
}
long days = ChronoUnit.DAYS.between(start, end);
if (days > 365)
{
start = end.minusDays(365);
}
dto.setStartDate(start.format(DATE_FORMATTER));
dto.setEndDate(end.format(DATE_FORMATTER));
return dto;
}
private String formatEnrollmentStatus(int status)
{
switch (status)
{
case 0: return "待报到";
case 1: return "已报到";
case 2: return "请假";
case 3: return "放弃";
case 4: return "已完成";
default: return "未知状态";
}
}
private String formatReportWay(int way)
{
switch (way)
{
case 1: return "人脸识别";
case 2: return "身份证刷卡";
case 3: return "二维码";
case 4: return "人工录入";
default: return "其他方式";
}
}
private String formatVerifyResult(int res)
{
switch (res)
{
case 1: return "核验通过";
case 2: return "核验不匹配";
case 3: return "人工放行";
default: return "未核验";
}
}
private String formatAuthStatus(String status)
{
if ("ACTIVE".equalsIgnoreCase(status)) return "生效中";
if ("PENDING_GRANT".equalsIgnoreCase(status)) return "待下发";
if ("GRANTING".equalsIgnoreCase(status)) return "下发中";
if ("PENDING_REVOKE".equalsIgnoreCase(status)) return "待注销";
if ("REVOKING".equalsIgnoreCase(status)) return "注销中";
if ("REVOKED".equalsIgnoreCase(status)) return "已注销";
if ("ERROR".equalsIgnoreCase(status)) return "授权异常";
return "未授权";
}
}
@@ -0,0 +1,409 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.train.mapper.TrainAnalysisMapper">
<!-- 1. 驾驶舱 KPI 计数 -->
<select id="countStudents" resultType="java.lang.Long">
SELECT count(1) FROM train_student WHERE del_flag = '0'
</select>
<select id="countTeachers" resultType="java.lang.Long">
SELECT count(1) FROM train_teacher WHERE del_flag = '0'
</select>
<select id="countCourses" resultType="java.lang.Long">
SELECT count(1) FROM train_course WHERE del_flag = '0'
</select>
<select id="countPlans" resultType="java.lang.Long">
SELECT count(1) FROM train_class_plan WHERE del_flag = '0'
</select>
<select id="countLessons" resultType="java.lang.Long">
SELECT count(1) FROM train_lesson
</select>
<select id="countEnrollments" resultType="java.lang.Long">
SELECT count(1) FROM train_enrollment
</select>
<select id="countReports" resultType="java.lang.Long">
SELECT count(1) FROM train_class_report
</select>
<select id="countCurrentAccommodations" resultType="java.lang.Long">
SELECT count(1) FROM train_accommodation WHERE accommodation_status = 1
</select>
<select id="selectPlanStatusDistribution" resultType="java.util.HashMap">
SELECT CAST(plan_status AS VARCHAR) AS status_code, count(1) AS count_num
FROM train_class_plan
WHERE del_flag = '0'
GROUP BY plan_status
</select>
<select id="countAbnormalAttendance" resultType="java.lang.Long">
SELECT count(1) FROM train_student_checkin WHERE attendance_status IN (0, 2, 3, 4)
</select>
<select id="countPendingReports" resultType="java.lang.Long">
SELECT count(1) FROM train_enrollment WHERE enrollment_status = 0
</select>
<select id="countPreallocatedAccommodations" resultType="java.lang.Long">
SELECT count(1) FROM train_accommodation WHERE accommodation_status = 0
</select>
<select id="countAbnormalDormAuths" resultType="java.lang.Long">
SELECT count(1) FROM train_dorm_auth WHERE auth_status = 'ERROR' OR last_error IS NOT NULL
</select>
<!-- 2. 驾驶舱图表 -->
<select id="selectAttendanceTrend30Days" resultType="java.util.HashMap">
SELECT
TO_CHAR(c.sign_in_time, 'YYYY-MM-DD') AS stat_date,
SUM(CASE WHEN c.attendance_status = 1 THEN 1 ELSE 0 END) AS normal_count,
SUM(CASE WHEN c.attendance_status != 1 THEN 1 ELSE 0 END) AS abnormal_count
FROM train_student_checkin c
WHERE c.sign_in_time IS NOT NULL
AND c.sign_in_time &gt;= CURRENT_DATE - INTERVAL '30 days'
GROUP BY TO_CHAR(c.sign_in_time, 'YYYY-MM-DD')
ORDER BY stat_date ASC
</select>
<select id="selectPlanEnrollmentReportComparisonTop10" resultType="java.util.HashMap">
SELECT
p.plan_id AS plan_id,
p.plan_name AS plan_name,
COUNT(DISTINCT e.enrollment_id) AS enrolled_count,
COUNT(DISTINCT r.report_id) AS reported_count
FROM train_class_plan p
LEFT JOIN train_enrollment e ON e.plan_id = p.plan_id
LEFT JOIN train_class_report r ON r.enrollment_id = e.enrollment_id
WHERE p.del_flag = '0'
GROUP BY p.plan_id, p.plan_name, p.create_time
ORDER BY p.create_time DESC
LIMIT 10
</select>
<select id="selectAttendanceStatusDistribution" resultType="java.util.HashMap">
SELECT
CAST(attendance_status AS VARCHAR) AS status_code,
COUNT(1) AS count_num
FROM train_student_checkin
GROUP BY attendance_status
ORDER BY attendance_status ASC
</select>
<select id="countTotalExpectedAttendanceForPastLessons" resultType="java.lang.Long">
SELECT count(1)
FROM train_lesson l
JOIN train_plan_course pc ON pc.plan_course_id = l.plan_course_id
JOIN train_enrollment e ON e.plan_id = pc.plan_id
WHERE l.start_time &lt;= CURRENT_TIMESTAMP
</select>
<select id="selectVenueUsageTop5" resultType="java.util.HashMap">
SELECT
COALESCE(cr.room_name, ts.site_name, '未知场地') AS venue_name,
count(1) AS usage_count,
ROUND(SUM(EXTRACT(EPOCH FROM (l.end_time - l.start_time)) / 3600.0)::numeric, 1) AS total_hours
FROM train_lesson l
LEFT JOIN train_classroom_info cr ON cr.class_room_id = l.class_room_id
LEFT JOIN train_training_site ts ON ts.site_id = l.site_id
GROUP BY COALESCE(cr.room_name, ts.site_name, '未知场地')
ORDER BY usage_count DESC, total_hours DESC
LIMIT 5
</select>
<!-- 3. 考勤专题分析 SQL -->
<select id="countFilteredLessons" resultType="java.lang.Long">
SELECT count(DISTINCT l.lesson_id)
FROM train_lesson l
JOIN train_plan_course pc ON pc.plan_course_id = l.plan_course_id
WHERE 1 = 1
<if test="dto.startDate != null and dto.startDate != ''">
AND l.start_time &gt;= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.endDate != null and dto.endDate != ''">
AND l.start_time &lt;= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.planId != null and dto.planId != ''">
AND pc.plan_id = #{dto.planId}
</if>
<if test="dto.courseId != null and dto.courseId != ''">
AND pc.c_id = #{dto.courseId}
</if>
</select>
<select id="countFilteredExpectedAttendance" resultType="java.lang.Long">
SELECT count(1)
FROM train_lesson l
JOIN train_plan_course pc ON pc.plan_course_id = l.plan_course_id
JOIN train_enrollment e ON e.plan_id = pc.plan_id
WHERE l.start_time &lt;= CURRENT_TIMESTAMP
<if test="dto.startDate != null and dto.startDate != ''">
AND l.start_time &gt;= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.endDate != null and dto.endDate != ''">
AND l.start_time &lt;= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.planId != null and dto.planId != ''">
AND pc.plan_id = #{dto.planId}
</if>
<if test="dto.courseId != null and dto.courseId != ''">
AND pc.c_id = #{dto.courseId}
</if>
</select>
<select id="countFilteredNormalAttendance" resultType="java.lang.Long">
SELECT count(1)
FROM train_student_checkin c
JOIN train_lesson l ON l.lesson_id = c.lesson_id
JOIN train_plan_course pc ON pc.plan_course_id = l.plan_course_id
WHERE c.attendance_status = 1
<if test="dto.startDate != null and dto.startDate != ''">
AND l.start_time &gt;= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.endDate != null and dto.endDate != ''">
AND l.start_time &lt;= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.planId != null and dto.planId != ''">
AND pc.plan_id = #{dto.planId}
</if>
<if test="dto.courseId != null and dto.courseId != ''">
AND pc.c_id = #{dto.courseId}
</if>
</select>
<select id="countFilteredAbnormalAttendance" resultType="java.lang.Long">
SELECT count(1)
FROM train_student_checkin c
JOIN train_lesson l ON l.lesson_id = c.lesson_id
JOIN train_plan_course pc ON pc.plan_course_id = l.plan_course_id
WHERE c.attendance_status != 1
<if test="dto.startDate != null and dto.startDate != ''">
AND l.start_time &gt;= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.endDate != null and dto.endDate != ''">
AND l.start_time &lt;= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.planId != null and dto.planId != ''">
AND pc.plan_id = #{dto.planId}
</if>
<if test="dto.courseId != null and dto.courseId != ''">
AND pc.c_id = #{dto.courseId}
</if>
</select>
<select id="selectFilteredDailyTrend" resultType="java.util.HashMap">
SELECT
TO_CHAR(c.sign_in_time, 'YYYY-MM-DD') AS stat_date,
SUM(CASE WHEN c.attendance_status = 1 THEN 1 ELSE 0 END) AS normal_count,
SUM(CASE WHEN c.attendance_status != 1 THEN 1 ELSE 0 END) AS abnormal_count
FROM train_student_checkin c
JOIN train_lesson l ON l.lesson_id = c.lesson_id
JOIN train_plan_course pc ON pc.plan_course_id = l.plan_course_id
WHERE c.sign_in_time IS NOT NULL
<if test="dto.startDate != null and dto.startDate != ''">
AND l.start_time &gt;= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.endDate != null and dto.endDate != ''">
AND l.start_time &lt;= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.planId != null and dto.planId != ''">
AND pc.plan_id = #{dto.planId}
</if>
<if test="dto.courseId != null and dto.courseId != ''">
AND pc.c_id = #{dto.courseId}
</if>
GROUP BY TO_CHAR(c.sign_in_time, 'YYYY-MM-DD')
ORDER BY stat_date ASC
</select>
<select id="selectFilteredStatusDistribution" resultType="java.util.HashMap">
SELECT
CAST(c.attendance_status AS VARCHAR) AS status_code,
COUNT(1) AS count_num
FROM train_student_checkin c
JOIN train_lesson l ON l.lesson_id = c.lesson_id
JOIN train_plan_course pc ON pc.plan_course_id = l.plan_course_id
WHERE 1 = 1
<if test="dto.startDate != null and dto.startDate != ''">
AND l.start_time &gt;= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.endDate != null and dto.endDate != ''">
AND l.start_time &lt;= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.planId != null and dto.planId != ''">
AND pc.plan_id = #{dto.planId}
</if>
<if test="dto.courseId != null and dto.courseId != ''">
AND pc.c_id = #{dto.courseId}
</if>
GROUP BY c.attendance_status
ORDER BY c.attendance_status ASC
</select>
<select id="selectPlanAttendanceComparison" resultType="java.util.HashMap">
SELECT
p.plan_id AS id,
p.plan_name AS name,
SUM(CASE WHEN c.attendance_status = 1 THEN 1 ELSE 0 END) AS normal_count,
SUM(CASE WHEN c.attendance_status != 1 THEN 1 ELSE 0 END) AS abnormal_count
FROM train_class_plan p
JOIN train_plan_course pc ON pc.plan_id = p.plan_id
JOIN train_lesson l ON l.plan_course_id = pc.plan_course_id
LEFT JOIN train_student_checkin c ON c.lesson_id = l.lesson_id
WHERE p.del_flag = '0'
<if test="dto.startDate != null and dto.startDate != ''">
AND l.start_time &gt;= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.endDate != null and dto.endDate != ''">
AND l.start_time &lt;= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.planId != null and dto.planId != ''">
AND p.plan_id = #{dto.planId}
</if>
<if test="dto.courseId != null and dto.courseId != ''">
AND pc.c_id = #{dto.courseId}
</if>
GROUP BY p.plan_id, p.plan_name
ORDER BY normal_count DESC
LIMIT 10
</select>
<select id="selectCourseAttendanceComparison" resultType="java.util.HashMap">
SELECT
crs.c_id AS id,
crs.c_name AS name,
SUM(CASE WHEN c.attendance_status = 1 THEN 1 ELSE 0 END) AS normal_count,
SUM(CASE WHEN c.attendance_status != 1 THEN 1 ELSE 0 END) AS abnormal_count
FROM train_course crs
JOIN train_plan_course pc ON pc.c_id = crs.c_id
JOIN train_lesson l ON l.plan_course_id = pc.plan_course_id
LEFT JOIN train_student_checkin c ON c.lesson_id = l.lesson_id
WHERE crs.del_flag = '0'
<if test="dto.startDate != null and dto.startDate != ''">
AND l.start_time &gt;= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.endDate != null and dto.endDate != ''">
AND l.start_time &lt;= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.planId != null and dto.planId != ''">
AND pc.plan_id = #{dto.planId}
</if>
<if test="dto.courseId != null and dto.courseId != ''">
AND crs.c_id = #{dto.courseId}
</if>
GROUP BY crs.c_id, crs.c_name
ORDER BY normal_count DESC
LIMIT 10
</select>
<select id="selectAbnormalStudentTop10" resultType="java.util.HashMap">
SELECT
s.s_id AS student_id,
s.s_name AS student_name,
p.plan_id AS plan_id,
p.plan_name AS plan_name,
SUM(CASE WHEN c.attendance_status = 0 THEN 1 ELSE 0 END) AS absent_count,
SUM(CASE WHEN c.attendance_status = 2 THEN 1 ELSE 0 END) AS late_count,
SUM(CASE WHEN c.attendance_status = 3 THEN 1 ELSE 0 END) AS early_count,
SUM(CASE WHEN c.attendance_status = 4 THEN 1 ELSE 0 END) AS leave_count,
SUM(CASE WHEN c.attendance_status IN (0, 2, 3, 4) THEN 1 ELSE 0 END) AS total_abnormal_count
FROM train_student_checkin c
JOIN train_enrollment e ON e.enrollment_id = c.enrollment_id
JOIN train_student s ON s.s_id = e.student_id
JOIN train_class_plan p ON p.plan_id = e.plan_id
JOIN train_lesson l ON l.lesson_id = c.lesson_id
JOIN train_plan_course pc ON pc.plan_course_id = l.plan_course_id
WHERE c.attendance_status IN (0, 2, 3, 4)
<if test="dto.startDate != null and dto.startDate != ''">
AND l.start_time &gt;= TO_TIMESTAMP(#{dto.startDate} || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.endDate != null and dto.endDate != ''">
AND l.start_time &lt;= TO_TIMESTAMP(#{dto.endDate} || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
</if>
<if test="dto.planId != null and dto.planId != ''">
AND p.plan_id = #{dto.planId}
</if>
<if test="dto.courseId != null and dto.courseId != ''">
AND pc.c_id = #{dto.courseId}
</if>
GROUP BY s.s_id, s.s_name, p.plan_id, p.plan_name
ORDER BY total_abnormal_count DESC, absent_count DESC
LIMIT 10
</select>
<!-- 4. 学员 360 画像 SQL -->
<select id="selectStudentBaseProfile" resultType="java.util.HashMap">
SELECT
s.s_id AS student_id,
s.s_name AS student_name,
s.s_gender AS gender,
s.s_birth AS birth,
s.s_phone AS phone,
s.s_email AS email,
s.org_name AS org_name,
s.id_card_last4 AS id_card_last4,
s.status AS status,
CASE WHEN s.user_id IS NOT NULL THEN '已绑定' ELSE '未绑定' END AS user_binding_status
FROM train_student s
WHERE s.s_id = #{studentId} AND s.del_flag = '0'
</select>
<select id="selectStudentEnrollmentTimeline" resultType="java.util.HashMap">
SELECT
e.enrollment_id AS enrollment_id,
p.plan_id AS plan_id,
p.plan_name AS plan_name,
e.enrollment_status AS enrollment_status,
e.join_time AS join_time,
r.report_way AS report_way,
r.verify_result AS verify_result,
r.report_time AS report_time
FROM train_enrollment e
JOIN train_class_plan p ON p.plan_id = e.plan_id
LEFT JOIN train_class_report r ON r.enrollment_id = e.enrollment_id
WHERE e.student_id = #{studentId}
ORDER BY e.create_time DESC
</select>
<select id="selectStudentAttendanceStats" resultType="java.util.HashMap">
SELECT
CAST(c.attendance_status AS VARCHAR) AS status_code,
COUNT(1) AS count_num
FROM train_student_checkin c
JOIN train_enrollment e ON e.enrollment_id = c.enrollment_id
WHERE e.student_id = #{studentId}
GROUP BY c.attendance_status
</select>
<select id="countStudentExpectedAttendance" resultType="java.lang.Long">
SELECT count(1)
FROM train_enrollment e
JOIN train_plan_course pc ON pc.plan_id = e.plan_id
JOIN train_lesson l ON l.plan_course_id = pc.plan_course_id
WHERE e.student_id = #{studentId}
AND l.start_time &lt;= CURRENT_TIMESTAMP
</select>
<select id="selectStudentAccommodationAuth" resultType="java.util.HashMap">
SELECT
a.accommodation_id AS accommodation_id,
a.dorm_id AS dorm_id,
d.dorm_name AS room_name,
a.bed_no AS bed_no,
a.accommodation_status AS accommodation_status,
auth.auth_status AS auth_status
FROM train_accommodation a
JOIN train_enrollment e ON e.enrollment_id = a.enrollment_id
LEFT JOIN train_dormitory_info d ON d.dorm_id = a.dorm_id
LEFT JOIN train_dorm_auth auth ON auth.accommodation_id = a.accommodation_id
WHERE e.student_id = #{studentId}
ORDER BY a.create_time DESC
LIMIT 1
</select>
</mapper>
@@ -0,0 +1,394 @@
package com.ruoyi.train.service.impl;
import static org.junit.Assert.*;
import java.lang.reflect.Field;
import java.util.*;
import org.junit.Before;
import org.junit.Test;
import com.ruoyi.train.domain.analysis.*;
import com.ruoyi.train.mapper.TrainAnalysisMapper;
/**
* 数据分析与可视化聚合服务单元测试
*/
public class TrainAnalysisServiceTest
{
private TrainAnalysisServiceImpl service;
private MockTrainAnalysisMapper mockMapper;
@Before
public void setUp() throws Exception
{
service = new TrainAnalysisServiceImpl();
mockMapper = new MockTrainAnalysisMapper();
Field field = TrainAnalysisServiceImpl.class.getDeclaredField("analysisMapper");
field.setAccessible(true);
field.set(service, mockMapper);
}
@Test
public void testDashboardOverviewAggregation()
{
DashboardOverviewVo vo = service.getDashboardOverview();
assertNotNull(vo);
assertEquals(Long.valueOf(200), vo.getStudentTotal());
assertEquals(Long.valueOf(20), vo.getTeacherTotal());
assertEquals(Long.valueOf(24), vo.getCourseTotal());
assertEquals(Long.valueOf(13), vo.getPlanTotal());
assertEquals(Long.valueOf(118), vo.getLessonTotal());
assertEquals(Long.valueOf(242), vo.getEnrollmentTotal());
assertEquals(Long.valueOf(63), vo.getReportTotal());
assertEquals(Long.valueOf(1), vo.getAccommodationCurrent());
Map<String, Long> planMap = vo.getPlanStatusCounts();
assertNotNull(planMap);
assertEquals(Long.valueOf(10), planMap.get("notStarted"));
assertEquals(Long.valueOf(0), planMap.get("ongoing"));
assertEquals(Long.valueOf(3), planMap.get("ended"));
assertEquals(Long.valueOf(0), planMap.get("cancelled"));
assertEquals(Long.valueOf(4), vo.getAbnormalAttendanceCount());
assertEquals(Long.valueOf(179), vo.getPendingReportCount());
assertEquals(Long.valueOf(48), vo.getPreallocatedAccommodationCount());
assertEquals(Long.valueOf(1), vo.getAbnormalAuthCount());
}
@Test
public void testDashboardChartsAggregation()
{
DashboardChartsVo vo = service.getDashboardCharts();
assertNotNull(vo);
assertEquals(2, vo.getTrendDates().size());
assertEquals(Long.valueOf(30), vo.getTrendNormalCounts().get(0));
assertEquals(Long.valueOf(2), vo.getTrendAbnormalCounts().get(0));
// 验证考勤状态构成包含“无签到记录”
List<NameValueDto> dist = vo.getAttendanceStatusDistribution();
assertNotNull(dist);
boolean foundUnlogged = false;
long unloggedCount = 0;
for (NameValueDto item : dist)
{
if ("无签到记录".equals(item.getName()))
{
foundUnlogged = true;
unloggedCount = item.getValue();
}
}
assertTrue("应包含无签到记录", foundUnlogged);
// 总应到 100,已有记录 77,无签到记录应为 23
assertEquals(23L, unloggedCount);
// 验证场地使用 Top 5
assertEquals(2, vo.getVenueUsageTop5().size());
assertEquals("主楼101多媒体教室", vo.getVenueUsageTop5().get(0).getVenueName());
assertEquals(Long.valueOf(50), vo.getVenueUsageTop5().get(0).getUsageCount());
assertEquals(Double.valueOf(100.0), vo.getVenueUsageTop5().get(0).getTotalHours());
}
@Test
public void testAttendanceSummaryWithUnloggedRateCalculation()
{
AttendanceFilterDto filter = new AttendanceFilterDto();
filter.setStartDate("2026-08-01");
filter.setEndDate("2026-08-31");
AttendanceSummaryVo vo = service.getAttendanceSummary(filter);
assertNotNull(vo);
assertEquals(Long.valueOf(20), vo.getLessonTotal());
assertEquals(Long.valueOf(100), vo.getExpectedAttendTotal());
assertEquals(Long.valueOf(73), vo.getNormalAttendTotal());
// 异常人次 = 4 (已打卡异常) + (100 - 77) (无签到) = 27
assertEquals(Long.valueOf(27), vo.getAbnormalAttendTotal());
// 出勤率 = 73 / 100 = 73.0%
assertEquals(Double.valueOf(73.0), vo.getAttendanceRate());
assertTrue(vo.getMetricDefinitionNotice().contains("BUSINESS_METRIC_DEFINITION_PENDING_CONFIRMATION"));
}
@Test
public void testAttendanceSummaryEmptyData()
{
mockMapper.expectedTotal = 0L;
mockMapper.normalTotal = 0L;
mockMapper.abnormalTotal = 0L;
mockMapper.lessonTotal = 0L;
AttendanceSummaryVo vo = service.getAttendanceSummary(new AttendanceFilterDto());
assertNotNull(vo);
assertEquals(Long.valueOf(0), vo.getLessonTotal());
assertEquals(Long.valueOf(0), vo.getExpectedAttendTotal());
assertEquals(Double.valueOf(0.0), vo.getAttendanceRate());
}
@Test
public void testAttendanceChartsDateRangeSafety()
{
// 传入超过 365 天的大范围日期
AttendanceFilterDto filter = new AttendanceFilterDto();
filter.setStartDate("2020-01-01");
filter.setEndDate("2026-09-20");
AttendanceChartsVo vo = service.getAttendanceCharts(filter);
assertNotNull(vo);
assertNotNull(vo.getDailyTrend());
assertNotNull(vo.getStatusDistribution());
assertNotNull(vo.getPlanComparison());
assertNotNull(vo.getCourseComparison());
assertNotNull(vo.getAbnormalStudentTop10());
}
@Test
public void testStudent360ProfileObjectiveCompliance()
{
Student360Vo vo = service.getStudent360("S2026001");
assertNotNull(vo);
assertEquals("S2026001", vo.getStudentId());
assertEquals("张三", vo.getStudentName());
assertEquals("男", vo.getGenderName());
assertEquals("**************1234", vo.getIdCardMasked());
assertEquals("已绑定", vo.getUserBindingStatus());
// 履历
assertEquals(1, vo.getEnrollmentTimeline().size());
assertEquals("已报到", vo.getEnrollmentTimeline().get(0).getEnrollmentStatusName());
assertEquals("人脸识别", vo.getEnrollmentTimeline().get(0).getReportWayName());
// 考勤
assertEquals(Long.valueOf(10), vo.getExpectedAttendCount());
assertEquals(Long.valueOf(8), vo.getNormalCount());
assertEquals(Long.valueOf(1), vo.getAbsentCount());
assertEquals(Long.valueOf(1), vo.getNoCheckinCount());
assertEquals(Double.valueOf(80.0), vo.getAttendanceRate());
// 成绩与自主学习坚决不造假
assertFalse(vo.getHasScoreData());
assertEquals("暂无成绩数据 (V2成绩表待考务录入)", vo.getScoreStatusDesc());
assertFalse(vo.getHasStudyData());
assertEquals("暂无自主学习记录 / 待外部平台数据同步", vo.getStudyStatusDesc());
// 住宿与门禁
assertTrue(vo.getHasAccommodation());
assertEquals("101", vo.getRoomName());
assertEquals("A床", vo.getBedNo());
assertEquals("已入住", vo.getAccommodationStatusName());
assertEquals("生效中", vo.getAuthStatusName());
}
// ==================== Mock Mapper Implementation ====================
private static class MockTrainAnalysisMapper implements TrainAnalysisMapper
{
Long lessonTotal = 20L;
Long expectedTotal = 100L;
Long normalTotal = 73L;
Long abnormalTotal = 4L;
public Long countStudents() { return 200L; }
public Long countTeachers() { return 20L; }
public Long countCourses() { return 24L; }
public Long countPlans() { return 13L; }
public Long countLessons() { return 118L; }
public Long countEnrollments() { return 242L; }
public Long countReports() { return 63L; }
public Long countCurrentAccommodations() { return 1L; }
public List<Map<String, Object>> selectPlanStatusDistribution()
{
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> m1 = new HashMap<>();
m1.put("status_code", "0");
m1.put("count_num", 10L);
list.add(m1);
Map<String, Object> m2 = new HashMap<>();
m2.put("status_code", "2");
m2.put("count_num", 3L);
list.add(m2);
return list;
}
public Long countAbnormalAttendance() { return 4L; }
public Long countPendingReports() { return 179L; }
public Long countPreallocatedAccommodations() { return 48L; }
public Long countAbnormalDormAuths() { return 1L; }
public List<Map<String, Object>> selectAttendanceTrend30Days()
{
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> row1 = new HashMap<>();
row1.put("stat_date", "2026-09-01");
row1.put("normal_count", 30L);
row1.put("abnormal_count", 2L);
list.add(row1);
Map<String, Object> row2 = new HashMap<>();
row2.put("stat_date", "2026-09-02");
row2.put("normal_count", 43L);
row2.put("abnormal_count", 2L);
list.add(row2);
return list;
}
public List<Map<String, Object>> selectPlanEnrollmentReportComparisonTop10()
{
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> row = new HashMap<>();
row.put("plan_id", "PLAN001");
row.put("plan_name", "2026年第一期房桥线路维修专项培训");
row.put("enrolled_count", 50L);
row.put("reported_count", 45L);
list.add(row);
return list;
}
public List<Map<String, Object>> selectAttendanceStatusDistribution()
{
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> r1 = new HashMap<>();
r1.put("status_code", "1");
r1.put("count_num", 73L);
list.add(r1);
Map<String, Object> r2 = new HashMap<>();
r2.put("status_code", "0");
r2.put("count_num", 2L);
list.add(r2);
Map<String, Object> r3 = new HashMap<>();
r3.put("status_code", "4");
r3.put("count_num", 2L);
list.add(r3);
return list;
}
public Long countTotalExpectedAttendanceForPastLessons() { return 100L; }
public List<Map<String, Object>> selectVenueUsageTop5()
{
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> r1 = new HashMap<>();
r1.put("venue_name", "主楼101多媒体教室");
r1.put("usage_count", 50L);
r1.put("total_hours", 100.0);
list.add(r1);
Map<String, Object> r2 = new HashMap<>();
r2.put("venue_name", "室外综合接触网实训场");
r2.put("usage_count", 30L);
r2.put("total_hours", 60.0);
list.add(r2);
return list;
}
public Long countFilteredLessons(AttendanceFilterDto dto) { return lessonTotal; }
public Long countFilteredExpectedAttendance(AttendanceFilterDto dto) { return expectedTotal; }
public Long countFilteredNormalAttendance(AttendanceFilterDto dto) { return normalTotal; }
public Long countFilteredAbnormalAttendance(AttendanceFilterDto dto) { return abnormalTotal; }
public List<Map<String, Object>> selectFilteredDailyTrend(AttendanceFilterDto dto)
{
return selectAttendanceTrend30Days();
}
public List<Map<String, Object>> selectFilteredStatusDistribution(AttendanceFilterDto dto)
{
return selectAttendanceStatusDistribution();
}
public List<Map<String, Object>> selectPlanAttendanceComparison(AttendanceFilterDto dto)
{
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> row = new HashMap<>();
row.put("id", "PLAN001");
row.put("name", "计划一");
row.put("normal_count", 73L);
row.put("abnormal_count", 4L);
list.add(row);
return list;
}
public List<Map<String, Object>> selectCourseAttendanceComparison(AttendanceFilterDto dto)
{
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> row = new HashMap<>();
row.put("id", "C001");
row.put("name", "铁道概论");
row.put("normal_count", 50L);
row.put("abnormal_count", 2L);
list.add(row);
return list;
}
public List<Map<String, Object>> selectAbnormalStudentTop10(AttendanceFilterDto dto)
{
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> row = new HashMap<>();
row.put("student_id", "S2026001");
row.put("student_name", "张三");
row.put("plan_id", "PLAN001");
row.put("plan_name", "计划一");
row.put("absent_count", 2L);
row.put("late_count", 1L);
row.put("early_count", 0L);
row.put("leave_count", 1L);
row.put("total_abnormal_count", 4L);
list.add(row);
return list;
}
public Map<String, Object> selectStudentBaseProfile(String studentId)
{
Map<String, Object> map = new HashMap<>();
map.put("student_id", studentId);
map.put("student_name", "张三");
map.put("gender", "0");
map.put("birth", new Date());
map.put("phone", "13800000000");
map.put("email", "zhangsan@example.com");
map.put("org_name", "房建公寓段");
map.put("id_card_last4", "1234");
map.put("user_binding_status", "已绑定");
map.put("status", 1);
return map;
}
public List<Map<String, Object>> selectStudentEnrollmentTimeline(String studentId)
{
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> row = new HashMap<>();
row.put("enrollment_id", 1001L);
row.put("plan_id", "PLAN001");
row.put("plan_name", "计划一");
row.put("enrollment_status", 1);
row.put("join_time", new Date());
row.put("report_way", 1);
row.put("verify_result", 1);
row.put("report_time", new Date());
list.add(row);
return list;
}
public List<Map<String, Object>> selectStudentAttendanceStats(String studentId)
{
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> row1 = new HashMap<>();
row1.put("status_code", "1");
row1.put("count_num", 8L);
list.add(row1);
Map<String, Object> row2 = new HashMap<>();
row2.put("status_code", "0");
row2.put("count_num", 1L);
list.add(row2);
return list;
}
public Long countStudentExpectedAttendance(String studentId) { return 10L; }
public Map<String, Object> selectStudentAccommodationAuth(String studentId)
{
Map<String, Object> map = new HashMap<>();
map.put("accommodation_id", 801L);
map.put("dorm_id", "DORM01");
map.put("room_name", "101");
map.put("bed_no", "A床");
map.put("accommodation_status", 1);
map.put("auth_status", "ACTIVE");
return map;
}
}
}
@@ -0,0 +1,68 @@
-- =============================================================================
-- Migration: Analytics V1 Permissions & Legacy Menu Deactivation
-- Target: KingbaseES, schema public
-- Scope: Deactivate legacy dropped score menus, add attendance analytics menu & dashboard permissions
-- =============================================================================
BEGIN;
-- 1. Deactivate legacy score menus (2166, 2175, 2176)
UPDATE public.sys_menu
SET status = '1', visible = '1', update_by = 'admin', update_time = CURRENT_TIMESTAMP, remark = '已废弃旧表停用(V2架构)'
WHERE menu_id IN (2166, 2175, 2176);
-- 2. Insert attendance analytics menu under '统计分析' (parent_id = 2169)
INSERT INTO public.sys_menu
(menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
SELECT 2251, '考勤分析', 2169, 1, 'attendance', 'train/analysis/attendance/index', '', 1, 0,
'C', '0', '0', 'train:analysis:attendance', 'chart', 'admin', CURRENT_TIMESTAMP, '', NULL, '考勤专题分析页面'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2251);
-- 3. Insert attendance query button permission
INSERT INTO public.sys_menu
(menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
SELECT 2252, '考勤分析查询', 2251, 1, '#', '', '', 1, 0,
'F', '0', '0', 'train:analysis:attendance:query', '#', 'admin', CURRENT_TIMESTAMP, '', NULL, '考勤分析数据查询'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2252);
-- 4. Insert dashboard button permission
INSERT INTO public.sys_menu
(menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
SELECT 2253, '管理驾驶舱', 2169, 0, '#', '', '', 1, 0,
'F', '0', '0', 'train:analysis:dashboard', '#', 'admin', CURRENT_TIMESTAMP, '', NULL, '管理端驾驶舱权限'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2253);
-- 5. Grant permissions to train_admin role (role_id = 3)
INSERT INTO public.sys_role_menu (role_id, menu_id)
SELECT 3, 2251
WHERE NOT EXISTS (SELECT 1 FROM public.sys_role_menu WHERE role_id = 3 AND menu_id = 2251);
INSERT INTO public.sys_role_menu (role_id, menu_id)
SELECT 3, 2252
WHERE NOT EXISTS (SELECT 1 FROM public.sys_role_menu WHERE role_id = 3 AND menu_id = 2252);
INSERT INTO public.sys_role_menu (role_id, menu_id)
SELECT 3, 2253
WHERE NOT EXISTS (SELECT 1 FROM public.sys_role_menu WHERE role_id = 3 AND menu_id = 2253);
-- 6. Verification
DO $$
BEGIN
IF (SELECT count(*) FROM public.sys_menu WHERE menu_id = 2251 AND perms = 'train:analysis:attendance') <> 1 THEN
RAISE EXCEPTION 'Menu 2251 verification failed';
END IF;
IF (SELECT count(*) FROM public.sys_menu WHERE menu_id = 2252 AND perms = 'train:analysis:attendance:query') <> 1 THEN
RAISE EXCEPTION 'Menu 2252 verification failed';
END IF;
IF (SELECT count(*) FROM public.sys_menu WHERE menu_id = 2253 AND perms = 'train:analysis:dashboard') <> 1 THEN
RAISE EXCEPTION 'Menu 2253 verification failed';
END IF;
IF (SELECT count(*) FROM public.sys_role_menu WHERE role_id = 3 AND menu_id IN (2251, 2252, 2253)) <> 3 THEN
RAISE EXCEPTION 'Role assignment verification failed';
END IF;
END $$;
COMMIT;
+43
View File
@@ -0,0 +1,43 @@
import request from '@/utils/request'
// 获取管理驾驶舱宏观概览
export function getDashboardOverview() {
return request({
url: '/train/analysis/dashboard/overview',
method: 'get'
})
}
// 获取管理驾驶舱图表数据
export function getDashboardCharts() {
return request({
url: '/train/analysis/dashboard/charts',
method: 'get'
})
}
// 获取考勤专题分析汇总 KPI
export function getAttendanceSummary(params) {
return request({
url: '/train/analysis/attendance/summary',
method: 'get',
params: params
})
}
// 获取考勤专题分析图表集合
export function getAttendanceCharts(params) {
return request({
url: '/train/analysis/attendance/charts',
method: 'get',
params: params
})
}
// 获取指定学员 360 客观全景画像
export function getStudent360(studentId) {
return request({
url: '/train/analysis/portrait/student-360/' + studentId,
method: 'get'
})
}
+3 -23
View File
@@ -4,29 +4,7 @@
<div v-if="loadingRoles" v-loading="true" style="height: 300px;" />
<!-- 1. 管理员优先展示管理端综合驾驶舱 (最高优先级: admin / train_admin) -->
<div v-else-if="isAdmin" class="app-container home">
<el-row :gutter="20">
<el-col :sm="24" :lg="14" style="padding-left: 20px;">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span style="font-weight: bold; font-size: 16px;">SYFJGYD学员管理系统</span>
</div>
<div class="text item" style="margin-bottom: 16px; font-size: 14px; color: #606266; line-height: 1.8;">
<p>欢迎使用SYFJGYD学员管理系统。本平台提供培训计划编排、学员主档与参训管理、智能排课排程、电子班牌联动、教学设施与实训场地调度,以及全流程多维学习数据分析等核心功能。</p>
</div>
<div class="text item" style="margin-bottom: 20px; text-align: center">
<img src="@/assets/images/version.png" alt="系统架构" style="max-width: 100%; border-radius: 4px;">
</div>
<el-divider style="margin: 15px 0;" />
<div class="text item" style="display: flex; justify-content: space-between; color: #909399; font-size: 13px;">
<span>系统版本:V1.0</span>
<span>版权所有:中国科学院沈阳计算技术研究所有限公司</span>
</div>
</el-card>
</el-col>
</el-row>
<el-divider />
</div>
<admin-dashboard v-else-if="isAdmin" />
<!-- 2. 异常检测:同时拥有教师与学员互斥角色,展示受控安全告警 -->
<div v-else-if="hasRoleConflict" class="app-container">
@@ -59,12 +37,14 @@
</template>
<script>
import AdminDashboard from '@/views/train/analysis/dashboard/index'
import TeacherDashboard from '@/views/train/me/teacher/index'
import StudentDashboard from '@/views/train/me/student/index'
export default {
name: 'Index',
components: {
AdminDashboard,
TeacherDashboard,
StudentDashboard
},
@@ -0,0 +1,655 @@
<template>
<div class="dashboard-editor-container">
<!-- 1. 顶部标题与数据更新状态 -->
<div class="dashboard-header">
<div class="header-title">
<h2>SYFJGYD学员管理系统 · 管理驾驶舱</h2>
<span class="subtitle">全校培训教学运行、排课调度、学员考勤与生活保障综合宏观指挥大盘</span>
</div>
<div class="header-action">
<el-button icon="el-icon-refresh" size="mini" type="primary" plain :loading="loading" @click="refreshAll">刷新数据</el-button>
</div>
</div>
<!-- 2. 核心 KPI 卡片 (8项客观清晰指标) -->
<el-row :gutter="16" class="kpi-row" v-loading="loadingOverview">
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">学员总数</div>
<div class="kpi-value text-primary">{{ overview.studentTotal || 0 }}</div>
<div class="kpi-sub">主档档案正常</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">教师总数</div>
<div class="kpi-value text-success">{{ overview.teacherTotal || 0 }}</div>
<div class="kpi-sub">专兼职讲师</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">课程总数</div>
<div class="kpi-value text-info">{{ overview.courseTotal || 0 }}</div>
<div class="kpi-sub">标准课程库</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">培训计划</div>
<div class="kpi-value text-warning">{{ overview.planTotal || 0 }}</div>
<div class="kpi-sub">全周期班级数</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">累计排课</div>
<div class="kpi-value">{{ overview.lessonTotal || 0 }}</div>
<div class="kpi-sub">课次课时调度</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">累计参训</div>
<div class="kpi-value">{{ overview.enrollmentTotal || 0 }}</div>
<div class="kpi-sub">人次总规模</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">已报到人数</div>
<div class="kpi-value text-success">{{ overview.reportTotal || 0 }}</div>
<div class="kpi-sub">核验确认人次</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">当前在宿</div>
<div class="kpi-value text-primary">{{ overview.accommodationCurrent || 0 }}</div>
<div class="kpi-sub">宿舍在住学员</div>
</div>
</el-col>
</el-row>
<!-- 3. 计划状态与业务异常提醒条 -->
<el-row :gutter="16" style="margin-top: 16px;">
<!-- 计划运行状态分布 -->
<el-col :xs="24" :sm="12" :md="10">
<el-card shadow="never" class="status-card">
<div slot="header" class="card-header">
<span><i class="el-icon-date" style="color: #409EFF; margin-right: 6px;"></i>开班计划运行状态</span>
</div>
<div class="plan-status-tags" v-loading="loadingOverview">
<div class="status-tag-item">
<span class="dot dot-info"></span>
<span class="label">未开始:</span>
<span class="count">{{ planStatus.notStarted || 0 }}</span> 个
</div>
<div class="status-tag-item">
<span class="dot dot-success"></span>
<span class="label">进行中:</span>
<span class="count">{{ planStatus.ongoing || 0 }}</span> 个
</div>
<div class="status-tag-item">
<span class="dot dot-ended"></span>
<span class="label">已结束:</span>
<span class="count">{{ planStatus.ended || 0 }}</span> 个
</div>
<div class="status-tag-item">
<span class="dot dot-danger"></span>
<span class="label">已取消:</span>
<span class="count">{{ planStatus.cancelled || 0 }}</span> 个
</div>
</div>
</el-card>
</el-col>
<!-- 业务待办与异常提醒 -->
<el-col :xs="24" :sm="12" :md="14">
<el-card shadow="never" class="status-card">
<div slot="header" class="card-header">
<span><i class="el-icon-warning-outline" style="color: #E6A23C; margin-right: 6px;"></i>业务运行待办与治理提醒</span>
<span class="tip-text">点击可直接跳转业务处理</span>
</div>
<div class="alert-grid" v-loading="loadingOverview">
<div class="alert-item clickable" @click="goTo('/train/analysis/attendance')">
<div class="alert-icon bg-danger"><i class="el-icon-time" /></div>
<div class="alert-content">
<div class="alert-num text-danger">{{ overview.abnormalAttendanceCount || 0 }}</div>
<div class="alert-title">异常考勤人次</div>
</div>
</div>
<div class="alert-item clickable" @click="goTo('/train/report')">
<div class="alert-icon bg-warning"><i class="el-icon-document-checked" /></div>
<div class="alert-content">
<div class="alert-num text-warning">{{ overview.pendingReportCount || 0 }}</div>
<div class="alert-title">待报到参训学员</div>
</div>
</div>
<div class="alert-item clickable" @click="goTo('/train/accommodation')">
<div class="alert-icon bg-primary"><i class="el-icon-office-building" /></div>
<div class="alert-content">
<div class="alert-num text-primary">{{ overview.preallocatedAccommodationCount || 0 }}</div>
<div class="alert-title">住宿预分配待入住</div>
</div>
</div>
<div class="alert-item clickable" @click="goTo('/train/extra')">
<div class="alert-icon bg-danger"><i class="el-icon-key" /></div>
<div class="alert-content">
<div class="alert-num text-danger">{{ overview.abnormalAuthCount || 0 }}</div>
<div class="alert-title">门禁授权异常数</div>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 4. 图表行 1:趋势与班级对比 -->
<el-row :gutter="16" style="margin-top: 16px;">
<!-- 近30天考勤走势 -->
<el-col :xs="24" :sm="24" :lg="14">
<el-card shadow="never" class="chart-card">
<div slot="header" class="card-header">
<span><i class="el-icon-data-line" style="color: #409EFF; margin-right: 6px;"></i>近 30 天考勤打卡情况走势</span>
<span class="tip-text">正常签到 vs 异常考勤人次</span>
</div>
<div v-loading="loadingCharts" class="chart-wrapper">
<div ref="trendChart" style="height: 320px; width: 100%;" />
<div v-if="!loadingCharts && (!charts.trendDates || charts.trendDates.length === 0)" class="empty-chart">
<el-empty description="近 30 天暂无考勤打卡流水" :image-size="70" />
</div>
</div>
</el-card>
</el-col>
<!-- 培训计划参训/报到对比 -->
<el-col :xs="24" :sm="24" :lg="10">
<el-card shadow="never" class="chart-card">
<div slot="header" class="card-header">
<span><i class="el-icon-s-data" style="color: #67C23A; margin-right: 6px;"></i>培训计划参训 / 报到对比 (Top 10)</span>
</div>
<div v-loading="loadingCharts" class="chart-wrapper">
<div ref="planCompChart" style="height: 320px; width: 100%;" />
<div v-if="!loadingCharts && (!charts.planComparisonNames || charts.planComparisonNames.length === 0)" class="empty-chart">
<el-empty description="暂无培训计划数据" :image-size="70" />
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 5. 图表行 2:状态构成与场地使用 -->
<el-row :gutter="16" style="margin-top: 16px; margin-bottom: 24px;">
<!-- 全校考勤状态构成 -->
<el-col :xs="24" :sm="24" :lg="10">
<el-card shadow="never" class="chart-card">
<div slot="header" class="card-header">
<span><i class="el-icon-pie-chart" style="color: #E6A23C; margin-right: 6px;"></i>全校考勤状态构成分布</span>
<span class="tip-text">含无签到记录独立统计</span>
</div>
<div v-loading="loadingCharts" class="chart-wrapper">
<div ref="statusChart" style="height: 300px; width: 100%;" />
<div v-if="!loadingCharts && (!charts.attendanceStatusDistribution || charts.attendanceStatusDistribution.length === 0)" class="empty-chart">
<el-empty description="暂无考勤状态数据" :image-size="70" />
</div>
</div>
</el-card>
</el-col>
<!-- 教学场地使用频次 Top 5 -->
<el-col :xs="24" :sm="24" :lg="14">
<el-card shadow="never" class="chart-card">
<div slot="header" class="card-header">
<span><i class="el-icon-school" style="color: #909399; margin-right: 6px;"></i>教学场地使用频次与累计课时 Top 5</span>
<span class="tip-text">依排课记录客观统计,不推算无分母利用率</span>
</div>
<div v-loading="loadingCharts" class="chart-wrapper">
<div ref="venueChart" style="height: 300px; width: 100%;" />
<div v-if="!loadingCharts && (!charts.venueUsageTop5 || charts.venueUsageTop5.length === 0)" class="empty-chart">
<el-empty description="暂无排课场地使用记录" :image-size="70" />
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script>
import { getDashboardOverview, getDashboardCharts } from '@/api/train/analysis'
export default {
name: 'AdminDashboard',
data() {
return {
loading: false,
loadingOverview: false,
loadingCharts: false,
overview: {},
charts: {},
trendChartInstance: null,
planCompChartInstance: null,
statusChartInstance: null,
venueChartInstance: null
}
},
computed: {
planStatus() {
return this.overview.planStatusCounts || {}
}
},
created() {
this.refreshAll()
},
mounted() {
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
this.disposeCharts()
},
methods: {
refreshAll() {
this.fetchOverview()
this.fetchCharts()
},
fetchOverview() {
this.loadingOverview = true
getDashboardOverview().then(res => {
if (res.code === 200) {
this.overview = res.data || {}
}
}).finally(() => {
this.loadingOverview = false
})
},
fetchCharts() {
this.loadingCharts = true
getDashboardCharts().then(res => {
if (res.code === 200) {
this.charts = res.data || {}
this.$nextTick(() => {
this.renderTrendChart()
this.renderPlanCompChart()
this.renderStatusChart()
this.renderVenueChart()
})
}
}).finally(() => {
this.loadingCharts = false
})
},
renderTrendChart() {
if (!this.$refs.trendChart) return
if (!this.trendChartInstance) {
this.trendChartInstance = this.$echarts.init(this.$refs.trendChart)
}
const dates = this.charts.trendDates || []
const normal = this.charts.trendNormalCounts || []
const abnormal = this.charts.trendAbnormalCounts || []
const option = {
tooltip: { trigger: 'axis' },
legend: { data: ['正常签到人次', '异常考勤人次'], top: 0 },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisLine: { lineStyle: { color: '#909399' } }
},
yAxis: {
type: 'value',
minInterval: 1,
splitLine: { lineStyle: { type: 'dashed' } }
},
series: [
{
name: '正常签到人次',
type: 'line',
smooth: true,
data: normal,
itemStyle: { color: '#67C23A' },
areaStyle: {
color: new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(103,194,58,0.3)' },
{ offset: 1, color: 'rgba(103,194,58,0.02)' }
])
}
},
{
name: '异常考勤人次',
type: 'line',
smooth: true,
data: abnormal,
itemStyle: { color: '#F56C6C' },
areaStyle: {
color: new this.$echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(245,108,108,0.3)' },
{ offset: 1, color: 'rgba(245,108,108,0.02)' }
])
}
}
]
}
this.trendChartInstance.setOption(option)
},
renderPlanCompChart() {
if (!this.$refs.planCompChart) return
if (!this.planCompChartInstance) {
this.planCompChartInstance = this.$echarts.init(this.$refs.planCompChart)
}
const names = this.charts.planComparisonNames || []
const enrolled = this.charts.planEnrolledCounts || []
const reported = this.charts.planReportedCounts || []
const option = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['参训人数', '报到人数'], top: 0 },
grid: { left: '3%', right: '4%', bottom: '8%', containLabel: true },
xAxis: {
type: 'category',
data: names,
axisLabel: {
interval: 0,
rotate: 20,
formatter: val => val.length > 7 ? val.substring(0, 7) + '...' : val
}
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{
name: '参训人数',
type: 'bar',
data: enrolled,
itemStyle: { color: '#409EFF' }
},
{
name: '报到人数',
type: 'bar',
data: reported,
itemStyle: { color: '#67C23A' }
}
]
}
this.planCompChartInstance.setOption(option)
},
renderStatusChart() {
if (!this.$refs.statusChart) return
if (!this.statusChartInstance) {
this.statusChartInstance = this.$echarts.init(this.$refs.statusChart)
}
const rawList = this.charts.attendanceStatusDistribution || []
const data = rawList.map(item => ({ name: item.name, value: item.value }))
const option = {
tooltip: { trigger: 'item', formatter: '{b}: {c} 人次 ({d}%)' },
legend: { bottom: 0, left: 'center' },
color: ['#67C23A', '#E6A23C', '#F56C6C', '#409EFF', '#909399', '#C0C4CC'],
series: [
{
name: '考勤状态',
type: 'pie',
radius: ['45%', '70%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
label: { show: false, position: 'center' },
emphasis: {
label: { show: true, fontSize: 14, fontWeight: 'bold' }
},
data: data
}
]
}
this.statusChartInstance.setOption(option)
},
renderVenueChart() {
if (!this.$refs.venueChart) return
if (!this.venueChartInstance) {
this.venueChartInstance = this.$echarts.init(this.$refs.venueChart)
}
const raw = this.charts.venueUsageTop5 || []
const names = raw.map(i => i.venueName).reverse()
const counts = raw.map(i => i.usageCount).reverse()
const hours = raw.map(i => i.totalHours).reverse()
const option = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: params => {
const name = params[0].name
let str = `<b>${name}</b><br/>`
params.forEach(p => {
str += `${p.seriesName}: ${p.value} ${p.seriesName.includes('课时') ? '小时' : '次'}<br/>`
})
return str
}
},
legend: { data: ['使用频次', '累计课时(小时)'], top: 0 },
grid: { left: '3%', right: '5%', bottom: '3%', containLabel: true },
xAxis: { type: 'value', minInterval: 1 },
yAxis: {
type: 'category',
data: names,
axisLabel: {
formatter: val => val.length > 8 ? val.substring(0, 8) + '...' : val
}
},
series: [
{
name: '使用频次',
type: 'bar',
data: counts,
itemStyle: { color: '#36cbcb' }
},
{
name: '累计课时(小时)',
type: 'bar',
data: hours,
itemStyle: { color: '#4ecb73' }
}
]
}
this.venueChartInstance.setOption(option)
},
handleResize() {
if (this.trendChartInstance) this.trendChartInstance.resize()
if (this.planCompChartInstance) this.planCompChartInstance.resize()
if (this.statusChartInstance) this.statusChartInstance.resize()
if (this.venueChartInstance) this.venueChartInstance.resize()
},
disposeCharts() {
if (this.trendChartInstance) { this.trendChartInstance.dispose(); this.trendChartInstance = null }
if (this.planCompChartInstance) { this.planCompChartInstance.dispose(); this.planCompChartInstance = null }
if (this.statusChartInstance) { this.statusChartInstance.dispose(); this.statusChartInstance = null }
if (this.venueChartInstance) { this.venueChartInstance.dispose(); this.venueChartInstance = null }
},
goTo(path) {
if (path) this.$router.push(path)
}
}
}
</script>
<style scoped lang="scss">
.dashboard-editor-container {
padding: 16px 20px;
background-color: #f0f2f5;
min-height: calc(100vh - 84px);
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
padding: 16px 20px;
border-radius: 4px;
margin-bottom: 16px;
border-left: 4px solid #409EFF;
.header-title h2 {
margin: 0 0 4px 0;
font-size: 18px;
color: #303133;
}
.header-title .subtitle {
font-size: 13px;
color: #909399;
}
}
.kpi-row {
margin-bottom: 0px;
}
.kpi-card {
background: #fff;
border-radius: 4px;
padding: 14px 16px;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
.kpi-label {
font-size: 13px;
color: #606266;
margin-bottom: 8px;
}
.kpi-value {
font-size: 22px;
font-weight: bold;
color: #303133;
line-height: 1.2;
margin-bottom: 6px;
}
.kpi-sub {
font-size: 11px;
color: #909399;
}
}
.text-primary { color: #409EFF !important; }
.text-success { color: #67C23A !important; }
.text-warning { color: #E6A23C !important; }
.text-danger { color: #F56C6C !important; }
.text-info { color: #909399 !important; }
.status-card {
min-height: 140px;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
font-size: 14px;
.tip-text {
font-size: 12px;
color: #909399;
font-weight: normal;
}
}
}
.plan-status-tags {
display: flex;
justify-content: space-around;
padding: 10px 0;
.status-tag-item {
display: flex;
align-items: center;
font-size: 13px;
color: #606266;
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
&.dot-info { background: #909399; }
&.dot-success { background: #67C23A; }
&.dot-ended { background: #409EFF; }
&.dot-danger { background: #F56C6C; }
}
.count {
font-weight: bold;
color: #303133;
margin-right: 2px;
}
}
}
.alert-grid {
display: flex;
justify-content: space-between;
gap: 12px;
.alert-item {
flex: 1;
display: flex;
align-items: center;
background: #fafafa;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 8px 12px;
transition: all 0.2s;
&.clickable:hover {
cursor: pointer;
background: #f0f7ff;
border-color: #b3d8ff;
}
.alert-icon {
width: 36px;
height: 36px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
margin-right: 10px;
&.bg-danger { background: #F56C6C; }
&.bg-warning { background: #E6A23C; }
&.bg-primary { background: #409EFF; }
}
.alert-content {
.alert-num {
font-size: 16px;
font-weight: bold;
}
.alert-title {
font-size: 12px;
color: #909399;
}
}
}
}
.chart-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
font-size: 14px;
.tip-text {
font-size: 12px;
color: #909399;
font-weight: normal;
}
}
.chart-wrapper {
position: relative;
.empty-chart {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
}
}
}
</style>