简介:这份JAVA万年历源码面向Java初学者与课程设计需求者,以单文件calendar类实现万年历与记事本二合一桌面应用,帮助读者在真实项目中理解日期时间处理、Swing界面设计与事件驱动编程。压缩包内共1个doc文件,约317KB,为完整的Java课程设计报告,涵盖总体设计、类与方法说明、源代码及运行效果截图,可对照文档梳理Calendar与java.time的用法、闰年判断、月份起始天与总天数计算等核心逻辑。项目还涉及JComboBox月份选择、JButton动作监听、JTextArea日记输入,以及基于java.io的文件读写与try-catch异常处理,界面采用多种布局管理器组合,结构清晰。目前已有185人学习,适合需要参考完整课设文档与可运行源码、快速完成类似项目的读者。
1. 单文件 Swing 万年历记事本:一个类撑起日历与日记
翻到这份calendar.java的时候我愣了一下——整个项目就一个源文件、一个类,既画日历又管日记,还顺手把闰年判断、月份切换、文件读写全塞进去了。它解决的是很具体的一类需求:桌面上放一个小工具,左边翻日历看日子,右边给某一天写点东西,点保存就落盘成 txt。适合谁?刚学完 Java 语法、想找一个能完整跑起来、又能看懂每一行的 Swing 练手项目的同学,以及需要给课程设计交一份“有界面、有交互、有持久化”作业的开发者。它不依赖任何第三方库,JDK 自带 AWT、Swing、IO 就够,这也是它作为 JAVA 万年历源码最讨喜的地方:拿到就能编译,改起来没有黑盒。
2. 架构与目录:一个类如何拆出日历面板和记事本面板
2.1 整体结构:JFrame + JSplitPane 的左右分栏
这个项目的“架构”谈不上分层,但布局思路是清晰的。主类calendar直接继承JFrame并实现ActionListener、MouseListener两个接口,窗口内容用一个JSplitPane水平切开:左边是日历区LeftPane,右边是记事本区RightPane。日历区再往下拆成北、中两块,北边放年份输入框、月份下拉框、查询按钮和前后月切换按钮,中间用GridLayout(7,7)铺出 7 列星期标题加 42 个日期格子。记事本区则是北边一个日期标题标签、中间一个带滚动条的JTextArea、南边保存和删除两个按钮。
这种“一个类全包”的写法在课程设计里很常见,好处是逻辑集中、不用来回跳文件,坏处是成员变量多到一张表都列不完。原文的变量表里能看到YearLabel、MonthCombobox、ShowDays[]、CenterText、SouthSave这些,基本就是按界面区域命名的,读代码时对着界面找变量很快。
2.2 目录与文件构成
项目本身极简,落到磁盘上大概是这样:
calendar/ ├── calendar.java # 唯一源文件,含主类与 main 方法 ├── images/ # 按钮图标资源(prior.gif / next.gif / Handle.gif) │ ├── prior.gif │ ├── next.gif │ └── Handle.gif └── Diary/ # 运行时自动创建的日记目录 └── 2012_Jun_25.txt # 按“年_月_日”命名的日记文件Diary目录不是预先放好的,而是WriteRecord()里用Diary.mkdir()在第一次保存时创建。日记文件名由成员变量key拼成,格式是年_月英文缩写_日,比如2012_Jun_25.txt。这个命名规则很关键,后面读、删都靠它定位文件。
提示:
images目录里的图标是可选资源,源码里用new ImageIcon("../images/prior.gif")这种相对路径引用。如果图标缺失,按钮会退化成无图状态,程序仍能跑,只是不好看。第一次运行前把图标补上,或者把这几处ImageIcon换成纯文字按钮,能省掉一堆路径报错。
2.3 事件分发:一个 actionPerformed 管所有按钮
所有按钮的点击最终都汇到actionPerformed(ActionEvent e)里,靠e.getActionCommand()或e.getSource()区分。源码里给PriorMonth、NextMonth、SouthSave、SouthDelete都显式设了setActionCommand,这是好习惯——比用getSource() == someButton更稳,也更好读。下面这段是典型的分发骨架,我按原逻辑整理过:
public void actionPerformed(ActionEvent e) { String cmd = e.getActionCommand(); if ("prior".equals(cmd)) { // 上一月 changemonthmessage--; if (changemonthmessage < 1) { // 跨年回退 changemonthmessage = 12; changeyearmessage--; } refreshCalendar(changeyearmessage, changemonthmessage); } else if ("next".equals(cmd)) { // 下一月 changemonthmessage++; if (changemonthmessage > 12) { // 跨年进位 changemonthmessage = 1; changeyearmessage++; } refreshCalendar(changeyearmessage, changemonthmessage); } else if ("Save".equals(cmd)) { WriteRecord(); // 写日记 } else if ("Delete".equals(cmd)) { DeleteFile(); // 删日记 } else if (e.getSource() == Query) { AboutActionListenerWay(); // 按输入年份+下拉月份查询 } }逻辑说明:changemonthmessage和changeyearmessage是“当前正在显示的”年月,和构造时传入的year、month分开存,避免翻月把初始值改乱。跨年判断放在翻月里做,< 1退到 12 月并年份减一,> 12进到 1 月并年份加一,这是万年历翻页最常见的边界处理。参数上,refreshCalendar是我抽出来的刷新方法,实际源码里是直接调PrintMonth(year, month)再更新ShowDate标签,效果一样。
3. 核心算法拆解:闰年、起始星期与 42 格填充
3.1 闰年判断与每月天数
日历能不能算对,第一道关就是闰年。源码里的IsLeapYear写得很标准:
public boolean IsLeapYear(int year) { // 能被400整除,或能被4整除但不能被100整除 if ((year % 400 == 0) || (year % 4 == 0 && year % 100 != 0)) return true; else return false; }GetNumOfDaysInMonth则按月份返回天数,1、3、5、7、8、10、12 月返回 31,4、6、9、11 月返回 30,2 月交给IsLeapYear决定 28 还是 29。这两个方法被GetTotalNumOfDays反复调用,是整个日期计算的底座。参数就是普通的int year, int month,没有做越界校验——传 13 月会返回 0,实际调用链里月份都来自下拉框 1~12,所以不会出问题,但二次开发时如果从外部传参,记得自己兜一下。
3.2 起始星期:从公元 1 年累加天数再取模
GetStartDay是这个项目里最“手工”的一段。它没有用java.util.Calendar现成的get(Calendar.DAY_OF_WEEK),而是自己从公元 1 年一路累加到目标年月,再对 7 取模:
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返回的是“从基准年到目标月之前的总天数”,GetStartDay加上偏移量-32768后对 7 取模,得到该月 1 号是星期几(0 表示周日)。这个-32768是作者调出来的经验值,用来把结果对齐到week[] = {"SUN","MON",...}的下标。参数上,year支持负数年份(循环从 -32767 起),所以理论上能算公元前,但界面输入框只接受正整数,实际用不到。
注意:这段循环在年份很大时会明显变慢,因为每年都要判断一次闰年。日常用 1900~2100 年完全无感,但如果要做“跳到 5000 年”这类功能,建议换成
java.time.LocalDate.of(year, month, 1).getDayOfWeek().getValue(),一行顶几十行,还不用维护那个魔数偏移。
3.3 42 格填充:PrintMonthBody 的边界处理
日历面板固定 42 个格子(6 行 × 7 列),PrintMonthBody负责把 1 号到月末填进去,并把前后空白格清空:
public void PrintMonthBody(int startday, int dayinmonth, int day) { // 填本月日期,startday 是 1 号所在列 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)); } else { ShowDays[i].setForeground(Color.white); ShowDays[i].setFont(new Font("TimesRoman", Font.BOLD, 12)); } ShowDays[i].setBackground(Color.DARK_GRAY); n++; } // 清空 1 号之前的格子 for (int i = 0; i < startday; i++) { ShowDays[i].setText(""); ShowDays[i].setBackground(Color.DARK_GRAY); } // 清空月末之后的格子 for (int i = startday + dayinmonth; i < 42; i++) { ShowDays[i].setText(""); ShowDays[i].setBackground(Color.DARK_GRAY); } }逻辑说明:startday决定 1 号从第几列开始,dayinmonth决定填多少天,day是“今天”,用来做绿色高亮。三个参数缺一不可,源码里还保留了一个不带day的重载版本,用于翻月时不需要高亮今天的场景。这里有个容易忽略的点:ShowDays是JTextField数组而不是JLabel,作者用文本框是为了让格子有边框感,同时setEditable(false)禁止输入,再挂MouseListener实现点击选日期。用文本框当格子,样式上确实比标签更像日历,代价是每个格子都带光标焦点逻辑,得靠setEditable(false)压住。
4. 记事本持久化:按日期命名文件的读写删
4.1 保存:WriteRecord 与 key 的生成
日记的落盘逻辑不复杂,核心是key这个文件名。构造时key = year + "_" + SwitchMonth + "_" + day,SwitchMonth是英文月份缩写(Jan、Feb……),所以文件名形如2012_Jun_25.txt。保存时先建目录再写文件:
public void WriteRecord() { String content = CenterText.getText(); try { File diary = new File("Diary"); diary.mkdir(); // 目录已存在时返回 false,无害 File myfile = new File("Diary\\" + key + ".txt"); FileWriter record = new FileWriter(myfile); // 覆盖写 record.write(content); record.close(); JOptionPane.showMessageDialog(this, "Save success!"); } catch (IOException ex) { ex.printStackTrace(); // 原码吞异常,建议至少打日志 } }逻辑说明:FileWriter默认是覆盖模式,同一天重复保存会直接盖掉旧内容,不会追加——这符合“一天一篇日记”的语义。参数上,key在切换日期时必须同步更新,否则会把今天的日记写到昨天的文件里。源码里key的更新散落在鼠标点击和查询逻辑中,二次开发时建议抽一个updateKey(year, month, day)统一维护,减少漏改。
4.2 读取:ReadRecord 的存在性判断顺序有坑
ReadRecord的本意是“切到某天时,如果那天有日记就问要不要读出来”。但源码里new FileReader(myfile)写在if (myfile.exists())之前,文件不存在时FileReader构造直接抛FileNotFoundException,被外层catch接住后把文本区设成 “Today has not logs.”。功能上碰巧能跑,但异常被当成正常分支用,调试时容易误判。我一般会改成先判断再打开:
public void ReadRecord() { File myfile = new File("Diary\\" + key + ".txt"); if (!myfile.exists()) { CenterText.setText("Today has not logs."); return; // 先判存在,避免异常当流程 } int n = JOptionPane.showConfirmDialog(this, "Today has logs, are you read?", "Confirm", JOptionPane.YES_NO_CANCEL_OPTION); if (n != JOptionPane.YES_OPTION) return; try (FileReader record = new FileReader(myfile)) { StringBuilder content = new StringBuilder(); int b; while ((b = record.read()) != -1) { content.append((char) b); } CenterText.setText(content.toString()); } catch (IOException ex) { CenterText.setText("Read failed: " + ex.getMessage()); } }逻辑说明:把“文件不存在”从异常路径改成正常返回,读失败才进catch,语义更清楚。try-with-resources保证FileReader一定关闭,比手动close()稳。参数上没变,还是靠key定位文件,改动只在控制流。
4.3 删除:DeleteFile 的跨平台问题
删除逻辑用Runtime.exec("cmd /c del " + filepath)调系统命令,这在 Windows 上能跑,换到 Linux 或 macOS 直接失效,而且路径里有空格时命令会被截断。更稳的做法是用File.delete():
public void DeleteFile() { File myfile = new File("Diary\\" + key + ".txt"); int n = JOptionPane.showConfirmDialog(this, "Are you sure delete the file?", "Confirm", JOptionPane.YES_NO_CANCEL_OPTION); if (n != JOptionPane.YES_OPTION) return; if (myfile.exists() && myfile.delete()) { // 跨平台,无需拼命令 JOptionPane.showMessageDialog(this, "Delete successed!"); CenterText.setText("Today has not logs."); } else { JOptionPane.showMessageDialog(this, "The file doesn't exist, delete failured!"); } }逻辑说明:File.delete()返回布尔值,成功失败都能判断,不依赖操作系统命令,也不怕路径带空格。参数还是key,行为对用户完全一致,只是底层实现换了。这类“用 exec 调 del”的写法在老课程设计里很常见,属于典型的踩坑点,后面避坑章节还会展开。
5. 跑起来与避坑:编译、图标路径和那几个血泪经验
5.1 从零编译运行
这份源码没有构建工具,纯javac+java就能跑。步骤不复杂,但顺序别乱:
# 1. 进入源码所在目录 cd calendar # 2. 编译,指定 UTF-8 防止中文注释乱码报错 javac -encoding UTF-8 calendar.java # 3. 运行,类名是小写 calendar,和文件名一致 java calendar如果main方法里没有直接new calendar(...),需要自己补一个入口。原文的Main方法描述里提到“唯一的主函数”,常见写法是取当前日期作为初始值:
public static void main(String[] args) { Calendar now = Calendar.getInstance(); int y = now.get(Calendar.YEAR); int m = now.get(Calendar.MONTH) + 1; // Calendar 月份从 0 开始 int d = now.get(Calendar.DAY_OF_MONTH); new calendar(y, m, d).setVisible(true); }逻辑说明:Calendar.MONTH是 0~11,必须加 1 才是人类月份,这是 Java 日期 API 最经典的坑之一。setVisible(true)别忘,否则窗口不显示,程序看起来像卡住。
5.2 避坑与常见问题排查
现象一:编译报“找不到符号 ImageIcon”或运行后按钮空白。原因:images目录没放在源码同级,或相对路径../images/prior.gif解析不到。解决:把images目录放到运行目录的上一级,或者干脆把三处new ImageIcon(...)改成new JButton("上一月")这类文字按钮,先保证功能跑通再补图标。
现象二:中文注释导致编译失败,报 illegal character。原因:源文件是 GBK 编码,javac默认按平台编码读。解决:编译时加-encoding UTF-8(或-encoding GBK,看文件实际编码),二者要匹配,别一个 UTF-8 一个 GBK 混着来。
现象三:点保存提示成功,但找不到日记文件。原因:Diary目录是相对当前工作目录创建的,从 IDE 里跑和从命令行跑,工作目录不同,文件落在不同地方。解决:统一在源码目录下用命令行启动,或者把new File("Diary")改成绝对路径/用户目录路径,比如System.getProperty("user.home") + "/Diary"。
现象四:删除日记在非 Windows 系统上无效。原因:Runtime.exec("cmd /c del ...")是 Windows 专用命令。解决:换成File.delete(),见 4.3 的改法,一行搞定跨平台。
现象五:翻月后写日记,内容串到别的日期。原因:key没跟着当前显示的年月日更新,仍指向旧日期。解决:在翻月、查询、点击日期格子的每个入口都调一次统一的updateKey,把key = year + "_" + SwitchMonth + "_" + day收口到一处,别散着写。
提示:这几个坑里,编码和
key同步是最容易反复翻车的。我第一次跑这类单文件 Swing 项目时,就是中文注释没加-encoding卡了半天,后来养成习惯——拿到任何老 Java 源码,先javac -encoding UTF-8试一遍,再谈功能。
6. 二次开发:把单文件拆开、把日期计算换成 java.time
真要把这份源码往“能继续维护”的方向改,我一般按三步走,每步都能独立验证,不至于一次改崩。
第一步,拆类。现在calendar一个类同时管界面、算法、IO,改任何一处都得通读全文。可以拆成CalendarPanel(日历网格与翻月)、NotePanel(文本区与保存删除)、DateUtil(闰年、起始星期、月天数)三个类,主类只负责组装JSplitPane和转发事件。拆的时候先抽DateUtil,因为它没有界面依赖,抽出来立刻能写单元测试验证。
第二步,换日期引擎。把GetStartDay、GetTotalNumOfDays这套手写累加换成java.time,代码量和出错面都小一个量级:
import java.time.LocalDate; import java.time.YearMonth; public class DateUtil { // 某月 1 号是星期几,周一=1 ... 周日=7 public static int startDayOfWeek(int year, int month) { return LocalDate.of(year, month, 1).getDayOfWeek().getValue(); } // 某月天数,自动处理闰年 public static int daysInMonth(int year, int month) { return YearMonth.of(year, month).lengthOfMonth(); } }逻辑说明:getDayOfWeek().getValue()返回 1~7(周一至周日),和原来 0~6(周日起)的约定不同,填充 42 格时要做一次(value % 7)的换算,或者把星期标题数组改成周一起头。YearMonth.lengthOfMonth()直接给出当月天数,闰年判断内置,IsLeapYear可以整个删掉。参数就是普通的年月,越界会抛DateTimeException,比返回 0 更早暴露问题。
第三步,改存储。把Diary\\key.txt这种 Windows 反斜杠路径换成Paths.get("Diary", key + ".txt"),删除用Files.deleteIfExists,读写用Files.readString/Files.writeString,顺带把编码固定成 UTF-8,避免换机器后中文日记变乱码。改完拿几天数据回归一遍:保存、切日期、读回、删除,四个动作都过一遍,确认文件名和内容对得上。
| 改造点 | 原实现 | 建议实现 | 验证方式 |
|---|---|---|---|
| 日期计算 | 手写逐年累加 + 魔数偏移 | java.time.LocalDate/YearMonth | 对比 1900、2000、2024 年 2 月起始星期 |
| 文件路径 | "Diary\\" + key | Paths.get("Diary", key + ".txt") | 在 Windows 与 Linux 各跑一次 |
| 删除 | Runtime.exec("cmd /c del") | Files.deleteIfExists | 删除后确认文件消失且无异常 |
| 编码 | 平台默认 | 读写显式 UTF-8 | 写中文日记再读回比对 |
这套改法不追求一步到位,先抽DateUtil加测试,再换存储,最后动界面拆分,每步都能单独回滚。从那以后我每次接手这种单文件老项目,都强制先把“算法”和“IO”从界面里剥出来跑一遍测试,再谈重构界面——不然改着改着就分不清是算法错了还是按钮没绑对。希望帮到你。
本文还有配套的精品资源,点击获取