【开源】c#造个轮子-日程安排工具
自从windows 10 升级到windows 11 就再也找不到 “页签”工具了,然后辗转反侧找了半天替代工具,找到了“小黄条”,用着感觉还行,但是限制挺多,用了几天之后,打开电脑惊呆了,如下图:

虽然说软件付费模式是周瑜打黄盖-一个愿打,一个愿挨。但是对咱码农来说,挣得都是辛苦米,能省一点是一点。所以干脆自己做一个日程安排工具,满足日常需要就行,比如记录最近一段时间工作安排,方便统计周报,月报,年计划什么的...于是就有了Calendar的产生!
无图言屌



软件架构图

数据流与事件流

目录结构
CalendarPlugin/
│
├── Program.cs # 入口 · 单实例 · 异常兜底
│
├── MainForm.cs # 主窗体:托盘、拖动、模式切换、主题应用
├── MainForm.Designer.cs
├── SettingsForm.cs # 设置对话框:自启动 / 嵌入 / 透明度 / 重置位置
├── SettingsForm.Designer.cs
│
├── Models/ # 数据模型
│ ├── Appointment.cs # 日程(Id/Title/Date/Color)
│ ├── HolidayInfo.cs # 节假日(Date/Name/Rest/DayType)
│ ├── CalendarDay.cs # 单日单元格(日期/是否本月/今天/日程列表)
│ ├── Theme.cs # 主题(30+ 颜色字段)
│ └── EventArgs.cs # Appointment / DateRange / MonthChanged 事件参数
│
├── Services/ # 业务服务
│ ├── JsonStorageService.cs # 日程 JSON 读写(原子替换)
│ ├── AppointmentService.cs # 日程 CRUD
│ ├── HolidayService.cs # 节假日异步拉取 + 兜底
│ ├── SettingsService.cs # 设置 JSON 读写
│ ├── AutoStartService.cs # 注册表 HKCU\Run
│ ├── DesktopEmbedService.cs # WorkerW / DefView 挂载
│ └── ThemeService.cs # 6 种内置主题
│
├── Controls/ # 自定义控件
│ ├── CalendarControl.cs # 自绘日历(核心,约 900 行)
│ ├── CalendarControl.Designer.cs
│ ├── AppointmentEditForm.cs # 日程编辑对话框
│ └── AppointmentEditForm.Designer.cs
│
├── Helpers/
│ └── WindowOpacity.cs # WS_EX_LAYERED + SetLayeredWindowAttributes
│
└── Interop/
└── NativeMethods.cs # Win32 API 声明
一、软件概述
DesktopCalendarPlugin 是一款基于 C# / .NET Framework 4.8 / WinForms 开发的桌面日历插件。它以桌面小部件形式嵌入 Windows 桌面壁纸层,提供月视图日程管理、中国法定节假日展示、跨天,跨月日程合并显示、多主题切换等能力,并支持开机自启动与 JSON 本地持久化,无需任何商业控件依赖。
二、功能点清单
🗓 日历展示
| 功能 | 说明 |
|---|---|
| 月视图日历 | 6 行 × 7 列网格,周一为每周起点,同时显示当月及相邻月份日期 |
| 今天高亮 | 今天单元格以暖黄色背景 + 橙色边框突出,日期数字加粗 |
| 周末区分 | 周六/周日以红色文字区分,右下角显示"休"角标 |
| 双月/多视图滚动 | 鼠标滚轮或标题栏 ‹ › 按钮切换月份,标题栏实时显示 yyyy年 M月 |
| 回到今天 | 一键回到当前月份(标题栏"今天"按钮) |
🎉 节假日信息
| 功能 | 说明 |
|---|---|
| 中国法定节假日 | 通过网络拉取 holiday-cn 开源数据,支持 2004–2026 年 |
| 调休标注 | 调休工作日显示"班"字,法定休息日显示节日名称(红色) |
| 网络失败兜底 | 无法联网时回退到内置的最小节假日数据(元旦、劳动节、国庆节) |
| 异步加载 | 首次加载、跨月、跨年时异步拉取,不阻塞 UI |
📝 日程管理
| 功能 | 说明 |
|---|---|
| 双击添加 | 双击空白日期直接创建当天日程 |
| 右键添加 | 右键日期弹出上下文菜单,可添加、查看、删除当日日程 |
| 鼠标滑动多选 | 按住左键在日期上拖动选择连续多天,松开后自动弹出添加对话框 |
| 跨天合并显示 | 连续多天的日程在周内合并为一整个连续的彩色横条,头尾圆角、中间平直,标题只在起点显示 |
| 泳道排布 | 同一周内多个日程用贪心算法分配泳道,避免重叠 |
| N 更多折叠 | 单日日程超过可视区域时,底部显示 +N 更多 |
| 日程编辑 | 双击日程条进入编辑对话框,可修改标题、描述、日期范围、颜色 |
| 日程删除 | 编辑对话框"删除"按钮、右键菜单"删除"、托盘菜单删除入口 |
| 颜色标识 | 每个日程可选 10 种预定义颜色(蓝/红/绿/橙/紫/青/粉/深灰/黄/灰) |
| 悬停高亮 | 鼠标悬停日程条时高亮显示并显示手型光标 |
🎨 主题系统
| 功能 | 说明 |
|---|---|
| 多主题内置 | 浅色(默认)、深色、蓝色海洋、森林绿、紫罗兰、暖阳橙 |
| 托盘一键切换 | 任务栏图标右键 → 切换主题,点击立即生效,无需打开设置 |
| 实时生效 | 切换主题即刻重绘日历、窗体背景、托盘菜单配色 |
| 持久化 | 当前主题 Id 保存到 settings.json,重启后保持 |
| 深色模式 | 深色主题整体采用暗色系,适合夜间使用 |
🖥 桌面小部件
| 功能 | 说明 |
|---|---|
| 嵌入桌面 | 通过 WorkerW / SHELLDLL_DefView 挂载技术,将窗口贴到桌面壁纸层,如同系统原生小部件 |
| 半透明玻璃效果 | 使用 WS_EX_LAYERED + SetLayeredWindowAttributes 精确控制 alpha 透明度(30%~100%) |
| Win+D 保持可见 | 嵌入模式下按"显示桌面"仍保持可见 |
| 不干扰桌面图标 | 挂在 DefView 顶层,不遮挡桌面图标选择与双击 |
| 浮动模式兜底 | 嵌入失败时自动回退为普通浮动窗口,不影响使用 |
| 模式切换 | 托盘菜单一键在"嵌入桌面 / 浮动窗口"之间切换 |
🖱 窗口交互
| 功能 | 说明 |
|---|---|
| 边缘调整大小 | 鼠标移到窗口四边或四角,光标变为双向箭头,按住拖动即可调整大小 |
| 标题栏拖动 | 按住顶部 44px 区域拖动窗口位置 |
| 鼠标捕获 | 拖动时鼠标移出窗口仍能继续拖拽 |
| 最小尺寸限制 | 480 × 400,防止缩得过小 |
| 无边框设计 | 隐藏系统边框和标题栏,作为小部件时更简洁 |
| 位置记忆 | 关闭时自动保存窗口坐标与尺寸,重启恢复 |
🔧 系统集成
| 功能 | 说明 |
|---|---|
| 开机自启动 | 通过注册表 HKCU\...\Run 实现,无需管理员权限 |
| 单实例运行 | Mutex 保证只启动一个实例,重复启动时提示已运行 |
| 系统托盘 | 最小化到托盘,双击显示/隐藏,右键打开菜单 |
| 动态托盘图标 | 代码动态绘制日历图标,无需外部 .ico 资源 |
| 托盘菜单项 | 隐藏/显示、嵌入/浮动、置顶、切换主题、自启动、设置、退出 |
💾 数据持久化
| 功能 | 说明 |
|---|---|
| JSON 本地存储 | 日程数据存于 %APPDATA%\CalendarPlugin\appointments.json |
| 原子写入 | 采用"临时文件 + Replace"策略,避免写入中断导致数据损坏 |
| 设置独立存储 | settings.json 单独保存窗口、主题、自启动等配置 |
| UTF-8 编码 | 完整支持中文标题、描述 |
三 关键技术映射
| 功能 | 关键技术 |
|---|---|
| 半透明玻璃效果 | WS_EX_LAYERED + SetLayeredWindowAttributes(LWA_ALPHA) |
| 桌面壁纸层挂载 | SendMessageTimeout(Progman, 0x052C) → 枚举 WorkerW / SHELLDLL_DefView → SetParent |
| 无边框窗口拖动/缩放 | 手动实现 WM_NCHITTEST + WM_MOUSEMOVE + Capture,覆盖子窗口场景 |
| 跨天日程合并 | 按周计算列范围 + 贪心泳道分配 + GraphicsPath 单侧圆角绘制 |
| 双缓冲绘制 | ControlStyles.OptimizedDoubleBuffer + AllPaintingInWmPaint |
| 原子写入 | 临时文件 + File.Replace |
| 单实例 | 命名 Mutex |
| 开机自启 | HKCU\Software\Microsoft\Windows\CurrentVersion\Run |
| 节假日数据 | CDN 拉取 holiday-cn JSON |
/// <summary>
/// 设置窗口透明度。opacity 取值 0.0~1.0;>= 0.999 时恢复为完全不透明。
/// 必须在句柄已创建后调用。
/// </summary>
public static bool Set(Form form, double opacity)
{
if (form == null || !form.IsHandleCreated) return false;
try
{
double op = Math.Max(0.0, Math.Min(1.0, opacity));
IntPtr hwnd = form.Handle;
int exStyle = NativeMethods.GetWindowLong(hwnd, NativeMethods.GWL_EXSTYLE);
if (op >= 0.999)
{
// 关闭分层
if ((exStyle & NativeMethods.WS_EX_LAYERED) != 0)
{
NativeMethods.SetWindowLong(hwnd, NativeMethods.GWL_EXSTYLE,
exStyle & ~NativeMethods.WS_EX_LAYERED);
}
return true;
}
// 打开分层
if ((exStyle & NativeMethods.WS_EX_LAYERED) == 0)
{
NativeMethods.SetWindowLong(hwnd, NativeMethods.GWL_EXSTYLE,
exStyle | NativeMethods.WS_EX_LAYERED);
}
byte alpha = (byte)Math.Round(op * 255);
if (!NativeMethods.SetLayeredWindowAttributes(hwnd, 0, alpha,
NativeMethods.LWA_ALPHA))
{
return false;
}
return true;
}
catch
{
return false;
}
}
window api 涉及接口:
internal static class NativeMethods
{
public const int GWL_STYLE = -16;
public const int GWL_EXSTYLE = -20;
public const int WS_CHILD = 0x40000000;
public const int WS_POPUP = unchecked((int)0x80000000);
public const int WS_EX_TOOLWINDOW = 0x00000080;
public const uint SMTO_NORMAL = 0x0000;
public const uint WM_SPAWN_WORKER = 0x052C;
public static readonly IntPtr HWND_BOTTOM = new IntPtr(1);
public static readonly IntPtr HWND_TOP = new IntPtr(0);
public const uint SWP_NOSIZE = 0x0001;
public const uint SWP_NOMOVE = 0x0002;
public const uint SWP_NOZORDER = 0x0004;
public const uint SWP_NOACTIVATE = 0x0010;
public const uint SWP_SHOWWINDOW = 0x0040;
public const int SW_SHOW = 5;
// ==== 分层窗口 ====
public const int WS_EX_LAYERED = 0x00080000;
public const uint LWA_ALPHA = 0x00000002;
[DllImport("user32.dll", SetLastError = true)]
public static extern bool SetLayeredWindowAttributes(IntPtr hwnd, uint crKey,
byte bAlpha, uint dwFlags);
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr FindWindowEx(IntPtr hwndParent, IntPtr hwndChildAfter,
string lpszClass, string lpszWindow);
[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg,
IntPtr wParam, IntPtr lParam, uint fuFlags, uint uTimeout, out IntPtr lpdwResult);
[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent);
[DllImport("user32.dll", SetLastError = true)]
public static extern int GetWindowLong(IntPtr hWnd, int nIndex);
[DllImport("user32.dll", SetLastError = true)]
public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
[DllImport("user32.dll", SetLastError = true)]
public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
int X, int Y, int cx, int cy, uint uFlags);
[DllImport("user32.dll")]
public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern int GetClassName(IntPtr hWnd, System.Text.StringBuilder lpClassName, int nMaxCount);
[DllImport("user32.dll")]
public static extern IntPtr GetParent(IntPtr hWnd);
}
注册表实现自启动
/// <summary>
/// 通过注册表 HKCU\Software\Microsoft\Windows\CurrentVersion\Run 实现自启动。
/// 使用 HKCU 无需管理员权限。
/// </summary>
public static class AutoStartService
{
private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string AppName = "DesktopCalendarPlugin";
public static bool IsEnabled
{
get
{
try
{
using (var key = Registry.CurrentUser.OpenSubKey(RunKey, false))
{
if (key == null) return false;
var value = key.GetValue(AppName) as string;
return !string.IsNullOrEmpty(value);
}
}
catch { return false; }
}
}
public static bool Enable()
{
try
{
string exePath = Application.ExecutablePath;
using (var key = Registry.CurrentUser.OpenSubKey(RunKey, true))
{
if (key == null) return false;
key.SetValue(AppName, "\"" + exePath + "\" --autostart");
}
return true;
}
catch { return false; }
}
public static bool Disable()
{
try
{
using (var key = Registry.CurrentUser.OpenSubKey(RunKey, true))
{
if (key == null) return false;
if (key.GetValue(AppName) != null)
key.DeleteValue(AppName, false);
}
return true;
}
catch { return false; }
}
}
将窗口挂载到桌面壁纸层
public static bool Embed(Form form, Point location)
{
Log("========== Embed 开始 ==========");
Log("form.Handle = " + form.Handle);
Log("location = " + location);
Log("form.Size = " + form.Size);
if (_isEmbedded) { Log("已经嵌入,直接返回 true"); return true; }
try
{
IntPtr progman = NativeMethods.FindWindow("Progman", null);
Log("Progman = " + progman);
if (progman == IntPtr.Zero) { Log("找不到 Progman,嵌入失败"); return false; }
// 触发 WorkerW 分裂(在部分系统上会创建出 WorkerW)
IntPtr result;
NativeMethods.SendMessageTimeout(progman, 0x052C,
new IntPtr(0xD), new IntPtr(0x1),
NativeMethods.SMTO_NORMAL, 1000, out result);
Log("已发送 0x052C 消息");
// ========== 收集所有候选父窗口 ==========
IntPtr defView = IntPtr.Zero; // SHELLDLL_DefView
IntPtr defViewParent = IntPtr.Zero; // DefView 的父窗口
IntPtr workerw = IntPtr.Zero; // DefView 父窗口的兄弟 WorkerW
NativeMethods.EnumWindows((tophandle, l) =>
{
IntPtr sv = NativeMethods.FindWindowEx(tophandle, IntPtr.Zero,
"SHELLDLL_DefView", null);
if (sv != IntPtr.Zero)
{
defView = sv;
defViewParent = tophandle;
string cls = GetClassName(tophandle);
Log($"找到 SHELLDLL_DefView={sv},父窗口={tophandle} 类名={cls}");
workerw = NativeMethods.FindWindowEx(IntPtr.Zero, tophandle,
"WorkerW", null);
Log("兄弟 WorkerW = " + workerw);
}
return true;
}, IntPtr.Zero);
// ========== 选择目标父窗口(按优先级) ==========
// 优先级:
// 1. 兄弟 WorkerW(标准做法,Win7~Win10)
// 2. SHELLDLL_DefView 本身(Win11 24H2+,无独立 WorkerW 时)
// 3. DefView 的父窗口(Progman)
// 4. Progman 兜底
IntPtr targetParent;
string parentKind;
if (workerw != IntPtr.Zero)
{
targetParent = workerw;
parentKind = "WorkerW";
}
else if (defView != IntPtr.Zero)
{
targetParent = defView;
parentKind = "SHELLDLL_DefView";
Log("未找到 WorkerW,改用 SHELLDLL_DefView 作为父窗口");
}
else if (defViewParent != IntPtr.Zero)
{
targetParent = defViewParent;
parentKind = "DefViewParent";
Log("未找到 WorkerW 和 DefView,改用 DefView 父窗口");
}
else
{
targetParent = progman;
parentKind = "Progman";
Log("全部未找到,回退到 Progman");
}
Log($"最终目标父窗口: {targetParent} (类型={parentKind})");
// ========== 修改窗口样式 ==========
int exStyle = NativeMethods.GetWindowLong(form.Handle, NativeMethods.GWL_EXSTYLE);
int style = NativeMethods.GetWindowLong(form.Handle, NativeMethods.GWL_STYLE);
Log("原 exStyle=0x" + exStyle.ToString("X") + ", style=0x" + style.ToString("X"));
//exStyle &= ~NativeMethods.WS_EX_LAYERED;
exStyle |= NativeMethods.WS_EX_TOOLWINDOW;
NativeMethods.SetWindowLong(form.Handle, NativeMethods.GWL_EXSTYLE, exStyle);
style &= ~NativeMethods.WS_POPUP;
style |= NativeMethods.WS_CHILD;
NativeMethods.SetWindowLong(form.Handle, NativeMethods.GWL_STYLE, style);
// ========== SetParent ==========
Log("SetParent 之前,当前 GetParent=" + NativeMethods.GetParent(form.Handle));
IntPtr prevParent = NativeMethods.SetParent(form.Handle, targetParent);
int lastErr = Marshal.GetLastWin32Error();
Log("SetParent 返回 prevParent=" + prevParent + ", lastErr=" + lastErr);
// ========== 位置与 Z 序 ==========
// 关键:挂到 DefView 时,用 HWND_TOP 让窗口在 SysListView32 之上;
// 挂到 WorkerW 或 Progman 时,用 HWND_BOTTOM 让窗口沉入壁纸层。
IntPtr insertAfter;
if (parentKind == "SHELLDLL_DefView")
{
// DefView 下:放最顶层,图标层(SysListView32)默认也是子窗口,
// 视觉上图标仍然可见(SysListView32 是透明的)
insertAfter = NativeMethods.HWND_TOP;
}
else
{
insertAfter = NativeMethods.HWND_BOTTOM;
}
NativeMethods.SetWindowPos(form.Handle, insertAfter,
location.X, location.Y, form.Width, form.Height,
NativeMethods.SWP_NOACTIVATE | NativeMethods.SWP_SHOWWINDOW);
// ========== 强制显示 ==========
NativeMethods.ShowWindow(form.Handle, NativeMethods.SW_SHOW);
// ========== 校验 ==========
IntPtr actualParent = NativeMethods.GetParent(form.Handle);
bool visible = NativeMethods.IsWindowVisible(form.Handle);
Log("挂载后 GetParent=" + actualParent + ", IsVisible=" + visible);
if (actualParent != targetParent)
{
Log("父窗口不匹配,嵌入失败,回退");
RestoreTopLevel(form, style, exStyle);
return false;
}
_isEmbedded = true;
Log($"========== Embed 成功 (父类型={parentKind}) ==========");
return true;
}
catch (Exception ex)
{
Log("Embed 异常:" + ex);
return false;
}
}
日历调用的是CDN地址:
https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master/{year}.json
试用安装包下载
需要源码的同学,请关注公众号回复“日程”即可
作者:Stephen-kzx
出处:http://www.cnblogs.com/axing/
公众号:会定时分享写工作中或者生活中遇到的小游戏和小工具源码。有兴趣的帮忙点下关注!感恩!
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

浙公网安备 33010602011771号