NewLife/RainbowBridge

设备流量统计支持今日累计与历史累计分离

本次改动实现设备流量“今日累计”与“历史累计”分离,支持跨天自动清零与历史持久化累计。数据模型层为 Device 实体新增今日累计字段及统计日期,完善序列化与常量定义。后端服务统一处理累计逻辑,支持跨天懒清零。API 返回今日累计字段,前端类型与页面同步调整,设备列表与详情支持今日/历史累计展示与排序。文档同步更新描述。新增单元测试覆盖今日累计、跨天清零等核心逻辑,确保功能正确性。
大石头 authored at 2026-09-02 22:59:01
bc7d839
Tree
1 Parent(s) d614614
Summary: 10 changed files with 199 additions and 22 deletions.
Modified +3 -3
Modified +1 -1
Modified +3 -0
Modified +48 -0
Modified +32 -4
Modified +4 -1
Modified +4 -2
Modified +1 -1
Modified +12 -10
Added +91 -0
Modified +3 -3
diff --git "a/Doc/\345\212\237\350\203\275\346\270\205\345\215\225.md" "b/Doc/\345\212\237\350\203\275\346\270\205\345\215\225.md"
index bf4672e..b5d88d7 100644
--- "a/Doc/\345\212\237\350\203\275\346\270\205\345\215\225.md"
+++ "b/Doc/\345\212\237\350\203\275\346\270\205\345\215\225.md"
@@ -58,7 +58,7 @@
 | 类别 | 页面 | 操作 |
 |------|------|------|
 | ✅ 已有排序(仅构建) | 网络连接 | pnpm build 部署 |
-| ⚠️ 补全字段 | 设备列表 | `SERVER_SORT_KEYS` 加 `totalRxBytes`/`totalTxBytes` |
+| ⚠️ 补全字段 | 设备列表 | `SERVER_SORT_KEYS` 加 `todayRxBytes`/`todayTxBytes` |
 | ⚠️ 加 sort/desc 参数 | 操作日志、DNS 黑名单 | Controller 加参数传入 Pager |
 | ⚠️ 改用 FindAll+Pager | DNS 查询日志、DNS 解析表 | 去掉硬编码 Search |
 | ❌ FindAll→分页 | 用户管理、DHCP 池/绑定、DNS 规则、防火墙、PPPoE、QoS、WAN | `FindAll()` → `FindAll(null, pager)` + `OkPage()` |
@@ -427,7 +427,7 @@
 | STAT-2c | 设备实时速率 | 前端 | ✅ | ✅ | ✅ | 新增 `GET /api/devices/traffic` 实时速率端点 + Devices 列表增加 ↓↑ 速率列 + 连接数列,3s 轮询显示。🔍审计修复(2026-09-02): 实时速度改指数平滑(RateSmoother 快升2s/慢降6s半衰,与后端同算法),无流量轮次平滑衰减而非瞬间归零;连接数列优先 3s 实时 connCount 回退 DB 快照,显示/排序口径统一。后端 RateSmootherTests + 前端 rateSmoother vitest 均通过 |
 | STAT-2d | ARP 表采集(Windows) | 后端 | ✅ | ✅ | ✅ | WindowsArpScanner |
 | STAT-2e | 设备流量趋势图 | 前端 | ✅ | ✅ | ✅ | `/stats/devices` 页面,ECharts 折线图,支持设备/分组/成员筛选。StatsE2ETests 覆盖页面加载+无JS错误 |
