窗体类的设计
MR 人脸识别打卡系统的窗体采用精简风格设计,整个系统只有一个窗体,但可以根据用户的操作更换窗体中展示的内容。不同的功能界面由不同的面板类来实现。项目中共设计了4个功能面板和1个对话框,分别是主面板、员工管理面板、考勤报表面板、录入新员工面板和登录对话框。下面将详细讲解这些窗体、面板及其组件的设计。
主窗体
com.mr.clock.frame 包下的 MainFrame 类就是主窗体类,该类继承 JFrame 窗体类。主窗体除了做主容器,不显示任何内容,如图24.7所示。但是,主窗体会提供以下3种功能。

-
数据初始化:主窗体对象是项目启动后第一个被创建的对象,因此在构造主窗体时,让 Session 类对象做数据初始化操作,这样就可以在窗体显示前一次性地将所有数据都加载完毕。
-
更换窗体中的面板:主窗体需要提供更换面板的功能,以确保相应用户切换界面的操作。
-
关闭窗体时弹出确认提示:为防止用户误关程序,主窗体添加了窗体事件监听。如果窗体被关闭,则会弹出确认对话框,只有在用户单击 “是” 按钮的情况下,程序才会被关闭,如图24.8所示。

MainFrame 类的具体代码如下:
public class MainFrame extends JFrame {
}
主面板
com.mr.clock.frame 包下的 MainPanel 类就是主面板类,该类继承 JPanel 面板类。主面板也叫作主菜单面板,是主窗体启动后加载的第一个功能面板,效果如图24.9所示。
主面板中部是人脸识别打卡的功能区,左侧是信息提示栏,可以输出摄像头启动日志和员工打卡成功提示。右侧黑色区域是摄像头画面,全黑或者 “提示相机未就绪” 则表示摄像头尚未开启工作。
黑色区域下方有一个较大的 “打卡” 按钮,单击此按钮后,系统会打开计算机默认连接的摄像头,并将摄像头捕捉到的画面展示在上方,效果如图24.10所示。如果摄像头捕捉到某位员工的正脸,就会在提示栏中输出该员工的名字和打卡时间,然后自动关闭摄像头,效果如图24.11所示。如果计算机没有连接任何摄像头,则会弹出如图24.12所示的对话框。




主面板底部有两个按钮,分别是 “考勤报表” 按钮和 “员工管理” 按钮,如果用户单击这两个按钮则会让主窗体切换至对应的功能界面。
MainPanel 类将很多面板中使用的组件定义成了类属性,具体如下:
private MainFrame parent;
private JToggleButton daka;
private JButton kaoqin;
private JButton yuangong;
private JTextArea area;
private DetectFaceThread dft;
private JPanel center;
因为 Webcam Capture 组件是通过一个摄像头线程让前台画面不断变化的,如果想要检测是否有员工正在刷脸打卡,就需要再单独创建一个人脸识别线程不断地分析当前画面。主面板类中的成员内部类 DetectFaceThread 就是人脸识别线程类。DetectFaceThread 类继承 Thread 线程类,并在 run() 方法中写了一个不停地执行的 while 循环,从而不断地捕捉当前摄像头捕捉的帧画面。如果摄像头处于工作状态,线程就会获取当前一帧画面,并交给 FaceEngineService 人脸识别服务类对象来看看画面中的人是谁。如果 FaceEngineService 类对象返回了一个员工特征码,说明找到了匹配员工,就让 HRService 人事服务类对象为该员工添加打卡记录,并在提示栏里输出打卡成功,最后关闭摄像头。
DetectFaceThread 线程类中有一个 work 属性,表示该线程是否持续循环执行,如果 stopThread() 方法被执行,work 的值就会变成 false,使 run() 方法中的 while 循环停止,也就停止了人脸识别线程。DetectFaceThread 类的具体代码如下:
private class DetectFaceThread extends Thread {
}
releaseCamera() 方法用于释放摄像头以及释放主面板中的一些资源,并重置一些组件的属性。该方法通常会在员工打卡完成、切换其他功能面板或者用户手动关闭摄像头之后被触发。该方法的具体代码如下:
private void releaseCamera() {
}
当 “打卡” 按钮被单击后,会触发 ActionListener 监听,按钮的文本会变成 “关闭摄像头”,创建一个临期线程启动摄像头,并将摄像头的画面展示在主面板中,同时启动人脸识别线程。使用临时线程启动摄像头是为了防止摄像头漫长的启动过程阻塞主程序线程。如果按钮被再次单击,按钮的文本会变回 “打卡”,并释放摄像头及其他资源。“打卡” 按钮触发事件的具体代码如下:
daka.addActionListener(new ActionListener() {
});
“考勤报表” 按钮和 “员工管理” 按钮触发的事件就相对简单了,首先会判断用户是否有管理员身份,如果没有就弹出登录对话框让用户登录,当用户登录成功后,就会让主窗体切换至各自的功能界面。“考勤报表” 按钮和 “员工管理” 按钮触发的事件的代码具体如下:
kaoqin.addActionListener(new ActionListener() {
});
yuangong.addActionListener(new ActionListener() {
});
登录对话框
本节使用的数据表:t_user
如果用户想要查看考勤报表或者管理公司员工数据,则需要以管理员身份登录系统。登录对话框就是让用户输入用户名和密码的界面,效果如图24.13所示。
com.mr.clock.frame 包下的 LoginDialog 类就是登录对话框类,该类继承 JDialog 对话框类。它由于是一个对话框而不是一个面板,因此是一个独立的小窗体,可以在主容器之外显示。对话框有一个特点:可以阻塞主窗体。这表示弹出登录对话框之后,用户无法对对话框后面的主窗体做任何操作。

