C# WinForms 日程提醒器实战:从Timer轮询到JSON存储的桌面应用开发
简介:一套基于C#与WinForms打造的日程提醒器源码资源,面向Windows桌面开发初学者、需要完成课程设计或练手项目的开发者,覆盖事件新增、列表查看、自定义提前提醒、倒计时显示和弹窗通知等核心功能。资源共47个文件,压缩包约317KB,以15个cs源码文件为主体,配合5个resx界面资源、5个resources编译资源、窗体设计器文件及日期提醒DataSet数据库脚本,并含可直接运行的exe与配套dll,便于对照学习界面布局与数据交互。已有395人学习下载。项目代码按窗体拆分,包括FormMain、FormManage、FormLogin、FormRegister等模块,可掌握WinForms窗体设计、Timer定时器触发、日期时间计算、数据集绑定及登录注册逻辑等要点;结构清晰,适合按模块逐步阅读或二次扩展。
1. 从一次错过会议说起:这个日程提醒器能救什么
你有没有过这种经历:早上到公司,打开电脑,弹窗提醒“10分钟后开会”,你抬手关掉,然后一头扎进代码里,再抬头时会议已经过了半小时。我用WinForms写过一个小工具,专门治这种“刚关提醒就忘事”的毛病——它不是一次性闹钟,而是一个能管理日期、倒计时、循环日程的桌面提醒器。界面是典型的C# WinForm,不依赖任何外部服务,双击就能跑。它适合谁?除了像我一样记性差的开发者,也适合需要定时记录工时、跟踪项目节点、给生日设置倒计时的普通用户。下面拆解这个项目的完整玩法,从架构到改代码,一步步来。
2. 项目架构拆解:WinForm界面、提醒内核与数据存储三条线
一个看起来简单的日程提醒器,实际拆开是三层逻辑:UI层负责展示和录入,数据层负责持久化,内核层负责每秒检查“有没有日程该响了”。这三条线各自独立,又通过事件和定时器串联,理解了这个结构,后面改功能就顺了。
2.1 目录结构与工程分层
整个项目没有用复杂的分层框架,而是按职责分了三个文件夹加一个入口:根目录下是FormMain.cs(主窗体)、ReminderCore.cs(提醒内核)、DataStore.cs(数据读写),辅助的还有Models文件夹存放日程实体类。它的优点是每个文件都能单独打开看,不用翻一堆目录。
ScheduleReminder/ ├── FormMain.cs // 主界面:日程列表、添加/编辑/删除、托盘图标 ├── FormMain.Designer.cs // 界面布局,WinForm设计器自动生成 ├── ReminderCore.cs // 提醒核心:Timer轮询、触发事件、响声与气泡 ├── DataStore.cs // 数据持久化:读写JSON文件,启动加载、变更保存 ├── Models/ │ └── ScheduleItem.cs // 日程实体:标题、时间、重复规则、是否启用 ├── Program.cs // 应用入口,加载主窗体 └── data/ └── schedules.json // 运行后自动生成的日程数据文件这里最关键的ScheduleItem类,我需要多说一句。它上面不是只挂了“标题和日期”两个字段,而是用枚举定义了重复类型:一次性、每天、每周、每月。这个设计直接影响内核的触发判断,如果后面你想加“每个工作日”或“每年生日提醒”,只需要扩展枚举和判断逻辑即可。数据层用JSON而不是SQLite,是因为日程数据量很小,一个文件扛得住,而且用户能直接打开文本修改,对非专业用户更友好。
2.2 数据层设计:用JSON存日程,不装数据库的理由
给桌面小工具装数据库,属于杀鸡用牛刀。这里用JSON文件存储,序列化用的是C#自带的System.Text.Json,代码干净,不需要引入外部依赖。每次添加或修改日程就调用一次SaveChanges(),把整个列表写回文件;启动时则读文件反序列化,如果文件不存在就用空列表兜底。
public class DataStore { private readonly string _filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "schedules.json"); public List<ScheduleItem> Load() { try { if (!File.Exists(_filePath)) return new List<ScheduleItem>(); var json = File.ReadAllText(_filePath); return JsonSerializer.Deserialize<List<ScheduleItem>>(json) ?? new List<ScheduleItem>(); } catch (Exception ex) { // 解析失败不能让程序崩溃,返回空列表,并在日志中记录 File.AppendAllText("error.log", $"{DateTime.Now}: {ex.Message}\n"); return new List<ScheduleItem>(); } } public void Save(List<ScheduleItem> items) { Directory.CreateDirectory(Path.GetDirectoryName(_filePath)); var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(_filePath, json); } }逻辑说明:Load()做了三件事——检查文件是否存在、读取并反序列化、异常兜底。我特意在异常时写错误日志而不是直接弹窗,是因为这种工具常驻后台,一个坏的日程文件不应该让用户措手不及。Save()每次全量写入,效率不高,但胜在实现简单,也符合数据量小的场景。
参数说明:JsonSerializerOptions.WriteIndented = true让JSON带缩进,用户用记事本打开也能看懂;AppDomain.CurrentDomain.BaseDirectory取的是程序运行目录,比如 Debug 或 Release 下的路径。注意这个路径和 exe 所在目录一致,很多人把它和Environment.CurrentDirectory搞混,后者可能因为启动方式不同而变化,踩过坑的都知道。数据文件放data子目录,也方便备份。
2.3 提醒内核:Timer驱动的检查循环
提醒内核是整个项目的心脏。WinForms里自带的System.Windows.Forms.Timer是UI线程控件,Tick事件能直接操作界面控件,不会出现跨线程异常。这里设置Interval = 1000,也就是每秒触发一次检查。为什么用1秒而不是1分钟?因为倒计时显示要精确到秒,而且日程安排可能精确到某一个分钟,每秒轮询的CPU开销其实很低。
public class ReminderCore { private System.Windows.Forms.Timer _timer; private List<ScheduleItem> _items; private readonly HashSet<int> _triggeredIds = new HashSet<int>(); // 记录已触发过的ID,防止重复弹出 public event Action<ScheduleItem> ReminderDue; // 到点后向外抛事件 public void Start(List<ScheduleItem> items) { _items = items; _timer = new System.Windows.Forms.Timer { Interval = 1000 }; _timer.Tick += OnTick; _timer.Start(); } private void OnTick(object sender, EventArgs e) { var now = DateTime.Now; foreach (var item in _items) { if (!item.Enabled) continue; if (item.RemindOnce && _triggeredIds.Contains(item.Id)) continue; if (IsDue(item, now)) { _triggeredIds.Add(item.Id); ReminderDue?.Invoke(item); // 一次性任务触发后自动禁用 if (item.RepeatType == RepeatType.Once) item.Enabled = false; } } } private bool IsDue(ScheduleItem item, DateTime now) { // 简化版判断:当前时间在计划的“已到时间”附近 var diff = now - item.ScheduledTime; return diff.TotalSeconds >= 0 && diff.TotalSeconds < 60; } }逻辑说明:Start()创建定时器并订阅OnTick,每一秒遍历所有日程。IsDue()判断“当前时间落在日程预定时间后的60秒窗口内”,这么做是为了避免因为几秒钟的轮询延迟导致漏触。_triggeredIds这个集合是关键,它保证同一条日程不会因为每条都在窗口期内而反复弹窗;一次性任务触发后还会设置Enabled = false,从根源上断电。
参数说明:diff.TotalSeconds >= 0 && < 60这个窗口可以根据你的需求调整。比如用户设置了“提前10分钟提醒”,那判断条件就是diff.TotalSeconds >= -600 && diff.TotalSeconds < 600吗?不对,更合理的写法是now - item.AlertTime。实际上我在改造时把“提醒时间”和“日程时间”拆成两个字段,界面上用户可以独立设置提前量,这样内核逻辑不变,只是把比较对象从ScheduledTime换成AlertTime,后面第6章细说。
3. 核心模块实战:日期倒计时、到期触发与通知弹出的完整链路
这一章把用户最关心的三条链路端到端打通:主界面上怎么显示“距某日还有多少天”、到点后系统怎么知道该提醒了、提醒时用什么样的交互不打扰人。每一条链路都配合代码讲清。
3.1 倒计时计算:TimeSpan与本地时区处理
倒计时的朴素做法是DateTime直接相减,得到TimeSpan,再取Days属性。但这有个隐蔽的坑:如果用户设置的日期是“2026年1月1日”,值默认是零点零分零秒,而“今天”的当前时刻是下午三点,相减会得到0天 15小时,界面上显示“剩余0天”就很不人性化。我的处理是先把目标日期的时分秒清零,再按“今天零点”做基准。
private string GetCountdownText(DateTime target) { var targetDay = target.Date; // 丢弃时分秒,只留日期 var today = DateTime.Now.Date; // 今天零点 var diff = targetDay - today; if (diff.TotalDays < 0) return "已过期"; if (diff.TotalDays < 1) return $"今天 {target:HH:mm}"; if (diff.TotalDays < 2) return $"明天 {target:HH:mm}"; return $"剩余 {diff.Days} 天"; }逻辑说明:target.Date和DateTime.Now.Date都把时间部分讨平,这样减法得到的TotalDays就是纯天数差。小于1天时直接显示“今天/明天”,避免像“0天23小时”这种不直观的文案。这个方法被绑定到列表控件的自定义绘制事件里,每次界面刷新、列表更新都会调用。
参数说明:这里有个细节值得注意——TimeSpan有个Days属性和TotalDays属性,前者是整数值,后者是带小数的浮点数。我们比较TotalDays时用< 2,而不是Days < 2,因为Days在跨天时会有截断误差。比如现在是2025-07-01 23:00,目标日期是2025-07-02 10:00,TotalDays约等于0.46,Days则直接是0,看起来没问题;但如果是提前2天零23小时,Days可能变成1,显示“剩余1天”,用户会以为算错了。所以干这行,浮点数比较一定要用TotalDays。
3.2 提醒触发:轮询与一次性/重复任务的调度
前面内核里的轮询代码是基础版,真正要支撑起“每天重复提醒”和“每月提醒”,需要细化判断逻辑。我的做法是给ScheduleItem增加RepeatType枚举,然后在内核里做分支匹配。需要注意月重复里的一种特殊情况:用户选了每月30号,但某个月只有28天,这时候不能漏掉也不能报错,我选择跳过该月,等下个月再判断。
public enum RepeatType { Once, // 一次性 Daily, // 每天 Weekly, // 每周固定星期几 Monthly // 每月固定几号 } // 在 IsDue 中补充重复判断 private bool IsRepeatedDue(ScheduleItem item, DateTime now) { switch (item.RepeatType) { case RepeatType.Daily: return now.Hour == item.ScheduledTime.Hour && now.Minute == item.ScheduledTime.Minute && now.Second >= 0; case RepeatType.Weekly: return now.DayOfWeek == item.ScheduledTime.DayOfWeek && now.Hour == item.ScheduledTime.Hour && now.Minute == item.ScheduledTime.Minute; case RepeatType.Monthly: // 跳过不存在的日期 if (item.ScheduledTime.Day > DateTime.DaysInMonth(now.Year, now.Month)) return false; return now.Day == item.ScheduledTime.Day && now.Hour == item.ScheduledTime.Hour && now.Minute == item.ScheduledTime.Minute; case RepeatType.Once: default: var diff = now - item.ScheduledTime; return diff.TotalSeconds >= 0 && diff.TotalSeconds < 60; } }逻辑说明:对于每日/每周/每月这种重复型日程,不能用“时间差小于60秒”的窗口去判断。因为每天提醒一次,昨天的“今天10:00”已经过去十几个小时了,时间差早就超过窗口。所以这里的思路是拆出“日、时、分”三个维度,精确匹配当前时刻的时和分,再分别匹配日维度。唯一例外的是一口 SQL 能触发的While逻辑,代码中Once分支继续走时间差。
参数说明:DaysInMonth是.NET内置的静态方法,返回指定年月的天数。2月要判断闰年,这个函数已经处理了,你不用自己写闰年算法。另外注意now.Second >= 0这个条件在Daily里看起来多余,实际是让触发窗口在整个分钟都有效——否则秒数正好等于0才有可能命中,丢概率。你可以把Second也参与比较,比如小于等30,但我一般不管,让它整分钟内都算数。
3.3 托盘与气泡通知:一个能独立运行的提醒器
日程提醒器不太适合一直霸占任务栏。用户往往开着它,但希望它缩在右下角。这里用NotifyIcon实现托盘图标,单击图标还原主窗体,双击也可以,右键菜单包含“显示主界面”和“退出”。到点弹出气泡通知时,用ShowBalloonTip是最省力的方案,系统自带样式,用户一看就懂。另外还配了一段蜂鸣音,防止用户没注意气泡。
private void NotifyReminder(ScheduleItem item) { // 托盘气泡显示标题和正文,5000毫秒后自动消失 _notifyIcon.ShowBalloonTip(5000, "日程提醒", item.Title, ToolTipIcon.Info); // 如果主窗体最小化或隐藏,把它闪一下任务栏,提示用户有事件 if (WindowState == FormWindowState.Minimized || !Visible) { WindowState = FormWindowState.Normal; FlashWindow(this.Handle, true); // 调用系统API闪烁窗口 } // 连续响三声,每声间隔300ms,避免像闹钟一样吵 for (int i = 0; i < 3; i++) { System.Media.SystemSounds.Exclamation.Play(); System.Threading.Thread.Sleep(300); } }逻辑说明:ShowBalloonTip的第一个参数是气泡显示毫秒数,在Windows 10及以上系统,实际显示时长受系统设置影响,但至少在5秒。FlashWindow是原生API,需要DllImport("user32.dll")引用,它的作用是让任务栏图标闪烁,提醒用户日程到了。蜂鸣音用了SystemSounds.Exclamation,是Windows内置的声音,不会在代码里嵌入音频文件,省体积。
参数说明:如果不想在触发时打断用户写代码,可以把蜂鸣音改成SystemSounds.Asterisk,声音更轻。还有一层细节:如果用户正在全屏打游戏或演示PPT,弹出气泡也可能被忽略。我一般在触发的瞬间记录一条日志到remind_log.txt,这样用户事后能查“刚才弹了没”,也算给工具加了一双后悔药。
4. 运行与调试:从源码到可执行文件的几步路
拿到这个WinForm项目的源码,第一件事不是读代码,是先把它跑起来。这里按我平时的流程来走:环境准备、编译、运行配置、打包。每一步都有可复现的命令和参数,照着做基本不会翻车。
4.1 环境准备与编译
开发WinForm应用,理论上只要装了.NET SDK就行,不需要装完整的Visual Studio。我用的是.NET 6.0以上的版本,因为从.NET Core 3.1开始,WinForms就被官方支持了。如果你的操作系统是Windows 10或11,用dotnet命令行就能编译。
# 查看已安装的SDK版本 dotnet --list-sdks # 还原NuGet依赖(项目里默认没有第三方包,但执行一下无妨) dotnet restore # 编译,-c Release生成发布版 dotnet build -c Release逻辑说明:dotnet restore会读取.csproj文件中的PackageReference,由于这个项目只用了系统库,所以这一步会很快。dotnet build的输出在bin/Release/net6.0-windows/目录下,里面包含.exe和一堆.dll,直接运行里面的 exe 就能看到界面。
参数说明:.csproj文件里需要重点关注两处。一处是<TargetFramework>net6.0-windows</TargetFramework>,后面必须带-windows,否则WinForms项目编译不过;另一处是<UseWindowsForms>true</UseWindowsForms>,这是Windows桌面应用的开关。如果你用的是Visual Studio,新建项目时选“Windows Forms应用”模板,这两项会自动生成。
4.2 首次运行配置
双击 exe 后的初始状态是个空列表,界面上方有“添加日程”按钮,下方是 ListView 展示的日程列表。添加日程的对话框长这样:标题输入框、日期选择器(DateTimePicker)、时间选择器、重复类型下拉框、是否启用复选框。录制后点确定,数据写入JSON,同时列表刷新。
// 添加按钮事件中的核心逻辑 private void BtnAdd_Click(object sender, EventArgs e) { var dlg = new FormScheduleEdit(); // 编辑对话框 if (dlg.ShowDialog() == DialogResult.OK) { var newItem = dlg.CreatedItem; _items.Add(newItem); _dataStore.Save(_items); // 立即持久化 RefreshList(); // 刷新ListBox _core.Restart(_items.ToList()); // 重启内核,让新日程马上生效 } }逻辑说明:ShowDialog()是模态对话框,用户点“确定”才会返回DialogResult.OK。CreatedItem是从对话框里拿到的日程实例,里面已经用DateTimePicker.Value合并了日期和时间。注意_core.Restart这步不能省——如果内核还在用旧的_items引用,新添加的日程永远不会被检查。重启内核就是停掉旧Timer,重新Start(new List)。
参数说明:DateTimePicker有两个模式,设置Format = DateTimePickerFormat.Custom和CustomFormat = "yyyy-MM-dd HH:mm"就可以同时输入日期和时间,不需要放两个控件。这个细节很多新手不知道,默认的Long格式只选日期,时间永远是12:00,我最早做这个工具时就翻在这上面。
4.3 打包成单文件exe的小技巧
给同事用的时候,扔一堆dll给别人不专业,也容易漏文件。我用.csproj里的PublishSingleFile属性,一行配置就能发布成单文件,同事拿过去直接双击,不需要安装运行库(前提是目标机器装了相应.NET运行时)。
<PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net6.0-windows</TargetFramework> <UseWindowsForms>true</UseWindowsForms> <PublishSingleFile>true</PublishSingleFile> <SelfContained>false</SelfContained> </PropertyGroup>发布命令用的还是dotnet publish:
dotnet publish -c Release -r win-x64 --self-contained false逻辑说明:--self-contained false让exe依赖用户机器上的 .NET 运行时,体积大约2-3MB,适合在公司内部已经装了运行环境的机器间分发。如果要发给从没装过.NET的亲戚朋友,那就改成--self-contained true,体积会到50MB以上,但人家不用装任何东西。
参数说明:发布后,exe文件里面会包含配置文件和数据目录吗?不会。data/schedules.json是运行时动态创建的,所以第一次在新机器上启动,程序当前目录下才会出现data文件夹。如果你改了JSON路径为固定目录,比如C:\ProgramData\...,那又涉及到权限问题,我建议还是用AppDomain.CurrentDomain.BaseDirectory,省心。
5. 避坑指南:Windows定时、时区与休眠那些坑
写桌面工具最头疼的不是功能逻辑,而是操作系统层面的“意外”。我总结了几条真实踩过的坑,每条都是现象、原因、解决三步走,照着排查能少走弯路。
5.1 系统休眠后提醒不触发
现象:笔记本电脑合盖再打开,发现之前设的提醒一个都没响。
原因:System.Windows.Forms.Timer依赖UI消息循环,而休眠时整个进程挂起,Timer的Tick事件根本不会执行。等唤醒后,Timer继续走,但时间窗口已经错过了。
解决:改用System.Threading.Timer或System.Timers.Timer,它们基于线程池,在系统唤醒后能继续触发,但仍然无法在网络休眠期间唤醒系统。更狠的方案是任务计划程序,定一个每分钟跑一次的任务来唤起程序,但这个小工具不值得这么复杂。我的常见做法是启动时扫描未触发的提醒,如果发现“已过时间且没有触发记录”,补一次提醒并打日志,相当于补发。
// 启动时补检:查找当前时间已超过提醒时间但未触发过的日程 foreach (var item in _items) { if (item.Enabled && item.ScheduledTime < DateTime.Now && !_triggeredIds.Contains(item.Id)) { ReminderDue?.Invoke(item); _triggeredIds.Add(item.Id); } }5.2 日期差一天:UTC与本地时区混用
现象:程序在美国同事的电脑上跑,日程提醒总是比实际时间早/晚8个小时。
原因:DateTime.Now返回本地时间,而DateTime.UtcNow返回UTC时间。如果JSON中保存的字符串带了Z后缀(UTC标志),反序列化后DateTime的Kind属性会变成Utc,与本地时间比较时就会自动换算时区,导致判断偏移。
解决:统一使用DateTime.Now作为唯一的存储和比较基准,序列化时不用相对或绝对格式。在ScheduleItem中使用[JsonConverter(typeof(LocalDateTimeConverter))]强制按本地时间处理,或者最简单粗暴的做法是:存储时调用ToLocalTime(),读取时再调用一次ToLocalTime(),宁可多此一举也不要让UTC混进来。
5.3 重复提醒每天多次触发
现象:设了“每天10:00提醒”,结果一天弹了七八次。
原因:IsRepeatedDue里对“分钟”窗口判断太宽,用了diff.TotalSeconds >= 0 && < 3600,导致10:00到11:00这段时间内每秒都命中。
解决:重复型提醒的窗口缩到1分钟以内,并且要用_triggeredIds做当天粒度去重。我的方案是把_triggeredIds的值从int改成string,存成“日程ID_日期”,比如"3_2025-07-01",这样每天第一次触发后记录,后续都不会再弹,第二天日期变了又能正常触发。
5.4 高DPI下界面错乱
现象:在笔记本电脑默认125%或150%缩放下,控件挤在一起,按钮文字被截断。
原因:WinForm的默认 DPI 感知模式是不感知,系统会对整个窗口进行缩放,导致布局计算混乱。特别是在AutoScaleMode未正确设置时。
解决:在FormMain的构造函数前加上[STAThread]之外,还要在Program.cs入口或app.manifest中声明PerMonitorV2感知模式。同时在设计器里把所有容器的AutoScaleMode设为Dpi或Font,这样字体缩放按钮位置会联动调整。如果你的主窗体用了固定像素的Location和Size,建议改成TableLayoutPanel或FlowLayoutPanel,让布局自适应。
5.5 路径问题:发布后找不到数据文件
现象:通过快捷方式启动程序后,日程列表变成空的,但点击exe直接启动又正常。
原因:Environment.CurrentDirectory在快捷方式启动时可能指向的是快捷方式所在的旧路径,而不是exe所在目录。我最初用File.ReadAllText("schedules.json")这种相对路径写法,所以翻车了。
解决:强制使用AppDomain.CurrentDomain.BaseDirectory拼接文件路径,前面数据结构里已经这样做了。另外,如果需要让数据文件单独存放到用户文档目录,用Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)拼接,也是同样思路。从那以后我每次写桌面工具,第一件事就是定义BaseDir,所有文件操作都从这里派生。
6. 二次开发指南:加“提前N分钟提醒”和“农历日期”功能
到这里,主流程你已经能跑通了。想把它变成真正能用的私人助理,还需两个高频功能:提前提醒和农历支持。前者是内核改造,后者是数据校验和显示扩展。我用两个实际案例带你走一遍。
先看“提前N分钟提醒”。这个功能的用户需求是:日程定在下午3点开会,希望提前15分钟弹窗。传统设计是让用户手动计算得到一个提前时间点,但更友好的是给ScheduleItem增加一个AlertOffsetMinutes字段,默认0,表示“准点提醒”。界面上一枚数字输入框,用户填5或10,在保存时把提醒时间 = 日程时间 - 偏移分钟数。
// ScheduleItem新增字段 public int AlertOffsetMinutes { get; set; } = 0; // 界面保存时计算 item.AlertTime = item.ScheduledTime.AddMinutes(-item.AlertOffsetMinutes); // 兼容旧数据:如果没有设置偏移,AlertTime就是ScheduledTime if (item.AlertOffsetMinutes == 0) item.AlertTime = item.ScheduledTime;内核判断时,把原来的ScheduledTime全部换成AlertTime即可。注意对于重复型任务,AlertTime也需要根据当前周期动态计算。比如“每天10:00提醒,提前5分钟”,那么判断时看的是now是否等于今天9:55。这个逻辑可以封装成方法:
private DateTime GetNextAlertTime(ScheduleItem item, DateTime from) { switch (item.RepeatType) { case RepeatType.Daily: return from.Date.Add(item.ScheduledTime.TimeOfDay).AddMinutes(-item.AlertOffsetMinutes); case RepeatType.Weekly: var daysUntil = ((int)item.ScheduledTime.DayOfWeek - (int)from.DayOfWeek + 7) % 7; return from.Date.AddDays(daysUntil).Add(item.ScheduledTime.TimeOfDay).AddMinutes(-item.AlertOffsetMinutes); // ...其余类型类似 default: return item.ScheduledTime.AddMinutes(-item.AlertOffsetMinutes); } }这段代码的返回值就是内核里要比较的“目标时间”。前一章提到的“每次启动补检”同样用GetNextAlertTime去跟当前时间比较,逻辑就统一了。
再来看农历日期功能。WinForm本身没有农历类库,但 .NET 自带的ChineseLunisolarCalendar可以做公历转农历,只是它不直接告诉我们“今天初几”。我的做法是封装一个转换函数,得到农历的年月日字符串,然后存到ScheduleItem.LunarDate字段中。用户在编辑界面勾选“按农历日期提醒”,保存时就先用公历换算农历,把农历月日存起来。内核判断时,每轮询一次都判断当天农历月日是否匹配。
private static string GetLunarMonthDay(DateTime solar) { var calendar = new System.Globalization.ChineseLunisolarCalendar(); var month = calendar.GetMonth(solar); var day = calendar.GetDayOfMonth(solar); return $"{month}-{day}"; } // 在Monthly重复分支里扩展 case RepeatType.Monthly: if (item.UseLunarCalendar) { var lunarToday = GetLunarMonthDay(now); return lunarToday == item.LunarDate && now.Hour == item.ScheduledTime.Hour && now.Minute == item.ScheduledTime.Minute; } // 原来的公历逻辑...实现后怎么验证?我会单独写一个测试小函数,遍历从今天到过年每一天,检查GetLunarMonthDay是否和传统日历App显示一致。注意你无法自动对比,只能手动抽查几个已知日期,比如2025年春节的公历日期对应正月初一。
改造完这些功能后,一个日程提醒器其实已经比系统自带日历工具更贴合日常需求了。从那以后,我每次做这类小工具都强制自己先画一遍“触发链路图”:用户输入 → 存储 → 轮询 → 判断 → 通知 → 防重复。只要这条链路上每一步都设了参数和扩展点,后面加再奇怪的需求都不至于推翻重来。希望这次的拆解能帮你把这套代码跑起来,再改出自己的版本。
本文还有配套的精品资源,点击获取