-| STAT-2f | 冗余字段 MemberId/GroupId + 历史累计 | 后端 | ✅ | ⏸️ | ✅ | DeviceStat/DeviceHourStat/DeviceDayStat 冗余 MemberId/GroupId;Device.TotalRxBytes/TotalTxBytes 逐分钟累加历史总流量,跨重启持久化,设备列表展示"累计↓/↑"。逐连接采集核心已由 ConntrackTrafficCollectorTests 覆盖;Total 累加位于 Linux 门控的 CollectDeviceStat 内,⏸️需 Linux+conntrack 环境验证 |
+| STAT-2f | 冗余字段 MemberId/GroupId + 今日/历史累计 | 后端 | ✅ | ✅ | ✅ | DeviceStat/DeviceHourStat/DeviceDayStat 冗余 MemberId/GroupId;Device.TotalRxBytes/TotalTxBytes 逐分钟累加历史总流量、TodayRxBytes/TodayTxBytes 逐分钟累加今日流量(TodayDate 跨天懒清零),均跨重启持久化。🔍调整(2026-09-02): 设备列表列由"累计↓/↑"改为"今日↓/↑"(数据 Device.TodayRxBytes/TodayTxBytes,支持服务端排序),历史累计移至设备详情抽屉展示;DeviceController 投影 todayRxBytes/todayTxBytes。累加/跨天逻辑抽为 StatCollector.AccumulateTraffic,DeviceTodayTrafficTests 4 例覆盖;CollectDeviceStat 逐分钟循环仍⏸️需 Linux+conntrack 环境验证 |
 | STAT-5a | 网卡跨平台采集 | 后端 | ✅ | ✅ | ✅ | NetworkInterfaceStatProvider:NetworkInterface API 采集 Rx/Tx 字节/包/错误/丢弃 8 项跨平台指标(原 16 项含 Linux 专属硬件字段已精简) |
 | STAT-5b | 网卡流量趋势图 | 前端 | ✅ | ✅ | ✅ | `/stats/interfaces` 页面,ECharts 多曲线图。StatsE2ETests 覆盖页面加载+无JS错误 |
 
@@ -626,7 +626,7 @@
 | RT-5 流量整形 QoS | 3 | 3/3 | ✅ | 🔍修复: RT-5a 下行限速、RT-5c 命令检查已修复 |
 | RT-6 Windows 网络路由 | 7 | 7/7 | ✅ | RT-6d⏸️需Windows Hyper-V环境 |
 | STAT-1 网口流量统计 | 5 | 5/5 | ✅ | 全部完成 |
-| STAT-2 设备流量统计 | 6 | 6/6 | ✅ | 逐连接增量采集重构修复系统性欠计 + 设备历史累计。🔍审计修复(2026-09-02): STAT-2c 实时速率平滑 + 实时连接数口径已修复 |
+| STAT-2 设备流量统计 | 6 | 6/6 | ✅ | 逐连接增量采集重构修复系统性欠计 + 设备今日/历史累计。🔍审计修复(2026-09-02): STAT-2c 实时速率平滑 + 实时连接数口径已修复。🔍调整(2026-09-02): 设备列表流量列改"今日↓/↑"(TodayRxBytes/TodayTxBytes 跨天清零),历史累计移至详情 |
 | STAT-5 网卡流量统计 | 2 | 2/2 | ✅ | 网卡跨平台采集(NetworkInterface API,8 项指标) |
 | STAT-3 成员流量统计 | 3 | 3/3 | ✅ | 🔍审计修复: STAT-3c 注释已补全 |
 | STAT-4 数据生命周期 | 3 | 3/3 | ✅ | 全部完成 |
Modified +1 -1
diff --git "a/Doc/\351\234\200\346\261\202\346\226\207\346\241\243.md" "b/Doc/\351\234\200\346\261\202\346\226\207\346\241\243.md"
index 0f65b82..370df22 100644
--- "a/Doc/\351\234\200\346\261\202\346\226\207\346\241\243.md"
+++ "b/Doc/\351\234\200\346\261\202\346\226\207\346\241\243.md"
@@ -380,7 +380,7 @@ Rainbow 可部署在 **Windows/macOS** 常开设备上,提供**系统监控、
 - **STAT-2c 设备实时速率**:设备列表页 3s 轮询显示每设备的↓↑实时速率和连接数
 - **STAT-2d ARP 表采集(Windows)**:WindowsArpScanner 读取 Windows ARP 表,供 Windows 平台设备发现与在线状态采集
 - **STAT-2e 设备流量趋势图**:`/stats/devices` 页面,ECharts 折线图展示单设备/分组/成员的历史流量趋势,支持今天/7天/30天粒度切换,含数据明细表格