LoginDialog 类使用的组件很少,关键性的组件被定义成了类属性,具体代码如下:
private JTextField usernameField = null;
private JPasswordField passwordField = null;
private JButton loginBtn = null;
private JButton cancelBtn = null;
private final int WIDTH = 300, HEIGHT = 150;
LoginDialog 类重写了父类的构造方法,并在构造方法中调用了父类的另一个构造方法 Dialog(Frame owner, String title, boolean modal),该构造方法的第一个参数 owner 表示对话框在哪个窗体上弹出,第二个参数 title 表示对话框的标题,第三个参数 modal 表示对话框是否会阻塞该窗体。LoginDialog 类重写构造方法的具体代码如下:
public LoginDialog(Frame owner, boolean modal) {
super(owner, "管理员登录", modal); // 阻塞主窗体
setSize(WIDTH, HEIGHT); // 设置宽高
// 在主窗体中央显示
setLocation(owner.getX() + (owner.getWidth() - WIDTH)/2, owner.getY() + (owner.getHeight() - HEIGHT) / 2);
init(); // 组件初始化
addListener(); // 为组件添加监听
}
登录面板只有两个按钮,“登录” 按钮用于校验用户名和密码是否正确。如果用户名和密码正确,就将登录成功的管理员对象保存在 Session 中;如果用户名和密码不正确,就弹出错误提示。“取消” 按钮只是简单地关闭了对话框。“登录” 按钮触发事件的具体代码如下:
// 登录按钮的事件
loginBtn.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
String username = usernameField.getText().trim();
String password = new String(passwordField.getPassword());
boolean result = HRService.userLogin(username,password);
if (result) {
LoginDialog.this.dispose();
} else {
JOptionPane.showMessageDialog(LoginDialog.this, "用户名或密码有误!");
}
}
});
考勤报表面板
本节使用的数据表:t_emp,t_lock_in_record,t_work_time
考勤报表是本系统的特色功能之一,系统会分析每一位员工的考勤状况,然后生成日报和月报。考勤报表面板就用来设置和展现这两种报表。
考勤报表面板采用 CardLayout 卡片式布局,这样可以保证同时只有一种报表展示在窗体中,但又可以流畅地切换成其他报表。面板下有 4 个按钮,如图24.14所示。前 3 个按钮可以切换当前显示的内容,最后一个 “返回” 按钮则可以回到主面板界面。
打开考勤报表面板时会默认显示今日的考勤日报,用户可以通过选择上方的日期下拉列表更换日报的日期。例如,2023年1月2日的日报如图24.15所示,2023年1月8日的日报如图24.16所示。如果修改完作息时间之后日报没有同步更新,则可以单击下拉列表右侧的 “刷新报表” 按钮来重新生成日报。



