【开源】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_DefViewSetParent
无边框窗口拖动/缩放 手动实现 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

试用安装包下载

需要源码的同学,请关注公众号回复"日程"即可

相关推荐
周杰伦fans2 小时前
CAD菜单栏和工具栏都不见了,怎么显示?
其他·c#
曹牧2 小时前
C#:HashSet<T> 去重
c#
czhc11400756632 小时前
起点、口径与因果:今天用错的七把尺子
c#
c#上位机15 小时前
C#上位机项目实战——Task.Run执行带参数的方法
c#·上位机
Lost of 程序猿21 小时前
用 YARP 为 .NET 微服务搭一座“立交桥“:路由转发与生产配置实践
c#·asp.net
点纭1 天前
C 语言 第七章 常用函数(3)
c语言·开发语言·算法·oracle·c#
用户788477316341 天前
用 .NET MAUI 一套 C# 同时出 Windows 和 Android:哪些是真能共享,哪些是 marketing
c#
Lost of 程序猿1 天前
用 Quartz.NET 优雅地处理 .NET 定时任务:从选型到 Docker 部署全记录
后端·c#·asp.net
林川~012 天前
Unity 万能物理检测工具:射线检测 / 范围检测 / 层级过滤 / 编辑器可视化(可直接拿去用)
游戏·unity·c#·射线检测·通用工具