-- **STAT-2f 冗余字段与设备历史累计**:DeviceStat/DeviceHourStat/DeviceDayStat 冗余存储 MemberId/GroupId,支持按用户/分组直接筛选;每分钟将设备流量增量累加至 Device.TotalRxBytes/TotalTxBytes,跨重启持久化,设备列表展示"累计↓/↑"历史总流量(替代原今日值)
+- **STAT-2f 冗余字段与设备今日/历史累计**:DeviceStat/DeviceHourStat/DeviceDayStat 冗余存储 MemberId/GroupId,支持按用户/分组直接筛选;每分钟将设备流量增量累加至 Device.TotalRxBytes/TotalTxBytes(历史总账,跨重启持久化)与 TodayRxBytes/TodayTxBytes(今日累计,TodayDate 跨天自动清零);设备列表展示"今日↓/↑"当日累计流量,设备详情展示"今日下行/上行 + 累计下行/上行"
 
 #### STAT-5 网卡流量统计
 - **STAT-5a 跨平台采集**:通过 NetworkInterface API 采集网口累计计数器(Rx/Tx 字节/包/错误/丢弃),逐分钟差值入库,Linux/Windows/macOS 统一实现
Modified +3 -0
diff --git a/Rainbow.Data/Network/Model.xml b/Rainbow.Data/Network/Model.xml
index 2508f9b..60e4bd6 100644
--- a/Rainbow.Data/Network/Model.xml
+++ b/Rainbow.Data/Network/Model.xml
@@ -60,6 +60,9 @@
         <Column Name="Traffic" DataType="Int64" Description="流量。今日流量(字节)" />
         <Column Name="TotalRxBytes" DataType="Int64" Description="历史累计接收字节。跨重启持久化,每分钟累加设备接收增量,设备级历史流量总账" />
         <Column Name="TotalTxBytes" DataType="Int64" Description="历史累计发送字节。跨重启持久化,每分钟累加设备发送增量" />
+        <Column Name="TodayRxBytes" DataType="Int64" Description="今日接收字节。当日累计接收流量,跨天自动清零" />
+        <Column Name="TodayTxBytes" DataType="Int64" Description="今日发送字节。当日累计发送流量,跨天自动清零" />
+        <Column Name="TodayDate" DataType="DateTime" Description="今日统计日期。最后一次清零今日流量的日期,跨天自动重置" />
         <Column Name="Logins" DataType="Int32" Description="上线次数。累计上线次数" />
         <Column Name="Enable" DataType="Boolean" Description="启用。拉黑时禁用" />
         <Column Name="CreateUserId" DataType="Int32" Description="创建用户" Category="扩展" />
Modified +48 -0
diff --git "a/Rainbow.Data/Network/\347\273\210\347\253\257\350\256\276\345\244\207.cs" "b/Rainbow.Data/Network/\347\273\210\347\253\257\350\256\276\345\244\207.cs"
index 3a460e7..dd64cda 100644
--- "a/Rainbow.Data/Network/\347\273\210\347\253\257\350\256\276\345\244\207.cs"
+++ "b/Rainbow.Data/Network/\347\273\210\347\253\257\350\256\276\345\244\207.cs"
@@ -164,6 +164,30 @@ public partial class Device
     [BindColumn("TotalTxBytes", "历史累计发送字节。跨重启持久化,每分钟累加设备发送增量", "")]
     public Int64 TotalTxBytes { get => _TotalTxBytes; set { if (OnPropertyChanging("TotalTxBytes", value)) { _TotalTxBytes = value; OnPropertyChanged("TotalTxBytes"); } } }
 
