☰
Java万年历源码剖析:Swing日历与记事本功能实现
2026/11/2 19:45:04 网站建设 项目流程

简介:这份JAVA万年历源码面向Java初学者与课程设计阶段的学生,围绕日期时间处理、Swing图形界面与事件驱动编程展开,可用于完成万年历记事本类项目或巩固Java基础。压缩包内共1个doc文件,约317KB,以课程设计报告形式呈现,涵盖总体设计、类与方法说明、运行效果展示及完整源代码,便于对照理解程序结构。资源已有185人学习,说明其在同类课程设计参考中具备一定认可度。读者可从中获取Calendar与java.time日期计算、闰年判断、月份天数推算、JComboBox与JButton等组件布局、ActionListener与MouseListener事件响应,以及基于java.io的日记读写与异常处理等实现思路,并参考报告中的测试与调试过程,快速搭建可运行的万年历记事本程序。

1. 一个 JFrame 撑起的万年历记事本,为什么值得拿来拆

翻到这份 JAVA万年历源码 的时候,我第一反应是:单文件、单类、Swing 手写布局,典型的课程设计产物。但真把它跑起来、点几下月份下拉框、在右侧文本框敲两行字再保存,你会发现它把「日历计算 + 事件分发 + 本地文件读写」这三件事塞进了同一个类里,反而成了一个很好的解剖样本。它适合两类人:刚学完 Swing 想找个能跑的小项目练手的同学,以及想快速搞懂「日期网格怎么排、按钮怎么联动、日记怎么落盘」的开发者。整份代码只有一个calendar.java,没有 Maven、没有第三方依赖,JDK 装好就能编译,这种低门槛恰恰是它最大的价值——你能在一屏代码里看到完整的 GUI 生命周期。

2. 架构与运行:单类 Swing 程序是怎么搭起来的

2.1 一个类扛下所有:成员变量就是它的骨架

这份源码最反直觉的地方在于,它没有做任何分层。calendar类直接extends JFrame并implements ActionListener, MouseListener,也就是说窗口、事件监听、业务逻辑全在一个类里。理解它的第一步不是看方法,而是看成员变量,因为变量名基本就是它的「模块划分」。

从源码里能拎出几组关键字段:year / month / day是当前选中的日期;yearafterquery / monthafterquery是查询后暂存的目标年月;startday存当月第一天是星期几;SwitchMonth是月份的英文缩写字符串;ischange / ischange_priornext两个布尔量用来标记「用户是否改过年月」;剩下的一大堆JPanel / JLabel / JButton / JTextField[] / JTextArea就是界面组件。这种「变量即状态」的写法在工程里会被骂,但读它的时候特别直观——你只要盯住year / month / day这三个值在哪个方法里被改,就能追出整条交互链路。

界面被切成左右两块,用JSplitPane水平分割:左边LeftPane是万年历,右边RightPane是记事本。左边再分LeftNorth(年份输入、月份下拉、Query 按钮、上一月/下一月)和LeftCenter(7×7 网格,第一行是SUN到SAT的星期标题,剩下 42 个JTextField装日期)。右边分RightNorth(显示当前年月日)、RightCenter(JTextArea日记区,套了JScrollPane)、RightSouth(Save / Delete 两个按钮)。这个布局结构建议你先在纸上画一遍,后面看事件处理会顺很多。

2.2 编译运行:三步把它跑起来

源码是 2012 年前后的写法,用的是JFrame+ActionListener那套老 API,好消息是它对 JDK 版本极其宽容,JDK 8 到 JDK 17 都能编。我一般这样跑:

# 1. 建目录,把 calendar.java 放进去 mkdir calendar-demo && cd calendar-demo # 2. 编译(源码里用了 ImageIcon 引外部图片,先注释掉或补图,见 2.3) javac -encoding UTF-8 calendar.java # 3. 运行,主类名就是小写的 calendar java calendar

这里有个血泪经验:类名是小写的calendar,和 Java 命名规范里「类名首字母大写」相反。javac不会拦你,但java calendar必须和文件名、类名完全一致,写成java Calendar会直接ClassNotFoundException。另外源码里new ImageIcon("../images/Handle.gif")这类相对路径引了外部图片,仓库里没带图的话,按钮图标会是空白但不影响功能;想干净跑通,最省事的做法是把这几处ImageIcon参数换成null,或者自己丢两张 gif 到上级images目录。

2.3 主函数与初始化顺序

main方法很短,本质就是new calendar(year, month, day)然后setVisible(true)。真正干活的是构造函数:它先setTitle,然后依次搭LeftPane、LeftCenter、RightPane,中间穿插MonthCombobox的 1~12 填充、42 个日期格子的创建与addMouseListener(this)、以及最关键的一句PrintMonth(year, month, day)——在窗口显示之前就把当月网格画好。构造函数末尾把this.year / this.month / this.day赋值,再getContentPane().add(split)。