如果单击了下方的 “月报” 按钮,面板会切换至月报界面。与日报不同,月报是以表格的方式显示所有员工在某一月的打卡情况的,用户可以通过选择上方的日期下拉列表更换月报的具体月份。例如,2023年1月的月报如图24.17所示,2023年12月的月报如图24.18所示。如果修改完作息时间之后月报没有同步更新,也可以单击下拉列表右侧的 “刷新报表” 按钮来重新生成月报。
如果单击了下方的 “作息时间设置” 按钮,面板会切换至作息时间设置界面。该界面会用几个文本框显示当前启动的作息时间,效果如图24.19所示。用户可以修改其中的时间值,但必须保证符合时间格式,否则会弹出错误提示。设置完之后单击 “替换作息时间” 按钮,系统就会换成新的作息时间。



com.mr.clock.frame 包下的 AttendanceManagementPanel 类就是考勤报表面板类,该类继承 JPanel 面板类。考勤报表面板中使用的组件非常多,其中一些关键性的组件被定义成了类属性,具体代码如下:
private MainFrame parent;// 主窗体
private JToggleButton dayRecordBtn; // 日报按钮
private JToggleButton monthRecordBtn;// 月报按钮
private JToggleButton worktimeBtn;// 作息时间设置按钮
private JButton back;// 返回按钮
private JButton flushD, flushM;// 分别在日报和月报面板中的刷新按钮
private JPanel centerdPanel; // 中央面板
private CardLayout card;// 中央面板使用的卡片布局
private JPanel dayRecordPanel;// 日报面板
private JTextArea area;// 日报面板里的文本域
// 日报面板里的年、月、日下拉列表
private JComboBox<Integer> yearComboBoxD, monthComboBoxD, dayComboBoxD;
// 年、月、日下拉列表使用的数据模型
private DefaultComboBoxModel<Integer> yearModelD, monthModelD, dayModelD;
private JPanel monthRecordPanel;// 月报面板
private JTable table;// 月报面板里的表格
private DefaultTableModel model;// 表格的数据模型
// 月报面板里的年、月下拉列表
private JComboBox<Integer> yearComboBoxM, monthComboBoxM;
// 年、月下拉列表使用的数据模型
private DefaultComboBoxModel<Integer> yearModelM, monthModelM;
private JPanel worktimePanel;// 作息时间面板
// 上班时间的时、分、秒文本框
private JTextField hourS, minuteS, secondS;
// 下班时间的时、分、秒文本框
private JTextField hourE, minuteE, secondE;
private JButton updateWorktime;// 替换作息时间按钮
考勤报表面板中两个核心方法是 updateDayRecord() 更新日报方法和 updateMonthRecord() 更新月报方法。
updateDayRecord() 更新日报方法首先会获取用户在日期下拉列表中选中的年、月、日,然后交给 HRService 人事服务类对象生成这一天的日报字符串,最后将字符串覆盖到文本域中,这样就实现了日报的更新,方法的具体代码如下:
private void updateDayRecord() {
// 获取日报面板中选中的年、月、日
int year = (int) yearComboBoxD.getSelectedItem();
int month = (int) monthComboBoxD.getSelectedItem();
int day = (int) dayComboBoxD.getSelectedItem();
// 获取日报报表
String report = HRService.getDayReport(year, month, day);
area.setText(report);// 日报报表覆盖到文本域中
}
updateMonthRecord() 更新月报方法会复杂一些。该方法只获取用户选择的年和月,计算出此月的最大天数后,按照姓名+最大天数来分配表格的列数,确保每一个记录都能展现在表格中。等表格的结构设计完之后,再通过 HRService 人事服务类对象生成此月的月报数据,并填充到表格中。这样就实现了月报的更新,方法的具体代码如下:
private void updateMonthRecord() {
// 获取月报面板中选中的年、月
int year = (int) yearComboBoxM.getSelectedItem();
int month = (int) monthComboBoxM.getSelectedItem();
int lastDay = DateTimeUtil.getLastDay(year, month);// 此月最大天数
String tatle[] = new String[lastDay + 1];// 表格列头
tatle[0] = "员工姓名";// 第一列是员工姓名
// 后面n为选中月份的每一天日期
for (int day = 1; day <= lastDay; day++) {
tatle[day] = year + "年" + month + "月" + day + "日";
}
// 获取月报数据
String values[][] = HRService.getMonthReport(year, month);
model.setDataVector(values, tatle);// 将数据和列头放入表格数据模型中
int columnCount = table.getColumnCount();// 获取表格中的所有列数
for (int i = 1; i < columnCount; i++) {// 遍历每一列
// 从第2列开始,没一列都设为100宽度
table.getColumnModel().getColumn(i).setPreferredWidth(100);
}
}
当用户重新选择下拉列表里的日期时,月报和日报会按照用户选择的日期重新生成。这就需要为每一个下拉列表添加事件监听。例如,当月报的日期下拉列表被重新选择时,表格中的月报会更新,其关键代码如下:
// 月报面板中的年份、月份下拉列表使用的监听对象
ActionListener yearM_monthM_Listener = new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
updateMonthRecord();// 更新月报
}
};
yearComboBoxM.addActionListener(yearM_monthM_Listener);// 添加监听
monthComboBoxM.addActionListener(yearM_monthM_Listener);
日报的日期下拉列表之间是存在联动的,如果用户修改了年和月,日下拉列表的天数会随之变化。例如:选择2023年1月之后,日下拉列表里就有31天;选中2023年11月之后,日下拉列表里就有30天。实现联动功能的关键代码如下:
// 日报面板中的日期下拉列表使用的监听对象
ActionListener dayD_Listener = new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
updateDayRecord();// 更新日报
}
};
dayComboBoxD.addActionListener(dayD_Listener);// 添加监听
// 日报面板中的年份、月份下拉列表使用的监听对象
ActionListener yearD_monthD_Listener = new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// 删除日期下拉列表使用的监听对象,防止日期改变后自动触发此监听
dayComboBoxD.removeActionListener(dayD_Listener);
updateDayModel();// 更新日下拉列表中的天数
updateDayRecord();// 更新日报
// 重新为日期下拉列表添加监听对象
dayComboBoxD.addActionListener(dayD_Listener);
}
};
yearComboBoxD.addActionListener(yearD_monthD_Listener);// 添加监听
monthComboBoxD.addActionListener(yearD_monthD_Listener);
更新日期下拉列表中的天数被单独封装成了一个 updateDayModel() 方法,在该方法中会根据用户选择的年和月来计算出此月共有多少天,并重置日期下拉列表中的内容。该方法的具体代码如下:
/**
* 更新日下拉列表中的天数
*/
private void updateDayModel() {
// 获取年下拉列表选中的值
int year = (int) yearComboBoxD.getSelectedItem();
// 获取月下拉列表选中的值
int month = (int) monthComboBoxD.getSelectedItem();
// 获取选中月份的最大天数
int lastDay = DateTimeUtil.getLastDay(year, month);
dayModelD.removeAllElements();// 清除已有元素
for (int i = 1; i <= lastDay; i++) {
dayModelD.addElement(i);// 将每一天都添加到日下拉列表数据模型中
}
}
“替换作息时间” 按钮触发事件的代码较多,但逻辑却很简单。从每一个文本框中获取用户输入的值,将这些值拼接成上班时间和下班时间两个字符串,并交给 DateTimeUtil 日期时间工具类对象校验格式是否正确。如果格式正确就让 HRService 人事服务类对象更新作息时间,如果错误就弹出提示让用户检查自己的输入。其关键代码如下:
// 替换作息时间按钮的事件
updateWorktime.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
String hs = hourS.getText().trim();// 上班的小时
String ms = minuteS.getText().trim();// 上班的分钟
String ss = secondS.getText().trim();// 上班的秒
String he = hourE.getText().trim();// 下班的小时
String me = minuteE.getText().trim();// 下班的分钟
String se = secondE.getText().trim();// 下班的秒
boolean check = true;// 时间校验成功标志
String startInput = hs + ":" + ms + ":" + ss;// 拼接上班时间
String endInput = he + ":" + me + ":" + se;// 拼接下班时间
// 如果上班时间不是正确的时间格式
if (!DateTimeUtil.checkTimeStr(startInput)) {
check = false;// 校验失败
// 弹出提示
JOptionPane.showMessageDialog(parent, "上班时间的格式不正确");
}
// 如果下班时间不是正确的时间格式
if (!DateTimeUtil.checkTimeStr(endInput)) {
check = false;// 校验失败
// 弹出提示
JOptionPane.showMessageDialog(parent, "下班时间的格式不正确");
}
if (check) {// 如果校验通过
// 弹出选择对话框,并记录用户选择
int confirmation = JOptionPane.showConfirmDialog(parent,
"确定做出以下设置?\n上班时间:" + startInput + "\n下班时间:" + endInput, "提示!", JOptionPane.YES_NO_OPTION);
if (confirmation == JOptionPane.YES_OPTION) {// 如果用户选择确定
WorkTime input = new WorkTime(startInput, endInput);
HRService.updateWorkTime(input);// 更新作息时间
// 修改标题
parent.setTitle("考勤报表 (上班时间:" + startInput + ",下班时间:" + endInput + ")");
}
}
}
});
员工管理面板
本节使用的数据表:t_emp
员工管理面板用于查看和删除员工,同时也是录入新员工的入口。员工管理面板界面的效果如图24.20所示。当管理员选中某位员工时,单击 “删除员工” 按钮会弹出确认对话框,效果如图24.21所示。如果管理员单击 “是” 按钮,则会彻底删除该员工的一切数据,包括员工信息、打卡记录和员工照片。