+    private Int64 _TodayRxBytes;
+    /// <summary>今日接收字节。当日累计接收流量,跨天自动清零</summary>
+    [DisplayName("今日接收字节")]
+    [Description("今日接收字节。当日累计接收流量,跨天自动清零")]
+    [DataObjectField(false, false, false, 0)]
+    [BindColumn("TodayRxBytes", "今日接收字节。当日累计接收流量,跨天自动清零", "")]
+    public Int64 TodayRxBytes { get => _TodayRxBytes; set { if (OnPropertyChanging("TodayRxBytes", value)) { _TodayRxBytes = value; OnPropertyChanged("TodayRxBytes"); } } }
+
+    private Int64 _TodayTxBytes;
+    /// <summary>今日发送字节。当日累计发送流量,跨天自动清零</summary>
+    [DisplayName("今日发送字节")]
+    [Description("今日发送字节。当日累计发送流量,跨天自动清零")]
+    [DataObjectField(false, false, false, 0)]
+    [BindColumn("TodayTxBytes", "今日发送字节。当日累计发送流量,跨天自动清零", "")]
+    public Int64 TodayTxBytes { get => _TodayTxBytes; set { if (OnPropertyChanging("TodayTxBytes", value)) { _TodayTxBytes = value; OnPropertyChanged("TodayTxBytes"); } } }
+
+    private DateTime _TodayDate;
+    /// <summary>今日统计日期。最后一次清零今日流量的日期,跨天自动重置</summary>
+    [DisplayName("今日统计日期")]
+    [Description("今日统计日期。最后一次清零今日流量的日期,跨天自动重置")]
+    [DataObjectField(false, false, true, 0)]
+    [BindColumn("TodayDate", "今日统计日期。最后一次清零今日流量的日期,跨天自动重置", "")]
+    public DateTime TodayDate { get => _TodayDate; set { if (OnPropertyChanging("TodayDate", value)) { _TodayDate = value; OnPropertyChanged("TodayDate"); } } }
+
     private Int32 _Logins;
     /// <summary>上线次数。累计上线次数</summary>
     [DisplayName("上线次数")]
@@ -293,6 +317,9 @@ public partial class Device
             "Traffic" => _Traffic,
             "TotalRxBytes" => _TotalRxBytes,
             "TotalTxBytes" => _TotalTxBytes,
+            "TodayRxBytes" => _TodayRxBytes,
+            "TodayTxBytes" => _TodayTxBytes,
+            "TodayDate" => _TodayDate,
             "Logins" => _Logins,
             "Enable" => _Enable,
             "CreateUserId" => _CreateUserId,
@@ -328,6 +355,9 @@ public partial class Device
                 case "Traffic": _Traffic = value.ToLong(); break;
                 case "TotalRxBytes": _TotalRxBytes = value.ToLong(); break;
                 case "TotalTxBytes": _TotalTxBytes = value.ToLong(); break;
+                case "TodayRxBytes": _TodayRxBytes = value.ToLong(); break;
+                case "TodayTxBytes": _TodayTxBytes = value.ToLong(); break;
+                case "TodayDate": _TodayDate = value.ToDateTime(); break;
                 case "Logins": _Logins = value.ToInt(); break;
                 case "Enable": _Enable = value.ToBoolean(); break;
                 case "CreateUserId": _CreateUserId = value.ToInt(); break;
@@ -524,6 +554,15 @@ public partial class Device
         /// <summary>历史累计发送字节。跨重启持久化,每分钟累加设备发送增量</summary>
         public static readonly Field TotalTxBytes = FindByName("TotalTxBytes");
 
+        /// <summary>今日接收字节。当日累计接收流量,跨天自动清零</summary>
+        public static readonly Field TodayRxBytes = FindByName("TodayRxBytes");
+
+        /// <summary>今日发送字节。当日累计发送流量,跨天自动清零</summary>
+        public static readonly Field TodayTxBytes = FindByName("TodayTxBytes");
+
+        /// <summary>今日统计日期。最后一次清零今日流量的日期,跨天自动重置</summary>
+        public static readonly Field TodayDate = FindByName("TodayDate");
+
         /// <summary>上线次数。累计上线次数</summary>
         public static readonly Field Logins = FindByName("Logins");
 
@@ -617,6 +656,15 @@ public partial class Device
         /// <summary>历史累计发送字节。跨重启持久化,每分钟累加设备发送增量</summary>
         public const String TotalTxBytes = "TotalTxBytes";
 
