36 Commits
Author SHA1 Message Date
Jokul 647d0878de chore: 版本号升至 1.1.0 准备封版 2026-07-20 23:49:46 +08:00
Jokul d4627f82d5 fix: 流量波动图Y轴动态适应最近活动,峰值过后自动缩小
- Y轴上限从全量5分钟缓冲改为最近60秒窗口计算
- 解决历史高峰值(如12MB/s下载)导致Y轴长期偏高、
  当前低网速曲线被压到底部无法观察波动的问题
- 峰值滑出60秒窗口后Y轴自动缩小,始终为当前活动提供合适显示比例
2026-07-20 23:23:08 +08:00
Jokul a12a106791 fix: SettingsWindow 组件加载失败修复 + 设置窗口交互优化
- 修复 import "components" 错误:SettingsWindow.qml 位于 components/ 目录,
  该 import 会找 components/components/(不存在)导致 QML 加载失败、插件消失;
  同目录组件 QML 自动解析,删除该 import
- 接口名左对齐、类型描述右对齐:RadioButton 只显示接口名,描述用独立
  Text 右对齐,替代原 modelData + " - " + 描述 的连写形式
- 窗口高度 450 -> 390,字体颜色到卸载按钮的 fillHeight 改固定 4px 间距,
  消除原大段空白
- 标题栏拖动改用 startSystemMove() 系统级窗口拖动(参考 TrafficChartWindow),
  替代手动鼠标事件模拟,解决拖动抖动/不跟手问题
2026-07-20 23:10:20 +08:00
Jokul 02c6d055ce refactor: 抽取设置窗口到独立组件 package/components/SettingsWindow.qml
- 新建 SettingsWindow.qml(411 行):Window 根元素,对外属性 applet /
  networkInterfaces / activeInterface / accentColor,内部化 interfaceDescription
  函数、clipboardHelper、statusHideTimer,完整搬移标题栏、网络接口选择区、
  字体颜色选择区、卸载按钮、卸载确认对话框
- networkview.qml 从 1126 行缩减至 751 行:删除内联设置窗口及相关辅助,
  原位置实例化 SettingsWindow 并保留 id: settingsWindow(右键菜单引用无需改动)
- 模式参考 AboutWindow.qml:对外依赖通过 property 注入,组件可独立预览
2026-07-20 22:10:55 +08:00
Jokul 9a02f10eb8 fix: 设置弹框移除底部关闭按钮、禁止拖拽缩放、标题栏可拖动
- 移除底部「关闭」按钮(保留标题栏 × 按钮关闭窗口)
- 设置 minimumWidth/maximumWidth/minimumHeight/maximumHeight
  固定窗口尺寸 340x450,禁止拖拽改变宽高
- 标题栏 MouseArea 已有 drag.target: settingsWindow 拖动能力
2026-07-20 21:44:52 +08:00
Jokul 4c6bc6f689 feat: 设置窗口网络接口列表显示类型描述
- 新增 interfaceDescription 函数:根据接口名模式返回类型说明
  (有线/无线/VPN/容器/桥接/虚拟桥接/本地回环/虚拟接口/其他)
- RadioButton 文本从 modelData 改为 modelData + 描述(如 enp3s0 — 有线网络)
2026-07-20 21:37:54 +08:00
Jokul f7157e15e8 fix: 任务栏 ↑↓ 箭头颜色跟随字体颜色设置
- 横向/竖向布局共 4 处箭头 Text 的 color 从 root.secondaryText
  改为 root.speedTextColor,与网速数值统一受 textColor 属性控制
- 弹窗详情的标签色(secondaryText)保持不变,不受字体颜色设置影响
2026-07-20 21:30:42 +08:00
Jokul 22900737b0 feat: 设置窗口新增字体颜色选择,任务栏网速数值色可自定义并持久化
- 新增 TextColorPicker.qml 颜色选择器组件:预设 8 色 + 跟随系统选项,
  2px 深色边框高亮选中态,白色圆角卡片与设置窗口其他区视觉一致
- C++ 后端新增 textColor Q_PROPERTY:QString 存储,空串=跟随系统主题,
  非空用 QColor 校验,写入 ~/.config/jnetapplet/settings.ini 持久化
- networkview.qml 新增 speedTextColor 属性回退逻辑:applet.textColor
  优先,空串时回退到 DockPalette 派生的 primaryText(深浅色自适应)
- 任务栏 4 处数值 Text(横向/竖向 × 下载/上传)color 绑定改为
  speedTextColor;设置窗口网络接口区下方新增字体颜色选择区
2026-07-20 21:17:59 +08:00
Jokul ef37fbbcbf fix: build-deb.sh 显示完整 CMake 错误并预检构建依赖
- 新增 run_step 辅助函数:成功时仅显示末尾 3 行,失败时打印完整输出,
  解决原 | tail -3 管道吞掉 find_package 关键错误、用户无法定位缺包问题
- 新增 [1/6] 构建依赖预检:用 dpkg -s 检查 qt6-base-dev /
  qt6-declarative-dev / libdtkcommon-dev / libdde-shell-dev,缺失时
  直接给出 sudo apt install 命令并退出,步骤编号顺延为 [1/6]~[6/6]
- README 新增「构建依赖」小节:完整 apt install 命令 + 依赖包与
  find_package 对照表 + 运行时无需 -dev 包说明,删除原一句话提示
2026-07-20 20:12:53 +08:00
Jokul 959371c236 chore: 版本号升级到 1.0.1 2026-07-19 21:39:34 +08:00
Jokul d0b8272ec6 feat: 新增流量波动图窗口,展示活动接口最近 5 分钟网速趋势
- C++ 后端增加 SpeedSample 采样结构与每接口环形缓冲(MAX_HISTORY_SAMPLES=300,
  即 5 分钟/1Hz),通过 speedHistoryDownload/speedHistoryUpload 暴露为
  QVariantList of QPointF 供 QML 绘制;接口切换/消失时同步清理历史避免泄漏
- 新增 package/components/TrafficChartWindow.qml:纯 QML Canvas 绘制下载/上传
  双折线,无外部依赖;支持 hover 辅助线、置顶图钉、空状态兜底
