实验室卫生管理系统详细搭建与开发指南
前言
本文是 D203 实验室卫生管理系统的全流程开发部署手册,在前期指南基础上补充了代码实现细节、API 规范、权限控制、前端交互优化等核心内容。全文覆盖从环境搭建到上线运维的每个环节,包含大量可直接复用的代码示例和图表说明,适合初学者或团队开发者参照复现,也可作为类似管理系统的开发参考。
一、项目背景与现状分析
实验室卫生管理传统模式依赖纸质记录和口头通知,存在三大痛点:
- 效率低:人工排班易出错,执行记录无法实时追溯;
- 标准不统一:清洁要求、评分规则依赖人工判断,结果差异大;
- 数据无价值:历史记录难以统计分析,无法为管理优化提供依据。
本系统通过 Spring Boot + Bootstrap + MySQL 技术栈,实现 “规则制定→任务执行→结果评估→整改闭环” 的数字化管理,同时支持后续与校园一卡通、微信小程序等系统集成,拓展使用场景。
项目获取与目录结构
1. 获取项目代码
从压缩包或 Git 仓库获取完整项目:
# 通过 Git 克隆(如有仓库)
git clone <repository-url> d203-lab-hygiene-system
cd d203-lab-hygiene-system
# 或者直接解压压缩包
unzip d203-lab-hygiene-system.zip -d d203-lab-hygiene-system
2. 项目目录结构
d203-lab-hygiene-system/
├─ database.sql # 数据库建表及初始数据脚本
├─ pom.xml # Maven 配置文件
├─ src
│ ├─ main
│ │ ├─ java
│ │ │ └─ com/project
│ │ │ ├─ controller # 控制器层(Spring MVC)
│ │ │ ├─ service # 业务服务层
│ │ │ ├─ mapper # MyBatis 接口层
│ │ │ └─ entity # 实体类
│ │ ├─ resources
│ │ │ ├─ application.yml # Spring Boot 配置
│ │ │ ├─ mapper xml # MyBatis XML 映射文件
│ │ │ └─ static # 前端静态文件(HTML/CSS/JS)
│ ├─ test # 测试代码
└─ README.md
源代码采用 MVC 分层设计,后端代码全部在 src/main/java 中,前端静态页面在 src/main/resources/static。架构示意图可回顾原报告中的图示。
二、角色与权限深入实现(附代码)
2.1 数据层设计
首先在成员表中新增角色字段,用于区分权限:
-- 新增角色字段,默认普通成员
ALTER TABLE tb_member ADD COLUMN role VARCHAR(20) DEFAULT 'ROLE_MEMBER';
ALTER TABLE tb_member ADD COLUMN password VARCHAR(100) NOT NULL; -- 存储加密后的密码
在代码中定义角色枚举,避免硬编码:
// com.project.enums.Role.java
public enum Role {
ROLE_ADMIN, // 管理员
ROLE_SUPERVISOR, // 教师/监督员
ROLE_MEMBER // 普通成员
}
2.2 Spring Security 认证授权实现
步骤 1:自定义用户认证服务
// com.project.security.CustomUserDetailsService.java
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private MemberMapper memberMapper;
@Override
public UserDetails loadUserByUsername(String studentId) throws UsernameNotFoundException {
// 根据学号查询成员
Member member = memberMapper.selectByStudentId(studentId);
if (member == null) {
throw new UsernameNotFoundException("用户不存在");
}
// 封装角色权限
List<GrantedAuthority> authorities = Collections.singletonList(
new SimpleGrantedAuthority(member.getRole())
);
// 返回Spring Security所需的User对象
return new User(
member.getStudentId(),
member.getPassword(), // 数据库中已加密的密码
authorities
);
}
}
步骤 2:配置安全规则
// com.project.security.SecurityConfig.java
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService userDetailsService;
// 密码加密方式(BCrypt)
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable() // 开发阶段关闭CSRF,生产环境需开启
.authorizeRequests()
// 公开接口:登录页、静态资源
.antMatchers("/login", "/css/**", "/js/**", "/images/**").permitAll()
// 管理员专属接口
.antMatchers("/admin/**", "/system/config/**").hasRole("ADMIN")
// 监督员专属接口
.antMatchers("/supervisor/score/**", "/supervisor/rectify/**").hasAnyRole("ADMIN", "SUPERVISOR")
// 其他接口需登录
.anyRequest().authenticated()
.and()
// 自定义登录页
.formLogin()
.loginPage("/login") // 登录页路径
.defaultSuccessUrl("/home", true) // 登录成功跳转页
.failureUrl("/login?error") // 登录失败跳转页
.and()
// 登出配置
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/login")
.deleteCookies("JSESSIONID"); // 清除会话Cookie
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 关联用户认证服务和密码加密器
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
}
2.3 JWT 无状态认证(适配移动端)
若需对接移动端或第三方系统,推荐使用 JWT 替代 Session:
- 引入依赖:
<!-- pom.xml 新增JWT依赖 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
- JWT 工具类:
// com.project.util.JwtUtil.java
public class JwtUtil {
// 密钥(生产环境需配置在环境变量中)
private static final String SECRET = "d203-lab-hygiene-system-secret-key";
// 过期时间(24小时)
private static final long EXPIRATION = 86400000;
// 生成Token
public static String generateToken(String studentId, String role) {
Date now = new Date();
Date expirationDate = new Date(now.getTime() + EXPIRATION);
return Jwts.builder()
.setSubject(studentId)
.claim("role", role)
.setIssuedAt(now)
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS512, SECRET)
.compact();
}
// 解析Token
public static Claims parseToken(String token) {
return Jwts.parser()
.setSigningKey(SECRET)
.parseClaimsJws(token)
.getBody();
}
// 验证Token是否过期
public static boolean isTokenExpired(String token) {
Date expiration = parseToken(token).getExpiration();
return expiration.before(new Date());
}
}
三、API 设计与规范(RESTful 风格)
3.1 核心设计原则
- 资源命名:使用复数名词(如
/members而非/member); - HTTP 方法语义:
- GET:查询资源(如
/api/v1/members获取成员列表); - POST:创建资源(如
/api/v1/members新增成员); - PUT:全量更新资源(如
/api/v1/members/1更新 ID=1 的成员); - DELETE:删除资源(如
/api/v1/members/1删除 ID=1 的成员);
- GET:查询资源(如
- 统一响应格式:
{
"code": 0, // 状态码:0成功,非0错误(如1001参数错误、1002权限不足)
"message": "success", // 提示信息
"data": {} // 业务数据(查询接口返回列表/对象,操作接口返回空或ID)
}
3.2 主体模块接口示例
| 模块 | 接口地址 | 方法 | 权限角色 | 描述 | 请求参数示例 |
|---|---|---|---|---|---|
| 成员管理 | /api/v1/members | GET | 所有登录用户 | 分页查询成员列表 | page=1&size=10&groupName=第一组 |
| 成员管理 | /api/v1/members | POST | ADMIN | 新增成员 | {"name":"张三","studentId":"2023001","role":"ROLE_MEMBER"} |
| 值日规则 | /api/v1/rules | GET | ADMIN/SUPERVISOR | 查询所有值日规则 | - |
| 排班管理 | /api/v1/schedules/auto-generate | POST | ADMIN | 触发自动排班(按规则生成) | {"ruleId":1,"startDate":"2025-11-01","endDate":"2025-11-07"} |
| 执行记录 | /api/v1/executions | POST | 所有登录用户 | 提交值日执行记录(签到 / 签退) | {"scheduleId":101,"status":"done","photos":["/img/1.jpg"]} |
| 统计分析 | /api/v1/statistics/member | GET | ADMIN/SUPERVISOR | 按成员统计得分和值日次数 | {"startDate":"2025-10-01","endDate":"2025-10-31"} |
四、核心模块代码实现(以排班为例)
4.1 排班算法 Java 实现(轮换 + 随机)
// com.project.service.ScheduleService.java
@Service
public class ScheduleService {
@Autowired
private DutyRuleMapper ruleMapper;
@Autowired
private MemberMapper memberMapper;
@Autowired
private ScheduleMapper scheduleMapper;
/**
* 按规则生成指定日期范围的排班
* @param startDate 开始日期
* @param endDate 结束日期
*/
public void generateSchedules(LocalDate startDate, LocalDate endDate) {
// 1. 查询所有启用的值日规则
List<DutyRule> enabledRules = ruleMapper.selectEnabledRules();
if (enabledRules.isEmpty()) {
throw new BusinessException("无启用的值日规则,请先配置规则");
}
// 2. 遍历规则生成排班
for (DutyRule rule : enabledRules) {
// 2.1 计算规则覆盖的日期列表(如每周一、周三)
List<LocalDate> dutyDates = calculateDutyDates(rule, startDate, endDate);
// 2.2 查询规则对应的可用成员(排除请假、禁用的成员)
List<Member> availableMembers = memberMapper.selectAvailableMembers(rule.getAreaId());
if (availableMembers.isEmpty()) {
log.warn("规则[{}]对应的区域无可用成员,跳过排班", rule.getRuleName());
continue;
}
// 2.3 按规则算法生成排班
if ("rotation".equals(rule.getScheduleAlgorithm())) {
// 轮换算法
generateByRotation(rule, dutyDates, availableMembers);
} else if ("random".equals(rule.getScheduleAlgorithm())) {
// 随机算法
generateByRandom(rule, dutyDates, availableMembers);
}
}
}
/**
* 轮换算法:按成员顺序循环排班
*/
private void generateByRotation(DutyRule rule, List<LocalDate> dates, List<Member> members) {
int memberCount = members.size();
// 从规则中获取上一次排班的最后一个成员索引(避免每次从头开始)
int lastIndex = rule.getLastScheduleIndex() == null ? 0 : rule.getLastScheduleIndex();
for (LocalDate date : dates) {
// 计算当前应排班的成员索引(循环取模)
int currentIndex = lastIndex % memberCount;
Member dutyMember = members.get(currentIndex);
// 构建排班记录
Schedule schedule = new Schedule();
schedule.setDutyDate(date);
schedule.setAreaId(rule.getAreaId());
schedule.setMemberId(dutyMember.getMemberId());
schedule.setRuleId(rule.getRuleId());
schedule.setStatus("pending"); // 初始状态:待执行
schedule.setGenerateType("auto"); // 生成方式:自动
// 保存排班
scheduleMapper.insert(schedule);
// 更新索引(下次从下一个成员开始)
lastIndex++;
}
// 更新规则的最后排班索引(下次复用)
rule.setLastScheduleIndex(lastIndex % memberCount);
ruleMapper.updateLastScheduleIndex(rule);
}
/**
* 随机算法:随机选择成员,但避免连续两次排同一人
*/
private void generateByRandom(DutyRule rule, List<LocalDate> dates, List<Member> members) {
int memberCount = members.size();
Random random = new Random();
Integer lastMemberId = null; // 上一个排班的成员ID(避免连续)
for (LocalDate date : dates) {
Member dutyMember;
// 循环随机,直到找到与上一个不同的成员
do {
int randomIndex = random.nextInt(memberCount);
dutyMember = members.get(randomIndex);
} while (lastMemberId != null && dutyMember.getMemberId().equals(lastMemberId));
// 构建并保存排班记录(同轮换算法)
Schedule schedule = new Schedule();
schedule.setDutyDate(date);
schedule.setAreaId(rule.getAreaId());
schedule.setMemberId(dutyMember.getMemberId());
schedule.setRuleId(rule.getRuleId());
schedule.setStatus("pending");
schedule.setGenerateType("auto");
scheduleMapper.insert(schedule);
// 更新上一个成员ID
lastMemberId = dutyMember.getMemberId();
}
}
/**
* 计算规则覆盖的日期列表(如每周一、每月1号)
*/
private List<LocalDate> calculateDutyDates(DutyRule rule, LocalDate start, LocalDate end) {
List<LocalDate> dates = new ArrayList<>();
LocalDate current = start;
while (!current.isAfter(end)) {
// 根据规则周期判断当前日期是否需要排班
if (isMatchRuleCycle(rule, current)) {
dates.add(current);
}
// 日期加1天
current = current.plusDays(1);
}
return dates;
}
/**
* 判断日期是否匹配规则周期(如每周三、每月5号)
*/
private boolean isMatchRuleCycle(DutyRule rule, LocalDate date) {
String cycleType = rule.getCycleType();
Integer cycleValue = rule.getCycleValue();
switch (cycleType) {
case "daily": // 每日
return true;
case "weekly": // 每周(如cycleValue=3表示每周三)
return date.getDayOfWeek().getValue() == cycleValue;
case "monthly": // 每月(如cycleValue=5表示每月5号)
return date.getDayOfMonth() == cycleValue;
default:
return false;
}
}
}
4.2 前端排班日历实现(FullCalendar 完整版)
<!-- src/main/resources/static/schedule.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>排班管理</title>
<!-- 引入依赖 -->
<link rel="stylesheet" href="css/bootstrap.min.css">
<link rel="stylesheet" href="css/fullcalendar.min.css">
<script src="js/jquery.min.js"></script>
<script src="js/moment.min.js"></script>
<script src="js/fullcalendar.min.js"></script>
<script src="js/fullcalendar/locale/zh-cn.js"></script>
<!-- 弹窗样式( Bootstrap 模态框 ) -->
<link rel="stylesheet" href="css/bootstrap-modal.min.css">
<script src="js/bootstrap-modal.min.js"></script>
</head>
<body>
<div class="container mt-4">
<!-- 筛选区域 -->
<div class="row mb-3">
<div class="col-6">
<label for="areaSelect" class="form-label">筛选区域:</label>
<select id="areaSelect" class="form-select" style="width: 300px;">
<option value="">全部区域</option>
<!-- 后端渲染区域选项(示例) -->
<option value="1">实验台区域</option>
<option value="2">仪器柜区域</option>
<option value="3">地面清洁区</option>
</select>
</div>
<div class="col-6 text-end">
<button id="refreshBtn" class="btn btn-primary mt-4">刷新排班</button>
<button id="generateBtn" class="btn btn-success mt-4 ms-2">手动生成排班</button>
</div>
</div>
<!-- 日历容器 -->
<div id="scheduleCalendar" style="border: 1px solid #eee; border-radius: 8px; padding: 10px;"></div>
</div>
<!-- 排班详情弹窗(隐藏状态) -->
<div class="modal fade" id="scheduleDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">排班详情</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-6 mb-3">
<label class="form-label">值日日期:</label>
<span id="detailDate" class="form-control-plaintext"></span>
</div>
<div class="col-6 mb-3">
<label class="form-label">值日区域:</label>
<span id="detailArea" class="form-control-plaintext"></span>
</div>
<div class="col-6 mb-3">
<label class="form-label">值日人员:</label>
<div class="d-flex align-items-center">
<img id="detailAvatar" src="" style="width: 30px; height: 30px; border-radius: 50%; margin-right: 10px;">
<span id="detailMember" class="form-control-plaintext"></span>
</div>
</div>
<div class="col-6 mb-3">
<label class="form-label">排班状态:</label>
<span id="detailStatus" class="form-control-plaintext badge bg-primary"></span>
</div>
<div class="col-12 mb-3">
<label class="form-label">值日任务:</label>
<ul id="detailTasks" class="list-group"></ul>
</div>
<div class="col-12 mb-3">
<label class="form-label">执行记录:</label>
<span id="detailExecution" class="form-control-plaintext text-muted">暂无执行记录</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
<button id="editScheduleBtn" type="button" class="btn btn-primary">修改排班</button>
</div>
</div>
</div>
</div>
<script>
$(function() {
// 1. 初始化日历
const calendar = new FullCalendar.Calendar(document.getElementById('scheduleCalendar'), {
initialView: 'dayGridMonth',
locale: 'zh-cn',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek' // 月视图/周视图切换
},
// 允许拖拽修改排班日期
editable: true,
// 事件点击:打开详情弹窗
eventClick: function(info) {
loadScheduleDetail(info.event.extendedProps.scheduleId);
$('#scheduleDetailModal').modal('show');
},
// 拖拽结束:更新排班日期
eventDrop: function(info) {
const scheduleId = info.event.extendedProps.scheduleId;
const newDate = info.event.startStr; // 拖拽后的新日期
updateScheduleDate(scheduleId, newDate);
},
// 事件数据源(从后端接口获取)
events: function(info, successCallback, failureCallback) {
$.ajax({
url: '/api/v1/schedules',
type: 'GET',
data: {
startDate: info.startStr, // 日历当前视图的开始日期
endDate: info.endStr, // 日历当前视图的结束日期
areaId: $('#areaSelect').val() // 区域筛选条件
},
success: function(res) {
if (res.code === 0) {
// 格式化后端数据为FullCalendar所需格式
const events = res.data.map(item => ({
id: item.scheduleId,
title: item.memberName + ' - ' + item.areaName, // 日历上显示的标题
start: item.dutyDate, // 值日日期
extendedProps: {
scheduleId: item.scheduleId,
memberId: item.memberId,
memberName: item.memberName,
avatar: item.avatar || '/images/default-avatar.png',
areaName: item.areaName,
status: item.status,
tasks: item.tasks // 值日任务列表(JSON格式)
},
// 根据状态设置不同颜色
color: item.status === 'done' ? '#198754' :
item.status === 'pending' ? '#0d6efd' :
item.status === 'overdue' ? '#dc3545' : '#ffc107'
}));
successCallback(events);
} else {
alert('获取排班失败:' + res.message);
failureCallback();
}
},
error: function() {
alert('网络错误,请检查后端服务是否正常');
failureCallback();
}
});
}
});
calendar.render(); // 渲染日历
// 2. 区域筛选下拉框change事件:刷新日历
$('#areaSelect').on('change', function() {
calendar.refetchEvents(); // 重新拉取事件数据
});
// 3. 刷新按钮点击事件
$('#refreshBtn').on('click', function() {
calendar.refetchEvents();
alert('已刷新排班数据');
});
// 4. 手动生成排班按钮点击事件
$('#generateBtn').on('click', function() {
// 弹出日期选择框(示例用prompt简化,实际建议用日期选择器)
const startDate = prompt('请输入开始日期(格式:2025-11-01):');
const endDate = prompt('请输入结束日期(格式:2025-11-07):');
if (!startDate || !endDate) return;
$.ajax({
url: '/api/v1/schedules/auto-generate',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
startDate: startDate,
endDate: endDate,
ruleId: $('#ruleSelect').val() // 实际项目中可加规则筛选
}),
success: function(res) {
if (res.code === 0) {
alert('排班生成成功!');
calendar.refetchEvents(); // 刷新日历
} else {
alert('生成失败:' + res.message);
}
},
error: function() {
alert('生成请求失败,请重试');
}
});
});
// 5. 加载排班详情(根据scheduleId)
function loadScheduleDetail(scheduleId) {
$.ajax({
url: '/api/v1/schedules/' + scheduleId,
type: 'GET',
success: function(res) {
if (res.code === 0) {
const data = res.data;
// 填充弹窗数据
$('#detailDate').text(data.dutyDate);
$('#detailArea').text(data.areaName);
$('#detailMember').text(data.memberName);
$('#detailAvatar').attr('src', data.avatar || '/images/default-avatar.png');
// 设置状态标签颜色
const statusBadge = $('#detailStatus');
statusBadge.text(data.statusDesc);
statusBadge.removeClass('bg-primary bg-success bg-danger bg-warning');
statusBadge.addClass(
data.status === 'done' ? 'bg-success' :
data.status === 'pending' ? 'bg-primary' :
data.status === 'overdue' ? 'bg-danger' : 'bg-warning'
);
// 渲染值日任务列表
const tasksList = $('#detailTasks');
tasksList.empty();
const tasks = JSON.parse(data.taskTemplate); // 任务模板是JSON字符串
tasks.forEach(task => {
tasksList.append(`<li class="list-group-item">${task}</li>`);
});
// 渲染执行记录(若有)
if (data.execution) {
$('#detailExecution').html(`
<div class="alert alert-info mb-0">
<p>签到时间:${data.execution.checkInTime || '未签到'}</p>
<p>签退时间:${data.execution.checkOutTime || '未签退'}</p>
<p>评分:${data.execution.score || '未评分'}</p>
</div>
`);
} else {
$('#detailExecution').text('暂无执行记录');
}
// 存储当前排班ID到按钮,供修改使用
$('#editScheduleBtn').data('scheduleId', scheduleId);
} else {
alert('获取详情失败:' + res.message);
}
},
error: function() {
alert('获取详情请求失败');
}
});
}
// 6. 修改排班日期(拖拽后调用)
function updateScheduleDate(scheduleId, newDate) {
$.ajax({
url: '/api/v1/schedules/' + scheduleId + '/date',
type: 'PUT',
contentType: 'application/json',
data: JSON.stringify({ dutyDate: newDate }),
success: function(res) {
if (res.code !== 0) {
alert('更新日期失败:' + res.message);
calendar.refetchEvents(); // 回滚日历显示
}
},
error: function() {
alert('更新请求失败,已回滚');
calendar.refetchEvents();
}
});
}
// 7. 编辑排班按钮点击事件(实际项目可扩展修改人员/区域)
$('#editScheduleBtn').on('click', function() {
const scheduleId = $(this).data('scheduleId');
// 跳转编辑页(或弹窗编辑,此处简化为跳转)
window.location.href = '/schedule/edit.html?id=' + scheduleId;
});
});
</script>
</body>
</html>
五、前端增强功能实现(表单验证 + 图表)
5.1 jQuery 表单验证(以成员添加为例)
<!-- src/main/resources/static/member-add.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>新增成员</title>
<link rel="stylesheet" href="css/bootstrap.min.css">
<script src="js/jquery.min.js"></script>
<script src="js/jquery.validate.min.js"></script>
<script src="js/bootstrap.bundle.min.js"></script>
</head>
<body>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">新增实验室成员</div>
<div class="card-body">
<form id="addMemberForm">
<div class="mb-3">
<label for="name" class="form-label">姓名 <span class="text-danger">*</span></label>
<input type="text" id="name" name="name" class="form-control" placeholder="请输入姓名">
</div>
<div class="mb-3">
<label for="studentId" class="form-label">学号 <span class="text-danger">*</span></label>
<input type="text" id="studentId" name="studentId" class="form-control" placeholder="请输入学号(如2023001)">
</div>
<div class="mb-3">
<label for="groupName" class="form-label">分组</label>
<select id="groupName" name="groupName" class="form-select">
<option value="第一组">第一组</option>
<option value="第二组">第二组</option>
<option value="第三组">第三组</option>
</select>
</div>
<div class="mb-3">
<label for="phone" class="form-label">手机号 <span class="text-danger">*</span></label>
<input type="text" id="phone" name="phone" class="form-control" placeholder="请输入手机号">
</div>
<div class="mb-3">
<label for="email" class="form-label">邮箱</label>
<input type="email" id="email" name="email" class="form-control" placeholder="请输入邮箱(可选)">
</div>
<div class="mb-3">
<label for="role" class="form-label">角色 <span class="text-danger">*</span></label>
<select id="role" name="role" class="form-select">
<option value="ROLE_MEMBER">普通成员</option>
<option value="ROLE_SUPERVISOR">监督员</option>
<option value="ROLE_ADMIN">管理员</option>
</select>
</div>
<div class="mb-3">
<label for="password" class="form-label">初始密码 <span class="text-danger">*</span></label>
<input type="password" id="password" name="password" class="form-control" placeholder="请设置初始密码(至少6位)">
</div>
<div class="d-flex justify-content-end">
<button type="button" class="btn btn-secondary me-2" onclick="window.history.back()">取消</button>
<button type="submit" class="btn btn-primary">提交</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
$(function() {
// 初始化表单验证
$('#addMemberForm').validate({
// 验证规则
rules: {
name: {
required: true, // 必填
minlength: 2, // 最小长度2
maxlength: 10 // 最大长度10
},
studentId: {
required: true,
digits: true, // 仅允许数字
minlength: 6, // 学号至少6位
maxlength: 10,
remote: { // 远程校验:学号是否已存在
url: '/api/v1/members/check-studentId',
type: 'GET',
data: {
studentId: function() {
return $('#studentId').val();
}
},
dataFilter: function(res) {
// 后端返回格式:{ "code":0, "data":true }(true表示可用)
const result = JSON.parse(res);
return result.code === 0 && result.data ? 'true' : 'false';
}
}
},
phone: {
required: true,
pattern: /^1[3-9]\d{9}$/ // 手机号正则
},
email: {
email: true // 邮箱格式校验(可选,不填则不校验)
},
password: {
required: true,
minlength: 6 // 密码至少6位
}
},
// 错误提示信息
messages: {
name: {
required: '请输入姓名',
minlength: '姓名至少2个字符',
maxlength: '姓名最多10个字符'
},
studentId: {
required: '请输入学号',
digits: '学号仅允许数字',
minlength: '学号至少6位',
maxlength: '学号最多10位',
remote: '该学号已存在,请更换'
},
phone: {
required: '请输入手机号',
pattern: '手机号格式错误(需以13-19开头)'
},
email: {
email: '邮箱格式错误(如xxx@xxx.com)'
},
password: {
required: '请设置初始密码',
minlength: '密码至少6位'
}
},
// 错误提示样式
errorClass: 'text-danger',
errorElement: 'div',
// 错误提示位置(输入框下方)
errorPlacement: function(error, element) {
error.appendTo(element.parent());
},
// 验证通过后提交表单
submitHandler: function(form) {
// 加密密码(前端简单加密,后端需再次加密存储)
const password = $('#password').val();
const encryptedPwd = btoa(password); // Base64加密(仅示例,生产建议用HTTPS+后端BCrypt)
// 构造请求数据
const formData = {
name: $('#name').val(),
studentId: $('#studentId').val(),
groupName: $('#groupName').val(),
phone: $('#phone').val(),
email: $('#email').val() || '',
role: $('#role').val(),
password: encryptedPwd // 传递加密后的密码
};
// 提交到后端
$.ajax({
url: '/api/v1/members',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(formData),
success: function(res) {
if (res.code === 0) {
alert('成员新增成功!初始密码已设置');
window.location.href = '/member-list.html'; // 跳转成员列表页
} else {
alert('新增失败:' + res.message);
}
},
error: function() {
alert('网络错误,请重试');
}
});
}
});
});
</script>
</body>
</html>
5.2 ECharts 统计图表(成员得分趋势)
<!-- src/main/resources/static/statistics.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>数据统计</title>
<link rel="stylesheet" href="css/bootstrap.min.css">
<script src="js/jquery.min.js"></script>
<script src="js/echarts.min.js"></script>
<script src="js/bootstrap.bundle.min.js"></script>
</head>
<body>
<div class="container mt-4">
<h3>实验室卫生统计分析</h3>
<!-- 筛选条件 -->
<div class="row mb-3">
<div class="col-4">
<label for="statStartDate" class="form-label">开始日期:</label>
<input type="date" id="statStartDate" class="form-control" value="2025-10-01">
</div>
<div class="col-4">
<label for="statEndDate" class="form-label">结束日期:</label>
<input type="date" id="statEndDate" class="form-control" value="2025-10-31">
</div>
<div class="col-4 d-flex align-items-end">
<button id="refreshChartBtn" class="btn btn-primary w-100">刷新图表</button>
</div>
</div>
<!-- 图表容器 -->
<div class="row">
<!-- 成员平均得分柱状图 -->
<div class="col-6 mb-4">
<div class="card">
<div class="card-header">成员平均得分</div>
<div class="card-body">
<div id="memberScoreChart" style="width: 100%; height: 400px;"></div>
</div>
</div>
</div>
<!-- 区域完成率饼图 -->
<div class="col-6 mb-4">
<div class="card">
<div class="card-header">区域值日完成率</div>
<div class="card-body">
<div id="areaCompletionChart" style="width: 100%; height: 400px;"></div>
</div>
</div>
</div>
<!-- 值日趋势折线图 -->
<div class="col-12 mb-4">
<div class="card">
<div class="card-header">每日值日完成情况趋势</div>
<div class="card-body">
<div id="dailyCompletionChart" style="width: 100%; height: 400px;"></div>
</div>
</div>
</div>
</div>
</div>
<script>
$(function() {
// 初始化3个图表
const memberScoreChart = echarts.init(document.getElementById('memberScoreChart'));
const areaCompletionChart = echarts.init(document.getElementById('areaCompletionChart'));
const dailyCompletionChart = echarts.init(document.getElementById('dailyCompletionChart'));
// 加载图表数据
function loadChartData() {
const startDate = $('#statStartDate').val();
const endDate = $('#statEndDate').val();
// 1. 加载成员平均得分数据
$.ajax({
url: '/api/v1/statistics/member-avg-score',
type: 'GET',
data: { startDate, endDate },
success: function(res) {
if (res.code === 0) {
const data = res.data;
// 配置柱状图
memberScoreChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' }
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: data.map(item => item.memberName),
axisLabel: {
rotate: 30 // 标签旋转30度,避免重叠
}
},
yAxis: {
type: 'value',
min: 0,
max: 100,
name: '平均得分'
},
series: [{
name: '平均得分',
type: 'bar',
data: data.map(item => item.avgScore),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#5470c6' },
{ offset: 1, color: '#91cc75' }
])
},
label: {
show: true,
position: 'top',
formatter: '{c}'
}
}]
});
}
}
});
// 2. 加载区域完成率数据
$.ajax({
url: '/api/v1/statistics/area-completion',
type: 'GET',
data: { startDate, endDate },
success: function(res) {
if (res.code === 0) {
const data = res.data;
// 配置饼图
areaCompletionChart.setOption({
tooltip: {
trigger: 'item'
},
legend: {
orient: 'vertical',
left: 10,
top: 20
},
series: [{
name: '完成率',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: 20,
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: data.map(item => ({
name: item.areaName + '(' + item.completionRate + '%)',
value: item.completionRate
}))
}]
});
}
}
});
// 3. 加载每日完成情况趋势数据
$.ajax({
url: '/api/v1/statistics/daily-completion',
type: 'GET',
data: { startDate, endDate },
success: function(res) {
if (res.code === 0) {
const data = res.data;
// 配置折线图
dailyCompletionChart.setOption({
tooltip: {
trigger: 'axis'
},
legend: {
data: ['应完成数', '已完成数'],
top: 0
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: data.map(item => item.date)
},
yAxis: {
type: 'value',
name: '值日次数'
},
series: [
{
name: '应完成数',
type: 'line',
data: data.map(item => item.totalCount),
smooth: true,
lineStyle: { color: '#67c23a' }
},
{
name: '已完成数',
type: 'line',
data: data.map(item => item.completedCount),
smooth: true,
lineStyle: { color: '#409eff' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(64, 158, 255, 0.5)' },
{ offset: 1, color: 'rgba(64, 158, 255, 0)' }
])
}
}
]
});
}
}
});
}
// 初始加载图表
loadChartData();
// 刷新按钮点击事件
$('#refreshChartBtn').on('click', function() {
loadChartData();
});
// 窗口大小变化时,自适应调整图表
window.addEventListener('resize', function() {
memberScoreChart.resize();
areaCompletionChart.resize();
dailyCompletionChart.resize();
});
});
</script>
</body>
</html>
六、常见问题与解决方案(开发 / 部署必备)
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 日历事件不显示 | 1. 后端接口返回格式错误;2. 日期格式不匹配;3. 跨域问题 | 1. 检查接口返回是否符合 {code:0, data:[]} 格式;2. 确保后端返回 dutyDate 是 yyyy-MM-dd 格式;3. 后端添加跨域配置:@Configurationpublic class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOrigin("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); }} |
| 表单提交后后端接收不到参数 | 1. 请求头 Content-Type 不匹配;2. 参数名与实体类字段不一致 | 1. 若用 JSON 提交,需设置 contentType: 'application/json';若用表单提交,无需设置(默认 application/x-www-form-urlencoded);2. 检查前端参数名(如 studentId)与后端实体类字段是否一致(避免大小写错误) |
| 文件上传提示 “超出大小限制” | 1. Spring Boot 默认上传大小限制(1MB);2. Nginx 上传限制 | 1. 在 application.yml 中配置:spring: servlet: multipart: max-file-size: 10MB(单个文件) max-request-size: 50MB(单次请求);2. Nginx 配置:client_max_body_size 10M; |
| 定时排班任务不执行 | 1. 未添加 @EnableScheduling 注解;2. 时区配置错误;3. 任务方法有参数 | 1. 在启动类添加 @EnableScheduling;2. 配置时区:spring.jackson.time-zone=GMT+8;3. 定时方法不能有参数(如 @Scheduled(cron="0 0 0 * * ?") 修饰的方法需无参) |
| 数据库连接超时 | 1. MySQL 服务未启动;2. 连接参数错误;3. 数据库权限不足 | 1. 执行 systemctl start mysql(Linux)或在服务中启动 MySQL;2. 检查 application.yml 中 url、username、password 是否正确;3. 给用户授权:GRANT ALL PRIVILEGES ON d203_hygiene_system.* TO 'root'@'%' IDENTIFIED BY '密码';FLUSH PRIVILEGES; |
七、总结与扩展建议
本文已覆盖 D203 实验室卫生管理系统的全流程开发细节,从权限控制、API 设计、核心算法到前端交互、统计图表,提供了可直接复制使用的代码示例。若需进一步扩展,可参考以下方向:
- 对接微信小程序:用 uni-app 开发小程序,实现扫码签到、消息推送功能;
- 添加消息通知:集成企业微信 / 钉钉机器人,值日提醒、整改通知实时推送;
- 优化排班算法:基于成员历史得分、请假情况,实现 “智能加权排班”;
- 数据导出:在统计页面添加 Excel 导出功能(使用 EasyExcel 工具);
- Docker 部署:编写
Dockerfile和docker-compose.yml,实现 “一键部署”:yaml
# docker-compose.yml 示例 version: '3' services: mysql: image: mysql:5.7 environment: MYSQL_ROOT_PASSWORD: 123456 MYSQL_DATABASE: d203_hygiene_system volumes: - ./sql:/docker-entrypoint-initdb.d app: build: . ports: - "8080:8080" depends_on: - mysql
若在开发过程中遇到具体问题,可参考文中 “常见问题” 部分,或留言讨论补充细节!
更多推荐


所有评论(0)