资讯详情

Java Swing日历记事本源码解析:日期算法与按日归档

📅 2026/10/26 5:03:27 | 华诺云谱 👁 阅读
Java Swing日历记事本源码解析:日期算法与按日归档
简介这是一份面向Java初学者与课程设计学习者的日历记事本实训项目资料将GUI日历与按日期记事功能结合可用于课程设计参考、Swing界面练习和事件处理学习。压缩包共28个文件、约405KB内含5个java源码、9个class编译文件、12张jpg界面截图与素材以及1份doc实训文档和1份txt说明。源码围绕年份与月份前后翻动、日期单击选择、已有记录提示及记事本显示等逻辑展开配合文档和截图可对照理解界面布局、按钮监听、日期处理与简单数据保存思路。目录中保留了需求说明、运行效果和可编译类文件便于从界面设计到事件响应逐层拆解也可作为二次改造与功能扩展的起点。已有1006人学习下载适合需要完成同类课设、夯实Java GUI基础或梳理交互流程的读者参考。1. 从一张算错星期的日历说起这个 Java 日历记事本到底能干什么某同学把课程设计交上去之前找我看了半小时界面做得挺漂亮日历记事本里 3 月 1 号却落在星期二——他自己电脑上明明是星期三。后来查出来是把Calendar.MONTH从 0 开始这件事记混了加月份的时候少算了一位整月的格子都跟着挪了位。这件事让我重新翻了一遍手里这份 Java 日历记事本源代码左边月历、右边当天记事看着简单真正决定它能不能用的其实是三件事——日期算法准不准、按日归档的数据怎么存、Swing 的事件链有没有断。它适合两类人一类是要交 Java 课程设计、想看懂每一行在干什么的同学另一类是想拿一个能直接改的桌面小工具练手 Swing 的开发者。源码包里附的那份感想也别跳过里面记的坑比代码本身更值钱。2. 项目骨架main 包里的四层结构与 Swing 选型这个日历记事本的代码量不大但分层是清楚的没有把所有东西都塞进一个MainFrame里。看源码之前先建立一个整体印象它本质上是「一个日期状态 两块视图 一个存储层」的组合任何一次点击最终都会落回这三个东西上。搞清楚这条主线后面看代码就不会迷路。2.1 目录结构四个包各管一件事源码包解开之后src下的结构大致是这样我按职责重新标注了一遍CalendarNote/ ├── src/ │ └── com/example/note/ │ ├── Main.java // 入口只负责装配窗口不写业务 │ ├── ui/ │ │ ├── MainFrame.java // 主窗口左侧日历 右侧记事BorderLayout 拆分 │ │ ├── CalendarPanel.java // 月视图表格、上/下月按钮、标题栏 │ │ └── NotePanel.java // 文本区 状态栏 保存按钮 │ ├── core/ │ │ ├── MonthGridBuilder.java // 把一个 YearMonth 摊成 42 个格子 │ │ └── NoteStore.java // 按日期读写 .properties │ └── util/ │ └── DateUtils.java // 日期格式化、星期中文名映射 ├── data/notes/ // 运行时自动生成一天一个文件 └── README.mdui只碰界面core只碰数据util是无状态的静态方法。这个划分不是为了好看是为了改的时候能定位界面错位去ui日期算错去core中文显示成方块去util。很多课程设计把这三样揉在一个类里两千行代码铺开改一个按钮位置都要翻半天那才是真正的返工现场。2.2 技术栈Swing java.time不引第三方依赖技术选型上这个项目很克制GUI 用 Swing日期用java.time存储用java.util.Properties整个项目零外部 jar。这样做的好处是拿到源码就能编译不用配 Maven 仓库、不用担心某个包下载不下来。代价是界面风格偏朴素需要自己写渲染器才能好看。为什么不用 JavaFX我的判断是课程设计和练手场景下JavaFX 从 JDK 11 之后被移出标准库得单独引依赖、配模块路径新手第一步就容易卡在Error: JavaFX runtime components are missing。Swing 虽然老但JTableTableCellRenderer这套东西拿来画月历实在太顺手——42 个格子天然就是 6 行 7 列的表格模型选中、重绘、滚动全都现成。日期部分建议全项目统一用LocalDate/YearMonth不要混Calendar。老代码里那种calendar.add(Calendar.MONTH, 1)的写法MONTH从 0 开始、DAY_OF_WEEK从 1 开始还跟地区有关两套下标混着用出错概率极高。源码里如果还有Calendar的残留可以趁二次开发的时候顺手换掉。2.3 跑起来三行命令和一次 IDE 导入先确认 JDK 版本。项目用到的LocalDate、YearMonth、DateTimeFormatter都是 Java 8 就有的JDK 11 或 17 都能直接跑# 编译注意 -encoding UTF-8Windows 下不加这句中文源码会乱码 javac -encoding UTF-8 -d out $(find src -name *.java) # 运行data/notes 目录会在第一次保存时自动创建 java -cp out com.example.note.MainWindows 的命令行里$(find ...)不认可以退化成先列文件再编译或者干脆在 IDE 里点运行。用 IDEA 的话File → Open选项目根目录把src标记成 Sources RootMain类上右键Run就行不需要额外配置。入口类本身没什么逻辑但有一个细节值得抄public class Main { public static void main(String[] args) { // Swing 组件必须在事件分发线程EDT里创建 // 否则偶尔出现的界面错位、按钮没反应排查起来非常折磨 SwingUtilities.invokeLater(() - { try { UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); } catch (Exception ignored) { // 取不到系统外观就用默认的不影响功能 } new MainFrame().setVisible(true); }); } }invokeLater这一句不是可选项。Swing 不是线程安全的main线程直接new JFrame()在大多数机器上看着没问题但在窗口初始化比较重的时候会偶发组件位置错乱。这个坑的可怕之处在于它不报错、不稳定复现属于典型的玄学问题。把窗口创建塞进 EDT这类问题基本根除。3. 日历渲染核心YearMonth 到 42 个格子的完整数据链路日历这一块是整个项目的技术含量所在。它的核心问题只有一个给定一个年月怎么画出一张对齐星期的表格。想清楚这条链路剩下的都是 Swing 的体力活。3.1 月视图数据生成为什么必须固定 42 格先讲结论月视图一定要用固定的 6 行 × 7 列 42 格不要按当月天数动态算行数。原因是月份长度和月初偏移组合起来会跨越边界——31 天的月份如果 1 号落在周六31 6 37动态算法一不留神就少一行某个日期直接消失。固定 42 格永远不会溢出代价是某些月份最后一行是空的视觉上完全可以接受。public class MonthGridBuilder { /** 固定 6 行 × 7 列保证表格行数恒定JTable 不会因为换月而抖动 */ public static final int CELLS 42; /** * param month 要展示的月份例如 YearMonth.of(2024, 3) * return 长度 42 的数组当月之外的位置填 null渲染器负责画空白 */ public static LocalDate[] build(YearMonth month) { LocalDate[] cells new LocalDate[CELLS]; LocalDate first month.atDay(1); // DayOfWeek.getValue() 返回 1(周一) ~ 7(周日) // 这里按「周一在第一列」排如果习惯周日打头改成 first.getDayOfWeek().getValue() % 7 int offset first.getDayOfWeek().getValue() - 1; int len month.lengthOfMonth(); // 自动处理闰年 2 月 for (int i 0; i len; i) { cells[offset i] first.plusDays(i); } return cells; } }参数说明month是整个方法唯一的状态输入它同时决定了「1 号是星期几」和「这个月有几天」——YearMonth把这两件事都封装好了lengthOfMonth()内部就是按闰年规则算的别再自己写if (year % 4 0 year % 100 ! 0 || year % 400 0)那套手写闰年判断错在% 100上的代码我见过不止一次。返回值里null表示「这一格属于上个月或下个月」渲染器遇到null就画空白点击时直接忽略。这比把前后月的日期补进来要简单因为一旦补了前后月就得处理「点 3 月表格里的 2 月 28 号算不算切月」这种边界问题纯属自找麻烦。3.2 表格渲染与日期点击Renderer 管长相Listener 管行为JTable的默认渲染器会把LocalDate的toString()直接印出来得到2024-03-01这种一长串格子根本放不下。所以要自己写一个TableCellRenderer只画「几号」这个数字。class DateCellRenderer extends DefaultTableCellRenderer { private final LocalDate today LocalDate.now(); Override public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) { super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column); setHorizontalAlignment(CENTER); if (value null) { // 当月之外的填充格 setText(); setBackground(Color.WHITE); return this; } LocalDate date (LocalDate) value; setText(String.valueOf(date.getDayOfMonth())); if (!isSelected) { // 选中态交给父类处理别抢 boolean isToday date.equals(today); setBackground(isToday ? new Color(0xFFF3CD) : (column 5 ? new Color(0xFFF8F8) : Color.WHITE)); setForeground(isToday ? new Color(0xC0392B) : Color.DARK_GRAY); } return this; } }这段代码里有三个地方容易改错。第一getTableCellRendererComponent是被反复调用的不要在里面new组件、读文件、查数据库否则滚动时会明显卡顿。第二setBackground必须在!isSelected分支里设不然会盖掉表格自带的选中色用户点上去毫无反馈。第三today在构造时取一次就够了但如果你希望程序通宵开着跨过零点之后高亮能跟着变就得改成每次比较时现取LocalDate.now()——两种都行看你更在意性能还是实时性。点击行为交给MouseListenertable.addMouseListener(new MouseAdapter() { Override public void mouseClicked(MouseEvent e) { int row table.rowAtPoint(e.getPoint()); int col table.columnAtPoint(e.getPoint()); if (row 0 || col 0) return; // 点在表头或空白区域 Object v table.getModel().getValueAt(row, col); if (v null) return; // 填充格不是真实日期 flushPending(); // 切日期前先把上一条落盘关键一步 currentDate (LocalDate) v; noteArea.setText(store.load(currentDate)); noteArea.setCaretPosition(0); // 光标回到开头不然会跳到末尾 statusLabel.setText(currentDate 的记事); } });flushPending()这一行是血泪经验。最开始的版本只在窗口关闭时保存结果用户写完一段话直接点下一个日期内容就没了。加上这一句之后任何一次日期切换都会先把当前编辑框的内容写下去才允许状态变更。setCaretPosition(0)也是同理setText之后光标默认停在末尾长文本一打开就滚到最下面用户会以为内容丢了。3.3 月份切换与「今天」定位月份切换的关键是状态只存YearMonth不要存LocalDate。因为LocalDate.plusMonths(1)在 1 月 31 号会收敛到 2 月 28 号再点回来就变成 2 月 28 号加一个月日期被永久改掉了。用YearMonth就完全避开这个问题。private YearMonth currentMonth YearMonth.now(); private void shiftMonth(int delta) { currentMonth currentMonth.plusMonths(delta); // YearMonth 只加减年月不碰日 titleLabel.setText(currentMonth.getYear() 年 currentMonth.getMonthValue() 月); LocalDate[] cells MonthGridBuilder.build(currentMonth); for (int i 0; i MonthGridBuilder.CELLS; i) { model.setValueAt(cells[i], i / 7, i % 7); // 一维数组按 7 个一行塞进表格模型 } } private void jumpToToday() { currentMonth YearMonth.now(); shiftMonth(0); // 复用刷新逻辑避免两份代码 currentDate LocalDate.now(); noteArea.setText(store.load(currentDate)); }i / 7和i % 7这个映射是一维数组塞二维表格的标准写法比嵌套两层for少一层缩进也不容易把行列写反。shiftMonth(0)这种用法看着有点取巧但它确实省掉了重复的刷新代码——二次开发时如果改了刷新逻辑只要改一处不会出现「点上月按钮正常、点今天按钮不刷新」这种分身 bug。标题栏还可以顺手加上农历、节气或者节假日标记思路是在渲染器里查一张MapLocalDate, String查到了就在数字下面补一行小字。这属于纯展示层扩展不影响核心逻辑适合拿来练手。4. 记事本模块按日期归档的读写与自动保存日历只解决了「看」记事本解决的是「写」。这一块的难点不在界面在于数据怎么落地——存得太散不好查存得太聚又容易冲突还得兼顾编码和自动保存。4.1 数据落地一天一个文件还是一天一行常见的有三种做法各有取舍方案存储形式优点缺点一天一文件data/notes/2024-03-01.properties定位快、单条损坏不影响全部、肉眼可读文件数量多全量搜索要遍历目录单文件多行notes.txt里按日期|内容一行一条只有一个文件方便整体备份每次保存都要重写整个文件容易写坏序列化/JSONnotes.json结构清晰、方便扩展字段需要引第三方库或者手写解析这个日历记事本用的是第一种一天一个.properties文件。我一般也推荐这种日记类数据天然按日期分片用户打开某一天就只读那一个文件几毫秒的事某个文件写坏了其他日子的记录一条不少后悔药留得足足的。文件数量看着多但一年也就 365 个现代文件系统完全扛得住。4.2 读写实现UTF-8 和 Properties 的正确用法public class NoteStore { private final Path root; public NoteStore(Path root) throws IOException { this.root root; Files.createDirectories(root); // 目录不存在就建省掉调用方每次判空 } // 文件名直接就是日期出问题时不用打开文件就知道是哪天的数据 private Path fileOf(LocalDate d) { return root.resolve(d.format(DateTimeFormatter.ISO_LOCAL_DATE) .properties); } public String load(LocalDate d) { Properties p new Properties(); try (Reader r Files.newBufferedReader(fileOf(d), StandardCharsets.UTF_8)) { p.load(r); return p.getProperty(content, ); // 没有 content 键就返回空串 } catch (NoSuchFileException e) { return ; // 这天还没写过属于正常情况 } catch (IOException e) { return [读取失败] e.getMessage(); // 把错误显示在界面上别只打日志 } } public void save(LocalDate d, String content) throws IOException { Properties p new Properties(); p.setProperty(content, content null ? : content); p.setProperty(updated, LocalDateTime.now().toString()); // 留个时间戳将来做排序有用 // 注意一定要用 Writer 重载不能用 store(OutputStream) try (Writer w Files.newBufferedWriter(fileOf(d), StandardCharsets.UTF_8)) { p.store(w, d.toString()); } } }参数说明root是笔记根目录一般传Paths.get(data, notes)相对路径的基准是启动目录打成 jar 之后基准会变这一点在打包那节会再提。Properties.store(Writer, String)和Properties.store(OutputStream, String)这个重载选择是全文最容易翻车的地方之一。用OutputStream版本所有非 ASCII 字符会被转成\u4f60\u597d这种转义形式虽然读回来还是对的但用户打开文件一看全是乱码会以为数据坏了。用Writer版本则是原样写出配合StandardCharsets.UTF_8文件用记事本打开就是中文。4.3 自动保存脏标记 定时器 窗口关闭钩子只靠手动点保存按钮是靠不住的用户在关窗口前忘了点一天的内容就没了。所以需要自动保存但它不能太频繁否则每敲一个字就写一次磁盘长文本会明显卡顿。private boolean dirty false; // 有未保存修改 private final Timer autoSave new Timer(30_000, e - saveIfDirty()); // 30 秒一次 // 文本区任何改动都只置标记不直接落盘 noteArea.getDocument().addDocumentListener(new DocumentListener() { public void insertUpdate(DocumentEvent e) { dirty true; } public void removeUpdate(DocumentEvent e) { dirty true; } public void changedUpdate(DocumentEvent e) { dirty true; } }); private void saveIfDirty() { if (!dirty || currentDate null) return; try { store.save(currentDate, noteArea.getText()); dirty false; statusLabel.setText(已自动保存 LocalTime.now().withNano(0)); } catch (IOException ex) { statusLabel.setText(保存失败 ex.getMessage()); // 失败就别清 dirty下轮重试 } } // 窗口关闭时兜底存一次再加一层保险 addWindowListener(new WindowAdapter() { Override public void windowClosing(WindowEvent e) { saveIfDirty(); } });Timer用的是javax.swing.Timer它的回调在 EDT 里执行可以直接碰界面组件如果换成java.util.Timer或者ScheduledExecutorService回调在后台线程改statusLabel就可能出现界面不刷新甚至抛异常。这个区别很多人第一次写 Swing 定时任务时压根不知道。saveIfDirty()里失败不清dirty是个小设计磁盘满、权限不足这类问题修好之后下一轮定时器会自动重试用户不用手动再点一次保存。这只是个小细节但正是这类小细节决定了工具能不能长期用。5. 避坑排查日历记事本最容易翻车的 5 个现场下面这几条都是照着源码改的时候真实会撞上的。我按「现象 → 原因 → 解决」记下来遇到了直接对号入座。5.1 日期与算法类现象一整月格子整体偏一格或者月份差一个月。原因基本可以锁定在Calendar上。Calendar.MONTH从 0 开始0 是一月Calendar.DAY_OF_WEEK从 1 开始且周日是 1跟DayOfWeek.getValue()的周一为 1 完全是两套体系。新旧代码混用错位是必然的。 解决全项目只留LocalDate和YearMonth需要格式化的地方统一走DateTimeFormatter。真要和老代码对接就在边界处写一个转换方法转换代码集中在一个类里别散落在各处。现象二1 月 31 号点「下个月」界面跳到 2 月 28 号再点回 1 月变成 1 月 28 号。原因状态变量用了LocalDateplusMonths遇到不存在的日期会自动收敛收敛之后原始日期就丢了。 解决月份状态只用YearMonth表示YearMonth.plusMonths只加减年月永远不会收敛具体日期用month.atDay(n)按需取。现象三某些月份的日历少一行某个日期找不到。原因按当月天数动态计算表格行数月初偏移加上天数超出预算的行数。 解决固定 42 格多出来的填null。这个问题在 2 月最容易暴露因为 28 天加当月 1 号是周日的组合恰好只需要 4 行动态算法容易把最后一天算漏。5.2 界面与事件类现象四切换日期之后上一条记事内容消失。原因保存动作只挂在窗口关闭事件上日期切换时状态直接被覆盖。 解决在mouseClicked里改currentDate之前先调flushPending()。养成「状态变更前先落盘」的习惯比事后加多少恢复逻辑都管用。现象五打包成 jar 双击运行界面一片空白或者报NullPointerException找不到图片。原因读取资源用了new File(images/icon.png)这种相对路径。jar 里的资源不是文件系统上的文件File拿不到。另一个常见原因是相对路径的基准变成了启动目录data/notes被写到了别的地方。 解决图片、配置文件一律用getClass().getResourceAsStream(/images/icon.png)数据目录用System.getProperty(user.home)拼一个固定路径别依赖当前工作目录。提示排查界面问题时先看控制台有没有异常堆栈。Swing 的事件回调里抛异常默认只打印不会弹窗界面表现就是「点了没反应」很容易误判成逻辑写错。5.3 编码与存储类现象六笔记文件用记事本打开是\u4f60\u597d这种转义串。原因Properties.store(OutputStream, String)会对非 ASCII 字符做转义。 解决换成store(Writer, String)配Files.newBufferedWriter(path, StandardCharsets.UTF_8)。同时编译时别忘了javac -encoding UTF-8源码里的中文字面量在 GBK 环境下会被读错。这几条踩过一遍之后再看源码里那些看着「多此一举」的写法基本就都能理解作者当时在防什么了。6. 二次开发给它加一套到点提醒的日程层原始版本只能记不能提醒。真要拿来日常用缺的恰恰是「到点告诉我」这一步。改造成本不大思路是在NoteStore之上再包一层日程解析让每条记事支持一个可选的时间前缀。我一般这么干记事内容的第一行允许写成[14:30]的形式保存时用正则提取出来存成.properties里的独立键remind。这样做的好处是完全向后兼容老数据没有这一行就当没有提醒不需要做数据迁移。// 从记事正文里抽时间前缀抽不到返回 null private static final Pattern REMIND Pattern.compile(^\\[(\\d{1,2}):(\\d{2})]\\s?(.*)$, Pattern.DOTALL); public LocalTime parseRemind(String content) { Matcher m REMIND.matcher(content); if (!m.find()) return null; // 没写前缀不设提醒 int h Integer.parseInt(m.group(1)); int min Integer.parseInt(m.group(2)); if (h 23 || min 59) return null; // 非法时间直接忽略别抛异常打断保存 return LocalTime.of(h, min); }然后是调度部分用一个Swing Timer每分钟扫一次比一次性算好所有提醒时间再挂Timer要稳——用户随时可能改记事、删记事一次性调度就得处理任务取消反而更复杂。// 每分钟检查一次命中就弹窗同一分钟的重复触发用集合去重 private final SetLocalDate firedToday new HashSet(); new Timer(60_000, e - { LocalTime now LocalTime.now().withSecond(0).withNano(0); LocalDate today LocalDate.now(); if (now.equals(LocalTime.MIDNIGHT)) firedToday.clear(); // 跨天清空去重记录 try (StreamPath files Files.list(Path.of(data, notes))) { files.filter(p - p.toString().endsWith(today .properties)).forEach(p - { String content store.load(today); LocalTime remind parseRemind(content); if (now.equals(remind) firedToday.add(today)) { JOptionPane.showMessageDialog(this, content, 日程提醒, JOptionPane.INFORMATION_MESSAGE); } }); } catch (IOException ignored) { } }).start();去重用SetLocalDate有点粗糙同一天只能响一次想支持多条提醒得换成SetString键用「日期 时间」拼起来。还有一个改造点更值得做把年视图做出来。一年 12 个月每个月一个小格子格子里有记事的日期标一个小圆点点进去跳到对应月份。这比逐个翻月查哪天记过东西好用得多实现上就是拿Files.list扫一遍目录从文件名反解日期再按月份分组。数据层已经是一天一个文件了这个功能几乎是白送。改造的时候有两条线别越一是core包不要import任何javax.swing的东西解析和存储要能脱离界面单独测二是所有的日期比较都用LocalDate.equals不要用LocalDate缓存了常用实例但没覆盖所有情况时不时给你一个反直觉的结果。从那以后我每次改这类带日期的小工具都强制先写一个只跑日期算法的main方法把跨年、闰年 2 月、月末切月这几种情况打一遍看着输出对了才动界面——省下的调试时间比写这几行测试多得多。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