+        /// <summary>今日接收字节。当日累计接收流量,跨天自动清零</summary>
+        public const String TodayRxBytes = "TodayRxBytes";
+
+        /// <summary>今日发送字节。当日累计发送流量,跨天自动清零</summary>
+        public const String TodayTxBytes = "TodayTxBytes";
+
+        /// <summary>今日统计日期。最后一次清零今日流量的日期,跨天自动重置</summary>
+        public const String TodayDate = "TodayDate";
+
         /// <summary>上线次数。累计上线次数</summary>
         public const String Logins = "Logins";
 
Modified +32 -4
diff --git a/Rainbow.Data/Services/StatCollector.cs b/Rainbow.Data/Services/StatCollector.cs
index 239835c..1ad4ad7 100644
--- a/Rainbow.Data/Services/StatCollector.cs
+++ b/Rainbow.Data/Services/StatCollector.cs
@@ -252,11 +252,9 @@ public class StatCollector : IHostedService
 
                 if (t != null) connCounts[device.IP] = t.ConnCount;
 
-                // 累加设备级历史累计流量(跨重启持久化,仅在有流量时更新以减少写入)
-                if (rx > 0 || tx > 0)
+                // 累加设备级今日/历史累计流量(跨重启持久化;今日值跨天清零。仅在有流量或跨天重置时返回 true 触发保存)
+                if (AccumulateTraffic(device, rx, tx, DateTime.Today))
                 {
-                    device.TotalRxBytes += rx;
-                    device.TotalTxBytes += tx;
                     device.Update();
                 }
             }
@@ -270,6 +268,36 @@ public class StatCollector : IHostedService
         SyncDeviceOnline(onlineDevices, connCounts, now);
     }
 
+    /// <summary>累加设备今日/历史累计流量,今日值跨天自动清零</summary>
+    /// <remarks>
+    /// 今日累计(TodayRxBytes/TodayTxBytes)供设备列表"今日↓/↑"列展示与服务端排序,跨天懒重置(统计日期不是今天时先归零再累加);
+    /// 历史累计(TotalRxBytes/TotalTxBytes)为全时段总账,永不归零。仅在有流量或跨天重置时返回 true,由调用方决定是否保存。
+    /// </remarks>
+    /// <param name="device">设备实体</param>
+    /// <param name="rx">本分钟接收字节增量</param>
+    /// <param name="tx">本分钟发送字节增量</param>
+    /// <param name="today">今日日期,用于跨天判定(可空,默认取当前日期)</param>
+    /// <returns>是否有变化需要保存</returns>
+    public static Boolean AccumulateTraffic(Device device, Int64 rx, Int64 tx, DateTime? today = null)
+    {
+        var now = (today ?? DateTime.Today).Date;
+        var dateChanged = device.TodayDate.Date != now;
+        if (dateChanged)
+        {
+            device.TodayRxBytes = 0;
+            device.TodayTxBytes = 0;
+            device.TodayDate = now;
+        }
+
+        if (rx <= 0 && tx <= 0 && !dateChanged) return false;
+
+        device.TodayRxBytes += rx;
+        device.TodayTxBytes += tx;
+        device.TotalRxBytes += rx;
+        device.TotalTxBytes += tx;
+        return true;
+    }
+
     /// <summary>统计指定分钟窗口内每 IP 的 DNS 查询次数。来自 dnsmasq 日志解析表 DnsQueryLog,best-effort</summary>
     /// <param name="start">窗口起(含)</param>
     /// <param name="end">窗口止(不含)</param>
Modified +4 -1
diff --git a/Rainbow.Web/Controllers/DeviceController.cs b/Rainbow.Web/Controllers/DeviceController.cs
index dd2db02..34f5855 100644
--- a/Rainbow.Web/Controllers/DeviceController.cs
+++ b/Rainbow.Web/Controllers/DeviceController.cs
@@ -438,9 +438,12 @@ public class DeviceController : ControllerBase
                 interfaceName = o?.Interface ?? "",
                 onlineRxBytes = o?.RxBytes ?? 0L,
                 onlineTxBytes = o?.TxBytes ?? 0L,
-                // 设备级历史累计流量(跨重启持久化,供设备列表/详情展示)
+                // 设备级历史累计流量(跨重启持久化,供设备详情展示)
                 totalRxBytes = d.TotalRxBytes,
                 totalTxBytes = d.TotalTxBytes,
+                // 设备级今日累计流量(跨天清零,供设备列表"今日↓/↑"列展示与服务端排序)
+                todayRxBytes = d.TodayRxBytes,
+                todayTxBytes = d.TodayTxBytes,
             };
         }).ToList();
     }
Modified +4 -2
diff --git a/Web/src/components/devices/DeviceDetailDrawer.tsx b/Web/src/components/devices/DeviceDetailDrawer.tsx
index 003b785..27b84b9 100644
--- a/Web/src/components/devices/DeviceDetailDrawer.tsx
+++ b/Web/src/components/devices/DeviceDetailDrawer.tsx
@@ -370,8 +370,10 @@ export function DeviceDetailDrawer({ deviceId, onClose, onSaved }: DeviceDetailD
               <div className="grid grid-cols-2 gap-x-4 gap-y-2.5">
                 <StatItem label="最后在线" value={fmtTime(device.lastOnline)} />
                 <StatItem label="在线时长" value={fmtDuration(device.onlineTime)} />
-                <StatItem label="今日下行" value={fmtBytes(device.rxBytes)} />
-                <StatItem label="今日上行" value={fmtBytes(device.txBytes)} />
+                <StatItem label="今日下行" value={fmtBytes(device.todayRxBytes)} />
+                <StatItem label="今日上行" value={fmtBytes(device.todayTxBytes)} />
+                <StatItem label="累计下行" value={fmtBytes(device.totalRxBytes)} />
+                <StatItem label="累计上行" value={fmtBytes(device.totalTxBytes)} />
                 <StatItem label="上线次数" value={device.logins != null ? `${device.logins}次` : '-'} />
                 <StatItem label="首次发现" value={fmtTime(device.createTime)} />
                 <StatItem label="最后更新" value={fmtTime(device.updateTime)} />
Modified +1 -1
diff --git a/Web/src/lib/api.ts b/Web/src/lib/api.ts
index 72298cc..51fa4ad 100644
--- a/Web/src/lib/api.ts
+++ b/Web/src/lib/api.ts
@@ -522,7 +522,7 @@ export function exportLogs(format: 'csv' | 'json' = 'csv', action?: string, days
 export interface SystemStats { cpuUsage: number; memoryUsage: number; diskUsage: number; uptimeSeconds: number; connectionCount: number; rxSpeed: number; txSpeed: number; }
 export interface SystemInfo { os: string; hostName: string; kernelVersion: string; isLinux: boolean; platform?: string; capabilities?: Record<string, boolean>; osVersion?: string; dotNetVersion?: string; }
 export interface TrafficItem { name: string; rawName?: string; rxSpeed: number; txSpeed: number; rxBytes: number; txBytes: number; }
-export interface Device { id: number; mac: string; ip: string; hostName: string; name: string; online: boolean; enable: boolean; staticIP: boolean; lastOnline: string; vendor?: string; kind?: number; owner?: string; groupId?: number; memberId?: number; memberName?: string; connections?: number; rxBytes?: number; txBytes?: number; totalRxBytes?: number; totalTxBytes?: number; interfaceName: string; icon?: string; remark?: string; onlineTime?: number; traffic?: number; logins?: number; createTime?: string; updateTime?: string; }
+export interface Device { id: number; mac: string; ip: string; hostName: string; name: string; online: boolean; enable: boolean; staticIP: boolean; lastOnline: string; vendor?: string; kind?: number; owner?: string; groupId?: number; memberId?: number; memberName?: string; connections?: number; rxBytes?: number; txBytes?: number; totalRxBytes?: number; totalTxBytes?: number; todayRxBytes?: number; todayTxBytes?: number; interfaceName: string; icon?: string; remark?: string; onlineTime?: number; traffic?: number; logins?: number; createTime?: string; updateTime?: string; }
 export interface DeviceGroup { id: number; name: string; parentId: number; remark?: string; }
 export interface WanInterface { id: number; name: string; displayName?: string; kind: number; ip: string; gateway: string; weight: number; isDefault: boolean; enable: boolean; rxSpeed: number; txSpeed: number; }
 export interface PppoeStatus { isConnected: boolean; publicIp: string; connectedSeconds: number; localIp: string; remoteIp: string; interfaceName: string; }
Modified +12 -10
diff --git a/Web/src/pages/Devices.tsx b/Web/src/pages/Devices.tsx
index 54a9187..58d4ce2 100644
--- a/Web/src/pages/Devices.tsx
+++ b/Web/src/pages/Devices.tsx
@@ -155,8 +155,8 @@ export function Devices() {
 
   /** 服务端支持的排序字段(rxSpeed/txSpeed/connCount 为客户端计算,不发送到 API) */
   // 服务端实体属性名为 PascalCase,前端用 camelCase 发送后 XCode 无法识别含大写驼峰字段
-  // 经验证 name/ip/hostName/online/mac/totalRxBytes/totalTxBytes 可服务端排序,rxBytes/txBytes/connections 需客户端排序
-  const SERVER_SORT_KEYS = new Set(['name', 'ip', 'hostName', 'online', 'mac', 'totalRxBytes', 'totalTxBytes'])
+  // 经验证 name/ip/hostName/online/mac/todayRxBytes/todayTxBytes 可服务端排序,rxSpeed/txSpeed/connections 需客户端排序
+  const SERVER_SORT_KEYS = new Set(['name', 'ip', 'hostName', 'online', 'mac', 'todayRxBytes', 'todayTxBytes'])
 
   /** 从 API 加载设备和分组数据(服务器端分页+筛选+排序) */
   const fetchData = useCallback(async (pg: number, st: StatusTab, gid: number | null, srch: string, srt: SortState) => {
@@ -455,23 +455,25 @@ export function Devices() {
       },
     },
     {
-      key: 'totalRxBytes',
-      label: '累计↓',
+      key: 'todayRxBytes',
+      label: '今日↓',
       sortable: true,
       width: 80,
       className: 'font-mono text-xs text-right',
-      render: (d) => d.totalRxBytes != null && d.totalRxBytes > 0
-        ? <span className="text-[var(--color-info)]">{formatBytes(d.totalRxBytes)}</span>
+      title: '今日累计下行流量',
+      render: (d) => d.todayRxBytes != null && d.todayRxBytes > 0
+        ? <span className="text-[var(--color-info)]">{formatBytes(d.todayRxBytes)}</span>
         : <span className="text-[var(--color-text-tertiary)]">-</span>,
     },
     {
-      key: 'totalTxBytes',
-      label: '累计↑',
+      key: 'todayTxBytes',
+      label: '今日↑',
       sortable: true,
       width: 80,
       className: 'font-mono text-xs text-right',
-      render: (d) => d.totalTxBytes != null && d.totalTxBytes > 0
-        ? <span className="text-[var(--color-warning)]">{formatBytes(d.totalTxBytes)}</span>
+      title: '今日累计上行流量',
+      render: (d) => d.todayTxBytes != null && d.todayTxBytes > 0
+        ? <span className="text-[var(--color-warning)]">{formatBytes(d.todayTxBytes)}</span>
         : <span className="text-[var(--color-text-tertiary)]">-</span>,
     },
     {
Added +91 -0
diff --git a/XUnitTest/Rainbow.Services/DeviceTodayTrafficTests.cs b/XUnitTest/Rainbow.Services/DeviceTodayTrafficTests.cs
new file mode 100644
index 0000000..710b798
--- /dev/null
+++ b/XUnitTest/Rainbow.Services/DeviceTodayTrafficTests.cs
@@ -0,0 +1,91 @@
+using Rainbow.Entity;
+using Rainbow.Services;
+using Xunit;
+
+namespace XUnitTest.Rainbow.Services;
+
+/// <summary>设备今日/历史累计流量(Device.TodayRxBytes/TodayTxBytes/TodayDate + StatCollector.AccumulateTraffic)测试</summary>
+/// <remarks>
+/// 纯内存逻辑测试,不依赖数据库(规避测试共享 SQLite 的不稳定)。验证:今日累计逐分钟累加、
+/// 跨天懒清零、历史累计永不清零、无流量且同日不触发保存等核心逻辑。
+/// </remarks>
+public class DeviceTodayTrafficTests
+{
+    /// <summary>构造一个仅内存的测试设备(不落库)</summary>
+    private static Device NewDevice(Int64 todayRx, Int64 todayTx, Int64 totalRx, Int64 totalTx, DateTime todayDate) => new()
+    {
+        Name = "today-traffic-test",
+        Mac = "DA:DB:00:00:00:01",
+        Enable = true,
+        TodayRxBytes = todayRx,
+        TodayTxBytes = todayTx,
+        TotalRxBytes = totalRx,
+        TotalTxBytes = totalTx,
+        TodayDate = todayDate,
+    };
+
+    [Fact(DisplayName = "今日累计_逐分钟累加今日与历史字节")]
+    public void Accumulate_AddsTodayAndTotal()
+    {
+        var today = DateTime.Today;
+        var device = NewDevice(0, 0, 0, 0, today);
+
+        // 第一分钟:下行 100 上行 10
+        Assert.True(StatCollector.AccumulateTraffic(device, 100, 10, today));
+        // 第二分钟:下行 200 上行 20(同日不再清零)
+        Assert.True(StatCollector.AccumulateTraffic(device, 200, 20, today));
+
+        Assert.Equal(300, device.TodayRxBytes);   // 今日累计 100+200
+        Assert.Equal(30, device.TodayTxBytes);    // 今日累计 10+20
+        Assert.Equal(300, device.TotalRxBytes);   // 历史累计同值
+        Assert.Equal(30, device.TotalTxBytes);
+        Assert.Equal(today, device.TodayDate.Date);
+    }
+
+    [Fact(DisplayName = "今日累计_跨天自动清零后再累加,历史累计不清零")]
+    public void Accumulate_CrossDayResetsTodayKeepsTotal()
+    {
+        var yesterday = DateTime.Today.AddDays(-1);
+        var today = DateTime.Today;
+        // 昨天的累计值:今日 500/300,历史总账 500/300(昨日流量已计入总账)
+        var device = NewDevice(500, 300, 500, 300, yesterday);
+
+        // 今天第一分钟产生新流量 100/20
+        Assert.True(StatCollector.AccumulateTraffic(device, 100, 20, today));
+
+        Assert.Equal(100, device.TodayRxBytes);   // 昨日 500 已清零,仅今日 100
+        Assert.Equal(20, device.TodayTxBytes);    // 昨日 300 已清零,仅今日 20
+        Assert.Equal(600, device.TotalRxBytes);   // 历史总账保留 500+100
+        Assert.Equal(320, device.TotalTxBytes);   // 历史总账保留 300+20
+        Assert.Equal(today, device.TodayDate.Date);
+    }
+
+    [Fact(DisplayName = "今日累计_同日无流量返回 false 不触发保存")]
+    public void Accumulate_SameDayNoTraffic_ReturnsFalse()
+    {
+        var today = DateTime.Today;
+        var device = NewDevice(100, 50, 100, 50, today);
+
+        var changed = StatCollector.AccumulateTraffic(device, 0, 0, today);
+
+        Assert.False(changed);
+        Assert.Equal(100, device.TodayRxBytes);
+        Assert.Equal(50, device.TodayTxBytes);
+    }
+
+    [Fact(DisplayName = "今日累计_跨天无流量也归零并返回 true 触发保存")]
+    public void Accumulate_CrossDayNoTraffic_ResetsAndReturnsTrue()
+    {
+        var yesterday = DateTime.Today.AddDays(-1);
+        var today = DateTime.Today;
+        var device = NewDevice(500, 300, 500, 300, yesterday);
+
+        var changed = StatCollector.AccumulateTraffic(device, 0, 0, today);
+
+        Assert.True(changed);                       // 跨天重置需落库
+        Assert.Equal(0, device.TodayRxBytes);
+        Assert.Equal(0, device.TodayTxBytes);
+        Assert.Equal(today, device.TodayDate.Date);
+        Assert.Equal(500, device.TotalRxBytes);     // 历史累计不受影响
+    }
+}