1. 项目概述
本文介绍基于 C# .NET 10 开发的 TaskScheduler 定时任务调度器,涵盖项目结构、核心模块、配置管理、任务调度、数据持久化、日志记录以及 WinForms 图形界面等完整实现。
2. 项目结构
项目采用分层架构,主要包含以下模块:
- Config:应用配置加载与解析(对应 Go 版 config 模块)
- Domain:任务领域实体与状态枚举
- JsonIO:任务 JSON 导入导出
- Logger:日志记录模块
- Repository:数据持久化层
- Gui:WinForms 图形界面
3. 配置管理
配置模块负责从 app.yaml 加载应用配置,并支持蛇形命名约定转换,对应 Go 版 config/config.go 的实现。
- AppConfig 配置类:应用信息、调度参数、数据库、日志等配置段
- ConfigLoader 加载器:读取 YAML 文件并设置全局单例
- SnakeCaseNamingConvention:属性名与 YAML 键的蛇形命名转换
4. 领域模型
领域层定义任务实体与状态枚举,字段与数据库表 t_task 严格一致。
- TaskEntity:任务 ID、名称、cron 表达式、执行命令、超时、重试、启用状态、备注等
- TaskHistoryEntity:任务执行历史记录
- TaskStatus 枚举:空闲、运行中
5. 任务 JSON 导入导出
JsonIO 模块实现任务数据的 JSON 序列化与反序列化,支持批量导入与全量导出。
- ExportAll:将全部任务导出为 JSON 文件
- ImportFromFile:从 JSON 文件导入任务,返回新增、更新、跳过统计
6. 图形界面(WinForms)
Gui 模块基于 WinForms 构建,提供任务管理、仪表盘、日志查看等交互功能。
- 任务列表:分页、搜索、状态高亮、下次执行时间计算
- 仪表盘:统计卡片、柱状图、最近执行记录
- 工具栏:新增、编辑、删除、立即执行、查看历史、导入导出
- 系统托盘:关闭最小化、双击恢复、退出菜单
7. 运行效果
程序运行后展示主界面,包含任务列表、仪表盘、日志输出等区域,支持实时刷新与任务操作。
8. 总结
本文完整展示了 TaskScheduler 定时任务调度器的 C# 实现,从配置管理、领域模型到图形界面,覆盖了定时任务系统的核心功能,可作为类似项目的参考实现。
项目结构:

cs
/*
# encoding: utf-8
# 版权所有 2026 ©涂聚文有限公司™ ®
# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
# 描述:Taskscheduler
# Author : geovindu,Geovin Du 涂聚文.
# IDE : vs2026 c# .net 10
# os : windows 10
# database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
# Datetime : 2026/08/28 22:16
# User : geovindu
# Product : Visual Studio 2026
# Project : Taskscheduler
# File : AppConfig.cs
*/
namespace Taskscheduler.Config;
using System.Text;
using YamlDotNet.Serialization;
/// <summary>
/// 应用配置(对应Go版 config/config.go,app.yaml模板)
/// </summary>
public class AppConfig
{
/// <summary>
/// 应用信息
/// </summary>
public AppSection App { get; set; } = new();
/// <summary>
/// 调度配置
/// </summary>
public SchedulerSection Scheduler { get; set; } = new();
/// <summary>
/// 数据库配置
/// </summary>
public DatabaseSection Database { get; set; } = new();
/// <summary>
/// 日志配置
/// </summary>
public LogSection Log { get; set; } = new();
/// <summary>
/// 应用信息段
/// </summary>
public class AppSection
{
/// <summary>
/// 应用名称
/// </summary>
public string AppName { get; set; } = "TaskScheduler定时任务调度器";
}
/// <summary>
/// 调度段
/// </summary>
public class SchedulerSection
{
/// <summary>
/// 扫描间隔(毫秒)
/// </summary>
public int ScanIntervalMs { get; set; } = 1000;
/// <summary>
/// 最大并发任务数
/// </summary>
public int ConcurrentMax { get; set; } = 8;
}
/// <summary>
/// 数据库段
/// </summary>
public class DatabaseSection
{
/// <summary>
/// SQLite数据库文件路径
/// </summary>
public string DbFile { get; set; } = "./task_scheduler.db";
}
/// <summary>
/// 日志段
/// </summary>
public class LogSection
{
/// <summary>
/// 日志目录
/// </summary>
public string LogDir { get; set; } = "./logs";
/// <summary>
/// 单文件最大MB
/// </summary>
public int MaxSizeMb { get; set; } = 5;
/// <summary>
/// 保留备份数
/// </summary>
public int MaxBackups { get; set; } = 10;
}
/// <summary>
/// 全局配置单例(对应Go版 config.Cfg)
/// </summary>
public static AppConfig Cfg { get; set; } = new();
}
/// <summary>
/// 蛇形命名约定:属性名 AppName → yaml键 app_name
/// (对应Go版 app.yaml 的 snake_case 风格)
/// </summary>
public class SnakeCaseNamingConvention : INamingConvention
{
public string Apply(string value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
var sb = new StringBuilder(value.Length + 8);
for (var i = 0; i < value.Length; i++)
{
var ch = value[i];
if (char.IsUpper(ch))
{
if (i > 0)
{
sb.Append('_');
}
sb.Append(char.ToLowerInvariant(ch));
}
else
{
sb.Append(ch);
}
}
return sb.ToString();
}
/// <summary>
/// 逆向转换:app_name → AppName(YamlDotNet接口要求)
/// </summary>
/// <param name="value"></param>
/// <returns></returns>
public string Reverse(string value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
var sb = new StringBuilder(value.Length);
var upperNext = false;
foreach (var ch in value)
{
if (ch == '_')
{
upperNext = true;
}
else if (upperNext)
{
sb.Append(char.ToUpperInvariant(ch));
upperNext = false;
}
else
{
sb.Append(ch);
}
}
return sb.ToString();
}
}
/*
encoding: utf-8
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述:Taskscheduler
Author : geovindu,Geovin Du 涂聚文.
IDE : vs2026 c# .net 10
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/08/28 22:16
User : geovindu
Product : Visual Studio 2026
Project : Taskscheduler
File : ConfigLoader.cs
*/
namespace Taskscheduler.Config;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
/// <summary>
/// 配置加载器:从app.yaml加载并设置全局单例 AppConfig.Cfg
/// 对应Go版 config/config.go
/// </summary>
public static class ConfigLoader
{
/// <summary>
/// 从YAML文件加载配置(文件不存在时使用默认值
/// </summary>
/// <param name="yamlFile"></param>
/// <returns></returns>
public static AppConfig Load(string yamlFile = "config/app.yaml")
{
if (File.Exists(yamlFile))
{
try
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(new SnakeCaseNamingConvention())
.Build();
var cfg = deserializer.Deserialize<AppConfig>(File.ReadAllText(yamlFile));
if (cfg != null)
{
AppConfig.Cfg = cfg;
return cfg;
}
}
catch (Exception ex)
{
Console.WriteLine($"配置文件解析失败,使用默认配置: {ex.Message}");
}
}
else
{
Console.WriteLine($"未找到配置文件 {yamlFile},使用默认配置");
}
return AppConfig.Cfg;
}
}
/*
encoding: utf-8
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述:Taskscheduler
Author : geovindu,Geovin Du 涂聚文.
IDE : vs2026 c# .net 10
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/08/28 22:16
User : geovindu
Product : Visual Studio 2026
Project : Taskscheduler
File : TaskEntity.cs
*/
namespace Taskscheduler.Domain;
/// <summary>
/// 任务领域实体(对应Go版 domain/task_entity.go、Python版 domain/task_entity.py)
/// 字段与Go版/数据库表 t_task 严格一致
/// </summary>
public class TaskEntity
{
/// <summary>
/// 任务ID(0=新增,由数据库自增)
/// </summary>
public long TaskId { get; set; }
/// <summary>
/// 任务名称
/// </summary>
public string TaskName { get; set; } = "";
/// <summary>
/// cron表达式(标准5字段:分 时 日 月 周)
/// </summary>
public string CronExpr { get; set; } = "";
/// <summary>
/// 执行命令
/// </summary>
public string Command { get; set; } = "";
/// <summary>
/// 超时秒数
/// </summary>
public int TimeoutSec { get; set; }
/// <summary>
/// 最大重试次数
/// </summary>
public int MaxRetry { get; set; }
/// <summary>
/// 重
/// 试间隔秒数
/// </summary>
public int RetryGapSec { get; set; }
/// <summary>
/// 是否启用
/// </summary>
public bool IsEnable { get; set; }
/// <summary>
/// 备注(含"一次性"则执行成功后自动禁用)
/// </summary>
public string Remark { get; set; } = "";
/// <summary>
/// 任务状态
/// </summary>
public TaskStatus TaskStatus { get; set; } = TaskStatus.Idle;
/// <summary>
/// 是否为一次性任务(备注含"一次性")
/// </summary>
public bool IsOneTime => !string.IsNullOrEmpty(Remark) && Remark.Contains("一次性", StringComparison.Ordinal);
}
/*
encoding: utf-8
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述:Taskscheduler
Author : geovindu,Geovin Du 涂聚文.
IDE : vs2026 c# .net 10
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/08/28 22:16
User : geovindu
Product : Visual Studio 2026
Project : Taskscheduler
File : TaskHistoryEntity.cs
*/
namespace Taskscheduler.Domain;
/// <summary>
/// 任务执行历史实体(对应Go版 domain/task_history_entity.go)
/// 字段与数据库表 t_task_history 严格一致
/// </summary>
public class TaskHistoryEntity
{
/// <summary>
/// 历史ID(0=新增,数据库自增)
/// </summary>
public long HistoryId { get; set; }
/// <summary>
/// 任务ID
/// </summary>
public long TaskId { get; set; }
/// <summary>
/// 执行开始时间
/// </summary>
public DateTime ExecuteStartTime { get; set; }
/// <summary>
/// 执行结束时间
/// </summary>
public DateTime ExecuteEndTime { get; set; }
/// <summary>
/// 是否成功
/// <summary>
public bool IsSuccess { get; set; }
/// <summary>
/// 执行输出信息
/// </summary>
public string ExecuteMsg { get; set; } = "";
}
/*
encoding: utf-8
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述:Taskscheduler
Author : geovindu,Geovin Du 涂聚文.
IDE : vs2026 c# .net 10
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/08/28 22:16
User : geovindu
Product : Visual Studio 2026
Project : Taskscheduler
File : TaskStatus.cs
*/
namespace Taskscheduler.Domain;
/// <summary>
/// 任务状态枚举(对应Go版 domain/task_status.go)
/// </summary>
public enum TaskStatus
{
/// <summary>
/// 空闲:等待调度
/// </summary>
Idle = 0,
/// <summary>
/// 运行中
/// </summary>
Running = 1,
}
/*
encoding: utf-8
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述:Taskscheduler
Author : geovindu,Geovin Du 涂聚文.
IDE : vs2026 c# .net 10
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/08/28 22:16
User : geovindu
Product : Visual Studio 2026
Project : Taskscheduler
File : TaskJsonIO.cs
*/
namespace Taskscheduler.JsonIO;
using System.Text.Json;
using System.Text.Json.Serialization;
using Taskscheduler.Domain;
using Taskscheduler.Repository;
/// <summary>
/// 任务JSON导入导出(对应Go版 jsonio/task_json_io.go)
/// 导出:全部任务导出为JSON文件;导入:读取JSON批量新增/更新任务
/// </summary>
public class TaskJsonIO
{
private readonly TaskRepo _repo = new();
/// <summary>
/// >JSON任务条目(与Go版字段完全一致)
/// </summary>
public class TaskJsonItem
{
[JsonPropertyName("task_id")]
public long TaskId { get; set; }
[JsonPropertyName("task_name")]
public string TaskName { get; set; } = "";
[JsonPropertyName("cron_expr")]
public string CronExpr { get; set; } = "";
[JsonPropertyName("command")]
public string Command { get; set; } = "";
[JsonPropertyName("timeout_sec")]
public int TimeoutSec { get; set; }
[JsonPropertyName("max_retry")]
public int MaxRetry { get; set; }
[JsonPropertyName("retry_gap_sec")]
public int RetryGapSec { get; set; }
[JsonPropertyName("is_enable")]
public bool IsEnable { get; set; }
[JsonPropertyName("remark")]
public string Remark { get; set; } = "";
[JsonPropertyName("task_status")]
public string TaskStatus { get; set; } = "IDLE";
}
/// <summary>
/// 导入统计结果
/// </summary>
/// <param name="Total"></param>
/// <param name="Insert"></param>
/// <param name="Update"></param>
/// <param name="Skip"></param>
public record ExportStat(int Total, int Insert, int Update, int Skip);
/// <summary>
/// 导出全部任务到JSON文件
/// </summary>
/// <param name="filePath"></param>
public void ExportAll(string filePath)
{
var items = _repo.GetList().Select(e => new TaskJsonItem
{
TaskId = e.TaskId,
TaskName = e.TaskName,
CronExpr = e.CronExpr,
Command = e.Command,
TimeoutSec = e.TimeoutSec,
MaxRetry = e.MaxRetry,
RetryGapSec = e.RetryGapSec,
IsEnable = e.IsEnable,
Remark = e.Remark,
TaskStatus = e.TaskStatus.ToString(),
}).ToList();
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(filePath, json);
Taskscheduler.Logger.AppLogger.Info($"导出完成:{items.Count} 条任务 → {filePath}");
}
/// <summary>
/// 从JSON文件导入任务,返回导入统计
/// </summary>
/// <param name="filePath"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public ExportStat ImportFromFile(string filePath)
{
var items = JsonSerializer.Deserialize<List<TaskJsonItem>>(File.ReadAllText(filePath));
if (items == null)
{
throw new InvalidOperationException("JSON内容为空或格式错误");
}
var insert = 0;
var update = 0;
var skip = 0;
foreach (var item in items)
{
// 校验必填字段
if (string.IsNullOrEmpty(item.TaskName) ||
string.IsNullOrEmpty(item.CronExpr) ||
string.IsNullOrEmpty(item.Command))
{
Taskscheduler.Logger.AppLogger.Warn($"跳过无效条目(名称/cron/命令为空):{item.TaskName}");
skip++;
continue;
}
var entity = new TaskEntity
{
TaskId = item.TaskId,
TaskName = item.TaskName,
CronExpr = item.CronExpr,
Command = item.Command,
TimeoutSec = item.TimeoutSec,
MaxRetry = item.MaxRetry,
RetryGapSec = item.RetryGapSec,
IsEnable = item.IsEnable,
Remark = item.Remark,
TaskStatus = string.Equals(item.TaskStatus, "RUNNING", StringComparison.OrdinalIgnoreCase)
? TaskStatus.Running : TaskStatus.Idle,
};
try
{
_repo.Save(entity);
if (item.TaskId <= 0)
{
insert++;
}
else
{
update++;
}
}
catch (Exception ex)
{
Taskscheduler.Logger.AppLogger.Error($"导入任务失败: {ex.Message}");
skip++;
}
}
var stat = new ExportStat(items.Count, insert, update, skip);
Taskscheduler.Logger.AppLogger.Info(
$"导入完成:总条数={stat.Total} 新增={stat.Insert} 更新={stat.Update} 跳过={stat.Skip}");
return stat;
}
}
/*
encoding: utf-8
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述:Taskscheduler
Author : geovindu,Geovin Du 涂聚文.
IDE : vs2026 c# .net 10
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/08/28 22:16
User : geovindu
Product : Visual Studio 2026
Project : Taskscheduler
File : AppLogger.cs
*/
namespace Taskscheduler.Logger;
using System.Text;
/// <summary>
/// 应用日志器(对应Go版 logger/logger.go)
/// 控制台 + 滚动文件输出(按大小切割,保留备份)+ 可选GUI回调
/// </summary>
public static class AppLogger
{
private static readonly object LockObj = new();
private static StreamWriter? _fileWriter;
private static string _logFile = "app.log";
private static int _maxSizeBytes = 5 * 1024 * 1024;
private static int _maxBackups = 10;
/// <summary>
/// 日志事件(GUI订阅,向日志框追加文本)
/// </summary>
public static event Action<string>? Logged;
/// <summary>初始化日志器</summary>
/// <param name="logDir">日志目录</param>
/// <param name="extraSink">额外输出目标(如GUI回调),可为null</param>
public static void Init(string logDir, Action<string>? extraSink = null)
{
lock (LockObj)
{
if (!Directory.Exists(logDir))
{
Directory.CreateDirectory(logDir);
}
_logFile = Path.Combine(logDir, "app.log");
// 控制台输出使用UTF-8(保证中文正常)
Console.OutputEncoding = Encoding.UTF8;
_fileWriter = new StreamWriter(_logFile, append: true, Encoding.UTF8) { AutoFlush = true };
if (extraSink != null)
{
Logged += extraSink;
}
Info("日志系统初始化完成");
}
}
/// <summary>
/// 记录INFO日志
/// </summary>
/// <param name="msg"></param>
public static void Info(string msg) => Write("INFO", msg);
/// <summary>
/// 记录ERROR日志
/// </summary>
/// <param name="msg"></param>
public static void Error(string msg) => Write("ERROR", msg);
/// <summary>
/// 记录WARN日志
/// </summary>
/// <param name="msg"></param>
public static void Warn(string msg) => Write("WARN", msg);
/// <summary>
/// 写入日志行(含滚动切割)
/// </summary>
/// <param name="level"></param>
/// <param name="msg"></param>
private static void Write(string level, string msg)
{
var line = $"{DateTime.Now:yyyy/MM/dd HH:mm:ss.fff} [{level}] {msg}";
lock (LockObj)
{
RotateIfNeeded();
_fileWriter?.WriteLine(line);
Console.WriteLine(line);
// 通知GUI
Logged?.Invoke(line + Environment.NewLine);
}
}
/// <summary>
/// 按大小滚动切割日志文件
/// </summary>
private static void RotateIfNeeded()
{
if (_fileWriter == null || !File.Exists(_logFile))
{
return;
}
var info = new FileInfo(_logFile);
if (info.Length < _maxSizeBytes)
{
return;
}
// 关闭当前写入器
_fileWriter.Dispose();
_fileWriter = null;
// 删除最旧备份,后移
for (var i = _maxBackups; i >= 1; i--)
{
var src = i == 1 ? _logFile : $"{_logFile}.{i - 1}";
var dst = $"{_logFile}.{i}";
if (File.Exists(src))
{
if (File.Exists(dst))
{
File.Delete(dst);
}
File.Move(src, dst);
}
}
// 重新打开新文件
_fileWriter = new StreamWriter(_logFile, append: false, Encoding.UTF8) { AutoFlush = true };
Write0("日志文件已滚动切割");
}
/// <summary>
/// 内部直接写行(用于滚动后说明),避免递归
/// </summary>
/// <param name="msg"></param>
private static void Write0(string msg)
{
var line = $"{DateTime.Now:yyyy/MM/dd HH:mm:ss.fff} [INFO] {msg}";
_fileWriter?.WriteLine(line);
Console.WriteLine(line);
}
}
/*
encoding: utf-8
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述:Taskscheduler
Author : geovindu,Geovin Du 涂聚文.
IDE : vs2026 c# .net 10
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/08/28 22:16
User : geovindu
Product : Visual Studio 2026
Project : Taskscheduler
File : TaskHistoryRepo.cs
*/
namespace Taskscheduler.Repository;
using Microsoft.Data.Sqlite;
using Taskscheduler.Domain;
using Taskscheduler.Storage;
/// <summary>
/// 任务历史仓储(对应Go版 repository/task_history_repo.go)
/// 写入/查询执行历史,含仪表盘统计方法
/// </summary>
public class TaskHistoryRepo
{
/// <summary>
/// 新增一条执行历史,返回带自增ID的实体
/// </summary>
/// <param name="entity"></param>
/// <returns></returns>
public TaskHistoryEntity Insert(TaskHistoryEntity entity)
{
using var conn = Db.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO t_task_history (task_id, execute_start_time, execute_end_time, is_success, execute_msg)
VALUES (@taskId, @start, @end, @success, @msg)
""";
cmd.Parameters.AddWithValue("@taskId", entity.TaskId);
cmd.Parameters.AddWithValue("@start", Db.FormatTime(entity.ExecuteStartTime));
cmd.Parameters.AddWithValue("@end", Db.FormatTime(entity.ExecuteEndTime));
cmd.Parameters.AddWithValue("@success", entity.IsSuccess ? 1 : 0);
cmd.Parameters.AddWithValue("@msg", entity.ExecuteMsg ?? "");
cmd.ExecuteNonQuery();
using var idCmd = conn.CreateCommand();
idCmd.CommandText = "SELECT last_insert_rowid()";
entity.HistoryId = (long)(idCmd.ExecuteScalar() ?? 0L);
return entity;
}
/// <summary>
/// 按任务ID查询执行历史(ID倒序,最新在前)
/// </summary>
/// <param name="taskId"></param>
/// <param name="limit"></param>
/// <returns></returns>
public List<TaskHistoryEntity> ListByTaskId(long taskId, int limit = 50)
{
using var conn = Db.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT id, task_id, execute_start_time, execute_end_time, is_success, execute_msg
FROM t_task_history WHERE task_id = @taskId ORDER BY id DESC LIMIT @limit
""";
cmd.Parameters.AddWithValue("@taskId", taskId);
cmd.Parameters.AddWithValue("@limit", limit);
return ReadList(cmd);
}
/// <summary>
/// 查询全局最近N条执行记录(仪表盘使用)
/// </summary>
/// <param name="limit"></param>
/// <returns></returns>
public List<TaskHistoryEntity> ListRecent(int limit = 20)
{
using var conn = Db.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT id, task_id, execute_start_time, execute_end_time, is_success, execute_msg
FROM t_task_history ORDER BY id DESC LIMIT @limit
""";
cmd.Parameters.AddWithValue("@limit", limit);
return ReadList(cmd);
}
/// <summary>
/// 统计执行总次数、成功次数、失败次数
/// </summary>
/// <returns></returns>
public (long Total, long Success, long Fail) CountStats()
{
using var conn = Db.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*), COALESCE(SUM(is_success),0) FROM t_task_history";
using var reader = cmd.ExecuteReader();
if (!reader.Read())
{
return (0, 0, 0);
}
var total = reader.GetInt64(0);
var success = reader.GetInt64(1);
return (total, success, total - success);
}
/// <summary>
/// 统计今日执行次数
/// </summary>
/// <returns></returns>
public long CountToday()
{
using var conn = Db.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM t_task_history WHERE execute_start_time LIKE @prefix";
cmd.Parameters.AddWithValue("@prefix", Db.FormatTime(DateTime.Now)[..10] + "%");
return (long)(cmd.ExecuteScalar() ?? 0L);
}
/// <summary>
/// 某天执行次数(柱状图数据)
/// </summary>
/// <param name="Date"></param>
/// <param name="Count"></param>
public record DayCount(string Date, long Count);
/// <summary>
/// 统计最近days天(含今天)每天执行次数,无记录的天补0
/// </summary>
/// <param name="days"></param>
/// <returns></returns>
public List<DayCount> CountByDay(int days = 7)
{
// 生成日期序列
var result = new List<DayCount>(days);
var indexMap = new Dictionary<string, int>(days);
for (var i = 0; i < days; i++)
{
var date = DateTime.Now.AddDays(-(days - 1 - i)).ToString("yyyy-MM-dd");
result.Add(new DayCount(date, 0));
indexMap[date] = i;
}
// 查询整个窗口内的聚合(左闭右开:[首日00:00:00, 明日00:00:00))
using var conn = Db.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT substr(execute_start_time,1,10) AS d, COUNT(*)
FROM t_task_history
WHERE execute_start_time >= @start AND execute_start_time < @end
GROUP BY d
""";
cmd.Parameters.AddWithValue("@start", result[0].Date + " 00:00:00");
cmd.Parameters.AddWithValue("@end", DateTime.Now.AddDays(1).ToString("yyyy-MM-dd") + " 00:00:00");
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var day = reader.GetString(0);
if (indexMap.TryGetValue(day, out var idx))
{
result[idx] = new DayCount(day, reader.GetInt64(1));
}
}
return result;
}
/// <summary>
/// 读取多行结果
/// </summary>
/// <param name="cmd"></param>
/// <returns></returns>
private static List<TaskHistoryEntity> ReadList(SqliteCommand cmd)
{
using var reader = cmd.ExecuteReader();
var list = new List<TaskHistoryEntity>();
while (reader.Read())
{
list.Add(new TaskHistoryEntity
{
HistoryId = reader.GetInt64(0),
TaskId = reader.GetInt64(1),
ExecuteStartTime = Db.ParseTime(reader.GetString(2)),
ExecuteEndTime = Db.ParseTime(reader.GetString(3)),
IsSuccess = reader.GetInt32(4) == 1,
ExecuteMsg = reader.IsDBNull(5) ? "" : reader.GetString(5),
});
}
return list;
}
}
/*
encoding: utf-8
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述:Taskscheduler
Author : geovindu,Geovin Du 涂聚文.
IDE : vs2026 c# .net 10
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/08/28 22:16
User : geovindu
Product : Visual Studio 2026
Project : Taskscheduler
File : TaskRepo.cs
*/
namespace Taskscheduler.Repository;
using Microsoft.Data.Sqlite;
using Taskscheduler.Domain;
using Taskscheduler.Storage;
/// <summary>
/// 任务仓储(对应Go版 repository/task_repo.go)
/// 提供 t_task 表完整CRUD,行记录↔领域实体双向转换
/// </summary>
public class TaskRepo
{
/// <summary>
/// 查询全部任务(按ID升序)
/// </summary>
public List<TaskEntity> GetList()
{
using var conn = Db.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT id, task_name, cron_expr, command, timeout_sec, max_retry,
retry_gap_sec, is_enable, remark, task_status
FROM t_task ORDER BY id
""";
using var reader = cmd.ExecuteReader();
var list = new List<TaskEntity>();
while (reader.Read())
{
list.Add(MapRow(reader));
}
return list;
}
/// <summary>
/// 查询全部启用任务
///
/// </summary>
public List<TaskEntity> GetEnabledList()
{
using var conn = Db.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT id, task_name, cron_expr, command, timeout_sec, max_retry,
retry_gap_sec, is_enable, remark, task_status
FROM t_task WHERE is_enable = 1 ORDER BY id
""";
using var reader = cmd.ExecuteReader();
var list = new List<TaskEntity>();
while (reader.Read())
{
list.Add(MapRow(reader));
}
return list;
}
/// <summary>
/// 按关键字统计任务总数(关键字匹配任务名称/备注,为空返回全量)
/// </summary>
/// <param name="keyword"></param>
/// <returns></returns>
public long Count(string? keyword = null)
{
var kw = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
using var conn = Db.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = kw == null
? "SELECT COUNT(*) FROM t_task"
: "SELECT COUNT(*) FROM t_task WHERE task_name LIKE @kw OR remark LIKE @kw";
if (kw != null)
{
cmd.Parameters.AddWithValue("@kw", $"%{kw}%");
}
return (long)(cmd.ExecuteScalar() ?? 0L);
}
/// <summary>
/// 关键字分页查询任务列表(page从1开始,pageSize>0)
/// </summary>
/// <param name="keyword"></param>
/// <param name="page"></param>
/// <param name="pageSize"></param>
/// <returns></returns>
public List<TaskEntity> SearchPage(string? keyword, int page, int pageSize)
{
if (page < 1)
{
page = 1;
}
if (pageSize < 1)
{
pageSize = 10;
}
var kw = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
using var conn = Db.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"""
SELECT id, task_name, cron_expr, command, timeout_sec, max_retry,
retry_gap_sec, is_enable, remark, task_status
FROM t_task
{(kw == null ? "" : "WHERE task_name LIKE @kw OR remark LIKE @kw")}
ORDER BY id
LIMIT @limit OFFSET @offset
""";
if (kw != null)
{
cmd.Parameters.AddWithValue("@kw", $"%{kw}%");
}
cmd.Parameters.AddWithValue("@limit", pageSize);
cmd.Parameters.AddWithValue("@offset", (page - 1) * pageSize);
using var reader = cmd.ExecuteReader();
var list = new List<TaskEntity>();
while (reader.Read())
{
list.Add(MapRow(reader));
}
return list;
}
/// <summary>
/// 按ID查询任务
/// </summary>
/// <param name="taskId"></param>
/// <returns></returns>
public TaskEntity? GetById(long taskId)
{
using var conn = Db.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT id, task_name, cron_expr, command, timeout_sec, max_retry,
retry_gap_sec, is_enable, remark, task_status
FROM t_task WHERE id = @id
""";
cmd.Parameters.AddWithValue("@id", taskId);
using var reader = cmd.ExecuteReader();
return reader.Read() ? MapRow(reader) : null;
}
/// <summary>
/// 保存任务:TaskId<=0新增,否则更新;返回带ID的实体
/// </summary>
/// <param name="entity"></param>
/// <returns></returns>
public TaskEntity Save(TaskEntity entity)
{
using var conn = Db.OpenConnection();
using var cmd = conn.CreateCommand();
if (entity.TaskId <= 0)
{
cmd.CommandText = """
INSERT INTO t_task (task_name, cron_expr, command, timeout_sec, max_retry,
retry_gap_sec, is_enable, remark, task_status)
VALUES (@name, @cron, @cmd, @timeout, @retry, @gap, @enable, @remark, @status)
""";
}
else
{
cmd.CommandText = """
UPDATE t_task SET task_name=@name, cron_expr=@cron, command=@cmd,
timeout_sec=@timeout, max_retry=@retry, retry_gap_sec=@gap,
is_enable=@enable, remark=@remark, task_status=@status
WHERE id=@id
""";
cmd.Parameters.AddWithValue("@id", entity.TaskId);
}
cmd.Parameters.AddWithValue("@name", entity.TaskName);
cmd.Parameters.AddWithValue("@cron", entity.CronExpr);
cmd.Parameters.AddWithValue("@cmd", entity.Command);
cmd.Parameters.AddWithValue("@timeout", entity.TimeoutSec);
cmd.Parameters.AddWithValue("@retry", entity.MaxRetry);
cmd.Parameters.AddWithValue("@gap", entity.RetryGapSec);
cmd.Parameters.AddWithValue("@enable", entity.IsEnable ? 1 : 0);
cmd.Parameters.AddWithValue("@remark", entity.Remark ?? "");
cmd.Parameters.AddWithValue("@status", entity.TaskStatus.ToString());
cmd.ExecuteNonQuery();
if (entity.TaskId <= 0)
{
using var idCmd = conn.CreateCommand();
idCmd.CommandText = "SELECT last_insert_rowid()";
entity.TaskId = (long)(idCmd.ExecuteScalar() ?? 0L);
}
return entity;
}
/// <summary>
/// 按ID删除任务
/// </summary>
/// <param name="taskId"></param>
/// <returns></returns>
public bool DeleteById(long taskId)
{
using var conn = Db.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM t_task WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
return cmd.ExecuteNonQuery() > 0;
}
/// <summary>
/// 更新任务状态
/// </summary>
/// <param name="taskId"></param>
/// <param name="status"></param>
/// <returns></returns>
public bool UpdateStatus(long taskId, TaskStatus status)
{
using var conn = Db.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE t_task SET task_status = @status WHERE id = @id";
cmd.Parameters.AddWithValue("@status", status.ToString());
cmd.Parameters.AddWithValue("@id", taskId);
return cmd.ExecuteNonQuery() > 0;
}
/// <summary>
/// 更新任务启用状态
/// </summary>
/// <param name="taskId"></param>
/// <param name="enable"></param>
/// <returns></returns>
public bool UpdateEnable(long taskId, bool enable)
{
using var conn = Db.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE t_task SET is_enable = @enable WHERE id = @id";
cmd.Parameters.AddWithValue("@enable", enable ? 1 : 0);
cmd.Parameters.AddWithValue("@id", taskId);
return cmd.ExecuteNonQuery() > 0;
}
/// <summary>
/// 数据行→领域实体(列顺序与SQL固定一致)
/// </summary>
/// <param name="reader"></param>
/// <returns></returns>
private static TaskEntity MapRow(SqliteDataReader reader)
{
var status = reader.GetString(9).ToUpperInvariant();
return new TaskEntity
{
TaskId = reader.GetInt64(0),
TaskName = reader.GetString(1),
CronExpr = reader.GetString(2),
Command = reader.GetString(3),
TimeoutSec = reader.GetInt32(4),
MaxRetry = reader.GetInt32(5),
RetryGapSec = reader.GetInt32(6),
IsEnable = reader.GetInt32(7) == 1,
Remark = reader.IsDBNull(8) ? "" : reader.GetString(8),
TaskStatus = status == "RUNNING" ? TaskStatus.Running : TaskStatus.Idle,
};
}
}
/*
encoding: utf-8
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述:Taskscheduler
Author : geovindu,Geovin Du 涂聚文.
IDE : vs2026 c# .net 10
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/08/28 22:16
User : geovindu
Product : Visual Studio 2026
Project : Taskscheduler
File : SubprocessRunner.cs
*/
namespace Taskscheduler.Runner;
using System.Diagnostics;
/// <summary>
/// 子进程命令执行器(对应Go版 runner/subprocess_runner.go)
/// 调用系统shell执行命令、超时控制、捕获stdout+stderr合并输出
/// Windows 使用 cmd /C,Linux/macOS 使用 sh -c
/// </summary>
public class SubprocessRunner
{
/// <summary>
/// 超时秒数</summary>
public int TimeoutSec { get; }
/// <summary>
/// 创建执行器
/// </summary>
public SubprocessRunner(int timeoutSec)
{
TimeoutSec = timeoutSec;
}
/// <summary>
/// 执行结果
///
/// </summary>
public class RunResult
{
/// <summary>
/// 是否成功(退出码0)
/// </summary>
public bool Success { get; set; }
/// <summary>
/// 合并输出文本(stdout+stderr)
///
/// </summary>
public string Output { get; set; } = "";
}
/// <summary>执行命令</summary>
/// <param name="taskId">任务ID(仅日志标识)</param>
/// <param name="command">待执行命令</param>
public RunResult Run(long taskId, string command)
{
var isWindows = OperatingSystem.IsWindows();
using var process = new Process();
process.StartInfo.FileName = isWindows ? "cmd.exe" : "/bin/sh";
process.StartInfo.Arguments = isWindows ? $"/C {command}" : $"-c \"{command}\"";
process.StartInfo.UseShellExecute = false;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
process.StartInfo.CreateNoWindow = true;
try
{
process.Start();
}
catch (Exception ex)
{
return new RunResult { Success = false, Output = $"启动进程失败: {ex.Message}" };
}
// 异步读取,避免缓冲区满死锁
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
// 超时控制
if (!process.WaitForExit(TimeoutSec * 1000))
{
try
{
process.Kill(entireProcessTree: true);
}
catch
{
// 进程可能已退出
}
return new RunResult
{
Success = false,
Output = $"【超时】任务task_id={taskId}超过{TimeoutSec}秒被强制终止",
};
}
var output = stdoutTask.GetAwaiter().GetResult() + stderrTask.GetAwaiter().GetResult();
return new RunResult
{
Success = process.ExitCode == 0,
Output = process.ExitCode == 0 ? output : $"返回码:{process.ExitCode}\n{output}",
};
}
}
/*
encoding: utf-8
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述:Taskscheduler
Author : geovindu,Geovin Du 涂聚文.
IDE : vs2026 c# .net 10
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/08/28 22:16
User : geovindu
Product : Visual Studio 2026
Project : Taskscheduler
File : BackgroundScheduler.cs
*/
namespace Taskscheduler.Scheduler;
using Taskscheduler.Application;
using Taskscheduler.Config;
using Taskscheduler.Domain;
using Taskscheduler.Logger;
using Taskscheduler.Repository;
/// <summary>
/// 后台调度线程(对应Go版 scheduler/background_scheduler.go)
/// 职责:
/// 1. 定时扫描启用任务
/// 2. cron时间判断(上次执行时间为基准,未执行过以启动时间为基准,不补跑)
/// 3. 并发数量控制(SemaphoreSlim + 运行标记集合)
/// 4. 调用应用层执行任务、更新任务运行状态
/// 5. 一次性任务(备注含"一次性")执行成功后自动禁用
/// </summary>
public class BackgroundScheduler
{
private readonly CancellationTokenSource _cts = new();
private readonly DateTime _bootTime = DateTime.Now;
private readonly Dictionary<long, DateTime> _lastRunMap = new();
private readonly HashSet<long> _runningIds = new();
private readonly SemaphoreSlim _semaphore;
private readonly int _scanIntervalMs;
private readonly TaskRepo _taskRepo = new();
private readonly TaskSchedulerService _service = new();
private readonly object _lock = new();
/// <summary>
/// 创建调度线程(从全局配置读取参数)
/// </summary>
public BackgroundScheduler()
{
_scanIntervalMs = AppConfig.Cfg.Scheduler.ScanIntervalMs;
_semaphore = new SemaphoreSlim(AppConfig.Cfg.Scheduler.ConcurrentMax);
}
/// <summary>
/// 启动调度主循环(后台任务,不阻塞)
/// </summary>
public void Start()
{
_ = Task.Run(RunLoopAsync);
AppLogger.Info($"调度主线程启动,扫描间隔={_scanIntervalMs}ms,最大并发={AppConfig.Cfg.Scheduler.ConcurrentMax}");
}
/// <summary>
/// 停止调度
/// </summary>
public void Stop()
{
_cts.Cancel();
AppLogger.Info("调度主线程已退出");
}
/// <summary>
/// 调度主循环
///
/// </summary>
private async Task RunLoopAsync()
{
try
{
while (!_cts.IsCancellationRequested)
{
ScanOnce(DateTime.Now);
await Task.Delay(_scanIntervalMs, _cts.Token);
}
}
catch (OperationCanceledException)
{
// 正常停止
}
}
/// <summary>
/// 单次调度扫描
/// </summary>
/// <param name="now"></param>
private void ScanOnce(DateTime now)
{
// 1. 获取启用任务并筛选满足cron条件的
var pending = new List<TaskEntity>();
foreach (var task in _taskRepo.GetEnabledList())
{
if (CanRunTask(task, now))
{
pending.Add(task);
}
}
if (pending.Count == 0)
{
return;
}
// 2. 并发执行(每个任务独立线程)
foreach (var task in pending)
{
if (!MarkRunning(task.TaskId))
{
continue; // 已被其他协程占用
}
_ = Task.Run(async () =>
{
await _semaphore.WaitAsync();
try
{
await WorkerAsync(task);
}
finally
{
_semaphore.Release();
UnmarkRunning(task.TaskId);
}
});
}
}
/// <summary>
/// 判断任务当前是否应执行
/// </summary>
/// <param name="task"></param>
/// <param name="now"></param>
/// <returns></returns>
private bool CanRunTask(TaskEntity task, DateTime now)
{
if (!task.IsEnable || IsRunning(task.TaskId))
{
return false;
}
// 基准时间:上次执行时间,否则启动时间
DateTime baseTime;
lock (_lock)
{
baseTime = _lastRunMap.TryGetValue(task.TaskId, out var last) ? last : _bootTime;
}
try
{
var next = CronExpression.Parse(task.CronExpr).GetNext(baseTime);
return next.HasValue && now >= next.Value;
}
catch (Exception ex)
{
AppLogger.Error($"cron表达式解析失败 task_id={task.TaskId} cron={task.CronExpr} err={ex.Message}");
return false;
}
}
/// <summary>
/// 任务是否运行中
/// </summary>
/// <param name="taskId"></param>
/// <returns></returns>
private bool IsRunning(long taskId)
{
lock (_lock)
{
return _runningIds.Contains(taskId);
}
}
/// <summary>
/// 标记任务运行中,返回是否成功占用
/// </summary>
/// <param name="taskId"></param>
/// <returns></returns>
private bool MarkRunning(long taskId)
{
lock (_lock)
{
if (_runningIds.Contains(taskId))
{
return false;
}
_runningIds.Add(taskId);
return true;
}
}
/// <summary>
/// 释放任务运行标记
/// </summary>
/// <param name="taskId"></param>
private void UnmarkRunning(long taskId)
{
lock (_lock)
{
_runningIds.Remove(taskId);
}
}
/// <summary>
/// 单个任务工作协程
/// </summary>
/// <param name="task"></param>
/// <returns></returns>
private async Task WorkerAsync(TaskEntity task)
{
try
{
// 1. 更新状态 RUNNING
_taskRepo.UpdateStatus(task.TaskId, TaskStatus.Running);
// 2. 执行任务(子进程 + 重试 + 写历史)
_service.ExecuteSingleTask(task);
// 3. 恢复状态 IDLE
_taskRepo.UpdateStatus(task.TaskId, TaskStatus.Idle);
// 4. 记录本次执行时间
lock (_lock)
{
_lastRunMap[task.TaskId] = DateTime.Now;
}
// 5. 一次性任务执行成功后自动禁用(真正"只执行一次")
if (task.IsOneTime)
{
_taskRepo.UpdateEnable(task.TaskId, false);
AppLogger.Info($"一次性任务 task_id={task.TaskId}【{task.TaskName}】执行完成,已自动禁用,不再调度");
}
}
catch (Exception ex)
{
AppLogger.Error($"任务执行异常 task_id={task.TaskId}: {ex}");
try
{
_taskRepo.UpdateStatus(task.TaskId, TaskStatus.Idle);
}
catch
{
// 忽略状态恢复失败
}
}
await Task.CompletedTask;
}
}
/*
encoding: utf-8
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述:Taskscheduler
Author : geovindu,Geovin Du 涂聚文.
IDE : vs2026 c# .net 10
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/08/28 22:16
User : geovindu
Product : Visual Studio 2026
Project : Taskscheduler
File : CronExpression.cs
*/
namespace Taskscheduler.Scheduler;
using System.Globalization;
/// <summary>
/// 标准5字段cron表达式(分 时 日 月 周)
/// 自实现解析与下次触发计算,行为对齐 robfig/cron(Go版):
/// - 支持 、数字、列表(1,2,3)、范围(1-5)、步长(/5、1-10/2)
/// - 日字段与周字段:任一为时只按另一个匹配;均非时按"或"匹配
/// - 周:0=周日,7也视为周日
/// </summary>
public class CronExpression
{
private readonly int[] _minutes;
private readonly int[] _hours;
private readonly int[] _daysOfMonth;
private readonly int[] _months;
private readonly int[] _daysOfWeek;
private readonly bool _domStar;
private readonly bool _dowStar;
/// <summary>
///
/// </summary>
/// <param name="minutes"></param>
/// <param name="hours"></param>
/// <param name="daysOfMonth"></param>
/// <param name="months"></param>
/// <param name="daysOfWeek"></param>
/// <param name="domStar"></param>
/// <param name="dowStar"></param>
private CronExpression(int[] minutes, int[] hours, int[] daysOfMonth, int[] months,
int[] daysOfWeek, bool domStar, bool dowStar)
{
_minutes = minutes;
_hours = hours;
_daysOfMonth = daysOfMonth;
_months = months;
_daysOfWeek = daysOfWeek;
_domStar = domStar;
_dowStar = dowStar;
}
/// <summary>
/// 解析标准5字段cron表达式
/// </summary>
public static CronExpression Parse(string expr)
{
var parts = expr.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 5)
{
throw new ArgumentException($"cron表达式必须为5段(分 时 日 月 周): {expr}");
}
var minutes = ParseField(parts[0], 0, 59);
var hours = ParseField(parts[1], 0, 23);
var dom = ParseField(parts[2], 1, 31);
var months = ParseField(parts[3], 1, 12);
var dow = ParseField(parts[4], 0, 7)
.Select(d => d % 7) // 7=周日转0
.Distinct()
.OrderBy(d => d)
.ToArray();
return new CronExpression(minutes, hours, dom, months, dow,
parts[2] == "*", parts[4] == "*");
}
/// <summary>
/// 解析单个字段为允许值集合
/// </summary>
private static int[] ParseField(string field, int min, int max)
{
var values = new SortedSet<int>();
foreach (var part in field.Split(','))
{
var step = 1;
var range = part;
var slashIdx = part.IndexOf('/');
if (slashIdx >= 0)
{
step = int.Parse(part[(slashIdx + 1)..], CultureInfo.InvariantCulture);
range = part[..slashIdx];
}
int start, end;
if (range == "*")
{
start = min;
end = max;
}
else
{
var dashIdx = range.IndexOf('-');
if (dashIdx >= 0)
{
start = int.Parse(range[..dashIdx], CultureInfo.InvariantCulture);
end = int.Parse(range[(dashIdx + 1)..], CultureInfo.InvariantCulture);
}
else
{
start = end = int.Parse(range, CultureInfo.InvariantCulture);
}
}
for (var v = start; v <= end; v += step)
{
if (v >= min && v <= max)
{
values.Add(v);
}
}
}
if (values.Count == 0)
{
throw new ArgumentException($"cron字段无有效值: {field}");
}
return values.ToArray();
}
/// <summary>
/// 计算从from之后的下一次触发时间(不含from本身)
/// 标准5字段cron匹配时刻秒恒为0,因此从"严格大于from的下一整分钟"开始扫描
/// </summary>
public DateTime? GetNext(DateTime from)
{
// 取严格大于from的下一整分钟(秒=0)
var t = from.AddSeconds(1);
t = new DateTime(t.Year, t.Month, t.Day, t.Hour, t.Minute, 0);
if (t <= from)
{
t = t.AddMinutes(1);
}
// 最多查找8年,避免死循环
for (var guard = 0; guard < 8 * 366; guard++)
{
// 月份不匹配 → 跳到下月1日
if (!_months.Contains(t.Month))
{
t = new DateTime(t.Year, t.Month, 1).AddMonths(1);
continue;
}
// 日期不匹配 → 跳到次日
if (!DayMatches(t))
{
t = t.Date.AddDays(1);
continue;
}
// 小时不匹配 → 跳到本日下一匹配小时,无则次日
if (!_hours.Contains(t.Hour))
{
var nextHour = _hours.Where(h => h > t.Hour).DefaultIfEmpty(-1).First();
t = nextHour >= 0
? new DateTime(t.Year, t.Month, t.Day, nextHour, 0, 0)
: t.Date.AddDays(1);
continue;
}
// 分钟不匹配 → 跳到本小时下一匹配分钟,无则下一小时
if (!_minutes.Contains(t.Minute))
{
var nextMin = _minutes.Where(m => m > t.Minute).DefaultIfEmpty(-1).First();
t = nextMin >= 0
? new DateTime(t.Year, t.Month, t.Day, t.Hour, nextMin, 0)
: new DateTime(t.Year, t.Month, t.Day, t.Hour, 0, 0).AddHours(1);
continue;
}
return t;
}
return null;
}
/// <summary>
/// 日期是否匹配(日/周字段规则)
/// </summary>
private bool DayMatches(DateTime t)
{
if (_domStar && _dowStar)
{
return true;
}
var domOk = _daysOfMonth.Contains(t.Day);
var dowOk = _daysOfWeek.Contains((int)t.DayOfWeek);
return _domStar ? dowOk : _dowStar ? domOk : domOk || dowOk;
}
}
/*
encoding: utf-8
版权所有 2026 ©涂聚文有限公司™ ®
许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
描述:Taskscheduler
Author : geovindu,Geovin Du 涂聚文.
IDE : vs2026 c# .net 10
os : windows 10
database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
Datetime : 2026/08/28 22:16
User : geovindu
Product : Visual Studio 2026
Project : Taskscheduler
File : Db.cs
*/
namespace Taskscheduler.Storage;
using Microsoft.Data.Sqlite;
/// <summary>
/// SQLite数据库层(对应Go版 storage/db.go)
/// 职责:建表、连接工厂、时间格式化
/// 说明:每次操作独立连接(WAL模式支持并发读写),简单可靠
/// </summary>
public static class Db
{
/// <summary>
/// 数据库文件路径(当前工作目录
/// )</summary>
public static string DbFile { get; private set; } = "task_scheduler.db";
/// <summary>
/// 数据库已初始化标志
/// </summary>
public static bool IsInitialized { get; private set; }
/// <summary>
/// 初始化数据库:创建连接目录、建表
/// </summary>
public static void Init(string dbFile)
{
DbFile = dbFile;
// 确保数据库所在目录存在
var dir = Path.GetDirectoryName(Path.GetFullPath(dbFile));
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
using var conn = OpenConnection();
// 建任务表
Exec(conn, """
CREATE TABLE IF NOT EXISTS t_task (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_name TEXT NOT NULL,
cron_expr TEXT NOT NULL,
command TEXT NOT NULL,
timeout_sec INTEGER NOT NULL DEFAULT 300,
max_retry INTEGER NOT NULL DEFAULT 0,
retry_gap_sec INTEGER NOT NULL DEFAULT 10,
is_enable INTEGER NOT NULL DEFAULT 1,
remark TEXT DEFAULT '',
task_status TEXT NOT NULL DEFAULT 'IDLE'
)
""");
// 建执行历史表
Exec(conn, """
CREATE TABLE IF NOT EXISTS t_task_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
execute_start_time TEXT NOT NULL,
execute_end_time TEXT NOT NULL,
is_success INTEGER NOT NULL,
execute_msg TEXT DEFAULT ''
)
""");
// 索引:按任务查历史
Exec(conn, "CREATE INDEX IF NOT EXISTS idx_history_task_id ON t_task_history(task_id)");
IsInitialized = true;
}
/// <summary>
/// 打开新的数据库连接(WAL模式)
/// </summary>
public static SqliteConnection OpenConnection()
{
var conn = new SqliteConnection($"Data Source={DbFile}");
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA journal_mode=WAL";
cmd.ExecuteNonQuery();
return conn;
}
/// <summary>
/// 执行非查询SQL
/// </summary>
public static void Exec(SqliteConnection conn, string sql, params (string Name, object Value)[] args)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
foreach (var (name, value) in args)
{
cmd.Parameters.AddWithValue(name, value);
}
cmd.ExecuteNonQuery();
}
/// <summary>
/// 格式化时间为数据库存储格式
/// </summary>
public static string FormatTime(DateTime dt) => dt.ToString("yyyy-MM-dd HH:mm:ss");
/// <summary>
/// 解析数据库时间字符串
/// </summary>
public static DateTime ParseTime(string s) =>
DateTime.TryParse(s, out var dt) ? dt : DateTime.MinValue;
}
cs
/*
# encoding: utf-8
# 版权所有 2026 ©涂聚文有限公司™ ®
# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
# 描述:Taskscheduler
# Author : geovindu,Geovin Du 涂聚文.
# IDE : vs2026 c# .net 10
# os : windows 10
# database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
# Datetime : 2026/08/28 22:16
# User : geovindu
# Product : Visual Studio 2026
# Project : Taskscheduler
# File : TaskSchedulerService.cs
*/
namespace Taskscheduler.Application;
using Taskscheduler.Domain;
using Taskscheduler.Repository;
using Taskscheduler.Runner;
using Taskscheduler.Storage;
/// <summary>
/// 应用层业务服务:任务执行入口(对应Go版 application/task_scheduler_service.go)
/// 职责:调用子进程执行命令、失败重试、组装历史实体、写入历史仓储
/// </summary>
public class TaskSchedulerService
{
private readonly TaskHistoryRepo _historyRepo = new();
/// <summary>
/// 执行单个任务(手动/调度共用)
/// 流程:子进程执行 → 失败按MaxRetry/RetryGapSec重试 → 写入执行历史
/// </summary>
public TaskHistoryEntity? ExecuteSingleTask(TaskEntity task)
{
Taskscheduler.Logger.AppLogger.Info($"开始执行任务 task_id={task.TaskId} 任务名称={task.TaskName}");
var startTime = DateTime.Now;
var runner = new SubprocessRunner(task.TimeoutSec);
var finalSuccess = false;
var finalMsg = "";
var currentRetry = 0;
// ---------- 执行 + 重试循环 ----------
while (true)
{
var result = runner.Run(task.TaskId, task.Command);
if (result.Success)
{
finalSuccess = true;
finalMsg = result.Output;
break;
}
finalMsg = result.Output;
currentRetry++;
if (currentRetry > task.MaxRetry)
{
break;
}
Taskscheduler.Logger.AppLogger.Info(
$"task_id={task.TaskId} 执行失败,第{currentRetry}次重试,间隔{task.RetryGapSec}秒");
Thread.Sleep(task.RetryGapSec * 1000);
}
var endTime = DateTime.Now;
// ---------- 组装并写入历史 ----------
var history = new TaskHistoryEntity
{
TaskId = task.TaskId,
ExecuteStartTime = startTime,
ExecuteEndTime = endTime,
IsSuccess = finalSuccess,
ExecuteMsg = finalMsg,
};
var saved = _historyRepo.Insert(history);
if (finalSuccess)
{
Taskscheduler.Logger.AppLogger.Info($"任务执行成功 task_id={task.TaskId}");
}
else
{
Taskscheduler.Logger.AppLogger.Error(
$"任务执行失败 task_id={task.TaskId},重试次数={currentRetry},output={finalMsg}");
}
return saved;
}
}
/*
# encoding: utf-8
# 版权所有 2026 ©涂聚文有限公司™ ®
# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
# 描述:Taskscheduler
# Author : geovindu,Geovin Du 涂聚文.
# IDE : vs2026 c# .net 10
# os : windows 10
# database : mysql 9.0 sql server 2019, postgreSQL 17.0 Oracle 21c Neo4j
# Datetime : 2026/08/28 22:16
# User : geovindu
# Product : Visual Studio 2026
# Project : Taskscheduler
# File : Program.cs
*/
namespace Taskscheduler.Cli;
using Taskscheduler.Config;
using Taskscheduler.Domain;
using Taskscheduler.JsonIO;
using Taskscheduler.Logger;
using Taskscheduler.Repository;
using Taskscheduler.Runner;
using Taskscheduler.Scheduler;
using Taskscheduler.Storage;
/// <summary>
/// CLI入口(对应Go版 main.go,命令一致)
/// 用法:
/// list 列出全部任务
/// add "名称" "cron" "命令" [--timeout 300] [--retry 0] [--gap 10]
/// del ID 删除任务
/// enable ID true|false 启用/禁用任务
/// runonce ID 立即执行一次任务
/// run 启动调度守护(阻塞)
/// history ID 查看任务执行历史
/// import 文件.json 从JSON导入任务
/// export 文件.json 导出全部任务到JSON
/// </summary>
public static class Program
{
public static int Main(string[] args)
{
// ---------- 初始化 ----------
var cfg = ConfigLoader.Load("config/app.yaml");
AppLogger.Init(cfg.Log.LogDir);
Db.Init(cfg.Database.DbFile);
if (args.Length == 0)
{
PrintUsage();
return 0;
}
var command = args[0].ToLowerInvariant();
switch (command)
{
case "list":
return ListTasks();
case "add":
return AddTask(args);
case "del":
return DeleteTask(args);
case "enable":
return EnableTask(args);
case "runonce":
return RunOnce(args);
case "run":
return RunDaemon();
case "history":
return ShowHistory(args);
case "import":
return Import(args);
case "export":
return Export(args);
default:
Console.WriteLine($"未知命令: {command}");
PrintUsage();
return 1;
}
}
/// <summary>
/// 列出任务
/// </summary>
private static int ListTasks()
{
var list = new TaskRepo().GetList();
Console.WriteLine($"{"ID",-5} {"任务名称",-16} {"Cron表达式",-16} {"状态",-8} {"启用",-5} 备注");
Console.WriteLine(new string('-', 90));
foreach (var t in list)
{
Console.WriteLine($"{t.TaskId,-5} {t.TaskName,-16} {t.CronExpr,-16} {t.TaskStatus,-8} {(t.IsEnable ? "是" : "否"),-5} {Truncate(t.Remark, 28)}");
}
Console.WriteLine($"共 {list.Count} 条任务");
return 0;
}
/// <summary>
/// 新增任务
/// </summary>
private static int AddTask(string[] args)
{
if (args.Length < 4)
{
Console.WriteLine("用法: add \"任务名称\" \"cron表达式\" \"执行命令\" [--timeout 300] [--retry 0] [--gap 10] [--remark 备注]");
return 1;
}
var entity = new TaskEntity
{
TaskName = args[1],
CronExpr = args[2],
Command = args[3],
TimeoutSec = 300,
MaxRetry = 0,
RetryGapSec = 10,
IsEnable = true,
Remark = "",
};
// 解析可选参数
for (var i = 4; i < args.Length; i++)
{
switch (args[i].ToLowerInvariant())
{
case "--timeout" when i + 1 < args.Length:
entity.TimeoutSec = int.Parse(args[++i]);
break;
case "--retry" when i + 1 < args.Length:
entity.MaxRetry = int.Parse(args[++i]);
break;
case "--gap" when i + 1 < args.Length:
entity.RetryGapSec = int.Parse(args[++i]);
break;
case "--remark" when i + 1 < args.Length:
entity.Remark = args[++i];
break;
}
}
var saved = new TaskRepo().Save(entity);
AppLogger.Info($"任务已新增 ID={saved.TaskId}");
Console.WriteLine($"任务已新增 ID={saved.TaskId}");
return 0;
}
/// <summary>
/// 删除任务
/// </summary>
private static int DeleteTask(string[] args)
{
if (args.Length < 2 || !long.TryParse(args[1], out var id))
{
Console.WriteLine("用法: del ID");
return 1;
}
if (new TaskRepo().DeleteById(id))
{
AppLogger.Info($"任务 {id} 已删除");
Console.WriteLine($"任务 {id} 已删除");
return 0;
}
Console.WriteLine($"任务 {id} 不存在");
return 1;
}
/// <summary>
/// 启用/禁用任务
/// </summary>
private static int EnableTask(string[] args)
{
if (args.Length < 3 || !long.TryParse(args[1], out var id) || !bool.TryParse(args[2], out var enable))
{
Console.WriteLine("用法: enable ID true|false");
return 1;
}
if (new TaskRepo().UpdateEnable(id, enable))
{
Console.WriteLine($"任务 {id} 已{(enable ? "启用" : "禁用")}");
return 0;
}
Console.WriteLine($"任务 {id} 不存在");
return 1;
}
/// <summary>
/// 立即执行任务一次
/// </summary>
private static int RunOnce(string[] args)
{
if (args.Length < 2 || !long.TryParse(args[1], out var id))
{
Console.WriteLine("用法: runonce ID");
return 1;
}
var task = new TaskRepo().GetById(id);
if (task == null)
{
Console.WriteLine($"任务 {id} 不存在");
return 1;
}
var sw = System.Diagnostics.Stopwatch.StartNew();
var history = new Application.TaskSchedulerService().ExecuteSingleTask(task);
sw.Stop();
if (history != null)
{
Console.WriteLine($"任务 {id}【{task.TaskName}】{(history.IsSuccess ? "执行成功" : "执行失败")},耗时{sw.ElapsedMilliseconds}ms,历史ID={history.HistoryId}");
}
// 一次性任务执行后自动禁用(与调度线程一致)
if (task.IsOneTime)
{
new TaskRepo().UpdateEnable(id, false);
AppLogger.Info($"一次性任务 {id} 已自动禁用");
}
return history is { IsSuccess: true } ? 0 : 1;
}
/// <summary>
/// 启动调度守护(阻塞)
/// </summary>
private static int RunDaemon()
{
var scheduler = new BackgroundScheduler();
scheduler.Start();
AppLogger.Info("调度守护已启动,Ctrl+C 退出");
var exit = new ManualResetEventSlim(false);
Console.CancelKeyPress += (_, e) => { e.Cancel = true; exit.Set(); };
exit.Wait();
scheduler.Stop();
return 0;
}
/// <summary>
/// 查看任务执行历史
/// </summary>
private static int ShowHistory(string[] args)
{
if (args.Length < 2 || !long.TryParse(args[1], out var id))
{
Console.WriteLine("用法: history ID");
return 1;
}
var list = new TaskHistoryRepo().ListByTaskId(id, 50);
Console.WriteLine($"{"历史ID",-8} {"执行时间",-20} {"结果",-5} 输出摘要");
Console.WriteLine(new string('-', 80));
foreach (var h in list)
{
Console.WriteLine($"{h.HistoryId,-8} {h.ExecuteStartTime,-20:yyyy-MM-dd HH:mm:ss} {(h.IsSuccess ? "成功" : "失败"),-5} {Truncate(h.ExecuteMsg, 40)}");
}
return 0;
}
/// <summary>
/// 导入任务
/// </summary>
private static int Import(string[] args)
{
if (args.Length < 2)
{
Console.WriteLine("用法: import 文件.json");
return 1;
}
try
{
var stat = new TaskJsonIO().ImportFromFile(args[1]);
Console.WriteLine($"导入完成:总条数={stat.Total} 新增={stat.Insert} 更新={stat.Update} 跳过={stat.Skip}");
return 0;
}
catch (Exception ex)
{
Console.WriteLine($"导入失败: {ex.Message}");
return 1;
}
}
/// <summary>
/// 导出任务
/// </summary>
private static int Export(string[] args)
{
if (args.Length < 2)
{
Console.WriteLine("用法: export 文件.json");
return 1;
}
try
{
new TaskJsonIO().ExportAll(args[1]);
Console.WriteLine($"导出完成 → {args[1]}");
return 0;
}
catch (Exception ex)
{
Console.WriteLine($"导出失败: {ex.Message}");
return 1;
}
}
/// <summary>
/// 截断长文本
/// </summary>
private static string Truncate(string s, int max)
{
if (string.IsNullOrEmpty(s) || s.Length <= max)
{
return s ?? "";
}
return s[..max] + "...";
}
/// <summary>
/// 打印用法
/// </summary>
private static void PrintUsage()
{
Console.WriteLine("""
TaskScheduler 定时任务调度器 (C# .NET 10 版)
用法:
list 列出全部任务
add 名称 cron 命令 [--timeout 300] [--retry 0] [--gap 10] [--remark 备注]
del ID 删除任务
enable ID true|false 启用/禁用任务
runonce ID 立即执行一次任务
run 启动调度守护
history ID 查看执行历史
import 文件.json 导入任务
export 文件.json 导出任务
""");
}
}
调用:
cs
namespace Taskscheduler.Gui;
using System.Drawing.Drawing2D;
using Taskscheduler.Domain;
using Taskscheduler.JsonIO;
using Taskscheduler.Logger;
using Taskscheduler.Repository;
using Taskscheduler.Scheduler;
/// <summary>
/// 主窗体(对应Go版 gui/main.go + dashboard_view.go 合并)
/// 布局:工具栏 + Notebook多标签页(任务列表 / 仪表盘)+ 底部实时日志 + 托盘最小化
/// 任务列表支持:关键字查询分页(首页/上一页/下一页/末页/每页条数/总条数/总页数)
/// </summary>
public class MainForm : Form
{
// ---------- 核心组件 ----------
private readonly TaskRepo _taskRepo = new();
private readonly TaskHistoryRepo _historyRepo = new();
private readonly BackgroundScheduler _scheduler = new();
// ---------- UI组件 ----------
private readonly TabControl _tabs = new();
private readonly DataGridView _gridTasks = new();
private readonly DataGridView _gridRecent = new();
private readonly RichTextBox _txtLog = new();
private readonly NotifyIcon _notifyIcon = new();
private readonly System.Windows.Forms.Timer _refreshTimer = new();
// 仪表盘统计卡片
private readonly Label _lblTotal = new();
private readonly Label _lblToday = new();
private readonly Label _lblSuccess = new();
private readonly Label _lblFail = new();
private readonly DailyChartControl _chart = new();
// ---------- 任务列表分页与查询 ----------
private TabPage? _tabTasks;
private int _page = 1;
private int _pageSize = 10;
private long _totalCount;
private int _totalPages = 1;
private string _keyword = "";
private readonly TextBox _txtSearch = new();
private readonly ComboBox _cboPageSize = new();
private readonly Label _lblPageInfo = new();
private readonly Label _lblTotalInfo = new();
private readonly Button _btnFirst = new();
private readonly Button _btnPrev = new();
private readonly Button _btnNext = new();
private readonly Button _btnLast = new();
private bool _exiting;
/// <summary>
/// 主窗体构造函数
/// </summary>
public MainForm()
{
Text = "TaskScheduler 定时任务调度器 (.NET 10)";
ClientSize = new Size(1080, 700);
StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Microsoft YaHei UI", 9.5f);
BuildUi();
SetupTray();
SetupRefreshTimer();
SubscribeLogs();
LoadTasksPage();
RefreshDashboard();
_scheduler.Start();
AppLogger.Info("主界面初始化完成,调度已启动");
}
// ==================== UI构建 ====================
/// <summary>
/// 构建主界面
/// </summary>
private void BuildUi()
{
// ---------- 工具栏 ----------
var toolStrip = new ToolStrip { GripStyle = ToolStripGripStyle.Hidden, Padding = new Padding(6, 2, 6, 2) };
toolStrip.Items.Add(MakeButton("新增任务", (_, _) => AddTask()));
toolStrip.Items.Add(MakeButton("编辑任务", (_, _) => EditTask()));
toolStrip.Items.Add(MakeButton("删除任务", (_, _) => DeleteTask()));
toolStrip.Items.Add(MakeButton("立即执行", (_, _) => RunOnce()));
toolStrip.Items.Add(MakeButton("执行历史", (_, _) => ShowHistory()));
toolStrip.Items.Add(new ToolStripSeparator());
toolStrip.Items.Add(MakeButton("导入", (_, _) => ImportTasks()));
toolStrip.Items.Add(MakeButton("导出", (_, _) => ExportTasks()));
toolStrip.Items.Add(new ToolStripSeparator());
toolStrip.Items.Add(MakeButton("任务列表", (_, _) => _tabs.SelectedIndex = 0));
toolStrip.Items.Add(MakeButton("仪表盘", (_, _) => _tabs.SelectedIndex = 1));
toolStrip.Items.Add(MakeButton("刷新", (_, _) => { LoadTasksPage(); RefreshDashboard(); }));
Controls.Add(toolStrip);
// ---------- 多标签页(三个Tab:任务列表 / 仪表盘 / 执行日志) ----------
_tabs.Dock = DockStyle.Fill;
Controls.Add(_tabs);
// Tab1 任务列表
_tabTasks = new TabPage("任务列表");
BuildTaskListTab(_tabTasks);
_tabs.TabPages.Add(_tabTasks);
// Tab2 仪表盘
var tabDash = new TabPage("仪表盘");
BuildDashboardTab(tabDash);
_tabs.TabPages.Add(tabDash);
// Tab3 执行日志(独立页签,替代原底部固定日志框)
var tabLog = new TabPage("执行日志");
BuildLogTab(tabLog);
_tabs.TabPages.Add(tabLog);
// 关闭时最小化到托盘
FormClosing += OnFormClosing;
}
/// <summary>
/// 构建执行日志页(清空按钮 + 实时日志框)
/// </summary>
private void BuildLogTab(TabPage page)
{
// 顶部工具栏:清空日志
var logToolbar = new FlowLayoutPanel
{
Dock = DockStyle.Top,
Height = 42,
Padding = new Padding(10, 7, 0, 0),
WrapContents = false,
};
var btnClearLog = new Button { Text = "清空日志", Width = 90, Margin = new Padding(0, 0, 6, 0) };
btnClearLog.Click += (_, _) =>
{
_txtLog.Clear();
};
var logTip = new Label
{
Text = "实时输出运行日志,滚动文件按 5MB 自动切割保留 10 份(logs\\app.log)",
AutoSize = true,
ForeColor = Color.Gray,
Margin = new Padding(10, 6, 0, 0),
};
logToolbar.Controls.Add(btnClearLog);
logToolbar.Controls.Add(logTip);
page.Controls.Add(logToolbar);
// 日志框(占满整页)
_txtLog.Dock = DockStyle.Fill;
_txtLog.ReadOnly = true;
_txtLog.BackColor = Color.FromArgb(30, 30, 30);
_txtLog.ForeColor = Color.FromArgb(220, 220, 220);
_txtLog.Font = new Font("Consolas", 9f);
_txtLog.BorderStyle = BorderStyle.FixedSingle;
page.Controls.Add(_txtLog);
}
/// <summary>
/// 构建任务列表页(查询栏 + 表格 + 分页栏)
/// </summary>
private void BuildTaskListTab(TabPage page)
{
// ---------- 顶部查询栏 ----------
var searchPanel = new FlowLayoutPanel
{
Dock = DockStyle.Top,
Height = 42,
Padding = new Padding(10, 8, 0, 0),
WrapContents = false,
};
var searchLabel = new Label { Text = "搜索:", AutoSize = true, Margin = new Padding(0, 6, 0, 0) };
_txtSearch.Width = 220;
_txtSearch.Margin = new Padding(0, 4, 0, 0);
_txtSearch.KeyDown += (_, e) =>
{
if (e.KeyCode == Keys.Enter)
{
ApplySearch();
}
};
var btnSearch = new Button { Text = "查询", Width = 70, Margin = new Padding(6, 3, 0, 0) };
btnSearch.Click += (_, _) => ApplySearch();
var btnReset = new Button { Text = "重置", Width = 70, Margin = new Padding(6, 3, 0, 0) };
btnReset.Click += (_, _) =>
{
_txtSearch.Clear();
_keyword = "";
_page = 1;
LoadTasksPage();
};
searchPanel.Controls.Add(searchLabel);
searchPanel.Controls.Add(_txtSearch);
searchPanel.Controls.Add(btnSearch);
searchPanel.Controls.Add(btnReset);
page.Controls.Add(searchPanel);
// ---------- 底部查询栏 ----------
var pagerPanel = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
Height = 40,
Padding = new Padding(10, 5, 0, 0),
WrapContents = false,
};
_btnFirst.Text = "首页";
_btnPrev.Text = "上一页";
_btnNext.Text = "下一页";
_btnLast.Text = "末页";
foreach (var btn in new[] { _btnFirst, _btnPrev, _btnNext, _btnLast })
{
btn.Width = 74;
btn.Margin = new Padding(0, 0, 6, 0);
btn.Click += (_, _) => OnPageButton(btn);
}
_lblPageInfo.AutoSize = true;
_lblPageInfo.Margin = new Padding(8, 6, 0, 0);
_lblTotalInfo.AutoSize = true;
_lblTotalInfo.Margin = new Padding(8, 6, 0, 0);
var sizeLabel = new Label { Text = "每页", AutoSize = true, Margin = new Padding(12, 6, 0, 0) };
_cboPageSize.Items.AddRange(new object[] { 10, 20, 50, 100 });
_cboPageSize.SelectedItem = 10;
_cboPageSize.Width = 64;
_cboPageSize.Margin = new Padding(4, 4, 0, 0);
_cboPageSize.SelectedIndexChanged += (_, _) =>
{
if (_cboPageSize.SelectedItem is int n && n > 0)
{
_pageSize = n;
_page = 1;
LoadTasksPage();
}
};
var sizeLabel2 = new Label { Text = "条", AutoSize = true, Margin = new Padding(4, 6, 0, 0) };
pagerPanel.Controls.Add(_btnFirst);
pagerPanel.Controls.Add(_btnPrev);
pagerPanel.Controls.Add(_lblPageInfo);
pagerPanel.Controls.Add(_btnNext);
pagerPanel.Controls.Add(_btnLast);
pagerPanel.Controls.Add(_lblTotalInfo);
pagerPanel.Controls.Add(sizeLabel);
pagerPanel.Controls.Add(_cboPageSize);
pagerPanel.Controls.Add(sizeLabel2);
page.Controls.Add(pagerPanel);
// ---------- 任务表格 ----------
_gridTasks.Dock = DockStyle.Fill;
_gridTasks.ReadOnly = true;
_gridTasks.AllowUserToAddRows = false;
_gridTasks.RowHeadersVisible = false;
_gridTasks.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill;
_gridTasks.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
_gridTasks.BackgroundColor = Color.White;
_gridTasks.CellDoubleClick += (_, _) => ShowHistory();
_gridTasks.Columns.Add("Id", "ID");
_gridTasks.Columns.Add("Name", "任务名称");
_gridTasks.Columns.Add("Cron", "Cron表达式");
_gridTasks.Columns.Add("Cmd", "执行命令");
_gridTasks.Columns.Add("Timeout", "超时(秒)");
_gridTasks.Columns.Add("Status", "状态");
_gridTasks.Columns.Add("Enable", "启用");
_gridTasks.Columns.Add("Next", "下次执行");
_gridTasks.Columns.Add("Remark", "备注");
_gridTasks.Columns["Id"]!.Width = 50;
_gridTasks.Columns["Name"]!.Width = 120;
_gridTasks.Columns["Cron"]!.Width = 110;
_gridTasks.Columns["Cmd"]!.FillWeight = 140;
_gridTasks.Columns["Timeout"]!.Width = 70;
_gridTasks.Columns["Status"]!.Width = 60;
_gridTasks.Columns["Enable"]!.Width = 50;
_gridTasks.Columns["Next"]!.Width = 140;
_gridTasks.Columns["Remark"]!.FillWeight = 120;
page.Controls.Add(_gridTasks);
var hint = new Label
{
Text = "双击任务行查看执行历史 | 备注含"一次性"的任务执行成功后自动禁用 | 搜索支持任务名称/备注关键字",
Dock = DockStyle.Bottom,
Height = 24,
ForeColor = Color.Gray,
TextAlign = ContentAlignment.MiddleLeft,
Padding = new Padding(8, 0, 0, 0),
};
page.Controls.Add(hint);
// 切回任务列表页时刷新当前页(保留页码)
_tabs.SelectedIndexChanged += (_, _) =>
{
if (_tabs.SelectedTab == _tabTasks)
{
LoadTasksPage();
}
};
}
/// <summary>
/// 应用搜索条件并回到第1页
/// </summary>
private void ApplySearch()
{
_keyword = _txtSearch.Text.Trim();
_page = 1;
LoadTasksPage();
}
/// <summary>
/// 分页按钮统一事件
/// </summary>
private void OnPageButton(Button btn)
{
if (btn == _btnFirst)
{
_page = 1;
}
else if (btn == _btnPrev)
{
if (_page > 1)
{
_page--;
}
}
else if (btn == _btnNext)
{
if (_page < _totalPages)
{
_page++;
}
}
else if (btn == _btnLast)
{
_page = _totalPages;
}
LoadTasksPage();
}
/// <summary>
/// 构建仪表盘页
/// </summary>
private void BuildDashboardTab(TabPage page)
{
// ---------- 统计卡片 ----------
var cardPanel = new FlowLayoutPanel
{
Dock = DockStyle.Top,
Height = 92,
Padding = new Padding(10, 8, 10, 0),
WrapContents = true,
};
cardPanel.Controls.Add(MakeCard("任务总数", _lblTotal, Color.FromArgb(70, 130, 220)));
cardPanel.Controls.Add(MakeCard("今日执行", _lblToday, Color.FromArgb(80, 170, 110)));
cardPanel.Controls.Add(MakeCard("累计成功", _lblSuccess, Color.FromArgb(90, 180, 120)));
cardPanel.Controls.Add(MakeCard("累计失败", _lblFail, Color.FromArgb(210, 110, 100)));
page.Controls.Add(cardPanel);
// ---------- 最近记录 ----------
var recentPanel = new Panel { Dock = DockStyle.Fill };
_gridRecent.Dock = DockStyle.Fill;
_gridRecent.ReadOnly = true;
_gridRecent.AllowUserToAddRows = false;
_gridRecent.RowHeadersVisible = false;
_gridRecent.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill;
_gridRecent.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
_gridRecent.BackgroundColor = Color.White;
_gridRecent.Columns.Add("Id", "历史ID");
_gridRecent.Columns.Add("TaskId", "任务ID");
_gridRecent.Columns.Add("Time", "执行时间");
_gridRecent.Columns.Add("Result", "结果");
_gridRecent.Columns.Add("Msg", "输出摘要");
_gridRecent.Columns["Id"]!.Width = 70;
_gridRecent.Columns["TaskId"]!.Width = 70;
_gridRecent.Columns["Time"]!.Width = 150;
_gridRecent.Columns["Result"]!.Width = 60;
var recentLabel = new Label
{
Text = "最近执行记录(导入任务后尚未执行则暂无记录)",
Dock = DockStyle.Top,
Height = 28,
Font = new Font("Microsoft YaHei UI", 9.5f, FontStyle.Bold),
Padding = new Padding(8, 4, 0, 0),
ForeColor = Color.DimGray,
};
recentPanel.Controls.Add(_gridRecent);
recentPanel.Controls.Add(recentLabel);
page.Controls.Add(recentPanel);
// ---------- 柱状图(右下) ----------
var chartPanel = new Panel { Dock = DockStyle.Right, Width = 330 };
_chart.Dock = DockStyle.Fill;
chartPanel.Controls.Add(_chart);
page.Controls.Add(chartPanel);
}
/// <summary>
/// 创建统计卡片
/// </summary>
private static Panel MakeCard(string title, Label valueLabel, Color color)
{
var panel = new Panel
{
Size = new Size(150, 70),
Margin = new Padding(6),
BackColor = Color.White,
};
panel.Paint += (_, e) =>
{
using var pen = new Pen(color, 3);
e.Graphics.DrawRectangle(pen, 0, 0, panel.Width - 1, panel.Height - 1);
e.Graphics.FillRectangle(new SolidBrush(Color.FromArgb(18, color.R, color.G, color.B)), 0, 0, panel.Width, panel.Height);
};
var titleLabel = new Label
{
Text = title,
Dock = DockStyle.Top,
Height = 22,
TextAlign = ContentAlignment.MiddleCenter,
ForeColor = Color.Gray,
};
valueLabel.Text = "0";
valueLabel.Font = new Font("Microsoft YaHei UI", 15f, FontStyle.Bold);
valueLabel.ForeColor = color;
valueLabel.Dock = DockStyle.Fill;
valueLabel.TextAlign = ContentAlignment.MiddleCenter;
panel.Controls.Add(valueLabel);
panel.Controls.Add(titleLabel);
return panel;
}
/// <summary>
/// 创建工具栏按钮
/// </summary>
private static ToolStripButton MakeButton(string text, EventHandler onClick)
{
var btn = new ToolStripButton(text) { DisplayStyle = ToolStripItemDisplayStyle.Text };
btn.Click += onClick;
return btn;
}
// ==================== 数据刷新 ====================
/// <summary>
/// 加载当前页任务列表(分页+查询,含"下次执行"列计算)
/// </summary>
private void LoadTasksPage()
{
try
{
// 1. 统计总数与总页数
_totalCount = _taskRepo.Count(_keyword);
_totalPages = (int)Math.Max(1, (_totalCount + _pageSize - 1) / _pageSize);
// 2. 页码越界自动回退(如删除末页全部任务后)
if (_page > _totalPages)
{
_page = _totalPages;
}
var list = _taskRepo.SearchPage(_keyword, _page, _pageSize);
var now = DateTime.Now;
_gridTasks.SuspendLayout();
_gridTasks.Rows.Clear();
foreach (var t in list)
{
string next;
try
{
var n = CronExpression.Parse(t.CronExpr).GetNext(now);
next = n.HasValue ? n.Value.ToString("yyyy-MM-dd HH:mm:ss") : "-";
}
catch
{
next = "表达式无效";
}
var idx = _gridTasks.Rows.Add(
t.TaskId, t.TaskName, t.CronExpr, Truncate(t.Command, 40),
t.TimeoutSec, t.TaskStatus, t.IsEnable ? "是" : "否", next,
Truncate(t.Remark, 24));
// 运行中状态行高亮
if (t.TaskStatus == TaskStatus.Running)
{
_gridTasks.Rows[idx].DefaultCellStyle.BackColor = Color.FromArgb(255, 245, 220);
}
else if (!t.IsEnable)
{
_gridTasks.Rows[idx].DefaultCellStyle.ForeColor = Color.Gray;
}
}
_gridTasks.ResumeLayout();
UpdatePager();
}
catch (Exception ex)
{
AppLogger.Error($"刷新任务列表失败: {ex.Message}");
}
}
/// <summary>
/// 更新分页控件状态
/// </summary>
private void UpdatePager()
{
_btnFirst.Enabled = _page > 1;
_btnPrev.Enabled = _page > 1;
_btnNext.Enabled = _page < _totalPages;
_btnLast.Enabled = _page < _totalPages;
_lblPageInfo.Text = $"第 {_page}/{_totalPages} 页";
_lblTotalInfo.Text = $"共 {_totalCount} 条";
}
/// <summary>
/// 刷新仪表盘(统计卡片 + 柱状图 + 最近记录)
/// </summary>
private void RefreshDashboard()
{
try
{
// 卡片1:任务总数(真实任务数量)
_lblTotal.Text = _taskRepo.Count().ToString();
// 卡片2-4:执行统计
var (total, success, fail) = _historyRepo.CountStats();
_lblToday.Text = _historyRepo.CountToday().ToString();
_lblSuccess.Text = success.ToString();
_lblFail.Text = fail.ToString();
_chart.SetData(_historyRepo.CountByDay(7)
.Select(d => (d.Date, d.Count)).ToList());
var recent = _historyRepo.ListRecent(15);
_gridRecent.SuspendLayout();
_gridRecent.Rows.Clear();
foreach (var h in recent)
{
var idx = _gridRecent.Rows.Add(
h.HistoryId, h.TaskId,
h.ExecuteStartTime.ToString("yyyy-MM-dd HH:mm:ss"),
h.IsSuccess ? "成功" : "失败",
Truncate(h.ExecuteMsg, 50));
_gridRecent.Rows[idx].DefaultCellStyle.ForeColor = h.IsSuccess
? Color.FromArgb(40, 130, 70)
: Color.FromArgb(200, 70, 60);
}
_gridRecent.ResumeLayout();
}
catch (Exception ex)
{
AppLogger.Error($"刷新仪表盘失败: {ex.Message}");
}
}
// ==================== 工具栏动作 ====================
/// <summary>
/// 新增任务
/// </summary>
private void AddTask()
{
using var dialog = new TaskEditForm();
if (dialog.ShowDialog(this) == DialogResult.OK && dialog.Result != null)
{
_taskRepo.Save(dialog.Result);
AppLogger.Info($"新增任务成功: {dialog.Result.TaskName} cron={dialog.Result.CronExpr}");
_keyword = "";
_txtSearch.Clear();
_page = 1;
LoadTasksPage();
RefreshDashboard();
}
}
/// <summary>
/// 编辑选中任务
/// </summary>
private void EditTask()
{
var task = SelectedTask();
if (task == null)
{
MessageBox.Show("请先选中要编辑的任务行", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
using var dialog = new TaskEditForm(task);
if (dialog.ShowDialog(this) == DialogResult.OK && dialog.Result != null)
{
_taskRepo.Save(dialog.Result);
AppLogger.Info($"编辑任务成功: {dialog.Result.TaskName}");
LoadTasksPage();
RefreshDashboard();
}
}
/// <summary>
/// 删除选中任务
/// </summary>
private void DeleteTask()
{
var task = SelectedTask();
if (task == null)
{
MessageBox.Show("请先选中要删除的任务行", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
if (MessageBox.Show($"确定删除任务【{task.TaskName}】(ID={task.TaskId})?",
"确认删除", MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes)
{
_taskRepo.DeleteById(task.TaskId);
AppLogger.Info($"删除任务: ID={task.TaskId} 名称={task.TaskName}");
LoadTasksPage();
RefreshDashboard();
}
}
/// <summary>
/// 立即执行选中任务
/// </summary>
private void RunOnce()
{
var task = SelectedTask();
if (task == null)
{
MessageBox.Show("请先选中要执行的任务行", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
// 后台执行,不阻塞UI
_ = Task.Run(() =>
{
_taskRepo.UpdateStatus(task.TaskId, TaskStatus.Running);
try
{
var history = new Application.TaskSchedulerService().ExecuteSingleTask(task);
if (history != null)
{
AppLogger.Info($"手动执行完成 task_id={task.TaskId} 结果={(history.IsSuccess ? "成功" : "失败")}");
}
}
finally
{
_taskRepo.UpdateStatus(task.TaskId, TaskStatus.Idle);
}
// 一次性任务执行成功后自动禁用
if (task.IsOneTime)
{
_taskRepo.UpdateEnable(task.TaskId, false);
AppLogger.Info($"一次性任务 task_id={task.TaskId} 已自动禁用");
}
Invoke(() => { LoadTasksPage(); RefreshDashboard(); });
});
}
/// <summary>
/// 查看选中任务历史
/// </summary>
private void ShowHistory()
{
var task = SelectedTask();
if (task == null)
{
MessageBox.Show("请先选中要查看历史的任务行", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
using var dialog = new HistoryForm(task.TaskId, task.TaskName);
dialog.ShowDialog(this);
}
/// <summary>
///
/// 导入任务
/// </summary>
private void ImportTasks()
{
using var open = new OpenFileDialog
{
Title = "选择要导入的任务JSON文件",
Filter = "JSON文件 (*.json)|*.json",
};
if (open.ShowDialog(this) != DialogResult.OK)
{
return;
}
try
{
var stat = new TaskJsonIO().ImportFromFile(open.FileName);
MessageBox.Show($"导入完成:总条数={stat.Total} 新增={stat.Insert} 更新={stat.Update} 跳过={stat.Skip}",
"导入结果", MessageBoxButtons.OK, MessageBoxIcon.Information);
_keyword = "";
_txtSearch.Clear();
_page = 1;
LoadTasksPage();
RefreshDashboard();
}
catch (Exception ex)
{
MessageBox.Show($"导入失败:{ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
AppLogger.Error($"导入失败: {ex.Message}");
}
}
/// <summary>
/// 导出任务
/// </summary>
private void ExportTasks()
{
using var save = new SaveFileDialog
{
Title = "选择导出位置",
Filter = "JSON文件 (*.json)|*.json",
FileName = $"task_export_{DateTime.Now:yyyyMMdd_HHmmss}.json",
};
if (save.ShowDialog(this) != DialogResult.OK)
{
return;
}
try
{
new TaskJsonIO().ExportAll(save.FileName);
MessageBox.Show($"导出完成 → {save.FileName}", "导出结果", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
MessageBox.Show($"导出失败:{ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
AppLogger.Error($"导出失败: {ex.Message}");
}
}
/// <summary>
/// 获取选中任务
/// </summary>
private TaskEntity? SelectedTask()
{
if (_gridTasks.CurrentRow == null ||
!long.TryParse(_gridTasks.CurrentRow.Cells["Id"]!.Value?.ToString(), out var id))
{
return null;
}
return _taskRepo.GetById(id);
}
// ==================== 托盘与关闭 ====================
/// <summary>
/// 设置系统托盘(关闭最小化 + 菜单退出)
/// </summary>
private void SetupTray()
{
_notifyIcon.Icon = SystemIcons.Application;
_notifyIcon.Text = "TaskScheduler 定时任务调度器";
_notifyIcon.Visible = true;
_notifyIcon.DoubleClick += (_, _) =>
{
Show();
WindowState = FormWindowState.Normal;
Activate();
};
var menu = new ContextMenuStrip();
menu.Items.Add("显示主窗口", null, (_, _) =>
{
Show();
WindowState = FormWindowState.Normal;
Activate();
});
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("退出程序", null, (_, _) =>
{
_exiting = true;
_notifyIcon.Visible = false;
System.Windows.Forms.Application.Exit();
});
_notifyIcon.ContextMenuStrip = menu;
}
/// <summary>
/// 窗体关闭事件:非退出状态下最小化到托盘
/// </summary>
private void OnFormClosing(object? sender, FormClosingEventArgs e)
{
if (_exiting)
{
// 真正退出:停止调度
_scheduler.Stop();
_refreshTimer.Stop();
return;
}
e.Cancel = true;
Hide();
_notifyIcon.ShowBalloonTip(1500, "TaskScheduler",
"程序已最小化到系统托盘,双击图标恢复窗口", ToolTipIcon.Info);
}
// ==================== 日志与定时刷新 ====================
/// <summary>
/// 订阅日志事件,向日志框实时追加
/// </summary>
private void SubscribeLogs()
{
AppLogger.Logged += line =>
{
if (IsDisposed)
{
return;
}
try
{
if (InvokeRequired)
{
Invoke(() => AppendLog(line));
}
else
{
AppendLog(line);
}
}
catch
{
// 窗体已关闭,忽略
}
};
}
/// <summary>
/// 追加日志行(限制最大行数防止内存膨胀,并滚动到底部)
/// </summary>
private void AppendLog(string line)
{
const int maxLines = 3000;
_txtLog.AppendText(line);
if (_txtLog.Lines.Length > maxLines)
{
var overflow = _txtLog.Lines.Length - maxLines;
_txtLog.Select(0, _txtLog.GetFirstCharIndexFromLine(overflow));
_txtLog.SelectedText = "";
}
_txtLog.SelectionStart = _txtLog.TextLength;
_txtLog.ScrollToCaret();
}
/// <summary>
/// 仪表盘/任务列表定时自动刷新(5秒)
/// </summary>
private void SetupRefreshTimer()
{
_refreshTimer.Interval = 5000;
_refreshTimer.Tick += (_, _) =>
{
LoadTasksPage();
RefreshDashboard();
};
_refreshTimer.Start();
}
/// <summary>
/// 截断长文本
/// </summary>
private static string Truncate(string s, int max)
{
if (string.IsNullOrEmpty(s) || s.Length <= max)
{
return s ?? "";
}
return s[..max] + "...";
}
}
输出:

