NewLife/AntJob

Merge branch 'master' into business
大石头 authored at 2024-10-29 10:27:40
d415bb2
Tree
2 Parent(s) eac5b1b + 09c0217
Summary: 105 changed files with 3355 additions and 2875 deletions.
Modified +16 -11
Modified +13 -11
Modified +7 -5
Modified +1 -1
Modified +3 -12
Modified +101 -140
Modified +1 -1
Modified +66 -279
Modified +124 -42
Modified +47 -27
Modified +68 -22
Modified +1 -1
Modified +16 -13
Modified +2 -0
Modified +15 -1
Modified +20 -1
Modified +1 -1
Modified +51 -1
Modified +2 -1
Modified +68 -25
Deleted +0 -103
AntJob.Data/Entity/应用配置.Biz.cs
Deleted +0 -235
AntJob.Data/Entity/应用配置.cs
Modified +44 -52
Added +5 -0
Modified +0 -0
Modified +1 -1
Modified +90 -20
Modified +19 -7
Modified +2 -2
Modified +25 -3
Modified +1 -1
Modified +43 -10
Modified +11 -0
Modified +75 -35
Modified +629 -103
Modified +3 -3
Modified +20 -12
Modified +10 -6
Modified +37 -8
Deleted +0 -34
AntJob.Web/Areas/Ant/Controllers/AppConfigController.cs
Modified +30 -73
Modified +7 -3
Modified +14 -3
Modified +5 -3
Modified +150 -76
Modified +7 -3
Modified +129 -26
Modified +4 -1
Deleted +0 -71
AntJob.Web/Areas/Ant/Views/App/_List_Data.cshtml
Deleted +0 -59
AntJob.Web/Areas/Ant/Views/AppConfig/_List_Data.cshtml
Deleted +0 -12
AntJob.Web/Areas/Ant/Views/AppConfig/_List_Search.cshtml
Deleted +0 -91
AntJob.Web/Areas/Ant/Views/AppHistory/_List_Data.cshtml
Deleted +0 -63
AntJob.Web/Areas/Ant/Views/AppMessage/_List_Data.cshtml
Deleted +0 -11
AntJob.Web/Areas/Ant/Views/AppMessage/_List_Search.cshtml
Deleted +0 -69
AntJob.Web/Areas/Ant/Views/AppOnline/_List_Data.cshtml
Deleted +0 -122
AntJob.Web/Areas/Ant/Views/Job/_List_Data.cshtml
Deleted +0 -52
AntJob.Web/Areas/Ant/Views/JobError/_List_Data.cshtml
Deleted +0 -10
AntJob.Web/Areas/Ant/Views/JobError/_List_Search.cshtml
Added +42 -0
Deleted +0 -109
AntJob.Web/Areas/Ant/Views/JobTask/_List_Data.cshtml
Added +61 -0
Modified +5 -6
Modified +2 -9
Modified +8 -1
Modified +9 -5
Modified +4 -1
Modified +32 -32
Modified +17 -20
Modified +13 -16
Modified +9 -12
Modified +80 -71
Modified +21 -22
Modified +19 -17
Modified +15 -18
Modified +19 -18
Modified +31 -28
Modified +24 -13
Added +170 -0
Modified +60 -20
Modified +4 -3
Modified +6 -15
Modified +21 -4
Modified +18 -16
Deleted +0 -13
AntJob/Properties/PublishProfiles/FolderProfile.pubxml
Modified +62 -73
Modified +18 -10
Modified +8 -6
Modified +25 -1
Modified +115 -34
Modified +123 -66
Modified +4 -4
Modified +6 -6
Modified +8 -8
Modified +70 -71
Added +110 -0
Modified +14 -4
Modified +32 -35
Modified +33 -33
Modified +23 -19
Modified +1 -5
Modified +13 -17
Modified +13 -26
Modified +1 -1
Modified +1 -1
Modified +1 -8
Modified +16 -11
diff --git a/.github/workflows/publish-beta.yml b/.github/workflows/publish-beta.yml
index 633a8ea..a746a37 100644
--- a/.github/workflows/publish-beta.yml
+++ b/.github/workflows/publish-beta.yml
@@ -6,27 +6,32 @@ on:
     paths:
         - 'AntJob/**'
         - 'AntJob.Extensions/**'
+  pull_request:
+    branches: [ master ]
+    paths:
+        - 'AntJob/**'
+        - 'AntJob.Extensions/**'
   workflow_dispatch:
 
 jobs:
   build-publish:
-
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v3
-    - name: Setup .NET
-      uses: actions/setup-dotnet@v2
+    - uses: actions/checkout@v4
+    - name: Setup dotNET
+      uses: actions/setup-dotnet@v3
       with:
-        dotnet-version: 6.0.x
-    - name: Restore
-      run: |
-        dotnet restore AntJob/AntJob.csproj
-        dotnet restore AntJob.Extensions/AntJob.Extensions.csproj
+        dotnet-version: |
+          6.0.x
+          7.0.x
+          8.x
+    - name: Get Version
+      run: echo "VERSION=$(date '+%Y.%m%d-beta%H%M')" >> $GITHUB_ENV
     - name: Build
       run: |
-        dotnet pack --no-restore --version-suffix $(date "+%Y.%m%d-beta%H%M") -c Release -o out AntJob/AntJob.csproj
-        dotnet pack --no-restore --version-suffix $(date "+%Y.%m%d-beta%H%M") -c Release -o out AntJob.Extensions/AntJob.Extensions.csproj
+        dotnet pack --version-suffix ${{ env.VERSION }} -c Release -o out AntJob/AntJob.csproj
+        dotnet pack --version-suffix ${{ env.VERSION }} -c Release -o out AntJob.Extensions/AntJob.Extensions.csproj
     - name: Publish
       run: |
         dotnet nuget push ./out/*.nupkg --skip-duplicate --source https://nuget.pkg.github.com/NewLifeX/index.json --api-key ${{ github.token }}
Modified +13 -11
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 6793eba..cb1d710 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -7,23 +7,25 @@ on:
 
 jobs:
   build-publish:
-
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v3
-    - name: Setup .NET
-      uses: actions/setup-dotnet@v2
+    - uses: actions/checkout@v4
+    - name: Setup dotNET
+      uses: actions/setup-dotnet@v3
       with:
-        dotnet-version: 6.0.x
-    - name: Restore
-      run: |
-        dotnet restore AntJob/AntJob.csproj
-        dotnet restore AntJob.Extensions/AntJob.Extensions.csproj
+        dotnet-version: |
+          6.0.x
+          7.0.x
+          8.x
+    - name: Get Version
+      run: echo "VERSION=$(date '+%Y.%m%d')" >> $GITHUB_ENV
     - name: Build
       run: |
-        dotnet pack --no-restore --version-suffix $(date "+%Y.%m%d") -c Release -o out AntJob/AntJob.csproj
-        dotnet pack --no-restore --version-suffix $(date "+%Y.%m%d") -c Release -o out AntJob.Extensions/AntJob.Extensions.csproj
+        dotnet pack --version-suffix ${{ env.VERSION }} -c Release -o out AntJob/AntJob.csproj
+        dotnet pack --version-suffix ${{ env.VERSION }} -c Release -o out AntJob.Extensions/AntJob.Extensions.csproj
+        dotnet pack --version-suffix ${{ env.VERSION }} -c Release -o out AntJob.Data/AntJob.Data.csproj
+        dotnet pack --version-suffix ${{ env.VERSION }} -c Release -o out AntWeb/AntWeb.csproj
     - name: Publish
       run: |
         dotnet nuget push ./out/*.nupkg --skip-duplicate --source https://nuget.pkg.github.com/NewLifeX/index.json --api-key ${{ github.token }}
Modified +7 -5
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 1ce515c..7708073 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -9,15 +9,17 @@ on:
 
 jobs:
   build-publish:
-
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v3
-    - name: Setup .NET
-      uses: actions/setup-dotnet@v2
+    - uses: actions/checkout@v4
+    - name: Setup dotNET
+      uses: actions/setup-dotnet@v3
       with:
-        dotnet-version: 6.0.x
+        dotnet-version: |
+          6.0.x
+          7.0.x
+          8.x
     - name: Build
       run: |
         dotnet build -c Release
Modified +1 -1
diff --git a/AntJob.Agent/AntJob.Agent.csproj b/AntJob.Agent/AntJob.Agent.csproj
index da8e8c4..347bbb9 100644
--- a/AntJob.Agent/AntJob.Agent.csproj
+++ b/AntJob.Agent/AntJob.Agent.csproj
@@ -32,7 +32,7 @@
   </ItemGroup>
 
   <ItemGroup>
-    <PackageReference Include="NewLife.Stardust" Version="2.9.2024.402" />
+    <PackageReference Include="NewLife.Stardust" Version="3.1.2024.1004" />
   </ItemGroup>
 
   <ItemGroup>
Modified +3 -12
diff --git a/AntJob.Agent/Program.cs b/AntJob.Agent/Program.cs
index cebe280..4ecf648 100644
--- a/AntJob.Agent/Program.cs
+++ b/AntJob.Agent/Program.cs
@@ -1,6 +1,5 @@
 using AntJob;
 using AntJob.Extensions;
-using AntJob.Providers;
 using NewLife.Log;
 using NewLife.Model;
 using Stardust;
@@ -17,18 +16,10 @@ var set = AntSetting.Current;
 var scheduler = new Scheduler
 {
     ServiceProvider = services.BuildServiceProvider(),
-
-    //// 使用分布式调度引擎替换默认的本地文件调度
-    //Provider = new NetworkJobProvider
-    //{
-    //    Debug = set.Debug,
-    //    Server = set.Server,
-    //    AppID = set.AppID,
-    //    Secret = set.Secret,
-    //}
+    Log = XTrace.Log,
 };
 
-scheduler.Join(set.Server, set.AppID, set.Secret, set.Debug);
+scheduler.Join(set);
 
 // 添加作业处理器
 //sc.Handlers.Add(new CSharpHandler());
@@ -36,7 +27,7 @@ scheduler.AddHandler<SqlHandler>();
 scheduler.AddHandler<SqlMessage>();
 
 // 启动调度引擎,调度器内部多线程处理
-scheduler.Start();
+scheduler.StartAsync();
 
 // 友好退出
 var host = services.BuildHost();
Modified +101 -140
diff --git a/AntJob.Data/Ant.htm b/AntJob.Data/Ant.htm
index d0a79f9..a87cefb 100644
--- a/AntJob.Data/Ant.htm
+++ b/AntJob.Data/Ant.htm
@@ -68,7 +68,7 @@
             <td></td>
             <td title="唯一索引">UQ</td>
             <td>N</td>
-            <td></td>
+            <td>应用英文名</td>
         </tr>
 
         <tr>
@@ -79,7 +79,7 @@
             <td></td>
             <td></td>
             <td></td>
-            <td></td>
+            <td>应用中文名</td>
         </tr>
 
         <tr>
@@ -90,7 +90,7 @@
             <td></td>
             <td></td>
             <td></td>
-            <td></td>
+            <td>一般不设置,应用默认接入</td>
         </tr>
 
         <tr>
@@ -145,7 +145,7 @@
             <td></td>
             <td></td>
             <td>N</td>
-            <td></td>
+            <td>该应用下作业个数</td>
         </tr>
 
         <tr>
@@ -156,6 +156,28 @@
             <td></td>
             <td></td>
             <td>N</td>
+            <td>该应用下消息条数</td>
+        </tr>
+
+        <tr>
+            <td>ManagerId</td>
+            <td>管理人</td>
+            <td>Int32</td>
+            <td></td>
+            <td></td>
+            <td></td>
+            <td>N</td>
+            <td>负责该应用的管理员</td>
+        </tr>
+
+        <tr>
+            <td>Manager</td>
+            <td>管理者</td>
+            <td>String</td>
+            <td>50</td>
+            <td></td>
+            <td></td>
+            <td></td>
             <td></td>
         </tr>
 
@@ -375,6 +397,17 @@
         </tr>
 
         <tr>
+            <td>Enable</td>
+            <td>启用</td>
+            <td>Boolean</td>
+            <td></td>
+            <td></td>
+            <td></td>
+            <td>N</td>
+            <td>是否允许申请任务</td>
+        </tr>
+
+        <tr>
             <td>Tasks</td>
             <td>任务数</td>
             <td>Int32</td>
@@ -657,7 +690,7 @@
     </tbody>
 </table>
 <br></br>
-<h3>应用配置(AppConfig)</h3>
+<h3>作业(Job)</h3>
 <table>
     <thead>
         <tr>
@@ -698,7 +731,7 @@
             <td>Name</td>
             <td>名称</td>
             <td>String</td>
-            <td>50</td>
+            <td>100</td>
             <td></td>
             <td></td>
             <td></td>
@@ -706,41 +739,19 @@
         </tr>
 
         <tr>
-            <td>Content</td>
-            <td>内容</td>
+            <td>ClassName</td>
+            <td>类名</td>
             <td>String</td>
-            <td>5000</td>
-            <td></td>
-            <td></td>
-            <td></td>
-            <td>一般是json格式</td>
-        </tr>
-
-        <tr>
-            <td>CreateUserID</td>
-            <td>创建人</td>
-            <td>Int32</td>
-            <td></td>
-            <td></td>
-            <td></td>
-            <td>N</td>
-            <td></td>
-        </tr>
-
-        <tr>
-            <td>CreateTime</td>
-            <td>创建时间</td>
-            <td>DateTime</td>
-            <td></td>
-            <td></td>
+            <td>100</td>
             <td></td>
             <td></td>
             <td></td>
+            <td>支持该作业的处理器实现</td>
         </tr>
 
         <tr>
-            <td>CreateIP</td>
-            <td>创建地址</td>
+            <td>DisplayName</td>
+            <td>显示名</td>
             <td>String</td>
             <td>50</td>
             <td></td>
@@ -750,9 +761,9 @@
         </tr>
 
         <tr>
-            <td>UpdateUserID</td>
-            <td>更新人</td>
-            <td>Int32</td>
+            <td>Enable</td>
+            <td>启用</td>
+            <td>Boolean</td>
             <td></td>
             <td></td>
             <td></td>
@@ -761,108 +772,25 @@
         </tr>
 
         <tr>
-            <td>UpdateTime</td>
-            <td>更新时间</td>
-            <td>DateTime</td>
-            <td></td>
-            <td></td>
-            <td></td>
-            <td></td>
-            <td></td>
-        </tr>
-
-        <tr>
-            <td>UpdateIP</td>
-            <td>更新地址</td>
-            <td>String</td>
-            <td>50</td>
-            <td></td>
-            <td></td>
-            <td></td>
-            <td></td>
-        </tr>
-    </tbody>
-</table>
-<br></br>
-<h3>作业(Job)</h3>
-<table>
-    <thead>
-        <tr>
-            <th>名称</th>
-            <th>显示名</th>
-            <th>类型</th>
-            <th>长度</th>
-            <th>精度</th>
-            <th>主键</th>
-            <th>允许空</th>
-            <th>备注</th>
-        </tr>
-    </thead>
-    <tbody>
-        <tr>
-            <td>ID</td>
-            <td>编号</td>
-            <td>Int32</td>
-            <td></td>
-            <td></td>
-            <td title="自增">AI</td>
-            <td>N</td>
-            <td></td>
-        </tr>
-
-        <tr>
-            <td>AppID</td>
-            <td>应用</td>
+            <td>Mode</td>
+            <td>调度模式</td>
             <td>Int32</td>
             <td></td>
             <td></td>
             <td></td>
             <td>N</td>
-            <td></td>
-        </tr>
-
-        <tr>
-            <td>Name</td>
-            <td>名称</td>
-            <td>String</td>
-            <td>100</td>
-            <td></td>
-            <td></td>
-            <td></td>
-            <td></td>
-        </tr>
-
-        <tr>
-            <td>ClassName</td>
-            <td>类名</td>
-            <td>String</td>
-            <td>100</td>
-            <td></td>
-            <td></td>
-            <td></td>
-            <td>支持该作业的处理器实现</td>
+            <td>定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑</td>
         </tr>
 
         <tr>
-            <td>DisplayName</td>
-            <td>显示名</td>
+            <td>Cron</td>
+            <td>执行频次</td>
             <td>String</td>
             <td>50</td>
             <td></td>
             <td></td>
             <td></td>
-            <td></td>
-        </tr>
-
-        <tr>
-            <td>Mode</td>
-            <td>调度模式</td>
-            <td>Int32</td>
-            <td></td>
-            <td></td>
-            <td></td>
-            <td>N</td>
-            <td>定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑</td>
+            <td>定时调度的Cron表达式</td>
         </tr>
 
         <tr>
@@ -888,14 +816,14 @@
         </tr>
 
         <tr>
-            <td>Start</td>
-            <td>开始</td>
+            <td>DataTime</td>
+            <td>数据时间</td>
             <td>DateTime</td>
             <td></td>
             <td></td>
             <td></td>
             <td></td>
-            <td>大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用</td>
+            <td>下一次处理数据的时间,默认从当前时间开始</td>
         </tr>
 
         <tr>
@@ -906,7 +834,7 @@
             <td></td>
             <td></td>
             <td></td>
-            <td>小于不等于,数据调度到达该时间点后触发(可能有偏移量),默认空表示无止境,消息调度不适用</td>
+            <td>到该时间停止调度作业,默认不设置时永不停止</td>
         </tr>
 
         <tr>
@@ -994,7 +922,7 @@
             <td></td>
             <td></td>
             <td>N</td>
-            <td>任务项保留天数,超过天数的任务项将被删除,默认3天</td>
+            <td>任务项保留天数,超过天数的任务项将被删除,默认30天</td>
         </tr>
 
         <tr>
@@ -1020,6 +948,17 @@
         </tr>
 
         <tr>
+            <td>Deadline</td>
+            <td>最后期限</td>
+            <td>DateTime</td>
+            <td></td>
+            <td></td>
+            <td></td>
+            <td></td>
+            <td>超过该时间后,任务将不再执行</td>
+        </tr>
+
+        <tr>
             <td>Total</td>
             <td>总数</td>
             <td>Int64</td>
@@ -1075,14 +1014,25 @@
         </tr>
 
         <tr>
-            <td>Enable</td>
-            <td>启用</td>
-            <td>Boolean</td>
+            <td>LastStatus</td>
+            <td>最后状态</td>
+            <td>Int32</td>
             <td></td>
             <td></td>
             <td></td>
             <td>N</td>
+            <td>最后一次状态</td>
+        </tr>
+
+        <tr>
+            <td>LastTime</td>
+            <td>最后时间</td>
+            <td>DateTime</td>
+            <td></td>
+            <td></td>
             <td></td>
+            <td></td>
+            <td>最后一次时间</td>
         </tr>
 
         <tr>
@@ -1257,8 +1207,8 @@
         </tr>
 
         <tr>
-            <td>Start</td>
-            <td>开始</td>
+            <td>DataTime</td>
+            <td>数据时间</td>
             <td>DateTime</td>
             <td></td>
             <td></td>
@@ -1379,13 +1329,13 @@
 
         <tr>
             <td>MsgCount</td>
-            <td>消费消息数</td>
+            <td>消息</td>
             <td>Int32</td>
             <td></td>
             <td></td>
             <td></td>
             <td>N</td>
-            <td></td>
+            <td>消费消息数</td>
         </tr>
 
         <tr>
@@ -1571,8 +1521,8 @@
         </tr>
 
         <tr>
-            <td>Start</td>
-            <td>开始</td>
+            <td>DataTime</td>
+            <td>数据时间</td>
             <td>DateTime</td>
             <td></td>
             <td></td>
@@ -1764,6 +1714,17 @@
         </tr>
 
         <tr>
+            <td>DelayTime</td>
+            <td>延迟时间</td>
+            <td>DateTime</td>
+            <td></td>
+            <td></td>
+            <td></td>
+            <td></td>
+            <td>延迟到该时间执行</td>
+        </tr>
+
+        <tr>
             <td>TraceId</td>
             <td>追踪</td>
             <td>String</td>
Modified +1 -1
diff --git a/AntJob.Data/AntJob.Data.csproj b/AntJob.Data/AntJob.Data.csproj
index b6fbabe..c37bf5a 100644
--- a/AntJob.Data/AntJob.Data.csproj
+++ b/AntJob.Data/AntJob.Data.csproj
@@ -40,7 +40,7 @@
     <None Remove="Build.tt" />
   </ItemGroup>
   <ItemGroup>
-    <PackageReference Include="NewLife.XCode" Version="11.11.2024.402" />
+    <PackageReference Include="NewLife.XCode" Version="11.16.2024.1005" />
   </ItemGroup>
   <ItemGroup>
     <None Update="Build.log">
Modified +66 -279
diff --git "a/AntJob.Data/Entity/\344\275\234\344\270\232.Biz.cs" "b/AntJob.Data/Entity/\344\275\234\344\270\232.Biz.cs"
index 4f9d932..8fb9d5b 100644
--- "a/AntJob.Data/Entity/\344\275\234\344\270\232.Biz.cs"
+++ "b/AntJob.Data/Entity/\344\275\234\344\270\232.Biz.cs"
@@ -1,14 +1,10 @@
 using System;
 using System.Collections.Generic;
-using System.Linq;
-using System.Threading;
 using NewLife;
-using NewLife.Caching;
 using NewLife.Data;
-using NewLife.Log;
-using NewLife.Serialization;
+using NewLife.Threading;
 using XCode;
-using XCode.Membership;
+using XCode.DataAccessLayer;
 
 namespace AntJob.Data.Entity;
 
@@ -43,13 +39,14 @@ public partial class Job : EntityBase<Job>
         //    throw new ArgumentNullException(nameof(Data), $"{Mode}调度模式要求设置Data模板");
 
         // 参数默认值
-        if (Step == 0) Step = 5;
-        if (MaxRetain == 0) MaxRetain = 3;
+        var step = Step;
+        if (step == 0) step = Step = 5;
+        if (MaxRetain == 0) MaxRetain = 30;
         if (MaxIdle == 0) MaxIdle = GetDefaultIdle();
 
         if (isNew)
         {
-            if (!Dirtys[nameof(MaxRetry)]) MaxRetry = 10;
+            if (!Dirtys[nameof(MaxRetry)]) MaxRetry = 100;
             if (!Dirtys[nameof(MaxTime)]) MaxTime = 600;
             if (!Dirtys[nameof(ErrorDelay)]) ErrorDelay = 60;
             if (!Dirtys[nameof(MaxIdle)]) MaxIdle = GetDefaultIdle();
@@ -59,6 +56,19 @@ public partial class Job : EntityBase<Job>
         //var len = _.Remark.Length;
         //if (!Remark.IsNullOrEmpty() && len > 0 && Remark.Length > len) Remark = Remark.Substring(0, len);
 
+        // 定时任务自动生成Cron
+        if (Mode == JobModes.Time && Cron.IsNullOrEmpty())
+        {
+            if (step < 60)
+                Cron = $"0 */{step} * * * ?";
+            else if (step % 86400 == 0 && step / 86400 < 30)
+                Cron = $"0 0 0 0/{step / 86400} * ?";
+            else if (step % 3600 == 0 && step / 3600 < 24)
+                Cron = $"0 0 0/{step / 3600} * * ?";
+            else if (step % 60 == 0 && step / 60 < 60)
+                Cron = $"0 0/{step / 60} * * * ?";
+        }
+
         var app = App;
         if (isNew && app != null)
         {
@@ -70,7 +80,7 @@ public partial class Job : EntityBase<Job>
     private Int32 GetDefaultIdle()
     {
         // 定时调度,取步进加一分钟
-        if (Mode == JobModes.Alarm) return Step + 600;
+        if (Mode == JobModes.Time) return Step + 600;
 
         return 3600;
     }
@@ -204,8 +214,8 @@ public partial class Job : EntityBase<Job>
         switch (Mode)
         {
             case JobModes.Data:
-            case JobModes.Alarm:
-                return Start.Year > 2000 && Step > 0;
+            case JobModes.Time:
+                return DataTime.Year > 2000 && Step > 0;
             case JobModes.Message:
                 return Topic.IsNullOrEmpty();
             default:
@@ -215,6 +225,44 @@ public partial class Job : EntityBase<Job>
         return false;
     }
 
+    /// <summary>获取下一次执行时间</summary>
+    /// <returns></returns>
+    public DateTime GetNext()
+    {
+        var step = Step;
+        if (step <= 0) step = 30;
+
+        switch (Mode)
+        {
+            case JobModes.Data:
+                break;
+            case JobModes.Time:
+                if (!Cron.IsNullOrEmpty())
+                {
+                    var time = DataTime.Year > 2000 ? DataTime : DateTime.Now;
+                    var next = DateTime.MaxValue;
+                    foreach (var item in Cron.Split(";"))
+                    {
+                        var cron = new Cron(item);
+                        var dt = cron.GetNext(time);
+                        if (dt < next) next = dt;
+                    }
+                    return next;
+                    //return NewLife.Threading.Cron.GetNext(Cron.Split(";"), time);
+                }
+                else
+                {
+                    return DataTime.AddSeconds(step);
+                }
+            case JobModes.Message:
+                break;
+            default:
+                break;
+        }
+
+        return DateTime.MinValue;
+    }
+
     /// <summary>重置任务,让它从新开始工作</summary>
     /// <param name="days">重置到多少天之前</param>
     /// <param name="stime">开始时间(优先级低于days)</param>
@@ -223,14 +271,14 @@ public partial class Job : EntityBase<Job>
     {
         if (days < 0)
         {
-            Start = DateTime.MinValue;
+            //DataTime = DateTime.MinValue;
 
             if (stime > DateTime.MinValue)
-                Start = stime;
+                DataTime = stime;
             End = etime;
         }
         else
-            Start = DateTime.Now.Date.AddDays(-days);
+            DataTime = DateTime.Now.Date.AddDays(-days);
 
         Save();
     }
@@ -269,7 +317,7 @@ public partial class Job : EntityBase<Job>
     {
         // 如果禁用,仅返回最简单的字段
         // 缺少开始时间赋值,会导致客户端启动校验失败,Job没有启用的状态下服务器报错无法正常启动
-        if (!Enable) return new JobModel { Name = Name, Enable = Enable, Start = Start };
+        if (!Enable) return new JobModel { Name = Name, Enable = Enable, DataTime = DataTime };
 
         return new JobModel
         {
@@ -277,8 +325,9 @@ public partial class Job : EntityBase<Job>
             ClassName = ClassName,
             Enable = Enable,
 
-            Start = Start,
+            DataTime = DataTime,
             End = End,
+            Cron = Cron,
             Topic = Topic,
             Data = Data,
 
@@ -291,266 +340,4 @@ public partial class Job : EntityBase<Job>
         };
     }
     #endregion
-
-    #region 申请任务
-    /// <summary>用于表示切片批次的序号</summary>
-    private static Int32 _idxBatch;
-
-    /// <summary>申请任务分片</summary>
-    /// <param name="server">申请任务的服务端</param>
-    /// <param name="ip">申请任务的IP</param>
-    /// <param name="pid">申请任务的服务端进程ID</param>
-    /// <param name="count">要申请的任务个数</param>
-    /// <param name="cache">缓存对象</param>
-    /// <returns></returns>
-    public IList<JobTask> Acquire(String server, String ip, Int32 pid, Int32 count, ICache cache)
-    {
-        var list = new List<JobTask>();
-
-        if (!Enable) return list;
-
-        var step = Step;
-        if (step <= 0) step = 30;
-
-        //// 全局锁,确保单个作业只有一个线程在分配作业
-        //using var ck = cache.AcquireLock($"Job:{ID}", 5_000);
-
-        using var ts = Meta.CreateTrans();
-        var start = Start;
-        for (var i = 0; i < count; i++)
-        {
-            if (!TrySplit(start, step, out var end)) break;
-
-            // 创建新的分片
-            var ti = new JobTask
-            {
-                AppID = AppID,
-                JobID = ID,
-                Start = start,
-                End = end,
-                BatchSize = BatchSize,
-            };
-
-            ti.Server = server;
-            ti.ProcessID = Interlocked.Increment(ref _idxBatch);
-            ti.Client = $"{ip}@{pid}";
-            ti.Status = JobStatus.就绪;
-            ti.CreateTime = DateTime.Now;
-            ti.UpdateTime = DateTime.Now;
-
-            //// 如果有模板,则进行计算替换
-            //if (!Data.IsNullOrEmpty()) ti.Data = TemplateHelper.Build(Data, ti.Start, ti.End);
-
-            // 重复切片判断
-            var key = $"job:task:{ID}:{start:yyyyMMddHHmmss}";
-            if (!cache.Add(key, ti, 30))
-            {
-                var ti2 = cache.Get<JobTask>(key);
-                XTrace.WriteLine("[{0}]重复切片:{1}", key, ti2?.ToJson());
-                using var span = DefaultTracer.Instance?.NewSpan($"job:AcquireDuplicate", ti2);
-            }
-            else
-            {
-                ti.Insert();
-
-                list.Add(ti);
-            }
-
-            // 更新任务
-            Start = end;
-            start = end;
-        }
-
-        if (list.Count > 0)
-        {
-            // 任务需要ID,不能批量插入优化
-            //list.Insert(null);
-
-            UpdateTime = DateTime.Now;
-            Save();
-            ts.Commit();
-        }
-
-        return list;
-    }
-
-    /// <summary>尝试分割时间片</summary>
-    /// <param name="start"></param>
-    /// <param name="step"></param>
-    /// <param name="end"></param>
-    /// <returns></returns>
-    public Boolean TrySplit(DateTime start, Int32 step, out DateTime end)
-    {
-        // 当前时间减去偏移量,作为当前时间。数据抽取不许超过该时间
-        var now = DateTime.Now.AddSeconds(-Offset);
-        // 去掉毫秒
-        now = now.Trim();
-
-        end = DateTime.MinValue;
-
-        // 开始时间和结束时间是否越界
-        if (start >= now) return false;
-
-        if (step <= 0) step = 30;
-
-        // 必须严格要求按照步进大小分片,除非有合适的End
-        end = start.AddSeconds(step);
-        // 任务结束时间超过作业结束时间时,取后者
-        if (End.Year > 2000 && end > End) end = End;
-
-        // 时间片必须严格要求按照步进大小分片,除非有合适的End
-        if (Mode != JobModes.Alarm)
-        {
-            if (end > now) return false;
-        }
-
-        // 时间区间判断
-        if (start >= end) return false;
-
-        return true;
-    }
-
-    /// <summary>申请历史错误或中断的任务</summary>
-    /// <param name="server">申请任务的服务端</param>
-    /// <param name="ip">申请任务的IP</param>
-    /// <param name="pid">申请任务的服务端进程ID</param>
-    /// <param name="count">要申请的任务个数</param>
-    /// <param name="cache">缓存对象</param>
-    /// <returns></returns>
-    public IList<JobTask> AcquireOld(String server, String ip, Int32 pid, Int32 count, ICache cache)
-    {
-        //// 全局锁,确保单个作业只有一个线程在分配作业
-        //using var ck = cache.AcquireLock($"Job:{ID}", 5_000);
-
-        using var ts = Meta.CreateTrans();
-        var list = new List<JobTask>();
-
-        // 查找历史错误任务
-        if (ErrorDelay > 0)
-        {
-            var dt = DateTime.Now.AddSeconds(-ErrorDelay);
-            var list2 = JobTask.Search(ID, dt, MaxRetry, new[] { JobStatus.错误, JobStatus.取消 }, count);
-            if (list2.Count > 0) list.AddRange(list2);
-        }
-
-        // 查找历史中断任务,持续10分钟仍然未完成
-        if (MaxTime > 0 && list.Count < count)
-        {
-            var dt = DateTime.Now.AddSeconds(-MaxTime);
-            var list2 = JobTask.Search(ID, dt, MaxRetry, new[] { JobStatus.就绪, JobStatus.抽取中, JobStatus.处理中 }, count - list.Count);
-            if (list2.Count > 0) list.AddRange(list2);
-        }
-        if (list.Count > 0)
-        {
-            foreach (var ti in list)
-            {
-                ti.Server = server;
-                ti.ProcessID = Interlocked.Increment(ref _idxBatch);
-                ti.Client = $"{ip}@{pid}";
-                //ti.Status = JobStatus.就绪;
-                ti.CreateTime = DateTime.Now;
-                ti.UpdateTime = DateTime.Now;
-            }
-            list.Save();
-        }
-
-        ts.Commit();
-
-        return list;
-    }
-
-    /// <summary>申请任务分片</summary>
-    /// <param name="topic">主题</param>
-    /// <param name="server">申请任务的服务端</param>
-    /// <param name="ip">申请任务的IP</param>
-    /// <param name="pid">申请任务的服务端进程ID</param>
-    /// <param name="count">要申请的任务个数</param>
-    /// <param name="cache">缓存对象</param>
-    /// <returns></returns>
-    public IList<JobTask> AcquireMessage(String topic, String server, String ip, Int32 pid, Int32 count, ICache cache)
-    {
-        // 消费消息时,保存主题
-        if (Topic != topic)
-        {
-            Topic = topic;
-            SaveAsync();
-        }
-
-        var list = new List<JobTask>();
-
-        if (!Enable) return list;
-
-        // 验证消息数
-        var now = DateTime.Now;
-        if (MessageCount == 0 && UpdateTime.AddMinutes(2) > now) return list;
-
-        //// 全局锁,确保单个作业只有一个线程在分配作业
-        //using var ck = cache.AcquireLock($"Job:{ID}", 5_000);
-
-        using var ts = Meta.CreateTrans();
-        var size = BatchSize;
-        if (size == 0) size = 1;
-
-        // 消费消息。请求任务数量=空闲线程*批大小
-        var msgs = AppMessage.GetTopic(AppID, topic, now, count * size);
-        if (msgs.Count > 0)
-        {
-            for (var i = 0; i < msgs.Count;)
-            {
-                var msgList = msgs.Skip(i).Take(size).ToList();
-                if (msgList.Count == 0) break;
-
-                i += msgList.Count;
-
-                // 创建新的分片
-                var ti = new JobTask
-                {
-                    AppID = AppID,
-                    JobID = ID,
-                    Data = msgList.Select(e => e.Data).ToJson(),
-                    MsgCount = msgList.Count,
-
-                    BatchSize = size,
-                };
-
-                ti.Server = server;
-                ti.ProcessID = Interlocked.Increment(ref _idxBatch);
-                ti.Client = $"{ip}@{pid}";
-                ti.Status = JobStatus.就绪;
-                ti.CreateTime = DateTime.Now;
-                ti.UpdateTime = DateTime.Now;
-
-                ti.Insert();
-
-                // 从去重缓存去掉
-                cache.Remove(msgList.Select(e => $"antjob:{AppID}:{Topic}:{e}").ToArray());
-
-                list.Add(ti);
-            }
-
-            // 批量删除消息
-            msgs.Delete();
-        }
-
-        // 更新作业下的消息数
-        MessageCount = AppMessage.FindCountByAppIDAndTopic(AppID, topic);
-        UpdateTime = now;
-        Save();
-
-        // 消费完成后,更新应用的消息数
-        if (MessageCount == 0)
-        {
-            var app = App;
-            if (app != null)
-            {
-                app.MessageCount = AppMessage.FindCountByAppID(ID);
-                app.SaveAsync();
-            }
-        }
-
-        ts.Commit();
-
-        return list;
-    }
-    #endregion
 }
\ No newline at end of file
Modified +124 -42
diff --git "a/AntJob.Data/Entity/\344\275\234\344\270\232.cs" "b/AntJob.Data/Entity/\344\275\234\344\270\232.cs"
index dfb5b71..4c90f5d 100644
--- "a/AntJob.Data/Entity/\344\275\234\344\270\232.cs"
+++ "b/AntJob.Data/Entity/\344\275\234\344\270\232.cs"
@@ -13,12 +13,12 @@ using XCode.DataAccessLayer;
 
 namespace AntJob.Data.Entity;
 
-/// <summary>作业。数据计算逻辑的主要单元,每个作业即是一个业务逻辑,各个作业之前存在依赖关系</summary>
+/// <summary>作业。数据计算逻辑的主要单元,每个作业即是一个业务逻辑</summary>
 [Serializable]
 [DataObject]
-[Description("作业。数据计算逻辑的主要单元,每个作业即是一个业务逻辑,各个作业之前存在依赖关系")]
+[Description("作业。数据计算逻辑的主要单元,每个作业即是一个业务逻辑")]
 [BindIndex("IU_Job_AppID_Name", true, "AppID,Name")]
-[BindTable("Job", Description = "作业。数据计算逻辑的主要单元,每个作业即是一个业务逻辑,各个作业之前存在依赖关系", ConnName = "Ant", DbType = DatabaseType.None)]
+[BindTable("Job", Description = "作业。数据计算逻辑的主要单元,每个作业即是一个业务逻辑", ConnName = "Ant", DbType = DatabaseType.None)]
 public partial class Job
 {
     #region 属性
@@ -62,6 +62,14 @@ public partial class Job
     [BindColumn("DisplayName", "显示名", "")]
     public String DisplayName { get => _DisplayName; set { if (OnPropertyChanging("DisplayName", value)) { _DisplayName = value; OnPropertyChanged("DisplayName"); } } }
 
+    private Boolean _Enable;
+    /// <summary>启用</summary>
+    [DisplayName("启用")]
+    [Description("启用")]
+    [DataObjectField(false, false, false, 0)]
+    [BindColumn("Enable", "启用", "")]
+    public Boolean Enable { get => _Enable; set { if (OnPropertyChanging("Enable", value)) { _Enable = value; OnPropertyChanged("Enable"); } } }
+
     private JobModes _Mode;
     /// <summary>调度模式。定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑</summary>
     [DisplayName("调度模式")]
@@ -70,6 +78,14 @@ public partial class Job
     [BindColumn("Mode", "调度模式。定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑", "")]
     public JobModes Mode { get => _Mode; set { if (OnPropertyChanging("Mode", value)) { _Mode = value; OnPropertyChanged("Mode"); } } }
 
+    private String _Cron;
+    /// <summary>执行频次。定时调度的Cron表达式</summary>
+    [DisplayName("执行频次")]
+    [Description("执行频次。定时调度的Cron表达式")]
+    [DataObjectField(false, false, true, 50)]
+    [BindColumn("Cron", "执行频次。定时调度的Cron表达式", "")]
+    public String Cron { get => _Cron; set { if (OnPropertyChanging("Cron", value)) { _Cron = value; OnPropertyChanged("Cron"); } } }
+
     private String _Topic;
     /// <summary>主题。消息调度时消费的主题</summary>
     [DisplayName("主题")]
@@ -86,20 +102,20 @@ public partial class Job
     [BindColumn("MessageCount", "消息数", "")]
     public Int32 MessageCount { get => _MessageCount; set { if (OnPropertyChanging("MessageCount", value)) { _MessageCount = value; OnPropertyChanged("MessageCount"); } } }
 
-    private DateTime _Start;
-    /// <summary>开始。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用</summary>
-    [DisplayName("开始")]
-    [Description("开始。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用")]
+    private DateTime _DataTime;
+    /// <summary>数据时间。下一次处理数据的时间,默认从当前时间开始</summary>
+    [DisplayName("数据时间")]
+    [Description("数据时间。下一次处理数据的时间,默认从当前时间开始")]
     [DataObjectField(false, false, true, 0)]
-    [BindColumn("Start", "开始。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用", "")]
-    public DateTime Start { get => _Start; set { if (OnPropertyChanging("Start", value)) { _Start = value; OnPropertyChanged("Start"); } } }
+    [BindColumn("DataTime", "数据时间。下一次处理数据的时间,默认从当前时间开始", "")]
+    public DateTime DataTime { get => _DataTime; set { if (OnPropertyChanging("DataTime", value)) { _DataTime = value; OnPropertyChanged("DataTime"); } } }
 
     private DateTime _End;
-    /// <summary>结束。小于不等于,数据调度到达该时间点后触发(可能有偏移量),默认空表示无止境,消息调度不适用</summary>
+    /// <summary>结束。到该时间停止调度作业,默认不设置时永不停止</summary>
     [DisplayName("结束")]
-    [Description("结束。小于不等于,数据调度到达该时间点后触发(可能有偏移量),默认空表示无止境,消息调度不适用")]
+    [Description("结束。到该时间停止调度作业,默认不设置时永不停止")]
     [DataObjectField(false, false, true, 0)]
-    [BindColumn("End", "结束。小于不等于,数据调度到达该时间点后触发(可能有偏移量),默认空表示无止境,消息调度不适用", "")]
+    [BindColumn("End", "结束。到该时间停止调度作业,默认不设置时永不停止", "")]
     public DateTime End { get => _End; set { if (OnPropertyChanging("End", value)) { _End = value; OnPropertyChanged("End"); } } }
 
     private Int32 _Step;
@@ -107,7 +123,7 @@ public partial class Job
     [DisplayName("步进")]
     [Description("步进。切分任务的时间区间,秒")]
     [DataObjectField(false, false, false, 0)]
-    [BindColumn("Step", "步进。切分任务的时间区间,秒", "")]
+    [BindColumn("Step", "步进。切分任务的时间区间,秒", "", ItemType = "TimeSpan")]
     public Int32 Step { get => _Step; set { if (OnPropertyChanging("Step", value)) { _Step = value; OnPropertyChanged("Step"); } } }
 
     private Int32 _BatchSize;
@@ -123,11 +139,12 @@ public partial class Job
     [DisplayName("偏移")]
     [Description("偏移。距离AntServer当前时间的秒数,避免因服务器之间的时间误差而错过部分数据,秒")]
     [DataObjectField(false, false, false, 0)]
-    [BindColumn("Offset", "偏移。距离AntServer当前时间的秒数,避免因服务器之间的时间误差而错过部分数据,秒", "")]
+    [BindColumn("Offset", "偏移。距离AntServer当前时间的秒数,避免因服务器之间的时间误差而错过部分数据,秒", "", ItemType = "TimeSpan")]
     public Int32 Offset { get => _Offset; set { if (OnPropertyChanging("Offset", value)) { _Offset = value; OnPropertyChanged("Offset"); } } }
 
     private Int32 _MaxTask;
     /// <summary>并行度。一共允许多少个任务并行处理,多执行端时平均分配,确保该作业整体并行度</summary>
+    [Category("控制参数")]
     [DisplayName("并行度")]
     [Description("并行度。一共允许多少个任务并行处理,多执行端时平均分配,确保该作业整体并行度")]
     [DataObjectField(false, false, false, 0)]
@@ -136,6 +153,7 @@ public partial class Job
 
     private Int32 _MaxError;
     /// <summary>最大错误。连续错误达到最大错误数时停止</summary>
+    [Category("控制参数")]
     [DisplayName("最大错误")]
     [Description("最大错误。连续错误达到最大错误数时停止")]
     [DataObjectField(false, false, false, 0)]
@@ -144,6 +162,7 @@ public partial class Job
 
     private Int32 _MaxRetry;
     /// <summary>最大重试。默认10次,超过该次数后将不再重试</summary>
+    [Category("控制参数")]
     [DisplayName("最大重试")]
     [Description("最大重试。默认10次,超过该次数后将不再重试")]
     [DataObjectField(false, false, false, 0)]
@@ -152,38 +171,52 @@ public partial class Job
 
     private Int32 _MaxTime;
     /// <summary>最大执行时间。默认600秒,超过该时间则认为执行器故障,将会把该任务分配给其它执行器</summary>
+    [Category("控制参数")]
     [DisplayName("最大执行时间")]
     [Description("最大执行时间。默认600秒,超过该时间则认为执行器故障,将会把该任务分配给其它执行器")]
     [DataObjectField(false, false, false, 0)]
-    [BindColumn("MaxTime", "最大执行时间。默认600秒,超过该时间则认为执行器故障,将会把该任务分配给其它执行器", "")]
+    [BindColumn("MaxTime", "最大执行时间。默认600秒,超过该时间则认为执行器故障,将会把该任务分配给其它执行器", "", ItemType = "TimeSpan")]
     public Int32 MaxTime { get => _MaxTime; set { if (OnPropertyChanging("MaxTime", value)) { _MaxTime = value; OnPropertyChanged("MaxTime"); } } }
 
     private Int32 _MaxRetain;
-    /// <summary>保留。任务项保留天数,超过天数的任务项将被删除,默认3天</summary>
+    /// <summary>保留。任务项保留天数,超过天数的任务项将被删除,默认30天</summary>
+    [Category("控制参数")]
     [DisplayName("保留")]
-    [Description("保留。任务项保留天数,超过天数的任务项将被删除,默认3天")]
+    [Description("保留。任务项保留天数,超过天数的任务项将被删除,默认30天")]
     [DataObjectField(false, false, false, 0)]
-    [BindColumn("MaxRetain", "保留。任务项保留天数,超过天数的任务项将被删除,默认3天", "")]
+    [BindColumn("MaxRetain", "保留。任务项保留天数,超过天数的任务项将被删除,默认30天", "")]
     public Int32 MaxRetain { get => _MaxRetain; set { if (OnPropertyChanging("MaxRetain", value)) { _MaxRetain = value; OnPropertyChanged("MaxRetain"); } } }
 
     private Int32 _MaxIdle;
     /// <summary>最大空闲时间。默认3600秒,超过该时间不更新则认为应用程序故障,系统触发告警</summary>
+    [Category("控制参数")]
     [DisplayName("最大空闲时间")]
     [Description("最大空闲时间。默认3600秒,超过该时间不更新则认为应用程序故障,系统触发告警")]
     [DataObjectField(false, false, false, 0)]
-    [BindColumn("MaxIdle", "最大空闲时间。默认3600秒,超过该时间不更新则认为应用程序故障,系统触发告警", "")]
+    [BindColumn("MaxIdle", "最大空闲时间。默认3600秒,超过该时间不更新则认为应用程序故障,系统触发告警", "", ItemType = "TimeSpan")]
     public Int32 MaxIdle { get => _MaxIdle; set { if (OnPropertyChanging("MaxIdle", value)) { _MaxIdle = value; OnPropertyChanged("MaxIdle"); } } }
 
     private Int32 _ErrorDelay;
     /// <summary>错误延迟。默认60秒,出错延迟后重新发放</summary>
+    [Category("控制参数")]
     [DisplayName("错误延迟")]
     [Description("错误延迟。默认60秒,出错延迟后重新发放")]
     [DataObjectField(false, false, false, 0)]
-    [BindColumn("ErrorDelay", "错误延迟。默认60秒,出错延迟后重新发放", "")]
+    [BindColumn("ErrorDelay", "错误延迟。默认60秒,出错延迟后重新发放", "", ItemType = "TimeSpan")]
     public Int32 ErrorDelay { get => _ErrorDelay; set { if (OnPropertyChanging("ErrorDelay", value)) { _ErrorDelay = value; OnPropertyChanged("ErrorDelay"); } } }
 
+    private DateTime _Deadline;
+    /// <summary>最后期限。超过该时间后,任务将不再执行</summary>
+    [Category("控制参数")]
+    [DisplayName("最后期限")]
+    [Description("最后期限。超过该时间后,任务将不再执行")]
+    [DataObjectField(false, false, true, 0)]
+    [BindColumn("Deadline", "最后期限。超过该时间后,任务将不再执行", "")]
+    public DateTime Deadline { get => _Deadline; set { if (OnPropertyChanging("Deadline", value)) { _Deadline = value; OnPropertyChanged("Deadline"); } } }
+
     private Int64 _Total;
     /// <summary>总数。任务处理的总数据,例如数据调度抽取得到的总行数,定时调度默认1</summary>
+    [Category("统计")]
     [DisplayName("总数")]
     [Description("总数。任务处理的总数据,例如数据调度抽取得到的总行数,定时调度默认1")]
     [DataObjectField(false, false, false, 0)]
@@ -192,6 +225,7 @@ public partial class Job
 
     private Int64 _Success;
     /// <summary>成功。成功处理的数据,取自于Handler.Execute返回值,或者ProcessItem返回true的个数</summary>
+    [Category("统计")]
     [DisplayName("成功")]
     [Description("成功。成功处理的数据,取自于Handler.Execute返回值,或者ProcessItem返回true的个数")]
     [DataObjectField(false, false, false, 0)]
@@ -200,6 +234,7 @@ public partial class Job
 
     private Int32 _Error;
     /// <summary>错误</summary>
+    [Category("统计")]
     [DisplayName("错误")]
     [Description("错误")]
     [DataObjectField(false, false, false, 0)]
@@ -208,6 +243,7 @@ public partial class Job
 
     private Int32 _Times;
     /// <summary>次数</summary>
+    [Category("统计")]
     [DisplayName("次数")]
     [Description("次数")]
     [DataObjectField(false, false, false, 0)]
@@ -216,19 +252,30 @@ public partial class Job
 
     private Int32 _Speed;
     /// <summary>速度</summary>
+    [Category("统计")]
     [DisplayName("速度")]
     [Description("速度")]
     [DataObjectField(false, false, false, 0)]
     [BindColumn("Speed", "速度", "")]
     public Int32 Speed { get => _Speed; set { if (OnPropertyChanging("Speed", value)) { _Speed = value; OnPropertyChanged("Speed"); } } }
 
-    private Boolean _Enable;
-    /// <summary>启用</summary>
-    [DisplayName("启用")]
-    [Description("启用")]
+    private JobStatus _LastStatus;
+    /// <summary>最后状态。最后一次状态</summary>
+    [Category("统计")]
+    [DisplayName("最后状态")]
+    [Description("最后状态。最后一次状态")]
     [DataObjectField(false, false, false, 0)]
-    [BindColumn("Enable", "启用", "")]
-    public Boolean Enable { get => _Enable; set { if (OnPropertyChanging("Enable", value)) { _Enable = value; OnPropertyChanged("Enable"); } } }
+    [BindColumn("LastStatus", "最后状态。最后一次状态", "")]
+    public JobStatus LastStatus { get => _LastStatus; set { if (OnPropertyChanging("LastStatus", value)) { _LastStatus = value; OnPropertyChanged("LastStatus"); } } }
+
+    private DateTime _LastTime;
+    /// <summary>最后时间。最后一次时间</summary>
+    [Category("统计")]
+    [DisplayName("最后时间")]
+    [Description("最后时间。最后一次时间")]
+    [DataObjectField(false, false, true, 0)]
+    [BindColumn("LastTime", "最后时间。最后一次时间", "")]
+    public DateTime LastTime { get => _LastTime; set { if (OnPropertyChanging("LastTime", value)) { _LastTime = value; OnPropertyChanged("LastTime"); } } }
 
     private String _Data;
     /// <summary>数据。Sql模板或C#模板</summary>
@@ -333,10 +380,12 @@ public partial class Job
             "Name" => _Name,
             "ClassName" => _ClassName,
             "DisplayName" => _DisplayName,
+            "Enable" => _Enable,
             "Mode" => _Mode,
+            "Cron" => _Cron,
             "Topic" => _Topic,
             "MessageCount" => _MessageCount,
-            "Start" => _Start,
+            "DataTime" => _DataTime,
             "End" => _End,
             "Step" => _Step,
             "BatchSize" => _BatchSize,
@@ -348,12 +397,14 @@ public partial class Job
             "MaxRetain" => _MaxRetain,
             "MaxIdle" => _MaxIdle,
             "ErrorDelay" => _ErrorDelay,
+            "Deadline" => _Deadline,
             "Total" => _Total,
             "Success" => _Success,
             "Error" => _Error,
             "Times" => _Times,
             "Speed" => _Speed,
-            "Enable" => _Enable,
+            "LastStatus" => _LastStatus,
+            "LastTime" => _LastTime,
             "Data" => _Data,
             "CreateUserID" => _CreateUserID,
             "CreateUser" => _CreateUser,
@@ -375,10 +426,12 @@ public partial class Job
                 case "Name": _Name = Convert.ToString(value); break;
                 case "ClassName": _ClassName = Convert.ToString(value); break;
                 case "DisplayName": _DisplayName = Convert.ToString(value); break;
+                case "Enable": _Enable = value.ToBoolean(); break;
                 case "Mode": _Mode = (JobModes)value.ToInt(); break;
+                case "Cron": _Cron = Convert.ToString(value); break;
                 case "Topic": _Topic = Convert.ToString(value); break;
                 case "MessageCount": _MessageCount = value.ToInt(); break;
-                case "Start": _Start = value.ToDateTime(); break;
+                case "DataTime": _DataTime = value.ToDateTime(); break;
                 case "End": _End = value.ToDateTime(); break;
                 case "Step": _Step = value.ToInt(); break;
                 case "BatchSize": _BatchSize = value.ToInt(); break;
@@ -390,12 +443,14 @@ public partial class Job
                 case "MaxRetain": _MaxRetain = value.ToInt(); break;
                 case "MaxIdle": _MaxIdle = value.ToInt(); break;
                 case "ErrorDelay": _ErrorDelay = value.ToInt(); break;
+                case "Deadline": _Deadline = value.ToDateTime(); break;
                 case "Total": _Total = value.ToLong(); break;
                 case "Success": _Success = value.ToLong(); break;
                 case "Error": _Error = value.ToInt(); break;
                 case "Times": _Times = value.ToInt(); break;
                 case "Speed": _Speed = value.ToInt(); break;
-                case "Enable": _Enable = value.ToBoolean(); break;
+                case "LastStatus": _LastStatus = (JobStatus)value.ToInt(); break;
+                case "LastTime": _LastTime = value.ToDateTime(); break;
                 case "Data": _Data = Convert.ToString(value); break;
                 case "CreateUserID": _CreateUserID = value.ToInt(); break;
                 case "CreateUser": _CreateUser = Convert.ToString(value); break;
@@ -423,6 +478,9 @@ public partial class Job
 
     #endregion
 
+    #region 扩展查询
+    #endregion
+
     #region 字段名
     /// <summary>取得作业字段信息的快捷方式</summary>
     public partial class _
@@ -442,19 +500,25 @@ public partial class Job
         /// <summary>显示名</summary>
         public static readonly Field DisplayName = FindByName("DisplayName");
 
+        /// <summary>启用</summary>
+        public static readonly Field Enable = FindByName("Enable");
+
         /// <summary>调度模式。定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑</summary>
         public static readonly Field Mode = FindByName("Mode");
 
+        /// <summary>执行频次。定时调度的Cron表达式</summary>
+        public static readonly Field Cron = FindByName("Cron");
+
         /// <summary>主题。消息调度时消费的主题</summary>
         public static readonly Field Topic = FindByName("Topic");
 
         /// <summary>消息数</summary>
         public static readonly Field MessageCount = FindByName("MessageCount");
 
-        /// <summary>开始。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用</summary>
-        public static readonly Field Start = FindByName("Start");
+        /// <summary>数据时间。下一次处理数据的时间,默认从当前时间开始</summary>
+        public static readonly Field DataTime = FindByName("DataTime");
 
-        /// <summary>结束。小于不等于,数据调度到达该时间点后触发(可能有偏移量),默认空表示无止境,消息调度不适用</summary>
+        /// <summary>结束。到该时间停止调度作业,默认不设置时永不停止</summary>
         public static readonly Field End = FindByName("End");
 
         /// <summary>步进。切分任务的时间区间,秒</summary>
@@ -478,7 +542,7 @@ public partial class Job
         /// <summary>最大执行时间。默认600秒,超过该时间则认为执行器故障,将会把该任务分配给其它执行器</summary>
         public static readonly Field MaxTime = FindByName("MaxTime");
 
-        /// <summary>保留。任务项保留天数,超过天数的任务项将被删除,默认3天</summary>
+        /// <summary>保留。任务项保留天数,超过天数的任务项将被删除,默认30天</summary>
         public static readonly Field MaxRetain = FindByName("MaxRetain");
 
         /// <summary>最大空闲时间。默认3600秒,超过该时间不更新则认为应用程序故障,系统触发告警</summary>
@@ -487,6 +551,9 @@ public partial class Job
         /// <summary>错误延迟。默认60秒,出错延迟后重新发放</summary>
         public static readonly Field ErrorDelay = FindByName("ErrorDelay");
 
+        /// <summary>最后期限。超过该时间后,任务将不再执行</summary>
+        public static readonly Field Deadline = FindByName("Deadline");
+
         /// <summary>总数。任务处理的总数据,例如数据调度抽取得到的总行数,定时调度默认1</summary>
         public static readonly Field Total = FindByName("Total");
 
@@ -502,8 +569,11 @@ public partial class Job
         /// <summary>速度</summary>
         public static readonly Field Speed = FindByName("Speed");
 
-        /// <summary>启用</summary>
-        public static readonly Field Enable = FindByName("Enable");
+        /// <summary>最后状态。最后一次状态</summary>
+        public static readonly Field LastStatus = FindByName("LastStatus");
+
+        /// <summary>最后时间。最后一次时间</summary>
+        public static readonly Field LastTime = FindByName("LastTime");
 
         /// <summary>数据。Sql模板或C#模板</summary>
         public static readonly Field Data = FindByName("Data");
@@ -556,19 +626,25 @@ public partial class Job
         /// <summary>显示名</summary>
         public const String DisplayName = "DisplayName";
 
+        /// <summary>启用</summary>
+        public const String Enable = "Enable";
+
         /// <summary>调度模式。定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑</summary>
         public const String Mode = "Mode";
 
+        /// <summary>执行频次。定时调度的Cron表达式</summary>
+        public const String Cron = "Cron";
+
         /// <summary>主题。消息调度时消费的主题</summary>
         public const String Topic = "Topic";
 
         /// <summary>消息数</summary>
         public const String MessageCount = "MessageCount";
 
-        /// <summary>开始。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用</summary>
-        public const String Start = "Start";
+        /// <summary>数据时间。下一次处理数据的时间,默认从当前时间开始</summary>
+        public const String DataTime = "DataTime";
 
-        /// <summary>结束。小于不等于,数据调度到达该时间点后触发(可能有偏移量),默认空表示无止境,消息调度不适用</summary>
+        /// <summary>结束。到该时间停止调度作业,默认不设置时永不停止</summary>
         public const String End = "End";
 
         /// <summary>步进。切分任务的时间区间,秒</summary>
@@ -592,7 +668,7 @@ public partial class Job
         /// <summary>最大执行时间。默认600秒,超过该时间则认为执行器故障,将会把该任务分配给其它执行器</summary>
         public const String MaxTime = "MaxTime";
 
-        /// <summary>保留。任务项保留天数,超过天数的任务项将被删除,默认3天</summary>
+        /// <summary>保留。任务项保留天数,超过天数的任务项将被删除,默认30天</summary>
         public const String MaxRetain = "MaxRetain";
 
         /// <summary>最大空闲时间。默认3600秒,超过该时间不更新则认为应用程序故障,系统触发告警</summary>
@@ -601,6 +677,9 @@ public partial class Job
         /// <summary>错误延迟。默认60秒,出错延迟后重新发放</summary>
         public const String ErrorDelay = "ErrorDelay";
 
+        /// <summary>最后期限。超过该时间后,任务将不再执行</summary>
+        public const String Deadline = "Deadline";
+
         /// <summary>总数。任务处理的总数据,例如数据调度抽取得到的总行数,定时调度默认1</summary>
         public const String Total = "Total";
 
@@ -616,8 +695,11 @@ public partial class Job
         /// <summary>速度</summary>
         public const String Speed = "Speed";
 
-        /// <summary>启用</summary>
-        public const String Enable = "Enable";
+        /// <summary>最后状态。最后一次状态</summary>
+        public const String LastStatus = "LastStatus";
+
+        /// <summary>最后时间。最后一次时间</summary>
+        public const String LastTime = "LastTime";
 
         /// <summary>数据。Sql模板或C#模板</summary>
         public const String Data = "Data";
Modified +47 -27
diff --git "a/AntJob.Data/Entity/\344\275\234\344\270\232\344\273\273\345\212\241.Biz.cs" "b/AntJob.Data/Entity/\344\275\234\344\270\232\344\273\273\345\212\241.Biz.cs"
index 5694561..e8f6c7e 100644
--- "a/AntJob.Data/Entity/\344\275\234\344\270\232\344\273\273\345\212\241.Biz.cs"
+++ "b/AntJob.Data/Entity/\344\275\234\344\270\232\344\273\273\345\212\241.Biz.cs"
@@ -92,30 +92,42 @@ public partial class JobTask : EntityBase<JobTask>
         return FindAll(_.JobID == jobid & _.CreateTime < createTime, _.CreateTime.Desc(), null, 0, 1).FirstOrDefault();
     }
 
-    /// <summary>根据作业、状态、开始查找</summary>
-    /// <param name="jobId">作业</param>
+    /// <summary>根据应用、客户端、状态查找</summary>
+    /// <param name="appId">应用</param>
+    /// <param name="client">客户端</param>
     /// <param name="status">状态</param>
-    /// <param name="start">开始</param>
     /// <returns>实体列表</returns>
-    public static IList<JobTask> FindAllByJobIDAndStatusAndStart(Int32 jobId, JobStatus status, DateTime start)
+    public static IList<JobTask> FindAllByAppIDAndClientAndStatus(Int32 appId, String client, JobStatus status)
     {
         // 实体缓存
-        if (Meta.Session.Count < 1000) return Meta.Cache.FindAll(e => e.JobID == jobId && e.Status == status && e.Start == start);
+        if (Meta.Session.Count < 1000) return Meta.Cache.FindAll(e => e.AppID == appId && e.Client.EqualIgnoreCase(client) && e.Status == status);
 
-        return FindAll(_.JobID == jobId & _.Status == status & _.Start == start);
+        return FindAll(_.AppID == appId & _.Client == client & _.Status == status);
     }
 
-    /// <summary>根据应用、客户端、状态查找</summary>
-    /// <param name="appId">应用</param>
-    /// <param name="client">客户端</param>
+    /// <summary>根据作业、状态、数据时间查找</summary>
+    /// <param name="jobId">作业</param>
     /// <param name="status">状态</param>
+    /// <param name="dataTime">数据时间</param>
     /// <returns>实体列表</returns>
-    public static IList<JobTask> FindAllByAppIDAndClientAndStatus(Int32 appId, String client, JobStatus status)
+    public static IList<JobTask> FindAllByJobIDAndStatusAndDataTime(Int32 jobId, JobStatus status, DateTime dataTime)
     {
         // 实体缓存
-        if (Meta.Session.Count < 1000) return Meta.Cache.FindAll(e => e.AppID == appId && e.Client.EqualIgnoreCase(client) && e.Status == status);
+        if (Meta.Session.Count < 1000) return Meta.Cache.FindAll(e => e.JobID == jobId && e.Status == status && e.DataTime == dataTime);
 
-        return FindAll(_.AppID == appId & _.Client == client & _.Status == status);
+        return FindAll(_.JobID == jobId & _.Status == status & _.DataTime == dataTime);
+    }
+
+    /// <summary>根据作业、数据时间查找</summary>
+    /// <param name="jobId">作业</param>
+    /// <param name="dataTime">数据时间</param>
+    /// <returns>实体列表</returns>
+    public static IList<JobTask> FindAllByJobIDAndDataTime(Int32 jobId, DateTime dataTime)
+    {
+        // 实体缓存
+        if (Meta.Session.Count < 1000) return Meta.Cache.FindAll(e => e.JobID == jobId && e.DataTime == dataTime);
+
+        return FindAll(_.JobID == jobId & _.DataTime == dataTime);
     }
     #endregion
 
@@ -125,50 +137,54 @@ public partial class JobTask : EntityBase<JobTask>
     /// <param name="appid"></param>
     /// <param name="jobid"></param>
     /// <param name="status"></param>
+    /// <param name="dataStart"></param>
+    /// <param name="dataEnd"></param>
     /// <param name="start"></param>
     /// <param name="end"></param>
     /// <param name="client"></param>
     /// <param name="key"></param>
     /// <param name="p"></param>
     /// <returns></returns>
-    public static IEnumerable<JobTask> Search(Int32 id, Int32 appid, Int32 jobid, JobStatus status, DateTime start, DateTime end, String client, String key, PageParameter p)
+    public static IEnumerable<JobTask> Search(Int32 id, Int32 appid, Int32 jobid, JobStatus status, DateTime dataStart, DateTime dataEnd, DateTime start, DateTime end, String client, String key, PageParameter p)
     {
         var exp = new WhereExpression();
 
         if (id > 0) exp &= _.ID == id;
-        if (appid > 0) exp &= _.AppID == appid;
-        if (jobid > 0) exp &= _.JobID == jobid;
+        if (appid >= 0) exp &= _.AppID == appid;
+        if (jobid >= 0) exp &= _.JobID == jobid;
         if (status >= JobStatus.就绪) exp &= _.Status == status;
         if (!client.IsNullOrEmpty()) exp &= _.Client == client;
         if (!key.IsNullOrEmpty()) exp &= _.Data.Contains(key) | _.Message.Contains(key) | _.Key == key;
-        exp &= _.Start.Between(start, end);
+
+        exp &= _.DataTime.Between(dataStart, dataEnd);
+        exp &= _.UpdateTime.Between(start, end);
 
         return FindAll(exp, p);
     }
 
     /// <summary>获取该任务下特定状态的任务项</summary>
     /// <param name="taskid"></param>
+    /// <param name="start"></param>
     /// <param name="end"></param>
     /// <param name="maxRetry"></param>
-    /// <param name="stats"></param>
+    /// <param name="maxError"></param>
+    /// <param name="status"></param>
     /// <param name="count">要申请的任务个数</param>
     /// <returns></returns>
-    public static IList<JobTask> Search(Int32 taskid, DateTime end, Int32 maxRetry, JobStatus[] stats, Int32 count)
+    public static IList<JobTask> Search(Int32 taskid, DateTime start, DateTime end, Int32 maxRetry, Int32 maxError, JobStatus[] status, Int32 count)
     {
         var exp = new WhereExpression();
         if (taskid > 0) exp &= _.JobID == taskid;
         if (maxRetry > 0) exp &= _.Times < maxRetry;
-        exp &= _.Status.In(stats);
-        exp &= _.UpdateTime >= DateTime.Now.AddDays(-7);
-        if (end > DateTime.MinValue)
-        {
-            exp &= _.UpdateTime < end;
-        }
+        if (status != null && status.Length > 0) exp &= _.Status.In(status);
 
         // 限制任务的错误次数,避免无限执行
-        exp &= _.Error < 32;
+        if (maxError > 0) exp &= _.Error < maxError;
 
-        return FindAll(exp, _.ID.Asc(), null, 0, count);
+        if (start.Year > 2000) exp &= _.UpdateTime >= start;
+        if (end.Year > 2000) exp &= _.UpdateTime < end;
+
+        return FindAll(exp, _.UpdateTime.Asc(), null, 0, count);
     }
     #endregion
 
@@ -191,6 +207,10 @@ public partial class JobTask : EntityBase<JobTask>
 
     //public static Int32 DeleteByAppId(Int32 appid) => Delete(_.AppID == appid);
 
+    /// <summary>删除作业已不存在的任务</summary>
+    /// <returns></returns>
+    public static Int32 DeleteNoJob() => Delete(_.JobID.NotIn(Entity.Job.FindSQLWithKey()));
+
     /// <summary>转模型类</summary>
     /// <returns></returns>
     public TaskModel ToModel()
@@ -199,7 +219,7 @@ public partial class JobTask : EntityBase<JobTask>
         {
             ID = ID,
 
-            Start = Start,
+            DataTime = DataTime,
             End = End,
             //Offset = Offset,
             //Step = Step,
Modified +68 -22
diff --git "a/AntJob.Data/Entity/\344\275\234\344\270\232\344\273\273\345\212\241.cs" "b/AntJob.Data/Entity/\344\275\234\344\270\232\344\273\273\345\212\241.cs"
index c0f1236..d9ad2ff 100644
--- "a/AntJob.Data/Entity/\344\275\234\344\270\232\344\273\273\345\212\241.cs"
+++ "b/AntJob.Data/Entity/\344\275\234\344\270\232\344\273\273\345\212\241.cs"
@@ -17,9 +17,10 @@ namespace AntJob.Data.Entity;
 [Serializable]
 [DataObject]
 [Description("作业任务。计算作业在执行过程中生成的任务实例,具有该次执行所需参数")]
-[BindIndex("IX_JobTask_JobID_Status_Start", false, "JobID,Status,Start")]
-[BindIndex("IX_JobTask_AppID_Client_Status", false, "AppID,Client,Status")]
+[BindIndex("IX_JobTask_JobID_DataTime", false, "JobID,DataTime")]
 [BindIndex("IX_JobTask_JobID_CreateTime", false, "JobID,CreateTime")]
+[BindIndex("IX_JobTask_JobID_UpdateTime", false, "JobID,UpdateTime")]
+[BindIndex("IX_JobTask_AppID_Client_Status", false, "AppID,Client,Status")]
 [BindTable("JobTask", Description = "作业任务。计算作业在执行过程中生成的任务实例,具有该次执行所需参数", ConnName = "Ant", DbType = DatabaseType.None)]
 public partial class JobTask
 {
@@ -56,13 +57,13 @@ public partial class JobTask
     [BindColumn("Client", "客户端。IP加进程", "")]
     public String Client { get => _Client; set { if (OnPropertyChanging("Client", value)) { _Client = value; OnPropertyChanged("Client"); } } }
 
-    private DateTime _Start;
-    /// <summary>开始。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用</summary>
-    [DisplayName("开始")]
-    [Description("开始。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用")]
+    private DateTime _DataTime;
+    /// <summary>数据时间。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用</summary>
+    [DisplayName("数据时间")]
+    [Description("数据时间。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用")]
     [DataObjectField(false, false, true, 0)]
-    [BindColumn("Start", "开始。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用", "", Master = true)]
-    public DateTime Start { get => _Start; set { if (OnPropertyChanging("Start", value)) { _Start = value; OnPropertyChanged("Start"); } } }
+    [BindColumn("DataTime", "数据时间。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用", "", DataScale = "time", Master = true)]
+    public DateTime DataTime { get => _DataTime; set { if (OnPropertyChanging("DataTime", value)) { _DataTime = value; OnPropertyChanged("DataTime"); } } }
 
     private DateTime _End;
     /// <summary>结束。小于不等于,数据调度到达该时间点后触发(可能有偏移量),消息调度不适用</summary>
@@ -125,7 +126,7 @@ public partial class JobTask
     [DisplayName("耗时")]
     [Description("耗时。秒,执行端计算的执行时间")]
     [DataObjectField(false, false, false, 0)]
-    [BindColumn("Cost", "耗时。秒,执行端计算的执行时间", "")]
+    [BindColumn("Cost", "耗时。秒,执行端计算的执行时间", "", ItemType = "TimeSpan")]
     public Int32 Cost { get => _Cost; set { if (OnPropertyChanging("Cost", value)) { _Cost = value; OnPropertyChanged("Cost"); } } }
 
     private Int32 _FullCost;
@@ -133,7 +134,7 @@ public partial class JobTask
     [DisplayName("全部耗时")]
     [Description("全部耗时。秒,从任务发放到执行完成的时间")]
     [DataObjectField(false, false, false, 0)]
-    [BindColumn("FullCost", "全部耗时。秒,从任务发放到执行完成的时间", "")]
+    [BindColumn("FullCost", "全部耗时。秒,从任务发放到执行完成的时间", "", ItemType = "TimeSpan")]
     public Int32 FullCost { get => _FullCost; set { if (OnPropertyChanging("FullCost", value)) { _FullCost = value; OnPropertyChanged("FullCost"); } } }
 
     private JobStatus _Status;
@@ -145,11 +146,11 @@ public partial class JobTask
     public JobStatus Status { get => _Status; set { if (OnPropertyChanging("Status", value)) { _Status = value; OnPropertyChanged("Status"); } } }
 
     private Int32 _MsgCount;
-    /// <summary>消费消息数</summary>
-    [DisplayName("消费消息数")]
-    [Description("消费消息数")]
+    /// <summary>消息。消费消息数</summary>
+    [DisplayName("消息")]
+    [Description("消息。消费消息数")]
     [DataObjectField(false, false, false, 0)]
-    [BindColumn("MsgCount", "消费消息数", "")]
+    [BindColumn("MsgCount", "消息。消费消息数", "")]
     public Int32 MsgCount { get => _MsgCount; set { if (OnPropertyChanging("MsgCount", value)) { _MsgCount = value; OnPropertyChanged("MsgCount"); } } }
 
     private String _Server;
@@ -250,7 +251,7 @@ public partial class JobTask
             "AppID" => _AppID,
             "JobID" => _JobID,
             "Client" => _Client,
-            "Start" => _Start,
+            "DataTime" => _DataTime,
             "End" => _End,
             "BatchSize" => _BatchSize,
             "Total" => _Total,
@@ -282,7 +283,7 @@ public partial class JobTask
                 case "AppID": _AppID = value.ToInt(); break;
                 case "JobID": _JobID = value.ToInt(); break;
                 case "Client": _Client = Convert.ToString(value); break;
-                case "Start": _Start = value.ToDateTime(); break;
+                case "DataTime": _DataTime = value.ToDateTime(); break;
                 case "End": _End = value.ToDateTime(); break;
                 case "BatchSize": _BatchSize = value.ToInt(); break;
                 case "Total": _Total = value.ToInt(); break;
@@ -329,6 +330,51 @@ public partial class JobTask
 
     #endregion
 
+    #region 扩展查询
+    /// <summary>根据编号查找</summary>
+    /// <param name="id">编号</param>
+    /// <returns>实体对象</returns>
+    public static JobTask FindByID(Int32 id)
+    {
+        if (id < 0) return null;
+
+        return Find(_.ID == id);
+    }
+
+    /// <summary>根据作业查找</summary>
+    /// <param name="jobId">作业</param>
+    /// <returns>实体列表</returns>
+    public static IList<JobTask> FindAllByJobID(Int32 jobId)
+    {
+        if (jobId < 0) return [];
+
+        return FindAll(_.JobID == jobId);
+    }
+
+    /// <summary>根据数据时间查找</summary>
+    /// <param name="dataTime">数据时间</param>
+    /// <returns>实体列表</returns>
+    public static IList<JobTask> FindAllByDataTime(DateTime dataTime)
+    {
+        if (dataTime.Year < 1000) return [];
+
+        return FindAll(_.DataTime == dataTime);
+    }
+    #endregion
+
+    #region 数据清理
+    /// <summary>清理指定时间段内的数据</summary>
+    /// <param name="start">开始时间。未指定时清理小于指定时间的所有数据</param>
+    /// <param name="end">结束时间</param>
+    /// <returns>清理行数</returns>
+    public static Int32 DeleteWith(DateTime start, DateTime end)
+    {
+        if (start == end) return Delete(_.DataTime == start);
+
+        return Delete(_.DataTime.Between(start, end));
+    }
+    #endregion
+
     #region 字段名
     /// <summary>取得作业任务字段信息的快捷方式</summary>
     public partial class _
@@ -345,8 +391,8 @@ public partial class JobTask
         /// <summary>客户端。IP加进程</summary>
         public static readonly Field Client = FindByName("Client");
 
-        /// <summary>开始。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用</summary>
-        public static readonly Field Start = FindByName("Start");
+        /// <summary>数据时间。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用</summary>
+        public static readonly Field DataTime = FindByName("DataTime");
 
         /// <summary>结束。小于不等于,数据调度到达该时间点后触发(可能有偏移量),消息调度不适用</summary>
         public static readonly Field End = FindByName("End");
@@ -378,7 +424,7 @@ public partial class JobTask
         /// <summary>状态</summary>
         public static readonly Field Status = FindByName("Status");
 
-        /// <summary>消费消息数</summary>
+        /// <summary>消息。消费消息数</summary>
         public static readonly Field MsgCount = FindByName("MsgCount");
 
         /// <summary>服务器</summary>
@@ -429,8 +475,8 @@ public partial class JobTask
         /// <summary>客户端。IP加进程</summary>
         public const String Client = "Client";
 
-        /// <summary>开始。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用</summary>
-        public const String Start = "Start";
+        /// <summary>数据时间。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用</summary>
+        public const String DataTime = "DataTime";
 
         /// <summary>结束。小于不等于,数据调度到达该时间点后触发(可能有偏移量),消息调度不适用</summary>
         public const String End = "End";
@@ -462,7 +508,7 @@ public partial class JobTask
         /// <summary>状态</summary>
         public const String Status = "Status";
 
-        /// <summary>消费消息数</summary>
+        /// <summary>消息。消费消息数</summary>
         public const String MsgCount = "MsgCount";
 
         /// <summary>服务器</summary>
Modified +1 -1
diff --git "a/AntJob.Data/Entity/\344\275\234\344\270\232\351\224\231\350\257\257.Biz.cs" "b/AntJob.Data/Entity/\344\275\234\344\270\232\351\224\231\350\257\257.Biz.cs"
index a222e5e..7f3eca5 100644
--- "a/AntJob.Data/Entity/\344\275\234\344\270\232\351\224\231\350\257\257.Biz.cs"
+++ "b/AntJob.Data/Entity/\344\275\234\344\270\232\351\224\231\350\257\257.Biz.cs"
@@ -116,7 +116,7 @@ public partial class JobError : EntityBase<JobError>
         if (jobid > 0) exp &= _.JobID == jobid;
         if (!client.IsNullOrEmpty()) exp &= _.Client == client;
         if (!key.IsNullOrEmpty()) exp &= _.Message.Contains(key);
-        exp &= _.Start.Between(start, end);
+        exp &= _.DataTime.Between(start, end);
 
         return FindAll(exp, p);
     }
Modified +16 -13
diff --git "a/AntJob.Data/Entity/\344\275\234\344\270\232\351\224\231\350\257\257.cs" "b/AntJob.Data/Entity/\344\275\234\344\270\232\351\224\231\350\257\257.cs"
index 90160a1..31396f0 100644
--- "a/AntJob.Data/Entity/\344\275\234\344\270\232\351\224\231\350\257\257.cs"
+++ "b/AntJob.Data/Entity/\344\275\234\344\270\232\351\224\231\350\257\257.cs"
@@ -63,13 +63,13 @@ public partial class JobError
     [BindColumn("Client", "客户端。IP加进程", "")]
     public String Client { get => _Client; set { if (OnPropertyChanging("Client", value)) { _Client = value; OnPropertyChanged("Client"); } } }
 
-    private DateTime _Start;
-    /// <summary>开始。大于等于</summary>
-    [DisplayName("开始")]
-    [Description("开始。大于等于")]
+    private DateTime _DataTime;
+    /// <summary>数据时间。大于等于</summary>
+    [DisplayName("数据时间")]
+    [Description("数据时间。大于等于")]
     [DataObjectField(false, false, true, 0)]
-    [BindColumn("Start", "开始。大于等于", "")]
-    public DateTime Start { get => _Start; set { if (OnPropertyChanging("Start", value)) { _Start = value; OnPropertyChanged("Start"); } } }
+    [BindColumn("DataTime", "数据时间。大于等于", "")]
+    public DateTime DataTime { get => _DataTime; set { if (OnPropertyChanging("DataTime", value)) { _DataTime = value; OnPropertyChanged("DataTime"); } } }
 
     private DateTime _End;
     /// <summary>结束。小于,不等于</summary>
@@ -170,7 +170,7 @@ public partial class JobError
             "JobID" => _JobID,
             "TaskID" => _TaskID,
             "Client" => _Client,
-            "Start" => _Start,
+            "DataTime" => _DataTime,
             "End" => _End,
             "Data" => _Data,
             "Server" => _Server,
@@ -192,7 +192,7 @@ public partial class JobError
                 case "JobID": _JobID = value.ToInt(); break;
                 case "TaskID": _TaskID = value.ToInt(); break;
                 case "Client": _Client = Convert.ToString(value); break;
-                case "Start": _Start = value.ToDateTime(); break;
+                case "DataTime": _DataTime = value.ToDateTime(); break;
                 case "End": _End = value.ToDateTime(); break;
                 case "Data": _Data = Convert.ToString(value); break;
                 case "Server": _Server = Convert.ToString(value); break;
@@ -232,10 +232,13 @@ public partial class JobError
 
     /// <summary>作业项</summary>
     [Map(nameof(TaskID), typeof(JobTask), "ID")]
-    public String TaskStart => Task?.ToString();
+    public String TaskDataTime => Task?.ToString();
 
     #endregion
 
+    #region 扩展查询
+    #endregion
+
     #region 字段名
     /// <summary>取得作业错误字段信息的快捷方式</summary>
     public partial class _
@@ -255,8 +258,8 @@ public partial class JobError
         /// <summary>客户端。IP加进程</summary>
         public static readonly Field Client = FindByName("Client");
 
-        /// <summary>开始。大于等于</summary>
-        public static readonly Field Start = FindByName("Start");
+        /// <summary>数据时间。大于等于</summary>
+        public static readonly Field DataTime = FindByName("DataTime");
 
         /// <summary>结束。小于,不等于</summary>
         public static readonly Field End = FindByName("End");
@@ -309,8 +312,8 @@ public partial class JobError
         /// <summary>客户端。IP加进程</summary>
         public const String Client = "Client";
 
-        /// <summary>开始。大于等于</summary>
-        public const String Start = "Start";
+        /// <summary>数据时间。大于等于</summary>
+        public const String DataTime = "DataTime";
 
         /// <summary>结束。小于,不等于</summary>
         public const String End = "End";
Modified +2 -0
diff --git "a/AntJob.Data/Entity/\345\272\224\347\224\250\345\216\206\345\217\262.Biz.cs" "b/AntJob.Data/Entity/\345\272\224\347\224\250\345\216\206\345\217\262.Biz.cs"
index f8c1af4..15d343d 100644
--- "a/AntJob.Data/Entity/\345\272\224\347\224\250\345\216\206\345\217\262.Biz.cs"
+++ "b/AntJob.Data/Entity/\345\272\224\347\224\250\345\216\206\345\217\262.Biz.cs"
@@ -13,6 +13,8 @@ public partial class AppHistory : EntityBase<AppHistory>
     #region 对象操作
     static AppHistory()
     {
+        Meta.Table.DataTable.InsertOnly = true;
+
         // 累加字段
         //var df = Meta.Factory.AdditionalFields;
         //df.Add(__.AppID);
Modified +15 -1
diff --git "a/AntJob.Data/Entity/\345\272\224\347\224\250\345\216\206\345\217\262.cs" "b/AntJob.Data/Entity/\345\272\224\347\224\250\345\216\206\345\217\262.cs"
index 86b1436..2b1d236 100644
--- "a/AntJob.Data/Entity/\345\272\224\347\224\250\345\216\206\345\217\262.cs"
+++ "b/AntJob.Data/Entity/\345\272\224\347\224\250\345\216\206\345\217\262.cs"
@@ -27,7 +27,7 @@ public partial class AppHistory
     [DisplayName("编号")]
     [Description("编号")]
     [DataObjectField(true, false, false, 0)]
-    [BindColumn("Id", "编号", "")]
+    [BindColumn("Id", "编号", "", DataScale = "time")]
     public Int64 Id { get => _Id; set { if (OnPropertyChanging("Id", value)) { _Id = value; OnPropertyChanged("Id"); } } }
 
     private Int32 _AppID;
@@ -178,6 +178,20 @@ public partial class AppHistory
 
     #endregion
 
+    #region 扩展查询
+    #endregion
+
+    #region 数据清理
+    /// <summary>清理指定时间段内的数据</summary>
+    /// <param name="start">开始时间。未指定时清理小于指定时间的所有数据</param>
+    /// <param name="end">结束时间</param>
+    /// <returns>清理行数</returns>
+    public static Int32 DeleteWith(DateTime start, DateTime end)
+    {
+        return Delete(_.Id.Between(start, end, Meta.Factory.Snow));
+    }
+    #endregion
+
     #region 字段名
     /// <summary>取得应用历史字段信息的快捷方式</summary>
     public partial class _
Modified +20 -1
diff --git "a/AntJob.Data/Entity/\345\272\224\347\224\250\345\234\250\347\272\277.cs" "b/AntJob.Data/Entity/\345\272\224\347\224\250\345\234\250\347\272\277.cs"
index 65babe6..2a7d2f8 100644
--- "a/AntJob.Data/Entity/\345\272\224\347\224\250\345\234\250\347\272\277.cs"
+++ "b/AntJob.Data/Entity/\345\272\224\347\224\250\345\234\250\347\272\277.cs"
@@ -96,6 +96,14 @@ public partial class AppOnline
     [BindColumn("Server", "服务端。客户端登录到哪个服务端,IP加端口", "")]
     public String Server { get => _Server; set { if (OnPropertyChanging("Server", value)) { _Server = value; OnPropertyChanged("Server"); } } }
 
+    private Boolean _Enable;
+    /// <summary>启用。是否允许申请任务</summary>
+    [DisplayName("启用")]
+    [Description("启用。是否允许申请任务")]
+    [DataObjectField(false, false, false, 0)]
+    [BindColumn("Enable", "启用。是否允许申请任务", "")]
+    public Boolean Enable { get => _Enable; set { if (OnPropertyChanging("Enable", value)) { _Enable = value; OnPropertyChanged("Enable"); } } }
+
     private Int32 _Tasks;
     /// <summary>任务数</summary>
     [DisplayName("任务数")]
@@ -133,7 +141,7 @@ public partial class AppOnline
     [DisplayName("耗时")]
     [Description("耗时。执行任务总耗时,秒")]
     [DataObjectField(false, false, false, 0)]
-    [BindColumn("Cost", "耗时。执行任务总耗时,秒", "")]
+    [BindColumn("Cost", "耗时。执行任务总耗时,秒", "", ItemType = "TimeSpan")]
     public Int64 Cost { get => _Cost; set { if (OnPropertyChanging("Cost", value)) { _Cost = value; OnPropertyChanged("Cost"); } } }
 
     private Int64 _Speed;
@@ -215,6 +223,7 @@ public partial class AppOnline
             "Version" => _Version,
             "CompileTime" => _CompileTime,
             "Server" => _Server,
+            "Enable" => _Enable,
             "Tasks" => _Tasks,
             "Total" => _Total,
             "Success" => _Success,
@@ -242,6 +251,7 @@ public partial class AppOnline
                 case "Version": _Version = Convert.ToString(value); break;
                 case "CompileTime": _CompileTime = value.ToDateTime(); break;
                 case "Server": _Server = Convert.ToString(value); break;
+                case "Enable": _Enable = value.ToBoolean(); break;
                 case "Tasks": _Tasks = value.ToInt(); break;
                 case "Total": _Total = value.ToLong(); break;
                 case "Success": _Success = value.ToLong(); break;
@@ -271,6 +281,9 @@ public partial class AppOnline
 
     #endregion
 
+    #region 扩展查询
+    #endregion
+
     #region 字段名
     /// <summary>取得应用在线字段信息的快捷方式</summary>
     public partial class _
@@ -302,6 +315,9 @@ public partial class AppOnline
         /// <summary>服务端。客户端登录到哪个服务端,IP加端口</summary>
         public static readonly Field Server = FindByName("Server");
 
+        /// <summary>启用。是否允许申请任务</summary>
+        public static readonly Field Enable = FindByName("Enable");
+
         /// <summary>任务数</summary>
         public static readonly Field Tasks = FindByName("Tasks");
 
@@ -371,6 +387,9 @@ public partial class AppOnline
         /// <summary>服务端。客户端登录到哪个服务端,IP加端口</summary>
         public const String Server = "Server";
 
+        /// <summary>启用。是否允许申请任务</summary>
+        public const String Enable = "Enable";
+
         /// <summary>任务数</summary>
         public const String Tasks = "Tasks";
 
Modified +1 -1
diff --git "a/AntJob.Data/Entity/\345\272\224\347\224\250\346\266\210\346\201\257.Biz.cs" "b/AntJob.Data/Entity/\345\272\224\347\224\250\346\266\210\346\201\257.Biz.cs"
index dad6748..0d8ee7f 100644
--- "a/AntJob.Data/Entity/\345\272\224\347\224\250\346\266\210\346\201\257.Biz.cs"
+++ "b/AntJob.Data/Entity/\345\272\224\347\224\250\346\266\210\346\201\257.Biz.cs"
@@ -119,7 +119,7 @@ public partial class AppMessage : EntityBase<AppMessage>
     /// <returns></returns>
     public static IList<AppMessage> GetTopic(Int32 appid, String topic, DateTime endTime, Int32 count)
     {
-        return FindAll(_.AppID == appid & _.Topic == topic & _.Id <= Meta.Factory.Snow.GetId(endTime), _.Id.Asc(), null, 0, count);
+        return FindAll(_.AppID == appid & _.Topic == topic & (_.DelayTime.IsNull() | _.DelayTime <= endTime), _.Id.Asc(), null, 0, count);
     }
 
     /// <summary>去重过滤</summary>
Modified +51 -1
diff --git "a/AntJob.Data/Entity/\345\272\224\347\224\250\346\266\210\346\201\257.cs" "b/AntJob.Data/Entity/\345\272\224\347\224\250\346\266\210\346\201\257.cs"
index c796757..86c8d1f 100644
--- "a/AntJob.Data/Entity/\345\272\224\347\224\250\346\266\210\346\201\257.cs"
+++ "b/AntJob.Data/Entity/\345\272\224\347\224\250\346\266\210\346\201\257.cs"
@@ -18,6 +18,7 @@ namespace AntJob.Data.Entity;
 [DataObject]
 [Description("应用消息。消息调度,某些作业负责生产消息,供其它作业进行消费处理")]
 [BindIndex("IX_AppMessage_AppID_Topic_UpdateTime", false, "AppID,Topic,UpdateTime")]
+[BindIndex("IX_AppMessage_AppID_Topic_DelayTime", false, "AppID,Topic,DelayTime")]
 [BindTable("AppMessage", Description = "应用消息。消息调度,某些作业负责生产消息,供其它作业进行消费处理", ConnName = "Ant", DbType = DatabaseType.None)]
 public partial class AppMessage
 {
@@ -27,7 +28,7 @@ public partial class AppMessage
     [DisplayName("编号")]
     [Description("编号")]
     [DataObjectField(true, false, false, 0)]
-    [BindColumn("Id", "编号", "")]
+    [BindColumn("Id", "编号", "", DataScale = "time")]
     public Int64 Id { get => _Id; set { if (OnPropertyChanging("Id", value)) { _Id = value; OnPropertyChanged("Id"); } } }
 
     private Int32 _AppID;
@@ -62,6 +63,14 @@ public partial class AppMessage
     [BindColumn("Data", "数据。可以是Json数据,比如StatID", "")]
     public String Data { get => _Data; set { if (OnPropertyChanging("Data", value)) { _Data = value; OnPropertyChanged("Data"); } } }
 
+    private DateTime _DelayTime;
+    /// <summary>延迟时间。延迟到该时间执行</summary>
+    [DisplayName("延迟时间")]
+    [Description("延迟时间。延迟到该时间执行")]
+    [DataObjectField(false, false, true, 0)]
+    [BindColumn("DelayTime", "延迟时间。延迟到该时间执行", "")]
+    public DateTime DelayTime { get => _DelayTime; set { if (OnPropertyChanging("DelayTime", value)) { _DelayTime = value; OnPropertyChanged("DelayTime"); } } }
+
     private String _TraceId;
     /// <summary>追踪。链路追踪,用于APM性能追踪定位,还原该事件的调用链</summary>
     [Category("扩展")]
@@ -121,6 +130,7 @@ public partial class AppMessage
             "JobID" => _JobID,
             "Topic" => _Topic,
             "Data" => _Data,
+            "DelayTime" => _DelayTime,
             "TraceId" => _TraceId,
             "CreateIP" => _CreateIP,
             "CreateTime" => _CreateTime,
@@ -137,6 +147,7 @@ public partial class AppMessage
                 case "JobID": _JobID = value.ToInt(); break;
                 case "Topic": _Topic = Convert.ToString(value); break;
                 case "Data": _Data = Convert.ToString(value); break;
+                case "DelayTime": _DelayTime = value.ToDateTime(); break;
                 case "TraceId": _TraceId = Convert.ToString(value); break;
                 case "CreateIP": _CreateIP = Convert.ToString(value); break;
                 case "CreateTime": _CreateTime = value.ToDateTime(); break;
@@ -167,6 +178,39 @@ public partial class AppMessage
 
     #endregion
 
+    #region 扩展查询
+    /// <summary>根据编号查找</summary>
+    /// <param name="id">编号</param>
+    /// <returns>实体对象</returns>
+    public static AppMessage FindById(Int64 id)
+    {
+        if (id < 0) return null;
+
+        return Find(_.Id == id);
+    }
+
+    /// <summary>根据应用查找</summary>
+    /// <param name="appId">应用</param>
+    /// <returns>实体列表</returns>
+    public static IList<AppMessage> FindAllByAppID(Int32 appId)
+    {
+        if (appId < 0) return [];
+
+        return FindAll(_.AppID == appId);
+    }
+    #endregion
+
+    #region 数据清理
+    /// <summary>清理指定时间段内的数据</summary>
+    /// <param name="start">开始时间。未指定时清理小于指定时间的所有数据</param>
+    /// <param name="end">结束时间</param>
+    /// <returns>清理行数</returns>
+    public static Int32 DeleteWith(DateTime start, DateTime end)
+    {
+        return Delete(_.Id.Between(start, end, Meta.Factory.Snow));
+    }
+    #endregion
+
     #region 字段名
     /// <summary>取得应用消息字段信息的快捷方式</summary>
     public partial class _
@@ -186,6 +230,9 @@ public partial class AppMessage
         /// <summary>数据。可以是Json数据,比如StatID</summary>
         public static readonly Field Data = FindByName("Data");
 
+        /// <summary>延迟时间。延迟到该时间执行</summary>
+        public static readonly Field DelayTime = FindByName("DelayTime");
+
         /// <summary>追踪。链路追踪,用于APM性能追踪定位,还原该事件的调用链</summary>
         public static readonly Field TraceId = FindByName("TraceId");
 
@@ -222,6 +269,9 @@ public partial class AppMessage
         /// <summary>数据。可以是Json数据,比如StatID</summary>
         public const String Data = "Data";
 
+        /// <summary>延迟时间。延迟到该时间执行</summary>
+        public const String DelayTime = "DelayTime";
+
         /// <summary>追踪。链路追踪,用于APM性能追踪定位,还原该事件的调用链</summary>
         public const String TraceId = "TraceId";
 
Modified +2 -1
diff --git "a/AntJob.Data/Entity/\345\272\224\347\224\250\347\263\273\347\273\237.Biz.cs" "b/AntJob.Data/Entity/\345\272\224\347\224\250\347\263\273\347\273\237.Biz.cs"
index b9eaf6b..d84bc6d 100644
--- "a/AntJob.Data/Entity/\345\272\224\347\224\250\347\263\273\347\273\237.Biz.cs"
+++ "b/AntJob.Data/Entity/\345\272\224\347\224\250\347\263\273\347\273\237.Biz.cs"
@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using System.Runtime.Serialization;
 using System.Xml.Serialization;
 using NewLife;
 using NewLife.Data;
@@ -46,7 +47,7 @@ public partial class App : EntityBase<App>
 
     #region 扩展属性
     /// <summary>作业集合</summary>
-    [XmlIgnore]
+    [XmlIgnore, IgnoreDataMember]
     public IList<Job> Jobs => Extends.Get(nameof(Jobs), k => Job.FindAllByAppID(ID));
     #endregion
 
Modified +68 -25
diff --git "a/AntJob.Data/Entity/\345\272\224\347\224\250\347\263\273\347\273\237.cs" "b/AntJob.Data/Entity/\345\272\224\347\224\250\347\263\273\347\273\237.cs"
index 683427e..e19e79b 100644
--- "a/AntJob.Data/Entity/\345\272\224\347\224\250\347\263\273\347\273\237.cs"
+++ "b/AntJob.Data/Entity/\345\272\224\347\224\250\347\263\273\347\273\237.cs"
@@ -31,27 +31,27 @@ public partial class App
     public Int32 ID { get => _ID; set { if (OnPropertyChanging("ID", value)) { _ID = value; OnPropertyChanged("ID"); } } }
 
     private String _Name;
-    /// <summary>名称</summary>
+    /// <summary>名称。应用英文名</summary>
     [DisplayName("名称")]
-    [Description("名称")]
+    [Description("名称。应用英文名")]
     [DataObjectField(false, false, false, 50)]
-    [BindColumn("Name", "名称", "", Master = true)]
+    [BindColumn("Name", "名称。应用英文名", "", Master = true)]
     public String Name { get => _Name; set { if (OnPropertyChanging("Name", value)) { _Name = value; OnPropertyChanged("Name"); } } }
 
     private String _DisplayName;
-    /// <summary>显示名</summary>
+    /// <summary>显示名。应用中文名</summary>
     [DisplayName("显示名")]
-    [Description("显示名")]
+    [Description("显示名。应用中文名")]
     [DataObjectField(false, false, true, 50)]
-    [BindColumn("DisplayName", "显示名", "")]
+    [BindColumn("DisplayName", "显示名。应用中文名", "")]
     public String DisplayName { get => _DisplayName; set { if (OnPropertyChanging("DisplayName", value)) { _DisplayName = value; OnPropertyChanged("DisplayName"); } } }
 
     private String _Secret;
-    /// <summary>密钥</summary>
+    /// <summary>密钥。一般不设置,应用默认接入</summary>
     [DisplayName("密钥")]
-    [Description("密钥")]
+    [Description("密钥。一般不设置,应用默认接入")]
     [DataObjectField(false, false, true, 50)]
-    [BindColumn("Secret", "密钥", "")]
+    [BindColumn("Secret", "密钥。一般不设置,应用默认接入", "")]
     public String Secret { get => _Secret; set { if (OnPropertyChanging("Secret", value)) { _Secret = value; OnPropertyChanged("Secret"); } } }
 
     private String _Category;
@@ -87,21 +87,37 @@ public partial class App
     public Boolean Enable { get => _Enable; set { if (OnPropertyChanging("Enable", value)) { _Enable = value; OnPropertyChanged("Enable"); } } }
 
     private Int32 _JobCount;
-    /// <summary>作业数</summary>
+    /// <summary>作业数。该应用下作业个数</summary>
     [DisplayName("作业数")]
-    [Description("作业数")]
+    [Description("作业数。该应用下作业个数")]
     [DataObjectField(false, false, false, 0)]
-    [BindColumn("JobCount", "作业数", "")]
+    [BindColumn("JobCount", "作业数。该应用下作业个数", "")]
     public Int32 JobCount { get => _JobCount; set { if (OnPropertyChanging("JobCount", value)) { _JobCount = value; OnPropertyChanged("JobCount"); } } }
 
     private Int32 _MessageCount;
-    /// <summary>消息数</summary>
+    /// <summary>消息数。该应用下消息条数</summary>
     [DisplayName("消息数")]
-    [Description("消息数")]
+    [Description("消息数。该应用下消息条数")]
     [DataObjectField(false, false, false, 0)]
-    [BindColumn("MessageCount", "消息数", "")]
+    [BindColumn("MessageCount", "消息数。该应用下消息条数", "")]
     public Int32 MessageCount { get => _MessageCount; set { if (OnPropertyChanging("MessageCount", value)) { _MessageCount = value; OnPropertyChanged("MessageCount"); } } }
 
+    private Int32 _ManagerId;
+    /// <summary>管理人。负责该应用的管理员</summary>
+    [DisplayName("管理人")]
+    [Description("管理人。负责该应用的管理员")]
+    [DataObjectField(false, false, false, 0)]
+    [BindColumn("ManagerId", "管理人。负责该应用的管理员", "")]
+    public Int32 ManagerId { get => _ManagerId; set { if (OnPropertyChanging("ManagerId", value)) { _ManagerId = value; OnPropertyChanged("ManagerId"); } } }
+
+    private String _Manager;
+    /// <summary>管理者</summary>
+    [DisplayName("管理者")]
+    [Description("管理者")]
+    [DataObjectField(false, false, true, 50)]
+    [BindColumn("Manager", "管理者", "")]
+    public String Manager { get => _Manager; set { if (OnPropertyChanging("Manager", value)) { _Manager = value; OnPropertyChanged("Manager"); } } }
+
     private Int32 _CreateUserID;
     /// <summary>创建人</summary>
     [Category("扩展")]
@@ -202,6 +218,8 @@ public partial class App
             "Enable" => _Enable,
             "JobCount" => _JobCount,
             "MessageCount" => _MessageCount,
+            "ManagerId" => _ManagerId,
+            "Manager" => _Manager,
             "CreateUserID" => _CreateUserID,
             "CreateUser" => _CreateUser,
             "CreateTime" => _CreateTime,
@@ -227,6 +245,8 @@ public partial class App
                 case "Enable": _Enable = value.ToBoolean(); break;
                 case "JobCount": _JobCount = value.ToInt(); break;
                 case "MessageCount": _MessageCount = value.ToInt(); break;
+                case "ManagerId": _ManagerId = value.ToInt(); break;
+                case "Manager": _Manager = Convert.ToString(value); break;
                 case "CreateUserID": _CreateUserID = value.ToInt(); break;
                 case "CreateUser": _CreateUser = Convert.ToString(value); break;
                 case "CreateTime": _CreateTime = value.ToDateTime(); break;
@@ -243,6 +263,17 @@ public partial class App
     #endregion
 
     #region 关联映射
+    /// <summary>管理人</summary>
+    [XmlIgnore, IgnoreDataMember, ScriptIgnore]
+    public XCode.Membership.User MyManager => Extends.Get(nameof(MyManager), k => XCode.Membership.User.FindByID(ManagerId));
+
+    /// <summary>管理人</summary>
+    [Map(nameof(ManagerId), typeof(XCode.Membership.User), "ID")]
+    public String ManagerName => MyManager?.Name;
+
+    #endregion
+
+    #region 扩展查询
     #endregion
 
     #region 字段名
@@ -252,13 +283,13 @@ public partial class App
         /// <summary>编号</summary>
         public static readonly Field ID = FindByName("ID");
 
-        /// <summary>名称</summary>
+        /// <summary>名称。应用英文名</summary>
         public static readonly Field Name = FindByName("Name");
 
-        /// <summary>显示名</summary>
+        /// <summary>显示名。应用中文名</summary>
         public static readonly Field DisplayName = FindByName("DisplayName");
 
-        /// <summary>密钥</summary>
+        /// <summary>密钥。一般不设置,应用默认接入</summary>
         public static readonly Field Secret = FindByName("Secret");
 
         /// <summary>类别</summary>
@@ -273,12 +304,18 @@ public partial class App
         /// <summary>启用</summary>
         public static readonly Field Enable = FindByName("Enable");
 
-        /// <summary>作业数</summary>
+        /// <summary>作业数。该应用下作业个数</summary>
         public static readonly Field JobCount = FindByName("JobCount");
 
-        /// <summary>消息数</summary>
+        /// <summary>消息数。该应用下消息条数</summary>
         public static readonly Field MessageCount = FindByName("MessageCount");
 
+        /// <summary>管理人。负责该应用的管理员</summary>
+        public static readonly Field ManagerId = FindByName("ManagerId");
+
+        /// <summary>管理者</summary>
+        public static readonly Field Manager = FindByName("Manager");
+
         /// <summary>创建人</summary>
         public static readonly Field CreateUserID = FindByName("CreateUserID");
 
@@ -315,13 +352,13 @@ public partial class App
         /// <summary>编号</summary>
         public const String ID = "ID";
 
-        /// <summary>名称</summary>
+        /// <summary>名称。应用英文名</summary>
         public const String Name = "Name";
 
-        /// <summary>显示名</summary>
+        /// <summary>显示名。应用中文名</summary>
         public const String DisplayName = "DisplayName";
 
-        /// <summary>密钥</summary>
+        /// <summary>密钥。一般不设置,应用默认接入</summary>
         public const String Secret = "Secret";
 
         /// <summary>类别</summary>
@@ -336,12 +373,18 @@ public partial class App
         /// <summary>启用</summary>
         public const String Enable = "Enable";
 
-        /// <summary>作业数</summary>
+        /// <summary>作业数。该应用下作业个数</summary>
         public const String JobCount = "JobCount";
 
-        /// <summary>消息数</summary>
+        /// <summary>消息数。该应用下消息条数</summary>
         public const String MessageCount = "MessageCount";
 
+        /// <summary>管理人。负责该应用的管理员</summary>
+        public const String ManagerId = "ManagerId";
+
+        /// <summary>管理者</summary>
+        public const String Manager = "Manager";
+
         /// <summary>创建人</summary>
         public const String CreateUserID = "CreateUserID";
 
Deleted +0 -103
AntJob.Data/Entity/应用配置.Biz.cs
Deleted +0 -235
AntJob.Data/Entity/应用配置.cs
Modified +44 -52
diff --git a/AntJob.Data/Model.xml b/AntJob.Data/Model.xml
index 4f57e08..fa1eb2b 100644
--- a/AntJob.Data/Model.xml
+++ b/AntJob.Data/Model.xml
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
-<EntityModel xmlns:xs="http://www.w3.org/2001/XMLSchema-instance" xs:schemaLocation="https://newlifex.com https://newlifex.com/Model202309.xsd" Document="https://newlifex.com/xcode/model" xmlns="https://newlifex.com/Model202309.xsd">
+<EntityModel xmlns:xs="http://www.w3.org/2001/XMLSchema-instance" xs:schemaLocation="https://newlifex.com https://newlifex.com/Model202407.xsd" Document="https://newlifex.com/xcode/model" xmlns="https://newlifex.com/Model202407.xsd">
   <Option>
     <!--类名模板。其中{name}替换为Table.Name,如{name}Model/I{name}Dto等-->
     <ClassNameTemplate />
@@ -42,15 +42,17 @@
     <Table Name="App" Description="应用系统。管理数据计算作业的应用模块,计算作业隶属于某个应用">
       <Columns>
         <Column Name="ID" DataType="Int32" Identity="True" PrimaryKey="True" Description="编号" />
-        <Column Name="Name" DataType="String" Master="True" Nullable="False" Description="名称" />
-        <Column Name="DisplayName" DataType="String" Description="显示名" />
-        <Column Name="Secret" DataType="String" Description="密钥" />
+        <Column Name="Name" DataType="String" Master="True" Nullable="False" Description="名称。应用英文名" />
+        <Column Name="DisplayName" DataType="String" Description="显示名。应用中文名" />
+        <Column Name="Secret" DataType="String" Description="密钥。一般不设置,应用默认接入" />
         <Column Name="Category" DataType="String" Description="类别" />
         <Column Name="Version" DataType="String" Description="版本" />
         <Column Name="CompileTime" DataType="DateTime" Description="编译时间" />
         <Column Name="Enable" DataType="Boolean" Description="启用" />
-        <Column Name="JobCount" DataType="Int32" Description="作业数" />
-        <Column Name="MessageCount" DataType="Int32" Description="消息数" />
+        <Column Name="JobCount" DataType="Int32" Description="作业数。该应用下作业个数" />
+        <Column Name="MessageCount" DataType="Int32" Description="消息数。该应用下消息条数" />
+        <Column Name="ManagerId" DataType="Int32" Map="XCode.Membership.User@ID@Name" Description="管理人。负责该应用的管理员" />
+        <Column Name="Manager" DataType="String" Description="管理者" />
         <Column Name="CreateUserID" DataType="Int32" Description="创建人" Category="扩展" />
         <Column Name="CreateUser" DataType="String" Description="创建者" Category="扩展" />
         <Column Name="CreateTime" DataType="DateTime" Description="创建时间" Category="扩展" />
@@ -76,11 +78,12 @@
         <Column Name="Version" DataType="String" Description="版本。客户端" />
         <Column Name="CompileTime" DataType="DateTime" Description="编译时间" />
         <Column Name="Server" DataType="String" Description="服务端。客户端登录到哪个服务端,IP加端口" />
+        <Column Name="Enable" DataType="Boolean" Description="启用。是否允许申请任务" />
         <Column Name="Tasks" DataType="Int32" Description="任务数" />
         <Column Name="Total" DataType="Int64" Description="总数" />
         <Column Name="Success" DataType="Int64" Description="成功" />
         <Column Name="Error" DataType="Int64" Description="错误" />
-        <Column Name="Cost" DataType="Int64" Description="耗时。执行任务总耗时,秒" />
+        <Column Name="Cost" DataType="Int64" ItemType="TimeSpan" Description="耗时。执行任务总耗时,秒" />
         <Column Name="Speed" DataType="Int64" Description="速度。每秒处理数" />
         <Column Name="LastKey" DataType="String" Description="最后键" />
         <Column Name="TraceId" DataType="String" Length="200" Description="追踪。链路追踪,用于APM性能追踪定位,还原该事件的调用链" Category="扩展" />
@@ -97,7 +100,7 @@
     </Table>
     <Table Name="AppHistory" Description="应用历史。数据计算应用的操作历史">
       <Columns>
-        <Column Name="Id" DataType="Int64" PrimaryKey="True" Description="编号" />
+        <Column Name="Id" DataType="Int64" PrimaryKey="True" DataScale="time" Description="编号" />
         <Column Name="AppID" DataType="Int32" Map="App@ID@$" Description="应用" />
         <Column Name="Name" DataType="String" Master="True" Description="名称" />
         <Column Name="Action" DataType="String" Description="操作" />
@@ -114,52 +117,38 @@
         <Index Columns="AppID,Action" />
       </Indexes>
     </Table>
-    <Table Name="AppConfig" Description="应用配置。数据计算应用的配置数据">
-      <Columns>
-        <Column Name="ID" DataType="Int32" Identity="True" PrimaryKey="True" Description="编号" />
-        <Column Name="AppID" DataType="Int32" Map="App@ID@$" Description="应用" />
-        <Column Name="Name" DataType="String" Master="True" Description="名称" />
-        <Column Name="Content" DataType="String" Length="5000" Description="内容。一般是json格式" />
-        <Column Name="CreateUserID" DataType="Int32" Description="创建人" Category="扩展" />
-        <Column Name="CreateTime" DataType="DateTime" Description="创建时间" Category="扩展" />
-        <Column Name="CreateIP" DataType="String" Description="创建地址" Category="扩展" />
-        <Column Name="UpdateUserID" DataType="Int32" Description="更新人" Category="扩展" />
-        <Column Name="UpdateTime" DataType="DateTime" Description="更新时间" Category="扩展" />
-        <Column Name="UpdateIP" DataType="String" Description="更新地址" Category="扩展" />
-      </Columns>
-      <Indexes>
-        <Index Columns="AppID,Name" Unique="True" />
-        <Index Columns="UpdateTime" />
-      </Indexes>
-    </Table>
-    <Table Name="Job" Description="作业。数据计算逻辑的主要单元,每个作业即是一个业务逻辑,各个作业之前存在依赖关系">
+    <Table Name="Job" Description="作业。数据计算逻辑的主要单元,每个作业即是一个业务逻辑">
       <Columns>
         <Column Name="ID" DataType="Int32" Identity="True" PrimaryKey="True" Description="编号" />
         <Column Name="AppID" DataType="Int32" Map="App@ID@$" Description="应用" />
         <Column Name="Name" DataType="String" Master="True" Length="100" Description="名称" />
         <Column Name="ClassName" DataType="String" Length="100" Description="类名。支持该作业的处理器实现" />
         <Column Name="DisplayName" DataType="String" Description="显示名" />
+        <Column Name="Enable" DataType="Boolean" Description="启用" />
         <Column Name="Mode" DataType="Int32" Description="调度模式。定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑" Type="JobModes" />
+        <Column Name="Cron" DataType="String" Description="执行频次。定时调度的Cron表达式" />
         <Column Name="Topic" DataType="String" Description="主题。消息调度时消费的主题" />
         <Column Name="MessageCount" DataType="Int32" Description="消息数" />
-        <Column Name="Start" DataType="DateTime" Description="开始。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用" />
-        <Column Name="End" DataType="DateTime" Description="结束。小于不等于,数据调度到达该时间点后触发(可能有偏移量),默认空表示无止境,消息调度不适用" />
-        <Column Name="Step" DataType="Int32" Description="步进。切分任务的时间区间,秒" />
+        <Column Name="DataTime" DataType="DateTime" Description="数据时间。下一次处理数据的时间,默认从当前时间开始" />
+        <Column Name="End" DataType="DateTime" Description="结束。到该时间停止调度作业,默认不设置时永不停止" />
+        <Column Name="Step" DataType="Int32" ItemType="TimeSpan" Description="步进。切分任务的时间区间,秒" />
         <Column Name="BatchSize" DataType="Int32" Description="批大小。数据调度每次抽取数据的分页大小,或消息调度每次处理的消息数,定时调度不适用" />
-        <Column Name="Offset" DataType="Int32" Description="偏移。距离AntServer当前时间的秒数,避免因服务器之间的时间误差而错过部分数据,秒" />
-        <Column Name="MaxTask" DataType="Int32" Description="并行度。一共允许多少个任务并行处理,多执行端时平均分配,确保该作业整体并行度" />
-        <Column Name="MaxError" DataType="Int32" Description="最大错误。连续错误达到最大错误数时停止" />
-        <Column Name="MaxRetry" DataType="Int32" Description="最大重试。默认10次,超过该次数后将不再重试" />
-        <Column Name="MaxTime" DataType="Int32" Description="最大执行时间。默认600秒,超过该时间则认为执行器故障,将会把该任务分配给其它执行器" />
-        <Column Name="MaxRetain" DataType="Int32" Description="保留。任务项保留天数,超过天数的任务项将被删除,默认3天" />
-        <Column Name="MaxIdle" DataType="Int32" Description="最大空闲时间。默认3600秒,超过该时间不更新则认为应用程序故障,系统触发告警" />
-        <Column Name="ErrorDelay" DataType="Int32" Description="错误延迟。默认60秒,出错延迟后重新发放" />
-        <Column Name="Total" DataType="Int64" Description="总数。任务处理的总数据,例如数据调度抽取得到的总行数,定时调度默认1" />
-        <Column Name="Success" DataType="Int64" Description="成功。成功处理的数据,取自于Handler.Execute返回值,或者ProcessItem返回true的个数" />
-        <Column Name="Error" DataType="Int32" Description="错误" />
-        <Column Name="Times" DataType="Int32" Description="次数" />
-        <Column Name="Speed" DataType="Int32" Description="速度" />
-        <Column Name="Enable" DataType="Boolean" Description="启用" />
+        <Column Name="Offset" DataType="Int32" ItemType="TimeSpan" Description="偏移。距离AntServer当前时间的秒数,避免因服务器之间的时间误差而错过部分数据,秒" />
+        <Column Name="MaxTask" DataType="Int32" Description="并行度。一共允许多少个任务并行处理,多执行端时平均分配,确保该作业整体并行度" Category="控制参数" />
+        <Column Name="MaxError" DataType="Int32" Description="最大错误。连续错误达到最大错误数时停止" Category="控制参数" />
+        <Column Name="MaxRetry" DataType="Int32" Description="最大重试。默认10次,超过该次数后将不再重试" Category="控制参数" />
+        <Column Name="MaxTime" DataType="Int32" ItemType="TimeSpan" Description="最大执行时间。默认600秒,超过该时间则认为执行器故障,将会把该任务分配给其它执行器" Category="控制参数" />
+        <Column Name="MaxRetain" DataType="Int32" Description="保留。任务项保留天数,超过天数的任务项将被删除,默认30天" Category="控制参数" />
+        <Column Name="MaxIdle" DataType="Int32" ItemType="TimeSpan" Description="最大空闲时间。默认3600秒,超过该时间不更新则认为应用程序故障,系统触发告警" Category="控制参数" />
+        <Column Name="ErrorDelay" DataType="Int32" ItemType="TimeSpan" Description="错误延迟。默认60秒,出错延迟后重新发放" Category="控制参数" />
+        <Column Name="Deadline" DataType="DateTime" Description="最后期限。超过该时间后,任务将不再执行" Category="控制参数" />
+        <Column Name="Total" DataType="Int64" Description="总数。任务处理的总数据,例如数据调度抽取得到的总行数,定时调度默认1" Category="统计" />
+        <Column Name="Success" DataType="Int64" Description="成功。成功处理的数据,取自于Handler.Execute返回值,或者ProcessItem返回true的个数" Category="统计" />
+        <Column Name="Error" DataType="Int32" Description="错误" Category="统计" />
+        <Column Name="Times" DataType="Int32" Description="次数" Category="统计" />
+        <Column Name="Speed" DataType="Int32" Description="速度" Category="统计" />
+        <Column Name="LastStatus" DataType="Int32" Description="最后状态。最后一次状态" Category="统计" Type="JobStatus" />
+        <Column Name="LastTime" DataType="DateTime" Description="最后时间。最后一次时间" Category="统计" />
         <Column Name="Data" DataType="String" Length="-1" Description="数据。Sql模板或C#模板" />
         <Column Name="CreateUserID" DataType="Int32" Description="创建人" Category="扩展" />
         <Column Name="CreateUser" DataType="String" Description="创建者" Category="扩展" />
@@ -181,7 +170,7 @@
         <Column Name="AppID" DataType="Int32" Map="App@ID@$" Description="应用" />
         <Column Name="JobID" DataType="Int32" Map="Job@ID@$" Description="作业" />
         <Column Name="Client" DataType="String" Description="客户端。IP加进程" />
-        <Column Name="Start" DataType="DateTime" Master="True" Description="开始。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用" />
+        <Column Name="DataTime" DataType="DateTime" Master="True" DataScale="time" Description="数据时间。大于等于,定时调度到达该时间点后触发(可能有偏移量),消息调度不适用" />
         <Column Name="End" DataType="DateTime" Description="结束。小于不等于,数据调度到达该时间点后触发(可能有偏移量),消息调度不适用" />
         <Column Name="BatchSize" DataType="Int32" Description="批大小。数据调度每次抽取数据的分页大小,或消息调度每次处理的消息数,定时调度不适用" />
         <Column Name="Total" DataType="Int32" Description="总数。任务处理的总数据,例如数据调度抽取得到的总行数,定时调度默认1" />
@@ -189,10 +178,10 @@
         <Column Name="Error" DataType="Int32" Description="错误" />
         <Column Name="Times" DataType="Int32" Description="次数" />
         <Column Name="Speed" DataType="Int32" Description="速度。每秒处理数,执行端计算" />
-        <Column Name="Cost" DataType="Int32" Description="耗时。秒,执行端计算的执行时间" />
-        <Column Name="FullCost" DataType="Int32" Description="全部耗时。秒,从任务发放到执行完成的时间" />
+        <Column Name="Cost" DataType="Int32" ItemType="TimeSpan" Description="耗时。秒,执行端计算的执行时间" />
+        <Column Name="FullCost" DataType="Int32" ItemType="TimeSpan" Description="全部耗时。秒,从任务发放到执行完成的时间" />
         <Column Name="Status" DataType="Int32" Description="状态" Type="JobStatus" />
-        <Column Name="MsgCount" DataType="Int32" Description="消费消息数" />
+        <Column Name="MsgCount" DataType="Int32" Description="消息。消费消息数" />
         <Column Name="Server" DataType="String" Description="服务器" />
         <Column Name="ProcessID" DataType="Int32" Description="进程" />
         <Column Name="Key" DataType="String" Description="最后键。Handler内记录作为样本的数据" />
@@ -205,9 +194,10 @@
         <Column Name="UpdateTime" DataType="DateTime" Description="更新时间" Category="扩展" />
       </Columns>
       <Indexes>
-        <Index Columns="JobID,Status,Start" />
-        <Index Columns="AppID,Client,Status" />
+        <Index Columns="JobID,DataTime" />
         <Index Columns="JobID,CreateTime" />
+        <Index Columns="JobID,UpdateTime" />
+        <Index Columns="AppID,Client,Status" />
       </Indexes>
     </Table>
     <Table Name="JobError" Description="作业错误。计算作业在执行过程中所发生的错误">
@@ -217,7 +207,7 @@
         <Column Name="JobID" DataType="Int32" Map="Job@ID@$" Description="作业" />
         <Column Name="TaskID" DataType="Int32" Map="JobTask@ID@$" Description="作业项" />
         <Column Name="Client" DataType="String" Description="客户端。IP加进程" />
-        <Column Name="Start" DataType="DateTime" Description="开始。大于等于" />
+        <Column Name="DataTime" DataType="DateTime" Description="数据时间。大于等于" />
         <Column Name="End" DataType="DateTime" Description="结束。小于,不等于" />
         <Column Name="Data" DataType="String" Length="2000" Description="数据" />
         <Column Name="Server" DataType="String" Description="服务器" />
@@ -236,11 +226,12 @@
     </Table>
     <Table Name="AppMessage" Description="应用消息。消息调度,某些作业负责生产消息,供其它作业进行消费处理">
       <Columns>
-        <Column Name="Id" DataType="Int64" PrimaryKey="True" Description="编号" />
+        <Column Name="Id" DataType="Int64" PrimaryKey="True" DataScale="time" Description="编号" />
         <Column Name="AppID" DataType="Int32" Map="App@ID@$" Description="应用" />
         <Column Name="JobID" DataType="Int32" Map="Job@ID@$" Description="作业。生产消息的作业" />
         <Column Name="Topic" DataType="String" Description="主题。区分作业下多种消息" />
         <Column Name="Data" DataType="String" Length="2000" Description="数据。可以是Json数据,比如StatID" />
+        <Column Name="DelayTime" DataType="DateTime" Description="延迟时间。延迟到该时间执行" />
         <Column Name="TraceId" DataType="String" Length="200" Description="追踪。链路追踪,用于APM性能追踪定位,还原该事件的调用链" Category="扩展" />
         <Column Name="CreateIP" DataType="String" Description="创建地址" Category="扩展" />
         <Column Name="CreateTime" DataType="DateTime" Description="创建时间" Category="扩展" />
@@ -249,6 +240,7 @@
       </Columns>
       <Indexes>
         <Index Columns="AppID,Topic,UpdateTime" />
+        <Index Columns="AppID,Topic,DelayTime" />
       </Indexes>
     </Table>
   </Tables>
Added +5 -0
diff --git a/AntJob.Data/xcodetool.bat b/AntJob.Data/xcodetool.bat
new file mode 100644
index 0000000..704a9a3
--- /dev/null
+++ b/AntJob.Data/xcodetool.bat
@@ -0,0 +1,5 @@
+xcode
+IF ERRORLEVEL 1 (
+    echo xcode ����ִ��ʧ�ܣ����ڰ�װ .NET ����...
+    dotnet tool install xcodetool -g --prerelease
+)
\ No newline at end of file
Modified +0 -0
diff --git a/AntJob.Data/xcodetool.exe b/AntJob.Data/xcodetool.exe
index 262a2aa..a69ad0d 100644
Binary files a/AntJob.Data/xcodetool.exe and b/AntJob.Data/xcodetool.exe differ
Modified +1 -1
diff --git a/AntJob.Extensions/AntJob.Extensions.csproj b/AntJob.Extensions/AntJob.Extensions.csproj
index 45dc81e..d07f05d 100644
--- a/AntJob.Extensions/AntJob.Extensions.csproj
+++ b/AntJob.Extensions/AntJob.Extensions.csproj
@@ -43,7 +43,7 @@
   </ItemGroup>
 
   <ItemGroup>
-    <PackageReference Include="NewLife.XCode" Version="11.11.2024.402" />
+    <PackageReference Include="NewLife.XCode" Version="11.16.2024.1005" />
   </ItemGroup>
   <ItemGroup>
     <ProjectReference Include="..\AntJob\AntJob.csproj" />
Modified +90 -20
diff --git a/AntJob.Extensions/DataHandler.cs b/AntJob.Extensions/DataHandler.cs
index dddf179..2e84a81 100644
--- a/AntJob.Extensions/DataHandler.cs
+++ b/AntJob.Extensions/DataHandler.cs
@@ -1,6 +1,7 @@
 using System.Collections;
 using AntJob.Data;
 using NewLife;
+using NewLife.Log;
 using XCode;
 using XCode.Configuration;
 
@@ -8,24 +9,57 @@ namespace AntJob.Extensions;
 
 /// <summary>数据处理作业(泛型)</summary>
 /// <remarks>
+/// 文档:https://newlifex.com/blood/antjob
+/// 
 /// 定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑。
-/// 任务切片条件:StartTime + Step + Offset &lt;= Now
+/// 任务切片条件:DataTime + Step + Offset &lt;= Now
 /// </remarks>
 /// <typeparam name="TEntity"></typeparam>
 public abstract class DataHandler<TEntity> : DataHandler where TEntity : Entity<TEntity>, new()
 {
     /// <summary>实例化数据处理作业</summary>
-    public DataHandler() => Factory = Entity<TEntity>.Meta.Factory;
+    public DataHandler()
+    {
+        Factory = Entity<TEntity>.Meta.Factory;
+
+        // 自动识别数据分区字段、主时间字段、雪花Id、更新时间字段、创建时间字段
+        var fact = Factory;
+        var field = fact.Fields.FirstOrDefault(e => e.Field != null && e.Field.DataScale.StartsWithIgnoreCase("time", "timeShard"));
+        field ??= fact.Fields.FirstOrDefault(e => e.PrimaryKey && !e.IsIdentity && e.Type == typeof(Int64));
+        field ??= fact.MasterTime;
+        field ??= fact.Fields.FirstOrDefault(e => e.Name.EqualIgnoreCase("UpdateTime"));
+        field ??= fact.Fields.FirstOrDefault(e => e.Name.EqualIgnoreCase("CreateTime"));
+
+        Field = field;
+    }
 
     #region 数据处理
+    /// <summary>分批抽取数据,一个任务内多次调用</summary>
+    /// <param name="ctx">上下文</param>
+    /// <param name="row">开始行数</param>
+    /// <returns></returns>
+    protected override Object Fetch(JobContext ctx, ref Int32 row)
+    {
+        var list = base.Fetch(ctx, ref row);
+        if (list is IEnumerable enumerable)
+        {
+            // 修改列表类型,由 IList<IEntity> 改为 IList<TEntity> ,方便用户使用
+            list = enumerable.Cast<TEntity>().ToList();
+        }
+
+        return list;
+    }
+
     /// <summary>处理一批数据</summary>
     /// <param name="ctx">上下文</param>
     /// <returns></returns>
-    protected override Int32 Execute(JobContext ctx)
+    public override Int32 Execute(JobContext ctx)
     {
         var count = 0;
         foreach (var item in ctx.Data as IEnumerable)
+        {
             if (ProcessItem(ctx, item as TEntity)) count++;
+        }
 
         return count;
     }
@@ -34,14 +68,16 @@ public abstract class DataHandler<TEntity> : DataHandler where TEntity : Entity<
     /// <param name="ctx">上下文</param>
     /// <param name="entity"></param>
     /// <returns></returns>
-    protected virtual Boolean ProcessItem(JobContext ctx, TEntity entity) => true;
+    public virtual Boolean ProcessItem(JobContext ctx, TEntity entity) => true;
     #endregion
 }
 
 /// <summary>数据处理作业</summary>
 /// <remarks>
+/// 文档:https://newlifex.com/blood/antjob
+/// 
 /// 定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑。
-/// 任务切片条件:StartTime + Step + Offset &lt;= Now
+/// 任务切片条件:DataTime + Step + Offset &lt;= Now
 /// </remarks>
 public abstract class DataHandler : Handler
 {
@@ -60,6 +96,9 @@ public abstract class DataHandler : Handler
 
     /// <summary>选择列</summary>
     public String Selects { get; set; }
+
+    /// <summary>每次都只查第一页</summary>
+    public bool KeepFirstPage { get; set; }
     #endregion
 
     #region 构造
@@ -68,11 +107,9 @@ public abstract class DataHandler : Handler
     #endregion
 
     #region 方法
-    /// <summary>开始</summary>
-    public override Boolean Start()
+    /// <summary>初始化。作业处理器启动之前</summary>
+    public override void Init()
     {
-        if (Active) return false;
-
         if (Factory == null) throw new ArgumentNullException(nameof(Factory));
 
         // 自动识别雪花Id字段
@@ -87,10 +124,38 @@ public abstract class DataHandler : Handler
         var job = Job;
         if (job.Step == 0) job.Step = 30;
 
-        // 获取最小时间
-        if (job.Start.Year < 2000) throw new InvalidOperationException("数据任务必须设置开始时间");
+        //todo 如果DataTime为空,则自动获取最小时间,并设置到DataTime,以减轻平台设置负担
 
-        return base.Start();
+        // 获取最小数据时间
+        if (job.DataTime.Year < 2000)
+        {
+            //throw new InvalidOperationException("数据任务必须设置开始时间");
+            job.DataTime = GetMinDataTime();
+        }
+    }
+
+    /// <summary>获取最小数据时间。初始化作业时自动设置首个数据时间</summary>
+    /// <returns></returns>
+    public virtual DateTime GetMinDataTime()
+    {
+        var field = Field;
+        if (field == null) return DateTime.MinValue;
+
+        // 按时间字段升序,取第一个
+        var list = Factory.FindAll(null, field.Asc(), field, 0, 1);
+        if (list.Count > 0)
+        {
+            var value = list[0][field.Name];
+            if (field.Type == typeof(Int64))
+            {
+                // 雪花Id
+                return Factory.Snow.TryParse(value.ToLong(), out var dt, out _, out _) ? dt.ToLocalTime() : DateTime.MinValue;
+            }
+
+            return value.ToDateTime();
+        }
+
+        return DateTime.MinValue;
     }
     #endregion
 
@@ -99,7 +164,7 @@ public abstract class DataHandler : Handler
     /// <param name="ctx"></param>
     protected override void OnProcess(JobContext ctx)
     {
-        var prov = Provider;
+        var span = DefaultSpan.Current;
         var row = 0;
         while (true)
         {
@@ -111,14 +176,17 @@ public abstract class DataHandler : Handler
             var data = Fetch(ctx, ref row);
 
             var list = data as IList;
-            if (list != null) ctx.Total += list.Count;
+            if (list != null)
+            {
+                ctx.Total += list.Count;
+                if (span != null) span.Value = ctx.Total;
+            }
             ctx.Data = data;
 
             if (data == null || list != null && list.Count == 0) break;
 
             // 报告进度
-            ctx.Status = JobStatus.处理中;
-            prov?.Report(ctx);
+            Report(ctx, JobStatus.处理中);
 
             // 批量处理
             ctx.Success += Execute(ctx);
@@ -141,7 +209,7 @@ public abstract class DataHandler : Handler
         if (task == null) throw new ArgumentNullException(nameof(task), "没有设置数据抽取配置");
 
         // 验证时间段
-        var start = task.Start;
+        var start = task.DataTime;
         var end = task.End;
 
         // 区间无效
@@ -166,7 +234,7 @@ public abstract class DataHandler : Handler
 
         if (!Where.IsNullOrEmpty()) exp &= Where;
 
-        var list = Factory.FindAll(exp, OrderBy, Selects, row, task.BatchSize);
+        var list = Factory.FindAll(exp, OrderBy, Selects, this.KeepFirstPage ? 0 : row, task.BatchSize);
 
         // 取到数据,需要滑动窗口
         if (list.Count > 0) row += list.Count;
@@ -177,11 +245,13 @@ public abstract class DataHandler : Handler
     /// <summary>处理一批数据</summary>
     /// <param name="ctx">上下文</param>
     /// <returns></returns>
-    protected override Int32 Execute(JobContext ctx)
+    public override Int32 Execute(JobContext ctx)
     {
         var count = 0;
         foreach (var item in ctx.Data as IEnumerable)
+        {
             if (ProcessItem(ctx, item as IEntity)) count++;
+        }
 
         return count;
     }
@@ -190,6 +260,6 @@ public abstract class DataHandler : Handler
     /// <param name="ctx">上下文</param>
     /// <param name="entity"></param>
     /// <returns></returns>
-    protected virtual Boolean ProcessItem(JobContext ctx, IEntity entity) => true;
+    public virtual Boolean ProcessItem(JobContext ctx, IEntity entity) => true;
     #endregion
 }
\ No newline at end of file
Modified +19 -7
diff --git a/AntJob.Extensions/SqlHandler.cs b/AntJob.Extensions/SqlHandler.cs
index b502fe7..197aecb 100644
--- a/AntJob.Extensions/SqlHandler.cs
+++ b/AntJob.Extensions/SqlHandler.cs
@@ -24,11 +24,11 @@ public class SqlHandler : Handler
     /// <summary>执行</summary>
     /// <param name="ctx"></param>
     /// <returns></returns>
-    protected override Int32 Execute(JobContext ctx)
+    public override Int32 Execute(JobContext ctx)
     {
-        //var sqls = ctx.Task.Data as String;
-        var sqls = Job.Data;
-        sqls = TemplateHelper.Build(sqls, ctx.Task.Start, ctx.Task.End);
+        var sqls = ctx.Task.Data;
+        //var sqls = Job.Data;
+        sqls = TemplateHelper.Build(sqls, ctx.Task.DataTime, ctx.Task.End);
         // 向调度中心返回解析后的Sql语句
         ctx.Remark = sqls;
 
@@ -54,12 +54,17 @@ public class SqlHandler : Handler
 
         // 打开事务
         foreach (var item in sections)
-            if (item.Action != SqlActions.Query) DAL.Create(item.ConnName).BeginTransaction();
+        {
+            if (item.Action != SqlActions.Query)
+                DAL.Create(item.ConnName).BeginTransaction();
+        }
+
         try
         {
             // 按顺序执行处理Sql语句
             DbTable dt = null;
             foreach (var section in sections)
+            {
                 switch (section.Action)
                 {
                     case SqlActions.Query:
@@ -79,16 +84,23 @@ public class SqlHandler : Handler
                     default:
                         break;
                 }
+            }
 
             // 提交事务
             foreach (var item in sections)
-                if (item.Action != SqlActions.Query) DAL.Create(item.ConnName).Commit();
+            {
+                if (item.Action != SqlActions.Query)
+                    DAL.Create(item.ConnName).Commit();
+            }
         }
         catch
         {
             // 回滚事务
             foreach (var item in sections)
-                if (item.Action != SqlActions.Query) DAL.Create(item.ConnName).Rollback();
+            {
+                if (item.Action != SqlActions.Query)
+                    DAL.Create(item.ConnName).Rollback();
+            }
 
             throw;
         }
Modified +2 -2
diff --git a/AntJob.Extensions/SqlMessage.cs b/AntJob.Extensions/SqlMessage.cs
index 10ef7ce..29f26fd 100644
--- a/AntJob.Extensions/SqlMessage.cs
+++ b/AntJob.Extensions/SqlMessage.cs
@@ -20,7 +20,7 @@ public class SqlMessage : MessageHandler
     /// <summary>根据解码后的消息执行任务</summary>
     /// <param name="ctx">上下文</param>
     /// <returns></returns>
-    protected override Int32 Execute(JobContext ctx)
+    public override Int32 Execute(JobContext ctx)
     {
         var msgs = ctx.Data as String[];
         var sqls = Job.Data;
@@ -45,7 +45,7 @@ public class SqlMessage : MessageHandler
     {
         if (dt == null || dt.Columns == null || dt.Columns.Length == 0 || dt.Rows == null || dt.Rows.Count == 0) return;
 
-        // select id as topic_roleId, id as topic_myId from role where updatetime>='{Start}' and updatetime<'{End}'
+        // select id as topic_roleId, id as topic_myId from role where updatetime>='{dt}' and updatetime<'{End}'
 
         for (var i = 0; i < dt.Columns.Length; i++)
         {
Modified +25 -3
diff --git a/AntJob.Extensions/SqlSection.cs b/AntJob.Extensions/SqlSection.cs
index 04778a3..ae4194a 100644
--- a/AntJob.Extensions/SqlSection.cs
+++ b/AntJob.Extensions/SqlSection.cs
@@ -32,6 +32,12 @@ public class SqlSection
     public String Sql { get; set; }
     #endregion
 
+    #region 构造
+    /// <summary>已重载</summary>
+    /// <returns></returns>
+    public override String ToString() => $"{ConnName}[{Action}]:{Sql}";
+    #endregion
+
     #region 解析
     /// <summary>分析sql语句集合,得到片段集合,以双换行分隔</summary>
     /// <param name="sqls"></param>
@@ -103,9 +109,25 @@ public class SqlSection
         // 解析数据表,如果目标表不存在,则返回
         var tableName = "";
         if (Sql.StartsWithIgnoreCase("delete "))
-            tableName = Sql.Substring(" from ", " ")?.Trim();
-        else if (Sql.StartsWithIgnoreCase("udpate "))
-            tableName = Sql.Substring("udpate ", " ")?.Trim();
+        {
+            var sep = " from ";
+            var p1 = Sql.IndexOf(sep);
+            if (p1 < 0) throw new InvalidDataException();
+
+            p1 += sep.Length;
+            var p2 = Sql.IndexOf(" ", p1);
+            tableName = p2 > 0 ? Sql[p1..p2].Trim() : Sql[p1..].Trim();
+        }
+        else if (Sql.StartsWithIgnoreCase("update "))
+        {
+            var sep = "update ";
+            var p1 = Sql.IndexOf(sep);
+            if (p1 < 0) throw new InvalidDataException();
+
+            p1 += sep.Length;
+            var p2 = Sql.IndexOf(" ", p1);
+            tableName = p2 > 0 ? Sql[p1..p2].Trim() : Sql[p1..].Trim();
+        }
 
         if (!tableName.IsNullOrEmpty())
         {
Modified +1 -1
diff --git a/AntJob.Server/AntJob.Server.csproj b/AntJob.Server/AntJob.Server.csproj
index 5eb23f6..a8b1272 100644
--- a/AntJob.Server/AntJob.Server.csproj
+++ b/AntJob.Server/AntJob.Server.csproj
@@ -29,7 +29,7 @@
     <Content Include="favicon.ico" />
   </ItemGroup>
   <ItemGroup>
-    <PackageReference Include="NewLife.Redis" Version="5.6.2024.402" />
+    <PackageReference Include="NewLife.Redis" Version="6.0.2024.1006" />
   </ItemGroup>
   <ItemGroup>
     <ProjectReference Include="..\AntJob.Data\AntJob.Data.csproj" />
Modified +43 -10
diff --git a/AntJob.Server/AntService.cs b/AntJob.Server/AntService.cs
index 5db006a..eaf3c37 100644
--- a/AntJob.Server/AntService.cs
+++ b/AntJob.Server/AntService.cs
@@ -5,18 +5,17 @@ using AntJob.Models;
 using AntJob.Server.Services;
 using NewLife;
 using NewLife.Caching;
-using NewLife.Data;
 using NewLife.Log;
 using NewLife.Net;
 using NewLife.Remoting;
+using NewLife.Remoting.Models;
 using NewLife.Threading;
-using XCode;
 
 namespace AntJob.Server;
 
 /// <summary>蚂蚁服务层,Rpc接口服务</summary>
 /// <remarks>
-/// 该服务层主要用于蚂蚁调度器与蚂蚁工作器之间的通讯,以及蚂蚁工作器与蚂蚁数据中心之间的通讯。
+/// 该服务层主要用于蚂蚁调度器与蚂蚁处理器之间的通讯,以及蚂蚁处理器与蚂蚁数据中心之间的通讯。
 /// 服务注册到内部对象容器IObjectContainer,要求宿主ApiServer指定ServiceProvider为IObjectContainer。
 /// </remarks>
 [Api(null)]
@@ -65,7 +64,7 @@ class AntService : IApi, IActionFilter
         }
         else
         {
-            throw new ApiException(401, $"{_Net.Remote}未登录!不能执行{act}");
+            throw new ApiException(ApiCode.Unauthorized, $"{_Net.Remote}未登录!不能执行{act}");
         }
     }
 
@@ -90,23 +89,46 @@ class AntService : IApi, IActionFilter
     }
     #endregion
 
-    #region 登录
+    #region 登录心跳
     /// <summary>应用登录</summary>
     /// <param name="model">模型</param>
     /// <returns></returns>
     [Api(nameof(Login))]
     public LoginResponse Login(LoginModel model)
     {
-        if (model.User.IsNullOrEmpty()) throw new ArgumentNullException(nameof(model.User));
+        // 兼容旧版本
+        if (model.Code.IsNullOrEmpty() && !model.User.IsNullOrEmpty()) model.Code = model.User;
+        if (model.Secret.IsNullOrEmpty() && !model.Pass.IsNullOrEmpty()) model.Secret = model.Pass;
 
-        var (app, rs) = _appService.Login(model, _Net.Remote.Host);
+        if (model.Code.IsNullOrEmpty()) throw new ArgumentNullException(nameof(model.Code));
+
+        var (app, online, rs) = _appService.Login(model, _Net.Remote.Host);
 
         // 记录当前用户
         Session["App"] = app;
+        Session["AppOnline"] = online;
 
         return rs;
     }
 
+    [Api(nameof(Logout))]
+    public ILogoutResponse Logout(String reason)
+    {
+        var app = Session["App"] as App;
+        var online = Session["AppOnline"] as AppOnline;
+
+        return _appService.Logout(app, online, reason, _Net.Remote.Host);
+    }
+
+    [Api(nameof(Ping))]
+    public IPingResponse Ping(PingRequest request)
+    {
+        var app = Session["App"] as App;
+        var online = Session["AppOnline"] as AppOnline;
+
+        return _appService.Ping(app, online, request, _Net.Remote.Host);
+    }
+
     /// <summary>获取当前应用的所有在线实例</summary>
     /// <returns></returns>
     [Api(nameof(GetPeers))]
@@ -125,22 +147,33 @@ class AntService : IApi, IActionFilter
     [Api(nameof(AddJobs))]
     public String[] AddJobs(JobModel[] jobs)
     {
-        if (jobs == null || jobs.Length == 0) return new String[0];
+        if (jobs == null || jobs.Length == 0) return [];
 
         return _jobService.AddJobs(_App, jobs);
     }
 
+    /// <summary>设置作业。支持控制作业启停、数据时间、步进等参数</summary>
+    /// <returns></returns>
+    [Api(nameof(SetJob))]
+    public IJob SetJob(JobModel job) => _jobService.SetJob(_App, job, ControllerContext.Current.Parameters);
+
     /// <summary>申请作业任务</summary>
     /// <param name="model">模型</param>
     /// <returns></returns>
     [Api(nameof(Acquire))]
     public ITask[] Acquire(AcquireModel model)
     {
+        var span = DefaultSpan.Current;
+        if (span != null) span.Value = 0;
+
         var job = model.Job?.Trim();
-        if (job.IsNullOrEmpty()) return new TaskModel[0];
+        if (job.IsNullOrEmpty()) return [];
 
         var ip = _Net.Remote.Host;
-        return _jobService.Acquire(_App, model, ip);
+        var tasks = _jobService.Acquire(_App, model, ip);
+        if (span != null) span.Value = tasks?.Length ?? 0;
+
+        return tasks;
     }
 
     /// <summary>生产消息</summary>
Modified +11 -0
diff --git a/AntJob.Server/Program.cs b/AntJob.Server/Program.cs
index 5ab5ada..5b4f408 100644
--- a/AntJob.Server/Program.cs
+++ b/AntJob.Server/Program.cs
@@ -4,6 +4,8 @@ using NewLife.Caching;
 using NewLife.Caching.Services;
 using NewLife.Log;
 using NewLife.Model;
+using NewLife.Security;
+using NewLife.Serialization;
 using Stardust;
 using XCode;
 
@@ -28,6 +30,9 @@ if (set2.IsNew)
     set2.Save();
 }
 
+//// 过渡期暂时使用FastJson,为了兼容旧数据序列化Start
+//JsonHelper.Default = new FastJson();
+
 services.AddSingleton(AntJobSetting.Current);
 
 // 分布式缓存,锚定配置中心RedisCache,若无配置则使用本地MemoryCache
@@ -36,9 +41,15 @@ services.AddSingleton<ICacheProvider, RedisCacheProvider>();
 services.AddSingleton<AppService>();
 services.AddSingleton<JobService>();
 
+// 注册密码提供者,用于通信过程中保护密钥,避免明文传输
+services.AddSingleton<IPasswordProvider>(new SaltPasswordProvider { Algorithm = "md5", SaltTime = 60 });
+
 // 预热数据层,执行反向工程建表等操作
 EntityFactory.InitConnection("Ant");
 
+// 修正旧版数据
+_ = Task.Run(() => JobService.FixOld());
+
 // 友好退出
 var host = services.BuildHost();
 
Modified +75 -35
diff --git a/AntJob.Server/Services/AppService.cs b/AntJob.Server/Services/AppService.cs
index 250636c..6dcb19b 100644
--- a/AntJob.Server/Services/AppService.cs
+++ b/AntJob.Server/Services/AppService.cs
@@ -3,9 +3,9 @@ using AntJob.Data;
 using AntJob.Data.Entity;
 using AntJob.Models;
 using NewLife;
-using NewLife.Caching;
 using NewLife.Log;
 using NewLife.Remoting;
+using NewLife.Remoting.Models;
 using NewLife.Security;
 using NewLife.Web;
 
@@ -13,14 +13,14 @@ namespace AntJob.Server.Services;
 
 public class AppService
 {
-    private readonly ICacheProvider _cacheProvider;
-    private readonly ITracer _tracer;
+    private readonly IPasswordProvider _passwordProvider;
+    private readonly AntJobSetting _setting;
     private readonly ILog _log;
 
-    public AppService(ICacheProvider cacheProvider, ITracer tracer, ILog log)
+    public AppService(IPasswordProvider passwordProvider, AntJobSetting setting, ILog log)
     {
-        _cacheProvider = cacheProvider;
-        _tracer = tracer;
+        _passwordProvider = passwordProvider;
+        _setting = setting;
         _log = log;
     }
 
@@ -28,52 +28,57 @@ public class AppService
     /// <summary>应用登录</summary>
     /// <param name="model">模型</param>
     /// <returns></returns>
-    public (App, LoginResponse) Login(LoginModel model, String ip)
+    public (App, AppOnline, LoginResponse) Login(LoginModel model, String ip)
     {
-        if (model.User.IsNullOrEmpty()) throw new ArgumentNullException(nameof(model.User));
+        if (model.Code.IsNullOrEmpty()) throw new ArgumentNullException(nameof(model.Code));
 
-        _log.Info("[{0}]从[{1}]登录[{2}@{3}]", model.User, ip, model.Machine, model.ProcessId);
+        _log.Info("[{0}]从[{1}]登录[{2}]", model.Code, ip, model.ClientId);
 
         // 找应用
         var autoReg = false;
-        var app = App.FindByName(model.User);
-        if (app == null || app.Secret.MD5() != model.Pass)
+        var app = App.FindByName(model.Code);
+
+        // 登录密码未设置或者未提交,则执行动态注册
+        var secret = model.Secret;
+        if (app == null || !app.Secret.IsNullOrEmpty() && secret.IsNullOrEmpty())
         {
-            app = CheckApp(app, model.User, model.Pass, ip);
-            if (app == null) throw new ArgumentOutOfRangeException(nameof(model.User));
+            app = CheckApp(app, model.Code, model.Secret, ip);
+            if (app == null) throw new ArgumentOutOfRangeException(nameof(model.Code));
 
             autoReg = true;
         }
 
-        if (app == null) throw new Exception($"应用[{model.User}]不存在!");
+        if (app == null) throw new Exception($"应用[{model.Code}]不存在!");
         if (!app.Enable) throw new Exception("已禁用!");
 
         // 核对密码
         if (!autoReg && !app.Secret.IsNullOrEmpty())
         {
-            var pass2 = app.Secret.MD5();
-            if (model.Pass != pass2) throw new Exception("密码错误!");
+            if (secret.IsNullOrEmpty() || !_passwordProvider.Verify(app.Secret, secret)) throw new Exception("密码错误!");
         }
 
         // 版本和编译时间
         if (app.Version.IsNullOrEmpty() || app.Version.CompareTo(model.Version) < 0) app.Version = model.Version;
-        if (app.CompileTime < model.Compile) app.CompileTime = model.Compile;
+        var compile = model.Compile.ToDateTime().ToLocalTime();
+        if (app.CompileTime < compile) app.CompileTime = compile;
         if (app.DisplayName.IsNullOrEmpty()) app.DisplayName = model.DisplayName;
 
         app.Save();
 
         // 应用上线
-        var online = CreateOnline(app, ip, model.Machine, model.ProcessId);
+        var online = CreateOnline(app, ip, model.ClientId);
+        online.Name = model.Machine;
+        online.ProcessId = model.ProcessId;
         online.Version = model.Version;
-        online.CompileTime = model.Compile;
+        online.CompileTime = compile;
         online.Save();
 
-        WriteHistory(app, autoReg ? "注册" : "登录", true, $"[{model.User}/{model.Pass}]在[{model.Machine}@{model.ProcessId}]登录[{app}]成功");
+        WriteHistory(app, autoReg ? "注册" : "登录", true, $"[{model.Code}/{model.Secret}]在[{model.ClientId}]登录[{app}]成功", ip);
 
-        var rs = new LoginResponse { Name = app.Name, DisplayName = app.DisplayName };
+        var rs = new LoginResponse { Name = app.Name };
         if (autoReg) rs.Secret = app.Secret;
 
-        return (app, rs);
+        return (app, online, rs);
     }
 
     protected virtual App CheckApp(App app, String user, String pass, String ip)
@@ -83,21 +88,21 @@ public class AppService
         if (app == null)
         {
             // 是否支持自动注册
-            var set = AntJobSetting.Current;
-            if (!set.AutoRegistry) throw new Exception($"找不到应用[{name}]");
+            //var set = AntJobSetting.Current;
+            if (!_setting.AutoRegistry) throw new Exception($"找不到应用[{name}]");
 
             app = new App
             {
-                Secret = Rand.NextString(16)
+                //Secret = Rand.NextString(16)
             };
         }
         else if (app.Secret.MD5() != pass)
         {
             // 是否支持自动注册
-            var set = AntJobSetting.Current;
-            if (!set.AutoRegistry) throw new Exception($"应用[{name}]申请重新激活,但服务器设置禁止自动注册");
+            //var set = AntJobSetting.Current;
+            if (!_setting.AutoRegistry) throw new Exception($"应用[{name}]申请重新激活,但服务器设置禁止自动注册");
 
-            if (app.Secret.IsNullOrEmpty()) app.Secret = Rand.NextString(16);
+            //if (app.Secret.IsNullOrEmpty()) app.Secret = Rand.NextString(16);
         }
 
         if (app.ID == 0)
@@ -117,9 +122,48 @@ public class AppService
 
         return app;
     }
+
+    public ILogoutResponse Logout(App app, AppOnline online, String reason, String ip)
+    {
+        if (app != null)
+        {
+            online ??= GetOnline(app, ip);
+            if (online != null)
+            {
+                WriteHistory(app, "注销", true, reason, ip);
+
+                online.Delete();
+            }
+        }
+
+        return new LogoutResponse { Name = app?.Name };
+    }
     #endregion
 
     #region 在线状态
+    public IPingResponse Ping(App app, AppOnline online, IPingRequest request, String ip)
+    {
+        if (app != null)
+        {
+            online ??= GetOnline(app, ip);
+            if (online != null)
+            {
+                if (request is PingRequest req)
+                {
+                }
+                online.UpdateIP = ip;
+
+                online.Save();
+            }
+        }
+
+        return new PingResponse
+        {
+            Time = request.Time,
+            ServerTime = DateTime.UtcNow.ToLong(),
+        };
+    }
+
     /// <summary>获取当前应用的所有在线实例</summary>
     /// <returns></returns>
     public PeerModel[] GetPeers(App app)
@@ -129,17 +173,13 @@ public class AppService
         return olts.Select(e => e.ToModel()).ToArray();
     }
 
-    AppOnline CreateOnline(App app, String ip, String machine, Int32 pid)
+    AppOnline CreateOnline(App app, String ip, String clientId)
     {
         var online = GetOnline(app, ip);
-        online.Client = $"{(ip.IsNullOrEmpty() ? machine : ip)}@{pid}";
-        online.Name = machine;
-        online.ProcessId = pid;
+        online.Client = clientId;
         online.UpdateIP = ip;
-        //online.Version = version;
 
         online.Server = Environment.MachineName;
-        //online.Save();
 
         return online;
     }
@@ -147,7 +187,7 @@ public class AppService
     public AppOnline GetOnline(App app, String ip)
     {
         var ins = $"{app.Name}@{ip}";
-        var online = AppOnline.FindByInstance(ins) ?? new AppOnline { CreateIP = ip };
+        var online = AppOnline.FindByInstance(ins) ?? new AppOnline { Enable = true, CreateIP = ip };
         online.AppID = app.ID;
         online.Instance = ins;
 
Modified +629 -103
diff --git a/AntJob.Server/Services/JobService.cs b/AntJob.Server/Services/JobService.cs
index 7c87641..99b2c0a 100644
--- a/AntJob.Server/Services/JobService.cs
+++ b/AntJob.Server/Services/JobService.cs
@@ -3,28 +3,21 @@ using AntJob.Data.Entity;
 using AntJob.Models;
 using NewLife;
 using NewLife.Caching;
-using NewLife.Data;
 using NewLife.Log;
+using NewLife.Reflection;
 using NewLife.Serialization;
 using NewLife.Threading;
 using XCode;
+using XCode.DataAccessLayer;
 
 namespace AntJob.Server.Services;
 
-public class JobService
+public class JobService(AppService appService, ICacheProvider cacheProvider, ITracer tracer, ILog log)
 {
-    private readonly AppService _appService;
-    private readonly ICacheProvider _cacheProvider;
-    private readonly ITracer _tracer;
-    private readonly ILog _log;
-
-    public JobService(AppService appService, ICacheProvider cacheProvider, ITracer tracer, ILog log)
-    {
-        _appService = appService;
-        _cacheProvider = cacheProvider;
-        _tracer = tracer;
-        _log = log;
-    }
+    private readonly AppService _appService = appService;
+    private readonly ICacheProvider _cacheProvider = cacheProvider;
+    private readonly ITracer _tracer = tracer;
+    private readonly ILog _log = log;
 
     #region 业务
     /// <summary>获取指定名称的作业</summary>
@@ -33,7 +26,26 @@ public class JobService
     {
         var jobs = Job.FindAllByAppID(app.ID);
 
-        return jobs.Select(e => e.ToModel()).ToArray();
+        //return jobs.Select(e => e.ToModel()).ToArray();
+
+        // 服务端下发的时间,约定UTC
+        var rs = new List<IJob>();
+        foreach (var job in jobs)
+        {
+            var model = job.ToModel();
+#pragma warning disable CS0612 // 类型或成员已过时
+            // 为兼容旧版
+            model.Start = model.DataTime;
+#pragma warning restore CS0612 // 类型或成员已过时
+
+            model.DataTime = model.DataTime.ToUniversalTime();
+            if (model.End.Year > 1000)
+                model.End = model.End.ToUniversalTime();
+
+            rs.Add(model);
+        }
+
+        return rs.ToArray();
     }
 
     /// <summary>批量添加作业</summary>
@@ -41,79 +53,114 @@ public class JobService
     /// <returns></returns>
     public String[] AddJobs(App app, JobModel[] jobs)
     {
-        if (jobs == null || jobs.Length == 0) return new String[0];
+        if (jobs == null || jobs.Length == 0) return [];
 
         var myJobs = Job.FindAllByAppID(app.ID);
         var list = new List<String>();
-        foreach (var item in jobs)
+        foreach (var model in jobs)
         {
-            var jb = myJobs.FirstOrDefault(e => e.Name.EqualIgnoreCase(item.Name));
-            jb ??= new Job
+            // 客户端上报的时间,约定UTC,需要转为本地时间
+            var job = myJobs.FirstOrDefault(e => e.Name.EqualIgnoreCase(model.Name));
+            job ??= new Job
             {
                 AppID = app.ID,
-                Name = item.Name,
-                Enable = item.Enable,
-                Start = item.Start,
-                End = item.End,
-                Offset = item.Offset,
-                Step = item.Step,
-                BatchSize = item.BatchSize,
-                MaxTask = item.MaxTask,
-                Mode = item.Mode,
+                Name = model.Name,
+                Enable = model.Enable,
+                DataTime = model.DataTime.ToLocalTime(),
+                End = model.End.Year > 1000 ? model.End.ToLocalTime() : model.End,
+                Offset = model.Offset,
+                Step = model.Step,
+                BatchSize = model.BatchSize,
+                MaxTask = model.MaxTask,
+                Mode = model.Mode,
                 MaxError = 100,
             };
 
-            if (item.Mode > 0) jb.Mode = item.Mode;
-            if (!item.DisplayName.IsNullOrEmpty()) jb.DisplayName = item.DisplayName;
-            if (!item.Description.IsNullOrEmpty()) jb.Remark = item.Description;
-            if (!item.ClassName.IsNullOrEmpty()) jb.ClassName = item.ClassName;
-            if (jb.Topic.IsNullOrEmpty()) jb.Topic = item.Topic;
+            if (model.Mode > 0) job.Mode = model.Mode;
+            if (!model.DisplayName.IsNullOrEmpty()) job.DisplayName = model.DisplayName;
+            if (!model.Description.IsNullOrEmpty()) job.Remark = model.Description;
+            if (!model.ClassName.IsNullOrEmpty()) job.ClassName = model.ClassName;
+            if (job.Cron.IsNullOrEmpty()) job.Cron = model.Cron;
+            if (job.Topic.IsNullOrEmpty()) job.Topic = model.Topic;
 
-            if (jb.Save() != 0)
+            // 添加定时作业时,计算下一次执行时间
+            if (job.ID == 0 && job.Mode == JobModes.Time)
+            {
+                var next = job.GetNext();
+                if (next.Year > 2000) job.DataTime = next;
+            }
+
+            if (job.Save() != 0)
             {
                 // 更新作业数
-                jb.SaveAsync();
+                job.SaveAsync();
 
-                list.Add(jb.Name);
+                list.Add(job.Name);
             }
         }
 
         return list.ToArray();
     }
 
+    /// <summary>设置作业。支持控制作业启停、数据时间、步进等参数</summary>
+    /// <param name="app"></param>
+    /// <param name="model"></param>
+    /// <returns></returns>
+    public IJob SetJob(App app, JobModel model, IDictionary<String, Object> parameters)
+    {
+        var job = Job.FindByAppIDAndName(app.ID, model.Name);
+        if (job == null) return null;
+
+        // 可以修改的字段
+        var fs = new[] { nameof(IJob.Enable), nameof(IJob.DataTime), nameof(IJob.End), nameof(IJob.Step), nameof(JobModel.DisplayName), nameof(JobModel.Description), nameof(IJob.Topic), nameof(IJob.Data) };
+        foreach (var item in fs)
+        {
+            if (parameters.ContainsKey(item))
+                job.SetItem(item, model.GetValue(item));
+        }
+
+        job.Update();
+
+        return job.ToModel();
+    }
+
     /// <summary>申请作业任务</summary>
     /// <param name="model">模型</param>
     /// <returns></returns>
     public ITask[] Acquire(App app, AcquireModel model, String ip)
     {
-        var job = model.Job?.Trim();
-        if (job.IsNullOrEmpty()) return new TaskModel[0];
+        var jobName = model.Job?.Trim();
+        if (jobName.IsNullOrEmpty()) return [];
 
-        if (app == null) return new TaskModel[0];
+        if (app == null) return [];
 
         // 应用停止发放作业
         app = App.FindByID(app.ID) ?? app;
-        if (!app.Enable) return new TaskModel[0];
-        var jb = app.Jobs.FirstOrDefault(e => e.Name == job);
+        if (!app.Enable) return [];
+
+        var job = app.Jobs.FirstOrDefault(e => e.Name == jobName);
 
         // 全局锁,确保单个作业只有一个线程在分配作业
-        using var ck = _cacheProvider.AcquireLock($"antjob:lock:{jb.ID}", 15_000);
+        using var ck = _cacheProvider.AcquireLock($"antjob:lock:{job.ID}", 15_000);
 
         // 找到作业。为了确保能够快速拿到新的作业参数,这里做二次查询
-        if (jb != null)
-            jb = Job.Find(Job._.ID == jb.ID);
+        if (job != null)
+            job = Job.Find(Job._.ID == job.ID);
         else
-            jb = Job.FindByAppIDAndName(app.ID, job);
+            job = Job.FindByAppIDAndName(app.ID, jobName);
 
-        if (jb == null) throw new XException($"应用[{app.ID}/{app.Name}]下未找到作业[{job}]");
-        if (jb.Step == 0 || jb.Start.Year <= 2000) throw new XException("作业[{0}/{1}]未设置开始时间或步进", jb.ID, jb.Name);
+        if (job == null) throw new XException($"应用[{app.ID}/{app.Name}]下未找到作业[{jobName}]");
+        if (job.DataTime.Year <= 2000) throw new XException("作业[{0}/{1}]未设置数据时间", job.ID, job.Name);
 
+        // 应用在线,但可能禁止向其分配任务
         var online = _appService.GetOnline(app, ip);
+        if (!online.Enable) return [];
 
         var list = new List<JobTask>();
 
-        // 每分钟检查一下错误任务和中断任务
-        CheckErrorTask(app, jb, model.Count, list, ip);
+        // 首先检查延迟任务和错误任务
+        CheckDelayTask(app, job, model.Count, list, ip);
+        CheckOldTask(app, job, model.Count, list, ip);
 
         // 错误项不够时,增加切片
         if (list.Count < model.Count)
@@ -123,63 +170,146 @@ public class JobService
             var pid = online.ProcessId;
             //var topic = ps["topic"] + "";
 
-            switch (jb.Mode)
+            switch (job.Mode)
             {
                 case JobModes.Message:
-                    list.AddRange(jb.AcquireMessage(model.Topic, server, ip, pid, model.Count - list.Count, _cacheProvider.Cache));
+                    list.AddRange(AcquireMessage(job, model.Topic, server, ip, pid, model.Count - list.Count, _cacheProvider.Cache));
                     break;
+                case JobModes.Time:
+                    {
+                        // 如果能够切片,则查询数据库后进入,避免缓存导致重复
+                        if (TrySplitTime(job, out _))
+                        {
+                            // 申请任务前,不能再查数据库,那样子会导致多线程脏读,从而出现多客户端分到相同任务的情况
+                            //jb = Job.FindByKey(jb.ID);
+                            list.AddRange(Acquire(job, server, ip, pid, model.Count - list.Count, _cacheProvider.Cache));
+                        }
+                        break;
+                    }
                 case JobModes.Data:
-                case JobModes.Alarm:
                 //case JobModes.CSharp:
                 //case JobModes.Sql:
                 default:
                     {
                         // 如果能够切片,则查询数据库后进入,避免缓存导致重复
-                        if (jb.TrySplit(jb.Start, jb.Step, out var end))
+                        if (TrySplit(job, job.DataTime, out var end))
                         {
                             // 申请任务前,不能再查数据库,那样子会导致多线程脏读,从而出现多客户端分到相同任务的情况
                             //jb = Job.FindByKey(jb.ID);
-                            list.AddRange(jb.Acquire(server, ip, pid, model.Count - list.Count, _cacheProvider.Cache));
+                            list.AddRange(Acquire(job, server, ip, pid, model.Count - list.Count, _cacheProvider.Cache));
                         }
                     }
                     break;
             }
         }
 
+        if (list.Count > 0)
+        {
+            job.LastStatus = JobStatus.处理中;
+            job.LastTime = DateTime.Now;
+
+            job.UpdateTime = DateTime.Now;
+            job.Save();
+        }
+
         // 记录状态
         online.Tasks += list.Count;
         online.SaveAsync();
 
-        return list.Select(e => e.ToModel()).ToArray();
+        //return list.Select(e => e.ToModel()).ToArray();
+
+        // 服务端下发的时间,约定UTC
+        var rs = new List<ITask>();
+        foreach (var task in list)
+        {
+            var model2 = task.ToModel();
+#pragma warning disable CS0612 // 类型或成员已过时
+            // 为兼容旧版
+            model2.Start = model2.DataTime;
+#pragma warning restore CS0612 // 类型或成员已过时
+
+            model2.DataTime = model2.DataTime.ToUniversalTime();
+            if (model2.End.Year > 1000)
+                model2.End = model2.End.ToUniversalTime();
+
+            rs.Add(model2);
+        }
+
+        return rs.ToArray();
+    }
+
+    private void CheckDelayTask(App app, Job job, Int32 count, List<JobTask> list, String ip)
+    {
+        // 获取下一次检查时间
+        var cache = _cacheProvider.Cache;
+        var nextKey = $"antjob:NextDelay_{job.ID}";
+        var now = TimerX.Now;
+        var next = cache.Get<DateTime>(nextKey);
+        if (next >= now)
+        {
+            // 如果常规检查时间未到,看看是否有挂起任务
+            var pendingKey = $"antjob:PendingDelay_{job.ID}";
+            var pending = cache.Get<DateTime>(pendingKey);
+            if (pending.Year > 2000) next = pending;
+        }
+        if (next <= now)
+        {
+            var online = _appService.GetOnline(app, ip);
+
+            next = now.AddSeconds(15);
+            list.AddRange(AcquireDelay(job, online.Server, ip, online.ProcessId, count, cache));
+
+            if (list.Count > 0)
+            {
+                // 既然有数据,待会还来
+                next = now;
+
+                _log.Info("作业[{0}/{1}]准备处理[{2}]个延迟任务 [{3}]", app, job.Name, list.Count, list.Join(",", e => e.ID + ""));
+            }
+
+            cache.Set(nextKey, next);
+        }
     }
 
-    private void CheckErrorTask(App app, Job jb, Int32 count, List<JobTask> list, String ip)
+    private void CheckOldTask(App app, Job job, Int32 count, List<JobTask> list, String ip)
     {
         // 每分钟检查一下错误任务和中断任务
-        var nextKey = $"antjob:NextAcquireOld_{jb.ID}";
+        var cache = _cacheProvider.Cache;
+        var nextKey = $"antjob:NextOld_{job.ID}";
         var now = TimerX.Now;
-        var next = _cacheProvider.Cache.Get<DateTime>(nextKey);
+        var next = cache.Get<DateTime>(nextKey);
         if (next < now)
         {
             var online = _appService.GetOnline(app, ip);
 
             next = now.AddSeconds(60);
-            list.AddRange(jb.AcquireOld(online.Server, ip, online.ProcessId, count, _cacheProvider.Cache));
+            list.AddRange(AcquireOld(job, online.Server, ip, online.ProcessId, count, cache));
 
             if (list.Count > 0)
             {
                 // 既然有数据,待会还来
                 next = now;
 
-                var n1 = list.Count(e => e.Status == JobStatus.错误 || e.Status == JobStatus.取消);
-                var n2 = list.Count(e => e.Status == JobStatus.就绪 || e.Status == JobStatus.抽取中 || e.Status == JobStatus.处理中);
-                _log.Info("作业[{0}/{1}]准备处理[{2}]个错误和[{3}]超时任务 [{4}]", app, jb.Name, n1, n2, list.Join(",", e => e.ID + ""));
+                var n1 = list.Count(e => e.Status is JobStatus.错误);
+                var n2 = list.Count(e => e.Status is JobStatus.就绪 or JobStatus.抽取中 or JobStatus.处理中);
+                _log.Info("作业[{0}/{1}]准备处理[{2}]个错误和[{3}]超时任务 [{4}]", app, job.Name, n1, n2, list.Join(",", e => e.ID + ""));
             }
-            else
-                _cacheProvider.Cache.Set(nextKey, next);
+
+            cache.Set(nextKey, next);
         }
     }
 
+    /// <summary>设置作业最近一个延迟任务的时间</summary>
+    /// <param name="jobId"></param>
+    /// <param name="nextTime"></param>
+    public void SetDelay(Int32 jobId, DateTime nextTime)
+    {
+        var nextKey = $"antjob:PendingDelay_{jobId}";
+        var next = _cacheProvider.Cache.Get<DateTime>(nextKey);
+        if (next.Year < 2000 || next > nextTime)
+            _cacheProvider.Cache.Set(nextKey, nextTime, 600);
+    }
+
     /// <summary>生产消息</summary>
     /// <param name="model">模型</param>
     /// <returns></returns>
@@ -231,12 +361,10 @@ public class JobService
 
             jm.CreateTime = jm.UpdateTime = now;
 
-            // 雪花Id直接指定消息在未来的消费时间
             if (model.DelayTime > 0)
-            {
-                jm.Id = snow.NewId(dTime);
-                jm.UpdateTime = dTime;
-            }
+                jm.DelayTime = dTime;
+            else
+                jm.DelayTime = now;
 
             ms.Add(jm);
         }
@@ -265,69 +393,98 @@ public class JobService
 
     #region 状态报告
     /// <summary>报告状态(进度、成功、错误)</summary>
-    /// <param name="task"></param>
+    /// <param name="result"></param>
     /// <returns></returns>
-    public Boolean Report(App app, TaskResult task, String ip)
+    public Boolean Report(App app, TaskResult result, String ip)
     {
-        if (task == null || task.ID == 0) throw new InvalidOperationException("无效操作 TaskID=" + task?.ID);
+        if (result == null || result.ID == 0) throw new InvalidOperationException("无效操作 TaskID=" + result?.ID);
 
         // 判断是否有权
 
-        var jt = JobTask.FindByID(task.ID) ?? throw new InvalidOperationException($"找不到任务[{task.ID}]");
-        var job = Job.FindByID(jt.JobID);
+        var task = JobTask.FindByID(result.ID) ?? throw new InvalidOperationException($"找不到任务[{result.ID}]");
+        var job = Job.FindByID(task.JobID);
         if (job == null || job.AppID != app.ID)
         {
-            _log.Info(task.ToJson());
-            throw new InvalidOperationException($"应用[{app}]无权操作作业[{job}#{jt}]");
+            _log.Info(result.ToJson());
+            throw new InvalidOperationException($"应用[{app}]无权操作作业[{job}#{task}]");
         }
 
         // 只有部分字段允许客户端修改
-        if (task.Status > 0) jt.Status = task.Status;
-
-        jt.Speed = task.Speed;
-        jt.Total = task.Total;
-        jt.Success = task.Success;
-        jt.Cost = task.Cost;
-        jt.Key = task.Key;
-        jt.Message = task.Message;
-
-        // 已终结的作业,汇总统计
-        if (task.Status == JobStatus.完成 || task.Status == JobStatus.错误)
+        if (result.Status > 0) task.Status = result.Status;
+
+        task.Speed = result.Speed;
+        task.Total = result.Total;
+        task.Success = result.Success;
+        task.Cost = (Int32)Math.Round(result.Cost / 1000d);
+        task.Key = result.Key;
+        task.Message = result.Message;
+
+        var traceId = result.TraceId ?? DefaultSpan.Current + "";
+        // 已终结的任务,汇总统计
+        if (result.Status is JobStatus.完成)
         {
-            jt.Times++;
+            task.Times++;
 
-            SetJobFinish(job, jt);
+            SetJobFinish(job, task);
 
             // 记录状态
-            _appService.UpdateOnline(app, jt, ip);
+            _appService.UpdateOnline(app, task, ip);
         }
-        if (task.Status == JobStatus.错误)
+        else if (result.Status == JobStatus.错误)
         {
-            SetJobError(job, jt);
-
-            jt.Error++;
+            task.Times++;
+            task.Error++;
             //ji.Message = err.Message;
 
+            SetJobError(job, task, ip);
+
             // 出错时判断如果超过最大错误数,则停止作业
             CheckMaxError(app, job);
+
+            // 记录状态
+            _appService.UpdateOnline(app, task, ip);
+        }
+        else if (result.Status == JobStatus.延迟)
+        {
+            using var span = _tracer?.NewSpan("Delay", new { job.Name, task.DataTime, NextTime = result.NextTime.ToLocalTime() });
+
+            task.Times++;
+
+            // 延迟任务的下一次执行时间
+            if (result.NextTime.Year > 2000)
+            {
+                task.UpdateTime = result.NextTime.ToLocalTime();
+
+                SetDelay(task.JobID, task.UpdateTime);
+            }
+            else
+            {
+                SetDelay(task.JobID, DateTime.Now.AddSeconds(job.ErrorDelay));
+            }
         }
 
         // 从创建到完成的全部耗时
-        var ts = DateTime.Now - jt.CreateTime;
-        jt.FullCost = (Int32)ts.TotalSeconds;
+        var ts = DateTime.Now - task.CreateTime;
+        task.FullCost = (Int32)ts.TotalSeconds;
 
-        jt.SaveAsync();
-        //ji.Save();
+        task.Update();
+
+        job.LastStatus = result.Status;
+        job.LastTime = DateTime.Now;
+        job.SaveAsync();
 
         return true;
     }
 
     private void SetJobFinish(Job job, JobTask task)
     {
+        using var span = _tracer?.NewSpan(nameof(SetJobFinish), new { job.Name, task.DataTime });
+
         job.Total += task.Total;
         job.Success += task.Success;
-        job.Error += task.Error;
+        //job.Error += task.Error;
         job.Times++;
+        if (task.Status == JobStatus.错误) job.Error++;
 
         var ths = job.MaxTask;
 
@@ -337,7 +494,7 @@ public class JobService
         {
             // 平均速度
             if (job.Speed > 0)
-                job.Speed = (Int32)((job.Speed * 3L + p1) / 4);
+                job.Speed = (Int32)Math.Round((job.Speed * 3d + p1) / 4);
             else
                 job.Speed = p1;
         }
@@ -346,14 +503,16 @@ public class JobService
         //job.Save();
     }
 
-    private JobError SetJobError(Job job, JobTask task)
+    private JobError SetJobError(Job job, JobTask task, String ip)
     {
+        using var span = _tracer?.NewSpan(nameof(SetJobError), new { job.Name, task.DataTime });
+
         var err = new JobError
         {
             AppID = job.AppID,
             JobID = job.ID,
             TaskID = task.ID,
-            Start = task.Start,
+            DataTime = task.DataTime,
             End = task.End,
             Data = task.Data,
 
@@ -361,6 +520,8 @@ public class JobService
             ProcessID = task.ProcessID,
             Client = task.Client,
 
+            CreateIP = ip,
+            UpdateIP = ip,
             CreateTime = DateTime.Now,
             UpdateTime = DateTime.Now,
         };
@@ -377,7 +538,7 @@ public class JobService
     private void CheckMaxError(App app, Job job)
     {
         // 出错时判断如果超过最大错误数,则停止作业
-        var maxError = job.MaxError < 1 ? 100 : job.MaxError;
+        var maxError = job.MaxError <= 0 ? 100 : job.MaxError;
         if (job.Enable && job.Error > maxError)
         {
             job.MaxError = maxError;
@@ -388,4 +549,369 @@ public class JobService
         }
     }
     #endregion
+
+    #region 申请任务
+    /// <summary>用于表示切片批次的序号</summary>
+    private Int32 _idxBatch;
+
+    /// <summary>申请任务分片(时间调度&数据调度)</summary>
+    /// <param name="server">申请任务的服务端</param>
+    /// <param name="ip">申请任务的IP</param>
+    /// <param name="pid">申请任务的服务端进程ID</param>
+    /// <param name="count">要申请的任务个数</param>
+    /// <param name="cache">缓存对象</param>
+    /// <returns></returns>
+    public IList<JobTask> Acquire(Job job, String server, String ip, Int32 pid, Int32 count, ICache cache)
+    {
+        var list = new List<JobTask>();
+
+        if (!job.Enable) return list;
+
+        using var span = _tracer?.NewSpan(nameof(Acquire), new { job.Name, server, ip, pid, count });
+
+        using var ts = Job.Meta.CreateTrans();
+        var start = job.DataTime;
+        for (var i = 0; i < count; i++)
+        {
+            var end = DateTime.MinValue;
+            if (job.Mode == JobModes.Time && !TrySplitTime(job, out end) ||
+                job.Mode != JobModes.Time && !TrySplit(job, start, out end))
+                break;
+            if (end.Year < 2000 || end.Year > 9000)
+                throw new ArgumentOutOfRangeException(nameof(end), end, "结束时间不合法");
+
+            // 创建新的任务
+            var task = new JobTask
+            {
+                AppID = job.AppID,
+                JobID = job.ID,
+                DataTime = start,
+                End = end,
+                Data = job.Data,
+                BatchSize = job.BatchSize,
+
+                Server = server,
+                ProcessID = Interlocked.Increment(ref _idxBatch),
+                Client = $"{ip}@{pid}",
+                Status = JobStatus.就绪,
+                CreateTime = DateTime.Now,
+                UpdateTime = DateTime.Now
+            };
+
+            //// 如果有模板,则进行计算替换
+            //if (!Data.IsNullOrEmpty()) ti.Data = TemplateHelper.Build(Data, ti.DataTime, ti.End);
+
+            // 重复切片判断
+            var key = $"job:task:{job.ID}:{start:yyyyMMddHHmmss}";
+            if (!cache.Add(key, task, 30))
+            {
+                var ti2 = cache.Get<JobTask>(key);
+                XTrace.WriteLine("[{0}]重复切片:{1}", key, ti2?.ToJson());
+                using var span2 = DefaultTracer.Instance?.NewSpan($"job:AcquireDuplicate", ti2);
+            }
+            else
+            {
+                task.Insert();
+
+                list.Add(task);
+            }
+
+            // 更新任务
+            job.DataTime = end;
+            start = end;
+        }
+
+        ts.Commit();
+
+        // 记录任务数
+        span?.AppendTag(null, list.Count);
+
+        return list;
+    }
+
+    /// <summary>尝试分割时间片(时间调度)</summary>
+    /// <param name="job"></param>
+    /// <param name="end"></param>
+    /// <returns></returns>
+    public Boolean TrySplitTime(Job job, out DateTime end)
+    {
+        var start = job.DataTime;
+
+        // 当前时间减去偏移量,作为当前时间。数据抽取不许超过该时间。去掉毫秒
+        var now = DateTime.Now.AddSeconds(-job.Offset).Trim("s");
+
+        end = DateTime.MinValue;
+
+        // 开始时间和结束时间是否越界
+        if (start >= now) return false;
+
+        // 计算下一次执行时间
+        end = job.GetNext();
+
+        // 任务结束时间超过作业结束时间时,取后者
+        if (job.End.Year > 2000 && end > job.End) end = job.End;
+
+        // 时间区间判断
+        if (end.Year > 2000 && start >= end) return false;
+
+        return true;
+    }
+
+    /// <summary>尝试分割时间片(数据调度)</summary>
+    /// <param name="start"></param>
+    /// <param name="step"></param>
+    /// <param name="end"></param>
+    /// <returns></returns>
+    public Boolean TrySplit(Job job, DateTime start, out DateTime end)
+    {
+        // 当前时间减去偏移量,作为当前时间。数据抽取不许超过该时间
+        var now = DateTime.Now.AddSeconds(-job.Offset);
+        // 去掉毫秒
+        now = now.Trim();
+
+        end = DateTime.MinValue;
+
+        // 开始时间和结束时间是否越界
+        if (start >= now) return false;
+
+        var step = job.Step;
+        if (step <= 0) step = 30;
+
+        // 必须严格要求按照步进大小分片,除非有合适的End
+        end = start.AddSeconds(step);
+        // 任务结束时间超过作业结束时间时,取后者
+        if (job.End.Year > 2000 && end > job.End) end = job.End;
+
+        // 时间片必须严格要求按照步进大小分片,除非有合适的End
+        if (end > now) return false;
+
+        // 时间区间判断
+        if (start >= end) return false;
+
+        return true;
+    }
+
+    /// <summary>申请延迟/取消任务</summary>
+    /// <param name="server">申请任务的服务端</param>
+    /// <param name="ip">申请任务的IP</param>
+    /// <param name="pid">申请任务的服务端进程ID</param>
+    /// <param name="count">要申请的任务个数</param>
+    /// <param name="cache">缓存对象</param>
+    /// <returns></returns>
+    public IList<JobTask> AcquireDelay(Job job, String server, String ip, Int32 pid, Int32 count, ICache cache)
+    {
+        using var span = _tracer?.NewSpan(nameof(AcquireDelay), new { job.Name, server, ip, pid, count });
+
+        using var ts = Job.Meta.CreateTrans();
+
+        var now = DateTime.Now;
+        var maxError = job.MaxError - job.Error;
+        var list = JobTask.Search(job.ID, now.AddDays(-7), now, job.MaxRetry, maxError, [JobStatus.取消, JobStatus.延迟], count);
+        foreach (var task in list)
+        {
+            task.Server = server;
+            task.ProcessID = Interlocked.Increment(ref _idxBatch);
+            task.Client = $"{ip}@{pid}";
+            task.Status = JobStatus.就绪;
+            //task.CreateTime = DateTime.Now;
+            task.UpdateTime = DateTime.Now;
+        }
+        list.Save();
+
+        ts.Commit();
+
+        // 记录任务数
+        span?.AppendTag(null, list.Count);
+
+        return list;
+    }
+
+    /// <summary>申请历史错误或中断的任务</summary>
+    /// <param name="server">申请任务的服务端</param>
+    /// <param name="ip">申请任务的IP</param>
+    /// <param name="pid">申请任务的服务端进程ID</param>
+    /// <param name="count">要申请的任务个数</param>
+    /// <param name="cache">缓存对象</param>
+    /// <returns></returns>
+    public IList<JobTask> AcquireOld(Job job, String server, String ip, Int32 pid, Int32 count, ICache cache)
+    {
+        using var span = _tracer?.NewSpan(nameof(AcquireOld), new { job.Name, server, ip, pid, count });
+
+        using var ts = Job.Meta.CreateTrans();
+        var list = new List<JobTask>();
+
+        var now = DateTime.Now;
+        var maxError = job.MaxError - job.Error;
+
+        // 查找历史错误任务
+        if (job.ErrorDelay > 0)
+        {
+            var end = now.AddSeconds(-job.ErrorDelay);
+            var list2 = JobTask.Search(job.ID, now.AddDays(-7), end, job.MaxRetry, maxError, [JobStatus.错误], count);
+            if (list2.Count > 0) list.AddRange(list2);
+        }
+
+        // 查找历史中断任务,持续10分钟仍然未完成
+        if (job.MaxTime > 0 && list.Count < count)
+        {
+            var end = now.AddSeconds(-job.MaxTime);
+            var list2 = JobTask.Search(job.ID, now.AddDays(-7), end, job.MaxRetry, maxError, [JobStatus.就绪, JobStatus.抽取中, JobStatus.处理中], count - list.Count);
+            if (list2.Count > 0) list.AddRange(list2);
+        }
+        if (list.Count > 0)
+        {
+            foreach (var task in list)
+            {
+                task.Server = server;
+                task.ProcessID = Interlocked.Increment(ref _idxBatch);
+                task.Client = $"{ip}@{pid}";
+                task.Status = JobStatus.就绪;
+                //task.CreateTime = DateTime.Now;
+                task.UpdateTime = DateTime.Now;
+            }
+            list.Save();
+        }
+
+        ts.Commit();
+
+        // 记录任务数
+        span?.AppendTag(null, list.Count);
+
+        return list;
+    }
+
+    /// <summary>申请任务分片</summary>
+    /// <param name="topic">主题</param>
+    /// <param name="server">申请任务的服务端</param>
+    /// <param name="ip">申请任务的IP</param>
+    /// <param name="pid">申请任务的服务端进程ID</param>
+    /// <param name="count">要申请的任务个数</param>
+    /// <param name="cache">缓存对象</param>
+    /// <returns></returns>
+    public IList<JobTask> AcquireMessage(Job job, String topic, String server, String ip, Int32 pid, Int32 count, ICache cache)
+    {
+        // 消费消息时,保存主题
+        if (job.Topic != topic)
+        {
+            job.Topic = topic;
+            job.SaveAsync();
+        }
+
+        var list = new List<JobTask>();
+
+        if (!job.Enable) return list;
+
+        // 验证消息数
+        var now = DateTime.Now;
+        if (job.MessageCount == 0 && job.UpdateTime.AddMinutes(2) > now) return list;
+
+        using var span = _tracer?.NewSpan(nameof(AcquireMessage), new { job.Name, topic, server, ip, pid, count });
+
+        using var ts = Job.Meta.CreateTrans();
+        var size = job.BatchSize;
+        if (size == 0) size = 1;
+
+        // 消费消息。请求任务数量=空闲线程*批大小
+        var msgs = AppMessage.GetTopic(job.AppID, topic, now, count * size);
+        if (msgs.Count > 0)
+        {
+            for (var i = 0; i < msgs.Count;)
+            {
+                var msgList = msgs.Skip(i).Take(size).ToList();
+                if (msgList.Count == 0) break;
+
+                i += msgList.Count;
+
+                // 创建新的分片
+                var ti = new JobTask
+                {
+                    AppID = job.AppID,
+                    JobID = job.ID,
+                    Data = msgList.Select(e => e.Data).ToJson(),
+                    MsgCount = msgList.Count,
+                    BatchSize = size,
+
+                    Server = server,
+                    ProcessID = Interlocked.Increment(ref _idxBatch),
+                    Client = $"{ip}@{pid}",
+                    Status = JobStatus.就绪,
+                    CreateTime = DateTime.Now,
+                    UpdateTime = DateTime.Now
+                };
+
+                ti.Insert();
+
+                // 从去重缓存去掉
+                cache.Remove(msgList.Select(e => $"antjob:{job.AppID}:{job.Topic}:{e}").ToArray());
+
+                list.Add(ti);
+            }
+
+            // 批量删除消息
+            msgs.Delete();
+        }
+
+        // 更新作业下的消息数
+        job.MessageCount = AppMessage.FindCountByAppIDAndTopic(job.AppID, topic);
+        job.UpdateTime = now;
+        job.Save();
+
+        // 消费完成后,更新应用的消息数
+        if (job.MessageCount == 0)
+        {
+            var app = job.App;
+            if (app != null)
+            {
+                app.MessageCount = AppMessage.FindCountByAppID(job.ID);
+                app.SaveAsync();
+            }
+        }
+
+        ts.Commit();
+
+        // 记录任务数
+        span?.AppendTag(null, list.Count);
+
+        return list;
+    }
+    #endregion
+
+    #region 辅助
+    /// <summary>兼容旧数据库。如DataTime对应Start</summary>
+    public static void FixOld()
+    {
+        using var span = DefaultTracer.Instance?.NewSpan(nameof(FixOld));
+
+        var dal = DAL.Create("Ant");
+        FixOld(dal, "Job");
+        FixOld(dal, "JobTask");
+        FixOld(dal, "JobError");
+    }
+
+    /// <summary>兼容旧数据库。如DataTime对应Start</summary>
+    /// <param name="dal"></param>
+    /// <param name="tableName"></param>
+    public static void FixOld(DAL dal, String tableName)
+    {
+        var table = dal.Tables.FirstOrDefault(e => tableName.EqualIgnoreCase(e.Name, e.TableName));
+        if (table == null) return;
+
+        // 如果不包括Start列,则不需要处理
+        if (!table.Columns.Any(e => e.Name.EqualIgnoreCase("Start"))) return;
+        if (!table.Columns.Any(e => e.Name.EqualIgnoreCase("DataTime"))) return;
+
+        // 查询最后一批数据,更新字段值
+        var id = dal.Query<Int32>($"select id from {tableName} where (DataTime is null or DataTime<'2000-01-01') order by id desc", null, 0, 1).FirstOrDefault();
+        if (id == 0) return;
+
+        XTrace.WriteLine("数据表[{0}]最大Id是:{1},开始修正", tableName, id);
+        if (id > 100_000)
+            id -= 100_000;
+        else
+            id = 0;
+
+        var rs = dal.Execute($"update {tableName} set DataTime=Start where ID>{id} and (DataTime is null or DataTime<'2000-01-01')");
+        XTrace.WriteLine("从Id={0}开始,共修正{1}行", id, rs);
+    }
+    #endregion
 }
Modified +3 -3
diff --git a/AntJob.Server/Setting.cs b/AntJob.Server/Setting.cs
index c0fb289..394d2de 100644
--- a/AntJob.Server/Setting.cs
+++ b/AntJob.Server/Setting.cs
@@ -32,8 +32,8 @@ public class AntJobSetting : Config<AntJobSetting>
     [Description("自动注册。任意应用登录时自动注册,省去人工配置应用账号的麻烦,默认true")]
     public Boolean AutoRegistry { get; set; } = true;
 
-    /// <summary>Redis缓存。设置用于控制任务切分的分布式锁,默认为空使用本进程内存锁</summary>
-    [Description("Redis缓存。设置用于控制任务切分的分布式锁,默认为空使用本进程内存锁")]
-    public String RedisCache { get; set; }
+    ///// <summary>Redis缓存。设置用于控制任务切分的分布式锁,默认为空使用本进程内存锁</summary>
+    //[Description("Redis缓存。设置用于控制任务切分的分布式锁,默认为空使用本进程内存锁")]
+    //public String RedisCache { get; set; }
     #endregion
 }
\ No newline at end of file
Modified +20 -12
diff --git a/AntJob.Server/Worker.cs b/AntJob.Server/Worker.cs
index 90a4f7d..8c07a56 100644
--- a/AntJob.Server/Worker.cs
+++ b/AntJob.Server/Worker.cs
@@ -17,19 +17,24 @@ public class Worker : IHostedService
     private readonly IRegistry _registry;
     private readonly ICacheProvider _cacheProvider;
     private readonly IServiceProvider _provider;
+    private readonly AntJobSetting _setting;
     private readonly ITracer _tracer;
 
-    public Worker(ICacheProvider cacheProvider, IServiceProvider provider, ITracer tracer)
+    public Worker(ICacheProvider cacheProvider, IServiceProvider provider, AntJobSetting setting, ITracer tracer)
     {
         _cacheProvider = cacheProvider;
         _provider = provider;
+        _setting = setting;
         _tracer = tracer;
         _registry = provider.GetService<IRegistry>();
     }
 
     public async Task StartAsync(CancellationToken cancellationToken)
     {
-        var set = AntJobSetting.Current;
+        InitData();
+
+        //var set = AntJobSetting.Current;
+        var set = _setting;
 
         // 实例化RPC服务端,指定端口,指定ServiceProvider,用于依赖注入获取接口服务层
         var server = new ApiServer(set.Port)
@@ -46,18 +51,18 @@ public class Worker : IHostedService
         // 本地结点
         AntService.Local = new IPEndPoint(NetHelper.MyIP(), set.Port);
 
-        // 数据缓存,也用于全局锁,支持MemoryCache和Redis
-        if (_cacheProvider.Cache is not FullRedis && !set.RedisCache.IsNullOrEmpty())
-        {
-            var redis = new Redis { Timeout = 5_000 + 1_000 };
-            redis.Init(set.RedisCache);
+        //// 数据缓存,也用于全局锁,支持MemoryCache和Redis
+        //if (_cacheProvider.Cache is not FullRedis && !set.RedisCache.IsNullOrEmpty())
+        //{
+        //    var redis = new Redis { Timeout = 5_000 + 1_000 };
+        //    redis.Init(set.RedisCache);
 
-            _cacheProvider.Cache = redis;
-        }
+        //    _cacheProvider.Cache = redis;
+        //}
 
         server.Start();
 
-        _clearOnlineTimer = new TimerX(ClearOnline, null, 1000, 10 * 1000);
+        _clearOnlineTimer = new TimerX(ClearOnline, null, 1000, 10 * 1000) { Async = true };
         _clearItemTimer = new TimerX(ClearItems, null, 10_000, 3600_000) { Async = true };
 
         // 启用星尘注册中心,向注册中心注册服务,服务消费者将自动更新服务端地址列表
@@ -74,8 +79,6 @@ public class Worker : IHostedService
 
     private static void InitData()
     {
-        var n = App.Meta.Count;
-
         var set = NewLife.Setting.Current;
         if (set.IsNew)
         {
@@ -94,6 +97,8 @@ public class Worker : IHostedService
 
             set2.Save();
         }
+
+        _ = EntityFactory.InitAllAsync();
     }
 
     #region 清理过时
@@ -140,6 +145,9 @@ public class Worker : IHostedService
             p += list.Count;
         }
 
+        // 删除作业已不存在的任务
+        rs += JobTask.DeleteNoJob();
+
         if (rs > 0)
         {
             sw.Stop();
Modified +10 -6
diff --git a/AntJob.Web/AntJob.Web.csproj b/AntJob.Web/AntJob.Web.csproj
index c1a418b..a514b10 100644
--- a/AntJob.Web/AntJob.Web.csproj
+++ b/AntJob.Web/AntJob.Web.csproj
@@ -27,13 +27,13 @@
   </ItemGroup>
   <ItemGroup>
     <Compile Remove="Areas\Ant\Controllers\AppConfigController.cs" />
+    <Compile Remove="Common\ApiFilterAttribute.cs" />
   </ItemGroup>
   <ItemGroup>
     <Content Remove="Areas\Ant\Views\AppHistory\_List_Data.cshtml" />
-    <Content Remove="Areas\Ant\Views\AppMessage\_List_Data.cshtml" />
-    <Content Remove="Areas\Ant\Views\AppOnline\_List_Data.cshtml" />
     <Content Remove="Areas\Ant\Views\App\_List_Data.cshtml" />
-    <Content Remove="Areas\Ant\Views\JobError\_List_Data.cshtml" />
+    <Content Remove="Areas\Ant\Views\JobTask\_List_Data.cshtml" />
+    <Content Remove="Areas\Ant\Views\Job\_List_Data.cshtml" />
   </ItemGroup>
   <ItemGroup>
     <Compile Include="..\AntJob.Server\Services\AppService.cs" Link="Services\AppService.cs" />
@@ -44,15 +44,19 @@
     <Content Include="favicon.ico" />
   </ItemGroup>
   <ItemGroup>
-    <PackageReference Include="NewLife.Cube.Core" Version="6.1.2024.403" />
-    <PackageReference Include="NewLife.Stardust.Extensions" Version="2.9.2024.402" />
+    <PackageReference Include="NewLife.Cube.Core" Version="6.1.2024.1005" />
+    <PackageReference Include="NewLife.Remoting.Extensions" Version="3.0.2024.1002" />
+    <PackageReference Include="NewLife.Stardust.Extensions" Version="3.1.2024.1004" />
   </ItemGroup>
   <ItemGroup>
     <ProjectReference Include="..\AntJob.Data\AntJob.Data.csproj" />
     <ProjectReference Include="..\AntJob\AntJob.csproj" />
   </ItemGroup>
   <ItemGroup>
-    <Folder Include="Areas\Ant\Views\AppOnline\" />
+    <Folder Include="Common\" />
     <Folder Include="Services\" />
   </ItemGroup>
+  <ItemGroup>
+    <None Include="Areas\Ant\Views\Shared\_App_Nav.cshtml" />
+  </ItemGroup>
 </Project>
\ No newline at end of file
Modified +37 -8
diff --git a/AntJob.Web/Areas/Ant/AntAreaRegistration.cs b/AntJob.Web/Areas/Ant/AntAreaRegistration.cs
index a38acc5..2987a64 100644
--- a/AntJob.Web/Areas/Ant/AntAreaRegistration.cs
+++ b/AntJob.Web/Areas/Ant/AntAreaRegistration.cs
@@ -1,16 +1,45 @@
-using System;
-using System.ComponentModel;
+using System.ComponentModel;
+using Microsoft.AspNetCore.Mvc.Filters;
 using NewLife;
 using NewLife.Cube;
+using NewLife.Cube.ViewModels;
+using XCode;
 
-namespace AntJob.Web.Areas.Ant
+namespace AntJob.Web.Areas.Ant;
+
+/// <summary>蚂蚁调度</summary>
+[DisplayName("蚂蚁调度")]
+public class AntArea : AreaBase
 {
-    /// <summary>蚂蚁调度</summary>
-    [DisplayName("蚂蚁调度")]
-    public class AntArea : AreaBase
+    public AntArea() : base(nameof(AntArea).TrimEnd("Area")) { }
+
+    static AntArea() => RegisterArea<AntArea>();
+}
+
+public class AntEntityController<T> : EntityController<T> where T : Entity<T>, new()
+{
+    public override void OnActionExecuting(ActionExecutingContext filterContext)
     {
-        public AntArea() : base(nameof(AntArea).TrimEnd("Area")) { }
+        base.OnActionExecuting(filterContext);
+
+        var appId = GetRequest("appId").ToInt(-1);
+        if (appId > 0)
+        {
+            PageSetting.NavView = "_App_Nav";
+            PageSetting.EnableNavbar = false;
+        }
+    }
+
+    protected override FieldCollection OnGetFields(ViewKinds kind, Object model)
+    {
+        var fields = base.OnGetFields(kind, model);
+
+        if (kind == ViewKinds.List)
+        {
+            var appId = GetRequest("appId").ToInt(-1);
+            if (appId > 0) fields.RemoveField("AppName");
+        }
 
-        static AntArea() => RegisterArea<AntArea>();
+        return fields;
     }
 }
\ No newline at end of file
Deleted +0 -34
AntJob.Web/Areas/Ant/Controllers/AppConfigController.cs
Modified +30 -73
diff --git a/AntJob.Web/Areas/Ant/Controllers/AppController.cs b/AntJob.Web/Areas/Ant/Controllers/AppController.cs
index 406c450..7ec989c 100644
--- a/AntJob.Web/Areas/Ant/Controllers/AppController.cs
+++ b/AntJob.Web/Areas/Ant/Controllers/AppController.cs
@@ -1,10 +1,11 @@
 using System.ComponentModel;
 using AntJob.Data.Entity;
 using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
 using NewLife;
 using NewLife.Cube;
+using NewLife.Cube.ViewModels;
 using NewLife.Web;
-using XCode;
 using XCode.Membership;
 
 namespace AntJob.Web.Areas.Ant.Controllers;
@@ -13,14 +14,21 @@ namespace AntJob.Web.Areas.Ant.Controllers;
 [AntArea]
 [DisplayName("应用系统")]
 [Menu(100)]
-public class AppController : EntityController<App>
+public class AppController : AntEntityController<App>
 {
     static AppController()
     {
+        LogOnChange = true;
+
         ListFields.RemoveField("UpdateUserID");
         ListFields.RemoveCreateField().RemoveRemarkField();
 
         {
+            var df = ListFields.GetField("Name") as ListField;
+            df.Url = "/Ant/App/Detail?id={ID}";
+            df.Target = "_blank";
+        }
+        {
             var df = ListFields.AddListField("Online", "UpdateUser");
             df.DisplayName = "在线";
             df.Url = "/Ant/AppOnline?appid={ID}";
@@ -52,22 +60,35 @@ public class AppController : EntityController<App>
         }
     }
 
+    public override void OnActionExecuting(ActionExecutingContext filterContext)
+    {
+        base.OnActionExecuting(filterContext);
+
+        var appId = GetRequest("Id").ToInt(-1);
+        if (appId > 0)
+        {
+            PageSetting.NavView = "_App_Nav";
+            PageSetting.EnableNavbar = false;
+        }
+    }
+
     /// <summary>搜索数据集</summary>
     /// <param name="p"></param>
     /// <returns></returns>
     protected override IEnumerable<App> Search(Pager p)
     {
         var id = p["id"].ToInt(-1);
+        if (id <= 0) id = p["appId"].ToInt(-1);
         if (id > 0)
         {
-            var list = new List<App>();
             var entity = App.FindByID(id);
-            if (entity != null) list.Add(entity);
-
-            return list;
+            if (entity != null) return [entity];
         }
 
-        return App.Search(p["category"], p["Enable"]?.ToBoolean(), p["q"], p);
+        var category = p["category"];
+        var enable = p["Enable"]?.ToBoolean();
+
+        return App.Search(category, enable, p["q"], p);
     }
 
     protected override Int32 OnUpdate(App entity)
@@ -85,7 +106,7 @@ public class AppController : EntityController<App>
     public ActionResult ResetApp()
     {
         var ids = GetRequest("keys").SplitAsInt();
-        if (!ids.Any()) return JsonRefresh("未选中项!");
+        if (ids.Length == 0) return JsonRefresh("未选中项!");
 
         var now = DateTime.Now;
         foreach (var appid in ids)
@@ -94,7 +115,7 @@ public class AppController : EntityController<App>
             var jobs = Job.FindAllByAppID2(appid);
             foreach (var job in jobs)
             {
-                job.Start = new DateTime(now.Year, now.Month, 1);
+                job.DataTime = new DateTime(now.Year, now.Month, 1);
                 job.ResetOther();
             }
 
@@ -111,68 +132,4 @@ public class AppController : EntityController<App>
 
         return JsonRefresh("操作完毕!");
     }
-
-    /// <summary>启用禁用任务</summary>
-    /// <param name="id"></param>
-    /// <param name="enable"></param>
-    /// <returns></returns>
-    [EntityAuthorize(PermissionFlags.Update)]
-    public ActionResult Set(Int32 id = 0, Boolean enable = true)
-    {
-        if (id > 0)
-        {
-            var dt = App.FindByID(id);
-            if (dt == null) throw new ArgumentNullException(nameof(id), "找不到任务 " + id);
-
-            dt.Enable = enable;
-            dt.Save();
-        }
-        else
-        {
-            var ids = GetRequest("keys").SplitAsInt();
-
-            foreach (var item in ids)
-            {
-                var dt = App.FindByID(item);
-                if (dt != null && dt.Enable != enable)
-                {
-                    dt.Enable = enable;
-                    dt.Save();
-                }
-            }
-        }
-        return JsonRefresh("操作成功!");
-    }
-
-    protected override Boolean Valid(App entity, DataObjectMethodType type, Boolean post)
-    {
-        if (!post) return base.Valid(entity, type, post);
-
-        var act = type switch
-        {
-            DataObjectMethodType.Update => "修改",
-            DataObjectMethodType.Insert => "添加",
-            DataObjectMethodType.Delete => "删除",
-            _ => type + "",
-        };
-
-        // 必须提前写修改日志,否则修改后脏数据失效,保存的日志为空
-        if (type == DataObjectMethodType.Update && (entity as IEntity).HasDirty)
-            LogProvider.Provider.WriteLog(act, entity);
-
-        var err = "";
-        try
-        {
-            return base.Valid(entity, type, post);
-        }
-        catch (Exception ex)
-        {
-            err = ex.Message;
-            throw;
-        }
-        finally
-        {
-            LogProvider.Provider.WriteLog(act, entity, err);
-        }
-    }
 }
\ No newline at end of file
Modified +7 -3
diff --git a/AntJob.Web/Areas/Ant/Controllers/AppHistoryController.cs b/AntJob.Web/Areas/Ant/Controllers/AppHistoryController.cs
index 80d1926..8201fbf 100644
--- a/AntJob.Web/Areas/Ant/Controllers/AppHistoryController.cs
+++ b/AntJob.Web/Areas/Ant/Controllers/AppHistoryController.cs
@@ -2,6 +2,7 @@
 using AntJob.Data.Entity;
 using NewLife.Cube;
 using NewLife.Cube.Extensions;
+using NewLife.Cube.ViewModels;
 using NewLife.Web;
 
 namespace AntJob.Web.Areas.Ant.Controllers;
@@ -10,13 +11,14 @@ namespace AntJob.Web.Areas.Ant.Controllers;
 [AntArea]
 [DisplayName("应用历史")]
 [Menu(0, false)]
-public class AppHistoryController : EntityController<AppHistory>
+public class AppHistoryController : AntEntityController<AppHistory>
 {
     static AppHistoryController()
     {
-        //MenuOrder = 85;
+        //AppHistory.Meta.Table.DataTable.InsertOnly = true;
 
-        AppOnline.Meta.Table.DataTable.InsertOnly = true;
+        ListFields.RemoveField("Id", "Version", "CompileTime", "");
+        ListFields.AddListField("Remark", null, "TraceId");
 
         ListFields.TraceUrl();
     }
@@ -26,6 +28,8 @@ public class AppHistoryController : EntityController<AppHistory>
     /// <returns></returns>
     protected override IEnumerable<AppHistory> Search(Pager p)
     {
+        PageSetting.EnableAdd = false;
+
         var appid = p["appid"].ToInt(-1);
         var act = p["action"];
         var success = p["success"]?.ToBoolean();
Modified +14 -3
diff --git a/AntJob.Web/Areas/Ant/Controllers/AppMessageController.cs b/AntJob.Web/Areas/Ant/Controllers/AppMessageController.cs
index 9642420..6ca29f1 100644
--- a/AntJob.Web/Areas/Ant/Controllers/AppMessageController.cs
+++ b/AntJob.Web/Areas/Ant/Controllers/AppMessageController.cs
@@ -2,6 +2,7 @@
 using AntJob.Data.Entity;
 using NewLife.Cube;
 using NewLife.Cube.Extensions;
+using NewLife.Cube.ViewModels;
 using NewLife.Web;
 
 namespace AntJob.Web.Areas.Ant.Controllers;
@@ -10,11 +11,21 @@ namespace AntJob.Web.Areas.Ant.Controllers;
 [AntArea]
 [DisplayName("应用消息")]
 [Menu(70)]
-public class AppMessageController : EntityController<AppMessage>
+public class AppMessageController : AntEntityController<AppMessage>
 {
-    //static AppMessageController() => MenuOrder = 49;
+    static AppMessageController()
+    {
+        LogOnChange = true;
+
+        ListFields.RemoveField("CreateIP", "UpdateIP");
 
-    static AppMessageController() => ListFields.TraceUrl();
+        {
+            var df = ListFields.AddListField("Data", null, "Topic");
+            df.TextAlign = TextAligns.Nowrap;
+        }
+
+        ListFields.TraceUrl();
+    }
 
     /// <summary>搜索数据集</summary>
     /// <param name="p"></param>
Modified +5 -3
diff --git a/AntJob.Web/Areas/Ant/Controllers/AppOnlineController.cs b/AntJob.Web/Areas/Ant/Controllers/AppOnlineController.cs
index cd97ff9..ab6a6db 100644
--- a/AntJob.Web/Areas/Ant/Controllers/AppOnlineController.cs
+++ b/AntJob.Web/Areas/Ant/Controllers/AppOnlineController.cs
@@ -10,13 +10,13 @@ namespace AntJob.Web.Areas.Ant.Controllers;
 [AntArea]
 [DisplayName("应用在线")]
 [Menu(80)]
-public class AppOnlineController : EntityController<AppOnline>
+public class AppOnlineController : AntEntityController<AppOnline>
 {
     static AppOnlineController()
     {
-        //MenuOrder = 90;
+        //AppOnline.Meta.Table.DataTable.InsertOnly = true;
 
-        AppOnline.Meta.Table.DataTable.InsertOnly = true;
+        ListFields.RemoveField("End");
 
         ListFields.TraceUrl();
     }
@@ -26,6 +26,8 @@ public class AppOnlineController : EntityController<AppOnline>
     /// <returns></returns>
     protected override IEnumerable<AppOnline> Search(Pager p)
     {
+        PageSetting.EnableAdd = false;
+
         var appid = p["appid"].ToInt(-1);
         var start = p["dtStart"].ToDateTime();
         var end = p["dtEnd"].ToDateTime();
Modified +150 -76
diff --git a/AntJob.Web/Areas/Ant/Controllers/JobController.cs b/AntJob.Web/Areas/Ant/Controllers/JobController.cs
index 37c6a80..aa3d2cc 100644
--- a/AntJob.Web/Areas/Ant/Controllers/JobController.cs
+++ b/AntJob.Web/Areas/Ant/Controllers/JobController.cs
@@ -1,11 +1,13 @@
 using System.ComponentModel;
+using AntJob.Data;
 using AntJob.Data.Entity;
 using Microsoft.AspNetCore.Mvc;
 using NewLife;
 using NewLife.Cube;
+using NewLife.Cube.ViewModels;
+using NewLife.Data;
 using NewLife.Security;
 using NewLife.Web;
-using XCode;
 using XCode.Membership;
 
 namespace AntJob.Web.Areas.Ant.Controllers;
@@ -14,28 +16,164 @@ namespace AntJob.Web.Areas.Ant.Controllers;
 [AntArea]
 [DisplayName("作业")]
 [Menu(0, false)]
-public class JobController : EntityController<Job>
+public class JobController : AntEntityController<Job>
 {
     static JobController()
     {
-        var list = ListFields;
-        list.RemoveField("Type");
-        list.RemoveField("CreateUserID");
-        list.RemoveField("CreateTime");
-        list.RemoveField("CreateIP");
-        list.RemoveField("UpdateUserID");
-        list.RemoveField("UpdateIP");
-
-        //MenuOrder = 80;
+        LogOnChange = true;
+
+        ListFields.RemoveField("ClassName", "Step", "Cron", "Topic", "MessageCount", "Time", "End");
+        ListFields.RemoveField("Times", "Speed");
+        ListFields.RemoveField("MaxError", "MaxRetry", "MaxTime", "MaxRetain", "MaxIdle", "ErrorDelay", "Deadline");
+        ListFields.RemoveCreateField().RemoveUpdateField();
+        ListFields.AddListField("UpdateTime");
+
+        {
+            var df = ListFields.GetField("Name") as ListField;
+            df.Url = "/Ant/JobTask?appid={AppID}&jobId={ID}";
+        }
+        //{
+        //    var df = ListFields.AddListField("Task", "Enable");
+        //    df.DisplayName = "任务";
+        //    df.Url = "/Ant/JobTask?appid={AppID}&jobId={ID}";
+        //}
+        {
+            var df = ListFields.AddListField("Title", null, "Mode");
+            df.Header = "下一次/Cron/主题";
+            df.HeaderTitle = "Cron格式,秒+分+时+天+月+星期+年";
+            df.AddService(new MyTextField());
+        }
+        {
+            var df = ListFields.GetField("DataTime") as ListField;
+            //df.GetClass = e => "text-center text-primary font-weight-bold";
+            df.AddService(new ColorField { Color = "Magenta", GetValue = e => ((DateTime)e).ToFullString("") });
+        }
+        //{
+        //    var df = ListFields.GetField("Step");
+        //    df.DataVisible = e => (e as Job).Mode == JobModes.Data;
+        //}
+        {
+            var df = ListFields.GetField("BatchSize");
+            df.DataVisible = e => (e as Job).Mode != JobModes.Time;
+        }
+        //{
+        //    var df = ListFields.GetField("MaxTask");
+        //    df.DataVisible = e => (e as Job).Mode != JobModes.Message;
+        //}
+        {
+            var df = ListFields.GetField("Success");
+            df.AddService(new ColorNumberField { Color = "green" });
+        }
+        {
+            var df = ListFields.GetField("Error");
+            df.AddService(new ColorNumberField { Color = "red" });
+        }
+        {
+            var df = ListFields.GetField("LastStatus") as ListField;
+            df.GetClass = e =>
+            {
+                var job = e as Job;
+                return job.LastStatus switch
+                {
+                    JobStatus.就绪 => "text-center",
+                    JobStatus.抽取中 => "text-center info",
+                    JobStatus.处理中 => "text-center warning",
+                    JobStatus.错误 => "text-center danger",
+                    JobStatus.完成 => "text-center success",
+                    JobStatus.取消 => "text-center active",
+                    JobStatus.延迟 => "text-center active",
+                    _ => "",
+                };
+            };
+        }
+    }
+
+    class MyTextField : ILinkExtend
+    {
+        public String Resolve(DataField field, IModel data)
+        {
+            var job = data as Job;
+            return job.Mode switch
+            {
+                JobModes.Data => $"<font color=blue><b>{job.DataTime.ToFullString("")}</b></font>",
+                JobModes.Time => $"<font color=DarkVoilet><b>{job.Cron}</b></font>",
+                JobModes.Message => $"<font color=green><b>{job.Topic}</b></font>",
+                //JobModes.CSharp => "[C#]" + job.Time.ToFullString(""),
+                //JobModes.Sql => "[Sql]" + job.Time.ToFullString(""),
+                _ => $"<b>{job.DataTime.ToFullString("")}</b>",
+            };
+        }
+    }
+
+    class ColorField : ILinkExtend
+    {
+        public String Color { get; set; }
+
+        public Func<Object, String> GetValue;
+
+        public String Resolve(DataField field, IModel data)
+        {
+            if (data is Job job)
+            {
+                if (job.Mode == JobModes.Message) return "";
+                if (job.Mode == JobModes.Data) return $"+{TimeSpan.FromSeconds(job.Step)}";
+            }
+
+            var value = data[field.Name];
+            if (GetValue != null) value = GetValue(value);
+            return $"<font color={Color}><b>{value}</b></font>";
+        }
+    }
+
+    class ColorNumberField : ILinkExtend
+    {
+        public String Color { get; set; }
+
+        public String Resolve(DataField field, IModel data)
+        {
+            var value = data[field.Name];
+            return $"<font color={Color}><b>{value:n0}</b></font>";
+        }
     }
 
-    public JobController() => PageSetting.EnableAdd = false;
+    protected override FieldCollection OnGetFields(ViewKinds kind, Object model)
+    {
+        var fs = base.OnGetFields(kind, model);
+        if (model is not Job job) return fs;
+
+        if (kind is ViewKinds.EditForm or ViewKinds.Detail)
+        {
+            // Cron/Topic/MessageCount/End/Step/Offset/BatchSize
+            switch (job.Mode)
+            {
+                case JobModes.Data:
+                    fs.RemoveField("Topic", "MessageCount", "Cron");
+                    break;
+                case JobModes.Time:
+                    fs.RemoveField("Topic", "MessageCount", "Step", "BatchSize");
+                    break;
+                case JobModes.Message:
+                    fs.RemoveField("Cron", "End", "Step", "Offset");
+                    break;
+                default:
+                    break;
+            }
+        }
+        else if (kind is ViewKinds.AddForm)
+        {
+            fs.RemoveField("Cron", "Topic", "MessageCount", "End", "Step", "Offset", "BatchSize");
+        }
+
+        return fs;
+    }
 
     /// <summary>搜索数据集</summary>
     /// <param name="p"></param>
     /// <returns></returns>
     protected override IEnumerable<Job> Search(Pager p)
     {
+        PageSetting.EnableAdd = false;
+
         var id = p["ID"].ToInt(-1);
         var appid = p["appid"].ToInt(-1);
         var start = p["dtStart"].ToDateTime();
@@ -45,38 +183,6 @@ public class JobController : EntityController<Job>
         return Job.Search(id, appid, start, end, mode, p["q"], p);
     }
 
-    /// <summary>启用禁用任务</summary>
-    /// <param name="id"></param>
-    /// <param name="enable"></param>
-    /// <returns></returns>
-    [EntityAuthorize(PermissionFlags.Update)]
-    public ActionResult Set(Int32 id = 0, Boolean enable = true)
-    {
-        if (id > 0)
-        {
-            var dt = Job.FindByID(id);
-            if (dt == null) throw new ArgumentNullException(nameof(id), "找不到任务 " + id);
-
-            dt.Enable = enable;
-            dt.Save();
-        }
-        else
-        {
-            var ids = GetRequest("keys").SplitAsInt();
-
-            foreach (var item in ids)
-            {
-                var dt = Job.FindByID(item);
-                if (dt != null && dt.Enable != enable)
-                {
-                    dt.Enable = enable;
-                    dt.Save();
-                }
-            }
-        }
-        return JsonRefresh("操作成功!");
-    }
-
     /// <summary>
     /// 重置时间
     /// </summary>
@@ -174,36 +280,4 @@ public class JobController : EntityController<Job>
         // 跳转到编辑页,这里时候已经得到新的自增ID
         return Edit(job.ID + "");
     }
-
-    protected override Boolean Valid(Job entity, DataObjectMethodType type, Boolean post)
-    {
-        if (!post) return base.Valid(entity, type, post);
-
-        var act = type switch
-        {
-            DataObjectMethodType.Update => "修改",
-            DataObjectMethodType.Insert => "添加",
-            DataObjectMethodType.Delete => "删除",
-            _ => type + "",
-        };
-
-        // 必须提前写修改日志,否则修改后脏数据失效,保存的日志为空
-        if (type == DataObjectMethodType.Update && (entity as IEntity).HasDirty)
-            LogProvider.Provider.WriteLog(act, entity);
-
-        var err = "";
-        try
-        {
-            return base.Valid(entity, type, post);
-        }
-        catch (Exception ex)
-        {
-            err = ex.Message;
-            throw;
-        }
-        finally
-        {
-            LogProvider.Provider.WriteLog(act, entity, err);
-        }
-    }
 }
\ No newline at end of file
Modified +7 -3
diff --git a/AntJob.Web/Areas/Ant/Controllers/JobErrorController.cs b/AntJob.Web/Areas/Ant/Controllers/JobErrorController.cs
index 6234994..06f1472 100644
--- a/AntJob.Web/Areas/Ant/Controllers/JobErrorController.cs
+++ b/AntJob.Web/Areas/Ant/Controllers/JobErrorController.cs
@@ -2,6 +2,7 @@
 using AntJob.Data.Entity;
 using NewLife.Cube;
 using NewLife.Cube.Extensions;
+using NewLife.Cube.ViewModels;
 using NewLife.Web;
 
 namespace AntJob.Web.Areas.Ant.Controllers;
@@ -10,12 +11,13 @@ namespace AntJob.Web.Areas.Ant.Controllers;
 [AntArea]
 [DisplayName("作业错误")]
 [Menu(0, false)]
-public class JobErrorController : EntityController<JobError>
+public class JobErrorController : AntEntityController<JobError>
 {
-    //static JobErrorController() => MenuOrder = 60;
-
     static JobErrorController()
     {
+        ListFields.RemoveField("DataTime", "End");
+        ListFields.AddListField("Message", null, "TraceId");
+
         ListFields.TraceUrl();
     }
 
@@ -24,6 +26,8 @@ public class JobErrorController : EntityController<JobError>
     /// <returns></returns>
     protected override IEnumerable<JobError> Search(Pager p)
     {
+        PageSetting.EnableAdd = false;
+
         var appid = p["appid"].ToInt(-1);
         var jobid = p["JobID"].ToInt(-1);
         var start = p["dtStart"].ToDateTime();
Modified +129 -26
diff --git a/AntJob.Web/Areas/Ant/Controllers/JobTaskController.cs b/AntJob.Web/Areas/Ant/Controllers/JobTaskController.cs
index 95b134c..72733e4 100644
--- a/AntJob.Web/Areas/Ant/Controllers/JobTaskController.cs
+++ b/AntJob.Web/Areas/Ant/Controllers/JobTaskController.cs
@@ -1,10 +1,14 @@
 using System.ComponentModel;
+using System.Web;
 using AntJob.Data;
 using AntJob.Data.Entity;
+using AntJob.Server.Services;
 using Microsoft.AspNetCore.Mvc;
 using NewLife;
 using NewLife.Cube;
 using NewLife.Cube.Extensions;
+using NewLife.Cube.ViewModels;
+using NewLife.Data;
 using NewLife.Web;
 using XCode.Membership;
 
@@ -14,29 +18,137 @@ namespace AntJob.Web.Areas.Ant.Controllers;
 [AntArea]
 [DisplayName("作业任务")]
 [Menu(0, false)]
-public class JobTaskController : EntityController<JobTask>
+public class JobTaskController : AntEntityController<JobTask>
 {
+    private readonly JobService _jobService;
+
     static JobTaskController()
     {
-        //MenuOrder = 70;
+        LogOnChange = true;
+
+        ListFields.RemoveField("Server", "ProcessID", "End");
+        ListFields.RemoveCreateField();
+
+        {
+            var df = ListFields.GetField("DataTime") as ListField;
+            df.Header = "时间/数据";
+            df.AddService(new MyTitleField());
+        }
+        {
+            var df = ListFields.GetField("Success") as ListField;
+            df.AddService(new ColorNumberField { Color = "green" });
+            //df.GetValue = e => $"<font color=green><b>{(e as IModel)["Success"]:n0}</b></font>";
+        }
+        {
+            var df = ListFields.GetField("Error");
+            df.DataVisible = e => (e as JobTask).Error > 0;
+            df.AddService(new ColorNumberField { Color = "red" });
+        }
+        {
+            var df = ListFields.GetField("Status") as ListField;
+            //df.AddService(new MyStatusField());
+            df.GetClass = e =>
+            {
+                var job = e as JobTask;
+                return job.Status switch
+                {
+                    JobStatus.就绪 => "text-center",
+                    JobStatus.抽取中 => "text-center info",
+                    JobStatus.处理中 => "text-center warning",
+                    JobStatus.错误 => "text-center danger",
+                    JobStatus.完成 => "text-center success",
+                    JobStatus.取消 => "text-center active",
+                    JobStatus.延迟 => "text-center active",
+                    _ => "",
+                };
+            };
+        }
 
         ListFields.TraceUrl();
     }
 
+    class MyTitleField : ILinkExtend
+    {
+        public String Resolve(DataField field, IModel data)
+        {
+            var task = data as JobTask;
+            var mode = task?.Job?.Mode ?? JobModes.Time;
+            return mode switch
+            {
+                JobModes.Data => $"<font color=blue><b>({task.DataTime:MM-dd HH:mm:ss} - {task.End:HH:mm:ss})</b></font>",
+                JobModes.Time => $"<font color=DarkVoilet><b>{task.DataTime.ToFullString()}</b></font>",
+                JobModes.Message => $"<font color=green title=\"{HttpUtility.HtmlEncode(task.Data)}\"><b>{task.Data?.Cut(64, "..")}</b></font>",
+                _ => $"<b>{task.DataTime.ToFullString("")}</b>",
+            };
+        }
+    }
+
+    class ColorField : ILinkExtend
+    {
+        public String Color { get; set; }
+
+        public Func<Object, String> GetValue;
+
+        public String Resolve(DataField field, IModel data)
+        {
+            var value = data[field.Name];
+            if (GetValue != null) value = GetValue(value);
+            return $"<font color={Color}><b>{value}</b></font>";
+        }
+    }
+
+    class ColorNumberField : ILinkExtend
+    {
+        public String Color { get; set; }
+
+        public String Resolve(DataField field, IModel data)
+        {
+            var value = data[field.Name];
+            return $"<font color={Color}><b>{value:n0}</b></font>";
+        }
+    }
+
+    public JobTaskController(JobService jobService) => _jobService = jobService;
+
+    protected override FieldCollection OnGetFields(ViewKinds kind, Object model)
+    {
+        var fs = base.OnGetFields(kind, model);
+        if (kind == ViewKinds.List)
+        {
+            var appId = GetRequest("appId").ToInt();
+            if (appId > 0) fs.RemoveField("AppID", "AppName");
+
+            var jobId = GetRequest("jobId").ToInt();
+            if (jobId > 0) fs.RemoveField("JobID", "JobName");
+        }
+
+        return fs;
+    }
+
     /// <summary>搜索数据集</summary>
     /// <param name="p"></param>
     /// <returns></returns>
     protected override IEnumerable<JobTask> Search(Pager p)
     {
+        PageSetting.EnableAdd = false;
+
         var id = p["id"].ToInt(-1);
         var jobid = p["JobID"].ToInt(-1);
         var appid = p["AppID"].ToInt(-1);
         var status = (JobStatus)p["Status"].ToInt(-1);
+        var client = p["Client"];
+
+        var dataStart = p["dataStart"].ToDateTime();
+        var dataEnd = p["dataEnd"].ToDateTime();
         var start = p["dtStart"].ToDateTime();
         var end = p["dtEnd"].ToDateTime();
-        var client = p["Client"];
 
-        return JobTask.Search(id, appid, jobid, status, start, end, client, p["q"], p);
+        if (jobid > 0)
+        {
+            ListFields.RemoveField("JobID");
+        }
+
+        return JobTask.Search(id, appid, jobid, status, dataStart, dataEnd, start, end, client, p["q"], p);
     }
 
     /// <summary>修改状态</summary>
@@ -45,32 +157,23 @@ public class JobTaskController : EntityController<JobTask>
     [EntityAuthorize(PermissionFlags.Update)]
     public ActionResult Set(Int32 id = 0)
     {
-        if (id > 0)
+        var rs = 0;
+        var ids = GetRequest("keys").SplitAsInt();
+        foreach (var item in ids)
         {
-            var dt = JobTask.FindByID(id);
-            if (dt == null) throw new ArgumentNullException(nameof(id), "找不到任务 " + id);
-
-            dt.Status = JobStatus.取消;
-            if (dt.Times >= 10) dt.Times = 0;
-
-            dt.Save();
-        }
-        else
-        {
-            var ids = GetRequest("keys").SplitAsInt();
-
-            foreach (var item in ids)
+            var task = JobTask.FindByID(item);
+            if (task != null)
             {
-                var dt = JobTask.FindByID(item);
-                if (dt != null)
-                {
-                    dt.Status = JobStatus.取消;
-                    if (dt.Times >= 10) dt.Times = 0;
+                task.Status = JobStatus.取消;
+                if (task.Times >= 10) task.Times = 0;
+
+                rs += task.Save();
 
-                    dt.Save();
-                }
+                // 提醒调度,马上放行
+                _jobService.SetDelay(task.JobID, DateTime.Now);
             }
         }
-        return JsonRefresh("操作成功!");
+
+        return JsonRefresh($"操作成功!rs={rs}");
     }
 }
\ No newline at end of file
Modified +4 -1
diff --git a/AntJob.Web/Areas/Ant/Views/_ViewImports.cshtml b/AntJob.Web/Areas/Ant/Views/_ViewImports.cshtml
index 39bae8f..6fd93cd 100644
--- a/AntJob.Web/Areas/Ant/Views/_ViewImports.cshtml
+++ b/AntJob.Web/Areas/Ant/Views/_ViewImports.cshtml
@@ -5,4 +5,7 @@
 @using NewLife.Web
 @using XCode
 @using XCode.Membership
-@using AntJob.Data.Entity
\ No newline at end of file
+@using AntJob.Data.Entity
+@using NewLife.Cube.Areas.Admin.Models
+@using NewLife.Cube.Extensions
+@using NewLife.Cube.ViewModels 
\ No newline at end of file
Deleted +0 -71
AntJob.Web/Areas/Ant/Views/App/_List_Data.cshtml
Deleted +0 -59
AntJob.Web/Areas/Ant/Views/AppConfig/_List_Data.cshtml
Deleted +0 -12
AntJob.Web/Areas/Ant/Views/AppConfig/_List_Search.cshtml
Deleted +0 -91
AntJob.Web/Areas/Ant/Views/AppHistory/_List_Data.cshtml
Deleted +0 -63
AntJob.Web/Areas/Ant/Views/AppMessage/_List_Data.cshtml
Deleted +0 -11
AntJob.Web/Areas/Ant/Views/AppMessage/_List_Search.cshtml
Deleted +0 -69
AntJob.Web/Areas/Ant/Views/AppOnline/_List_Data.cshtml
Deleted +0 -122
AntJob.Web/Areas/Ant/Views/Job/_List_Data.cshtml
Deleted +0 -52
AntJob.Web/Areas/Ant/Views/JobError/_List_Data.cshtml
Deleted +0 -10
AntJob.Web/Areas/Ant/Views/JobError/_List_Search.cshtml
Added +42 -0
diff --git a/AntJob.Web/Areas/Ant/Views/JobTask/_DataRange.cshtml b/AntJob.Web/Areas/Ant/Views/JobTask/_DataRange.cshtml
new file mode 100644
index 0000000..bc5bac2
--- /dev/null
+++ b/AntJob.Web/Areas/Ant/Views/JobTask/_DataRange.cshtml
@@ -0,0 +1,42 @@
+@using NewLife;
+@{
+    var fmt = Model as String;
+    var formatStr = !fmt.IsNullOrEmpty() ? fmt : "yyyy-MM-dd";
+    var p = ViewBag.Page as Pager;
+
+    var dataStart = p["dataStart"].ToDateTime();
+    var dataEnd = p["dataEnd"].ToDateTime();
+    var step = (Int32)(dataEnd - dataStart).TotalDays + 1;
+}
+<div class="form-group">
+    <label for="dataStart" class="control-label">数据时间:</label>
+    <div class="input-group">
+        @if (formatStr == "yyyy-MM-dd" && (dataStart.Year > 2000 || dataEnd.Year > 2000))
+        {
+            var url = p.GetBaseUrl(true, true, true, new[] { "dataStart", "dataEnd" });
+            if (dataStart.Year > 2000 && dataEnd.Year > 2000) url.UrlParam("dataStart", dataStart.AddDays(-step).ToString("yyyy-MM-dd"));
+            if (dataStart.Year > 2000 && dataEnd.Year > 2000) url.UrlParam("dataEnd", dataEnd.AddDays(-step).ToString("yyyy-MM-dd"));
+            <span class="input-group-addon"><a href="?@Html.Raw(url)" title="前一段"><i class="fa fa-calendar"></i></a></span>
+        }
+        else
+        {
+            <span class="input-group-addon"><i class="fa fa-calendar"></i></span>
+        }
+        <input name="dataStart" id="dataStart" value="@p["dataStart"]" dateformat="@formatStr" class="form-control form_datetime" autocomplete="off" />
+    </div>
+    @if (formatStr == "yyyy-MM-dd" && (dataStart.Year > 2000 || dataEnd.Year > 2000))
+    {
+        var url = p.GetBaseUrl(true, true, true, new[] { "dataStart", "dataEnd" });
+        if (dataStart.Year > 2000 && dataEnd.Year > 2000) url.UrlParam("dataStart", dataStart.AddDays(step).ToString("yyyy-MM-dd"));
+        if (dataStart.Year > 2000 && dataEnd.Year > 2000) url.UrlParam("dataEnd", dataEnd.AddDays(step).ToString("yyyy-MM-dd"));
+        <label for="dataEnd" class="control-label"><a href="?@Html.Raw(url)" title="后一段">~</a></label>
+    }
+    else
+    {
+        <label for="dataEnd" class="control-label">~</label>
+    }
+    <div class="input-group">
+        @*<span class="input-group-addon"><i class="fa fa-calendar"></i></span>*@
+        <input name="dataEnd" id="dataEnd" value="@p["dataEnd"]" dateformat="@formatStr" class="form-control form_datetime" autocomplete="off" />
+    </div>
+</div>
\ No newline at end of file
Deleted +0 -109
AntJob.Web/Areas/Ant/Views/JobTask/_List_Data.cshtml
Added +61 -0
diff --git a/AntJob.Web/Areas/Ant/Views/Shared/_App_Nav.cshtml b/AntJob.Web/Areas/Ant/Views/Shared/_App_Nav.cshtml
new file mode 100644
index 0000000..9716979
--- /dev/null
+++ b/AntJob.Web/Areas/Ant/Views/Shared/_App_Nav.cshtml
@@ -0,0 +1,61 @@
+@using AntJob.Data;
+@{
+    var path = Context.Request.Path + "";
+    var path2 = "/" + path?.Split("/").Take(3).Join("/");
+
+    var appId = Context.Request.Query["appId"].ToInt(0);
+    if (appId == 0 && path2.EqualIgnoreCase("/Ant/App"))
+    {
+        appId = Context.Request.Query["Id"].ToInt(0);
+    }
+
+    var start = Context.Request.Query["dtStart"].ToDateTime();
+    var end = Context.Request.Query["dtEnd"].ToDateTime();
+    if (end.Year < 2000) end = Context.Request.Query["dtEnd2"].ToDateTime().AddSeconds(1);
+
+    var app = App.FindByID(appId);
+
+    var dic = new Dictionary<String, Object>();
+    dic[app?.Name + ""] = "/Ant/App/Edit?Id=" + appId;
+    dic["作业"] = "/Ant/Job?appId=" + appId;
+    dic["任务"] = "/Ant/JobTask?appId=" + appId;
+    dic["消息"] = "/Ant/AppMessage?appId=" + appId;
+    dic["在线"] = "/Ant/AppOnline?appId=" + appId;
+    dic["历史"] = "/Ant/AppHistory?appId=" + appId;
+    dic["错误"] = "/Ant/JobError?appId=" + appId;
+
+}
+@if (appId > 0)
+{
+    <div class="navbar-collapse text-center">
+        <ul class="nav nav-pills" style="margin-bottom: 10px; display: inline-block;float: none;">
+            @foreach (var item in dic)
+            {
+                if (item.Value is IDictionary<String, Object> childs)
+                {
+                    <li role="presentation" class="dropdown">
+                        <a class="dropdown-toggle" data-toggle="dropdown" href="#" role="button" aria-haspopup="true" aria-expanded="false">@item.Key <span class="caret"></span></a>
+                        <ul class="dropdown-menu">
+                            @foreach (var elm in childs)
+                            {
+                                var url = elm.Value + "";
+                                var v = url.Substring(null, "?");
+                                <li role="presentation" class="@(path.EqualIgnoreCase(v)?"active":"")">
+                                    <a href="@url">@elm.Key</a>
+                                </li>
+                            }
+                        </ul>
+                    </li>
+                }
+                else
+                {
+                    var url = item.Value + "";
+                    var v = url.Substring(null, "?");
+                    <li role="presentation" class="@(path.EqualIgnoreCase(v)?"active":"")">
+                        <a href="@url">@item.Key</a>
+                    </li>
+                }
+            }
+        </ul>
+    </div>
+}
\ No newline at end of file
Modified +5 -6
diff --git a/AntJob.Web/Controllers/AntJobController.cs b/AntJob.Web/Controllers/AntJobController.cs
index 0e5c098..8c03bd1 100644
--- a/AntJob.Web/Controllers/AntJobController.cs
+++ b/AntJob.Web/Controllers/AntJobController.cs
@@ -4,19 +4,19 @@ using AntJob.Data.Entity;
 using AntJob.Models;
 using AntJob.Server;
 using AntJob.Server.Services;
-using AntJob.Web.Common;
 using AntJob.Web.Models;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.Controllers;
 using Microsoft.AspNetCore.Mvc.Filters;
 using NewLife;
-using NewLife.Caching;
 using NewLife.Cube;
 using NewLife.Log;
 using NewLife.Remoting;
+using NewLife.Remoting.Models;
 using NewLife.Serialization;
 using NewLife.Web;
+using ApiFilterAttribute = NewLife.Remoting.Extensions.ApiFilterAttribute;
 using IActionFilter = Microsoft.AspNetCore.Mvc.Filters.IActionFilter;
 
 namespace AntJob.Web.Controllers;
@@ -36,7 +36,6 @@ public class AntJobController : ControllerBase, IActionFilter
     private AntJobSetting _setting;
     private readonly AppService _appService;
     private readonly JobService _jobService;
-    private readonly ICacheProvider _cacheProvider;
 
     #region 构造
     public AntJobController(AppService appService, JobService jobService, AntJobSetting setting)
@@ -103,9 +102,9 @@ public class AntJobController : ControllerBase, IActionFilter
     [HttpPost(nameof(Login))]
     public LoginResponse Login(LoginModel model)
     {
-        if (model.User.IsNullOrEmpty()) throw new ArgumentNullException(nameof(model.User));
+        if (model.Code.IsNullOrEmpty()) throw new ArgumentNullException(nameof(model.Code));
 
-        var (app, rs) = _appService.Login(model, UserHost);
+        var (app, online, rs) = _appService.Login(model, UserHost);
 
         return rs;
     }
@@ -125,7 +124,7 @@ public class AntJobController : ControllerBase, IActionFilter
             // 密码模式
             if (model.grant_type == "password")
             {
-                var (app, rs) = _appService.Login(new LoginModel { User = model.UserName, Pass = model.Password }, ip);
+                var (app, online, rs) = _appService.Login(new LoginModel { Code = model.UserName, Secret = model.Password }, ip);
 
                 var tokenModel = _appService.IssueToken(app.Name, set);
 
Modified +2 -9
diff --git a/AntJob.Web/Program.cs b/AntJob.Web/Program.cs
index 503fd72..d82983f 100644
--- a/AntJob.Web/Program.cs
+++ b/AntJob.Web/Program.cs
@@ -1,7 +1,4 @@
-using System;
-using Microsoft.AspNetCore.Hosting;
-using Microsoft.Extensions.Hosting;
-using NewLife.Cube;
+using NewLife.Cube;
 using NewLife.Log;
 
 namespace AntJob.Web;
@@ -12,11 +9,7 @@ public class Program
     {
         XTrace.UseConsole();
 
-        var app = ApplicationManager.Load();
-        do
-        {
-            app.Start(CreateHostBuilder(args).Build());
-        } while (app.Restarting);
+        CreateHostBuilder(args).Build().Run();
     }
 
     public static IHostBuilder CreateHostBuilder(String[] args)
Modified +8 -1
diff --git a/AntJob.Web/Startup.cs b/AntJob.Web/Startup.cs
index 4ab7f35..672f02a 100644
--- a/AntJob.Web/Startup.cs
+++ b/AntJob.Web/Startup.cs
@@ -1,6 +1,7 @@
 using AntJob.Server;
 using AntJob.Server.Services;
 using NewLife.Cube;
+using NewLife.Security;
 using XCode;
 
 namespace AntJob.Web;
@@ -28,6 +29,9 @@ public class Startup
         services.AddSingleton<AppService>();
         services.AddSingleton<JobService>();
 
+        // 注册密码提供者,用于通信过程中保护密钥,避免明文传输
+        services.AddSingleton<IPasswordProvider>(new SaltPasswordProvider { Algorithm = "md5", SaltTime = 60 });
+
         services.AddControllersWithViews();
         services.AddCube();
     }
@@ -40,6 +44,9 @@ public class Startup
         EntityFactory.InitConnection("Cube");
         EntityFactory.InitConnection("Ant");
 
+        // 修正旧版数据
+        Task.Run(() => JobService.FixOld());
+
         // 使用Cube前添加自己的管道
         if (env.IsDevelopment())
             app.UseDeveloperExceptionPage();
@@ -63,6 +70,6 @@ public class Startup
 
         // 启用星尘注册中心,向注册中心注册服务,服务消费者将自动更新服务端地址列表
         app.RegisterService("AntWeb", null, env.EnvironmentName);
-        app.RegisterService("AntServer", null, env.EnvironmentName);
+        //app.RegisterService("AntServer", null, env.EnvironmentName);
     }
 }
\ No newline at end of file
Modified +9 -5
diff --git a/AntJob/AntJob.csproj b/AntJob/AntJob.csproj
index e10b931..4970646 100644
--- a/AntJob/AntJob.csproj
+++ b/AntJob/AntJob.csproj
@@ -6,7 +6,7 @@
     <Description>分布式任务调度系统,纯NET打造的重量级大数据实时计算平台,万亿级调度经验积累。</Description>
     <Company>新生命开发团队</Company>
     <Copyright>©2002-2024 NewLife</Copyright>
-    <VersionPrefix>3.4</VersionPrefix>
+    <VersionPrefix>4.0</VersionPrefix>
     <VersionSuffix>$([System.DateTime]::Now.ToString(`yyyy.MMdd`))</VersionSuffix>
     <Version>$(VersionPrefix).$(VersionSuffix)</Version>
     <FileVersion>$(Version)</FileVersion>
@@ -28,7 +28,7 @@
     <RepositoryUrl>https://github.com/NewLifeX/AntJob</RepositoryUrl>
     <RepositoryType>git</RepositoryType>
     <PackageTags>新生命团队;X组件;NewLife;$(AssemblyName)</PackageTags>
-    <PackageReleaseNotes>修正RPC粘包处理问题</PackageReleaseNotes>
+    <PackageReleaseNotes>架构升级</PackageReleaseNotes>
     <PackageLicenseExpression>MIT</PackageLicenseExpression>
     <PublishRepositoryUrl>true</PublishRepositoryUrl>
     <EmbedUntrackedSources>true</EmbedUntrackedSources>
@@ -36,6 +36,10 @@
     <SymbolPackageFormat>snupkg</SymbolPackageFormat>
     <PackageReadmeFile>Readme.MD</PackageReadmeFile>
   </PropertyGroup>
+  
+  <ItemGroup>
+    <Compile Remove="Providers\HttpJobProvider.cs" />
+  </ItemGroup>
 
   <ItemGroup>
     <PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0">
@@ -45,9 +49,9 @@
   </ItemGroup>
 
   <ItemGroup>
-    <PackageReference Include="NewLife.Core" Version="10.9.2024.402" />
-    <PackageReference Include="NewLife.Remoting" Version="2.8.2024.402" />
-    <PackageReference Include="NewLife.Stardust" Version="2.9.2024.402" />
+    <PackageReference Include="NewLife.Core" Version="11.0.2024.1001" />
+    <PackageReference Include="NewLife.Remoting" Version="3.1.2024.1002" />
+    <PackageReference Include="NewLife.Stardust" Version="3.1.2024.1004" />
   </ItemGroup>
   
   <ItemGroup>
Modified +4 -1
diff --git a/AntJob/AntSetting.cs b/AntJob/AntSetting.cs
index 9043f31..10c7d29 100644
--- a/AntJob/AntSetting.cs
+++ b/AntJob/AntSetting.cs
@@ -2,12 +2,13 @@
 using System.Reflection;
 using NewLife;
 using NewLife.Configuration;
+using NewLife.Remoting.Clients;
 
 namespace AntJob;
 
 /// <summary>蚂蚁配置。主要用于网络型调度系统</summary>
 [Config("Ant")]
-public class AntSetting : Config<AntSetting>
+public class AntSetting : Config<AntSetting>, IClientSetting
 {
     #region 属性
     /// <summary>调试开关。默认false</summary>
@@ -25,6 +26,8 @@ public class AntSetting : Config<AntSetting>
     /// <summary>应用密钥。</summary>
     [Description("应用密钥。")]
     public String Secret { get; set; }
+
+    String IClientSetting.Code { get => AppID; set => AppID = value; }
     #endregion
 
     #region 方法
Modified +32 -32
diff --git a/AntJob/Data/IJob.cs b/AntJob/Data/IJob.cs
index eff6b30..abca1bc 100644
--- a/AntJob/Data/IJob.cs
+++ b/AntJob/Data/IJob.cs
@@ -1,46 +1,46 @@
-using System;
+namespace AntJob.Data;
 
-namespace AntJob.Data
+/// <summary>作业参数</summary>
+public interface IJob
 {
-    /// <summary>作业参数</summary>
-    public interface IJob
-    {
-        /// <summary>名称</summary>
-        String Name { get; set; }
+    /// <summary>名称</summary>
+    String Name { get; set; }
 
-        /// <summary>类名。支持该作业的处理器实现</summary>
-        String ClassName { get; set; }
+    /// <summary>类名。支持该作业的处理器实现</summary>
+    String ClassName { get; set; }
 
-        /// <summary>是否启用</summary>
-        Boolean Enable { get; set; }
+    /// <summary>是否启用</summary>
+    Boolean Enable { get; set; }
 
-        /// <summary>开始。大于等于该时间,定时作业和数据作业使用</summary>
-        DateTime Start { get; set; }
+    /// <summary>数据时间。定时调度的执行时间点,或者数据调度的开始时间</summary>
+    DateTime DataTime { get; set; }
 
-        /// <summary>结束。小于该时间,数据作业使用</summary>
-        DateTime End { get; set; }
+    /// <summary>结束。小于该时间,数据作业使用</summary>
+    DateTime End { get; set; }
 
-        /// <summary>时间偏移。距离实时时间的秒数,部分业务不能跑到实时</summary>
-        Int32 Offset { get; set; }
+    /// <summary>时间偏移。距离实时时间的秒数,考虑到服务器之间的时间差,部分业务不能跑到实时</summary>
+    Int32 Offset { get; set; }
 
-        /// <summary>步进。最大区间大小,秒</summary>
-        Int32 Step { get; set; }
+    /// <summary>步进。最大区间大小,秒</summary>
+    Int32 Step { get; set; }
 
-        /// <summary>批大小</summary>
-        Int32 BatchSize { get; set; }
+    /// <summary>批大小</summary>
+    Int32 BatchSize { get; set; }
 
-        /// <summary>并行度。最大同时执行任务数</summary>
-        Int32 MaxTask { get; set; }
+    /// <summary>并行度。最大同时执行任务数</summary>
+    Int32 MaxTask { get; set; }
 
-        /// <summary>调度模式。定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑</summary>
-        JobModes Mode { get; set; }
+    /// <summary>调度模式。定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑</summary>
+    JobModes Mode { get; set; }
 
-        /// <summary>消息主题</summary>
-        String Topic { get; set; }
+    /// <summary>Cron定时表达式</summary>
+    String Cron { get; set; }
 
-        /// <summary>数据</summary>
-        String Data { get; set; }
-    }
+    /// <summary>消息主题</summary>
+    String Topic { get; set; }
 
-    public partial class JobModel : IJob { }
-}
\ No newline at end of file
+    /// <summary>数据</summary>
+    String Data { get; set; }
+}
+
+public partial class JobModel : IJob { }
\ No newline at end of file
Modified +17 -20
diff --git a/AntJob/Data/IPeer.cs b/AntJob/Data/IPeer.cs
index 4de40a1..2d9e13c 100644
--- a/AntJob/Data/IPeer.cs
+++ b/AntJob/Data/IPeer.cs
@@ -1,28 +1,25 @@
-using System;
+namespace AntJob.Data;
 
-namespace AntJob.Data
+/// <summary>邻居伙伴</summary>
+public interface IPeer
 {
-    /// <summary>邻居伙伴</summary>
-    public interface IPeer
-    {
-        /// <summary>实例。IP加端口,唯一</summary>
-        String Instance { get; set; }
+    /// <summary>实例。IP加端口,唯一</summary>
+    String Instance { get; set; }
 
-        /// <summary>客户端。IP加进程</summary>
-        String Client { get; set; }
+    /// <summary>客户端。IP加进程</summary>
+    String Client { get; set; }
 
-        /// <summary>名称。机器名称</summary>
-        String Machine { get; set; }
+    /// <summary>名称。机器名称</summary>
+    String Machine { get; set; }
 
-        /// <summary>版本。客户端</summary>
-        String Version { get; set; }
+    /// <summary>版本。客户端</summary>
+    String Version { get; set; }
 
-        /// <summary>创建时间</summary>
-        DateTime CreateTime { get; set; }
+    /// <summary>创建时间</summary>
+    DateTime CreateTime { get; set; }
 
-        /// <summary>更新时间</summary>
-        DateTime UpdateTime { get; set; }
-    }
+    /// <summary>更新时间</summary>
+    DateTime UpdateTime { get; set; }
+}
 
-    public partial class PeerModel : IPeer { }
-}
\ No newline at end of file
+public partial class PeerModel : IPeer { }
\ No newline at end of file
Modified +13 -16
diff --git a/AntJob/Data/ITask.cs b/AntJob/Data/ITask.cs
index 6e1d154..823bfc9 100644
--- a/AntJob/Data/ITask.cs
+++ b/AntJob/Data/ITask.cs
@@ -1,23 +1,20 @@
-using System;
+namespace AntJob.Data;
 
-namespace AntJob.Data
+/// <summary>任务参数</summary>
+public interface ITask
 {
-    /// <summary>任务参数</summary>
-    public interface ITask
-    {
-        /// <summary>任务项编号</summary>
-        Int32 ID { get; set; }
+    /// <summary>任务项编号</summary>
+    Int32 ID { get; set; }
 
-        /// <summary>开始。大于等于</summary>
-        DateTime Start { get; set; }
+    /// <summary>数据时间。定时调度的执行时间点,或者数据调度的开始时间</summary>
+    DateTime DataTime { get; set; }
 
-        /// <summary>结束。小于</summary>
-        DateTime End { get; set; }
+    /// <summary>结束。小于</summary>
+    DateTime End { get; set; }
 
-        /// <summary>批大小</summary>
-        Int32 BatchSize { get; set; }
+    /// <summary>批大小</summary>
+    Int32 BatchSize { get; set; }
 
-        /// <summary>数据</summary>
-        String Data { get; set; }
-    }
+    /// <summary>数据</summary>
+    String Data { get; set; }
 }
\ No newline at end of file
Modified +9 -12
diff --git a/AntJob/Data/ITaskResult.cs b/AntJob/Data/ITaskResult.cs
index 04141dc..4929d5b 100644
--- a/AntJob/Data/ITaskResult.cs
+++ b/AntJob/Data/ITaskResult.cs
@@ -1,17 +1,14 @@
-using System;
+namespace AntJob.Data;
 
-namespace AntJob.Data
+/// <summary>任务结果</summary>
+public interface ITaskResult
 {
-    /// <summary>任务结果</summary>
-    public interface ITaskResult
-    {
-        /// <summary>任务项编号</summary>
-        Int32 ID { get; set; }
+    /// <summary>任务项编号</summary>
+    Int32 ID { get; set; }
 
-        /// <summary>状态</summary>
-        JobStatus Status { get; set; }
+    /// <summary>状态</summary>
+    JobStatus Status { get; set; }
 
-        /// <summary>消息内容。异常信息或其它任务消息</summary>
-        String Message { get; set; }
-    }
+    /// <summary>消息内容。异常信息或其它任务消息</summary>
+    String Message { get; set; }
 }
\ No newline at end of file
Modified +80 -71
diff --git a/AntJob/Data/JobModel.cs b/AntJob/Data/JobModel.cs
index 832213c..4f85c54 100644
--- a/AntJob/Data/JobModel.cs
+++ b/AntJob/Data/JobModel.cs
@@ -1,74 +1,83 @@
-using System;
-using System.Xml.Serialization;
+using System.Xml.Serialization;
 
-namespace AntJob.Data
+namespace AntJob.Data;
+
+/// <summary>作业模型</summary>
+/// <remarks>定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑</remarks>
+public partial class JobModel : ICloneable
 {
-    /// <summary>作业模型</summary>
-    /// <remarks>定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑</remarks>
-    public partial class JobModel
-    {
-        #region 属性
-        /// <summary>名称</summary>
-        [XmlAttribute]
-        public String Name { get; set; }
-
-        /// <summary>类名。支持该作业的处理器实现</summary>
-        [XmlAttribute]
-        public String ClassName { get; set; }
-
-        /// <summary>是否启用</summary>
-        [XmlAttribute]
-        public Boolean Enable { get; set; }
-
-        /// <summary>开始。大于等于</summary>
-        [XmlAttribute]
-        public DateTime Start { get; set; }
-
-        /// <summary>结束。小于</summary>
-        [XmlAttribute]
-        public DateTime End { get; set; }
-
-        /// <summary>时间偏移。距离实时时间的秒数,部分业务不能跑到实时</summary>
-        [XmlAttribute]
-        public Int32 Offset { get; set; }
-
-        /// <summary>步进。最大区间大小,秒</summary>
-        [XmlAttribute]
-        public Int32 Step { get; set; }
-
-        /// <summary>批大小</summary>
-        [XmlAttribute]
-        public Int32 BatchSize { get; set; } = 5000;
-
-        /// <summary>最大任务数</summary>
-        [XmlAttribute]
-        public Int32 MaxTask { get; set; }
-
-        /// <summary>调度模式。定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑</summary>
-        [XmlAttribute]
-        public JobModes Mode { get; set; }
-
-        /// <summary>显示名</summary>
-        [XmlAttribute]
-        public String DisplayName { get; set; }
-
-        /// <summary>描述</summary>
-        [XmlAttribute]
-        public String Description { get; set; }
-
-        /// <summary>消息主题</summary>
-        [XmlAttribute]
-        public String Topic { get; set; }
-
-        /// <summary>数据</summary>
-        [XmlAttribute]
-        public String Data { get; set; }
-        #endregion
-
-        #region 构造
-        /// <summary>已重载。</summary>
-        /// <returns></returns>
-        public override String ToString() => Name;
-        #endregion
-    }
+    #region 属性
+    /// <summary>名称</summary>
+    [XmlAttribute]
+    public String Name { get; set; }
+
+    /// <summary>类名。支持该作业的处理器实现</summary>
+    [XmlAttribute]
+    public String ClassName { get; set; }
+
+    /// <summary>是否启用</summary>
+    [XmlAttribute]
+    public Boolean Enable { get; set; }
+
+    /// <summary>数据时间。定时调度的执行时间点,或者数据调度的开始时间</summary>
+    [XmlAttribute]
+    public DateTime DataTime { get; set; }
+
+    /// <summary>开始时间。兼容旧版</summary>
+    [XmlAttribute]
+    [Obsolete]
+    public DateTime Start { get; set; }
+
+    /// <summary>结束。小于</summary>
+    [XmlAttribute]
+    public DateTime End { get; set; }
+
+    /// <summary>时间偏移。距离实时时间的秒数,考虑到服务器之间的时间差,部分业务不能跑到实时</summary>
+    [XmlAttribute]
+    public Int32 Offset { get; set; }
+
+    /// <summary>步进。最大区间大小,秒</summary>
+    [XmlAttribute]
+    public Int32 Step { get; set; }
+
+    /// <summary>批大小</summary>
+    [XmlAttribute]
+    public Int32 BatchSize { get; set; } = 5000;
+
+    /// <summary>最大任务数</summary>
+    [XmlAttribute]
+    public Int32 MaxTask { get; set; }
+
+    /// <summary>调度模式。定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑</summary>
+    [XmlAttribute]
+    public JobModes Mode { get; set; }
+
+    /// <summary>显示名</summary>
+    [XmlAttribute]
+    public String DisplayName { get; set; }
+
+    /// <summary>描述</summary>
+    [XmlAttribute]
+    public String Description { get; set; }
+
+    /// <summary>Cron定时表达式</summary>
+    [XmlAttribute]
+    public String Cron { get; set; }
+
+    /// <summary>消息主题</summary>
+    [XmlAttribute]
+    public String Topic { get; set; }
+
+    /// <summary>数据</summary>
+    [XmlAttribute]
+    public String Data { get; set; }
+    #endregion
+
+    #region 构造
+    /// <summary>已重载。</summary>
+    /// <returns></returns>
+    public override String ToString() => Name;
+
+    Object ICloneable.Clone() => MemberwiseClone();
+    #endregion
 }
\ No newline at end of file
Modified +21 -22
diff --git a/AntJob/Data/JobModes.cs b/AntJob/Data/JobModes.cs
index 06e1296..52730b2 100644
--- a/AntJob/Data/JobModes.cs
+++ b/AntJob/Data/JobModes.cs
@@ -1,30 +1,29 @@
 using System.ComponentModel;
 
-namespace AntJob.Data
+namespace AntJob.Data;
+
+/// <summary>作业模式</summary>
+/// <remarks>定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑</remarks>
+//[Description("作业模式")]
+public enum JobModes
 {
-    /// <summary>作业模式</summary>
-    /// <remarks>定时调度只要达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑</remarks>
-    //[Description("作业模式")]
-    public enum JobModes
-    {
-        /// <summary>数据调度</summary>
-        [Description("数据调度")]
-        Data = 1,
+    /// <summary>数据调度</summary>
+    [Description("数据调度")]
+    Data = 1,
 
-        /// <summary>定时调度</summary>
-        [Description("定时调度")]
-        Alarm = 2,
+    /// <summary>定时调度</summary>
+    [Description("定时调度")]
+    Time = 2,
 
-        /// <summary>消息调度</summary>
-        [Description("消息调度")]
-        Message = 3,
+    /// <summary>消息调度</summary>
+    [Description("消息调度")]
+    Message = 3,
 
-        ///// <summary>C#调度</summary>
-        //[Description("C#调度")]
-        //CSharp = 4,
+    ///// <summary>C#调度</summary>
+    //[Description("C#调度")]
+    //CSharp = 4,
 
-        ///// <summary>SQL调度</summary>
-        //[Description("SQL调度")]
-        //Sql = 5,
-    }
+    ///// <summary>SQL调度</summary>
+    //[Description("SQL调度")]
+    //Sql = 5,
 }
\ No newline at end of file
Modified +19 -17
diff --git a/AntJob/Data/JobStatus.cs b/AntJob/Data/JobStatus.cs
index d23d36b..06b51a9 100644
--- a/AntJob/Data/JobStatus.cs
+++ b/AntJob/Data/JobStatus.cs
@@ -1,24 +1,26 @@
-namespace AntJob.Data
+namespace AntJob.Data;
+
+/// <summary>作业状态</summary>
+public enum JobStatus
 {
-    /// <summary>作业状态</summary>
-    public enum JobStatus
-    {
-        /// <summary>就绪</summary>
-        就绪 = 0,
+    /// <summary>就绪</summary>
+    就绪 = 0,
+
+    /// <summary>抽取中</summary>
+    抽取中 = 1,
 
-        /// <summary>抽取中</summary>
-        抽取中 = 1,
+    /// <summary>处理中</summary>
+    处理中 = 2,
 
-        /// <summary>处理中</summary>
-        处理中 = 2,
+    /// <summary>错误</summary>
+    错误 = 3,
 
-        /// <summary>错误</summary>
-        错误 = 3,
+    /// <summary>已完成</summary>
+    完成 = 4,
 
-        /// <summary>已完成</summary>
-        完成 = 4,
+    /// <summary>已取消</summary>
+    取消 = 5,
 
-        /// <summary>已取消</summary>
-        取消 = 5,
-    }
+    /// <summary>延迟重试</summary>
+    延迟 = 6,
 }
\ No newline at end of file
Modified +15 -18
diff --git a/AntJob/Data/PeerModel.cs b/AntJob/Data/PeerModel.cs
index 8bfe0a5..945fdff 100644
--- a/AntJob/Data/PeerModel.cs
+++ b/AntJob/Data/PeerModel.cs
@@ -1,26 +1,23 @@
-using System;
+namespace AntJob.Data;
 
-namespace AntJob.Data
+/// <summary>邻居伙伴</summary>
+public partial class PeerModel
 {
-    /// <summary>邻居伙伴</summary>
-    public partial class PeerModel
-    {
-        /// <summary>实例。IP加端口</summary>
-        public String Instance { get; set; }
+    /// <summary>实例。IP加端口</summary>
+    public String Instance { get; set; }
 
-        /// <summary>客户端。IP加进程</summary>
-        public String Client { get; set; }
+    /// <summary>客户端。IP加进程</summary>
+    public String Client { get; set; }
 
-        /// <summary>名称。机器名称</summary>
-        public String Machine { get; set; }
+    /// <summary>名称。机器名称</summary>
+    public String Machine { get; set; }
 
-        /// <summary>版本。客户端</summary>
-        public String Version { get; set; }
+    /// <summary>版本。客户端</summary>
+    public String Version { get; set; }
 
-        /// <summary>创建时间</summary>
-        public DateTime CreateTime { get; set; }
+    /// <summary>创建时间</summary>
+    public DateTime CreateTime { get; set; }
 
-        /// <summary>更新时间</summary>
-        public DateTime UpdateTime { get; set; }
-    }
+    /// <summary>更新时间</summary>
+    public DateTime UpdateTime { get; set; }
 }
\ No newline at end of file
Modified +19 -18
diff --git a/AntJob/Data/TaskModel.cs b/AntJob/Data/TaskModel.cs
index 1c960d7..93ee818 100644
--- a/AntJob/Data/TaskModel.cs
+++ b/AntJob/Data/TaskModel.cs
@@ -1,25 +1,26 @@
-using System;
+namespace AntJob.Data;
 
-namespace AntJob.Data
+/// <summary>任务模型</summary>
+public partial class TaskModel : ITask
 {
-    /// <summary>任务模型</summary>
-    public partial class TaskModel : ITask
-    {
-        #region 属性
-        /// <summary>编号</summary>
-        public Int32 ID { get; set; }
+    #region 属性
+    /// <summary>编号</summary>
+    public Int32 ID { get; set; }
 
-        /// <summary>开始。大于等于</summary>
-        public DateTime Start { get; set; }
+    /// <summary>数据时间。定时调度的执行时间点,或者数据调度的开始时间</summary>
+    public DateTime DataTime { get; set; }
 
-        /// <summary>结束。小于,不等于</summary>
-        public DateTime End { get; set; }
+    /// <summary>开始时间。兼容旧版</summary>
+    [Obsolete]
+    public DateTime Start { get; set; }
 
-        /// <summary>批大小</summary>
-        public Int32 BatchSize { get; set; }
+    /// <summary>结束。小于,不等于</summary>
+    public DateTime End { get; set; }
 
-        /// <summary>数据</summary>
-        public String Data { get; set; }
-        #endregion
-    }
+    /// <summary>批大小</summary>
+    public Int32 BatchSize { get; set; }
+
+    /// <summary>数据</summary>
+    public String Data { get; set; }
+    #endregion
 }
\ No newline at end of file
Modified +31 -28
diff --git a/AntJob/Data/TaskResult.cs b/AntJob/Data/TaskResult.cs
index 3cc32b6..b0c2fd7 100644
--- a/AntJob/Data/TaskResult.cs
+++ b/AntJob/Data/TaskResult.cs
@@ -1,40 +1,43 @@
-using System;
+namespace AntJob.Data;
 
-namespace AntJob.Data
+/// <summary>任务结果</summary>
+public partial class TaskResult : ITaskResult
 {
-    /// <summary>任务结果</summary>
-    public partial class TaskResult : ITaskResult
-    {
-        #region 属性
-        /// <summary>编号</summary>
-        public Int32 ID { get; set; }
+    #region 属性
+    /// <summary>编号</summary>
+    public Int32 ID { get; set; }
 
-        /// <summary>总数</summary>
-        public Int32 Total { get; set; }
+    /// <summary>总数</summary>
+    public Int32 Total { get; set; }
 
-        /// <summary>成功</summary>
-        public Int32 Success { get; set; }
+    /// <summary>成功</summary>
+    public Int32 Success { get; set; }
 
-        /// <summary>耗时,秒</summary>
-        public Int32 Cost { get; set; }
+    /// <summary>耗时,毫秒</summary>
+    public Int32 Cost { get; set; }
 
-        /// <summary>错误</summary>
-        public Int32 Error { get; set; }
+    /// <summary>错误</summary>
+    public Int32 Error { get; set; }
 
-        /// <summary>次数</summary>
-        public Int32 Times { get; set; }
+    /// <summary>次数</summary>
+    public Int32 Times { get; set; }
 
-        /// <summary>速度</summary>
-        public Int32 Speed { get; set; }
+    /// <summary>速度。每秒处理数据量</summary>
+    public Int32 Speed { get; set; }
 
-        /// <summary>状态</summary>
-        public JobStatus Status { get; set; }
+    /// <summary>状态</summary>
+    public JobStatus Status { get; set; }
 
-        /// <summary>最后键值</summary>
-        public String Key { get; set; }
+    /// <summary>下一次执行时间。UTC时间,确保时区兼容</summary>
+    public DateTime NextTime { get; set; }
 
-        /// <summary>消息内容。异常信息或其它任务消息</summary>
-        public String Message { get; set; }
-        #endregion
-    }
+    /// <summary>最后键值</summary>
+    public String Key { get; set; }
+
+    /// <summary>链路追踪</summary>
+    public String TraceId { get; set; }
+
+    /// <summary>消息内容。异常信息或其它任务消息</summary>
+    public String Message { get; set; }
+    #endregion
 }
\ No newline at end of file
Modified +24 -13
diff --git a/AntJob/Data/TemplateHelper.cs b/AntJob/Data/TemplateHelper.cs
index 4de2487..c373e9f 100644
--- a/AntJob/Data/TemplateHelper.cs
+++ b/AntJob/Data/TemplateHelper.cs
@@ -20,20 +20,21 @@ public static class TemplateHelper
         var p = 0;
         while (true)
         {
-            var ti = Find(str, "Start", p);
-            if (ti == null)
+            var ti = Find(str, "DataTime", p);
+            if (ti.IsEmpty) ti = Find(str, "dt", p);
+            if (ti.IsEmpty)
             {
                 sb.Append(str.Substring(p));
                 break;
             }
 
             // 准备替换
-            var val = ti.Item3.IsNullOrEmpty() ? startTime.ToFullString() : startTime.ToString(ti.Item3);
-            sb.Append(str.Substring(p, ti.Item1 - p));
+            var val = ti.Format.IsNullOrEmpty() ? startTime.ToFullString() : startTime.ToString(ti.Format);
+            sb.Append(str.Substring(p, ti.Start - p));
             sb.Append(val);
 
             // 移动指针
-            p = ti.Item2 + 1;
+            p = ti.End + 1;
         }
 
         str = sb.ToString();
@@ -42,32 +43,32 @@ public static class TemplateHelper
         while (true)
         {
             var ti = Find(str, "End", p);
-            if (ti == null)
+            if (ti.IsEmpty)
             {
                 sb.Append(str.Substring(p));
                 break;
             }
 
             // 准备替换
-            var val = ti.Item3.IsNullOrEmpty() ? endTime.ToFullString() : endTime.ToString(ti.Item3);
-            sb.Append(str.Substring(p, ti.Item1 - p));
+            var val = ti.Format.IsNullOrEmpty() ? endTime.ToFullString() : endTime.ToString(ti.Format);
+            sb.Append(str.Substring(p, ti.Start - p));
             sb.Append(val);
 
             // 移动指针
-            p = ti.Item2 + 1;
+            p = ti.End + 1;
         }
 
         return sb.Put(true);
     }
 
-    private static Tuple<Int32, Int32, String> Find(String str, String key, Int32 p)
+    private static VarItem Find(String str, String key, Int32 p)
     {
         // 头尾
         var p1 = str.IndexOf("{" + key, p);
-        if (p1 < 0) return null;
+        if (p1 < 0) return _empty;
 
         var p2 = str.IndexOf("}", p1);
-        if (p2 < 0) return null;
+        if (p2 < 0) return _empty;
 
         // 格式化字符串
         var format = "";
@@ -75,7 +76,17 @@ public static class TemplateHelper
         if (p3 > 0 && p3 < p2) format = str.Substring(p3 + 1, p2 - p3 - 1);
 
         // 左括号位置,右括号位置,格式化字符串
-        return new Tuple<Int32, Int32, String>(p1, p2, format);
+        return new VarItem(p1, p2, format);
+    }
+
+    private static VarItem _empty = new(-1, -1, "");
+    struct VarItem(Int32 start, Int32 end, String format)
+    {
+        public Int32 Start = start;
+        public Int32 End = end;
+        public String Format = format;
+
+        public readonly Boolean IsEmpty => Start < 0;
     }
 
     /// <summary>使用消息数组处理模板</summary>
Added +170 -0
diff --git a/AntJob/Data/TimeExpression.cs b/AntJob/Data/TimeExpression.cs
new file mode 100644
index 0000000..41b334a
--- /dev/null
+++ b/AntJob/Data/TimeExpression.cs
@@ -0,0 +1,170 @@
+using NewLife;
+
+namespace AntJob.Data;
+
+/// <summary>时间表达式,一次解析多次使用。如{dt+1M+5d:yyyyMMdd}</summary>
+public class TimeExpression
+{
+    #region 属性
+    /// <summary>表达式</summary>
+    public String Expression { get; set; }
+
+    /// <summary>变量名</summary>
+    public String VarName { get; set; } = "dt";
+
+    /// <summary>格式化字符串</summary>
+    public String Format { get; set; }
+
+    /// <summary>时间表达式项集合</summary>
+    public IList<TimeExpressionItem> Items { get; } = [];
+    #endregion
+
+    #region 构造
+    /// <summary>实例化时间表达式</summary>
+    public TimeExpression() { }
+
+    /// <summary>实例化时间表达式</summary>
+    public TimeExpression(String expression) => Parse(expression);
+    #endregion
+
+    #region 方法
+    /// <summary>解析表达式</summary>
+    public Boolean Parse(String expression)
+    {
+        var p1 = expression.IndexOf('{');
+        if (p1 < 0) return false;
+
+        var p2 = expression.IndexOf('}', p1);
+        if (p2 < 0) return false;
+
+        expression = expression.Substring(p1 + 1, p2 - p1 - 1);
+
+        // 循环查找
+        var ms = Items;
+        p1 = -1;
+        while (true)
+        {
+            p2 = expression.IndexOfAny(['+', '-', ':', ','], p1 + 1);
+            if (p2 < 0) p2 = expression.Length;
+
+            // 第一段是变量名
+            if (p1 < 0 && p2 > 0)
+            {
+                VarName = expression[0..p2];
+            }
+            else if (expression[p1] is '+' or '-')
+            {
+                var str = expression[p1..p2];
+                var item = new TimeExpressionItem();
+                if (!item.Parse(str)) throw new InvalidDataException($"Invalid [{str}]");
+
+                ms.Add(item);
+            }
+            else if (expression[p1] is ':' or ',')
+            {
+                // 最后一段是格式化字符串
+                p2 = expression.Length;
+                Format = expression[(p1 + 1)..p2];
+            }
+
+            if (p2 >= expression.Length) break;
+            p1 = p2;
+        }
+
+        // 默认天级
+        if (ms.Count == 0) ms.Add(new TimeExpressionItem { Level = "d", Value = 0 });
+
+        Expression = expression;
+
+        return true;
+    }
+
+    /// <summary>执行时间偏移</summary>
+    public DateTime Execute(DateTime time)
+    {
+        foreach (var item in Items)
+        {
+            time = item.Execute(time);
+        }
+
+        return time;
+    }
+
+    /// <summary>构建时间字符串</summary>
+    public String Build(DateTime time)
+    {
+        time = Execute(time);
+
+        var ms = Items;
+        var format = Format;
+        if (format.IsNullOrEmpty() && ms.Count > 0) format = ms[ms.Count - 1].GetFormat();
+        if (format.IsNullOrEmpty()) format = "yyyyMMdd";
+
+        return time.ToString(format);
+    }
+    #endregion
+
+    /// <summary>处理时间偏移模版。如{dt+1M+5d:yyyyMMdd}</summary>
+    /// <param name="template"></param>
+    /// <param name="time"></param>
+    /// <returns></returns>
+    public static String Build(String template, DateTime time)
+    {
+        return null;
+    }
+}
+
+/// <summary>时间表达式项。如+5d</summary>
+public class TimeExpressionItem
+{
+    /// <summary>级别。如y/M/d/H/m/w</summary>
+    public String Level { get; set; }
+
+    /// <summary>数值。包括正负</summary>
+    public Int32 Value { get; set; }
+
+    /// <summary>分解表达式项。如+5d</summary>
+    /// <param name="value"></param>
+    /// <returns></returns>
+    public Boolean Parse(String value)
+    {
+        if (value.IsNullOrEmpty() || value.Length < 3) return false;
+
+        Level = value[^1..];
+        Value = value[..^1].TrimStart('+').ToInt();
+
+        return true;
+    }
+
+    /// <summary>执行时间偏移</summary>
+    public DateTime Execute(DateTime time)
+    {
+        return Level switch
+        {
+            "y" => new DateTime(time.Year, 1, 1, 0, 0, 0, time.Kind).AddYears(Value),
+            "M" => new DateTime(time.Year, time.Month, 1, 0, 0, 0, time.Kind).AddMonths(Value),
+            "d" => new DateTime(time.Year, time.Month, time.Day, 0, 0, 0, time.Kind).AddDays(Value),
+            "H" => new DateTime(time.Year, time.Month, time.Day, time.Hour, 0, 0, time.Kind).AddHours(Value),
+            "m" => new DateTime(time.Year, time.Month, time.Day, time.Hour, time.Minute, 0, time.Kind).AddMinutes(Value),
+            "s" => new DateTime(time.Year, time.Month, time.Day, time.Hour, time.Minute, time.Second, time.Kind).AddSeconds(Value),
+            "w" => new DateTime(time.Year, time.Month, time.Day, 0, 0, 0, time.Kind).AddDays(Value * 7),
+            _ => time,
+        };
+    }
+
+    /// <summary>获取格式化字符串</summary>
+    public String GetFormat()
+    {
+        return Level switch
+        {
+            "y" => "yyyy",
+            "M" => "yyyyMM",
+            "d" => "yyyyMMdd",
+            "H" => "yyyyMMddHH",
+            "m" => "yyyyMMddHHmm",
+            "s" => "yyyyMMddHHmmss",
+            "w" => "yyyyww",
+            _ => "",
+        };
+    }
+}
\ No newline at end of file
Modified +60 -20
diff --git a/AntJob/Handler.cs b/AntJob/Handler.cs
index 253c5b5..2e146a5 100644
--- a/AntJob/Handler.cs
+++ b/AntJob/Handler.cs
@@ -2,19 +2,25 @@
 using AntJob.Data;
 using AntJob.Providers;
 using NewLife;
-using NewLife.Collections;
+using NewLife.Data;
 using NewLife.Log;
 
 namespace AntJob;
 
 /// <summary>处理器基类,每个作业一个处理器</summary>
 /// <remarks>
+/// 文档:https://newlifex.com/blood/antjob
+/// 
 /// 每个作业一个处理器类,负责一个业务处理模块。
 /// 例如在数据同步或数据清洗中,每张表就写一个处理器,如果一组数据表有共同特性,还可以为它们封装一个自己的处理器基类。
 /// 
 /// 定时调度只要当前时间达到时间片开头就可以跑,数据调度要求达到时间片末尾才可以跑。
+/// 
+/// 调度器控制方法:Start|Stop|Acquire
+/// 任务处理流程:Process->OnProcess->Execute->OnFinish
+/// 任务控制方法:Produce|Delay
 /// </remarks>
-public abstract class Handler
+public abstract class Handler : IExtend, ITracerFeature, ILogFeature
 {
     #region 属性
     /// <summary>名称</summary>
@@ -33,22 +39,24 @@ public abstract class Handler
     public Boolean Active { get; private set; }
 
     /// <summary>调度模式</summary>
-    public virtual JobModes Mode { get; set; } = JobModes.Alarm;
+    public virtual JobModes Mode { get; set; } = JobModes.Time;
 
     private volatile Int32 _Busy;
     /// <summary>正在处理中的任务数</summary>
     public Int32 Busy => _Busy;
 
-    /// <summary>性能跟踪器</summary>
-    public ITracer Tracer { get; set; }
+    private Int32 _speed;
     #endregion
 
     #region 索引器
-    private readonly IDictionary<String, Object> _Items = new NullableDictionary<String, Object>(StringComparer.OrdinalIgnoreCase);
+    private readonly Dictionary<String, Object> _Items = [];
+    /// <summary>扩展数据</summary>
+    IDictionary<String, Object> IExtend.Items => _Items;
+
     /// <summary>用户数据</summary>
     /// <param name="item"></param>
     /// <returns></returns>
-    public Object this[String item] { get => _Items[item]; set => _Items[item] = value; }
+    public Object this[String item] { get => _Items.TryGetValue(item, out var obj) ? obj : null; set => _Items[item] = value; }
     #endregion
 
     #region 构造
@@ -61,10 +69,11 @@ public abstract class Handler
         var now = DateTime.Now;
         var job = new JobModel
         {
-            Start = new DateTime(now.Year, now.Month, 1),
+            DataTime = now.Date,
             Step = 30,
             Offset = 15,
-            Mode = JobModes.Alarm,
+            Mode = JobModes.Time,
+            Cron = "0/30 * * *",
         };
 
         // 默认并发数为核心数
@@ -76,6 +85,9 @@ public abstract class Handler
     #endregion
 
     #region 基本方法
+    /// <summary>初始化。作业处理器启动之前</summary>
+    public virtual void Init() { }
+
     /// <summary>开始</summary>
     public virtual Boolean Start()
     {
@@ -83,7 +95,7 @@ public abstract class Handler
 
         var msg = "开始工作";
         var job = Job;
-        if (job != null) msg += $" {job.Enable} 区间({job.Start.ToFullString("")}, {job.End.ToFullString("")}) Offset={job.Offset} Step={job.Step} MaxTask={job.MaxTask}";
+        if (job != null) msg += $" {job.Enable} 区间({job.DataTime.ToFullString("")}, {job.End.ToFullString("")}) Offset={job.Offset} Step={job.Step} MaxTask={job.MaxTask}";
 
         using var span = Tracer?.NewSpan($"job:{Name}:Start", msg);
         WriteLog(msg);
@@ -131,25 +143,31 @@ public abstract class Handler
 
     /// <summary>处理一项新任务</summary>
     /// <param name="task"></param>
-    public void Process(ITask task)
+    public virtual void Process(ITask task)
     {
         if (task == null) return;
 
+        var result = new TaskResult { ID = task.ID };
         var ctx = new JobContext
         {
             Handler = this,
             Task = task,
-            Result = new TaskResult { ID = task.ID },
+            Result = result,
         };
 
         // APM埋点
-        var span = Schedule.Tracer?.NewSpan($"job:{Name}", task.Data ?? $"({task.Start.ToFullString()}, {task.End.ToFullString()})");
-        ctx.Remark = span?.ToString();
+        using var span = Schedule?.Tracer?.NewSpan($"job:{Name}", task.Data ?? $"({task.DataTime.ToFullString()}, {task.End.ToFullString()})");
+        result.TraceId = span?.TraceId;
+
+        // 较慢的作业,及时报告进度
+        if (_speed < 10) Report(ctx, JobStatus.处理中);
 
         var sw = Stopwatch.StartNew();
         try
         {
             OnProcess(ctx);
+
+            if (span != null) span.Value = ctx.Total;
         }
         catch (Exception ex)
         {
@@ -161,16 +179,17 @@ public abstract class Handler
         finally
         {
             Interlocked.Decrement(ref _Busy);
-            span?.Dispose();
+            //span?.Dispose();
         }
 
         sw.Stop();
         ctx.Cost = sw.Elapsed.TotalMilliseconds;
+        _speed = ctx.Speed;
 
         OnFinish(ctx);
         Schedule?.OnFinish(ctx);
 
-        ctx.Items.Clear();
+        //ctx.Items.Clear();
     }
 
     /// <summary>处理任务。内部分批处理</summary>
@@ -180,27 +199,48 @@ public abstract class Handler
         ctx.Total = 1;
         ctx.Success = Execute(ctx);
     }
+
+    /// <summary>报告任务状态</summary>
+    /// <param name="ctx"></param>
+    /// <param name="status"></param>
+    protected virtual void Report(JobContext ctx, JobStatus status)
+    {
+        ctx.Status = status;
+        Provider?.Report(ctx);
+    }
+
+    /// <summary>整个任务完成</summary>
+    /// <param name="ctx"></param>
+    protected virtual void OnFinish(JobContext ctx) => Provider?.Finish(ctx);
     #endregion
 
     #region 数据处理
     /// <summary>处理一批数据,一个任务内多次调用</summary>
     /// <param name="ctx">上下文</param>
     /// <returns></returns>
-    protected abstract Int32 Execute(JobContext ctx);
+    public abstract Int32 Execute(JobContext ctx);
 
     /// <summary>生产消息</summary>
     /// <param name="topic">主题</param>
     /// <param name="messages">消息集合</param>
     /// <param name="option">消息选项</param>
     /// <returns></returns>
-    public Int32 Produce(String topic, String[] messages, MessageOption option = null) => Provider.Produce(Job?.Name, topic, messages, option);
+    public virtual Int32 Produce(String topic, String[] messages, MessageOption option = null) => Provider.Produce(Job?.Name, topic, messages, option);
 
-    /// <summary>整个任务完成</summary>
+    /// <summary>延迟执行,指定下一次执行时间</summary>
     /// <param name="ctx"></param>
-    protected virtual void OnFinish(JobContext ctx) => Provider?.Finish(ctx);
+    /// <param name="nextTime"></param>
+    public virtual void Delay(JobContext ctx, DateTime nextTime)
+    {
+        ctx.Status = JobStatus.延迟;
+        ctx.NextTime = nextTime;
+    }
     #endregion
 
     #region 日志
+    /// <summary>性能跟踪器</summary>
+    public ITracer Tracer { get; set; }
+
     /// <summary>日志</summary>
     public ILog Log { get; set; } = Logger.Null;
 
Modified +4 -3
diff --git a/AntJob/Handlers/CSharpHandler.cs b/AntJob/Handlers/CSharpHandler.cs
index c34bfd0..6e74175 100644
--- a/AntJob/Handlers/CSharpHandler.cs
+++ b/AntJob/Handlers/CSharpHandler.cs
@@ -1,4 +1,5 @@
-using NewLife;
+using AntJob.Data;
+using NewLife;
 
 namespace AntJob.Handlers;
 
@@ -15,7 +16,7 @@ public class CSharpHandler : Handler
     /// <summary>实例化</summary>
     public CSharpHandler()
     {
-        //Mode = JobModes.CSharp;
+        Mode = JobModes.Time;
 
         var job = Job;
         job.BatchSize = 8;
@@ -25,7 +26,7 @@ public class CSharpHandler : Handler
     /// <summary>执行</summary>
     /// <param name="ctx"></param>
     /// <returns></returns>
-    protected override Int32 Execute(JobContext ctx)
+    public override Int32 Execute(JobContext ctx)
     {
         var code = ctx.Data as String;
         if (code.IsNullOrWhiteSpace()) return -1;
Modified +6 -15
diff --git a/AntJob/Handlers/MessageHandler.cs b/AntJob/Handlers/MessageHandler.cs
index 0d3edd0..901fc7f 100644
--- a/AntJob/Handlers/MessageHandler.cs
+++ b/AntJob/Handlers/MessageHandler.cs
@@ -1,6 +1,7 @@
 using System.Collections;
 using AntJob.Data;
 using NewLife;
+using NewLife.Log;
 using NewLife.Serialization;
 
 namespace AntJob.Handlers;
@@ -58,18 +59,11 @@ public abstract class MessageHandler : Handler
         var ss = ctx.Task.Data.ToJsonEntity<String[]>();
         if (ss == null || ss.Length == 0) return;
 
-        //// 消息作业特殊优待字符串,不需要再次Json解码
-        //if (typeof(TModel) == typeof(String))
-        //{
         ctx.Total = ss.Length;
         ctx.Data = ss;
-        //}
-        //else
-        //{
-        //    var ms = ss.Select(e => e.ToJsonEntity<TModel>()).ToList();
-        //    ctx.Total = ms.Count;
-        //    ctx.Data = ms;
-        //}
+
+        var span = DefaultSpan.Current;
+        if (span != null) span.Value = ctx.Total;
 
         Execute(ctx);
     }
@@ -77,14 +71,11 @@ public abstract class MessageHandler : Handler
     /// <summary>根据解码后的消息执行任务</summary>
     /// <param name="ctx">上下文</param>
     /// <returns></returns>
-    protected override Int32 Execute(JobContext ctx)
+    public override Int32 Execute(JobContext ctx)
     {
         var count = 0;
         foreach (String item in ctx.Data as IEnumerable)
         {
-            //ctx.Key = item as String;
-            //ctx.Entity = item;
-
             if (ProcessItem(ctx, item)) count++;
         }
 
@@ -95,6 +86,6 @@ public abstract class MessageHandler : Handler
     /// <param name="ctx">上下文</param>
     /// <param name="message">消息</param>
     /// <returns></returns>
-    protected virtual Boolean ProcessItem(JobContext ctx, String message) => true;
+    public virtual Boolean ProcessItem(JobContext ctx, String message) => true;
     #endregion
 }
\ No newline at end of file
Modified +21 -4
diff --git a/AntJob/JobContext.cs b/AntJob/JobContext.cs
index d9f3c7b..a5b27b1 100644
--- a/AntJob/JobContext.cs
+++ b/AntJob/JobContext.cs
@@ -1,4 +1,5 @@
-using AntJob.Data;
+using System.Collections;
+using AntJob.Data;
 using NewLife.Collections;
 using NewLife.Data;
 
@@ -35,12 +36,12 @@ public class JobContext : IExtend
     /// <summary>最后处理键值。由业务决定,便于分析问题</summary>
     public String Key { get; set; }
 
-    ///// <summary>当前处理对象</summary>
-    //public Object Entity { get; set; }
-
     /// <summary>处理异常</summary>
     public Exception Error { get; set; }
 
+    /// <summary>下一次执行时间</summary>
+    public DateTime NextTime { get; set; }
+
     /// <summary>任务备注消息。可用于保存到任务项内容字段</summary>
     public String Remark { get; set; }
     #endregion
@@ -59,4 +60,20 @@ public class JobContext : IExtend
     /// <summary>处理速度</summary>
     public Int32 Speed => (Cost <= 0 || Total == 0) ? 0 : (Int32)Math.Min(Total * 1000L / Cost, Int32.MaxValue);
     #endregion
+
+    #region 方法
+    /// <summary>根据指定实体类型返回数据列表</summary>
+    /// <typeparam name="T"></typeparam>
+    /// <returns></returns>
+    public IList<T> GetDatas<T>()
+    {
+        if (Data == null) return null;
+        if (Data is IList<T> data) return data;
+
+        // 修改列表类型,由 IList<IEntity> 改为 IList<TEntity> ,方便用户使用
+        if (Data is IEnumerable enumerable) return enumerable.Cast<T>().ToList();
+
+        return null;
+    }
+    #endregion
 }
\ No newline at end of file
Modified +18 -16
diff --git a/AntJob/Models/LoginModel.cs b/AntJob/Models/LoginModel.cs
index 9115867..c9c2bbe 100644
--- a/AntJob/Models/LoginModel.cs
+++ b/AntJob/Models/LoginModel.cs
@@ -1,7 +1,9 @@
-namespace AntJob.Models;
+using NewLife.Remoting.Models;
+
+namespace AntJob.Models;
 
 /// <summary>登录模型</summary>
-public class LoginModel
+public class LoginModel : LoginRequest
 {
     /// <summary>用户名</summary>
     public String User { get; set; }
@@ -18,22 +20,22 @@ public class LoginModel
     /// <summary>进程Id</summary>
     public Int32 ProcessId { get; set; }
 
-    /// <summary>版本</summary>
-    public String Version { get; set; }
+    ///// <summary>版本</summary>
+    //public String Version { get; set; }
 
-    /// <summary>编译时间</summary>
-    public DateTime Compile { get; set; }
+    ///// <summary>编译时间</summary>
+    //public DateTime Compile { get; set; }
 }
 
-/// <summary>登录响应</summary>
-public class LoginResponse
-{
-    /// <summary>名称</summary>
-    public String Name { get; set; }
+///// <summary>登录响应</summary>
+//public class LoginResponse
+//{
+//    /// <summary>名称</summary>
+//    public String Name { get; set; }
 
-    /// <summary>密钥。仅注册时返回</summary>
-    public String Secret { get; set; }
+//    /// <summary>密钥。仅注册时返回</summary>
+//    public String Secret { get; set; }
 
-    /// <summary>显示名</summary>
-    public String DisplayName { get; set; }
-}
\ No newline at end of file
+//    /// <summary>显示名</summary>
+//    public String DisplayName { get; set; }
+//}
\ No newline at end of file
Deleted +0 -13
AntJob/Properties/PublishProfiles/FolderProfile.pubxml
Modified +62 -73
diff --git a/AntJob/Providers/AntClient.cs b/AntJob/Providers/AntClient.cs
index 446a3da..163971f 100644
--- a/AntJob/Providers/AntClient.cs
+++ b/AntJob/Providers/AntClient.cs
@@ -4,71 +4,75 @@ using System.Reflection;
 using AntJob.Data;
 using AntJob.Models;
 using NewLife;
-using NewLife.Log;
-using NewLife.Net;
+using NewLife.Model;
 using NewLife.Reflection;
-using NewLife.Remoting;
-using NewLife.Serialization;
+using NewLife.Remoting.Clients;
+using NewLife.Remoting.Models;
 
 namespace AntJob.Providers;
 
 /// <summary>蚂蚁客户端</summary>
-public class AntClient : ApiClient
+public class AntClient : ClientBase
 {
     #region 属性
-    /// <summary>用户名</summary>
-    public String UserName { get; set; }
-
-    /// <summary>密码</summary>
-    public String Password { get; set; }
-
-    /// <summary>是否已登录</summary>
-    public Boolean Logined { get; set; }
-
-    /// <summary>最后一次登录成功后的消息</summary>
-    public LoginResponse Info { get; private set; }
+    private readonly AntSetting _setting;
     #endregion
 
-    #region 方法
+    #region 构造
+    /// <summary>实例化</summary>
+    public AntClient() => InitClient();
+
     /// <summary>实例化</summary>
-    public AntClient()
+    /// <param name="setting"></param>
+    public AntClient(AntSetting setting) : base(setting)
     {
-        Log = XTrace.Log;
+        _setting = setting;
 
-        //StatPeriod = 60;
-        ShowError = true;
+        InitClient();
+    }
+
+    private void InitClient()
+    {
+        Features = Features.Login | Features.Ping;
 
-#if DEBUG
-        EncoderLog = XTrace.Log;
-        StatPeriod = 10;
-#endif
+        if (Server.StartsWithIgnoreCase("http://", "https://"))
+            SetActions("AntJob/");
+        else
+            SetActions("");
     }
+    #endregion
 
-    /// <summary>实例化</summary>
-    /// <param name="uri"></param>
-    public AntClient(String uri) : this()
+    #region 方法
+    /// <summary>初始化</summary>
+    protected override void OnInit()
     {
-        if (!uri.IsNullOrEmpty())
+        var provider = ServiceProvider ??= ObjectContainer.Provider;
+
+        // 找到容器,注册默认的模型实现,供后续InvokeAsync时自动创建正确的模型对象
+        var container = ModelExtension.GetService<IObjectContainer>(provider) ?? ObjectContainer.Current;
+        if (container != null)
         {
-            var ss = uri.Split(",", ";");
+            container.TryAddTransient<ILoginRequest, LoginModel>();
+            //container.TryAddTransient<ILoginResponse, LoginResponse>();
+            //container.TryAddTransient<ILogoutResponse, LogoutResponse>();
+            //container.TryAddTransient<IPingRequest, PingInfo>();
+            //container.TryAddTransient<IPingResponse, PingResponse>();
+            //container.TryAddTransient<IUpgradeInfo, UpgradeInfo>();
+        }
 
-            Servers = ss;
+        InitClient();
 
-            var u = new Uri(ss[0]);
-            var us = u.UserInfo.Split(":");
-            if (us.Length > 0) UserName = us[0];
-            if (us.Length > 1) Password = us[1];
-        }
+        base.OnInit();
     }
     #endregion
 
     #region 登录
-    /// <summary>连接后自动登录</summary>
-    /// <param name="client">客户端</param>
-    /// <param name="force">强制登录</param>
-    protected override async Task<Object> OnLoginAsync(ISocketClient client, Boolean force)
+    /// <summary>创建登录请求</summary>
+    /// <returns></returns>
+    public override ILoginRequest BuildLoginRequest()
     {
-        if (Logined && !force) return null;
+        var request = new LoginModel();
+        FillLoginRequest(request);
 
         var asmx = AssemblyX.Entry;
         var title = asmx?.Asm.GetCustomAttribute<AssemblyTitleAttribute>();
@@ -76,61 +80,46 @@ public class AntClient : ApiClient
         var des = asmx?.Asm.GetCustomAttribute<DescriptionAttribute>();
         var dname = title?.Title ?? dis?.DisplayName ?? des?.Description;
 
-        var arg = new LoginModel
-        {
-            User = UserName,
-            Pass = Password.IsNullOrEmpty() ? null : Password.MD5(),
-            DisplayName = dname,
-            Machine = Environment.MachineName,
-            ProcessId = Process.GetCurrentProcess().Id,
-            Version = asmx.Version,
-            Compile = asmx.Compile,
-        };
-
-        var rs = await base.InvokeWithClientAsync<LoginResponse>(client, "Login", arg);
-
-        var set = AntSetting.Current;
-        if (set.Debug) XTrace.WriteLine("登录{0}成功!{1}", client, rs.ToJson());
-
-        // 保存下发密钥
-        if (!rs.Secret.IsNullOrEmpty())
-        {
-            set.Secret = rs.Secret;
-            set.Save();
-        }
-
-        Logined = true;
+        request.DisplayName = dname;
+        request.Machine = Environment.MachineName;
+        request.ProcessId = Process.GetCurrentProcess().Id;
+        //request.Compile = asmx.Compile;
 
-        return Info = rs;
+        return request;
     }
     #endregion
 
     #region 核心方法
     /// <summary>获取指定名称的作业</summary>
     /// <returns></returns>
-    public IJob[] GetJobs() => Invoke<JobModel[]>(nameof(GetJobs));
+    public IJob[] GetJobs() => InvokeAsync<JobModel[]>(nameof(GetJobs)).Result;
 
     /// <summary>批量添加作业</summary>
     /// <param name="jobs"></param>
     /// <returns></returns>
-    public String[] AddJobs(IJob[] jobs) => Invoke<String[]>(nameof(AddJobs), new { jobs });
+    public String[] AddJobs(IJob[] jobs) => InvokeAsync<String[]>(nameof(AddJobs), new { jobs }).Result;
+
+    /// <summary>设置作业。支持控制作业启停、数据时间、步进等参数</summary>
+    /// <param name="job"></param>
+    /// <returns></returns>
+    public IJob SetJob(IDictionary<String, Object> job) => InvokeAsync<JobModel>(nameof(SetJob), job).Result;
 
     /// <summary>申请作业任务</summary>
     /// <param name="job">作业</param>
     /// <param name="topic">主题</param>
     /// <param name="count">要申请的任务个数</param>
     /// <returns></returns>
-    public ITask[] Acquire(String job, String topic, Int32 count) => Invoke<TaskModel[]>(nameof(Acquire), new AcquireModel
+    public ITask[] Acquire(String job, String topic, Int32 count) => InvokeAsync<TaskModel[]>(nameof(Acquire), new AcquireModel
     {
         Job = job,
         Topic = topic,
         Count = count,
-    });
+    }).Result;
 
     /// <summary>生产消息</summary>
     /// <param name="model">模型</param>
     /// <returns></returns>
-    public Int32 Produce(ProduceModel model) => Invoke<Int32>(nameof(Produce), model);
+    public Int32 Produce(ProduceModel model) => InvokeAsync<Int32>(nameof(Produce), model).Result;
 
     /// <summary>报告状态(进度、成功、错误)</summary>
     /// <param name="task"></param>
@@ -143,7 +132,7 @@ public class AntClient : ApiClient
         {
             try
             {
-                return Invoke<Boolean>(nameof(Report), task);
+                return InvokeAsync<Boolean>(nameof(Report), task).Result;
             }
             catch (Exception ex)
             {
@@ -156,6 +145,6 @@ public class AntClient : ApiClient
 
     /// <summary>获取当前应用的所有在线实例</summary>
     /// <returns></returns>
-    public IPeer[] GetPeers() => Invoke<PeerModel[]>(nameof(GetPeers));
+    public IPeer[] GetPeers() => InvokeAsync<PeerModel[]>(nameof(GetPeers)).Result;
     #endregion
 }
\ No newline at end of file
Modified +18 -10
diff --git a/AntJob/Providers/FileJobProvider.cs b/AntJob/Providers/FileJobProvider.cs
index 30f6038..b8f4a53 100644
--- a/AntJob/Providers/FileJobProvider.cs
+++ b/AntJob/Providers/FileJobProvider.cs
@@ -43,7 +43,7 @@ public class FileJobProvider : JobProvider
                 var df = job?.Job;
                 if (df != null) model.Copy(df);
 
-                if (model.Start.Year <= 2000) model.Start = DateTime.Now.Date;
+                if (model.DataTime.Year <= 2000) model.DataTime = DateTime.Now.Date;
                 if (model.Step <= 0) model.Step = 30;
                 if (model.BatchSize <= 0) model.BatchSize = 10000;
                 if (model.MaxTask <= 0) model.MaxTask = Environment.ProcessorCount;
@@ -88,6 +88,11 @@ public class FileJobProvider : JobProvider
         return list.ToArray();
     }
 
+    /// <summary>设置作业。支持控制作业启停、数据时间、步进等参数</summary>
+    /// <param name="job"></param>
+    /// <returns></returns>
+    public override IJob SetJob(IJob job) => null;
+
     /// <summary>申请任务</summary>
     /// <param name="job">作业</param>
     /// <param name="topic">主题</param>
@@ -108,7 +113,7 @@ public class FileJobProvider : JobProvider
         var step = job.Step;
         if (step <= 0) step = 30;
 
-        var start = job.Start;
+        var start = job.DataTime;
         for (var i = 0; i < count; i++)
         {
             // 开始时间和结束时间是否越界
@@ -119,7 +124,7 @@ public class FileJobProvider : JobProvider
             if (job.End.Year > 2000 && end > job.End) end = job.End;
 
             // 时间片必须严格要求按照步进大小分片,除非有合适的End
-            if (job.Mode != JobModes.Alarm)
+            if (job.Mode != JobModes.Time)
             {
                 if (end > now) break;
             }
@@ -128,9 +133,9 @@ public class FileJobProvider : JobProvider
             if (start >= end) break;
 
             // 切分新任务
-            var set = new TaskModel
+            var task = new TaskModel
             {
-                Start = start,
+                DataTime = start,
                 End = end,
                 //Step = job.Step,
                 //Offset = job.Offset,
@@ -138,10 +143,10 @@ public class FileJobProvider : JobProvider
             };
 
             // 更新任务
-            job.Start = end;
+            job.DataTime = end;
             start = end;
 
-            list.Add(set);
+            list.Add(task);
         }
 
         if (list.Count > 0)
@@ -163,13 +168,16 @@ public class FileJobProvider : JobProvider
         if (ctx.Total > 0)
         {
             var set = ctx.Task;
+            var time = set.DataTime;
+            var end = set.End;
             var n = 0;
-            if (set.End > set.Start) n = (Int32)(set.End - set.Start).TotalSeconds;
-            var msg = $"{ctx.Handler.Name} 处理{ctx.Total:n0} 行,区间({set.Start} + {n}, {set.End:HH:mm:ss})";
-            if (ctx.Handler.Mode == JobModes.Alarm)
+            if (end > time) n = (Int32)(end - time).TotalSeconds;
+            var msg = $"{ctx.Handler.Name} 处理{ctx.Total:n0} 行,区间({time} + {n}, {end:HH:mm:ss})";
+            if (ctx.Handler.Mode == JobModes.Time)
                 msg += $",耗时{ctx.Cost:n0}ms";
             else
                 msg += $",速度{ctx.Speed:n0}tps,耗时{ctx.Cost:n0}ms";
+
             XTrace.WriteLine(msg);
         }
     }
Modified +8 -6
diff --git a/AntJob/Providers/HttpJobProvider.cs b/AntJob/Providers/HttpJobProvider.cs
index d960fa5..74058a3 100644
--- a/AntJob/Providers/HttpJobProvider.cs
+++ b/AntJob/Providers/HttpJobProvider.cs
@@ -186,6 +186,8 @@ public class HttpJobProvider : JobProvider
         task.Total = ctx.Total;
         task.Success = ctx.Success;
 
+        if (ctx.NextTime.Year > 2000) task.NextTime = ctx.NextTime.ToUniversalTime();
+
         Report(ctx.Handler.Job, task);
     }
 
@@ -214,11 +216,11 @@ public class HttpJobProvider : JobProvider
                 task.Message = msg;
             }
         }
-        else
+        else if (task.Status <= JobStatus.处理中)
         {
             task.Status = JobStatus.完成;
-            task.Cost = (Int32)Math.Round(ctx.Cost / 1000);
         }
+        task.Cost = (Int32)Math.Round(ctx.Cost / 1000);
         if (task.Message.IsNullOrEmpty()) task.Message = ctx.Remark;
 
         task.Key = ctx.Key;
@@ -234,7 +236,7 @@ public class HttpJobProvider : JobProvider
         }
         catch (Exception ex)
         {
-            XTrace.WriteLine("[{0}]的[{1}]状态报告失败!{2}", job, task.Status, ex.GetTrue().Message);
+            WriteLog("[{0}]的[{1}]状态报告失败!{2}", job, task.Status, ex.GetTrue().Message);
         }
     }
     #endregion
@@ -246,18 +248,18 @@ public class HttpJobProvider : JobProvider
         var ps = Client?.Get<PeerModel[]>("/AntJob/GetPeers");
         if (ps == null || ps.Length == 0) return;
 
-        var old = (Peers ?? new IPeer[0]).ToList();
+        var old = (Peers ?? []).ToList();
         foreach (var item in ps)
         {
             var pr = old.FirstOrDefault(e => e.Instance == item.Instance);
             if (pr == null)
-                XTrace.WriteLine("[{0}]上线!{1}", item.Instance, item.Machine);
+                WriteLog("[{0}]上线!{1}", item.Instance, item.Machine);
             else
                 old.Remove(pr);
         }
         foreach (var item in old)
         {
-            XTrace.WriteLine("[{0}]下线!{1}", item.Instance, item.Machine);
+            WriteLog("[{0}]下线!{1}", item.Instance, item.Machine);
         }
 
         Peers = ps;
Modified +25 -1
diff --git a/AntJob/Providers/IJobProvider.cs b/AntJob/Providers/IJobProvider.cs
index 156ba9d..9b2a006 100644
--- a/AntJob/Providers/IJobProvider.cs
+++ b/AntJob/Providers/IJobProvider.cs
@@ -1,5 +1,6 @@
 using AntJob.Data;
 using NewLife;
+using NewLife.Log;
 
 namespace AntJob.Providers;
 
@@ -19,6 +20,11 @@ public interface IJobProvider
     /// <returns></returns>
     IJob[] GetJobs();
 
+    /// <summary>设置作业。支持控制作业启停、数据时间、步进等参数</summary>
+    /// <param name="job"></param>
+    /// <returns></returns>
+    IJob SetJob(IJob job);
+
     /// <summary>申请任务</summary>
     /// <param name="job">作业</param>
     /// <param name="topic">主题</param>
@@ -44,7 +50,7 @@ public interface IJobProvider
 }
 
 /// <summary>任务提供者基类</summary>
-public abstract class JobProvider : DisposeBase, IJobProvider
+public abstract class JobProvider : DisposeBase, IJobProvider, ITracerFeature, ILogFeature
 {
     /// <summary>调度器</summary>
     public Scheduler Schedule { get; set; }
@@ -59,6 +65,11 @@ public abstract class JobProvider : DisposeBase, IJobProvider
     /// <returns></returns>
     public abstract IJob[] GetJobs();
 
+    /// <summary>设置作业。支持控制作业启停、数据时间、步进等参数</summary>
+    /// <param name="job"></param>
+    /// <returns></returns>
+    public abstract IJob SetJob(IJob job);
+
     /// <summary>申请任务</summary>
     /// <param name="job">作业</param>
     /// <param name="topic">主题</param>
@@ -81,4 +92,17 @@ public abstract class JobProvider : DisposeBase, IJobProvider
     /// <summary>完成任务,每个任务只调用一次</summary>
     /// <param name="ctx">上下文</param>
     public virtual void Finish(JobContext ctx) { }
+
+    #region 日志
+    /// <summary>性能跟踪器</summary>
+    public ITracer Tracer { get; set; }
+
+    /// <summary>日志</summary>
+    public ILog Log { get; set; } = Logger.Null;
+
+    /// <summary>写日志</summary>
+    /// <param name="format"></param>
+    /// <param name="args"></param>
+    public void WriteLog(String format, params Object[] args) => Log?.Info(format, args);
+    #endregion
 }
\ No newline at end of file
Modified +115 -34
diff --git a/AntJob/Providers/NetworkJobProvider.cs b/AntJob/Providers/NetworkJobProvider.cs
index 537aec0..da2d5a0 100644
--- a/AntJob/Providers/NetworkJobProvider.cs
+++ b/AntJob/Providers/NetworkJobProvider.cs
@@ -2,35 +2,19 @@
 using AntJob.Handlers;
 using AntJob.Models;
 using NewLife;
-using NewLife.Log;
 using NewLife.Threading;
 
 namespace AntJob.Providers;
 
 /// <summary>网络任务提供者</summary>
-public class NetworkJobProvider : JobProvider
+public class NetworkJobProvider(AntSetting setting) : JobProvider
 {
     #region 属性
-    /// <summary>调试,打开编码日志</summary>
-    public Boolean Debug { get; set; }
-
-    /// <summary>调度中心地址</summary>
-    public String Server { get; set; }
-
-    /// <summary>应用编号</summary>
-    public String AppId { get; set; }
-
-    /// <summary>应用密钥</summary>
-    public String Secret { get; set; }
-
     /// <summary>客户端</summary>
     public AntClient Ant { get; set; }
 
     /// <summary>邻居伙伴。用于应用判断自身有多少个实例在运行</summary>
     public IPeer[] Peers { get; private set; }
-
-    /// <summary>性能跟踪器</summary>
-    public ITracer Tracer { get; set; }
     #endregion
 
     #region 构造
@@ -40,6 +24,8 @@ public class NetworkJobProvider : JobProvider
     {
         base.Dispose(disposing);
 
+        Stop();
+
         _timer.TryDispose();
         _timer = null;
     }
@@ -49,17 +35,15 @@ public class NetworkJobProvider : JobProvider
     /// <summary>开始</summary>
     public override void Start()
     {
-        var svr = Server;
+        WriteLog("正在连接调度中心:{0}", setting.Server);
 
         // 使用配置中心账号
-        var ant = new AntClient(svr)
+        var ant = new AntClient(setting)
         {
-            UserName = AppId,
-            Password = Secret,
             Tracer = Tracer,
+            Log = Log,
         };
-        if (Debug) ant.EncoderLog = XTrace.Log;
-        ant.Open();
+        ant.Login().Wait();
 
         // 断开前一个连接
         Ant.TryDispose();
@@ -72,6 +56,16 @@ public class NetworkJobProvider : JobProvider
         var list = new List<IJob>();
         foreach (var handler in bs)
         {
+            // 初始化处理器
+            try
+            {
+                handler.Init();
+            }
+            catch (Exception ex)
+            {
+                Log?.Error(ex.Message);
+            }
+
             var job = handler.Job ?? new JobModel();
 
             job.Name = handler.Name;
@@ -89,9 +83,34 @@ public class NetworkJobProvider : JobProvider
                 if (handler is MessageHandler mhandler) job2.Topic = mhandler.Topic;
             }
 
+            // 改为UTC通信
+            if (job.DataTime.Year > 1000)
+                job.DataTime = job.DataTime.ToUniversalTime();
+            if (job.End.Year > 1000)
+                job.End = job.End.ToUniversalTime();
+
             list.Add(job);
         }
-        if (list.Count > 0) Ant.AddJobs(list.ToArray());
+        if (list.Count > 0)
+        {
+            WriteLog("注册作业[{0}]:{1}", list.Count, list.Join(",", e => e.Name));
+
+            var rs = Ant.AddJobs(list.ToArray());
+
+            WriteLog("注册成功[{0}]:{1}", rs?.Length, rs.Join());
+        }
+
+        // 通信完成,改回来本地时间
+        foreach (var handler in bs)
+        {
+            var job = handler.Job;
+            if (job != null)
+            {
+                job.DataTime = job.DataTime.ToLocalTime();
+                if (job.End.Year > 1000)
+                    job.End = job.End.ToLocalTime();
+            }
+        }
 
         // 定时更新邻居
         _timer = new TimerX(DoCheckPeer, null, 1_000, 30_000) { Async = true };
@@ -100,6 +119,8 @@ public class NetworkJobProvider : JobProvider
     /// <summary>停止</summary>
     public override void Stop()
     {
+        Ant?.Logout(nameof(Stop)).Wait(1_000);
+
         // 断开前一个连接
         Ant.TryDispose();
         Ant = null;
@@ -108,6 +129,7 @@ public class NetworkJobProvider : JobProvider
 
     #region 作业消息控制
     private IJob[] _jobs;
+    private IJob[] _baks;
     private DateTime _NextGetJobs;
     /// <summary>获取所有作业名称</summary>
     /// <returns></returns>
@@ -120,17 +142,68 @@ public class NetworkJobProvider : JobProvider
             _NextGetJobs = now.AddSeconds(5);
 
             _jobs = Ant.GetJobs();
+
+            if (_jobs != null)
+            {
+                foreach (var job in _jobs)
+                {
+                    // 通信约定UTC,收到后需转为本地时间
+                    job.DataTime = job.DataTime.ToLocalTime();
+                    if (job.End.Year > 1000)
+                        job.End = job.End.ToLocalTime();
+                }
+
+                // 备份一份,用于比较
+                _baks = _jobs.Select(e => ((ICloneable)e).Clone() as IJob).ToArray();
+            }
         }
 
         return _jobs;
     }
 
+    /// <summary>设置作业。支持控制作业启停、数据时间、步进等参数</summary>
+    /// <param name="job"></param>
+    /// <returns></returns>
+    public override IJob SetJob(IJob job)
+    {
+        var dic = job.ToDictionary();
+        var old = _baks?.FirstOrDefault(e => e.Name == job.Name);
+        old ??= new JobModel();
+
+        var dic2 = old.ToDictionary();
+        foreach (var item in dic2)
+        {
+            if (item.Key == nameof(job.Name)) continue;
+
+            // 未修改的不要传递过去
+            if (dic.TryGetValue(item.Key, out var value) && Equals(value, item.Value))
+                dic.Remove(item.Key);
+        }
+
+        return Ant.SetJob(dic);
+    }
+
     /// <summary>申请任务</summary>
     /// <param name="job">作业</param>
     /// <param name="topic">主题</param>
     /// <param name="count">要申请的任务个数</param>
     /// <returns></returns>
-    public override ITask[] Acquire(IJob job, String topic, Int32 count) => Ant.Acquire(job.Name, topic, count);
+    public override ITask[] Acquire(IJob job, String topic, Int32 count)
+    {
+        var rs = Ant.Acquire(job.Name, topic, count);
+        if (rs != null)
+        {
+            foreach (var task in rs)
+            {
+                // 通信约定UTC,收到后需转为本地时间
+                task.DataTime = task.DataTime.ToLocalTime();
+                if (task.End.Year > 1000)
+                    task.End = task.End.ToLocalTime();
+            }
+        }
+
+        return rs;
+    }
 
     /// <summary>生产消息</summary>
     /// <param name="job">作业</param>
@@ -159,9 +232,6 @@ public class NetworkJobProvider : JobProvider
     #endregion
 
     #region 报告状态
-    //private static readonly String _MachineName = Environment.MachineName;
-    //private static readonly Int32 _ProcessID = Process.GetCurrentProcess().Id;
-
     /// <summary>报告进度,每个任务多次调用</summary>
     /// <param name="ctx">上下文</param>
     public override void Report(JobContext ctx)
@@ -178,6 +248,8 @@ public class NetworkJobProvider : JobProvider
         task.Total = ctx.Total;
         task.Success = ctx.Success;
 
+        if (ctx.NextTime.Year > 2000) task.NextTime = ctx.NextTime.ToUniversalTime();
+
         Report(ctx.Handler.Job, task);
     }
 
@@ -192,6 +264,8 @@ public class NetworkJobProvider : JobProvider
         task.Success = ctx.Success;
         task.Times++;
 
+        if (ctx.NextTime.Year > 2000) task.NextTime = ctx.NextTime.ToUniversalTime();
+
         // 区分正常完成还是错误终止
         if (ctx.Error != null)
         {
@@ -202,15 +276,21 @@ public class NetworkJobProvider : JobProvider
             if (ex != null)
             {
                 var msg = ctx.Error.GetMessage();
-                if (msg.Contains("Exception:")) msg = msg.Substring("Exception:").Trim();
+                var p = msg.IndexOf("Exception:");
+                if (p >= 0) msg = msg.Substring(p + "Exception:".Length).Trim();
                 task.Message = msg;
             }
         }
+        else if (ctx.Status == JobStatus.延迟)
+        {
+            task.Status = JobStatus.延迟;
+        }
         else
         {
             task.Status = JobStatus.完成;
-            task.Cost = (Int32)Math.Round(ctx.Cost / 1000);
         }
+
+        task.Cost = (Int32)Math.Round(ctx.Cost);
         if (task.Message.IsNullOrEmpty()) task.Message = ctx.Remark;
 
         task.Key = ctx.Key;
@@ -226,30 +306,31 @@ public class NetworkJobProvider : JobProvider
         }
         catch (Exception ex)
         {
-            XTrace.WriteLine("[{0}]的[{1}]状态报告失败!{2}", job, task.Status, ex.GetTrue().Message);
+            WriteLog("[{0}]的[{1}]状态报告失败!{2}", job, task.Status, ex.GetTrue().Message);
         }
     }
     #endregion
 
     #region 邻居
     private TimerX _timer;
+
     private void DoCheckPeer(Object state)
     {
         var ps = Ant?.GetPeers();
         if (ps == null || ps.Length == 0) return;
 
-        var old = (Peers ?? new IPeer[0]).ToList();
+        var old = (Peers ?? []).ToList();
         foreach (var item in ps)
         {
             var pr = old.FirstOrDefault(e => e.Instance == item.Instance);
             if (pr == null)
-                XTrace.WriteLine("[{0}]上线!{1}", item.Instance, item.Machine);
+                WriteLog("[{0}]上线!{1}", item.Instance, item.Machine);
             else
                 old.Remove(pr);
         }
         foreach (var item in old)
         {
-            XTrace.WriteLine("[{0}]下线!{1}", item.Instance, item.Machine);
+            WriteLog("[{0}]下线!{1}", item.Instance, item.Machine);
         }
 
         Peers = ps;
Modified +123 -66
diff --git a/AntJob/Scheduler.cs b/AntJob/Scheduler.cs
index 06a69aa..cd42b7b 100644
--- a/AntJob/Scheduler.cs
+++ b/AntJob/Scheduler.cs
@@ -2,13 +2,12 @@
 using AntJob.Handlers;
 using AntJob.Providers;
 using NewLife;
+using NewLife.Caching;
 using NewLife.Log;
 using NewLife.Model;
 using NewLife.Reflection;
-using NewLife.Remoting;
 using NewLife.Threading;
 using Stardust.Registry;
-using static System.Net.WebRequestMethods;
 
 namespace AntJob;
 
@@ -17,7 +16,7 @@ public class Scheduler : DisposeBase
 {
     #region 属性
     /// <summary>处理器集合</summary>
-    public List<Handler> Handlers { get; } = new List<Handler>();
+    public List<Handler> Handlers { get; } = [];
 
     /// <summary>作业提供者</summary>
     public IJobProvider Provider { get; set; }
@@ -25,8 +24,7 @@ public class Scheduler : DisposeBase
     /// <summary>服务提供者</summary>
     public IServiceProvider ServiceProvider { get; set; }
 
-    /// <summary>性能跟踪器</summary>
-    public ITracer Tracer { get; set; }
+    private ICache _cache = MemoryCache.Default;
     #endregion
 
     #region 构造
@@ -59,52 +57,47 @@ public class Scheduler : DisposeBase
 
     #region 核心方法
     /// <summary>加入调度中心,从注册中心获取地址,自动识别RPC/Http</summary>
-    /// <param name="server"></param>
-    /// <param name="appId"></param>
-    /// <param name="secret"></param>
-    /// <param name="debug"></param>
+    /// <param name="set"></param>
     /// <returns></returns>
-    public IJobProvider Join(String server, String appId, String secret, Boolean debug = false)
+    public IJobProvider Join(AntSetting set)
     {
+        var server = set.Server;
+
         var registry = ServiceProvider?.GetService<IRegistry>();
         if (registry != null)
         {
+            var rs = registry.ResolveAsync("AntServer").Result;
             var svrs = registry.ResolveAddressAsync("AntServer").Result;
             if (svrs != null && svrs.Length > 0) server = svrs.Join();
         }
 
         if (server.IsNullOrEmpty()) return null;
+        set.Server = server;
 
         // 根据地址决定用Http还是RPC
-        var servers = server.Split(",");
-        if (servers.Any(e => e.StartsWithIgnoreCase("http://", "https://")))
-        {
-            var http = new HttpJobProvider
-            {
-                Debug = debug,
-                Server = server,
-                AppId = appId,
-                Secret = secret,
-            };
-
-            // 如果有注册中心,则使用注册中心的服务发现
-            if (registry != null)
-            {
-                //http.Client = registry.CreateForService("AntServer") as ApiHttpClient;
-                //http.Client.RoundRobin = false;
-            }
+        //var servers = server.Split(",");
+        //if (servers.Any(e => e.StartsWithIgnoreCase("http://", "https://")))
+        //{
+        //    var http = new HttpJobProvider
+        //    {
+        //        Debug = debug,
+        //        Server = server,
+        //        AppId = appId,
+        //        Secret = secret,
+        //    };
+
+        //    // 如果有注册中心,则使用注册中心的服务发现
+        //    if (registry != null)
+        //    {
+        //        //http.Client = registry.CreateForService("AntServer") as ApiHttpClient;
+        //        //http.Client.RoundRobin = false;
+        //    }
 
-            Provider = http;
-        }
-        else
+        //    Provider = http;
+        //}
+        //else
         {
-            var rpc = new NetworkJobProvider
-            {
-                Debug = debug,
-                Server = server,
-                AppId = appId,
-                Secret = secret,
-            };
+            var rpc = new NetworkJobProvider(set);
 
             Provider = rpc;
         }
@@ -115,11 +108,44 @@ public class Scheduler : DisposeBase
     /// <summary>开始</summary>
     public void Start()
     {
+        OnStart();
+    }
+
+    /// <summary>异步开始。使用定时器尝试连接服务端</summary>
+    public void StartAsync()
+    {
+        _timerStart = new TimerX(CheckStart, null, 100, 15000, "Job") { Async = true };
+    }
+
+    private Boolean _inited;
+    private TimerX _timerStart;
+    private void CheckStart(Object state)
+    {
+        if (!_inited)
+        {
+            try
+            {
+                OnStart();
+
+                _inited = true;
+            }
+            catch (Exception ex)
+            {
+                Log?.Error(ex.Message);
+            }
+        }
+
+        if (_inited) _timerStart.TryDispose();
+    }
+
+    private void OnStart()
+    {
         // 检查本地添加的处理器
         var hs = Handlers;
         if (hs.Count == 0) throw new ArgumentNullException(nameof(Handlers), "没有可用处理器");
 
         // 埋点
+        Tracer ??= ServiceProvider?.GetService<ITracer>();
         using var span = Tracer?.NewSpan("job:SchedulerStart");
 
         // 启动作业提供者
@@ -127,32 +153,17 @@ public class Scheduler : DisposeBase
         prv ??= Provider = new FileJobProvider();
         prv.Schedule ??= this;
 
-        //if (prv is NetworkJobProvider network)
-        //{
-        //    network.Tracer ??= Tracer;
-
-        //    // 从注册中心获取服务端地址,优先于本地配置文件
-        //    if (network.Server.IsNullOrEmpty() || network.Server.EqualIgnoreCase(AntSetting.Current.Server))
-        //    {
-        //        // 从注册中心获取包
-        //        var registry = ServiceProvider?.GetService<IRegistry>();
-        //        if (registry != null)
-        //        {
-        //            var svrs = registry.ResolveAddressAsync("AntServer").Result;
-        //            if (svrs != null && svrs.Length > 0) network.Server = svrs.Join();
-        //        }
-        //    }
-        //}
+        if (prv is ITracerFeature tf) tf.Tracer = Tracer;
+        if (prv is ILogFeature lf) lf.Log = Log;
 
         prv.Start();
 
         // 获取本应用在调度中心管理的所有作业
-        var jobs = prv.GetJobs();
-        if (jobs == null || jobs.Length == 0) throw new Exception("调度中心没有可用作业");
+        var jobs = prv.GetJobs() ?? [];
+        //if (jobs == null || jobs.Length == 0) throw new Exception("调度中心没有可用作业");
 
         // 输出日志
-        var msg = $"启动任务调度引擎[{prv}],作业[{hs.Count}]项,定时{Period}秒";
-        XTrace.WriteLine(msg);
+        WriteLog($"启动任务调度引擎[{prv}],作业[{hs.Count}]项,定时{Period}秒");
 
         // 设置日志
         foreach (var handler in hs)
@@ -162,11 +173,22 @@ public class Scheduler : DisposeBase
 
             // 查找作业参数,分配给处理器
             var job = jobs.FirstOrDefault(e => e.Name == handler.Name);
+            if (job == null || !job.Enable) continue;
+
             if (job != null && job.Mode == 0) job.Mode = handler.Mode;
             handler.Job = job;
 
-            handler.Log = XTrace.Log;
-            handler.Start();
+            handler.Tracer = Tracer;
+            handler.Log = Log;
+
+            try
+            {
+                handler.Start();
+            }
+            catch (Exception ex)
+            {
+                Log?.Error("作业[{0}]启动失败!{1}", handler.GetType().FullName, ex.Message);
+            }
         }
 
         // 定时执行
@@ -185,7 +207,14 @@ public class Scheduler : DisposeBase
 
         foreach (var handler in Handlers)
         {
-            handler.Stop("SchedulerStop");
+            try
+            {
+                handler.Stop("SchedulerStop");
+            }
+            catch (Exception ex)
+            {
+                Log?.Error("作业[{0}]停止失败!{1}", handler.GetType().FullName, ex.Message);
+            }
         }
     }
 
@@ -224,7 +253,17 @@ public class Scheduler : DisposeBase
             // 更新作业参数,并启动处理器
             handler.Job = job;
             if (job.Mode == 0) job.Mode = handler.Mode;
-            if (!handler.Active) handler.Start();
+            if (!handler.Active)
+            {
+                try
+                {
+                    handler.Start();
+                }
+                catch (Exception ex)
+                {
+                    Log?.Error("作业[{0}]启动失败!{1}", handler.GetType().FullName, ex.Message);
+                }
+            }
 
             // 如果正在处理任务数没达到最大并行度,则继续安排任务
             var max = job.MaxTask;
@@ -267,9 +306,12 @@ public class Scheduler : DisposeBase
             var handler = handlers.FirstOrDefault(e => e.Name == job.Name);
             if (handler == null && job.Enable && !job.ClassName.IsNullOrEmpty())
             {
+                // 遇到废弃作业时,避免反复输出日志
+                if (!_cache.Add($"job:NewHandler:{job.Name}", 1, 3600)) return;
+
                 using var span = Tracer?.NewSpan($"job:NewHandler", job);
 
-                XTrace.WriteLine("发现未知作业[{0}]@[{1}]", job.Name, job.ClassName);
+                WriteLog("发现未知作业[{0}]@[{1}]", job.Name, job.ClassName);
                 try
                 {
                     // 实例化一个处理器
@@ -279,15 +321,16 @@ public class Scheduler : DisposeBase
                         handler = type.CreateInstance() as Handler;
                         if (handler != null)
                         {
-                            XTrace.WriteLine("添加新作业[{0}]@[{1}]", job.Name, job.ClassName);
+                            WriteLog("添加新作业[{0}]@[{1}]", job.Name, job.ClassName);
 
                             handler.Name = job.Name;
                             handler.Schedule = this;
                             handler.Provider = provider;
 
-                            if (handler is MessageHandler messageHandler && !job.Topic.IsNullOrEmpty()) messageHandler.Topic = job.Topic;
+                            if (handler is MessageHandler messageHandler && !job.Topic.IsNullOrEmpty())
+                                messageHandler.Topic = job.Topic;
 
-                            handler.Log = XTrace.Log;
+                            handler.Log = Log;
                             handler.Tracer = Tracer;
                             handler.Start();
 
@@ -298,7 +341,8 @@ public class Scheduler : DisposeBase
                 catch (Exception ex)
                 {
                     span?.SetError(ex, null);
-                    XTrace.WriteException(ex);
+                    //XTrace.WriteException(ex);
+                    Log?.Error("作业[{0}]启动失败!{1}", handler?.GetType().FullName, ex.Message);
                 }
             }
         }
@@ -324,4 +368,17 @@ public class Scheduler : DisposeBase
         if (rs) TimerX.Current.SetNext(-1);
     }
     #endregion
+
+    #region 日志
+    /// <summary>性能跟踪器</summary>
+    public ITracer Tracer { get; set; }
+
+    /// <summary>日志</summary>
+    public ILog Log { get; set; } = Logger.Null;
+
+    /// <summary>写日志</summary>
+    /// <param name="format"></param>
+    /// <param name="args"></param>
+    public void WriteLog(String format, params Object[] args) => Log?.Info(format, args);
+    #endregion
 }
\ No newline at end of file
Modified +4 -4
diff --git a/AntTest/AntTest.csproj b/AntTest/AntTest.csproj
index 42c381f..1535955 100644
--- a/AntTest/AntTest.csproj
+++ b/AntTest/AntTest.csproj
@@ -9,10 +9,10 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
-    <PackageReference Include="NewLife.UnitTest" Version="1.0.2023.1204" />
-    <PackageReference Include="xunit" Version="2.7.0" />
-    <PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
+    <PackageReference Include="NewLife.UnitTest" Version="1.0.2024.1006" />
+    <PackageReference Include="xunit" Version="2.9.2" />
+    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
     </PackageReference>
Modified +6 -6
diff --git a/AntTest/SqlHandlerTests.cs b/AntTest/SqlHandlerTests.cs
index 05f3dfa..0161180 100644
--- a/AntTest/SqlHandlerTests.cs
+++ b/AntTest/SqlHandlerTests.cs
@@ -1,9 +1,7 @@
-using System;
-using System.Linq;
+using System.Linq;
 using AntJob;
 using AntJob.Data;
 using AntJob.Extensions;
-using NewLife.Reflection;
 using XCode.DataAccessLayer;
 using XCode.Membership;
 using Xunit;
@@ -44,11 +42,13 @@ public class SqlHandlerTests
             Task = task,
         };
 
-        var method = handler.GetType().GetMethodEx("OnProcess", typeof(JobContext));
-        method.Invoke(handler, new Object[] { ctx });
+        //var method = handler.GetType().GetMethodEx("OnProcess", typeof(JobContext));
+        //method.Invoke(handler, new Object[] { ctx });
+        var rs = handler.Execute(ctx);
 
         Assert.Equal(4, ctx.Total);
         //Assert.Equal(4, ctx.Success);
-        Assert.True(ctx.Success > 0);
+        Assert.Equal(8, rs);
+        //Assert.True(ctx.Success > 0);
     }
 }
Modified +8 -8
diff --git a/AntTest/SqlSectionTests.cs b/AntTest/SqlSectionTests.cs
index 6104fa0..9fbb505 100644
--- a/AntTest/SqlSectionTests.cs
+++ b/AntTest/SqlSectionTests.cs
@@ -9,7 +9,7 @@ namespace AntTest
         public void ParseQuery()
         {
             var tt = @"/*use his*/
-select * from t1 where time between '{Start}' and '{End}'
+select * from t1 where time between '{dt}' and '{End}'
 ";
 
             var section = new SqlSection();
@@ -17,7 +17,7 @@ select * from t1 where time between '{Start}' and '{End}'
 
             Assert.Equal("his", section.ConnName);
             Assert.Equal(SqlActions.Query, section.Action);
-            Assert.Equal("select * from t1 where time between '{Start}' and '{End}'", section.Sql);
+            Assert.Equal("select * from t1 where time between '{dt}' and '{End}'", section.Sql);
         }
 
         [Fact]
@@ -39,7 +39,7 @@ insert into t1 (c1, c2) values(v1, v2);
         public void ParseDelete()
         {
             var tt = @"/*use his*/
-delete from t2 where time between '{Start}' and '{End}';
+delete from t2 where time between '{dt}' and '{End}';
 ";
 
             var section = new SqlSection();
@@ -47,7 +47,7 @@ delete from t2 where time between '{Start}' and '{End}';
 
             Assert.Equal("his", section.ConnName);
             Assert.Equal(SqlActions.Execute, section.Action);
-            Assert.Equal("delete from t2 where time between '{Start}' and '{End}'", section.Sql);
+            Assert.Equal("delete from t2 where time between '{dt}' and '{End}'", section.Sql);
         }
 
         [Fact]
@@ -85,10 +85,10 @@ insert t2;
         public void ParseAllSqls()
         {
             var tt = @"/*use his*/
-select * from t1 where time between '{Start}' and '{End}'
+select * from t1 where time between '{dt}' and '{End}'
 
 /*use his_bak*/
-delete from t2 where time between '{Start}' and '{End}';
+delete from t2 where time between '{dt}' and '{End}';
 
 /*use his_bak*/
 insert t2;
@@ -100,11 +100,11 @@ insert t2;
 
             Assert.Equal("his", cs[0].ConnName);
             Assert.Equal(SqlActions.Query, cs[0].Action);
-            Assert.Equal("select * from t1 where time between '{Start}' and '{End}'", cs[0].Sql);
+            Assert.Equal("select * from t1 where time between '{dt}' and '{End}'", cs[0].Sql);
 
             Assert.Equal("his_bak", cs[1].ConnName);
             Assert.Equal(SqlActions.Execute, cs[1].Action);
-            Assert.Equal("delete from t2 where time between '{Start}' and '{End}'", cs[1].Sql);
+            Assert.Equal("delete from t2 where time between '{dt}' and '{End}'", cs[1].Sql);
 
             Assert.Equal("his_bak", cs[2].ConnName);
             Assert.Equal(SqlActions.Insert, cs[2].Action);
Modified +70 -71
diff --git a/AntTest/TemplateHelperTests.cs b/AntTest/TemplateHelperTests.cs
index 3c40344..6d327c1 100644
--- a/AntTest/TemplateHelperTests.cs
+++ b/AntTest/TemplateHelperTests.cs
@@ -2,95 +2,94 @@
 using AntJob.Data;
 using Xunit;
 
-namespace AntTest
+namespace AntTest;
+
+public class TemplateHelperTests
 {
-    public class TemplateHelperTests
+    [Fact]
+    public void BuildTest()
     {
-        [Fact]
-        public void BuildTest()
-        {
-            var tt = @"/*use His*/
-insert into t1(xxx) select * from t2 where time between {Start} and {End}";
-            var start = DateTime.Now;
-            var end = start.AddSeconds(30);
+        var tt = @"/*use His*/
+insert into t1(xxx) select * from t2 where time between {dt} and {End}";
+        var start = DateTime.Now;
+        var end = start.AddSeconds(30);
 
-            var str = TemplateHelper.Build(tt, start, end);
-            Assert.NotNull(str);
-            Assert.NotEmpty(str);
-            Assert.DoesNotContain("{Start}", str);
-            Assert.DoesNotContain("{End}", str);
+        var str = TemplateHelper.Build(tt, start, end);
+        Assert.NotNull(str);
+        Assert.NotEmpty(str);
+        Assert.DoesNotContain("{dt}", str);
+        Assert.DoesNotContain("{End}", str);
 
-            var rs = tt.Replace("{Start}", start.ToFullString()).Replace("{End}", end.ToFullString());
-            Assert.Equal(rs, str);
-        }
+        var rs = tt.Replace("{dt}", start.ToFullString()).Replace("{End}", end.ToFullString());
+        Assert.Equal(rs, str);
+    }
 
-        [Fact]
-        public void BuildTest2()
-        {
-            var tt = @"/*use His*/
-insert into t1(xxx) select * from t2 where time between {Start:yyMMdd} and {End:HH:mm:ss}";
-            var start = DateTime.Now;
-            var end = start.AddSeconds(30);
+    [Fact]
+    public void BuildTest2()
+    {
+        var tt = @"/*use His*/
+insert into t1(xxx) select * from t2 where time between {dt:yyMMdd} and {End:HH:mm:ss}";
+        var start = DateTime.Now;
+        var end = start.AddSeconds(30);
 
-            var str = TemplateHelper.Build(tt, start, end);
-            Assert.NotNull(str);
-            Assert.NotEmpty(str);
-            Assert.DoesNotContain("{Start:yyMMdd}", str);
-            Assert.DoesNotContain("{End:HH:mm:ss}", str);
+        var str = TemplateHelper.Build(tt, start, end);
+        Assert.NotNull(str);
+        Assert.NotEmpty(str);
+        Assert.DoesNotContain("{dt:yyMMdd}", str);
+        Assert.DoesNotContain("{End:HH:mm:ss}", str);
 
-            var rs = tt.Replace("{Start:yyMMdd}", start.ToString("yyMMdd")).Replace("{End:HH:mm:ss}", end.ToString("HH:mm:ss"));
-            Assert.Equal(rs, str);
-        }
+        var rs = tt.Replace("{dt:yyMMdd}", start.ToString("yyMMdd")).Replace("{End:HH:mm:ss}", end.ToString("HH:mm:ss"));
+        Assert.Equal(rs, str);
+    }
 
-        [Fact]
-        public void BuildTest3()
-        {
-            var tt = @"/*use His*/
-insert into t1(xxx) select * from t2 where time between {Start:yyMMdd} and {End:HH:mm:ss} time2 between {Start:yyMMdd} and {End:yyMMdd}";
-            var start = DateTime.Now;
-            var end = start.AddSeconds(30);
+    [Fact]
+    public void BuildTest3()
+    {
+        var tt = @"/*use His*/
+insert into t1(xxx) select * from t2 where time between {dt:yyMMdd} and {End:HH:mm:ss} time2 between {dt:yyMMdd} and {End:yyMMdd}";
+        var start = DateTime.Now;
+        var end = start.AddSeconds(30);
 
-            var str = TemplateHelper.Build(tt, start, end);
-            Assert.NotNull(str);
-            Assert.NotEmpty(str);
-            Assert.DoesNotContain("{Start:yyMMdd}", str);
-            Assert.DoesNotContain("{End:HH:mm:ss}", str);
-            Assert.DoesNotContain("{End:yyMMdd}", str);
+        var str = TemplateHelper.Build(tt, start, end);
+        Assert.NotNull(str);
+        Assert.NotEmpty(str);
+        Assert.DoesNotContain("{dt:yyMMdd}", str);
+        Assert.DoesNotContain("{End:HH:mm:ss}", str);
+        Assert.DoesNotContain("{End:yyMMdd}", str);
 
-            var rs = tt
-                .Replace("{Start:yyMMdd}", start.ToString("yyMMdd"))
-                .Replace("{End:HH:mm:ss}", end.ToString("HH:mm:ss"))
-                .Replace("{End:yyMMdd}", end.ToString("yyMMdd"))
-               ;
-            Assert.Equal(rs, str);
-        }
+        var rs = tt
+            .Replace("{dt:yyMMdd}", start.ToString("yyMMdd"))
+            .Replace("{End:HH:mm:ss}", end.ToString("HH:mm:ss"))
+            .Replace("{End:yyMMdd}", end.ToString("yyMMdd"))
+           ;
+        Assert.Equal(rs, str);
+    }
 
-        [Fact]
-        public void BuildTest4()
-        {
-            var tt = @"/*use his*/
-select * from t1 where time between '{Start}' and '{End}'
+    [Fact]
+    public void BuildTest4()
+    {
+        var tt = @"/*use his*/
+select * from t1 where time between '{dt}' and '{End}'
 
 /*use hist_bak*/
-delete from t2 where time between '{Start}' and '{End}';
+delete from t2 where time between '{dt}' and '{End}';
 
 /*use hist_bak*/
 /*batchinsert t2*/
 ";
-            var start = DateTime.Now;
-            var end = start.AddSeconds(30);
+        var start = DateTime.Now;
+        var end = start.AddSeconds(30);
 
-            var str = TemplateHelper.Build(tt, start, end);
-            Assert.NotNull(str);
-            Assert.NotEmpty(str);
-            Assert.DoesNotContain("{Start}", str);
-            Assert.DoesNotContain("{End}", str);
+        var str = TemplateHelper.Build(tt, start, end);
+        Assert.NotNull(str);
+        Assert.NotEmpty(str);
+        Assert.DoesNotContain("{dt}", str);
+        Assert.DoesNotContain("{End}", str);
 
-            var rs = tt
-                .Replace("{Start}", start.ToFullString())
-                .Replace("{End}", end.ToFullString())
-               ;
-            Assert.Equal(rs, str);
-        }
+        var rs = tt
+            .Replace("{dt}", start.ToFullString())
+            .Replace("{End}", end.ToFullString())
+           ;
+        Assert.Equal(rs, str);
     }
 }
\ No newline at end of file
Added +110 -0
diff --git a/AntTest/TimeExpressionTests.cs b/AntTest/TimeExpressionTests.cs
new file mode 100644
index 0000000..c233a28
--- /dev/null
+++ b/AntTest/TimeExpressionTests.cs
@@ -0,0 +1,110 @@
+using System;
+using AntJob.Data;
+using Xunit;
+
+namespace AntTest;
+
+public class TimeExpressionTests
+{
+    [Theory]
+    [InlineData("+1y", "y", 1)]
+    [InlineData("-1y", "y", -1)]
+    [InlineData("+5M", "M", 5)]
+    [InlineData("-5M", "M", -5)]
+    [InlineData("+5d", "d", 5)]
+    [InlineData("-5d", "d", -5)]
+    [InlineData("-0d", "d", 0)]
+    [InlineData("+5H", "H", 5)]
+    [InlineData("-5H", "H", -5)]
+    [InlineData("+5m", "m", 5)]
+    [InlineData("-5m", "m", -5)]
+    [InlineData("+5w", "w", 5)]
+    [InlineData("-5w", "w", -5)]
+    public void ParseItem(String str, String level, Int32 value)
+    {
+        var item = new TimeExpressionItem();
+        var rs = item.Parse(str);
+        Assert.True(rs);
+        Assert.Equal(level, item.Level);
+        Assert.Equal(value, item.Value);
+
+        var time = DateTime.Now;
+        var time2 = item.Execute(time);
+
+        if (value > 0)
+            Assert.True(time2 > time);
+        else if (value < 0)
+            Assert.True(time2 < time);
+    }
+
+    [Fact]
+    public void TestDefault()
+    {
+        var exp = new TimeExpression("${dt}");
+        Assert.Equal("dt", exp.Expression);
+        Assert.Equal("dt", exp.VarName);
+        Assert.Null(exp.Format);
+        Assert.Single(exp.Items);
+
+        var time = DateTime.Now;
+        var time2 = exp.Execute(time);
+        Assert.Equal(time.Date, time2);
+
+        var rs = exp.Build(time);
+        Assert.Equal(time.ToString("yyyyMMdd"), rs);
+    }
+
+    [Fact]
+    public void Test2()
+    {
+        var exp = new TimeExpression("${dt+2d}");
+        Assert.Equal("dt+2d", exp.Expression);
+        Assert.Equal("dt", exp.VarName);
+        Assert.Null(exp.Format);
+        Assert.Single(exp.Items);
+
+        var time = DateTime.Now;
+        var time2 = exp.Execute(time);
+        var time3 = time.Date.AddDays(2);
+        Assert.Equal(time3, time2);
+
+        var rs = exp.Build(time);
+        Assert.Equal(time3.ToString("yyyyMMdd"), rs);
+    }
+
+    [Fact]
+    public void Test3()
+    {
+        var exp = new TimeExpression("${dt-3H:yyMMddHH}");
+        Assert.Equal("dt-3H:yyMMddHH", exp.Expression);
+        Assert.Equal("dt", exp.VarName);
+        Assert.Equal("yyMMddHH", exp.Format);
+        Assert.Single(exp.Items);
+
+        var time = DateTime.Now;
+        var time2 = exp.Execute(time);
+        var time3 = time.Date.AddHours(time.Hour - 3);
+        Assert.Equal(time3, time2);
+
+        var rs = exp.Build(time);
+        Assert.Equal(time3.ToString("yyMMddHH"), rs);
+    }
+
+    [Fact]
+    public void Test4()
+    {
+        var exp = new TimeExpression("${dt+1M+4d:yy-MM-dd}");
+        Assert.Equal("dt+1M+4d:yy-MM-dd", exp.Expression);
+        Assert.Equal("dt", exp.VarName);
+        Assert.Equal("yy-MM-dd", exp.Format);
+        Assert.Equal(2, exp.Items.Count);
+
+        var time = DateTime.Now;
+        var time2 = exp.Execute(time);
+        var time3 = time.Date.AddDays(1 - time.Day).AddMonths(1).AddDays(4);
+        Assert.Equal(time3, time2);
+
+        var rs = exp.Build(time);
+        Assert.Equal(time3.ToString("yy-MM-dd"), rs);
+    }
+}
Modified +14 -4
diff --git a/Readme.MD b/Readme.MD
index dc3693c..fbf8acc 100644
--- a/Readme.MD
+++ b/Readme.MD
@@ -20,6 +20,15 @@
 使用教程:[https://newlifex.com/blood/antjob](https://newlifex.com/blood/antjob)  
 体验地址:[http://ant.newlifex.com](http://ant.newlifex.com/)  
 
+# v4架构升级
+v4版本是对v3版本的重构,主要是为了解决v3版本的一些问题,以及提供更多的功能。  
+v4版本亮点:
+1. []新增Http接入,由AntWeb提供调度服务,无需部署AntServer,满足轻量级项目需要。(进行中,等NewLife.Remoting提供WebsocketClient)
+2. [x]增强定时调度,支持指定Cron表达式,逐步替代Start+Step的恒定间隔定时调度
+3. []提前生成任务,提前下发给执行器,到时间后马上执行,提高任务执行时间精度。(待重新评估)
+4. []支持任务主动延迟,任务在执行中发现数据条件未满足时,可以向调度中心请求延迟一段时间后再执行,增加执行次数但不增加错误次数
+5. []扩充调度模式,常态化部署AntAgent,正式把Sql调度和C#调度加入主线,将来增加数据抽取和数据推送等多种调度模式
+
 # 功能特点
 AntJob的核心是**蚂蚁算法**:**把任意大数据拆分成为小块,采用蚂蚁搬家策略计算每一块!**  
 (蚂蚁搬家,一个馒头掉在地上,众多小蚂蚁会把馒头掰成小块小块往家里般!)  
@@ -28,7 +37,7 @@ AntJob的核心是**蚂蚁算法**:**把任意大数据拆分成为小块,
 
 2016年在中通快递某产品项目中使用该算法进行大数据实时计算,成功挑战每日1200万的订单。并进一步发展衍生成为重量级实时计算平台,集分布式计算、集群调度、配置中心、负载均衡、故障转移、跨机房冗余、作业监控告警、百亿级数据清洗、超大Redis缓存(>2T)于一身,于2019年达到每年万亿级计算量(2019年双十一日订单量破亿)。  
 
-AntJob是开源简化版,仅提供分布式计算和集中调度能力,支持百亿级调度(需要改造)。  
+AntJob是开源简化版,仅提供分布式计算和集中调度能力,支持百亿级调度。  
 
 AntJob主要功能点:
 1. 作业处理器。每一个最小业务模块实现一个处理器类,用于处理这一类作业。例如同步数据表时,每张表写一个处理器类,并在调度中心注册一个作业,调度中心按照作业时间切片得到任务,然后把任务(主要包含时间区间)分派给各个计算节点上的处理器类执行。又如,每天汇总计算是一个作业,而每月汇总计算又是另一个作业;
@@ -57,6 +66,7 @@ AntJob主要功能点:
 9. 支持数据库同步到HTTP;
 10. 支持RocketMQ/Kafka/MQTT到数据库的反向同步;
 
+
 # 定时调度
 以下源码位于 [https://github.com/NewLifeX/AntJob/tree/master/Samples/HisAgent](https://github.com/NewLifeX/AntJob/tree/master/Samples/HisAgent) 
 
@@ -114,14 +124,14 @@ namespace HisAgent
         {
             // 今天零点开始,每10秒一次
             var job = Job;
-            job.Start = DateTime.Today;
+            job.Time = DateTime.Today;
             job.Step = 10;
         }
 
         protected override Int32 Execute(JobContext ctx)
         {
             // 当前任务时间
-            var time = ctx.Task.Start;
+            var time = ctx.Task.Time;
             WriteLog("新生命蚂蚁调度系统!当前任务时间:{0}", time);
 
             // 成功处理数据量
@@ -194,7 +204,7 @@ public class AntSetting : Config<AntSetting>
 ![image.png](https://cdn.nlark.com/yuque/0/2020/png/1144030/1586439584880-ea4aa91c-460d-4893-b849-8ac8b0995311.png#align=left&display=inline&height=340&name=image.png&originHeight=680&originWidth=1075&size=127959&status=done&style=none&width=537.5)  
 AppID默认取本应用名,Secret由调度中心生成并下发。  
 调度中心默认打开自动注册AutoRegistry,任意应用登录时自动注册,省去人工配置应用账号的麻烦。  
-企业内部正式场景使用时,为安全期间,建议关闭自动注册。  
+企业内部正式场景使用时,为安全起见,建议关闭自动注册。  
 
 再来看看前面跑起来的日志  
 ```sql
Modified +32 -35
diff --git a/Samples/HisAgent/BuildPatient.cs b/Samples/HisAgent/BuildPatient.cs
index 1961bf8..111aa72 100644
--- a/Samples/HisAgent/BuildPatient.cs
+++ b/Samples/HisAgent/BuildPatient.cs
@@ -6,47 +6,44 @@ using HisData;
 using NewLife.Security;
 using XCode;
 
-namespace HisAgent
+namespace HisAgent;
+
+[DisplayName("生产病人")]
+[Description("定时生成一批随机病人")]
+internal class BuildPatient : Handler
 {
-    [DisplayName("生产病人")]
-    [Description("定时生成一批随机病人")]
-    internal class BuildPatient : Handler
+    public BuildPatient()
     {
-        public BuildPatient()
-        {
-            var job = Job;
-            job.Start = DateTime.Today;
-            job.Step = 15;
-        }
+        Job.Cron = "5 1/3 * * * ?";
+    }
 
-        protected override Int32 Execute(JobContext ctx)
-        {
-            // 随机造几个病人
-            var count = Rand.Next(1, 9);
+    public override Int32 Execute(JobContext ctx)
+    {
+        // 随机造几个病人
+        var count = Rand.Next(1, 9);
 
-            var list = new List<ZYBH0>();
-            for (var i = 0; i < count; i++)
+        var list = new List<ZYBH0>();
+        for (var i = 0; i < count; i++)
+        {
+            var time = DateTime.Now.AddSeconds(Rand.Next(-30 * 24 * 3600, 0));
+            var time2 = time.AddSeconds(Rand.Next(3600, 10 * 24 * 3600));
+            var pi = new ZYBH0
             {
-                var time = DateTime.Now.AddSeconds(Rand.Next(-30 * 24 * 3600, 0));
-                var time2 = time.AddSeconds(Rand.Next(3600, 10 * 24 * 3600));
-                var pi = new ZYBH0
-                {
-                    Bhid = Rand.Next(999999),
-                    XM = Rand.NextString(8),
-                    Ryrq = time,
-                    Cyrq = time2,
-                    Sfzh = Rand.NextString(18),
-                    FB = Rand.NextString(6),
-                    State = Rand.Next(8),
-                    Flag = Rand.Next(2),
-                };
+                Bhid = Rand.Next(999999),
+                XM = Rand.NextString(8),
+                Ryrq = time,
+                Cyrq = time2,
+                Sfzh = Rand.NextString(18),
+                FB = Rand.NextString(6),
+                State = Rand.Next(8),
+                Flag = Rand.Next(2),
+            };
 
-                list.Add(pi);
-            }
-            list.Insert(true);
-
-            // 成功处理数据量
-            return count;
+            list.Add(pi);
         }
+        list.Insert(true);
+
+        // 成功处理数据量
+        return count;
     }
 }
\ No newline at end of file
Modified +33 -33
diff --git a/Samples/HisAgent/BuildWill.cs b/Samples/HisAgent/BuildWill.cs
index 8f1a145..d0bd870 100644
--- a/Samples/HisAgent/BuildWill.cs
+++ b/Samples/HisAgent/BuildWill.cs
@@ -6,48 +6,48 @@ using HisData;
 using NewLife.Security;
 using XCode;
 
-namespace HisAgent
+namespace HisAgent;
+
+[DisplayName("生产遗嘱")]
+[Description("根据病人生成其对应的遗嘱")]
+class BuildWill : DataHandler
 {
-    [DisplayName("生产遗嘱")]
-    [Description("根据病人生成其对应的遗嘱")]
-    class BuildWill : DataHandler
+    public BuildWill()
     {
-        public BuildWill()
-        {
-            var job = Job;
-            job.Start = DateTime.Today;
-            job.Step = 30;
-        }
+        var job = Job;
+        job.DataTime = DateTime.Today;
+        job.Step = 30;
+        job.BatchSize = 1000;
+    }
 
-        public override Boolean Start()
-        {
-            // 指定要抽取数据的实体类以及时间字段
-            Factory = ZYBH0.Meta.Factory;
-            Field = ZYBH0._.CreateTime;
+    public override Boolean Start()
+    {
+        // 指定要抽取数据的实体类以及时间字段
+        Factory = ZYBH0.Meta.Factory;
+        Field = ZYBH0._.CreateTime;
 
-            return base.Start();
-        }
+        return base.Start();
+    }
 
-        protected override Boolean ProcessItem(JobContext ctx, IEntity entity)
-        {
-            var pi = entity as ZYBH0;
+    public override Boolean ProcessItem(JobContext ctx, IEntity entity)
+    {
+        var pi = entity as ZYBH0;
 
-            // 创建医嘱信息
-            var will = new ZYBHYZ0
-            {
-                Bhid = pi.Bhid,
-                Mgroupid = Rand.Next(9999),
+        // 创建医嘱信息
+        var will = new ZYBHYZ0
+        {
+            Bhid = pi.Bhid,
+            Mgroupid = Rand.Next(9999),
 
-                Kyzrq = pi.Ryrq.AddHours(1),
-                Tyzrq = pi.Cyrq.AddHours(-3),
-                Kyzys = Rand.NextString(8),
+            Kyzrq = pi.Ryrq.AddHours(1),
+            Tyzrq = pi.Cyrq.AddHours(-3),
+            Kyzys = Rand.NextString(8),
 
-                State = pi.State,
-            };
+            State = pi.State,
+        };
 
-            will.Insert();
+        will.Insert();
 
-            return true;
-        }
+        return true;
     }
 }
\ No newline at end of file
Modified +23 -19
diff --git a/Samples/HisAgent/HelloJob.cs b/Samples/HisAgent/HelloJob.cs
index 14f2b82..2180d03 100644
--- a/Samples/HisAgent/HelloJob.cs
+++ b/Samples/HisAgent/HelloJob.cs
@@ -1,29 +1,33 @@
 using System;
 using System.ComponentModel;
 using AntJob;
+using NewLife;
+using NewLife.Security;
 
-namespace HisAgent
+namespace HisAgent;
+
+[DisplayName("定时欢迎")]
+[Description("简单的定时任务")]
+internal class HelloJob : Handler
 {
-    [DisplayName("定时欢迎")]
-    [Description("简单的定时任务")]
-    internal class HelloJob : Handler
+    public HelloJob()
     {
-        public HelloJob()
-        {
-            // 今天零点开始,每10秒一次
-            var job = Job;
-            job.Start = DateTime.Today;
-            job.Step = 10;
-        }
+        Job.Cron = "7/30 * * * * ?";
+    }
+
+    public override Int32 Execute(JobContext ctx)
+    {
+        using var span = Tracer?.NewSpan("HelloJob", ctx.Task.DataTime);
+
+        // 当前任务时间
+        var time = ctx.Task.DataTime;
+        WriteLog("新生命蚂蚁调度系统!当前任务时间:{0}", time);
+        if (!ctx.Task.Data.IsNullOrEmpty()) WriteLog("数据:{0}", ctx.Task.Data);
 
-        protected override Int32 Execute(JobContext ctx)
-        {
-            // 当前任务时间
-            var time = ctx.Task.Start;
-            WriteLog("新生命蚂蚁调度系统!当前任务时间:{0}", time);
+        //// 一定几率抛出异常
+        //if (Rand.Next(2) == 0) throw new Exception("Error");
 
-            // 成功处理数据量
-            return 1;
-        }
+        // 成功处理数据量
+        return 1;
     }
 }
\ No newline at end of file
Modified +1 -5
diff --git a/Samples/HisAgent/HisAgent.csproj b/Samples/HisAgent/HisAgent.csproj
index b146300..da4a4e6 100644
--- a/Samples/HisAgent/HisAgent.csproj
+++ b/Samples/HisAgent/HisAgent.csproj
@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk.Web">
+<Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
     <OutputType>Exe</OutputType>
@@ -10,10 +10,6 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="NewLife.Stardust.Extensions" Version="2.9.2024.402" />
-  </ItemGroup>
-
-  <ItemGroup>
     <ProjectReference Include="..\..\AntJob.Extensions\AntJob.Extensions.csproj" />
     <ProjectReference Include="..\..\AntJob\AntJob.csproj" />
     <ProjectReference Include="..\HisData\HisData.csproj" />
Modified +13 -17
diff --git a/Samples/HisAgent/JobHost.cs b/Samples/HisAgent/JobHost.cs
index fc5724b..c723e59 100644
--- a/Samples/HisAgent/JobHost.cs
+++ b/Samples/HisAgent/JobHost.cs
@@ -2,9 +2,9 @@
 using System.Threading;
 using System.Threading.Tasks;
 using AntJob;
-using AntJob.Providers;
-using Microsoft.Extensions.Hosting;
 using NewLife;
+using NewLife.Log;
+using NewLife.Model;
 
 namespace HisAgent;
 
@@ -12,36 +12,32 @@ public class JobHost : BackgroundService
 {
     private Scheduler _scheduler;
     private readonly IServiceProvider _serviceProvider;
+    private readonly AntSetting _setting;
 
-    public JobHost(IServiceProvider serviceProvider) => _serviceProvider = serviceProvider;
+    public JobHost(IServiceProvider serviceProvider, AntSetting setting)
+    {
+        _serviceProvider = serviceProvider;
+        _setting = setting;
+    }
 
     protected override Task ExecuteAsync(CancellationToken stoppingToken)
     {
-        var set = AntSetting.Current;
-
         // 实例化调度器
         var scheduler = new Scheduler
         {
             ServiceProvider = _serviceProvider,
-
-            //// 使用分布式调度引擎替换默认的本地文件调度
-            //Provider = new NetworkJobProvider
-            //{
-            //    Server = set.Server,
-            //    AppID = set.AppID,
-            //    Secret = set.Secret,
-            //    Debug = false
-            //}
+            Log = XTrace.Log,
         };
 
-        scheduler.Join(set.Server, set.AppID, set.Secret, set.Debug);
+        scheduler.Join(_setting);
 
         // 添加作业
         scheduler.AddHandler<HelloJob>();
-
+        scheduler.AddHandler<BuildPatient>();
+        scheduler.AddHandler<BuildWill>();
 
         // 启动调度引擎,调度器内部多线程处理
-        scheduler.Start();
+        scheduler.StartAsync();
         _scheduler = scheduler;
 
         return Task.CompletedTask;
Modified +13 -26
diff --git a/Samples/HisAgent/Program.cs b/Samples/HisAgent/Program.cs
index 823b0cd..bceddee 100644
--- a/Samples/HisAgent/Program.cs
+++ b/Samples/HisAgent/Program.cs
@@ -1,33 +1,20 @@
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
+using AntJob;
+using HisAgent;
 using NewLife.Log;
+using NewLife.Model;
+using Stardust;
 
-namespace HisAgent;
+// 启用控制台日志,拦截所有异常
+XTrace.UseConsole();
 
-class Program
-{
-    static void Main(string[] args)
-    {
-        XTrace.UseConsole();
+var services = ObjectContainer.Current;
+services.AddStardust();
 
-        CreateHostBuilder(args).Build().Run();
-    }
+services.AddSingleton(AntSetting.Current);
 
-    /// <summary></summary>
-    /// <param name="args"></param>
-    /// <returns></returns>
-    public static IHostBuilder CreateHostBuilder(string[] args) =>
-      Host.CreateDefaultBuilder(args)
-        .ConfigureServices((hostContext, services) => ConfigureServices(services));
+// 友好退出
+var host = services.BuildHost();
 
-    /// <summary></summary>
-    /// <param name="hostBuilderContext"></param>
-    /// <param name="services"></param>
-    public static void ConfigureServices(IServiceCollection services)
-    {
-        services.AddStardust();
+host.Add<JobHost>();
 
-        // 添加后台调度服务
-        services.AddHostedService<JobHost>();
-    }
-}
\ No newline at end of file
+await host.RunAsync();
\ No newline at end of file
Modified +1 -1
diff --git a/Samples/HisData/HisData.csproj b/Samples/HisData/HisData.csproj
index cef75d8..c0297b2 100644
--- a/Samples/HisData/HisData.csproj
+++ b/Samples/HisData/HisData.csproj
@@ -16,7 +16,7 @@
   </ItemGroup>
 
   <ItemGroup>
-    <PackageReference Include="NewLife.XCode" Version="11.11.2024.402" />
+    <PackageReference Include="NewLife.XCode" Version="11.16.2024.1005" />
   </ItemGroup>
 
   <ItemGroup>
Modified +1 -1
diff --git a/Samples/HisWeb/HisWeb.csproj b/Samples/HisWeb/HisWeb.csproj
index 6c2207c..9fffe89 100644
--- a/Samples/HisWeb/HisWeb.csproj
+++ b/Samples/HisWeb/HisWeb.csproj
@@ -9,7 +9,7 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="NewLife.Cube.Core" Version="6.1.2024.403" />
+    <PackageReference Include="NewLife.Cube.Core" Version="6.1.2024.1005" />
   </ItemGroup>
 
   <ItemGroup>
Modified +1 -8
diff --git "a/\350\232\202\350\232\201.sln" "b/\350\232\202\350\232\201.sln"
index c2c4ec0..f549049 100644
--- "a/\350\232\202\350\232\201.sln"
+++ "b/\350\232\202\350\232\201.sln"
@@ -5,6 +5,7 @@ VisualStudioVersion = 17.1.32328.378
 MinimumVisualStudioVersion = 10.0.40219.1
 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Others", "Others", "{0EA980BB-BB15-41A3-B75B-537BC42E985B}"
 	ProjectSection(SolutionItems) = preProject
+		.editorconfig = .editorconfig
 		.github\workflows\publish-beta.yml = .github\workflows\publish-beta.yml
 		.github\workflows\publish.yml = .github\workflows\publish.yml
 		Readme.MD = Readme.MD
@@ -25,11 +26,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AntJob.Extensions", "AntJob
 EndProject
 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{E842807F-C45E-44DA-8AAE-7915C1EBF2A2}"
 EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{A1EF271C-AEA8-4EA3-A76F-906B4D4A9058}"
-	ProjectSection(SolutionItems) = preProject
-		.editorconfig = .editorconfig
-	EndProjectSection
-EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AntJob.Agent", "AntJob.Agent\AntJob.Agent.csproj", "{0970FDBA-2331-4600-8DD5-A37B41AF989F}"
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HisAgent", "Samples\HisAgent\HisAgent.csproj", "{E62006DC-E61B-42B0-A06B-ED5BF3F73D9E}"
@@ -38,8 +34,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HisData", "Samples\HisData\
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HisWeb", "Samples\HisWeb\HisWeb.csproj", "{153499A6-E73C-4C5A-8867-D29BD586A74B}"
 EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UnitTest", "UnitTest", "{26660D0A-8724-4434-88D1-5EE861A68309}"
-EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AntTest", "AntTest\AntTest.csproj", "{A13E58D2-A185-4945-98B7-B6B0951A19D2}"
 EndProject
 Global
@@ -100,7 +94,6 @@ Global
 		{E62006DC-E61B-42B0-A06B-ED5BF3F73D9E} = {E842807F-C45E-44DA-8AAE-7915C1EBF2A2}
 		{38F8667D-70B7-4A90-8CA7-63738E925DFF} = {E842807F-C45E-44DA-8AAE-7915C1EBF2A2}
 		{153499A6-E73C-4C5A-8867-D29BD586A74B} = {E842807F-C45E-44DA-8AAE-7915C1EBF2A2}
-		{A13E58D2-A185-4945-98B7-B6B0951A19D2} = {26660D0A-8724-4434-88D1-5EE861A68309}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {9337283C-C795-479F-A2F1-C892EBE2490C}