com.mr.clock.frame 包下的 EmployeeManagementPanel 类就是员工管理面板类,该类继承 JPanel 面板类。员工管理面板使用的组件很少,关键性的组件被定义成了类属性,具体代码如下:
private MainFrame parent;// 主窗体
private JTable table;// 员工信息表格
private DefaultTableModel model;// 表格的数据模型
private JButton add;// 录入新员工按钮
private JButton delete;// 删除员工按钮
private JButton back;// 返回按钮
员工管理面板显示的表格是一个自定义的表格,由 EmpTable 内部类实现,该类继承 JTable 表格类。EmpTable 类重写了父类的 isCellEditable() 方法,确保表格中的内容无法被编辑。同时,该类重写 getDefaultRenderer() 方法,让表格中的所有内容居中显示。EmpTable 类的具体代码如下:
private class EmpTable extends JTable {
public EmpTable(TableModel dm) {
super(dm);
setSelectionMode(ListSelectionModel.SINGLE_SELECTION);// 只能单选
}
@Override
public boolean isCellEditable(int row, int column) {
return false;// 表格不可编辑
}
@Override
public TableCellRenderer getDefaultRenderer(Class<?> columnClass) {
// 获取单元格渲染对象
DefaultTableCellRenderer cr = (DefaultTableCellRenderer) super.getDefaultRenderer(columnClass);
// 表格文字居中显示
cr.setHorizontalAlignment(DefaultTableCellRenderer.CENTER);
return cr;
}
}
表格中的第一列为员工编号,因此用户单击 “删除员工” 按钮时,就可以根据用户选择的行数确定被选中员工的编号。将员工编号交给 HRService 人事服务类对象彻底删除该员工。“删除员工” 按钮触发事件的具体代码如下:
// 删除员工按钮的事件
delete.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
int selecRow = table.getSelectedRow();// 获取表格选中的行索引
if (selecRow != -1) {// 如果有行被选中
// 弹出选择对话框,并记录用户选择
int deleteCode = JOptionPane.showConfirmDialog(parent, "确定删除该员工?", "提示!",
JOptionPane.YES_NO_OPTION);
if (deleteCode == JOptionPane.YES_OPTION) {// 如果用户选择确定
// 获取选中的员工编号
String id = (String) model.getValueAt(selecRow, 0);
HRService.deleteEmp(Integer.parseInt(id));// 删除此员工
model.removeRow(selecRow);// 表格删除此行
}
}
}
});
录入新员工面板
本节使用的数据表:t_emp
如果管理员在员工管理面板中单击了 “录入新员工” 按钮,主窗体则会切换至录入新员工面板上。该面板会立刻打卡摄像头,效果如图24.22所示。此时员工需在下方输入自己的名字,并正面面向摄像头,最后单击下方的 “拍照并录入” 按钮,就会弹出如图24.23所示的提示框。


