简介:这是一套基于Java Swing与MySQL开发的轻量级停车场管理系统实战项目,面向Java初学者及GUI+数据库综合实践学习者,解决小型车场基础业务管理需求,如车辆出入场、计费查询、用户信息维护与管理员权限控制等。资源包共80个文件,含25个核心Java源码(涵盖UI界面、业务逻辑与数据库交互)、47个编译后Class字节码、1个建库SQL脚本(含charger/park/station/users四张关键表)、2个IDE配置文件及运行截图等,整体2.11MB,结构清晰,便于逐模块阅读与调试。已有728人学习下载,项目完整呈现了Swing事件驱动编程、JDBC连接池基础应用、MySQL 8.0数据建模与CRUD操作全流程,附带可直接运行的GUI界面与典型业务场景截图,适合用于课程设计、毕业实训或Java桌面应用入门进阶实践。
1. 停车场管理系统为什么非得用 Java + Swing + MySQL?——不是怀旧,是稳得住、改得动、查得清
你可能在招聘JD里见过“熟悉Swing开发”被列为加分项,也可能在GitHub上刷到过一堆标着“Java停车场系统”的冷门仓库,点进去发现界面朴素、代码直白、连Maven都没配——但偏偏能跑,能增删车位,能按车牌查记录,能导出Excel。这不是过时技术的残影,而是中小型物业、园区、学校后勤这类场景的真实选择:不需要高并发压测,但要求本地可部署、数据库可直连、逻辑可快速调整、故障能一眼看出在哪一行。Swing不是UI框架里的顶流,但它把“窗口—事件—数据库”这条链路压得极短:一个JButton点击触发PreparedStatement执行,中间没拦截器、没DTO转换、没Spring Boot自动装配的黑匣子;MySQL也不是云原生首选,但它让管理员用Navicat点几下就能改字段、看慢查询、备份单表——这对运维人力紧张的单位,比“微服务拆分”实在得多。本文不讲Spring Cloud怎么搭,只带你用最简路径,把“车辆入场→分配车位→计费生成→出场结算”这整条业务线,用纯Java+Swing+MySQL跑通、调稳、查准。适合刚毕业想补全栈闭环的新手,也适合需要快速交付定制化模块的老手——毕竟,当甲方说“明天要加个‘VIP月租车位锁定’按钮”,你打开MainFrame.java加个JCheckBox,改两行SQL,打包发exe,比改三个微服务配置还快。
2. 从零建库到Swing主窗体:最小可行系统的三步落地
2.1 MySQL建库建表:按业务实体拆,别一上来就搞ER图
停车场核心实体就四个:车辆(car)、车位(parking_space)、停车记录(parking_record)、管理员(admin)。别学教科书搞“用户-角色-权限”大而全,先让数据能存进去再说。我习惯用命令行建库,避免图形界面默认字符集埋坑:
mysql -u root -p CREATE DATABASE parking_system CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE parking_system;建表时重点盯三点:status字段用TINYINT而非VARCHAR(0=空闲/1=占用/2=维修),in_time和out_time必须设DEFAULT CURRENT_TIMESTAMP,plate_number加UNIQUE索引——这是后续查重和快速定位的命脉:
-- 车位表:物理位置+状态+类型(固定/临时) CREATE TABLE parking_space ( id INT PRIMARY KEY AUTO_INCREMENT, space_no VARCHAR(10) NOT NULL UNIQUE, -- 如A01、B15 type ENUM('fixed','temporary') DEFAULT 'fixed', status TINYINT DEFAULT 0, -- 0:空闲 1:占用 2:维修 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 停车记录表:关键!in_time必须有索引,否则查“当前在场车辆”会慢 CREATE TABLE parking_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, plate_number VARCHAR(15) NOT NULL, space_id INT NOT NULL, in_time DATETIME DEFAULT CURRENT_TIMESTAMP, out_time DATETIME NULL, fee DECIMAL(8,2) DEFAULT 0.00, status ENUM('in','out','abnormal') DEFAULT 'in', INDEX idx_plate (plate_number), INDEX idx_intime (in_time), INDEX idx_status_intime (status, in_time) );提示:
parking_record表的status字段用ENUM而非INT,是为了防止插入非法值(如status=99),Swing层校验再严,也挡不住直接SQL注入——数据库层兜底才是真稳。
2.2 Swing主窗体骨架:用BorderLayout撑起四大功能区
Swing不是靠拖控件堆出来的,而是靠布局管理器“卡”出来的。ParkingMainFrame继承JFrame,用BorderLayout分五块:顶部菜单栏(JMenuBar)、左侧功能树(JTree)、中部操作面板(JPanel)、右侧实时车位图(JPanel绘图)、底部状态栏(JLabel)。重点不是炫技,是让每个区域职责清晰——比如“左侧功能树”只负责跳转,不处理数据;“中部操作面板”只管输入和按钮,不连数据库。这样改需求时,加个“月租车续费”功能,只动中部面板,不影响车位图渲染逻辑。
public class ParkingMainFrame extends JFrame { private JTree functionTree; // 左侧功能树 private JPanel centerPanel; // 中部操作区(动态切换) private JPanel mapPanel; // 右侧车位图(自定义绘制) private JLabel statusLabel; // 底部状态栏 public ParkingMainFrame() { setTitle("停车场管理系统 v1.0"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setLayout(new BorderLayout()); // 1. 顶部菜单栏(文件、查询、报表、帮助) setJMenuBar(createMenuBar()); // 2. 左侧功能树:用DefaultMutableTreeNode构建层级 functionTree = createFunctionTree(); add(new JScrollPane(functionTree), BorderLayout.WEST); // 3. 中部操作面板:初始加载入场登记面板 centerPanel = new EntryPanel(); // 自定义JPanel add(centerPanel, BorderLayout.CENTER); // 4. 右侧车位图:用paintComponent重绘,每格代表一个车位 mapPanel = new ParkingMapPanel(); add(new JScrollPane(mapPanel), BorderLayout.EAST); // 5. 底部状态栏 statusLabel = new JLabel("就绪 | 当前连接: localhost:3306"); add(statusLabel, BorderLayout.SOUTH); pack(); setLocationRelativeTo(null); setVisible(true); } private JTree createFunctionTree() { DefaultMutableTreeNode root = new DefaultMutableTreeNode("功能导航"); DefaultMutableTreeNode entryNode = new DefaultMutableTreeNode("车辆入场"); DefaultMutableTreeNode exitNode = new DefaultMutableTreeNode("车辆出场"); DefaultMutableTreeNode queryNode = new DefaultMutableTreeNode("记录查询"); DefaultMutableTreeNode reportNode = new DefaultMutableTreeNode("报表统计"); root.add(entryNode); root.add(exitNode); root.add(queryNode); root.add(reportNode); return new JTree(root); } }逻辑说明:
createFunctionTree()返回的JTree不绑定事件监听——那是EntryPanel和ExitPanel自己的事。Swing的解耦哲学是:组件只管“画什么”,不问“为什么画”。后续所有业务逻辑都放在独立Panel类里,主窗体只做容器和路由。
2.3 JDBC连接池:HikariCP不是必须,但DriverManager绝对要换掉
别用Class.forName("com.mysql.cj.jdbc.Driver")+DriverManager.getConnection()这种教科书写法。它每次new Connection都走TCP握手,10个并发就卡死。HikariCP轻量(仅200KB)、启动快、监控全,且与Swing无冲突。Maven加依赖:
<dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>5.0.1</version> </dependency>初始化连接池时,关键参数就三个:maximumPoolSize=5(Swing桌面应用根本用不到50连接)、connectionTimeout=3000(超时3秒报错,别让用户干等)、idleTimeout=600000(空闲10分钟回收):
public class DBConnection { private static HikariDataSource dataSource; static { HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/parking_system?useSSL=false&serverTimezone=Asia/Shanghai"); config.setUsername("root"); config.setPassword("your_password"); config.setMaximumPoolSize(5); config.setConnectionTimeout(3000); config.setIdleTimeout(600000); config.setLeakDetectionThreshold(60000); // 60秒未关闭连接告警 dataSource = new HikariDataSource(config); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }参数说明:
useSSL=false是本地开发必需(MySQL 8.0+默认强制SSL,Swing连不上会报Public Key Retrieval is not allowed);serverTimezone=Asia/Shanghai解决时间戳存取错乱——这是新手踩坑率最高的点,没有之一。
3. 车辆入场与出场:Swing事件驱动下的数据库事务闭环
3.1 入场流程:从车牌输入到车位分配,三步锁住数据一致性
入场不是简单INSERT。真实场景要检查:车牌是否已存在未出场记录(防重复入场)、是否有空闲车位、分配后要更新车位状态。这三步必须在一个事务里完成,否则出现“记录写了但车位没占”,系统就乱套了。Swing按钮监听器里,用try-with-resources确保Connection和PreparedStatement自动关闭:
// EntryPanel.java 中的入场按钮事件 private void onEntryClick() { String plate = plateField.getText().trim(); if (plate.isEmpty()) { JOptionPane.showMessageDialog(this, "请输入车牌号!"); return; } Connection conn = null; PreparedStatement ps = null; try { conn = DBConnection.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 检查该车牌是否有未出场记录 ps = conn.prepareStatement("SELECT id FROM parking_record WHERE plate_number = ? AND status = 'in'"); ps.setString(1, plate); ResultSet rs = ps.executeQuery(); if (rs.next()) { JOptionPane.showMessageDialog(this, "车牌" + plate + "已在场内,请勿重复入场!"); conn.rollback(); return; } // 2. 查找第一个空闲车位 ps = conn.prepareStatement("SELECT id, space_no FROM parking_space WHERE status = 0 LIMIT 1 FOR UPDATE"); rs = ps.executeQuery(); if (!rs.next()) { JOptionPane.showMessageDialog(this, "车位已满!"); conn.rollback(); return; } int spaceId = rs.getInt("id"); String spaceNo = rs.getString("space_no"); // 3. 写入停车记录 + 更新车位状态(同一事务) ps = conn.prepareStatement("INSERT INTO parking_record (plate_number, space_id, in_time, status) VALUES (?, ?, NOW(), 'in')"); ps.setString(1, plate); ps.setInt(2, spaceId); ps.executeUpdate(); ps = conn.prepareStatement("UPDATE parking_space SET status = 1 WHERE id = ?"); ps.setInt(1, spaceId); ps.executeUpdate(); conn.commit(); JOptionPane.showMessageDialog(this, "入场成功!车位:" + spaceNo); statusLabel.setText("入场成功 | 车牌:" + plate + " → " + spaceNo); } catch (SQLException e) { try { if (conn != null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } JOptionPane.showMessageDialog(this, "入场失败:" + e.getMessage()); } finally { try { if (ps != null) ps.close(); if (conn != null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }关键细节:
SELECT ... FOR UPDATE是核心——它给查到的那条车位记录加行锁,防止两个用户同时点“入场”抢同一个空位。没这句,高并发下必出“车位分配冲突”。
3.2 出场结算:时间计算+费用生成+状态更新,一个都不能少
出场逻辑比入场更重:要算停留时长、查费率规则、更新两条记录(记录表+车位表)、生成小票。MySQL里用TIMESTAMPDIFF算小时数最稳,别用JavaDuration.between()——时区和夏令时会让结果飘:
// ExitPanel.java 中的出场按钮事件 private void onExitClick() { String plate = plateField.getText().trim(); if (plate.isEmpty()) { JOptionPane.showMessageDialog(this, "请输入车牌号!"); return; } Connection conn = null; PreparedStatement ps = null; try { conn = DBConnection.getConnection(); conn.setAutoCommit(false); // 1. 查找未出场记录 ps = conn.prepareStatement( "SELECT id, space_id, in_time FROM parking_record WHERE plate_number = ? AND status = 'in'"); ps.setString(1, plate); ResultSet rs = ps.executeQuery(); if (!rs.next()) { JOptionPane.showMessageDialog(this, "未找到车牌" + plate + "的入场记录!"); conn.rollback(); return; } long recordId = rs.getLong("id"); int spaceId = rs.getInt("space_id"); Timestamp inTime = rs.getTimestamp("in_time"); // 2. 计算费用:MySQL直接算小时数(向上取整),避免Java端时区误差 ps = conn.prepareStatement( "SELECT CEIL(TIMESTAMPDIFF(MINUTE, ?, NOW()) / 60.0) AS hours FROM dual"); ps.setTimestamp(1, inTime); rs = ps.executeQuery(); rs.next(); int hours = rs.getInt("hours"); BigDecimal fee = new BigDecimal(hours).multiply(new BigDecimal("5.00")); // 5元/小时 // 3. 更新记录表(出场时间、费用、状态)+ 车位表(释放) ps = conn.prepareStatement( "UPDATE parking_record SET out_time = NOW(), fee = ?, status = 'out' WHERE id = ?"); ps.setBigDecimal(1, fee); ps.setLong(2, recordId); ps.executeUpdate(); ps = conn.prepareStatement("UPDATE parking_space SET status = 0 WHERE id = ?"); ps.setInt(1, spaceId); ps.executeUpdate(); conn.commit(); JOptionPane.showMessageDialog(this, String.format("出场成功!停留%d小时,费用%.2f元", hours, fee.doubleValue())); statusLabel.setText("出场成功 | 车牌:" + plate + " → 费用:" + fee); } catch (SQLException e) { try { if (conn != null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } JOptionPane.showMessageDialog(this, "出场失败:" + e.getMessage()); } finally { // 同上,关闭资源 } }血泪经验:
CEIL(TIMESTAMPDIFF(MINUTE, in_time, NOW()) / 60.0)这句必须写在SQL里。我曾把计算逻辑移到Java端,结果某天服务器时区被运维误改成UTC,所有计费少算8小时——客户投诉电话打爆。
4. 避坑指南:Swing+MySQL组合里,90%的翻车都发生在这五个地方
4.1 现象:Swing界面卡死、按钮点击无响应
原因:Swing是单线程模型(Event Dispatch Thread),所有UI操作和耗时数据库操作都在同一线程。一旦getConnection()或executeQuery()阻塞超过1秒,整个界面冻结。
解决:用SwingWorker把数据库操作扔到后台线程,结果通过done()回调更新UI。别用Thread.sleep()模拟,那只是掩盖问题。
private void searchRecords() { String plate = queryField.getText().trim(); new SwingWorker<List<ParkingRecord>, Void>() { @Override protected List<ParkingRecord> doInBackground() throws Exception { return DBQuery.searchByPlate(plate); // 耗时查询 } @Override protected void done() { try { List<ParkingRecord> records = get(); updateTable(records); // 在EDT线程安全更新JTable } catch (Exception e) { JOptionPane.showMessageDialog(ParkingMainFrame.this, "查询失败:" + e.getMessage()); } } }.execute(); }4.2 现象:MySQL插入中文变问号(????)
原因:MySQL服务端、数据库、表、连接URL四层字符集不一致。常见是建库时用了utf8(MySQL的utf8实际是utf8mb3,不支持emoji),而Java连接URL没指定characterEncoding=utf8mb4。
解决:
- 建库时用
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - 连接URL加
?useUnicode=true&characterEncoding=utf8mb4; - MySQL配置文件
my.cnf中[client]和[mysqld]段都设default-character-set=utf8mb4。
4.3 现象:JTable显示数据但无法编辑,双击单元格没反应
原因:JTable默认Model(DefaultTableModel)的isCellEditable()返回false,且Swing对表格编辑有严格要求:必须实现setValueAt()、getColumnClass()返回对应类型(String列不能返回Object)。
解决:自定义TableModel,重写关键方法:
class RecordTableModel extends AbstractTableModel { private List<ParkingRecord> data = new ArrayList<>(); private final String[] columns = {"ID", "车牌", "车位", "入场时间", "出场时间", "费用", "状态"}; @Override public boolean isCellEditable(int row, int column) { return false; // 只读表格,禁止编辑 } @Override public Class<?> getColumnClass(int columnIndex) { if (columnIndex == 0 || columnIndex == 3 || columnIndex == 4) return Long.class; if (columnIndex == 5) return Double.class; return String.class; } }4.4 现象:程序退出后MySQL连接未释放,报Too many connections
原因:Swing的setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE)会杀进程,但HikariCP的连接池线程可能来不及优雅关闭,导致连接堆积。
解决:重写windowClosing事件,主动关闭连接池:
this.addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent e) { try { if (DBConnection.getDataSource() != null) { DBConnection.getDataSource().close(); // 主动关闭HikariCP } } catch (Exception ex) { ex.printStackTrace(); } System.exit(0); } });4.5 现象:Swing Tree节点勾选后,无法获取选中状态
原因:“swing带勾选框的tree”不是原生支持,需用JCheckBoxTree或自定义TreeCellRenderer。但多数人直接往DefaultMutableTreeNode塞Boolean,却忘了重写toString()——节点显示为空白,勾选状态丢失。
解决:用JCheckBoxTree第三方组件(轻量,仅一个类),或自定义Renderer时,确保getTreeCellRendererComponent()里正确设置JCheckBox状态:
public class CheckBoxTreeCellRenderer extends DefaultTreeCellRenderer { private JCheckBox checkBox = new JCheckBox(); @Override public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected, boolean expanded, boolean leaf, int row, boolean hasFocus) { super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus); if (value instanceof DefaultMutableTreeNode) { Object userObj = ((DefaultMutableTreeNode) value).getUserObject(); if (userObj instanceof CheckBoxNode) { CheckBoxNode node = (CheckBoxNode) userObj; checkBox.setSelected(node.isSelected()); checkBox.setText(node.getText()); return checkBox; } } return this; } }5. 报表统计与导出:用JFreeChart画趋势图,用Apache POI导Excel
5.1 日报表生成:用JFreeChart画“每小时入场车辆数”折线图
Swing里嵌图表不用JavaFX,JFreeChart成熟稳定、文档全、导出格式多。核心是构造CategoryDataset——把MySQL查出的每小时数据转成DefaultCategoryDataset:
private JFreeChart createHourlyChart(List<Object[]> hourlyData) { DefaultCategoryDataset dataset = new DefaultCategoryDataset(); for (Object[] row : hourlyData) { // row[0]=hour, row[1]=count dataset.addValue((Number) row[1], "车辆数", String.valueOf(row[0])); } JFreeChart chart = ChartFactory.createLineChart( "今日每小时入场车辆数", "小时", "数量", dataset, PlotOrientation.VERTICAL, true, true, false ); // 美化:字体、网格线、坐标轴 chart.getTitle().setFont(new Font("微软雅黑", Font.BOLD, 14)); XYPlot plot = (XYPlot) chart.getPlot(); plot.getRangeAxis().setAutoRangeIncludesZero(false); plot.setDomainGridlinesVisible(true); plot.setRangeGridlinesVisible(true); return chart; }注意:
hourlyData从MySQL来,SQL用GROUP BY HOUR(in_time)聚合,别用Java端循环计算——数据库聚合效率高十倍。
5.2 Excel导出:Apache POI写.xlsx,避开xls兼容性雷
POI 5.x支持.xlsx(.xls已淘汰),导出时注意三点:日期单元格设CellStyle、数字列对齐右、表头加粗。关键代码:
private void exportToExcel(List<ParkingRecord> records, String filename) { try (XSSFWorkbook workbook = new XSSFWorkbook(); FileOutputStream fileOut = new FileOutputStream(filename)) { XSSFSheet sheet = workbook.createSheet("停车记录"); XSSFRow headerRow = sheet.createRow(0); String[] headers = {"ID", "车牌", "车位", "入场时间", "出场时间", "费用", "状态"}; for (int i = 0; i < headers.length; i++) { XSSFCell cell = headerRow.createCell(i); cell.setCellValue(headers[i]); // 表头样式:加粗 XSSFCellStyle style = workbook.createCellStyle(); XSSFFont font = workbook.createFont(); font.setBold(true); style.setFont(font); cell.setCellStyle(style); } // 数据行 for (int i = 0; i < records.size(); i++) { XSSFRow row = sheet.createRow(i + 1); ParkingRecord r = records.get(i); row.createCell(0).setCellValue(r.getId()); row.createCell(1).setCellValue(r.getPlateNumber()); row.createCell(2).setCellValue(r.getSpaceNo()); // 时间列:用CellStyle格式化 XSSFCellStyle dateStyle = workbook.createCellStyle(); CreationHelper createHelper = workbook.getCreationHelper(); dateStyle.setDataFormat(createHelper.createDataFormat().getFormat("yyyy-mm-dd hh:mm:ss")); XSSFCell inCell = row.createCell(3); inCell.setCellValue(r.getInTime()); inCell.setCellStyle(dateStyle); row.createCell(4).setCellValue(r.getOutTime() == null ? "" : r.getOutTime().toString()); row.createCell(5).setCellValue(r.getFee().doubleValue()); row.createCell(6).setCellValue(r.getStatus()); } // 自动列宽 for (int i = 0; i < headers.length; i++) { sheet.autoSizeColumn(i); } workbook.write(fileOut); JOptionPane.showMessageDialog(this, "导出成功:" + filename); } catch (IOException e) { JOptionPane.showMessageDialog(this, "导出失败:" + e.getMessage()); } }提示:
XSSFFont和XSSFCellStyle必须用workbook.createXXX()创建,不能复用——POI的样式是Workbook级对象,跨sheet复用会崩溃。
5.3 实战技巧:用MySQL视图简化复杂查询,Swing里当普通表用
报表常要“各类型车位使用率”,写JOIN太长易错。建视图把逻辑封装:
CREATE VIEW space_usage_view AS SELECT ps.type, COUNT(*) as total_spaces, SUM(CASE WHEN ps.status = 1 THEN 1 ELSE 0 END) as occupied_count, ROUND(SUM(CASE WHEN ps.status = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) as usage_rate FROM parking_space ps GROUP BY ps.type;Swing里查视图和查表一样:
public static List<SpaceUsage> loadSpaceUsage() { List<SpaceUsage> list = new ArrayList<>(); String sql = "SELECT type, total_spaces, occupied_count, usage_rate FROM space_usage_view"; try (Connection conn = DBConnection.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(new SpaceUsage( rs.getString("type"), rs.getInt("total_spaces"), rs.getInt("occupied_count"), rs.getDouble("usage_rate") )); } } catch (SQLException e) { e.printStackTrace(); } return list; }我的习惯:所有报表SQL都先在MySQL Workbench里跑通,再粘到Java里。视图名加
_view后缀,和业务表隔开,避免混淆。这招省下30%调试时间——因为错误在数据库层就暴露了,不用进Swing断点。
希望帮到你。
本文还有配套的精品资源,点击获取