- networkview.qml 右键菜单增加"流量波动图"入口,弹出屏幕居中独立窗口
2026-07-19 21:38:54 +08:00
Jokul c1a535a005 fix: version() 用 value("Version") 直接读取(DPluginMetaData 已展开 Plugin 层级) 2026-07-19 19:22:54 +08:00
Jokul c07f17d212 fix: version() 尝试两种元数据访问方式(直接/嵌套),加 qDebug 排查 2026-07-19 19:20:41 +08:00
Jokul fcb43fe34f feat: About 窗口版本号改为项目统一版本,仓库地址增加一键复制按钮 2026-07-19 19:13:13 +08:00
Jokul 6b2721c821 fix: About 窗口标题和副标题水平居中(Layout.alignment 在 ColumnLayout 内不可靠,改用 fillWidth+horizontalAlignment) 2026-07-19 18:36:32 +08:00
Jokul e83fb0e269 style: About 窗口改用 DTK 原生风格,键值对信息布局 2026-07-19 18:02:53 +08:00
Jokul 09e64d4859 docs: 同步项目结构图,补充 components/AboutWindow.qml 2026-07-19 17:32:17 +08:00
Jokul d53409808c refactor: 抽取 About 窗口到独立 QML 组件 package/components/AboutWindow.qml 2026-07-19 17:31:42 +08:00
Jokul 0f58dcad03 refactor: C++ 后端文件移入 src/ 目录,优化项目结构层级 2026-07-19 17:02:45 +08:00
Jokul 6325fb51cc fix: install.sh 清理 QML 磁盘缓存和安装目录残留文件 2026-07-19 16:43:55 +08:00
Jokul 6bc1a3e3fb fix:ipv6背景色 2026-07-19 14:03:14 +08:00
Jokul 903e0f1801 refactor: 统一版本号管理,metadata.json 由 CMake 模板生成 2026-07-19 13:44:42 +08:00
Jokul 3c92359407 feat: 任务栏面板项网速数值统一反色并增大字号 2026-07-19 13:32:13 +08:00
Jokul 170e602945 fix: 竖向布局箭头顶部对齐,数值变化时向下拓展 2026-07-19 13:21:36 +08:00
Jokul 02ac3faf85 fix: 收紧竖向布局字符间距 2026-07-19 13:16:49 +08:00
Jokul b80769c26e fix: 竖向布局字符居中并加高以容纳长速度字符串 2026-07-19 13:08:00 +08:00
Jokul 790103698e feat: 竖向任务栏新增双列字符竖排布局,解决宽度溢出导致的裁切和图标错位 2026-07-19 13:01:44 +08:00
Jokul 2beafd4cf1 feat: 前端 tooltip 与弹框新增 IPv6 地址显示 2026-07-19 12:09:04 +08:00
Jokul f7281a5f0c feat: 后端新增 ipv6Address 属性并扩展 detectIpAddress 同时检测 IPv4/IPv6 2026-07-19 12:03:06 +08:00
Jokul 43cb5238c8 chore: 忽略 docs/superpowers 内部文档目录 2026-07-18 19:53:17 +08:00
Jokul ea2081f0a6 remove 2026-07-18 19:51:24 +08:00
Jokul ab1a5227fa docs: 更新 README 与 AGENTS 说明,补充 deb 打包与最新功能 2026-07-18 19:48:31 +08:00
Jokul 8f5e30532d Merge pull request 'Feature/ui redesign' (#1) from feature/ui-redesign into master
Reviewed-on: #1
2026-07-18 19:39:03 +08:00
Jokul 9b9c26d5d1 feat: 增加 deb 一键打包脚本
- build-deb.sh:自动构建、staging 安装、生成 control、打包 deb
- 从 CMakeLists.txt 读取版本号,自动检测架构
- postinst/prerm 脚本以桌面用户身份重启 dde-shell
- 依赖声明:dde-shell, Qt6 Core/Gui/Quick/Network, DTK6 Core
2026-07-18 19:36:10 +08:00
Jokul 6142dc4769 feat: 优化设置/关于窗口与交互体验
- 设置窗口:frameless 自定义标题栏,网卡接口选择,卸载插件功能
- 卸载流程:复制命令到剪贴板,按钮文字临时显示提示并禁用点击
- 关于窗口:独立 Window 在桌面居中,展示作者信息
- 右键菜单:删除刷新项,精简为设置/关于
- 网卡列表:活动接口优先排序,超出宽度水平滚动
- 弹出面板:网卡名+IP 卡片背景,双列速度居中
- 修复 tooltip 数值变 0、右键菜单不展示等问题
2026-07-18 19:25:07 +08:00
Jokul 009dd9578f feat: 重新设计插件 UI 样式与交互
- 任务栏图标:双行数值显示(下载蓝/上传绿),固定宽度避免抖动
- 左键弹出面板:360×320 居中布局,双列速度卡片,网卡名+IP 卡片背景
- 右键菜单:deepin 风格 Platform.Menu + MenuHelper(刷新/设置/关于)
- 设置窗口:frameless 自定义标题栏,网卡接口选择,卸载插件功能
- Tooltip:网卡名 + IP 地址单行展示
- C++ 后端:新增 ipAddress 属性(QNetworkInterface),优先选择物理网卡
- 速度格式化:最小单位 KB,2 位小数
- 移除 QML 侧多余 refresh 调用,修复 tooltip 数值变 0
- 新增设计文档与实现计划
2026-07-18 18:44:12 +08:00
16 changed files with 2974 additions and 687 deletions
+3
View File
@@ -23,3 +23,6 @@ cmake-build-*/
# System files # System files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# AI 工作流内部文档(不纳入版本控制)
docs/superpowers/
+35 -8
View File
@@ -1,7 +1,7 @@
# AGENTS.md # AGENTS.md
Guidance for AI agents working in this repo. Verified against `CMakeLists.txt`, Guidance for AI agents working in this repo. Verified against `CMakeLists.txt`,
`package/metadata.json`, and the installed macro file at `package/metadata.json.in`, and the installed macro file at
`/usr/lib/x86_64-linux-gnu/cmake/DDEShell/DDEShellPackageMacros.cmake`. `/usr/lib/x86_64-linux-gnu/cmake/DDEShell/DDEShellPackageMacros.cmake`.
## What this is ## What this is
@@ -21,6 +21,8 @@ for the UI. The C++ backend reads `/proc/net/dev` to monitor network traffic.
- Taskbar icon displays live speed (changes color at high speeds) - Taskbar icon displays live speed (changes color at high speeds)
- Hover tooltip shows speed summary - Hover tooltip shows speed summary
- Click to open detailed popup - Click to open detailed popup
- Popup shows active interface IP address, per-interface speed comparison and stats
- One-click plugin uninstall from the popup
## Build & install ## Build & install
@@ -42,6 +44,14 @@ bash install.sh
`cmake --install` needs `sudo` because the default `DDE_SHELL_PACKAGE_INSTALL_DIR` `cmake --install` needs `sudo` because the default `DDE_SHELL_PACKAGE_INSTALL_DIR`
is `/usr/share/dde-shell` (a CMake CACHE variable on this system). is `/usr/share/dde-shell` (a CMake CACHE variable on this system).
### Deb packaging
`build-deb.sh` builds a `.deb` from the version declared in `CMakeLists.txt`:
```sh
bash build-deb.sh # -> jnetapplet_<version>_<arch>.deb
```
After installation, restart dde-shell: After installation, restart dde-shell:
```sh ```sh
@@ -57,11 +67,16 @@ then restart `dde-shell` (it discovers applets by scanning the install dir for
``` ```
├── CMakeLists.txt # Build configuration ├── CMakeLists.txt # Build configuration
├── install.sh # Install script ├── install.sh # Install script
├── networkmonitorapplet.h # C++ backend header ├── build-deb.sh # Deb packaging script
├── networkmonitorapplet.cpp # C++ backend implementation ├── src/
│ ├── networkmonitorapplet.h # C++ backend header
│ └── networkmonitorapplet.cpp # C++ backend implementation
├── package/ ├── package/
│ ├── metadata.json # Plugin metadata │ ├── metadata.json.in # Plugin metadata 模板(由 CMake 生成 metadata.json
── networkview.qml # QML UI ── networkview.qml # QML UI 主入口
│ └── components/ # 拆分出的子组件
│ └── AboutWindow.qml # 关于窗口组件
├── docs/ # Design docs and implementation plans
└── AGENTS.md # This file └── AGENTS.md # This file
``` ```
@@ -90,6 +105,7 @@ Inherits from `DApplet`, provides:
- `networkInterfaces`: List of available interfaces - `networkInterfaces`: List of available interfaces
- `interfaceStats`: Per-interface statistics - `interfaceStats`: Per-interface statistics
- `activeInterface`: Currently selected interface - `activeInterface`: Currently selected interface
- `ipAddress`: IP address of the active interface
- `refresh()`: Manually trigger stats update - `refresh()`: Manually trigger stats update
- `setActiveInterface(name)`: Switch active interface - `setActiveInterface(name)`: Switch active interface
@@ -99,15 +115,26 @@ Inherits from `DApplet`, provides:
|---|---|---| |---|---|---|
| `CMakeLists.txt` | `add_library(...)` target | `space.jokul.JNetApplet` | | `CMakeLists.txt` | `add_library(...)` target | `space.jokul.JNetApplet` |
| `CMakeLists.txt` | `PLUGIN_ID` | `space.jokul.JNetApplet` | | `CMakeLists.txt` | `PLUGIN_ID` | `space.jokul.JNetApplet` |
| `package/metadata.json` | `Plugin.Id` | `space.jokul.JNetApplet` | | `package/metadata.json.in` | `Plugin.Id` | `space.jokul.JNetApplet` |
`Plugin.Url` (`networkview.qml`) is a path **relative to `package/`**, not an identifier. `Plugin.Url` (`networkview.qml`) is a path **relative to `package/`**, not an identifier.
Keep it pointing at the entry QML. Keep it pointing at the entry QML.
## Required metadata fields (QML applet) ## Required metadata fields (QML applet)
`metadata.json` must contain `Plugin.Version`, `Plugin.Id`, `Plugin.Url`, and `package/metadata.json.in` is the template; CMake's `configure_file()` generates the
`Plugin.Parent`. The current file is complete — don't drop any. final `metadata.json` in the build directory at configure time. The template must
contain `Plugin.Version`, `Plugin.Id`, `Plugin.Url`, and `Plugin.Parent`. Don't drop any.
## Version management
**Single source of truth**: `CMakeLists.txt` line 3 `project(JNetApplet VERSION x.y.z)`.
- `package/metadata.json.in` uses `@PROJECT_VERSION@` placeholder, substituted at
configure time -> installed `metadata.json` always matches.
- `build-deb.sh` extracts the version directly from `CMakeLists.txt` via grep.
- **To bump the version**: edit only the `project(... VERSION ...)` line in
`CMakeLists.txt`. All downstream consumers (install, deb) pick it up automatically.
## Conventions ## Conventions
+18 -6
View File
@@ -1,12 +1,12 @@
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.16)
project(JNetApplet VERSION 1.0.0 LANGUAGES CXX) project(JNetApplet VERSION 1.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOMOC ON)
find_package(Qt6 REQUIRED COMPONENTS Core Quick DBus) find_package(Qt6 REQUIRED COMPONENTS Core Quick DBus Network)
find_package(Dtk6 REQUIRED COMPONENTS Core) find_package(Dtk6 REQUIRED COMPONENTS Core)
find_package(DDEShell REQUIRED) find_package(DDEShell REQUIRED)
@@ -14,14 +14,15 @@ find_package(DDEShell REQUIRED)
set(PLUGIN_ID "space.jokul.JNetApplet") set(PLUGIN_ID "space.jokul.JNetApplet")
add_library(space.jokul.JNetApplet SHARED add_library(space.jokul.JNetApplet SHARED
networkmonitorapplet.h src/networkmonitorapplet.h
networkmonitorapplet.cpp src/networkmonitorapplet.cpp
) )
set_target_properties(space.jokul.JNetApplet PROPERTIES PREFIX "") set_target_properties(space.jokul.JNetApplet PROPERTIES PREFIX "")
# 头文件搜索路径指向 src/,使 #include "networkmonitorapplet.h" 可被外部解析
target_include_directories(space.jokul.JNetApplet PRIVATE target_include_directories(space.jokul.JNetApplet PRIVATE
${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/src
/usr/include/dde-shell /usr/include/dde-shell
) )
@@ -29,11 +30,22 @@ target_link_libraries(space.jokul.JNetApplet PRIVATE
Qt6::Core Qt6::Core
Qt6::Quick Qt6::Quick
Qt6::DBus Qt6::DBus
Qt6::Network
Dtk6::Core Dtk6::Core
Dde::Shell Dde::Shell
) )
# 安装到dde-shell插件目录 # 安装到dde-shell插件目录
install(TARGETS space.jokul.JNetApplet DESTINATION /usr/lib/x86_64-linux-gnu/dde-shell) install(TARGETS space.jokul.JNetApplet DESTINATION /usr/lib/x86_64-linux-gnu/dde-shell)
install(FILES package/metadata.json DESTINATION /usr/share/dde-shell/${PLUGIN_ID})
# 由模板生成 metadata.json,版本号从 project() 取,避免多处手动维护
# 设计原因:版本号唯一源为 CMakeLists.txt 顶部的 project(... VERSION)
# configure_file 在构建目录生成最终 metadata.jsoninstall 安装生成文件
configure_file(package/metadata.json.in ${CMAKE_CURRENT_BINARY_DIR}/metadata.json @ONLY)
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/metadata.json DESTINATION /usr/share/dde-shell/${PLUGIN_ID})
install(FILES package/networkview.qml DESTINATION /usr/share/dde-shell/${PLUGIN_ID}) install(FILES package/networkview.qml DESTINATION /usr/share/dde-shell/${PLUGIN_ID})
# 安装 components 子目录下所有 QML 组件
# 设计原因:拆分出的子组件需随主 QML 一起安装到 dde-shell 插件目录,
# 否则 networkview.qml 的 import "components" 在运行时找不到类型
install(DIRECTORY package/components DESTINATION /usr/share/dde-shell/${PLUGIN_ID})
+94
View File
@@ -1,2 +1,96 @@
# JNetApplet # JNetApplet
基于 dde-shell 的**深度系统任务栏网络速率监控插件**,使用 C++ 后端采集数据、
QML 前端渲染界面,实时显示当前网卡的上行/下行速率与累计流量。
- 插件 ID`space.jokul.JNetApplet`
- 适用环境:deepin 桌面环境(dde-shell 面板)
- 数据源:读取 `/proc/net/dev` 计算网络流量
## 功能特性
- 实时上行/下行速率监控(1 秒刷新)
- 累计上传/下载流量统计
- 多网卡支持,可在弹窗中切换活动网卡
- 任务栏图标实时显示速率,高速时变色提示
- 悬停显示速率摘要,点击展开详情弹窗
- 详情弹窗展示活动网卡 IP 地址、各网卡速率对比与统计图表
- 支持从弹窗一键卸载插件
## 构建与安装
### 构建依赖
构建需要以下开发包:
```sh
sudo apt install build-essential cmake pkg-config qt6-base-dev qt6-declarative-dev libdtkcommon-dev libdde-shell-dev
```
各包与 CMake `find_package` 的对应关系:
| 依赖包 | 提供 | 对应 CMake find_package |
|---|---|---|
| qt6-base-dev | Qt6 Core / DBus / Network | Qt6 Core DBus Network |
| qt6-declarative-dev | Qt6 Quick | Qt6 Quick |
| libdtkcommon-dev | Dtk6 Core | Dtk6 Core |
| libdde-shell-dev | DDEShell | DDEShell |
> 运行时仅需 `dde-shell` 及 Qt6/Dtk6 运行库,上述 `-dev` 包仅构建时需要。
### 方式一:从源码安装
```sh
cmake -B build
cmake --build build
sudo cmake --install build # -> /usr/share/dde-shell/space.jokul.JNetApplet/
```
或使用安装脚本:
```sh
bash install.sh
```
`cmake --install` 需要 `sudo`,因为默认 `DDE_SHELL_PACKAGE_INSTALL_DIR`
`/usr/share/dde-shell`(本机 CMake CACHE 变量)。
安装后重启 dde-shell 使插件生效:
```sh
systemctl --user restart dde-shell@DDE
```
### 方式二:打包为 deb 安装
仓库提供 `build-deb.sh` 一键打包脚本,会从 `CMakeLists.txt` 读取版本号并构建 deb 包:
```sh
bash build-deb.sh
```
产物为 `jnetapplet_<version>_<arch>.deb`,可直接用 `dpkg -i` 安装。
## 验证
项目无测试 / lint / typecheck / CI 流程,验证方式为手动:安装后重启 `dde-shell`
(其通过扫描安装目录中的 `metadata.json` 发现插件)。
## 项目结构
```
├── CMakeLists.txt # 构建配置
├── install.sh # 安装脚本
├── build-deb.sh # deb 一键打包脚本
├── src/
│ ├── networkmonitorapplet.h # C++ 后端头文件
│ └── networkmonitorapplet.cpp # C++ 后端实现
├── package/
│ ├── metadata.json.in # 插件元数据模板(由 CMake 生成 metadata.json
│ ├── networkview.qml # QML 界面主入口
│ └── components/ # 拆分出的子组件
│ └── AboutWindow.qml # 关于窗口组件
├── docs/ # 设计文档与实现计划
├── README.md # 本文件
└── AGENTS.md # AI 代理工作指引
```
Executable
+168
View File
@@ -0,0 +1,168 @@
#!/bin/bash
# JNetApplet deb 一键打包脚本
# 用法:bash build-deb.sh
# 产物:jnetapplet_<version>_<arch>.deb
set -e
# 运行构建步骤:成功时仅显示末尾 3 行,失败时打印完整输出后返回非零
# 设计原因:原脚本用 | tail -3 管道会吞掉 find_package 等关键错误,
# 导致缺依赖时用户看不到真正原因(CMake 只在末尾报 Configuring incomplete
run_step() {
local desc="$1"; shift
local log
log=$(mktemp)
if "$@" > "$log" 2>&1; then
tail -3 "$log"
rm -f "$log"
return 0
else
echo "错误:${desc}失败,完整输出如下:" >&2
cat "$log" >&2
rm -f "$log"
return 1
fi
}
# 项目根目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# 从 CMakeLists.txt 读取版本号
VERSION=$(grep -oP 'project\([^)]*VERSION\s+\K[0-9]+\.[0-9]+\.[0-9]+' CMakeLists.txt)
if [ -z "$VERSION" ]; then
echo "错误:无法从 CMakeLists.txt 读取版本号"
exit 1
fi
# 架构判断
ARCH=$(dpkg --print-architecture)
if [ -z "$ARCH" ]; then
ARCH="amd64"
fi
PKG_NAME="jnetapplet"
DEB_FILE="${PKG_NAME}_${VERSION}_${ARCH}.deb"
echo "=========================================="
echo " JNetApplet deb 打包"
echo " 版本: $VERSION"
echo " 架构: $ARCH"
echo " 产物: $DEB_FILE"
echo "=========================================="
# 检查依赖工具
for cmd in cmake dpkg-deb; do
if ! command -v "$cmd" &> /dev/null; then
echo "错误:缺少 $cmd,请先安装"
exit 1
fi
done
echo "[1/6] 检查构建依赖..."
# 检查构建依赖:确认开发包已安装,缺失时提示安装命令并退出
# 设计原因:用户 clone 后直接运行 build-deb.sh 经常因缺少 -dev 包导致
# CMake 配置失败,但原脚本用 tail -3 吞掉关键错误,用户无法定位问题。
# 在构建前主动预检可提前给出明确指引。
DEPS=(qt6-base-dev qt6-declarative-dev libdtkcommon-dev libdde-shell-dev)
MISSING=()
for pkg in "${DEPS[@]}"; do
if ! dpkg -s "$pkg" &> /dev/null 2>&1; then
MISSING+=("$pkg")
fi
done
if [ ${#MISSING[@]} -gt 0 ]; then
echo "错误:缺少构建依赖包:${MISSING[*]}" >&2
echo "请执行以下命令安装:" >&2
echo " sudo apt install ${MISSING[*]} build-essential cmake" >&2
exit 1
fi
echo "所有构建依赖已就绪"
# 清理并创建构建目录
echo "[2/6] 构建 CMake 项目..."
rm -rf build
run_step "CMake 配置" cmake -B build -S . -DCMAKE_BUILD_TYPE=Release || exit 1
run_step "CMake 构建" cmake --build build -j"$(nproc)" || exit 1
# 创建 staging 目录(模拟安装根目录)
STAGE_DIR="$(mktemp -d)"
trap "rm -rf '$STAGE_DIR'" EXIT
echo "[3/6] 安装到 staging 目录..."
# DESTDIR 指定安装根目录前缀,cmake 会将 /usr/... 安装到 $STAGE_DIR/usr/...
run_step "安装到 staging" env DESTDIR="$STAGE_DIR" cmake --install build || exit 1
# 生成 DEBIAN/control
echo "[4/6] 生成 control 文件..."
mkdir -p "$STAGE_DIR/DEBIAN"
# 计算安装后大小(KB
INSTALLED_SIZE=$(du -sk "$STAGE_DIR/usr" | cut -f1)
cat > "$STAGE_DIR/DEBIAN/control" << EOF
Package: ${PKG_NAME}
Version: ${VERSION}
Architecture: ${ARCH}
Maintainer: Jokul <jokul@git.jokul.space>
Installed-Size: ${INSTALLED_SIZE}
Depends: dde-shell, libc6, libqt6core6, libqt6gui6, libqt6quick6, libqt6network6, libdtk6core
Section: utils
Priority: optional
Description: 网络速度监控 dde-shell 任务栏插件
实时监控网络下载/上传速度,支持多网卡切换、IP 地址显示、
总流量统计。适用于 deepin 桌面环境的 dde-shell 任务栏。
Homepage: https://git.jokul.space/Jokul/JNetApplet
EOF
# 生成 postinst 脚本:安装后重启桌面用户的 dde-shell
# 注意:dpkg 以 root 运行此脚本,systemctl --user 需切换到桌面用户身份
cat > "$STAGE_DIR/DEBIAN/postinst" << 'EOF'
#!/bin/bash
set -e
# 找到活跃桌面用户(UID >= 1000),以其身份重启 dde-shell 用户服务
for user in $(who | awk '{print $1}' | sort -u); do
[ "$user" = "root" ] && continue
uid=$(id -u "$user" 2>/dev/null) || continue
[ "$uid" -lt 1000 ] && continue
su "$user" -c "systemctl --user restart dde-shell@DDE" 2>/dev/null || true
done
exit 0
EOF
chmod 755 "$STAGE_DIR/DEBIAN/postinst"
# 生成 prerm 脚本:卸载前重启桌面用户的 dde-shell 移除插件
cat > "$STAGE_DIR/DEBIAN/prerm" << 'EOF'
#!/bin/bash
set -e
# 以桌面用户身份重启 dde-shell 以卸载插件
for user in $(who | awk '{print $1}' | sort -u); do
[ "$user" = "root" ] && continue
uid=$(id -u "$user" 2>/dev/null) || continue
[ "$uid" -lt 1000 ] && continue
su "$user" -c "systemctl --user restart dde-shell@DDE" 2>/dev/null || true
done
exit 0
EOF
chmod 755 "$STAGE_DIR/DEBIAN/prerm"
# 修正文件权限(so 文件需要 755qml/json 需要 644
echo "[5/6] 修正文件权限..."
find "$STAGE_DIR/usr" -type f -name "*.so" -exec chmod 755 {} \;
find "$STAGE_DIR/usr" -type f \( -name "*.qml" -o -name "*.json" \) -exec chmod 644 {} \;
# 构建 deb
echo "[6/6] 构建 deb 包..."
rm -f "$DEB_FILE"
dpkg-deb --build --root-owner-group "$STAGE_DIR" "$DEB_FILE"
echo ""
echo "=========================================="
echo " 打包完成!"
echo " 产物: $DEB_FILE ($(du -h "$DEB_FILE" | cut -f1))"
echo ""
echo " 安装: sudo dpkg -i $DEB_FILE"
echo " 卸载: sudo dpkg -r $PKG_NAME"
echo "=========================================="
+37 -11
View File
@@ -4,23 +4,49 @@
set -e set -e
echo "Building JNetApplet..." INSTALL_DIR="/usr/share/dde-shell/space.jokul.JNetApplet"
SO_DIR="/usr/lib/x86_64-linux-gnu/dde-shell"
# 清理旧的构建目录 echo "=== JNetApplet 安装脚本 ==="
# 1. 清理旧的构建目录
echo "[1/6] 清理构建目录..."
rm -rf build rm -rf build
# 配置CMake # 2. 配置 + 构建
echo "[2/6] 配置 CMake 并构建..."
cmake -B build cmake -B build
# 构建
cmake --build build cmake --build build
echo "Installing JNetApplet (requires sudo)..." # 3. 清理安装目录中残留的旧文件(包括之前重构产生的多余 QML 文件)
echo "[3/6] 清理安装目录中的旧文件..."
if [ -d "$INSTALL_DIR" ]; then
sudo rm -f "$INSTALL_DIR"/*.qml "$INSTALL_DIR"/*.qmlc
fi
# 安装 # 4. 清理 Qt QML 磁盘缓存(关键!否则 dde-shell 可能使用旧的编译缓存)
echo "[4/6] 清理 QML 磁盘缓存..."
rm -rf ~/.cache/preloader/qmlcache/ 2>/dev/null || true
find ~/.cache -path "*/dde-shell*" -name "*.qmlc" -delete 2>/dev/null || true
# 5. 安装
echo "[5/6] 安装插件(需要 sudo..."
sudo cmake --install build sudo cmake --install build
echo "Installation complete!" # 验证安装结果
echo "Restarting dde-shell..." echo ""
systemctl --user restart dde-shell@DDE echo "=== 安装验证 ==="
echo "Done! Plugin loaded." echo "QML 文件:"
ls -la "$INSTALL_DIR"/*.qml 2>/dev/null || echo " [警告] 未找到 QML 文件!"
echo "动态库:"
ls -la "$SO_DIR"/space.jokul.JNetApplet.so 2>/dev/null || echo " [警告] 未找到 .so 文件!"
echo "metadata.json 版本:"
grep '"Version"' "$INSTALL_DIR/metadata.json" 2>/dev/null || echo " [警告] 未找到 metadata.json"
echo ""
# 6. 重启 dde-shell
echo "[6/6] 重启 dde-shell..."
systemctl --user stop dde-shell@DDE 2>/dev/null || true
sleep 1
systemctl --user start dde-shell@DDE
echo "完成!插件已加载。"
-262
View File
@@ -1,262 +0,0 @@
// SPDX-FileCopyrightText: 2024 MyCompany
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#include "networkmonitorapplet.h"
#include <pluginfactory.h>
#include <QFile>
#include <QTextStream>
#include <QDebug>
#include <QDir>
#include <QRegularExpression>
DS_BEGIN_NAMESPACE
NetworkMonitorApplet::NetworkMonitorApplet(QObject *parent)
: DApplet(parent)
, m_refreshTimer(nullptr)
, m_lastRxBytes(0)
, m_lastTxBytes(0)
, m_downloadSpeed(0)
, m_uploadSpeed(0)
, m_totalDownload(0)
, m_totalUpload(0)
, m_ready(false)
, m_firstUpdate(true)
{
}
NetworkMonitorApplet::~NetworkMonitorApplet()
{
}
bool NetworkMonitorApplet::load()
{
return DApplet::load();
}
bool NetworkMonitorApplet::init()
{
detectInterfaces();
// 启动定时刷新
m_refreshTimer = new QTimer(this);
connect(m_refreshTimer, &QTimer::timeout, this, &NetworkMonitorApplet::refresh);
m_refreshTimer->start(1000); // 每秒刷新一次
// 初始读取
readNetworkStats();
return DApplet::init();
}
double NetworkMonitorApplet::downloadSpeed() const
{
return static_cast<double>(m_downloadSpeed);
}
double NetworkMonitorApplet::uploadSpeed() const
{
return static_cast<double>(m_uploadSpeed);
}
double NetworkMonitorApplet::totalDownload() const
{
return static_cast<double>(m_totalDownload);
}
double NetworkMonitorApplet::totalUpload() const
{
return static_cast<double>(m_totalUpload);
}
QStringList NetworkMonitorApplet::networkInterfaces() const
{
return m_interfaceList;
}
QStringList NetworkMonitorApplet::interfaceStats() const
{
QStringList stats;
for (const QString &name : m_interfaceList) {
if (m_interfaces.contains(name)) {
const NetworkInterface &iface = m_interfaces[name];
stats << QString("%1|%2|%3|%4|%5")
.arg(iface.name)
.arg(iface.rxBytes)
.arg(iface.txBytes)
.arg(iface.rxPackets)
.arg(iface.txPackets);
}
}
return stats;
}
bool NetworkMonitorApplet::ready() const
{
return m_ready;
}
QString NetworkMonitorApplet::activeInterface() const
{
return m_activeInterface;
}
void NetworkMonitorApplet::refresh()
{
readNetworkStats();
}
void NetworkMonitorApplet::setActiveInterface(const QString &interface)
{
if (m_activeInterface != interface) {
m_activeInterface = interface;
m_firstUpdate = true;
emit activeInterfaceChanged();
}
}
void NetworkMonitorApplet::readNetworkStats()
{
QFile file("/proc/net/dev");
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
qDebug() << "Failed to open /proc/net/dev";
return;
}
QTextStream stream(&file);
QStringList lines = stream.readAll().split('\n', Qt::SkipEmptyParts);
file.close();
QMap<QString, NetworkInterface> newInterfaces;
// 跳过前两行标题
for (int i = 2; i < lines.size(); ++i) {
QString line = lines[i].trimmed();
if (line.isEmpty()) continue;
// 格式: "interface: rx_bytes rx_packets rx_errors rx_dropped ... tx_bytes tx_packets tx_errors tx_dropped ..."
QRegularExpression re("^([^:]+):\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+\\d+\\s+\\d+\\s+\\d+\\s+\\d+\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)");
QRegularExpressionMatch match = re.match(line);
if (match.hasMatch()) {
NetworkInterface iface;
iface.name = match.captured(1);
iface.rxBytes = match.captured(2).toLongLong();
iface.rxPackets = match.captured(3).toLongLong();
iface.rxErrors = match.captured(4).toLongLong();
iface.rxDropped = match.captured(5).toLongLong();
iface.txBytes = match.captured(6).toLongLong();
iface.txPackets = match.captured(7).toLongLong();
iface.txErrors = match.captured(8).toLongLong();
iface.txDropped = match.captured(9).toLongLong();
// 过滤掉回环接口和虚拟接口
if (iface.name != "lo" && !iface.name.startsWith("veth") &&
!iface.name.startsWith("docker") && !iface.name.startsWith("br-")) {
newInterfaces[iface.name] = iface;
}
}
}
// 检测接口变化
if (newInterfaces.keys() != m_interfaces.keys()) {
m_interfaceList = newInterfaces.keys();
m_interfaces = newInterfaces;
// 自动选择活动接口
if (m_activeInterface.isEmpty() && !m_interfaceList.isEmpty()) {
// 优先选择有流量的接口
for (const QString &name : m_interfaceList) {
const NetworkInterface &iface = m_interfaces[name];
if (iface.rxBytes > 0 || iface.txBytes > 0) {
m_activeInterface = name;
break;
}
}
if (m_activeInterface.isEmpty()) {
m_activeInterface = m_interfaceList.first();
}
emit activeInterfaceChanged();
}
m_ready = !m_interfaceList.isEmpty();
emit interfacesChanged();
emit readyChanged();
} else {
m_interfaces = newInterfaces;
}
calculateSpeed();
emit statsChanged();
}
void NetworkMonitorApplet::calculateSpeed()
{
qint64 currentRxBytes = getActiveRxBytes();
qint64 currentTxBytes = getActiveTxBytes();
if (m_firstUpdate) {
m_firstUpdate = false;
m_lastRxBytes = currentRxBytes;
m_lastTxBytes = currentTxBytes;
return;
}
// 计算速度(字节/秒)
m_downloadSpeed = currentRxBytes - m_lastRxBytes;
m_uploadSpeed = currentTxBytes - m_lastTxBytes;
// 更新总量
m_totalDownload = currentRxBytes;
m_totalUpload = currentTxBytes;
m_lastRxBytes = currentRxBytes;
m_lastTxBytes = currentTxBytes;
emit speedChanged();
emit totalChanged();
}
void NetworkMonitorApplet::detectInterfaces()
{
QDir sysClass("/sys/class/net");
if (!sysClass.exists()) return;
QStringList entries = sysClass.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
m_interfaceList.clear();
for (const QString &entry : entries) {
// 过滤回环接口
if (entry != "lo") {
m_interfaceList << entry;
}
}
m_ready = !m_interfaceList.isEmpty();
emit interfacesChanged();
emit readyChanged();
}
qint64 NetworkMonitorApplet::getActiveRxBytes() const
{
if (m_activeInterface.isEmpty() || !m_interfaces.contains(m_activeInterface)) {
return 0;
}
return m_interfaces[m_activeInterface].rxBytes;
}
qint64 NetworkMonitorApplet::getActiveTxBytes() const
{
if (m_activeInterface.isEmpty() || !m_interfaces.contains(m_activeInterface)) {
return 0;
}
return m_interfaces[m_activeInterface].txBytes;
}
D_APPLET_CLASS(NetworkMonitorApplet)
DS_END_NAMESPACE
#include "networkmonitorapplet.moc"
-95
View File
@@ -1,95 +0,0 @@
// SPDX-FileCopyrightText: 2024 MyCompany
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#pragma once
#include <applet.h>
#include <QString>
#include <QStringList>
#include <QTimer>
#include <QVariantList>
#include <QMap>
DS_BEGIN_NAMESPACE
struct NetworkInterface {
QString name; // 接口名称,如 "eth0", "wlan0"
qint64 rxBytes; // 接收字节数
qint64 txBytes; // 发送字节数
qint64 rxPackets; // 接收包数
qint64 txPackets; // 发送包数
qint64 rxErrors; // 接收错误数
qint64 txErrors; // 发送错误数
qint64 rxDropped; // 接收丢弃数
qint64 txDropped; // 发送丢弃数
};
class NetworkMonitorApplet : public DApplet
{
Q_OBJECT
Q_PROPERTY(double downloadSpeed READ downloadSpeed NOTIFY speedChanged)
Q_PROPERTY(double uploadSpeed READ uploadSpeed NOTIFY speedChanged)
Q_PROPERTY(double totalDownload READ totalDownload NOTIFY totalChanged)
Q_PROPERTY(double totalUpload READ totalUpload NOTIFY totalChanged)
Q_PROPERTY(QStringList networkInterfaces READ networkInterfaces NOTIFY interfacesChanged)
Q_PROPERTY(QStringList interfaceStats READ interfaceStats NOTIFY statsChanged)
Q_PROPERTY(bool ready READ ready NOTIFY readyChanged)
Q_PROPERTY(QString activeInterface READ activeInterface NOTIFY activeInterfaceChanged)
public:
explicit NetworkMonitorApplet(QObject *parent = nullptr);
~NetworkMonitorApplet();
virtual bool load() override;
virtual bool init() override;
double downloadSpeed() const;
double uploadSpeed() const;
double totalDownload() const;
double totalUpload() const;
QStringList networkInterfaces() const;
QStringList interfaceStats() const;
bool ready() const;
QString activeInterface() const;
Q_INVOKABLE void refresh();
Q_INVOKABLE void setActiveInterface(const QString &interface);
signals:
void speedChanged();
void totalChanged();
void interfacesChanged();
void statsChanged();
void readyChanged();
void activeInterfaceChanged();
private:
void readNetworkStats();
void calculateSpeed();
void detectInterfaces();
qint64 getActiveRxBytes() const;
qint64 getActiveTxBytes() const;
QTimer *m_refreshTimer;
QMap<QString, NetworkInterface> m_interfaces;
QStringList m_interfaceList;
QString m_activeInterface;
// 速度计算
qint64 m_lastRxBytes;
qint64 m_lastTxBytes;
qint64 m_downloadSpeed;
qint64 m_uploadSpeed;
// 总量
qint64 m_totalDownload;
qint64 m_totalUpload;
bool m_ready;
bool m_firstUpdate;
};
DS_END_NAMESPACE
+344
View File
@@ -0,0 +1,344 @@
// SPDX-FileCopyrightText: 2024 MyCompany
//
// SPDX-License-Identifier: LGPL-3.0-or-later
// 关于窗口:DTK 原生风格的独立顶层窗口,桌面居中弹出,展示插件信息
// 设计要点:白色圆角卡片 + 键值对信息布局,信息标签使用 deepin 蓝(#0081FF),
// 关闭按钮 hover 高亮色由父组件通过 accentColor 传入(红色)
// 对外依赖:accentColor,由 networkview.qml 实例化时传入,触发方式为 show()/raise()/requestActivate()
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Window 2.15
Window {
id: root
// 对外依赖:关闭按钮 hover 态文字高亮色,由父组件传入
// 默认值与 networkview.qml 中 root.accentRed 一致,确保独立可用
property color accentColor: Qt.rgba(220 / 255, 38 / 255, 38 / 255, 1)
// 插件版本号,由父组件从 C++ 后端 applet.version 传入
// 默认值保证组件独立可用(如 qmlscene 预览时未传入)
property string version: "1.0"
// 复制成功状态标记:true 时复制图标变绿并显示"已复制"提示,
// 2 秒后由 copyResetTimer 复位
property bool copied: false
width: 320
height: 260
visible: false
flags: Qt.FramelessWindowHint | Qt.Window
modality: Qt.NonModal
color: "transparent"
onVisibleChanged: {
if (visible) {
x = (Screen.width - width) / 2
y = (Screen.height - height) / 2
}
}
// 窗口主体:白色圆角卡片,1px 浅灰边框模拟 DTK 窗口描边
Rectangle {
anchors.fill: parent
color: "#FFFFFF"
radius: 12
border.width: 1
border.color: "#E8E8E8"
ColumnLayout {
anchors.fill: parent
spacing: 0
// 自定义标题栏:左侧"关于"标题 + 右侧圆形关闭按钮,整栏可拖动窗口
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 44
color: "transparent"
// 拖动层:声明在关闭按钮之前(位于其下方),避免遮挡按钮点击与 hover
MouseArea {
anchors.fill: parent
drag.target: root
}
Text {
anchors.left: parent.left
anchors.leftMargin: 16
anchors.verticalCenter: parent.verticalCenter
text: qsTr("关于")
font.pixelSize: 15
font.weight: Font.Bold
color: "#333333"
}
// 关闭按钮:28x28 圆形,hover 时淡红底 + 红色 ×(颜色由 accentColor 决定)
Rectangle {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
radius: 14
color: aboutCloseMouse.containsMouse ? Qt.rgba(220 / 255, 38 / 255, 38 / 255, 0.15) : "transparent"
Text {
anchors.centerIn: parent
text: "×"
font.pixelSize: 20
font.weight: Font.Bold
color: aboutCloseMouse.containsMouse ? accentColor : "#666666"
}
MouseArea {
id: aboutCloseMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.hide()
}
}
}
// 标题区上方间距
Item {
Layout.preferredHeight: 12
}
// 标题区:插件中文名 + 英文名,整体居中
// 注意:用 Layout.fillWidth + horizontalAlignment 而非 Layout.alignment
// 后者在 ColumnLayout 内对 Text 不可靠(实测会左对齐贴边)
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
text: qsTr("网络速度监控")
font.pixelSize: 18
font.weight: Font.Bold
color: "#333333"
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
}
Text {
text: "JNetApplet"
font.pixelSize: 12
color: "#999999"
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
}
}
// 标题区下方间距
Item {
Layout.preferredHeight: 12
}
// 分隔线:左右留 24px 边距,与信息区对齐
Rectangle {
Layout.fillWidth: true
Layout.leftMargin: 24
Layout.rightMargin: 24
Layout.preferredHeight: 1
color: "#E8E8E8"
}
// 信息区上方间距
Item {
Layout.preferredHeight: 12
}
// 信息区:键值对布局,左侧标签固定 56px 宽(deepin 蓝),右侧为值
ColumnLayout {
Layout.fillWidth: true
Layout.leftMargin: 24
Layout.rightMargin: 24
spacing: 8
// 版本行
RowLayout {
Layout.fillWidth: true
spacing: 0
Text {
text: qsTr("版本")
font.pixelSize: 12
color: "#0081FF"
Layout.preferredWidth: 56
Layout.alignment: Qt.AlignTop
}
Text {
text: version
font.pixelSize: 12
color: "#333333"
Layout.fillWidth: true
}
}
// 作者行
RowLayout {
Layout.fillWidth: true
spacing: 0
Text {
text: qsTr("作者")
font.pixelSize: 12
color: "#0081FF"
Layout.preferredWidth: 56
Layout.alignment: Qt.AlignTop
}
Text {
text: "Jokul"
font.pixelSize: 12
color: "#333333"
Layout.fillWidth: true
}
}
// 描述行
RowLayout {
Layout.fillWidth: true
spacing: 0
Text {
text: qsTr("描述")
font.pixelSize: 12
color: "#0081FF"
Layout.preferredWidth: 56
Layout.alignment: Qt.AlignTop
}
Text {
text: qsTr("监控网络速度和流量")
font.pixelSize: 12
color: "#333333"
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
// 仓库行:URL 无空格,需 WrapAnywhere 才能在窄宽度下正确折行
RowLayout {
Layout.fillWidth: true
spacing: 0
Text {
text: qsTr("仓库")
font.pixelSize: 12
color: "#0081FF"
Layout.preferredWidth: 56
Layout.alignment: Qt.AlignTop
}
Text {
text: "git.jokul.space/Jokul/JNetApplet"
font.pixelSize: 11
color: "#999999"
Layout.fillWidth: true
wrapMode: Text.WrapAnywhere
}
// 复制按钮:点击复制仓库地址到剪贴板,成功后图标变绿并显示"已复制"提示 2 秒
Item {
Layout.preferredWidth: 24
Layout.preferredHeight: 24
Layout.alignment: Qt.AlignTop
// "已复制"提示:白底绿字小标签,显示在按钮左侧,
// 覆盖于 URL 文字上方,避免与值文本混排
Rectangle {
anchors.right: parent.left
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
visible: copied
width: copiedHintText.implicitWidth + 10
height: 18
radius: 4
color: "#FFFFFF"
border.width: 1
border.color: "#22A34A"
Text {
id: copiedHintText
anchors.centerIn: parent
text: qsTr("已复制")
font.pixelSize: 10
color: "#22A34A"
}
}
MouseArea {
id: copyRepoMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
clipboardHelper.text = "git.jokul.space/Jokul/JNetApplet"
clipboardHelper.selectAll()
clipboardHelper.copy()
copied = true
copyResetTimer.restart()
}
}
// 复制图标:两个重叠的小圆角矩形
// 前层矩形白底压住后层边框重叠区,形成"堆叠纸张"的视觉效果
// 后层矩形(向右下偏移 2px
Rectangle {
x: 8
y: 8
width: 10
height: 10
radius: 2
color: "#FFFFFF"
border.width: 1
border.color: copyRepoMouse.containsMouse ? "#333333" : "#999999"
}
// 前层矩形(左上),复制成功时边框短暂变绿
Rectangle {
x: 6
y: 6
width: 10
height: 10
radius: 2
color: "#FFFFFF"
border.width: 1
border.color: copied ? "#22A34A" : (copyRepoMouse.containsMouse ? "#333333" : "#999999")
}
}
}
}
// 弹性空间:吸收多余高度,保证底部内边距固定为 16px
Item {
Layout.fillHeight: true
}
// 底部内边距
Item {
Layout.preferredHeight: 16
}
}
}
// 隐藏的 TextEdit 用于复制仓库地址到剪贴板
// QML 没有直接剪贴板 API,用 TextEdit.selectAll()+copy() 实现
//(与 networkview.qml 中 clipboardHelper 同模式)
TextEdit {
id: clipboardHelper
visible: false
text: ""
}
// 复制状态还原定时器:2 秒后将 copied 复位,图标与"已复制"提示恢复原状
Timer {
id: copyResetTimer
interval: 2000
onTriggered: root.copied = false
}
}
+422
View File
@@ -0,0 +1,422 @@
// SPDX-FileCopyrightText: 2024 MyCompany
//
// SPDX-License-Identifier: LGPL-3.0-or-later
// 设置窗口:独立顶层窗口,桌面居中弹出,提供网络接口选择、字体颜色选择、插件卸载功能
// 设计要点:白色圆角卡片 + 自定义标题栏可拖动,接口单选切换,卸载按钮带复制命令功能
// 对外依赖:appletC++ 后端对象)、networkInterfaces(接口列表)、activeInterface(当前接口)、
// accentColor(强调色),由 networkview.qml 实例化时传入,触发方式为 show()/raise()/requestActivate()
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtQuick.Window 2.15
Window {
id: root
// 对外依赖:C++ 后端对象,用于获取/设置接口、字体颜色等
property var applet: null
// 网络接口名称列表,由父组件从 C++ 后端 applet.networkInterfaces 传入
property var networkInterfaces: []
// 当前活动接口名称,由父组件从 C++ 后端 applet.activeInterface 传入
property string activeInterface: ""
// 强调色(红色),关闭按钮 hover 态文字高亮色,由父组件传入
// 默认值与 networkview.qml 中 root.accentRed 一致,确保独立可用
property color accentColor: Qt.rgba(220 / 255, 38 / 255, 38 / 255, 1)
// 卸载按钮文字:复制命令后临时显示提示,定时器到期后还原
property string uninstallButtonText: qsTr("卸载插件")
width: 350
height: 390
minimumWidth: 350
maximumWidth: 350
minimumHeight: 390
maximumHeight: 390
visible: false
flags: Qt.FramelessWindowHint | Qt.Window
// NonModal:不阻塞桌面其他区域,用户可同时操作任务栏
modality: Qt.NonModal
// 窗口透明:让圆角外的区域不显示,由内部 Rectangle 提供可见背景
color: "transparent"
// 根据接口名返回类型描述,用于设置窗口网络接口列表
// 设计原因:用户面对多个网口时难以仅凭 enp3s0/wlp3s0 等命名判断用途,
// 加一行类型说明(有线/无线/VPN 等)降低认知负担
function interfaceDescription(name) {
if (name === "lo") return qsTr("本地回环")
if (name === "Meta") return qsTr("虚拟接口")
if (/^enp|^eth/.test(name)) return qsTr("有线网络")
if (/^wlp|^wlan/.test(name)) return qsTr("无线网络")
if (/^docker|^veth/.test(name)) return qsTr("容器网络")
if (/^br/.test(name)) return qsTr("桥接")
if (/^tun|^tap/.test(name)) return qsTr("VPN")
if (/^virbr/.test(name)) return qsTr("虚拟桥接")
return qsTr("其他")
}
// 显示时居中到桌面
onVisibleChanged: {
if (visible) {
x = (Screen.width - width) / 2
y = (Screen.height - height) / 2
}
}
// 主容器:提供不透明背景 + 圆角 + 边框
Rectangle {
anchors.fill: parent
color: "#f5f5f5"
radius: 12
border.width: 1
border.color: "#e0e0e0"
ColumnLayout {
anchors.fill: parent
anchors.margins: 0
spacing: 0
// 自定义标题栏(可拖动)
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 44
color: "transparent"
// 拖动层:声明在关闭按钮之前(位于其下方),避免遮挡按钮点击与 hover
// 用 startSystemMove() 系统级窗口拖动,由窗口管理器接管移动,
// 这是 frameless Window 的正确做法,pressed 即触发,丝滑无抖动
MouseArea {
anchors.fill: parent
cursorShape: Qt.OpenHandCursor
onPressed: root.startSystemMove()
}
// 标题文字
Text {
anchors.left: parent.left
anchors.leftMargin: 16
anchors.verticalCenter: parent.verticalCenter
text: qsTr("设置")
font.pixelSize: 15
font.weight: Font.Bold
color: "#333333"
}
// 关闭按钮
Rectangle {
id: closeButton
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
radius: 14
color: closeMouse.containsMouse ? Qt.rgba(220/255, 38/255, 38/255, 0.15) : "transparent"
Text {
anchors.centerIn: parent
text: "×"
font.pixelSize: 20
font.weight: Font.Bold
color: closeMouse.containsMouse ? accentColor : "#666666"
}
MouseArea {
id: closeMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.hide()
}
}
}
// 分隔线
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: "#e0e0e0"
}
// 内容区域
Item {
Layout.fillWidth: true
Layout.fillHeight: true
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 12
// 网络接口选择区
Text {
text: qsTr("网络接口")
font.pixelSize: 13
font.weight: Font.Bold
color: "#666666"
}
// 接口列表容器
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: networkInterfaces.length > 0 ? 40 + networkInterfaces.length * 28 : 80
color: "#ffffff"
radius: 10
border.width: 1
border.color: "#e0e0e0"
ColumnLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
// 接口列表:RadioButton 单选切换活动接口
Repeater {
model: networkInterfaces
delegate: Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 28
color: "transparent"
radius: 6
RadioButton {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: modelData
checked: modelData === activeInterface
onToggled: {
if (applet) applet.setActiveInterface(modelData)
}
}
// 接口类型描述:右对齐显示,与接口名分居两侧
Text {
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: interfaceDescription(modelData)
font.pixelSize: 12
color: "#999999"
}
}
}
// 无接口提示
Text {
text: qsTr("未检测到网络接口")
font.pixelSize: 12
color: "#999999"
visible: networkInterfaces.length === 0
Layout.alignment: Qt.AlignHCenter
}
}
}
// 字体颜色选择区:预设色板 + 跟随系统选项
// 设计原因:用户选择的颜色通过 applet.textColor 持久化到
// ~/.config/jnetapplet/settings.ini,重启 dde-shell 后仍生效
Text {
text: qsTr("字体颜色")
font.pixelSize: 13
font.weight: Font.Bold
color: "#666666"
}
TextColorPicker {
Layout.fillWidth: true
currentColor: applet ? applet.textColor : ""
onColorSelected: if (applet) applet.textColor = color
}
Item { Layout.preferredHeight: 4 }
// 卸载插件按钮:文字可动态切换(复制命令后显示提示)
// 提示状态期间按钮不可点击,文字变绿色
Rectangle {
id: uninstallButton
Layout.fillWidth: true
Layout.preferredHeight: 40
// 提示状态时背景和边框变绿
readonly property bool isStatus: uninstallButtonText !== qsTr("卸载插件")
color: isStatus
? Qt.rgba(22/255, 163/255, 74/255, 0.08)
: (uninstallMouse.containsMouse ? Qt.rgba(220/255, 38/255, 38/255, 0.15) : Qt.rgba(220/255, 38/255, 38/255, 0.08))
radius: 10
border.width: 1
border.color: isStatus ? Qt.rgba(22/255, 163/255, 74/255, 1) : accentColor
Text {
anchors.centerIn: parent
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 8
text: uninstallButtonText
font.pixelSize: uninstallButton.isStatus ? 11 : 13
font.weight: Font.Medium
// 提示状态时文字绿色,否则红色
color: uninstallButton.isStatus ? Qt.rgba(22/255, 163/255, 74/255, 1) : accentColor
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
MouseArea {
id: uninstallMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// 提示状态期间禁用点击
enabled: !uninstallButton.isStatus
onClicked: uninstallConfirmDialog.open()
}
}
}
}
}
}
// 卸载确认对话框:在设置窗口内水平居中
Dialog {
id: uninstallConfirmDialog
width: 320
height: 260
modal: true
visible: false
// 在设置窗口中心显示
x: (root.width - width) / 2
y: (root.height - height) / 2
background: Rectangle {
color: "#f5f5f5"
radius: 12
border.width: 1
border.color: "#e0e0e0"
}
contentItem: ColumnLayout {
spacing: 16
anchors.fill: parent
anchors.margins: 20
Text {
text: qsTr("警告")
font.pixelSize: 16
font.weight: Font.Bold
color: accentColor
Layout.alignment: Qt.AlignHCenter
}
Text {
text: qsTr("以下命令用于卸载插件并重启 dde-shell,请复制到终端中执行:")
font.pixelSize: 12
color: "#333333"
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
// 命令显示区域
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 60
color: "#e8e8e8"
radius: 8
Text {
anchors.fill: parent
anchors.margins: 10
text: "sudo rm -rf /usr/share/dde-shell/space.jokul.JNetApplet/ && systemctl --user restart dde-shell@DDE"
font.pixelSize: 10
font.family: "monospace"
color: "#666666"
wrapMode: Text.WordWrap
}
}
Item { Layout.fillHeight: true }
// 按钮区域
RowLayout {
Layout.fillWidth: true
spacing: 12
// 取消按钮
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
color: cancelUninstallMouse.containsMouse ? "#f0f0f0" : "#ffffff"
radius: 8
border.width: 1
border.color: "#e0e0e0"
Text {
anchors.centerIn: parent
text: qsTr("取消")
font.pixelSize: 13
color: "#333333"
}
MouseArea {
id: cancelUninstallMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: uninstallConfirmDialog.close()
}
}
// 复制命令按钮
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
color: confirmUninstallMouse.containsMouse ? accentColor : Qt.rgba(220/255, 38/255, 38/255, 0.8)
radius: 8
Text {
anchors.centerIn: parent
text: qsTr("复制命令")
font.pixelSize: 13
font.weight: Font.Medium
color: "white"
}
MouseArea {
id: confirmUninstallMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
// 复制卸载命令到剪贴板
clipboardHelper.selectAll()
clipboardHelper.copy()
uninstallConfirmDialog.close()
// 卸载按钮文字临时替换为复制成功提示,5 秒后还原
uninstallButtonText = qsTr("卸载命令已复制到剪贴板,请在终端中粘贴执行")
statusHideTimer.start()
}
}
}
}
}
}
// 隐藏的 TextEdit 用于复制卸载命令到剪贴板
// QML 没有直接剪贴板 API,用 TextEdit.selectAll()+copy() 实现
TextEdit {
id: clipboardHelper
visible: false
text: "sudo rm -rf /usr/share/dde-shell/space.jokul.JNetApplet/ && systemctl --user restart dde-shell@DDE"
}
// 卸载按钮文字还原定时器:复制命令后 5 秒将按钮文字从提示还原为"卸载插件"
Timer {
id: statusHideTimer
interval: 5000
onTriggered: uninstallButtonText = qsTr("卸载插件")
}
}
+118
View File
@@ -0,0 +1,118 @@
// SPDX-FileCopyrightText: 2024 MyCompany
//
// SPDX-License-Identifier: LGPL-3.0-or-later
// 字体颜色选择器:用于设置窗口,提供预设色板与"跟随系统"选项
// 设计要点:白色圆角卡片与设置窗口其他分区视觉一致;圆形色块 22x22,
// 当前选中色加 2px 深色边框高亮;点击只发射 colorSelected 信号,
// 由父层(networkview.qml)回写 currentColor 并同步到 C++ 后端持久化
// 属性语义:
// currentColor - 当前选中颜色字符串,空串表示"跟随系统"(默认)
// colorSelected(string) - 用户点击色块时发射,参数为空串或 #RRGGBB
import QtQuick 2.15
import QtQuick.Layouts 1.15
Rectangle {
id: root
// 当前选中颜色:空串=跟随系统,用于显示选中态高亮
property string currentColor: ""
// 用户点击色块时发射,父层据此回写 currentColor 并持久化
signal colorSelected(string color)
// 预设色板:8 个常用色,覆盖白/黑/暖/冷,克制实用
property var presetColors: [
"#FFFFFF", "#000000", "#DC2626", "#F59E0B",
"#16A34A", "#1450A0", "#7C3AED", "#0891B2"
]
Layout.fillWidth: true
Layout.preferredHeight: 52
color: "#ffffff"
radius: 10
border.width: 1
border.color: "#e0e0e0"
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
// 预设色块
Repeater {
model: root.presetColors
delegate: Rectangle {
width: 26
height: 26
radius: 13
color: "transparent"
// 当前选中态:2px 深色边框
border.width: root.currentColor === modelData ? 2 : 0
border.color: "#333333"
Rectangle {
anchors.centerIn: parent
width: 22
height: 22
radius: 11
// 白色色块在白卡上需细边框可见
color: modelData
border.width: modelData === "#FFFFFF" ? 1 : 0
border.color: "#e0e0e0"
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.colorSelected(modelData)
}
}
}
// 跟随系统选项:斜线色块表示恢复默认
Rectangle {
width: 26
height: 26
radius: 13
color: "transparent"
// currentColor 为空串时高亮(跟随系统选中态)
border.width: root.currentColor === "" ? 2 : 0
border.color: "#333333"
Rectangle {
anchors.centerIn: parent
width: 22
height: 22
radius: 11
color: "#f5f5f5"
border.width: 1
border.color: "#e0e0e0"
// 斜线:表示"无自定义色/跟随系统"
Canvas {
anchors.fill: parent
onPaint: {
var ctx = getContext("2d")
ctx.reset()
ctx.strokeStyle = "#999999"
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(2, height - 2)
ctx.lineTo(width - 2, 2)
ctx.stroke()
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.colorSelected("")
}
}
// 弹性空间,让色块左对齐
Item { Layout.fillWidth: true }
}
}
+577
View File
@@ -0,0 +1,577 @@
// SPDX-FileCopyrightText: 2024 MyCompany
//
// SPDX-License-Identifier: LGPL-3.0-or-later
// 流量波动图窗口:屏幕居中的独立顶层窗口,展示当前活动接口最近 5 分钟的
// 网速趋势(下载/上传双折线图),随 C++ 后端 speedHistoryChanged 信号每秒动态刷新
// 设计要点:
// - 视觉风格复用 AboutWindow.qml(白色圆角卡片 12px、1px #E8E8E8 边框、
// 44px 标题栏、28x28 圆形关闭按钮 hover 淡红底)
// - 图表使用纯 QML Canvas 绘制,无 QtCharts 等外部依赖(与项目零依赖风格一致)
// - 数据通过属性注入:applet 即 networkview.qml 的 root.appletC++ 后端对象),
// 为 null 时组件可独立预览(Canvas 显示"暂无历史数据"空状态)
// 触发方式:右键菜单"流量波动图" -> show()/raise()/requestActivate()
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Window 2.15
Window {
id: root
// 对外依赖:关闭按钮 hover 态文字高亮色,由父组件传入
// 默认值与 networkview.qml 中 root.accentRed 一致,确保独立可用
property color accentColor: Qt.rgba(220 / 255, 38 / 255, 38 / 255, 1)
// 对外依赖:C++ 后端对象(NetworkMonitorApplet),由 networkview.qml 传入
// 提供 speedHistoryDownload / speedHistoryUploadQVariantList of QPointF
// x=时间戳秒, y=速度 bytes/sec)、downloadSpeed / uploadSpeed、activeInterface
// 为 null 时组件可独立预览(显示空状态),所有数据访问处均需做空值兜底
property var applet: null
// Hover 状态:当前悬停采样点在历史缓冲中的索引,-1 表示无悬停
// 由图表区 MouseArea 根据 mouseX 反查最近采样点更新;Canvas 据此绘制
// 竖直辅助线与圆点标记,底部提示条据此显示该时刻数值
property int hoverIndex: -1
// Hover 提示文本:非空时底部提示条显示该内容,为空显示默认引导语
// 格式:"14:23:05 ↓856.00 KB/s ↑120.00 KB/s"
property string hoverHint: ""
// 窗口置顶状态:true 时 flags 附加 Qt.WindowStaysOnTopHint,窗口保持在
// 所有非置顶窗口之上(便于边下载大文件边观察曲线);由标题栏图钉按钮切换
property bool pinned: false
// 置顶状态变化时重绘图钉图标(未置顶=灰色轮廓,置顶=蓝色填充)
onPinnedChanged: pinIcon.requestPaint()
// 下载折线颜色(绿)与上传折线颜色(橙),与规范 §4.4 一致
readonly property color downloadLineColor: "#34C759"
readonly property color uploadLineColor: "#FF9500"
// 图表区内边距:左侧容纳 Y 轴刻度标签(如 "10 MB"),底部容纳 X 轴时间标签
// 定义为根属性,供 Canvas 绘制与 MouseArea 坐标换算共用,保证两处一致
readonly property int chartMarginLeft: 56
readonly property int chartMarginRight: 12
readonly property int chartMarginTop: 12
readonly property int chartMarginBottom: 26
width: 680
height: 420
visible: false
// flags 绑定 pinned:置顶时附加 WindowStaysOnTopHint;未置顶时该位为 0
// 与原静态 flags 行为完全一致
flags: Qt.FramelessWindowHint | Qt.Window | (pinned ? Qt.WindowStaysOnTopHint : 0)
modality: Qt.NonModal
color: "transparent"
onVisibleChanged: {
if (visible) {
x = (Screen.width - width) / 2
y = (Screen.height - height) / 2
}
}
// 格式化速度显示(带单位,最小 KB,保留 2 位小数)
// 与 networkview.qml 的 formatSpeed 同实现;组件内自带一份保证独立可用
function formatSpeed(bytesPerSec) {
if (bytesPerSec < 1024 * 1024) {
return (bytesPerSec / 1024).toFixed(2) + " KB/s"
} else if (bytesPerSec < 1024 * 1024 * 1024) {
return (bytesPerSec / (1024 * 1024)).toFixed(2) + " MB/s"
} else {
return (bytesPerSec / (1024 * 1024 * 1024)).toFixed(2) + " GB/s"
}
}
// Y 轴上限取整:将 v 向上取整到 1/2/5 × 10^n 序列
// 设计原因:使刻度值为易读的整数(如 12345 -> 2000080000 -> 100000),
// 避免 vMax * 1.2 的余量产生零碎刻度(如 14814)
function niceCeil(v) {
if (v <= 0) return 1024
var exp = Math.floor(Math.log(v) / Math.LN10)
var base = Math.pow(10, exp)
var f = v / base
var nf
if (f <= 1) nf = 1
else if (f <= 2) nf = 2
else if (f <= 5) nf = 5
else nf = 10
return nf * base
}
// Y 轴刻度单位选择:根据上限自动选择 B/KB/MB/GB,返回 {div, suffix}
// 设计原因:让刻度数值保持在 0~1000 的易读区间(如 200 KB 而非 204800 B
function axisUnit(yMax) {
if (yMax >= 1024 * 1024 * 1024) return { div: 1024 * 1024 * 1024, suffix: "GB" }
if (yMax >= 1024 * 1024) return { div: 1024 * 1024, suffix: "MB" }
if (yMax >= 1024) return { div: 1024, suffix: "KB" }
return { div: 1, suffix: "B" }
}
// 格式化单个 Y 轴刻度值:大值取整、小值保留 1 位小数,避免 "10.00" 这类冗余
function formatAxisValue(v, unit) {
var val = v / unit.div
var s
if (val >= 100) s = val.toFixed(0)
else if (val >= 10) s = val.toFixed(1)
else s = val.toFixed(val === 0 ? 0 : 1)
return s + " " + unit.suffix
}
// 窗口主体:白色圆角卡片,1px 浅灰边框模拟 DTK 窗口描边(与 AboutWindow 一致)
Rectangle {
anchors.fill: parent
color: "#FFFFFF"
radius: 12
border.width: 1
border.color: "#E8E8E8"
ColumnLayout {
anchors.fill: parent
spacing: 0
// 自定义标题栏:左侧"流量波动图"标题 + 右侧置顶/关闭按钮,整栏可拖动窗口
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 44
color: "transparent"
// 拖动层:声明在关闭/置顶按钮之前(位于其下方),避免遮挡按钮点击与 hover
// 注意:drag.target 只对 Item 生效,对 Window 无效(原实现拖动不工作);
// 改用 startSystemMove() 系统级窗口拖动,这是 frameless Window 的正确做法,
// pressed 即触发,由窗口管理器接管移动,不干扰按钮的 hover/click
MouseArea {
anchors.fill: parent
cursorShape: Qt.OpenHandCursor
onPressed: root.startSystemMove()
}
Text {
anchors.left: parent.left
anchors.leftMargin: 16
anchors.verticalCenter: parent.verticalCenter
text: qsTr("流量波动图")
font.pixelSize: 15
font.weight: Font.Bold
color: "#333333"
}
// 置顶按钮:28x28 圆形(与关闭按钮一致),位于关闭按钮左侧
// 三态视觉:未置顶=灰色图钉轮廓;置顶=deepin 蓝填充 + 淡蓝底;
// hover=淡灰底(置顶时淡蓝底优先)
Rectangle {
anchors.right: chartCloseButton.left
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
radius: 14
color: root.pinned ? Qt.rgba(0, 129 / 255, 1, 0.12)
: (pinMouse.containsMouse ? Qt.rgba(0, 0, 0, 0.06) : "transparent")
// 置顶图标:Canvas 绘制"上箭头触顶"(⤒ 风格)——向上箭头指向顶部横杠,
// 是"置顶/移到顶部"最通用的视觉语言,与关闭按钮"×"形态区分明显
// 未置顶:灰色细线描边;置顶:蓝色加粗 + 箭头头部实心填充,激活态更醒目
Canvas {
id: pinIcon
anchors.centerIn: parent
width: 16
height: 16
onPaint: {
var ctx = getContext("2d")
ctx.clearRect(0, 0, width, height)
var c = root.pinned ? "#0081FF" : "#999999"
ctx.strokeStyle = c
ctx.fillStyle = c
ctx.lineCap = "round"
ctx.lineJoin = "round"
ctx.lineWidth = root.pinned ? 2 : 1.5
// 顶部横杠:y=3x 3..13
ctx.beginPath()
ctx.moveTo(3, 3)
ctx.lineTo(13, 3)
ctx.stroke()
// 箭头主干:自底部 (8,13) 向上至 (8,7)
// 与横杠间留 ~1.5px 间隙,保持"触顶"的意象清晰
ctx.beginPath()
ctx.moveTo(8, 13)
ctx.lineTo(8, 7)
ctx.stroke()
if (root.pinned) {
// 置顶:箭头头部实心三角填充,与描边态形成明确视觉差异
ctx.beginPath()
ctx.moveTo(8, 5.2)
ctx.lineTo(4.6, 9)
ctx.lineTo(11.4, 9)
ctx.closePath()
ctx.fill()
} else {
// 未置顶:左右两片箭头羽,自顶端 (8,5.5) 向两肩展开
ctx.beginPath()
ctx.moveTo(8, 5.5)
ctx.lineTo(5, 8.5)
ctx.moveTo(8, 5.5)
ctx.lineTo(11, 8.5)
ctx.stroke()
}
}
}
MouseArea {
id: pinMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.pinned = !root.pinned
}
}
// 关闭按钮:28x28 圆形,hover 时淡红底 + 红色 ×(颜色由 accentColor 决定)
Rectangle {
id: chartCloseButton
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
radius: 14
color: chartCloseMouse.containsMouse ? Qt.rgba(220 / 255, 38 / 255, 38 / 255, 0.15) : "transparent"
Text {
anchors.centerIn: parent
text: "×"
font.pixelSize: 20
font.weight: Font.Bold
color: chartCloseMouse.containsMouse ? root.accentColor : "#666666"
}
MouseArea {
id: chartCloseMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.hide()
}
}
}
// 顶部状态条:实时下行/上行速度(箭头颜色与折线一致,兼作图例)
// + 右侧活动接口名与时间窗口标签
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: 30
Layout.leftMargin: 16
Layout.rightMargin: 16
spacing: 6
Text {
text: "↓"
font.pixelSize: 14
font.weight: Font.Bold
color: root.downloadLineColor
}
Text {
// applet 为空时兜底为 0,保证独立预览不报错
text: root.formatSpeed(root.applet ? root.applet.downloadSpeed : 0)
font.pixelSize: 12
color: "#333333"
}
Item { Layout.preferredWidth: 10 }
Text {
text: "↑"
font.pixelSize: 14
font.weight: Font.Bold
color: root.uploadLineColor
}
Text {
text: root.formatSpeed(root.applet ? root.applet.uploadSpeed : 0)
font.pixelSize: 12
color: "#333333"
}
Item { Layout.fillWidth: true }
Text {
text: (root.applet && root.applet.activeInterface
? root.applet.activeInterface : "—") + " · 5min"
font.pixelSize: 12
color: "#999999"
}
}
// 图表区:Canvas 绘制双折线 + 网格 + 坐标轴 + Hover 辅助线
// visible 绑定窗口可见性:弹窗关闭时不绘制(C++ 后端仍继续采集)
Canvas {
id: canvas
Layout.fillWidth: true
Layout.fillHeight: true
Layout.leftMargin: 8
Layout.rightMargin: 8
visible: root.visible
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
onPaint: {
var ctx = getContext("2d")
ctx.clearRect(0, 0, width, height)
// 绘图区几何(与 chartHover 坐标换算共用同一组边距)
var pl = root.chartMarginLeft
var pr = root.chartMarginRight
var pt = root.chartMarginTop
var pb = root.chartMarginBottom
var pw = width - pl - pr
var ph = height - pt - pb
if (pw <= 0 || ph <= 0) return
// 取历史数据;applet 为空时视为空缓冲(走空状态分支)
var dl = root.applet ? root.applet.speedHistoryDownload : []
var ul = root.applet ? root.applet.speedHistoryUpload : []
var n = dl.length
// ---- Y 轴上限计算(动态适应最近活动)----
// 使用最近 60 秒的数据计算 Y 轴上限,而非全量 5 分钟缓冲。
// 设计原因:若使用全量缓冲,当历史中存在大流量峰值(如 12MB/s 下载)时,
// 即使当前网速已降至 KB/s 级别,Y 轴仍保持高位,导致当前曲线被压到底部
// 无法观察波动。改用 60 秒窗口后,峰值滑出窗口时 Y 轴自动缩小,
// 始终为当前活动提供合适的显示比例。
var vMax = 0
var i
var recentWindow = 60 // 秒,Y 轴基于最近 1 分钟的活动计算
var recentStart = Math.max(0, n - recentWindow)
for (i = recentStart; i < n; i++) {
if (dl[i].y > vMax) vMax = dl[i].y
if (i < ul.length && ul[i].y > vMax) vMax = ul[i].y
}
var yMax = vMax <= 0 ? 1024 : root.niceCeil(vMax * 1.2)
var unit = root.axisUnit(yMax)
// ---- 水平网格线与 Y 轴刻度([0, yMax] 均分 4 段)----
ctx.font = "10px sans-serif"
ctx.textBaseline = "middle"
var g, gy
for (g = 0; g <= 4; g++) {
gy = pt + ph - (ph * g / 4)
// +0.5 让 1px 线条落在像素边界上,避免抗锯齿发虚
// 基线(g=0)略深一档,其余 4 条网格线用规范色 #E5E5E5
ctx.strokeStyle = g === 0 ? "#DDDDDD" : "#E5E5E5"
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(pl, gy + 0.5)
ctx.lineTo(pl + pw, gy + 0.5)
ctx.stroke()
// 刻度标签右对齐于绘图区左侧
ctx.fillStyle = "#999999"
ctx.textAlign = "right"
ctx.fillText(root.formatAxisValue(yMax * g / 4, unit), pl - 8, gy)
}
// ---- X 轴刻度:每分钟一个标签,均布于绘图区 ----
// 5 分钟窗口用 -5m/-4m/-3m/-2m/-1m/now 共 6 个标签,比 4 个三分点
// 的 -3m20s/-1m40s 更易读;循环按数组长度参数化,两端左/右对齐
var xLabels = ["-5m", "-4m", "-3m", "-2m", "-1m", "now"]
ctx.fillStyle = "#999999"
ctx.textBaseline = "alphabetic"
for (i = 0; i < xLabels.length; i++) {
var lx = pl + pw * i / (xLabels.length - 1)
// 两端标签分别左/右对齐,避免文字越出卡片边缘被裁剪
ctx.textAlign = i === 0 ? "left" : (i === xLabels.length - 1 ? "right" : "center")
ctx.fillText(xLabels[i], lx, height - 8)
}
// ---- 空状态:画完空网格后中央提示,不画折线(规范 §5)----
if (n === 0) {
ctx.fillStyle = "#999999"
ctx.font = "13px sans-serif"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText(qsTr("暂无历史数据"), pl + pw / 2, pt + ph / 2)
return
}
// ---- 折线绘制 ----
// X 轴固定为 5 分钟窗口 [tMax-300, tMax]:右缘始终对齐"now"
// 数据不足 5 分钟时曲线从右侧向左生长,左侧留空网格。
// 设计原因:若按实际数据范围 [dl[0].x, tMax] 映射,刚启动时仅有
// 几秒数据会被拉伸到整个绘图宽度,X 轴标签 -5m/-4m/... 与实际范围
// 严重不符,用户看到的是"几秒趋势图"而非"5 分钟趋势图"。
// 300 = 5 分钟 × 60 秒,与 C++ MAX_HISTORY_SAMPLES 一致
var tMax = dl[n - 1].x
var tMin = tMax - 300
var tRange = 300
// 单点边界:只有 1 个采样点时无法连线,画一个圆点。
// 固定窗口下单点对齐 now(右缘),而非居中
if (n === 1) {
var sx = pl + pw
var sy = pt + ph - (dl[0].y / yMax) * ph
ctx.fillStyle = "#34C759"
ctx.beginPath()
ctx.arc(sx, sy, 3, 0, 2 * Math.PI)
ctx.fill()
return
}
// 绘制一条序列折线:先填充线下淡色区域增强可读性,再描边 2px 主线
// lineCap/lineJoin 用 round 使折线端点与转角圆润
// 注意每次 stroke/fill 前必须 beginPath(),避免与上一段路径串连
function drawSeries(data, colorCss, fillCss) {
var m = data.length
if (m === 0) return
var j, px, py
// 线下淡色填充(alpha 0.08):贴到基线形成闭合区域
ctx.beginPath()
for (j = 0; j < m; j++) {
px = pl + ((data[j].x - tMin) / tRange) * pw
py = pt + ph - (data[j].y / yMax) * ph
if (j === 0) ctx.moveTo(px, py)
else ctx.lineTo(px, py)
}
ctx.lineTo(pl + ((data[m - 1].x - tMin) / tRange) * pw, pt + ph)
ctx.lineTo(pl + ((data[0].x - tMin) / tRange) * pw, pt + ph)
ctx.closePath()
ctx.fillStyle = fillCss
ctx.fill()
// 主线:2px 描边
ctx.beginPath()
for (j = 0; j < m; j++) {
px = pl + ((data[j].x - tMin) / tRange) * pw
py = pt + ph - (data[j].y / yMax) * ph
if (j === 0) ctx.moveTo(px, py)
else ctx.lineTo(px, py)
}
ctx.strokeStyle = colorCss
ctx.lineWidth = 2
ctx.lineCap = "round"
ctx.lineJoin = "round"
ctx.stroke()
}
// 先画上传(橙)再画下载(绿):下载通常是主视线,后画置于上层
drawSeries(ul, "#FF9500", "rgba(255, 149, 0, 0.08)")
drawSeries(dl, "#34C759", "rgba(52, 199, 89, 0.08)")
// ---- Hover:竖直辅助线 + 两条折线上的圆点标记 ----
// hoverIndex 由 chartHover 根据 mouseX 反查更新;此处按索引换算坐标
var hi = root.hoverIndex
if (hi >= 0 && hi < n) {
var hx = pl + ((dl[hi].x - tMin) / tRange) * pw
// 竖直辅助线:虚线,避免与实线折线混淆
ctx.strokeStyle = "#BBBBBB"
ctx.lineWidth = 1
ctx.setLineDash([4, 3])
ctx.beginPath()
ctx.moveTo(hx, pt)
ctx.lineTo(hx, pt + ph)
ctx.stroke()
ctx.setLineDash([])
// 圆点标记:白芯 + 2px 彩色描边,在折线上形成清晰锚点
function drawDot(yVal, colorCss) {
var hy = pt + ph - (yVal / yMax) * ph
ctx.beginPath()
ctx.arc(hx, hy, 4, 0, 2 * Math.PI)
ctx.fillStyle = "#FFFFFF"
ctx.fill()
ctx.strokeStyle = colorCss
ctx.lineWidth = 2
ctx.stroke()
}
drawDot(dl[hi].y, "#34C759")
if (hi < ul.length) drawDot(ul[hi].y, "#FF9500")
}
}
// Hover 交互层:横向移动时按 mouseX 反查最近采样点
// 设计原因:采样点按时间均匀分布(1Hz),先由 X 坐标线性反推时间戳,
// 再线性扫描找最近点;300 点上限下扫描开销可忽略,无需二分
MouseArea {
id: chartHover
anchors.fill: parent
hoverEnabled: true
onPositionChanged: {
if (!root.applet) return
var dl = root.applet.speedHistoryDownload
var n = dl.length
if (n === 0) return
var pl = root.chartMarginLeft
var pw = width - pl - root.chartMarginRight
if (pw <= 0) return
// X 轴用与绘制一致的固定 5 分钟窗口 [tMax-300, tMax] 反推时间戳,
// 保证 Hover 辅助线与折线上的采样点严格对齐
var tMax = dl[n - 1].x
var tMin = tMax - 300
var tRange = 300
// 将 mouseX 限制在绘图区内,再线性反推时间戳
var clampedX = Math.max(pl, Math.min(mouse.x, pl + pw))
var target = tMin + (clampedX - pl) / pw * tRange
var best = 0
var bestDist = Math.abs(dl[0].x - target)
for (var i = 1; i < n; i++) {
var d = Math.abs(dl[i].x - target)
if (d < bestDist) {
bestDist = d
best = i
}
}
root.hoverIndex = best
// 更新底部提示条:时间 + 下行/上行速度
var ul = root.applet.speedHistoryUpload
var idx = root.hoverIndex
var upVal = idx < ul.length ? ul[idx].y : 0
root.hoverHint = Qt.formatDateTime(new Date(dl[idx].x * 1000), "HH:mm:ss")
+ " ↓" + root.formatSpeed(dl[idx].y)
+ " ↑" + root.formatSpeed(upVal)
canvas.requestPaint()
}
onExited: {
root.hoverIndex = -1
root.hoverHint = ""
canvas.requestPaint()
}
}
}
// 底部提示条:默认显示操作引导;Hover 时显示该时刻的时间与上下行速度
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 30
color: "transparent"
Text {
anchors.centerIn: parent
text: root.hoverHint.length > 0
? root.hoverHint : qsTr("鼠标移到曲线上查看详情")
font.pixelSize: 11
color: root.hoverHint.length > 0 ? "#333333" : "#999999"
}
}
}
}
// 动态刷新:C++ 每秒追加采样点时发射 speedHistoryChanged,触发 Canvas 重绘
// 注意:此处不重置 Hover 状态,否则用户悬停时提示会随每秒刷新闪断;
// 缓冲满后旧点滑出导致的索引漂移由 onPaint 中 hi < n 的边界判断兜底
Connections {
target: root.applet
function onSpeedHistoryChanged() {
canvas.requestPaint()
}
// 接口切换时历史整体更换,原 Hover 索引失去意义,需清除避免残留标记
function onActiveInterfaceChanged() {
root.hoverIndex = -1
root.hoverHint = ""
canvas.requestPaint()
}
}
}
@@ -1,6 +1,6 @@
{ {
"Plugin": { "Plugin": {
"Version": "1.0", "Version": "@PROJECT_VERSION@",
"Id": "space.jokul.JNetApplet", "Id": "space.jokul.JNetApplet",
"Url": "networkview.qml", "Url": "networkview.qml",
"Parent": "org.deepin.ds.dock", "Parent": "org.deepin.ds.dock",
+512 -281
View File
@@ -6,18 +6,27 @@ import QtQuick 2.15
import QtQuick.Controls 2.15 import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15 import QtQuick.Layouts 1.15
import QtQuick.Window 2.15 import QtQuick.Window 2.15
import Qt.labs.platform as Platform
import org.deepin.ds 1.0 import org.deepin.ds 1.0
import org.deepin.ds.dock 1.0 import org.deepin.ds.dock 1.0
import org.deepin.dtk 1.0 import org.deepin.dtk 1.0
import "components"
AppletItem { AppletItem {
id: root id: root
objectName: "network monitor applet" objectName: "network monitor applet"
property int dockOrder: 21 property int dockOrder: 21
property int dockSize: Panel.rootObject.dockItemMaxSize || 48 property int dockSize: Panel.rootObject.dockItemMaxSize || 48
// 任务栏方向:0=Top, 1=Right, 2=Bottom, 3=Left% 2 为 0 表示水平,1 表示竖向
// 设计原因:dde-shell 规范用法,org.deepin.ds.dock 已在 line 11 导入
property bool isVerticalDock: Panel.position % 2 === 1
implicitWidth: dockSize // 水平任务栏:宽度稍宽容纳双行数值,高度匹配 dock 尺寸
implicitHeight: dockSize // 竖向任务栏:宽度匹配 dock 尺寸(~40px),高度加大容纳竖排字符
// 2.4 倍:dockSize=40 时约 96px,可容纳最多 8 字符的速度字符串(如 "1023.99K"
// 增大字号后需更高以容纳竖排字符
implicitWidth: isVerticalDock ? dockSize : Math.round(dockSize * 1.35)
implicitHeight: isVerticalDock ? Math.round(dockSize * 2.4) : dockSize
readonly property var applet: Applet readonly property var applet: Applet
readonly property bool ready: applet ? applet.ready : false readonly property bool ready: applet ? applet.ready : false
@@ -26,15 +35,58 @@ AppletItem {
readonly property real totalDownload: applet ? applet.totalDownload : 0 readonly property real totalDownload: applet ? applet.totalDownload : 0
readonly property real totalUpload: applet ? applet.totalUpload : 0 readonly property real totalUpload: applet ? applet.totalUpload : 0
readonly property string activeInterface: applet ? (applet.activeInterface || "") : "" readonly property string activeInterface: applet ? (applet.activeInterface || "") : ""
// 活动接口的 IPv4 地址,由 C++ 后端 detectIpAddress 持续更新
readonly property string ipAddress: applet ? (applet.ipAddress || "") : ""
// 活动接口的 IPv6 全球地址,由 C++ 后端 detectIpAddress 持续更新
readonly property string ipv6Address: applet ? (applet.ipv6Address || "") : ""
readonly property var networkInterfaces: applet ? (applet.networkInterfaces || []) : [] readonly property var networkInterfaces: applet ? (applet.networkInterfaces || []) : []
readonly property var interfaceStats: applet ? (applet.interfaceStats || []) : [] readonly property var interfaceStats: applet ? (applet.interfaceStats || []) : []
// 格式化速度显示 // 判断是否为物理网卡(QML 侧排序用,与 C++ isPhysicalInterface 逻辑一致)
function isPhysicalIf(name) {
return name.startsWith("wlp") || name.startsWith("wlan")
|| name.startsWith("enp") || name.startsWith("eth")
}
// 排序后的接口列表:活动接口最前,其次物理网卡,最后虚拟网卡
// 设计原因:用户希望启动监测的网卡在最左边,方便查看和切换
readonly property var sortedInterfaces: {
if (!root.ready || root.networkInterfaces.length === 0) return []
var physical = []
var virtual = []
for (var i = 0; i < root.networkInterfaces.length; i++) {
var name = root.networkInterfaces[i]
if (name === root.activeInterface) continue
if (root.isPhysicalIf(name)) physical.push(name)
else virtual.push(name)
}
physical.sort()
virtual.sort()
var result = []
if (root.activeInterface) result.push(root.activeInterface)
return result.concat(physical).concat(virtual)
}
// 紧凑格式化速度(用于任务栏图标和 tooltip,不带单位后缀,节省空间)
// 设计原因:任务栏 ~48px 空间有限,"1.2M" 比 "1.2 MB/s" 节省约一半宽度
// 最小单位为 KB:B 级别也转换为 KB 显示(如 512 B/s -> "0.50K"),
// 避免纯数字无后缀时用户无法判断单位;所有级别保留 2 位小数
function formatSpeedShort(bytesPerSec) {
if (bytesPerSec < 1024 * 1024) {
return (bytesPerSec / 1024).toFixed(2) + "K"
} else if (bytesPerSec < 1024 * 1024 * 1024) {
return (bytesPerSec / (1024 * 1024)).toFixed(2) + "M"
} else {
return (bytesPerSec / (1024 * 1024 * 1024)).toFixed(2) + "G"
}
}
// 格式化速度显示(带单位,用于弹出面板,信息更完整)
// 最小单位为 KB,与 formatSpeedShort 保持一致;所有级别保留 2 位小数
function formatSpeed(bytesPerSec) { function formatSpeed(bytesPerSec) {
if (bytesPerSec < 1024) { if (bytesPerSec < 1024 * 1024) {
return bytesPerSec.toFixed(0) + " B/s" return (bytesPerSec / 1024).toFixed(2) + " KB/s"
} else if (bytesPerSec < 1024 * 1024) {
return (bytesPerSec / 1024).toFixed(1) + " KB/s"
} else if (bytesPerSec < 1024 * 1024 * 1024) { } else if (bytesPerSec < 1024 * 1024 * 1024) {
return (bytesPerSec / (1024 * 1024)).toFixed(2) + " MB/s" return (bytesPerSec / (1024 * 1024)).toFixed(2) + " MB/s"
} else { } else {
@@ -42,7 +94,7 @@ AppletItem {
} }
} }
// 格式化总量显示 // 格式化总量显示(用于弹出面板的累计统计)
function formatTotal(bytes) { function formatTotal(bytes) {
if (bytes < 1024) { if (bytes < 1024) {
return bytes.toFixed(0) + " B" return bytes.toFixed(0) + " B"
@@ -55,89 +107,176 @@ AppletItem {
} }
} }
// 根据速度计算图标颜色 // 任务栏与面板的颜色全部派生自 DockPalette.iconTextPalette
readonly property color iconColor: { // 这样在深色/浅色任务栏下自动适配,无需手动判断主题
if (downloadSpeed > 10 * 1024 * 1024) return Qt.rgba(220/255, 38/255, 38/255, 1) // 高速红色
if (downloadSpeed > 1 * 1024 * 1024) return Qt.rgba(245/255, 158/255, 11/255, 1) // 中速橙色
return root.primaryText // 正常颜色
}
property Palette basePalette: DockPalette.iconTextPalette property Palette basePalette: DockPalette.iconTextPalette
readonly property color primaryText: Qt.rgba(basePalette.r, basePalette.g, basePalette.b, 0.95) readonly property color primaryText: Qt.rgba(basePalette.r, basePalette.g, basePalette.b, 0.95)
readonly property color secondaryText: Qt.rgba(basePalette.r, basePalette.g, basePalette.b, 0.8) readonly property color secondaryText: Qt.rgba(basePalette.r, basePalette.g, basePalette.b, 0.80)
readonly property color tertiaryText: Qt.rgba(basePalette.r, basePalette.g, basePalette.b, 0.65) readonly property color tertiaryText: Qt.rgba(basePalette.r, basePalette.g, basePalette.b, 0.65)
readonly property color cardBackground: Qt.rgba(basePalette.r, basePalette.g, basePalette.b, 0.06) readonly property color cardBackground: Qt.rgba(basePalette.r, basePalette.g, basePalette.b, 0.06)
readonly property color cardBorder: Qt.rgba(basePalette.r, basePalette.g, basePalette.b, 0.1) readonly property color cardBorder: Qt.rgba(basePalette.r, basePalette.g, basePalette.b, 0.10)
// 强调色:下载蓝、上传绿,是面板与任务栏的主视觉区分
readonly property color accentBlue: Qt.rgba(20 / 255, 80 / 255, 160 / 255, 1) readonly property color accentBlue: Qt.rgba(20 / 255, 80 / 255, 160 / 255, 1)
readonly property color accentBlueLight: Qt.rgba(20 / 255, 80 / 255, 160 / 255, 0.12) readonly property color accentBlueLight: Qt.rgba(20 / 255, 80 / 255, 160 / 255, 0.12)
readonly property color accentGreen: Qt.rgba(22 / 255, 163 / 255, 74 / 255, 1)
readonly property color accentGreenLight: Qt.rgba(22 / 255, 163 / 255, 74 / 255, 0.12)
// 图标区域 // 高速警示色:下载速度超过阈值时由蓝转橙再转红
Rectangle { readonly property color accentOrange: Qt.rgba(245 / 255, 158 / 255, 11 / 255, 1)
anchors.centerIn: parent readonly property color accentRed: Qt.rgba(220 / 255, 38 / 255, 38 / 255, 1)
width: dockSize * 0.7
height: dockSize * 0.7
color: "transparent"
radius: width * 0.45
border.width: 1
border.color: root.iconColor
// 下载值颜色:保留原有阈值逻辑(>10MB/s 红、>1MB/s 橙、否则蓝),仅作用于下载值
readonly property color downloadValueColor: {
if (downloadSpeed > 10 * 1024 * 1024) return accentRed
if (downloadSpeed > 1 * 1024 * 1024) return accentOrange
return accentBlue
}
// 上传值颜色:固定绿色,不做高速警示
readonly property color uploadValueColor: accentGreen
// 任务栏网速数值字体色:用户自定义色优先(持久化),未设置时跟随系统主题
// 设计原因:primaryText 派生自 DockPalette,深浅色任务栏自动适配;
// 用户主动选色后覆盖,空串回退到 primaryText 保持自适应
readonly property color speedTextColor: (applet && applet.textColor.length > 0) ? applet.textColor : primaryText
// 任务栏图标区:双行紧凑数值,箭头与数值紧贴、左对齐
// 设计原因:箭头与数值作为一组固定在左侧,数值左对齐紧贴箭头;
// 不给每行固定高度,让 RowLayout 按内容自然高度排列,
// 整组垂直居中于 dock 区域,避免两行间出现过大间隙
Column { Column {
anchors.centerIn: parent visible: !root.isVerticalDock
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 4
spacing: 0
// 下载速度行
RowLayout {
spacing: 2 spacing: 2
// 下载速度
Text { Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "↓" text: "↓"
font.pixelSize: root.dockSize * 0.18 font.pixelSize: root.dockSize * 0.20
color: root.iconColor color: root.speedTextColor
Layout.alignment: Qt.AlignVCenter
} }
Text { Text {
anchors.horizontalCenter: parent.horizontalCenter text: root.formatSpeedShort(root.downloadSpeed)
text: formatSpeed(root.downloadSpeed) font.pixelSize: root.dockSize * 0.25
font.pixelSize: root.dockSize * 0.12 font.weight: Font.Medium
font.bold: true color: root.speedTextColor
color: root.iconColor Layout.alignment: Qt.AlignVCenter
horizontalAlignment: Text.AlignLeft
}
} }
// 上传速度 // 上传速度
RowLayout {
spacing: 2
Text { Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "↑" text: "↑"
font.pixelSize: root.dockSize * 0.18 font.pixelSize: root.dockSize * 0.20
color: root.iconColor color: root.speedTextColor
Layout.alignment: Qt.AlignVCenter
} }
Text { Text {
anchors.horizontalCenter: parent.horizontalCenter text: root.formatSpeedShort(root.uploadSpeed)
text: formatSpeed(root.uploadSpeed) font.pixelSize: root.dockSize * 0.25
font.pixelSize: root.dockSize * 0.12 font.weight: Font.Medium
font.bold: true color: root.speedTextColor
color: root.iconColor Layout.alignment: Qt.AlignVCenter
horizontalAlignment: Text.AlignLeft
} }
} }
} }
// 悬停提示 // 竖向任务栏布局:双列字符竖排,每列一个方向(下载|上传)
// 设计原因:竖向任务栏宽度仅 ~40px,水平布局的 "↓6.64K" 约 35px 勉强容纳但
// 数值长度变化时易裁切;逐字符竖排每列仅 ~10px,双列 ~25px,舒适容纳且视觉对称
RowLayout {
visible: root.isVerticalDock
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: 4
spacing: 4
// 下载列:箭头在上,数值字符从上往下竖排
Column {
spacing: 0
Layout.alignment: Qt.AlignTop | Qt.AlignHCenter
Text {
text: "↓"
font.pixelSize: root.dockSize * 0.20
color: root.speedTextColor
anchors.horizontalCenter: parent.horizontalCenter
}
Repeater {
// 将 "6.64K" 拆为 ["6", ".", "6", "4", "K"] 逐字符渲染
// 属性绑定:downloadSpeed 变化时 formatSpeedShort 重算,split 自动刷新 model
model: root.formatSpeedShort(root.downloadSpeed).split('')
Text {
text: modelData
font.pixelSize: root.dockSize * 0.25
height: font.pixelSize
font.weight: Font.Medium
color: root.speedTextColor
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
// 上传列:结构与下载列对称,颜色用 uploadValueColor
Column {
spacing: 0
Layout.alignment: Qt.AlignTop | Qt.AlignHCenter
Text {
text: "↑"
font.pixelSize: root.dockSize * 0.20
color: root.speedTextColor
anchors.horizontalCenter: parent.horizontalCenter
}
Repeater {
model: root.formatSpeedShort(root.uploadSpeed).split('')
Text {
text: modelData
font.pixelSize: root.dockSize * 0.25
height: font.pixelSize
font.weight: Font.Medium
color: root.speedTextColor
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
}
// 悬停提示:网卡名 + IPv4 单行,IPv6 在第二行(仅有 IPv6 时追加)
// 设计原因:IPv6 地址较长,单行容纳不下;无 IPv6 时保持单行与原视觉一致
PanelToolTip { PanelToolTip {
id: toolTip id: toolTip
text: root.ready ? buildToolTipText() : qsTr("No network interface detected") text: root.ready
? (root.activeInterface
+ " · IPv4" + (root.ipAddress || qsTr("无"))
+ (root.ipv6Address ? "\nIPv6" + root.ipv6Address : ""))
: qsTr("未检测到网络接口")
toolTipX: DockPanelPositioner.x toolTipX: DockPanelPositioner.x
toolTipY: DockPanelPositioner.y toolTipY: DockPanelPositioner.y
} }
// 统计数据刷新定时器 // 注意:不再需要 QML 侧的 statsRefreshTimer。
Timer { // C++ 后端 NetworkMonitorApplet::init() 已启动 1 秒间隔的 m_refreshTimer
id: statsRefreshTimer // 持续调用 refresh()->readNetworkStats()->calculateSpeed() 更新速度属性。
interval: 1000 // QML 通过属性绑定自动获取最新值,无需主动触发 refresh。
repeat: true // 此前 QML 额外调用 applet.refresh() 会打破 1 秒定时间隔,
onTriggered: { // 导致 calculateSpeed() 基于不固定间隔计算字节差,速度显示接近 0。
if (root.applet) {
root.applet.refresh()
}
}
}
Timer { Timer {
id: toolTipShowTimer id: toolTipShowTimer
@@ -151,20 +290,15 @@ AppletItem {
HoverHandler { HoverHandler {
onHoveredChanged: { onHoveredChanged: {
// 仅控制 tooltip 显示,不触发 refresh。
// C++ 后端 m_refreshTimer 持续每秒更新速度属性,QML 属性绑定自动反映。
if (hovered && !networkPopup.popupVisible) { if (hovered && !networkPopup.popupVisible) {
toolTipShowTimer.start() toolTipShowTimer.start()
if (root.applet) {
root.applet.refresh()
}
statsRefreshTimer.start()
} else { } else {
if (toolTipShowTimer.running) { if (toolTipShowTimer.running) {
toolTipShowTimer.stop() toolTipShowTimer.stop()
} }
toolTip.close() toolTip.close()
if (!networkPopup.popupVisible) {
statsRefreshTimer.stop()
}
} }
} }
} }
@@ -172,20 +306,16 @@ AppletItem {
// 弹出窗口 // 弹出窗口
PanelPopup { PanelPopup {
id: networkPopup id: networkPopup
width: 400 width: 360
height: 350 height: 320
popupX: DockPanelPositioner.x popupX: DockPanelPositioner.x
popupY: DockPanelPositioner.y popupY: DockPanelPositioner.y
onPopupVisibleChanged: { onPopupVisibleChanged: {
// 仅控制 tooltip 关闭,不触发 refresh。
// C++ 后端 m_refreshTimer 持续更新,popup 通过属性绑定自动显示最新数据。
if (popupVisible) { if (popupVisible) {
toolTip.close() toolTip.close()
statsRefreshTimer.start()
if (root.applet) {
root.applet.refresh()
}
} else {
statsRefreshTimer.stop()
} }
} }
@@ -195,266 +325,296 @@ AppletItem {
padding: 16 padding: 16
contentItem: ColumnLayout { contentItem: ColumnLayout {
spacing: 14 spacing: 12
// 标题区域 // 标题栏:标题在上,网卡名+IP 在卡片底色背景内
RowLayout { Item {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 10 Layout.preferredHeight: 72
Item { Layout.fillWidth: true } ColumnLayout {
Rectangle {
width: 28
height: 28
color: accentBlueLight
radius: 8
Text {
anchors.centerIn: parent anchors.centerIn: parent
text: "NET" spacing: 6
font.pixelSize: 12
font.bold: true
color: accentBlue
}
}
Text { Text {
text: qsTr("Network Monitor") text: qsTr("网络速度监控")
font.pixelSize: 16 font.pixelSize: 15
font.bold: true font.weight: Font.Bold
color: root.primaryText color: root.primaryText
Layout.alignment: Qt.AlignHCenter
} }
Item { Layout.fillWidth: true } // 网卡名 + IP 地址卡片背景
// 刷新图标
Rectangle { Rectangle {
width: 28 Layout.alignment: Qt.AlignHCenter
height: 28 Layout.preferredWidth: 300
radius: 6 // 有 IPv6 时增高以容纳第三行;无 IPv6 时保持原 40px
color: refreshIconArea.containsMouse ? root.accentBlueLight : "transparent" Layout.preferredHeight: root.ipv6Address ? 54 : 40
color: root.cardBackground
Text { radius: 8
anchors.centerIn: parent border.width: 1
text: "⟳" border.color: root.cardBorder
font.pixelSize: 18
color: refreshIconArea.containsMouse ? root.accentBlue : root.secondaryText
}
MouseArea {
id: refreshIconArea
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.applet) {
root.applet.refresh()
}
}
}
// 旋转动画
RotationAnimator on rotation {
id: refreshSpin
running: false
from: 0
to: 360
duration: 500
}
Connections {
target: refreshIconArea
function onClicked() { refreshSpin.start() }
}
}
}
// 分隔线
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: root.cardBorder
}
// 当前接口信息
RowLayout {
Layout.fillWidth: true
spacing: 8
visible: root.ready visible: root.ready
Text { ColumnLayout {
text: qsTr("Interface:") anchors.centerIn: parent
font.pixelSize: 12 spacing: 0
color: root.secondaryText
}
Text { Text {
text: root.activeInterface text: root.activeInterface
font.pixelSize: 12 font.pixelSize: 11
font.bold: true color: root.secondaryText
color: root.primaryText Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true }
elide: Text.ElideRight
Text {
text: root.ipAddress ? qsTr("IPv4") + root.ipAddress : ""
font.pixelSize: 13
color: root.secondaryText
visible: root.ipAddress !== ""
Layout.alignment: Qt.AlignHCenter
}
// IPv6 全球地址行,无 IPv6 时隐藏不占位
Text {
text: root.ipv6Address ? qsTr("IPv6") + root.ipv6Address : ""
font.pixelSize: 13
color: root.secondaryText
visible: root.ipv6Address !== ""
Layout.alignment: Qt.AlignHCenter
}
}
}
} }
} }
// 速度统计卡片 // 实时速度卡片:双列布局,每列内容在各自半区内水平垂直居中
// 设计原因:用显式 anchor 分两个等宽半区,比 RowLayout+fillWidth 更可靠,
// 确保下载/上传内容各自在左/右半区正中显示
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: 120 Layout.preferredHeight: 110
color: root.cardBackground color: root.cardBackground
radius: 10 radius: 12
border.width: 1 border.width: 1
border.color: root.cardBorder border.color: root.cardBorder
// 下载半区(左半)
Item {
id: downloadHalf
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.leftMargin: 12
width: (parent.width - 1) / 2 - 12
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.centerIn: parent
anchors.margins: 12 spacing: 4
spacing: 12
// 下载速度 Rectangle {
RowLayout { width: 32
Layout.fillWidth: true height: 32
spacing: 8 radius: 16
color: root.accentBlueLight
Layout.alignment: Qt.AlignHCenter
Text { Text {
text: "↓ " + qsTr("Download") anchors.centerIn: parent
font.pixelSize: 13 text: "↓"
color: root.secondaryText
}
Item { Layout.fillWidth: true }
Text {
text: formatSpeed(root.downloadSpeed)
font.pixelSize: 18 font.pixelSize: 18
font.bold: true font.weight: Font.Bold
color: root.accentBlue color: root.accentBlue
} }
} }
// 上传速度
RowLayout {
Layout.fillWidth: true
spacing: 8
Text { Text {
text: "↑ " + qsTr("Upload") text: qsTr("下载")
font.pixelSize: 13 font.pixelSize: 11
color: root.secondaryText color: root.secondaryText
Layout.alignment: Qt.AlignHCenter
} }
Item { Layout.fillWidth: true }
Text { Text {
text: formatSpeed(root.uploadSpeed) text: root.formatSpeed(root.downloadSpeed)
font.pixelSize: 18 font.pixelSize: 18
font.bold: true font.weight: Font.Bold
color: Qt.rgba(22/255, 163/255, 74/255, 1) color: root.downloadValueColor
} Layout.alignment: Qt.AlignHCenter
} }
} }
} }
// 总量统计卡片 // 中间分隔线
Rectangle { Rectangle {
Layout.fillWidth: true anchors.horizontalCenter: parent.horizontalCenter
Layout.preferredHeight: 80 anchors.top: parent.top
color: root.cardBackground anchors.topMargin: 12
radius: 10 anchors.bottom: parent.bottom
border.width: 1 anchors.bottomMargin: 12
border.color: root.cardBorder width: 1
color: root.cardBorder
}
// 上传半区(右半)
Item {
id: uploadHalf
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.rightMargin: 12
width: (parent.width - 1) / 2 - 12
ColumnLayout { ColumnLayout {
anchors.centerIn: parent
spacing: 4
Rectangle {
width: 32
height: 32
radius: 16
color: root.accentGreenLight
Layout.alignment: Qt.AlignHCenter
Text {
anchors.centerIn: parent
text: "↑"
font.pixelSize: 18
font.weight: Font.Bold
color: root.accentGreen
}
}
Text {
text: qsTr("上传")
font.pixelSize: 11
color: root.secondaryText
Layout.alignment: Qt.AlignHCenter
}
Text {
text: root.formatSpeed(root.uploadSpeed)
font.pixelSize: 18
font.weight: Font.Bold
color: root.uploadValueColor
Layout.alignment: Qt.AlignHCenter
}
}
}
}
// 总量统计:单行紧凑
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 44
color: root.cardBackground
radius: 12
border.width: 1
border.color: root.cardBorder
visible: root.ready
RowLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: 12 anchors.margins: 12
spacing: 8 spacing: 8
Item { Layout.fillWidth: true }
Text { Text {
text: qsTr("Total Data") text: qsTr("总计")
font.pixelSize: 12 font.pixelSize: 11
font.bold: true font.weight: Font.Bold
color: root.secondaryText color: root.secondaryText
} }
RowLayout {
Layout.fillWidth: true
spacing: 20
// 下载总量
ColumnLayout {
spacing: 2
Text { Text {
text: "↓ " + qsTr("Download") text: "↓ " + root.formatTotal(root.totalDownload)
font.pixelSize: 11
color: root.tertiaryText
}
Text {
text: formatTotal(root.totalDownload)
font.pixelSize: 14
font.bold: true
color: root.primaryText
}
}
// 上传总量
ColumnLayout {
spacing: 2
Text {
text: "↑ " + qsTr("Upload")
font.pixelSize: 11
color: root.tertiaryText
}
Text {
text: formatTotal(root.totalUpload)
font.pixelSize: 14
font.bold: true
color: root.primaryText
}
}
}
}
}
// 接口切换下拉框
ComboBox {
id: interfaceCombo
Layout.fillWidth: true
Layout.preferredHeight: 32
visible: root.networkInterfaces.length > 1
model: root.networkInterfaces
currentIndex: {
var idx = root.networkInterfaces.indexOf(root.activeInterface)
return idx >= 0 ? idx : 0
}
onActivated: {
if (root.applet && root.networkInterfaces[index]) {
root.applet.setActiveInterface(root.networkInterfaces[index])
}
}
background: Rectangle {
color: interfaceCombo.hovered ? root.accentBlueLight : root.cardBackground
radius: 6
border.width: 1
border.color: root.cardBorder
}
contentItem: Text {
text: interfaceCombo.displayText
font.pixelSize: 12 font.pixelSize: 12
color: root.primaryText color: root.primaryText
verticalAlignment: Text.AlignVCenter }
leftPadding: 10
Text {
text: "↑ " + root.formatTotal(root.totalUpload)
font.pixelSize: 12
color: root.primaryText
}
Item { Layout.fillWidth: true }
} }
} }
// 未检测到网络接口提示 // 接口切换 chip 列表:水平滚动,活动接口在最左,物理网卡次之,虚拟网卡在后
// chip 少时分散撑满一行;chip 多超出宽度时固定宽度 + 水平滚动
Flickable {
id: chipFlickable
Layout.fillWidth: true
Layout.preferredHeight: 32
visible: root.ready && root.networkInterfaces.length > 1
contentWidth: Math.max(chipRow.width, width)
contentHeight: chipRow.height
flickableDirection: Flickable.HorizontalFlick
clip: true
// 等宽分配宽度:假设所有 chip 等宽撑满时的单个宽度
readonly property real equalChipWidth: root.sortedInterfaces.length > 0
? (width - 6 * (root.sortedInterfaces.length - 1)) / root.sortedInterfaces.length
: 0
Row {
id: chipRow
spacing: 6
Repeater {
model: root.sortedInterfaces
Rectangle {
// 宽度取等宽和自然宽度的较大值:
// chip 少时 equalChipWidth > 自然宽度 -> 分散撑满一行
// chip 多时 自然宽度 > equalChipWidth -> 固定宽度 + 水平滚动
width: Math.max(chipFlickable.equalChipWidth, chipText.implicitWidth + 24)
height: 28
radius: 8
color: modelData === root.activeInterface
? root.accentBlue
: (chipMouse.containsMouse ? root.accentBlueLight : root.cardBackground)
border.width: 1
border.color: modelData === root.activeInterface
? root.accentBlue
: root.cardBorder
Text {
id: chipText
anchors.centerIn: parent
text: modelData
font.pixelSize: 11
font.weight: modelData === root.activeInterface ? Font.Bold : Font.Normal
color: modelData === root.activeInterface ? "white" : root.primaryText
}
MouseArea {
id: chipMouse
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onClicked: {
if (root.applet) root.applet.setActiveInterface(modelData)
}
}
}
}
}
ScrollBar.horizontal: ScrollBar {
policy: ScrollBar.AsNeeded
}
}
// 未检测到接口占位
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: 60 Layout.preferredHeight: 80
color: root.cardBackground color: root.cardBackground
radius: 10 radius: 12
border.width: 1 border.width: 1
border.color: root.cardBorder border.color: root.cardBorder
visible: !root.ready visible: !root.ready
@@ -465,13 +625,16 @@ AppletItem {
Text { Text {
text: "⚠" text: "⚠"
font.pixelSize: 20 font.pixelSize: 24
color: root.secondaryText
Layout.alignment: Qt.AlignHCenter
} }
Text { Text {
text: qsTr("No network interface detected") text: qsTr("未检测到网络接口")
font.pixelSize: 12 font.pixelSize: 12
color: root.secondaryText color: root.secondaryText
Layout.alignment: Qt.AlignHCenter
} }
} }
} }
@@ -488,17 +651,70 @@ AppletItem {
} }
} }
// 构建 tooltip 文本 // 右键菜单:使用 Qt.labs.platform.Menu + MenuHelper
function buildToolTipText() { // 设计原因:dde-shell dock 环境下 AppletItem 嵌入 layer-shell 窗口,
if (!root.ready) { // QtQuick.Controls.Menu.popup() 无法获取有效 QQuickWindow 来弹出菜单。
return qsTr("No network interface detected") // 必须用 Qt.labs.platform.Menu(原生菜单)配合 org.deepin.ds.dock 的
// MenuHelper 单例管理菜单生命周期(确保同一时间只有一个菜单打开)。
Platform.Menu {
id: contextMenu
Platform.MenuItem {
text: qsTr("设置")
onTriggered: {
settingsWindow.show()
settingsWindow.raise()
settingsWindow.requestActivate()
}
} }
var lines = [] // 流量波动图:屏幕居中独立窗口,展示活动接口最近 30 分钟网速趋势
lines.push("↓ " + formatSpeed(root.downloadSpeed)) Platform.MenuItem {
lines.push("↑ " + formatSpeed(root.uploadSpeed)) text: qsTr("流量波动图")
lines.push(root.activeInterface) onTriggered: {
return lines.join(" | ") trafficChartWindow.show()
trafficChartWindow.raise()
trafficChartWindow.requestActivate()
}
}
Platform.MenuSeparator {}
Platform.MenuItem {
text: qsTr("关于")
onTriggered: {
aboutWindow.show()
aboutWindow.raise()
aboutWindow.requestActivate()
}
}
}
// 关于窗口:抽取为独立组件 package/components/AboutWindow.qml
// 依赖通过属性传入:accentColor = root.accentRedversion 取自 C++ 后端 applet.version
AboutWindow {
id: aboutWindow
accentColor: root.accentRed
version: root.applet ? root.applet.version : "1.0"
}
// 流量波动图窗口:屏幕居中独立窗口,展示活动接口最近 30 分钟网速趋势
// 依赖通过属性传入:accentColor = root.accentRedapplet = root.applet
TrafficChartWindow {
id: trafficChartWindow
accentColor: root.accentRed
applet: root.applet
}
// 设置窗口:独立顶层窗口,在桌面中间弹出
// 设计原因:Dialog 使用父窗口(dock layer-surface)的 overlay,仅覆盖任务栏区域,
// 无法在桌面中间显示。改用 Window 创建独立顶层窗口,可在桌面任意位置弹出。
SettingsWindow {
id: settingsWindow
applet: root.applet
networkInterfaces: root.networkInterfaces
activeInterface: root.activeInterface
accentColor: root.accentRed
} }
// 点击处理 // 点击处理
@@ -518,4 +734,19 @@ AppletItem {
toolTip.close() toolTip.close()
} }
} }
// 右键点击:弹出上下文菜单
TapHandler {
acceptedButtons: Qt.RightButton
gesturePolicy: TapHandler.ReleaseWithinBounds
onTapped: {
// 关闭可能打开的 popup,避免视觉冲突
if (networkPopup.popupVisible) networkPopup.close()
toolTip.close()
// 使用 MenuHelper 打开菜单(dde-shell dock 环境必需,
// 它会先关闭当前活动菜单再打开新菜单,保证同一时间只有一个菜单)
MenuHelper.openMenu(contextMenu)
}
}
} }
+465
View File
@@ -0,0 +1,465 @@
// SPDX-FileCopyrightText: 2024 MyCompany
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#include "networkmonitorapplet.h"
#include <pluginfactory.h>
#include <QFile>
#include <QTextStream>
#include <QDebug>
#include <QDir>
#include <QRegularExpression>
#include <QNetworkInterface>
#include <QColor>
#include <QSettings>
#include <QStandardPaths>
DS_BEGIN_NAMESPACE
NetworkMonitorApplet::NetworkMonitorApplet(QObject *parent)
: DApplet(parent)
, m_refreshTimer(nullptr)
, m_lastRxBytes(0)
, m_lastTxBytes(0)
, m_downloadSpeed(0)
, m_uploadSpeed(0)
, m_totalDownload(0)
, m_totalUpload(0)
, m_ready(false)
, m_firstUpdate(true)
, m_textColor()
{
// 从独立配置文件读取持久化的字体颜色
// 设计原因:使用独立配置文件避免污染 dde-shell 的共享配置,
// 空串表示"跟随系统",由 QML 层回退到 primaryText 实现
const QString configPath = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)
+ QStringLiteral("/jnetapplet/settings.ini");
QSettings settings(configPath, QSettings::IniFormat);
if (settings.contains(QStringLiteral("textColor"))) {
const QString saved = settings.value(QStringLiteral("textColor")).toString();
// 空串合法(跟随系统),非空则校验是否为合法颜色值
if (saved.isEmpty() || QColor::isValidColorName(saved)) {
m_textColor = saved;
}
// 若校验失败则保持空串默认值(跟随系统),不抛出也不记录
}
}
NetworkMonitorApplet::~NetworkMonitorApplet()
{
}
bool NetworkMonitorApplet::load()
{
return DApplet::load();
}
bool NetworkMonitorApplet::init()
{
detectInterfaces();
// 启动定时刷新
m_refreshTimer = new QTimer(this);
connect(m_refreshTimer, &QTimer::timeout, this, &NetworkMonitorApplet::refresh);
m_refreshTimer->start(1000); // 每秒刷新一次
// 初始读取
readNetworkStats();
return DApplet::init();
}
double NetworkMonitorApplet::downloadSpeed() const
{
return static_cast<double>(m_downloadSpeed);
}
double NetworkMonitorApplet::uploadSpeed() const
{
return static_cast<double>(m_uploadSpeed);
}
double NetworkMonitorApplet::totalDownload() const
{
return static_cast<double>(m_totalDownload);
}
double NetworkMonitorApplet::totalUpload() const
{
return static_cast<double>(m_totalUpload);
}
QStringList NetworkMonitorApplet::networkInterfaces() const
{
return m_interfaceList;
}
QStringList NetworkMonitorApplet::interfaceStats() const
{
QStringList stats;
for (const QString &name : m_interfaceList) {
if (m_interfaces.contains(name)) {
const NetworkInterface &iface = m_interfaces[name];
stats << QString("%1|%2|%3|%4|%5")
.arg(iface.name)
.arg(iface.rxBytes)
.arg(iface.txBytes)
.arg(iface.rxPackets)
.arg(iface.txPackets);
}
}
return stats;
}
bool NetworkMonitorApplet::ready() const
{
return m_ready;
}
QString NetworkMonitorApplet::activeInterface() const
{
return m_activeInterface;
}
// 返回活动接口的 IPv4 地址,供 QML 显示
QString NetworkMonitorApplet::ipAddress() const
{
return m_ipAddress;
}
// 返回活动接口的 IPv6 全球地址,供 QML 显示
QString NetworkMonitorApplet::ipv6Address() const
{
return m_ipv6Address;
}
// 返回插件版本号,从 dde-shell 插件元数据(metadata.json)读取
// 版本唯一源为 CMakeLists.txt 的 project(VERSION),经 configure_file 写入 metadata.json
// dde-shell 加载时存入 DPluginMetaData,此处通过继承自 DApplet 的 pluginMetaData() 读取
QString NetworkMonitorApplet::version() const
{
// DPluginMetaData::value() 已在 "Plugin" 层级内部查找(源码:frame/pluginmetadata.cpp
// metadata.json 的 { "Plugin": { "Version": "x.y.z" } } 被 DPluginMetaData 加载后,
// value("Version") 直接返回版本号,无需再 .value("Plugin").toMap() 展开
return pluginMetaData().value("Version").toString();
}
// 返回任务栏网速字体颜色,空串表示跟随系统主题
QString NetworkMonitorApplet::textColor() const
{
return m_textColor;
}
// 设置任务栏网速字体颜色,空串表示跟随系统主题
// 设计原因:
// - 空串语义:空串作为"跟随系统"的语义值,QML 层用 textColor ? textColor : primaryText 做回退,
// 避免后端硬编码系统色
// - 校验逻辑:非空颜色值通过 QColor::isValidColor 校验,无效值直接忽略,不存储不发射信号
// - 独立配置文件:使用 ~/.config/jnetapplet/settings.ini 而非 dde-shell 共享配置,
// 避免污染其他组件的配置空间
void NetworkMonitorApplet::setTextColor(const QString &color)
{
// 空串视为合法(表示跟随系统),不校验
if (!color.isEmpty() && !QColor::isValidColorName(color)) {
return;
}
if (m_textColor == color) {
return;
}
m_textColor = color;
// 持久化到独立配置文件
const QString configPath = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)
+ QStringLiteral("/jnetapplet/settings.ini");
QSettings settings(configPath, QSettings::IniFormat);
settings.setValue(QStringLiteral("textColor"), m_textColor);
settings.sync();
emit textColorChanged();
}
// 返回当前活动接口的下行速度历史,转换为 QVariantList of QPointF 供 QML 使用
// 设计原因:QPointF 仅携带 x/y 两个值,上传与下载分别对应两个列表
QVariantList NetworkMonitorApplet::speedHistoryDownload() const
{
QVariantList list;
if (m_activeInterface.isEmpty() || !m_speedHistory.contains(m_activeInterface)) {
return list;
}
const QVector<SpeedSample> &samples = m_speedHistory[m_activeInterface];
list.reserve(samples.size());
for (const SpeedSample &s : samples) {
list.append(QPointF(s.timestamp, s.downloadSpeed));
}
return list;
}
// 返回当前活动接口的上行速度历史,结构同 speedHistoryDownload
QVariantList NetworkMonitorApplet::speedHistoryUpload() const
{
QVariantList list;
if (m_activeInterface.isEmpty() || !m_speedHistory.contains(m_activeInterface)) {
return list;
}
const QVector<SpeedSample> &samples = m_speedHistory[m_activeInterface];
list.reserve(samples.size());
for (const SpeedSample &s : samples) {
list.append(QPointF(s.timestamp, s.uploadSpeed));
}
return list;
}
void NetworkMonitorApplet::refresh()
{
readNetworkStats();
}
void NetworkMonitorApplet::setActiveInterface(const QString &interface)
{
if (m_activeInterface != interface) {
m_activeInterface = interface;
m_firstUpdate = true;
emit activeInterfaceChanged();
// 接口切换后通知 QML 趋势图切换显示新接口的历史数据
emit speedHistoryChanged();
// 接口切换后立即检测新接口的 IP 地址
detectIpAddress();
}
}
void NetworkMonitorApplet::readNetworkStats()
{
QFile file("/proc/net/dev");
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
qDebug() << "Failed to open /proc/net/dev";
return;
}
QTextStream stream(&file);
QStringList lines = stream.readAll().split('\n', Qt::SkipEmptyParts);
file.close();
QMap<QString, NetworkInterface> newInterfaces;
// 跳过前两行标题
for (int i = 2; i < lines.size(); ++i) {
QString line = lines[i].trimmed();
if (line.isEmpty()) continue;
// 格式: "interface: rx_bytes rx_packets rx_errors rx_dropped ... tx_bytes tx_packets tx_errors tx_dropped ..."
QRegularExpression re("^([^:]+):\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+\\d+\\s+\\d+\\s+\\d+\\s+\\d+\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)");
QRegularExpressionMatch match = re.match(line);
if (match.hasMatch()) {
NetworkInterface iface;
iface.name = match.captured(1);
iface.rxBytes = match.captured(2).toLongLong();
iface.rxPackets = match.captured(3).toLongLong();
iface.rxErrors = match.captured(4).toLongLong();
iface.rxDropped = match.captured(5).toLongLong();
iface.txBytes = match.captured(6).toLongLong();
iface.txPackets = match.captured(7).toLongLong();
iface.txErrors = match.captured(8).toLongLong();
iface.txDropped = match.captured(9).toLongLong();
// 过滤掉回环接口和虚拟接口
if (iface.name != "lo" && !iface.name.startsWith("veth") &&
!iface.name.startsWith("docker") && !iface.name.startsWith("br-")) {
newInterfaces[iface.name] = iface;
}
}
}
// 检测接口变化
if (newInterfaces.keys() != m_interfaces.keys()) {
m_interfaceList = newInterfaces.keys();
m_interfaces = newInterfaces;
// 清理已消失接口的历史缓冲,避免内存泄漏
// 设计原因:USB 网卡、VPN 隧道等接口可能随时消失,其历史数据不再需要
const QStringList oldIfaces = m_speedHistory.keys();
for (const QString &name : oldIfaces) {
if (!newInterfaces.contains(name)) {
m_speedHistory.remove(name);
}
}
// 自动选择活动接口
if (m_activeInterface.isEmpty() && !m_interfaceList.isEmpty()) {
// 优先选择物理网卡(wlp/wlan/enp/eth)中有流量的接口,
// 避免选中 Meta/tun0 等虚拟代理接口作为默认显示
for (const QString &name : m_interfaceList) {
if (!isPhysicalInterface(name)) continue;
const NetworkInterface &iface = m_interfaces[name];
if (iface.rxBytes > 0 || iface.txBytes > 0) {
m_activeInterface = name;
break;
}
}
// 其次选择任意有流量的接口(物理网卡都无流量时的兜底)
if (m_activeInterface.isEmpty()) {
for (const QString &name : m_interfaceList) {
const NetworkInterface &iface = m_interfaces[name];
if (iface.rxBytes > 0 || iface.txBytes > 0) {
m_activeInterface = name;
break;
}
}
}
// 最后选第一个(无任何流量时)
if (m_activeInterface.isEmpty()) {
m_activeInterface = m_interfaceList.first();
}
emit activeInterfaceChanged();
}
m_ready = !m_interfaceList.isEmpty();
emit interfacesChanged();
emit readyChanged();
} else {
m_interfaces = newInterfaces;
}
calculateSpeed();
emit statsChanged();
// 每次刷新都检测 IP,以应对 DHCP 续约等 IP 变更场景
detectIpAddress();
}
void NetworkMonitorApplet::calculateSpeed()
{
qint64 currentRxBytes = getActiveRxBytes();
qint64 currentTxBytes = getActiveTxBytes();
if (m_firstUpdate) {
m_firstUpdate = false;
m_lastRxBytes = currentRxBytes;
m_lastTxBytes = currentTxBytes;
return;
}
// 计算速度(字节/秒)
m_downloadSpeed = currentRxBytes - m_lastRxBytes;
m_uploadSpeed = currentTxBytes - m_lastTxBytes;
// 更新总量
m_totalDownload = currentRxBytes;
m_totalUpload = currentTxBytes;
m_lastRxBytes = currentRxBytes;
m_lastTxBytes = currentTxBytes;
// 追加采样点到当前活动接口的环形缓冲
// 设计原因:applet 启动即持续采集,用户随时打开趋势图都能看到完整 5 分钟数据
if (!m_activeInterface.isEmpty()) {
SpeedSample sample;
sample.timestamp = QDateTime::currentSecsSinceEpoch();
sample.downloadSpeed = static_cast<double>(m_downloadSpeed);
sample.uploadSpeed = static_cast<double>(m_uploadSpeed);
m_speedHistory[m_activeInterface].append(sample);
// 滑动窗口裁剪:超过 300 点时丢弃最旧的
// removeFirst 是 O(n),但 300 点每秒一次开销可忽略,无需复杂环形索引
QVector<SpeedSample> &samples = m_speedHistory[m_activeInterface];
while (samples.size() > MAX_HISTORY_SAMPLES) {
samples.removeFirst();
}
emit speedHistoryChanged();
}
emit speedChanged();
emit totalChanged();
}
void NetworkMonitorApplet::detectInterfaces()
{
QDir sysClass("/sys/class/net");
if (!sysClass.exists()) return;
QStringList entries = sysClass.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
m_interfaceList.clear();
for (const QString &entry : entries) {
// 过滤回环接口
if (entry != "lo") {
m_interfaceList << entry;
}
}
m_ready = !m_interfaceList.isEmpty();
emit interfacesChanged();
emit readyChanged();
}
qint64 NetworkMonitorApplet::getActiveRxBytes() const
{
if (m_activeInterface.isEmpty() || !m_interfaces.contains(m_activeInterface)) {
return 0;
}
return m_interfaces[m_activeInterface].rxBytes;
}
qint64 NetworkMonitorApplet::getActiveTxBytes() const
{
if (m_activeInterface.isEmpty() || !m_interfaces.contains(m_activeInterface)) {
return 0;
}
return m_interfaces[m_activeInterface].txBytes;
}
// 检测活动接口的 IPv4 与 IPv6 地址
// IPv4:跳过 loopback,取第一个
// IPv6:跳过 loopback 和 link-local (fe80::),取第一个全球地址
// 任一地址变化时分别 emit 对应信号通知 QML 更新
// 设计原因:DHCP 续约、网络切换、IPv6 SLAAC 等场景下地址可能变化,需持续检测
void NetworkMonitorApplet::detectIpAddress()
{
QString newIp;
QString newIpv6;
if (!m_activeInterface.isEmpty()) {
QNetworkInterface iface = QNetworkInterface::interfaceFromName(m_activeInterface);
for (const QNetworkAddressEntry &entry : iface.addressEntries()) {
// IPv4:跳过 loopback,取第一个
if (entry.ip().protocol() == QAbstractSocket::IPv4Protocol
&& !entry.ip().isLoopback()
&& newIp.isEmpty()) {
newIp = entry.ip().toString();
}
// IPv6:跳过 loopback 和 link-local (fe80::),取第一个全球地址
if (entry.ip().protocol() == QAbstractSocket::IPv6Protocol
&& !entry.ip().isLoopback()
&& !entry.ip().isLinkLocal()
&& newIpv6.isEmpty()) {
newIpv6 = entry.ip().toString();
}
}
}
if (m_ipAddress != newIp) {
m_ipAddress = newIp;
emit ipAddressChanged();
}
if (m_ipv6Address != newIpv6) {
m_ipv6Address = newIpv6;
emit ipv6AddressChanged();
}
}
// 判断是否为物理网卡
// 物理网卡前缀:无线 wlp/wlan/wifi,有线 enp/eth
// 虚拟接口(Meta/tun/tap/docker/veth/br-)返回 false
// 用于自动选择时优先真实网卡而非代理 TUN 接口
bool NetworkMonitorApplet::isPhysicalInterface(const QString &name) const
{
return name.startsWith("wlp") || name.startsWith("wlan")
|| name.startsWith("enp") || name.startsWith("eth");
}
D_APPLET_CLASS(NetworkMonitorApplet)
DS_END_NAMESPACE
#include "networkmonitorapplet.moc"
+157
View File
@@ -0,0 +1,157 @@
// SPDX-FileCopyrightText: 2024 MyCompany
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#pragma once
#include <applet.h>
#include <QString>
#include <QStringList>
#include <QTimer>
#include <QVariantList>
#include <QMap>
#include <QHash>
#include <QPointF>
#include <QDateTime>
#include <QColor>
#include <QSettings>
DS_BEGIN_NAMESPACE
struct NetworkInterface {
QString name; // 接口名称,如 "eth0", "wlan0"
qint64 rxBytes; // 接收字节数
qint64 txBytes; // 发送字节数
qint64 rxPackets; // 接收包数
qint64 txPackets; // 发送包数
qint64 rxErrors; // 接收错误数
qint64 txErrors; // 发送错误数
qint64 rxDropped; // 接收丢弃数
qint64 txDropped; // 发送丢弃数
};
// 单个速度采样点:用于趋势图的环形缓冲
// timestamp: 自 epoch 的秒级时间戳,作为 X 轴定位
// downloadSpeed / uploadSpeed: 当时的瞬时速度(字节/秒),作为两条折线的 Y 值
struct SpeedSample {
qint64 timestamp;
double downloadSpeed;
double uploadSpeed;
};
class NetworkMonitorApplet : public DApplet
{
Q_OBJECT
Q_PROPERTY(double downloadSpeed READ downloadSpeed NOTIFY speedChanged)
Q_PROPERTY(double uploadSpeed READ uploadSpeed NOTIFY speedChanged)
Q_PROPERTY(double totalDownload READ totalDownload NOTIFY totalChanged)
Q_PROPERTY(double totalUpload READ totalUpload NOTIFY totalChanged)
Q_PROPERTY(QStringList networkInterfaces READ networkInterfaces NOTIFY interfacesChanged)
Q_PROPERTY(QStringList interfaceStats READ interfaceStats NOTIFY statsChanged)
Q_PROPERTY(bool ready READ ready NOTIFY readyChanged)
Q_PROPERTY(QString activeInterface READ activeInterface NOTIFY activeInterfaceChanged)
// 活动接口的 IPv4 地址,供 QML 在弹出面板和 tooltip 中显示
Q_PROPERTY(QString ipAddress READ ipAddress NOTIFY ipAddressChanged)
// 活动接口的 IPv6 全球地址(已过滤 link-local 和 loopback),供 QML 显示
Q_PROPERTY(QString ipv6Address READ ipv6Address NOTIFY ipv6AddressChanged)
// 插件版本号,从 dde-shell 元数据读取,版本唯一源为 CMakeLists.txt project(VERSION)
Q_PROPERTY(QString version READ version CONSTANT)
// 任务栏网速数值字体颜色,空串表示跟随系统主题(由 QML 回退到 primaryText),
// 用户在设置窗口选择后持久化到 ~/.config/jnetapplet/settings.ini
Q_PROPERTY(QString textColor READ textColor WRITE setTextColor NOTIFY textColorChanged)
// 当前活动接口的下行速度历史采样点(QPointF 列表:x=时间戳秒, y=速度 bytes/sec
// 用于趋势图绘制下载折线,每次 calculateSpeed 追加一个点并发射 speedHistoryChanged
Q_PROPERTY(QVariantList speedHistoryDownload READ speedHistoryDownload NOTIFY speedHistoryChanged)
// 当前活动接口的上行速度历史采样点,结构同 speedHistoryDownload
Q_PROPERTY(QVariantList speedHistoryUpload READ speedHistoryUpload NOTIFY speedHistoryChanged)
public:
explicit NetworkMonitorApplet(QObject *parent = nullptr);
~NetworkMonitorApplet();
virtual bool load() override;
virtual bool init() override;
double downloadSpeed() const;
double uploadSpeed() const;
double totalDownload() const;
double totalUpload() const;
QStringList networkInterfaces() const;
QStringList interfaceStats() const;
bool ready() const;
QString activeInterface() const;
QString ipAddress() const;
QString ipv6Address() const;
// 返回当前活动接口的下行速度历史(QPointF 列表),供 QML 趋势图绘制
QVariantList speedHistoryDownload() const;
// 返回当前活动接口的上行速度历史(QPointF 列表),供 QML 趋势图绘制
QVariantList speedHistoryUpload() const;
// 返回插件版本号,从 dde-shell 插件元数据(metadata.json)读取
QString version() const;
// 返回任务栏网速字体颜色,空串表示跟随系统主题
QString textColor() const;
// 设置任务栏网速字体颜色,空串表示跟随系统主题
void setTextColor(const QString &color);
Q_INVOKABLE void refresh();
Q_INVOKABLE void setActiveInterface(const QString &interface);
signals:
void speedChanged();
void totalChanged();
void interfacesChanged();
void statsChanged();
void readyChanged();
void activeInterfaceChanged();
void ipAddressChanged();
void ipv6AddressChanged();
void speedHistoryChanged();
// 任务栏网速字体颜色变化时通知 QML 更新
void textColorChanged();
private:
void readNetworkStats();
void calculateSpeed();
void detectInterfaces();
// 检测活动接口的 IPv4 与 IPv6 地址,变化时分别发射对应信号
void detectIpAddress();
// 判断是否为物理网卡(无线 wlp/wlan,有线 enp/eth),
// 用于自动选择时优先真实网卡而非虚拟代理接口(如 Meta/tun0)
bool isPhysicalInterface(const QString &name) const;
qint64 getActiveRxBytes() const;
qint64 getActiveTxBytes() const;
QTimer *m_refreshTimer;
QMap<QString, NetworkInterface> m_interfaces;
QStringList m_interfaceList;
QString m_activeInterface;
QString m_ipAddress; // 活动接口的 IPv4 地址
QString m_ipv6Address; // 活动接口的 IPv6 全球地址(过滤 link-local
// 速度计算
qint64 m_lastRxBytes;
qint64 m_lastTxBytes;
qint64 m_downloadSpeed;
qint64 m_uploadSpeed;
// 总量
qint64 m_totalDownload;
qint64 m_totalUpload;
bool m_ready;
bool m_firstUpdate;
// 任务栏网速字体颜色,空串表示跟随系统主题
// 设计原因:空串作为"跟随系统"的语义值,QML 层用 textColor ? textColor : primaryText
// 做回退,避免后端硬编码系统色
QString m_textColor;
// 速度历史环形缓冲:每个接口独立维护一份,key 为接口名
// 设计原因:接口切换时趋势图不出现跳变,切回时仍能看到该接口历史
QHash<QString, QVector<SpeedSample>> m_speedHistory;
// 每个接口最多保留的采样点数:5 分钟 × 60 秒 = 300
static constexpr int MAX_HISTORY_SAMPLES = 300;
};
DS_END_NAMESPACE