单击提示提示框上的 “确定” 按钮后,主窗体会切换回员工管理面板,此时可以在表格中看到新员工的编号和名称,如图24.24所示。同时也可以在项目的 faces 文件夹中看到刚为新员工拍摄的照片文件,位置如图24.25所示,文件名为该员工的特征码。


com.mr.clock.frame 包下的 AddEmployeePanel 类就是录入新员工面板类,该类继承 JPanel 面板类。录入新员工面板使用的组件很少,关键性的组件被定义成了类属性,具体代码如下:
private MainFrame parent;// 主窗体
private JLabel message;// 提示
private JTextField nameField;// 姓名文本框
private JButton submit;// 提交按钮
private JButton back;// 返回按钮
private JPanel center;// 中部面板
与主面板一样,录入新员工面板也为启动摄像头编写了一个临时线程,只不过在未检测到摄像头的情况下会自动触发 “返回” 按钮的单击事件,也就是让主窗体切换回员工管理面板。其关键代码如下:
// 摄像头启动线程
Thread cameraThread = new Thread() {
public void run() {
if (CameraService.startCamera()) {// 如果摄像头成功开启
message.setText("请正面面向摄像头");// 更换提示信息
// 获取摄像头画面面板
JPanel cameraPanel = CameraService.getCameraPanel();
// 设置面板的坐标和宽高
cameraPanel.setBounds(150, 75, 320, 240);
center.add(cameraPanel);// 放到中部面板
} else {
// 弹出提示
JOptionPane.showMessageDialog(parent, "未检测到摄像头!");
back.doClick();// 出发返回按钮的点击事件
}
}
};
cameraThread.start();// 开启线程
录入新员工面板的主要业务都集中在 “拍照并录入” 按钮上,该按钮被单击后会做 3 种校验:员工是否输入了自己的姓名?摄像头是否正常工作?摄像头是否拍到了员工的脸?只有在这 3 种校验都通过的情况下,才会通过 HRService 人事服务类对象添加新员工,并从摄像头中取一帧有人脸的画面作为员工的照片,交给 ImageService 图片服务类对象保存照片文件,同时将该员工的面部特征保存在 Session 类对象的面部特征库里。“拍照并录入” 按钮触发事件的具体代码如下:
// 提交按钮的事件
submit.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e1) {
String name = nameField.getText().trim();// 获取文本框里的名字
if (name == null || "".equals(name)) {// 如果是空内容
JOptionPane.showMessageDialog(parent, "名字不能为空!");
return;// 中断方法
}
if (!CameraService.cameraIsOpen()) {// 如果摄像头未开启
JOptionPane.showMessageDialog(parent, "摄像头尚未开启,请稍后。");
return;
}
// 获取当前摄像头捕捉的帧
BufferedImage image = CameraService.getCameraFrame();
// 获取此图像中人脸的面部特征
FaceFeature ff = FaceEngineService.getFaceFeature(image);
if (ff == null) {// 如果不存在面部特征
JOptionPane.showMessageDialog(parent, "未检测到有效人脸信息");
return;
}
Employee e = HRService.addEmp(name, image);// 添加新员工
ImageService.saveFaceImage(image, e.getCode());// 保存员工照片文件
Session.FACE_FEATURE_MAP.put(e.getCode(), ff);// 全局回话记录此人面部特征
JOptionPane.showMessageDialog(parent, "员工添加成功!");// 弹出提示框
back.doClick();// 出发返回按钮的点击事件
}
});