public calendar(int year, int month, int day) { setTitle("My Calendar&Textbook"); // 左面板:北边查询区 + 中间日期网格 + 南边回今天按钮 LeftPane = new JPanel(); // ... 省略组件搭建 ... for (int i = 1; i <= 12; i++) { MonthCombobox.addItem(new Integer(i)); // 月份下拉 1~12 } for (int i = 0; i < 42; i++) { ShowDays[i] = new JTextField(); ShowDays[i].addMouseListener(this); // 每个日期格子都能点 ShowDays[i].setEditable(false); LeftCenter.add(ShowDays[i]); } PrintMonth(year, month, day); // 先画网格,再显示窗口 this.year = year; this.month = month; this.day = day; }

参数说明:year / month / day是启动时的默认日期,一般传Calendar.getInstance()拿到的今天。注意PrintMonth在this.year赋值之前调用,靠的是方法参数而不是成员变量,这个顺序别调换,否则网格会画错月份。ShowDays固定 42 格是因为一个月最多跨 6 周,7×6=42,这是日历网格的通用做法。

3. 核心算法拆解:日期网格到底怎么算出来的

3.1 闰年与月天数:两个小方法定生死

整个日历的正确性压在三个方法上:IsLeapYear、GetNumOfDaysInMonth、GetStartDay。前两个是教科书级实现,但源码里有个细节值得说——GetNumOfDaysInMonth用了一长串if判断 31 天和 30 天的月份,2 月单独走闰年分支,最后return 0兜底。这种写法可读性一般,但逻辑没错。

public boolean IsLeapYear(int year) { // 能被 400 整除,或能被 4 整除但不能被 100 整除 return (year % 400 == 0) || (year % 4 == 0 && year % 100 != 0); } public int GetNumOfDaysInMonth(int year, int month) { if (month == 1 || month == 3 || month == 5 || month == 7 || month == 8 || month == 10 || month == 12) return 31; if (month == 4 || month == 6 || month == 9 || month == 11) return 30; if (month == 2) return IsLeapYear(year) ? 29 : 28; return 0; // 非法月份兜底 }

参数说明:month用的是 1~12 的自然月,不是Calendar里 0~11 的索引,这一点在跨方法传参时最容易翻车。IsLeapYear的判定顺序也有讲究,先判 400 再判 4 和 100,能避免 1900 这种「能被 4 整除但不是闰年」的经典坑。

3.2 当月第一天是星期几:GetStartDay 的取模逻辑

这是整份代码里最「玄学」的一段。GetStartDay先调GetTotalNumOfDays算出从某个基准年到目标月的总天数,再对一个基准偏移量取模 7。源码里写的是startday0001 = -32768,然后(totalnumofdays + startday0001) % 7。这个-32768是作者为了对齐星期几硬凑的偏移量,属于「能跑但说不清」的典型。

public int GetStartDay(int year, int month) { int startday0001 = -32768; // 基准偏移,用于对齐星期 long totalnumofdays = GetTotalNumOfDays(year, month); return (int) ((totalnumofdays + startday0001) % 7); } public long GetTotalNumOfDays(int year, int month) { long total = 0; for (int i = -32767; i < year; i++) { // 逐年累加 total += IsLeapYear(i) ? 366 : 365; } for (int i = 1; i < month; i++) { // 累加当年已过月份 total += GetNumOfDaysInMonth(year, i); } return total; }

逻辑说明:GetTotalNumOfDays从-32767年开始累加,这个负年份起点纯粹是为了让偏移量凑成整数,实际业务里没人关心公元前。% 7的结果直接当作ShowDays数组的起始下标用——0 对应周日,6 对应周六,正好和week[]数组{"SUN",...,"SAT"}的顺序对上。如果你要改造成现代写法,我一般会换成java.time.LocalDate.of(year, month, 1).getDayOfWeek().getValue() % 7,一行顶掉这两个方法,还不用背那个-32768。

3.3 PrintMonthBody:42 个格子的填充与高亮

网格填充的核心是PrintMonthBody(startday, dayinmonth, day)。它从startday开始,把 1 到dayinmonth依次塞进ShowDays[i],遇到n == day就把这一格字体放大、前景变绿、背景变深灰,做出「今天高亮」的效果;startday之前的格子和月末之后的格子统一清空并刷深灰背景。

public void PrintMonthBody(int startday, int dayinmonth, int day) { for (int i = startday, n = 1; i < startday + dayinmonth; i++) { ShowDays[i].setText("" + n); ShowDays[i].setHorizontalAlignment(0); // 居中 if (n == day) { // 当天高亮 ShowDays[i].setForeground(Color.green); ShowDays[i].setFont(new Font("TimesRoman", Font.BOLD, 20)); ShowDays[i].setBackground(Color.DARK_GRAY); } else { ShowDays[i].setFont(new Font("TimesRoman", Font.BOLD, 12)); ShowDays[i].setForeground(Color.white); ShowDays[i].setBackground(Color.DARK_GRAY); } n++; } for (int i = 0; i < startday; i++) { ShowDays[i].setText(""); } // 月初留白 for (int i = startday + dayinmonth; i < 42; i++) { ShowDays[i].setText(""); } // 月末留白 }

参数说明:startday是当月 1 号落在网格里的下标(0~6),dayinmonth是当月天数,day是要高亮的那一天。源码里有两个重载版本,一个带day一个不带,不带day的用于翻月时不需要高亮今天的场景。这里有个小坑:ShowDays是JTextField而不是JLabel,虽然设了setEditable(false),但用户仍能选中文字,视觉上会有点怪,改成JLabel或JButton会更干净。

4. 交互与持久化:按钮、鼠标和日记文件怎么串起来

4.1 事件分发:一个 actionPerformed 管所有按钮

因为类实现了ActionListener,所有按钮都addActionListener(this),最终全涌进actionPerformed(ActionEvent e)。源码里靠e.getActionCommand()区分来源:PriorMonth和NextMonth在构造时被setActionCommand("prior")和"next",Save / Delete 也各自设了命令字符串,Query 和 ToToday 则靠对象引用比较或命令判断。

public void actionPerformed(ActionEvent e) { String cmd = e.getActionCommand(); if ("prior".equals(cmd)) { // 上一月 if (month == 1) { month = 12; year--; } else { month--; } SwitchMonth(month); PrintMonth(year, month, day); } else if ("next".equals(cmd)) { // 下一月 if (month == 12) { month = 1; year++; } else { month++; } SwitchMonth(month); PrintMonth(year, month, day); } else if ("Save".equals(cmd)) { WriteRecord(); } else if ("Delete".equals(cmd)) { DeleteFile(); } // Query / ToToday 分支略 }

逻辑说明:翻月时先处理「1 月往前是去年 12 月、12 月往后是明年 1 月」的边界,再调SwitchMonth更新英文缩写,最后重画网格。SwitchMonth本身是个 12 分支的switch,把 1~12 映射成Jan~Dec,注意源码里 10 月写成了"Qct",这是个笔误,显示出来是 Qct 不是 Oct,二次开发时顺手改掉。

4.2 鼠标选日期:mouseClicked 里换 key

日期格子是JTextField且注册了MouseListener,点某一天会触发mouseClicked。源码在这里做的事是:从被点的格子里读出文本(也就是日号),更新day,刷新右上角NorthMonthDayYear标签,并重新拼出key = year + "_" + SwitchMonth + "_" + day。这个key是后面文件读写的唯一凭据,格式类似2024_Jun_15。

public void mouseClicked(MouseEvent e) { JTextField src = (JTextField) e.getSource(); String text = src.getText(); if (text != null && !text.isEmpty()) { day = Integer.parseInt(text); // 更新选中日 key = year + "_" + SwitchMonth + "_" + day; // 重拼文件 key NorthMonthDayYear.setText(">>" + year + "," + SwitchMonth + "," + day + "<<"); ReadRecord(); // 若当天有日记,询问是否读取 } }

参数说明:e.getSource()拿到的是被点的那个JTextField,强转后取文本。这里必须判空,因为月初月末的留白格子文本是空串,直接parseInt会抛NumberFormatException。ReadRecord在点击时就被调用,意味着你每点一天都会弹一次「今天有日志,是否读取」的确认框,交互上略烦,但逻辑是通的。

4.3 日记落盘:WriteRecord / ReadRecord / DeleteFile

持久化没有用数据库,就是纯文件。WriteRecord先new File("Diary").mkdir()建目录,再用FileWriter往Diary/<key>.txt写CenterText的内容,写完弹「Save success!」。ReadRecord用FileReader逐字符读回,读之前先showConfirmDialog问用户要不要读。DeleteFile更粗暴,直接Runtime.getRuntime().exec("cmd /c del " + filepath)调系统命令删文件。

public void WriteRecord() { String content = CenterText.getText(); try { File diaryDir = new File("Diary"); diaryDir.mkdir(); // 目录不存在就建 File myfile = new File("Diary/" + key + ".txt"); // key 形如 2024_Jun_15 FileWriter record = new FileWriter(myfile); record.write(content); record.close(); JOptionPane.showMessageDialog(this, "Save success!"); } catch (IOException ex) { ex.printStackTrace(); // 源码里是空 catch,建议补日志 } }

参数说明:key决定了文件归属哪一天,所以点日期、翻月之后key必须同步更新,否则会写串文件。DeleteFile里的cmd /c del是 Windows 专用,换到 Linux/macOS 直接失效,这是这份代码跨平台性最差的一处,二次开发时我一般改成myfile.delete(),一行搞定还不用起子进程。另外源码的catch块基本是空的,文件写失败你完全看不到提示,调试时建议至少ex.printStackTrace()。

5. 避坑与排查:跑这份老代码最容易翻车的五件事

5.1 类名小写导致 ClassNotFound

现象:javac编译通过,java calendar却报Could not find or load main class。原因:类名是calendar,有人习惯性敲java Calendar,大小写不匹配。解决:严格用java calendar,或者把类名改成CalendarApp并同步改文件名,后者更符合规范。

5.2 ImageIcon 相对路径找不到图

现象:程序能起,但上一月/下一月/回今天按钮是空白。原因:new ImageIcon("../images/prior.gif")依赖上级目录的images文件夹,源码包里没带图。解决:把这几处换成new ImageIcon()或直接new JButton("上一月")用文字,功能不受影响。

5.3 空格子点击抛 NumberFormatException

现象:点月初或月末的空白日期格,控制台刷异常。原因:mouseClicked里对空文本直接Integer.parseInt。解决:解析前加if (text == null || text.trim().isEmpty()) return;,这也是 4.2 里强调判空的原因。

5.4 删除日记在非 Windows 上失效

现象:点 Delete 弹了确认框,但文件还在。原因:Runtime.exec("cmd /c del ...")只在 Windows 有效。解决:改成new File("Diary/" + key + ".txt").delete(),跨平台且不用处理子进程。

5.5 翻月后 key 没更新导致日记写串

现象:翻到 7 月保存日记,结果写进了 6 月的文件。原因:key只在mouseClicked里重拼,翻月按钮的actionPerformed分支没同步更新key。解决:在翻月逻辑末尾补一句key = year + "_" + SwitchMonth + "_" + day;,保证任何改变年月日的路径都刷新 key。

6. 二次开发:把它从课程作业改成能用的桌面小工具

真要把这份源码用起来,我建议按「先修 bug、再换算法、最后拆结构」三步走。第一步是第 5 章那五个坑,尤其 5.4 和 5.5,不修的话数据会丢、文件会串,属于必须处理的正确性问题。第二步是把日期计算整体换成java.time,GetStartDay和GetTotalNumOfDays两个方法可以直接删掉,用LocalDate替代:

import java.time.LocalDate; import java.time.DayOfWeek; // 当月 1 号是星期几(0=周日,对齐 week[] 数组) int startday = LocalDate.of(year, month, 1) .getDayOfWeek() .getValue() % 7; // 当月天数 int dayinmonth = LocalDate.of(year, month, 1).lengthOfMonth();

这两行替换掉原来那一坨负年份累加,闰年判断也一并省了,IsLeapYear可以保留但不再被调用。第三步是拆结构:把calendar这个巨类按职责切成CalendarPanel(只管网格渲染)、DiaryService(只管文件读写)、MainFrame(只管组装和事件转发)三个类。拆的时候有个技巧——先别动 UI 代码,只把WriteRecord / ReadRecord / DeleteFile抽到一个DiaryService里,构造时传入Diary目录路径,这样文件逻辑立刻可单测,UI 那边只留一行diaryService.save(key, text)。

改造点原实现建议替换收益
星期计算负年份累加 + 偏移取模LocalDate.getDayOfWeek()去掉玄学偏移,可读性拉满
文件删除Runtime.exec("cmd /c del")File.delete()跨平台,无子进程
类结构单类 800 行拆 Panel / Service / Frame可测试、可复用
异常处理空 catch至少打印堆栈 + 弹窗提示出错不再当黑匣子

验证方法也简单:改完之后拿几个边界日期跑一遍——闰年 2 月 29 日、跨年 12 月 31 日翻到 1 月 1 日、1900 年 2 月(不是闰年)、2000 年 2 月(是闰年),看网格首格位置和天数对不对;再点几天分别存日记、删日记,确认文件落在Diary/下且文件名和选中日期一致。这套回归清单我每次改日期类代码都会走一遍,比肉眼盯屏幕靠谱得多。

说个我自己的教训:早年改这类单文件项目,我总想一口气重构成 MVC,结果 UI 和逻辑一起动,改到一半连原来能跑的功能都跑不起来了,连后悔药都没得吃。从那以后我每次动这种老代码,都强制先跑通原版、记下基线行为,再一小步一小步替换,每步都编译运行验证一次。这份万年历源码不大,正好适合拿来练这个习惯——先让它跑起来,再让它算对,最后让它好改。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询