前言

本文是 D203 实验室卫生管理系统的全流程开发部署手册,在前期指南基础上补充了代码实现细节、API 规范、权限控制、前端交互优化等核心内容。全文覆盖从环境搭建到上线运维的每个环节,包含大量可直接复用的代码示例和图表说明,适合初学者或团队开发者参照复现,也可作为类似管理系统的开发参考。

一、项目背景与现状分析

实验室卫生管理传统模式依赖纸质记录和口头通知,存在三大痛点:

  1. 效率低:人工排班易出错,执行记录无法实时追溯;
  2. 标准不统一:清洁要求、评分规则依赖人工判断,结果差异大;
  3. 数据无价值:历史记录难以统计分析,无法为管理优化提供依据。

本系统通过 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:

  1. 引入依赖
<!-- pom.xml 新增JWT依赖 -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>
  1. 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 核心设计原则

  1. 资源命名:使用复数名词(如 /members 而非 /member);
  2. HTTP 方法语义
    • GET:查询资源(如 /api/v1/members 获取成员列表);
    • POST:创建资源(如 /api/v1/members 新增成员);
    • PUT:全量更新资源(如 /api/v1/members/1 更新 ID=1 的成员);
    • DELETE:删除资源(如 /api/v1/members/1 删除 ID=1 的成员);
  3. 统一响应格式
{
  "code": 0,          // 状态码:0成功,非0错误(如1001参数错误、1002权限不足)
  "message": "success", // 提示信息
  "data": {}          // 业务数据(查询接口返回列表/对象,操作接口返回空或ID)
}

3.2 主体模块接口示例

模块接口地址方法权限角色描述请求参数示例
成员管理/api/v1/membersGET所有登录用户分页查询成员列表page=1&size=10&groupName=第一组
成员管理/api/v1/membersPOSTADMIN新增成员{"name":"张三","studentId":"2023001","role":"ROLE_MEMBER"}
值日规则/api/v1/rulesGETADMIN/SUPERVISOR查询所有值日规则-
排班管理/api/v1/schedules/auto-generatePOSTADMIN触发自动排班(按规则生成){"ruleId":1,"startDate":"2025-11-01","endDate":"2025-11-07"}
执行记录/api/v1/executionsPOST所有登录用户提交值日执行记录(签到 / 签退){"scheduleId":101,"status":"done","photos":["/img/1.jpg"]}
统计分析/api/v1/statistics/memberGETADMIN/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 中 urlusernamepassword 是否正确;3. 给用户授权:GRANT ALL PRIVILEGES ON d203_hygiene_system.* TO 'root'@'%' IDENTIFIED BY '密码';FLUSH PRIVILEGES;

七、总结与扩展建议

本文已覆盖 D203 实验室卫生管理系统的全流程开发细节,从权限控制、API 设计、核心算法到前端交互、统计图表,提供了可直接复制使用的代码示例。若需进一步扩展,可参考以下方向:

  1. 对接微信小程序:用 uni-app 开发小程序,实现扫码签到、消息推送功能;
  2. 添加消息通知:集成企业微信 / 钉钉机器人,值日提醒、整改通知实时推送;
  3. 优化排班算法:基于成员历史得分、请假情况,实现 “智能加权排班”;
  4. 数据导出:在统计页面添加 Excel 导出功能(使用 EasyExcel 工具);
  5. 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
    

若在开发过程中遇到具体问题,可参考文中 “常见问题” 部分,或留言讨论补充细节!

Logo

助力合肥开发者学习交流的技术社区,不定期举办线上线下活动,欢迎大家的加入

更多推荐