Compare commits
35 Commits
2beafd4cf1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 42028d93bd | |||
| af7ae6b334 | |||
| e53981f828 | |||
| 9f11b32b5e | |||
| b9b5232d5f | |||
| 7f7ab2d737 | |||
| c4246cd122 | |||
| 539442ef48 | |||
| 647d0878de | |||
| d4627f82d5 | |||
| a12a106791 | |||
| 02c6d055ce | |||
| 9a02f10eb8 | |||
| 4c6bc6f689 | |||
| f7157e15e8 | |||
| 22900737b0 | |||
| ef37fbbcbf | |||
| 959371c236 | |||
| d0b8272ec6 | |||
| c1a535a005 | |||
| c07f17d212 | |||
| fcb43fe34f | |||
| 6b2721c821 | |||
| e83fb0e269 | |||
| 09e64d4859 | |||
| d53409808c | |||
| 0f58dcad03 | |||
| 6325fb51cc | |||
| 6bc1a3e3fb | |||
| 903e0f1801 | |||
| 3c92359407 | |||
| 170e602945 | |||
| 02ac3faf85 | |||
| b80769c26e | |||
| 790103698e |
@@ -1,7 +1,7 @@
|
||||
# AGENTS.md
|
||||
|
||||
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`.
|
||||
|
||||
## What this is
|
||||
@@ -68,11 +68,14 @@ then restart `dde-shell` (it discovers applets by scanning the install dir for
|
||||
├── CMakeLists.txt # Build configuration
|
||||
├── install.sh # Install script
|
||||
├── build-deb.sh # Deb packaging script
|
||||
├── networkmonitorapplet.h # C++ backend header
|
||||
├── networkmonitorapplet.cpp # C++ backend implementation
|
||||
├── src/
|
||||
│ ├── networkmonitorapplet.h # C++ backend header
|
||||
│ └── networkmonitorapplet.cpp # C++ backend implementation
|
||||
├── package/
|
||||
│ ├── metadata.json # Plugin metadata
|
||||
│ └── networkview.qml # QML UI
|
||||
│ ├── metadata.json.in # Plugin metadata 模板(由 CMake 生成 metadata.json)
|
||||
│ ├── networkview.qml # QML UI 主入口
|
||||
│ └── components/ # 拆分出的子组件
|
||||
│ └── AboutWindow.qml # 关于窗口组件
|
||||
├── docs/ # Design docs and implementation plans
|
||||
└── AGENTS.md # This file
|
||||
```
|
||||
@@ -112,15 +115,26 @@ Inherits from `DApplet`, provides:
|
||||
|---|---|---|
|
||||
| `CMakeLists.txt` | `add_library(...)` target | `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.
|
||||
Keep it pointing at the entry QML.
|
||||
|
||||
## Required metadata fields (QML applet)
|
||||
|
||||
`metadata.json` must contain `Plugin.Version`, `Plugin.Id`, `Plugin.Url`, and
|
||||
`Plugin.Parent`. The current file is complete — don't drop any.
|
||||
`package/metadata.json.in` is the template; CMake's `configure_file()` generates the
|
||||
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
|
||||
|
||||
|
||||
+16
-5
@@ -1,6 +1,6 @@
|
||||
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_REQUIRED ON)
|
||||
@@ -14,14 +14,15 @@ find_package(DDEShell REQUIRED)
|
||||
set(PLUGIN_ID "space.jokul.JNetApplet")
|
||||
|
||||
add_library(space.jokul.JNetApplet SHARED
|
||||
networkmonitorapplet.h
|
||||
networkmonitorapplet.cpp
|
||||
src/networkmonitorapplet.h
|
||||
src/networkmonitorapplet.cpp
|
||||
)
|
||||
|
||||
set_target_properties(space.jokul.JNetApplet PROPERTIES PREFIX "")
|
||||
|
||||
# 头文件搜索路径指向 src/,使 #include "networkmonitorapplet.h" 可被外部解析
|
||||
target_include_directories(space.jokul.JNetApplet PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
/usr/include/dde-shell
|
||||
)
|
||||
|
||||
@@ -36,5 +37,15 @@ target_link_libraries(space.jokul.JNetApplet PRIVATE
|
||||
|
||||
# 安装到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.json,install 安装生成文件
|
||||
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})
|
||||
|
||||
# 安装 components 子目录下所有 QML 组件
|
||||
# 设计原因:拆分出的子组件需随主 QML 一起安装到 dde-shell 插件目录,
|
||||
# 否则 networkview.qml 的 import "components" 在运行时找不到类型
|
||||
install(DIRECTORY package/components DESTINATION /usr/share/dde-shell/${PLUGIN_ID})
|
||||
@@ -19,7 +19,24 @@ QML 前端渲染界面,实时显示当前网卡的上行/下行速率与累计
|
||||
|
||||
## 构建与安装
|
||||
|
||||
需要配置期存在 `DDEShell` CMake 包(来自 `dde-shell` 开发包)。
|
||||
### 构建依赖
|
||||
|
||||
构建需要以下开发包:
|
||||
|
||||
```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` 包仅构建时需要。
|
||||
|
||||
### 方式一:从源码安装
|
||||
|
||||
@@ -65,11 +82,14 @@ bash build-deb.sh
|
||||
├── CMakeLists.txt # 构建配置
|
||||
├── install.sh # 安装脚本
|
||||
├── build-deb.sh # deb 一键打包脚本
|
||||
├── networkmonitorapplet.h # C++ 后端头文件
|
||||
├── networkmonitorapplet.cpp # C++ 后端实现
|
||||
├── src/
|
||||
│ ├── networkmonitorapplet.h # C++ 后端头文件
|
||||
│ └── networkmonitorapplet.cpp # C++ 后端实现
|
||||
├── package/
|
||||
│ ├── metadata.json # 插件元数据
|
||||
│ └── networkview.qml # QML 界面
|
||||
│ ├── metadata.json.in # 插件元数据模板(由 CMake 生成 metadata.json)
|
||||
│ ├── networkview.qml # QML 界面主入口
|
||||
│ └── components/ # 拆分出的子组件
|
||||
│ └── AboutWindow.qml # 关于窗口组件
|
||||
├── docs/ # 设计文档与实现计划
|
||||
├── README.md # 本文件
|
||||
└── AGENTS.md # AI 代理工作指引
|
||||
|
||||
+48
-8
@@ -6,6 +6,25 @@
|
||||
|
||||
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"
|
||||
@@ -41,22 +60,43 @@ for cmd in cmake dpkg-deb; do
|
||||
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 "[1/5] 构建 CMake 项目..."
|
||||
echo "[2/6] 构建 CMake 项目..."
|
||||
rm -rf build
|
||||
cmake -B build -S . -DCMAKE_BUILD_TYPE=Release 2>&1 | tail -3
|
||||
cmake --build build -j"$(nproc)" 2>&1 | tail -3
|
||||
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 "[2/5] 安装到 staging 目录..."
|
||||
echo "[3/6] 安装到 staging 目录..."
|
||||
# DESTDIR 指定安装根目录前缀,cmake 会将 /usr/... 安装到 $STAGE_DIR/usr/...
|
||||
DESTDIR="$STAGE_DIR" cmake --install build 2>&1 | tail -3
|
||||
run_step "安装到 staging" env DESTDIR="$STAGE_DIR" cmake --install build || exit 1
|
||||
|
||||
# 生成 DEBIAN/control
|
||||
echo "[3/5] 生成 control 文件..."
|
||||
echo "[4/6] 生成 control 文件..."
|
||||
mkdir -p "$STAGE_DIR/DEBIAN"
|
||||
|
||||
# 计算安装后大小(KB)
|
||||
@@ -109,12 +149,12 @@ EOF
|
||||
chmod 755 "$STAGE_DIR/DEBIAN/prerm"
|
||||
|
||||
# 修正文件权限(so 文件需要 755,qml/json 需要 644)
|
||||
echo "[4/5] 修正文件权限..."
|
||||
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 "[5/5] 构建 deb 包..."
|
||||
echo "[6/6] 构建 deb 包..."
|
||||
rm -f "$DEB_FILE"
|
||||
dpkg-deb --build --root-owner-group "$STAGE_DIR" "$DEB_FILE"
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# JNetApplet 项目审查清单
|
||||
|
||||
> 审查日期:2026-07-22
|
||||
> 审查范围:全部源码(C++ 后端 + QML 前端 + 构建脚本 + 文档)
|
||||
|
||||
---
|
||||
|
||||
## 一、缺陷(Bug / 逻辑错误)
|
||||
|
||||
### 🔴 高优先级
|
||||
|
||||
**1. ~~速度计算假设定时间隔严格为 1 秒~~ ✅ 已修复**
|
||||
~~`calculateSpeed()` 直接用 `currentRxBytes - m_lastRxBytes` 作为 bytes/sec,未除以实际流逝时间。系统负载高或定时器抖动时,速度值会失真。~~
|
||||
已改为记录上次采样的毫秒时间戳 `m_lastTimestampMs`,按真实流逝时间 `delta_bytes / elapsed_seconds` 计算速度;速度存储由 `qint64` 改为 `double` 保留精度;`elapsedSec > 0` 守卫避免除零。
|
||||
|
||||
**2. ~~计数器回绕/重置产生负速度~~ ✅ 已修复**
|
||||
~~网卡重启、`/proc/net/dev` 计数器溢出或接口重置时,`currentRxBytes - m_lastRxBytes` 可能为负。代码无任何兜底,QML 会显示负速度。~~
|
||||
已在 `calculateSpeed()` 中对 `rxDelta` / `txDelta` 钳制为 0,计数器回绕/接口重置/网卡重启时不再产生负速度。
|
||||
|
||||
**3. ~~"总量统计"语义有误导~~ ✅ 已修复**
|
||||
~~`m_totalDownload = currentRxBytes` 只存储活动接口的当前计数器值,并非真正的累计流量。~~
|
||||
已改为累加会话增量 `m_totalDownload += rxDeltaClamped`,"总计"语义从"活动接口计数器值"变为"本次会话累计流量":从 0 开始增长,切换网卡不再跳变,网卡重启不归零。跨重启持久化属于 #18 的范畴。
|
||||
|
||||
**4. 独立窗口不支持深色模式**
|
||||
`AboutWindow.qml`、`SettingsWindow.qml`、`TextColorPicker.qml`、`TrafficChartWindow.qml` 全部硬编码浅色(`#FFFFFF`、`#333333`、`#f5f5f5` 等),不随系统主题适配。而 `networkview.qml` 和 `NetworkPopup.qml` 正确地从 `DockPalette` 派生颜色。deepin 深色模式下这些窗口视觉割裂严重。
|
||||
|
||||
**5. `detectInterfaces()` 过滤规则与 `readNetworkStats()` 不一致**
|
||||
`init()` 先调 `detectInterfaces()`(读 `/sys/class/net`,仅过滤 `lo`),再调 `readNetworkStats()`(读 `/proc/net/dev`,过滤 `lo`/`veth`/`docker`/`br-`)。首次刷新前,docker/veth 接口会短暂出现在列表中,造成 UI 闪烁。且 `detectInterfaces()` 的结果会被 `readNetworkStats()` 完全覆盖,属于冗余调用。
|
||||
|
||||
### 🟡 中优先级
|
||||
|
||||
**6. `setActiveInterface()` 不校验接口有效性**
|
||||
可传入不存在的接口名,会被持久化到配置文件。下次启动时虽然会被 `readNetworkStats()` 清空回退,但当前会话期间 `getActiveRxBytes()` 返回 0,速度恒为 0,用户困惑。
|
||||
|
||||
**7. 速度历史仅记录活动接口**
|
||||
`m_speedHistory` 每秒只给 `m_activeInterface` 追加采样点。非活动接口永不采集,切换到新接口时趋势图为空,需等待数分钟才有数据。若要"切回仍能看到历史",应后台为所有接口持续采样。
|
||||
|
||||
**8. 窗口居中未考虑多显示器**
|
||||
`AboutWindow`、`SettingsWindow`、`TrafficChartWindow` 用 `Screen.width/height` 居中,frameless 窗口在多屏环境下可能出现在非预期屏幕。
|
||||
|
||||
**9. SPDX 版权归属为占位符**
|
||||
所有文件头 `SPDX-FileCopyrightText: 2024 MyCompany`,"MyCompany" 是模板占位符,应改为实际版权持有者(如 `2024 Jokul`)。
|
||||
|
||||
**10. `version()` 无兜底**
|
||||
若 `pluginMetaData().value("Version")` 返回空(元数据未加载),AboutWindow 版本行显示空白。networkview.qml 有 `"1.0"` 兜底,但 AboutWindow 的 `version` 属性直接来自 `applet.version`,无兜底。
|
||||
|
||||
---
|
||||
|
||||
## 二、代码质量 / 可维护性
|
||||
|
||||
**11. 大量重复代码跨组件复制**
|
||||
以下代码在 `networkview.qml` 和 `NetworkPopup.qml` 中完全重复,`formatSpeed` 还在 `TrafficChartWindow.qml` 中第三份拷贝:
|
||||
- `isPhysicalIf()` 函数
|
||||
- `sortedInterfaces` 属性
|
||||
- `formatSpeed()` / `formatTotal()` 函数
|
||||
- 全套调色板定义(`basePalette`、`primaryText`、`accentBlue`…)
|
||||
- `downloadValueColor` / `uploadValueColor`
|
||||
|
||||
改一处需同步改 2-3 处,极易遗漏。应抽取为公共 QML 文件(如 `Theme.qml` 单例 + `Format.js`)。
|
||||
|
||||
**12. 死代码:`interfaceStats` 属性未被使用**
|
||||
`networkview.qml:43` 绑定了 `interfaceStats` 属性,但从未在任何 UI 中读取。C++ 侧的 `interfaceStats()` 函数和 `Q_PROPERTY` 也是死代码。且其 `QStringList` 用 `|` 分隔符编码结构化数据的方式本身也很脆弱。
|
||||
|
||||
**13. 死代码:`refresh()` Q_INVOKABLE 从未被 QML 调用**
|
||||
注释(networkview.qml:276)说明此前 QML 调 `applet.refresh()` 导致问题后已移除调用,但 C++ 侧的 `Q_INVOKABLE void refresh()` 仍保留。若确无外部调用方,可删除或标注保留原因。
|
||||
|
||||
**14. `interfaceStats` 暴露的数据不完整**
|
||||
`NetworkInterface` 结构体解析了 `rxErrors`/`txErrors`/`rxDropped`/`txDropped`,但 `interfaceStats()` 输出时丢弃了这些字段。若未来要展示丢包/错误率,需补全。
|
||||
|
||||
**15. README.md 和 AGENTS.md 严重过时**
|
||||
- 项目结构只列 `AboutWindow.qml`,实际有 5 个组件(缺 `NetworkPopup`、`SettingsWindow`、`TextColorPicker`、`TrafficChartWindow`)
|
||||
- README 功能特性未提及:设置窗口、字体颜色自定义、流量波动图、IPv6 显示
|
||||
- README 写"支持从弹窗一键卸载插件",但卸载功能实际在设置窗口
|
||||
- AGENTS.md "Project structure" 同样过时
|
||||
|
||||
**16. `qsTr()` 源字符串为中文,无翻译基础设施**
|
||||
QML 中 `qsTr("网络速度监控")` 等以中文为源串,但项目无 `.ts` 翻译文件、无 `lupdate`/`lrelease` 构建步骤、C++ 无翻译加载逻辑。`qsTr()` 实质为空操作。若仅面向中文用户可接受;若计划国际化,需补全 i18n 基础设施并以英文为源串。
|
||||
|
||||
**17. C++ 后端无单元测试**
|
||||
项目无任何测试。`calculateSpeed`、`readNetworkStats` 的正则解析、`isPhysicalInterface`、`niceCeil`(QML)等纯逻辑函数适合且应该有单元测试覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 三、可开发的新需求 / 功能增强
|
||||
|
||||
### 🟢 推荐开发
|
||||
|
||||
| # | 需求 | 说明 | 价值 |
|
||||
|---|------|------|------|
|
||||
| 18 | **日/月流量统计持久化** | 将每日累计流量写入 `settings.ini`,重启后保留。弹窗/设置窗口展示今日/本月用量 | 解决缺陷 #3,核心功能提升 |
|
||||
| 19 | **深色模式适配** | AboutWindow/SettingsWindow/TextColorPicker/TrafficChartWindow 改用 DTK 主题色或 `DockPalette` 派生色 | 解决缺陷 #4,视觉一致性 |
|
||||
| 20 | **公共代码抽取** | 颜色/格式化/排序逻辑抽取为共享文件,消除 3 处重复 | 解决 #11,降低维护成本 |
|
||||
| 21 | **文档同步** | 更新 README + AGENTS.md 的项目结构、功能列表、组件说明 | 解决 #15,新人/AI 接手必备 |
|
||||
| 22 | **网络断连检测** | 活动接口 `operState != Up` 或 IP 丢失时,任务栏显示断连图标 + tooltip 提示 | 用户体验提升 |
|
||||
| 23 | **全接口持续采样** | 后台为所有接口采集速度历史,切换接口时趋势图立即有数据 | 解决缺陷 #7 |
|
||||
| 24 | **速度单位切换** | 设置中可选 KB/s(二进制)或 Mbps(十进制),或智能自动切换 | 国际化/习惯适配 |
|
||||
|
||||
### 🔵 可选增强
|
||||
|
||||
| # | 需求 | 说明 |
|
||||
|---|------|------|
|
||||
| 25 | **刷新间隔可配置** | 设置中可选 1s/2s/5s 刷新频率,降低低端设备 CPU 占用 |
|
||||
| 26 | **流量超限通知** | 设置阈值,下载/上传超限时发送系统通知 |
|
||||
| 27 | **趋势图时间窗口可选** | 流量波动图支持 5min / 30min / 1h 切换(需持久化历史数据) |
|
||||
| 28 | **多接口聚合速度** | 任务栏可选显示所有接口合计速度,而非仅活动接口 |
|
||||
| 29 | **HiDPI 适配** | 独立窗口使用相对尺寸而非固定像素,适配高分辨率屏幕 |
|
||||
| 30 | **i18n 翻译基础设施** | 源串改英文 + 添加中文 `.ts` + `lrelease` 构建集成 |
|
||||
| 31 | **DTK 原生窗口框架** | 独立窗口改用 `DWindow` / DTK 窗口装饰,获得原生 deepin 标题栏、圆角、阴影 |
|
||||
| 32 | **导出流量数据** | 将流量统计导出为 CSV/JSON,便于分析 |
|
||||
| 33 | **C++ 后端单元测试** | 为速度计算、正则解析、接口检测添加 Qt Test 单元测试 |
|
||||
| 34 | **丢包/错误率展示** | 弹窗中展示 `rxErrors`/`rxDropped`,网络质量诊断 |
|
||||
| 35 | **开机自启保障** | 确保 dde-shell 启动时插件自动加载(可能需 dde-shell 配置) |
|
||||
|
||||
---
|
||||
|
||||
## 优先级建议
|
||||
|
||||
**第一优先级**(影响正确性):#1 速度计算间隔、#2 负速度、#5 接口过滤不一致
|
||||
|
||||
**第二优先级**(影响体验):#4 深色模式、#3 总量语义、#7 历史采样、#15 文档过时
|
||||
|
||||
**第三优先级**(工程质量):#11 重复代码、#12-14 死代码、#17 测试缺失
|
||||
|
||||
**第四优先级**(功能扩展):#18 日/月统计、#22 断连检测、#26 流量通知
|
||||
+38
-12
@@ -4,23 +4,49 @@
|
||||
|
||||
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
|
||||
|
||||
# 配置CMake
|
||||
cmake -Bbuild
|
||||
|
||||
# 构建
|
||||
# 2. 配置 + 构建
|
||||
echo "[2/6] 配置 CMake 并构建..."
|
||||
cmake -B 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
|
||||
|
||||
echo "Installation complete!"
|
||||
echo "Restarting dde-shell..."
|
||||
systemctl --user restart dde-shell@DDE
|
||||
echo "Done! Plugin loaded."
|
||||
# 验证安装结果
|
||||
echo ""
|
||||
echo "=== 安装验证 ==="
|
||||
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 "完成!插件已加载。"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
// SPDX-FileCopyrightText: 2024 MyCompany
|
||||
//
|
||||
// SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
// 网络速度监控弹窗内容组件
|
||||
// 展示实时速度、总量统计、网卡切换 chip 列表;
|
||||
// 由 networkview.qml 的 PanelPopup 实例化,传入 applet 属性,内部自行派生颜色和数据
|
||||
// 设计原因:PanelPopup 是 dock 上下文类型,不能作为独立组件根元素,
|
||||
// 因此本组件用 Control 作为根,由 networkview.qml 的 PanelPopup 包裹
|
||||
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Controls 2.15
|
||||
import QtQuick.Layouts 1.15
|
||||
import org.deepin.ds.dock 1.0
|
||||
import org.deepin.dtk 1.0
|
||||
|
||||
Control {
|
||||
id: popup
|
||||
|
||||
// C++ 后端对象,提供速度/接口/IP 等所有数据
|
||||
property var applet: null
|
||||
|
||||
readonly property bool ready: applet ? applet.ready : false
|
||||
readonly property real downloadSpeed: applet ? applet.downloadSpeed : 0
|
||||
readonly property real uploadSpeed: applet ? applet.uploadSpeed : 0
|
||||
readonly property real totalDownload: applet ? applet.totalDownload : 0
|
||||
readonly property real totalUpload: applet ? applet.totalUpload : 0
|
||||
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 || []) : []
|
||||
|
||||
// 弹窗的颜色全部派生自 DockPalette.iconTextPalette,与任务栏颜色独立
|
||||
// 设计原因:PanelPopup 不继承 AppletItem 的颜色属性,需要自行派生
|
||||
property Palette basePalette: DockPalette.iconTextPalette
|
||||
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.80)
|
||||
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 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 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)
|
||||
|
||||
// 高速警示色:下载速度超过阈值时由蓝转橙再转红
|
||||
readonly property color accentOrange: Qt.rgba(245 / 255, 158 / 255, 11 / 255, 1)
|
||||
readonly property color accentRed: Qt.rgba(220 / 255, 38 / 255, 38 / 255, 1)
|
||||
|
||||
// 下载值颜色:保留原有阈值逻辑(>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
|
||||
|
||||
// 判断是否为物理网卡(QML 侧排序用,与 C++ isPhysicalInterface 逻辑一致)
|
||||
function isPhysicalIf(name) {
|
||||
return name.startsWith("wlp") || name.startsWith("wlan")
|
||||
|| name.startsWith("enp") || name.startsWith("eth")
|
||||
}
|
||||
|
||||
// 排序后的接口列表:物理网卡在前,虚拟网卡在后,各自按名称排序
|
||||
// 设计原因:保持稳定排序,活动接口不再移到最前,避免切换网卡时 chip 顺序跳动;
|
||||
// 活动 chip 若被截断,由 chipFlickable 自动滚动露出完整样式
|
||||
readonly property var sortedInterfaces: {
|
||||
if (!popup.ready || popup.networkInterfaces.length === 0) return []
|
||||
var physical = []
|
||||
var virtual = []
|
||||
for (var i = 0; i < popup.networkInterfaces.length; i++) {
|
||||
var name = popup.networkInterfaces[i]
|
||||
if (popup.isPhysicalIf(name)) physical.push(name)
|
||||
else virtual.push(name)
|
||||
}
|
||||
physical.sort()
|
||||
virtual.sort()
|
||||
return physical.concat(virtual)
|
||||
}
|
||||
|
||||
// 格式化速度显示(带单位,用于弹出面板,信息更完整)
|
||||
// 最小单位为 KB,与 formatSpeedShort 保持一致;所有级别保留 2 位小数
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化总量显示(用于弹出面板的累计统计)
|
||||
function formatTotal(bytes) {
|
||||
if (bytes < 1024) {
|
||||
return bytes.toFixed(0) + " B"
|
||||
} else if (bytes < 1024 * 1024) {
|
||||
return (bytes / 1024).toFixed(1) + " KB"
|
||||
} else if (bytes < 1024 * 1024 * 1024) {
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB"
|
||||
} else {
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB"
|
||||
}
|
||||
}
|
||||
|
||||
// 供外部调用:popup 打开时滚动到活动 chip
|
||||
function scrollToActiveChip(animated) {
|
||||
chipFlickable.scrollToActiveChip(animated)
|
||||
}
|
||||
|
||||
padding: 16
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
spacing: 12
|
||||
|
||||
// 标题栏:标题在上,网卡名+IP 在卡片底色背景内
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 72
|
||||
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: qsTr("网络速度监控")
|
||||
font.pixelSize: 15
|
||||
font.weight: Font.Bold
|
||||
color: popup.primaryText
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
// 网卡名 + IP 地址卡片背景
|
||||
Rectangle {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.preferredWidth: 300
|
||||
// 有 IPv6 时增高以容纳第三行;无 IPv6 时保持原 40px
|
||||
Layout.preferredHeight: popup.ipv6Address ? 54 : 40
|
||||
color: popup.cardBackground
|
||||
radius: 8
|
||||
border.width: 1
|
||||
border.color: popup.cardBorder
|
||||
visible: popup.ready
|
||||
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
text: popup.activeInterface
|
||||
font.pixelSize: 11
|
||||
color: popup.secondaryText
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
text: popup.ipAddress ? qsTr("IPv4:") + popup.ipAddress : ""
|
||||
font.pixelSize: 13
|
||||
color: popup.secondaryText
|
||||
visible: popup.ipAddress !== ""
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
// IPv6 全球地址行,无 IPv6 时隐藏不占位
|
||||
Text {
|
||||
text: popup.ipv6Address ? qsTr("IPv6:") + popup.ipv6Address : ""
|
||||
font.pixelSize: 13
|
||||
color: popup.secondaryText
|
||||
visible: popup.ipv6Address !== ""
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 实时速度卡片:双列布局,每列内容在各自半区内水平垂直居中
|
||||
// 设计原因:用显式 anchor 分两个等宽半区,比 RowLayout+fillWidth 更可靠,
|
||||
// 确保下载/上传内容各自在左/右半区正中显示
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 110
|
||||
color: popup.cardBackground
|
||||
radius: 12
|
||||
border.width: 1
|
||||
border.color: popup.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 {
|
||||
anchors.centerIn: parent
|
||||
spacing: 4
|
||||
|
||||
Rectangle {
|
||||
width: 32
|
||||
height: 32
|
||||
radius: 16
|
||||
color: popup.accentBlueLight
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "↓"
|
||||
font.pixelSize: 18
|
||||
font.weight: Font.Bold
|
||||
color: popup.accentBlue
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: qsTr("下载")
|
||||
font.pixelSize: 11
|
||||
color: popup.secondaryText
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
text: popup.formatSpeed(popup.downloadSpeed)
|
||||
font.pixelSize: 18
|
||||
font.weight: Font.Bold
|
||||
color: popup.downloadValueColor
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 中间分隔线
|
||||
Rectangle {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 12
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 12
|
||||
width: 1
|
||||
color: popup.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 {
|
||||
anchors.centerIn: parent
|
||||
spacing: 4
|
||||
|
||||
Rectangle {
|
||||
width: 32
|
||||
height: 32
|
||||
radius: 16
|
||||
color: popup.accentGreenLight
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "↑"
|
||||
font.pixelSize: 18
|
||||
font.weight: Font.Bold
|
||||
color: popup.accentGreen
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: qsTr("上传")
|
||||
font.pixelSize: 11
|
||||
color: popup.secondaryText
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
text: popup.formatSpeed(popup.uploadSpeed)
|
||||
font.pixelSize: 18
|
||||
font.weight: Font.Bold
|
||||
color: popup.uploadValueColor
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 总量统计:单行紧凑
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 44
|
||||
color: popup.cardBackground
|
||||
radius: 12
|
||||
border.width: 1
|
||||
border.color: popup.cardBorder
|
||||
visible: popup.ready
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Text {
|
||||
text: qsTr("总计")
|
||||
font.pixelSize: 11
|
||||
font.weight: Font.Bold
|
||||
color: popup.secondaryText
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "↓ " + popup.formatTotal(popup.totalDownload)
|
||||
font.pixelSize: 12
|
||||
color: popup.primaryText
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "↑ " + popup.formatTotal(popup.totalUpload)
|
||||
font.pixelSize: 12
|
||||
color: popup.primaryText
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
|
||||
// 接口切换 chip 列表:水平滚动,物理网卡在前、虚拟网卡在后,顺序稳定不随切换变化
|
||||
// chip 少时分散撑满一行;chip 多超出宽度时固定宽度 + 水平滚动
|
||||
// 活动 chip 不在可视区域或被截断时,自动滚动露出完整样式(见 scrollToActiveChip)
|
||||
Flickable {
|
||||
id: chipFlickable
|
||||
Layout.fillWidth: true
|
||||
// 高度需容纳 chip(28px)+ 水平滚动条(~6px)+ 上下间距,
|
||||
// 此前 32px 仅剩 4px 给滚动条,导致滚动条无法正常显示
|
||||
Layout.preferredHeight: 40
|
||||
visible: popup.ready && popup.networkInterfaces.length > 1
|
||||
contentWidth: Math.max(chipRow.width, width)
|
||||
contentHeight: chipRow.height
|
||||
flickableDirection: Flickable.HorizontalFlick
|
||||
clip: true
|
||||
|
||||
// 平滑滚动动画:直接赋值 contentX 会被 Flickable 的拖拽/回弹行为覆盖,
|
||||
// 用 NumberAnimation 驱动 contentX 实现自动滚动,平滑且不冲突
|
||||
NumberAnimation {
|
||||
id: chipScrollAnim
|
||||
target: chipFlickable
|
||||
property: "contentX"
|
||||
duration: 200
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
// 用户开始拖拽/滑动时停止自动滚动,避免动画与用户操作争抢
|
||||
onMovementStarted: chipScrollAnim.stop()
|
||||
|
||||
// 自动滚动使活动接口的 chip 完整可见:
|
||||
// 列表顺序稳定后活动 chip 可能位于可视区域外或被截断,
|
||||
// 切换接口或 popup 打开时调用,将其滚动露出完整样式。
|
||||
// animated=false 用于 popup 刚打开时立即定位,跳过滚动过程
|
||||
function scrollToActiveChip(animated) {
|
||||
if (!visible || width <= 0) return
|
||||
var idx = popup.sortedInterfaces.indexOf(popup.activeInterface)
|
||||
if (idx < 0) return
|
||||
var chip = chipRepeater.itemAt(idx)
|
||||
if (!chip) return
|
||||
var targetX = contentX
|
||||
if (chip.x < contentX) {
|
||||
// chip 在可视区左侧之外:回滚使 chip 左边缘与可视区左缘对齐
|
||||
targetX = chip.x
|
||||
} else if (chip.x + chip.width > contentX + width) {
|
||||
// chip 在可视区右侧之外或被截断:滚动使 chip 右边缘与可视区右缘对齐
|
||||
targetX = chip.x + chip.width - width
|
||||
}
|
||||
// 钳制到合法滚动范围 [0, contentWidth - width],避免触发边界回弹
|
||||
targetX = Math.max(0, Math.min(targetX, Math.max(0, contentWidth - width)))
|
||||
if (Math.abs(targetX - contentX) < 1) return
|
||||
chipScrollAnim.stop()
|
||||
if (animated) {
|
||||
chipScrollAnim.to = targetX
|
||||
chipScrollAnim.start()
|
||||
} else {
|
||||
contentX = targetX
|
||||
}
|
||||
}
|
||||
|
||||
// 活动接口变化时(用户点击 chip 或后端自动切换)自动滚动露出活动 chip
|
||||
Connections {
|
||||
target: popup
|
||||
function onActiveInterfaceChanged() {
|
||||
chipFlickable.scrollToActiveChip(true)
|
||||
}
|
||||
}
|
||||
|
||||
// 等宽分配宽度:假设所有 chip 等宽撑满时的单个宽度
|
||||
readonly property real equalChipWidth: popup.sortedInterfaces.length > 0
|
||||
? (width - 6 * (popup.sortedInterfaces.length - 1)) / popup.sortedInterfaces.length
|
||||
: 0
|
||||
|
||||
Row {
|
||||
id: chipRow
|
||||
spacing: 6
|
||||
|
||||
Repeater {
|
||||
id: chipRepeater
|
||||
model: popup.sortedInterfaces
|
||||
|
||||
Rectangle {
|
||||
// 宽度取等宽和自然宽度的较大值:
|
||||
// chip 少时 equalChipWidth > 自然宽度 -> 分散撑满一行
|
||||
// chip 多时 自然宽度 > equalChipWidth -> 固定宽度 + 水平滚动
|
||||
width: Math.max(chipFlickable.equalChipWidth, chipText.implicitWidth + 24)
|
||||
height: 28
|
||||
radius: 8
|
||||
color: modelData === popup.activeInterface
|
||||
? popup.accentBlue
|
||||
: (chipMouse.containsMouse ? popup.accentBlueLight : popup.cardBackground)
|
||||
border.width: 1
|
||||
border.color: modelData === popup.activeInterface
|
||||
? popup.accentBlue
|
||||
: popup.cardBorder
|
||||
|
||||
Text {
|
||||
id: chipText
|
||||
anchors.centerIn: parent
|
||||
text: modelData
|
||||
font.pixelSize: 11
|
||||
font.weight: modelData === popup.activeInterface ? Font.Bold : Font.Normal
|
||||
color: modelData === popup.activeInterface ? "white" : popup.primaryText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: chipMouse
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
if (popup.applet) popup.applet.setActiveInterface(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义滚动条样式:默认 ScrollBar 在面板配色下可能不可见,
|
||||
// 用 contentItem 显式绘制,颜色派生自 basePalette 适配深浅主题
|
||||
ScrollBar.horizontal: ScrollBar {
|
||||
id: chipScrollBar
|
||||
policy: ScrollBar.AsNeeded
|
||||
interactive: true
|
||||
|
||||
contentItem: Rectangle {
|
||||
implicitHeight: 4
|
||||
radius: 2
|
||||
// 按压时加深、悬停时中等、默认稍淡,保证滚动条在深浅主题下均可见
|
||||
color: chipScrollBar.pressed ? popup.secondaryText
|
||||
: (chipScrollBar.hovered ? popup.secondaryText : popup.tertiaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 未检测到接口占位
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 80
|
||||
color: popup.cardBackground
|
||||
radius: 12
|
||||
border.width: 1
|
||||
border.color: popup.cardBorder
|
||||
visible: !popup.ready
|
||||
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: "⚠"
|
||||
font.pixelSize: 24
|
||||
color: popup.secondaryText
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
text: qsTr("未检测到网络接口")
|
||||
font.pixelSize: 12
|
||||
color: popup.secondaryText
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
// SPDX-FileCopyrightText: 2024 MyCompany
|
||||
//
|
||||
// SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
// 设置窗口:独立顶层窗口,桌面居中弹出,提供网络接口选择、字体颜色选择、插件卸载功能
|
||||
// 设计要点:白色圆角卡片 + 自定义标题栏可拖动,接口单选切换,卸载按钮带复制命令功能
|
||||
// 对外依赖:applet(C++ 后端对象)、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("其他")
|
||||
}
|
||||
|
||||
// 根据接口名返回类型图标(Unicode 符号),用于网络接口列表每行左侧
|
||||
// 设计原因:项目未引入图标库,用 Text 渲染 Unicode 符号(与 networkview.qml 的 ↓↑ 一致);
|
||||
// 所选符号均为 DejaVu/Noto 等常见字体覆盖的单色字形,避免彩色 emoji 破坏浅色主题观感,
|
||||
// 匹配规则与 interfaceDescription 保持一致
|
||||
function interfaceIcon(name) {
|
||||
if (name === "lo") return "↻" // 本地回环:循环箭头
|
||||
if (name === "Meta") return "▢" // 虚拟接口:空心方块
|
||||
if (/^enp|^eth/.test(name)) return "⇄" // 有线网络:双向链路
|
||||
if (/^wlp|^wlan/.test(name)) return "∿" // 无线网络:信号波形
|
||||
if (/^docker|^veth/.test(name)) return "▣" // 容器网络:盒中盒
|
||||
if (/^br/.test(name)) return "⋈" // 桥接:连接(蝴蝶结形)
|
||||
if (/^tun|^tap/.test(name)) return "⚿" // VPN:钥匙
|
||||
if (/^virbr/.test(name)) return "⋈" // 虚拟桥接:同桥接
|
||||
return "◉" // 其他:通用网络节点
|
||||
}
|
||||
|
||||
// 显示时居中到桌面
|
||||
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"
|
||||
}
|
||||
|
||||
// 接口列表容器:最多显示约 5 行(160px),超出部分垂直滚动
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
// 高度 = 上下边距 10*2 + 列表可视高度;可视高度 = min(内容高度, 160)
|
||||
// 内容高度 = 行高 30*n + 行间距 2*(n-1),接口少时卡片随行数收缩
|
||||
Layout.preferredHeight: networkInterfaces.length > 0
|
||||
? Math.min(ifaceColumn.implicitHeight, 160) + 20
|
||||
: 80
|
||||
color: "#ffffff"
|
||||
radius: 10
|
||||
border.width: 1
|
||||
border.color: "#e0e0e0"
|
||||
|
||||
// 用 ScrollView 而非裸 Flickable:自带滚轮滚动支持,
|
||||
// 且滚动条显示时自动让出内容宽度,不会遮挡行内文字
|
||||
ScrollView {
|
||||
id: ifaceScrollView
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
visible: networkInterfaces.length > 0
|
||||
clip: true
|
||||
// 内容宽度跟随可视宽度(自动减去滚动条占位),禁止水平滚动
|
||||
contentWidth: availableWidth
|
||||
|
||||
// 自定义滚动条样式:与 networkview.qml chip 滚动条一致(4px 圆角矩形),
|
||||
// 颜色改用硬编码灰色系适配本窗口浅色主题;
|
||||
// AsNeeded 策略保证接口少时不显示滚动条
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
id: ifaceScrollBar
|
||||
policy: ScrollBar.AsNeeded
|
||||
interactive: true
|
||||
|
||||
contentItem: Rectangle {
|
||||
implicitWidth: 4
|
||||
radius: 2
|
||||
// 按压/悬停加深、默认稍淡,保证滚动条在白色卡片上可见
|
||||
color: ifaceScrollBar.pressed ? "#666666"
|
||||
: (ifaceScrollBar.hovered ? "#666666" : "#999999")
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: ifaceColumn
|
||||
width: ifaceScrollView.availableWidth
|
||||
// 行间距 2:紧凑列表,各行 hover 背景不粘连
|
||||
spacing: 2
|
||||
|
||||
// 接口列表:整行点击切换活动接口
|
||||
// 行样式:类型图标 + 单选圆点 + 名称 + 右侧类型描述;
|
||||
// hover 显示浅灰底,选中行浅蓝底 + 蓝色细边框
|
||||
Repeater {
|
||||
model: networkInterfaces
|
||||
delegate: Rectangle {
|
||||
id: ifaceRow
|
||||
width: ifaceColumn.width
|
||||
height: 30
|
||||
radius: 8
|
||||
// 当前行是否为活动接口(选中态)
|
||||
readonly property bool isActive: modelData === activeInterface
|
||||
readonly property bool hovered: rowMouse.containsMouse
|
||||
// 选中态强调色用 #1565c0(较 #1976d2 更深):
|
||||
// 保证 ▢ 等空心图标在浅蓝选中背景上仍有足够对比度
|
||||
readonly property color activeColor: "#1565c0"
|
||||
// 背景优先级:选中 > hover;选中行 hover 时稍加深以保留交互反馈
|
||||
color: isActive
|
||||
? (hovered ? "#d8ecfd" : "#e3f2fd")
|
||||
: (hovered ? "#f0f0f0" : "transparent")
|
||||
// 选中行加蓝色细边框增强视觉;未选中保持透明边框占位,避免切换时出现 1px 抖动
|
||||
border.width: 1
|
||||
border.color: isActive ? "#90caf9" : "transparent"
|
||||
|
||||
// 整行 hover + 点击:行内元素均不处理鼠标事件,
|
||||
// 点击行任意位置即选中该接口
|
||||
MouseArea {
|
||||
id: rowMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: if (applet) applet.setActiveInterface(modelData)
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
spacing: 8
|
||||
|
||||
// 网卡类型图标:固定宽度居中,保证不同字形的视觉对齐
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.preferredWidth: 20
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: interfaceIcon(modelData)
|
||||
font.pixelSize: 14
|
||||
color: ifaceRow.isActive ? ifaceRow.activeColor : "#777777"
|
||||
}
|
||||
|
||||
// 自绘单选圆点:替代 RadioButton 作纯视觉指示器,
|
||||
// 避免 DTK 样式渲染差异(本文件早期版本 RadioButton 标签渲染异常),
|
||||
// 选中态颜色完全可控;点击由整行 MouseArea 统一处理
|
||||
Rectangle {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.preferredWidth: 16
|
||||
Layout.preferredHeight: 16
|
||||
radius: 8
|
||||
color: "transparent"
|
||||
border.width: 2
|
||||
border.color: ifaceRow.isActive ? ifaceRow.activeColor
|
||||
: (ifaceRow.hovered ? "#666666" : "#999999")
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: 8
|
||||
height: 8
|
||||
radius: 4
|
||||
visible: ifaceRow.isActive
|
||||
color: ifaceRow.activeColor
|
||||
}
|
||||
}
|
||||
|
||||
// 接口名称:过长时右侧省略
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.fillWidth: true
|
||||
text: modelData
|
||||
font.pixelSize: 13
|
||||
font.weight: ifaceRow.isActive ? Font.Medium : Font.Normal
|
||||
color: ifaceRow.isActive ? ifaceRow.activeColor : "#333333"
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// 接口类型描述:右对齐显示,与接口名分居两侧
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
text: interfaceDescription(modelData)
|
||||
font.pixelSize: 12
|
||||
color: "#999999"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 无接口提示:在卡片内居中
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: qsTr("未检测到网络接口")
|
||||
font.pixelSize: 12
|
||||
color: "#999999"
|
||||
visible: networkInterfaces.length === 0
|
||||
}
|
||||
}
|
||||
|
||||
// 字体颜色选择区:预设色板 + 跟随系统选项
|
||||
// 设计原因:用户选择的颜色通过 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("卸载插件")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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.applet(C++ 后端对象),
|
||||
// 为 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 / speedHistoryUpload(QVariantList 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 -> 20000,80000 -> 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=3,x 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": {
|
||||
"Version": "1.0",
|
||||
"Version": "@PROJECT_VERSION@",
|
||||
"Id": "space.jokul.JNetApplet",
|
||||
"Url": "networkview.qml",
|
||||
"Parent": "org.deepin.ds.dock",
|
||||
+125
-861
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,9 @@
|
||||
#include <QDir>
|
||||
#include <QRegularExpression>
|
||||
#include <QNetworkInterface>
|
||||
#include <QColor>
|
||||
#include <QSettings>
|
||||
#include <QStandardPaths>
|
||||
|
||||
DS_BEGIN_NAMESPACE
|
||||
|
||||
@@ -19,13 +22,36 @@ NetworkMonitorApplet::NetworkMonitorApplet(QObject *parent)
|
||||
, m_refreshTimer(nullptr)
|
||||
, m_lastRxBytes(0)
|
||||
, m_lastTxBytes(0)
|
||||
, m_lastTimestampMs(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;
|
||||
}
|
||||
// 若校验失败则保持空串默认值(跟随系统),不抛出也不记录
|
||||
}
|
||||
|
||||
// 从配置文件读取持久化的活动接口,启动时自动恢复用户上次选择
|
||||
// 设计原因:用户手动选择的网卡应跨重启保持,而非每次启动重新自动检测;
|
||||
// 若保存的接口已不存在(如 USB 网卡拔出),readNetworkStats 会清空并回退到自动选择
|
||||
if (settings.contains(QStringLiteral("activeInterface"))) {
|
||||
m_activeInterface = settings.value(QStringLiteral("activeInterface")).toString();
|
||||
}
|
||||
}
|
||||
|
||||
NetworkMonitorApplet::~NetworkMonitorApplet()
|
||||
@@ -54,12 +80,12 @@ bool NetworkMonitorApplet::init()
|
||||
|
||||
double NetworkMonitorApplet::downloadSpeed() const
|
||||
{
|
||||
return static_cast<double>(m_downloadSpeed);
|
||||
return m_downloadSpeed;
|
||||
}
|
||||
|
||||
double NetworkMonitorApplet::uploadSpeed() const
|
||||
{
|
||||
return static_cast<double>(m_uploadSpeed);
|
||||
return m_uploadSpeed;
|
||||
}
|
||||
|
||||
double NetworkMonitorApplet::totalDownload() const
|
||||
@@ -116,6 +142,84 @@ 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();
|
||||
@@ -126,7 +230,18 @@ void NetworkMonitorApplet::setActiveInterface(const QString &interface)
|
||||
if (m_activeInterface != interface) {
|
||||
m_activeInterface = interface;
|
||||
m_firstUpdate = true;
|
||||
|
||||
// 持久化到配置文件,下次启动自动加载用户选择的网卡
|
||||
// 设计原因:弹窗 chip 和设置窗口都调用此方法,统一持久化保证两处选择一致
|
||||
const QString configPath = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)
|
||||
+ QStringLiteral("/jnetapplet/settings.ini");
|
||||
QSettings settings(configPath, QSettings::IniFormat);
|
||||
settings.setValue(QStringLiteral("activeInterface"), m_activeInterface);
|
||||
settings.sync();
|
||||
|
||||
emit activeInterfaceChanged();
|
||||
// 接口切换后通知 QML 趋势图切换显示新接口的历史数据
|
||||
emit speedHistoryChanged();
|
||||
// 接口切换后立即检测新接口的 IP 地址
|
||||
detectIpAddress();
|
||||
}
|
||||
@@ -180,6 +295,20 @@ void NetworkMonitorApplet::readNetworkStats()
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存的接口已不在当前列表中(如 USB 网卡拔出),清空让自动选择接管
|
||||
if (!m_activeInterface.isEmpty() && !m_interfaceList.contains(m_activeInterface)) {
|
||||
m_activeInterface.clear();
|
||||
}
|
||||
|
||||
// 自动选择活动接口
|
||||
if (m_activeInterface.isEmpty() && !m_interfaceList.isEmpty()) {
|
||||
// 优先选择物理网卡(wlp/wlan/enp/eth)中有流量的接口,
|
||||
@@ -226,24 +355,61 @@ void NetworkMonitorApplet::calculateSpeed()
|
||||
{
|
||||
qint64 currentRxBytes = getActiveRxBytes();
|
||||
qint64 currentTxBytes = getActiveTxBytes();
|
||||
// 当前采样时间戳(毫秒),用于按真实流逝时间计算速度
|
||||
const qint64 nowMs = QDateTime::currentMSecsSinceEpoch();
|
||||
|
||||
if (m_firstUpdate) {
|
||||
m_firstUpdate = false;
|
||||
m_lastRxBytes = currentRxBytes;
|
||||
m_lastTxBytes = currentTxBytes;
|
||||
m_lastTimestampMs = nowMs;
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算速度(字节/秒)
|
||||
m_downloadSpeed = currentRxBytes - m_lastRxBytes;
|
||||
m_uploadSpeed = currentTxBytes - m_lastTxBytes;
|
||||
// 按真实流逝时间计算速度(字节/秒)
|
||||
// 设计原因:原实现假设定时间隔严格 1 秒,直接取字节差作为速度;
|
||||
// 系统负载高时 QTimer 可能延迟触发,实际间隔偏离 1 秒导致速度失真。
|
||||
// 改为除以真实间隔后,无论定时器抖动如何速度都准确。
|
||||
const double elapsedSec = (nowMs - m_lastTimestampMs) / 1000.0;
|
||||
|
||||
// 更新总量
|
||||
m_totalDownload = currentRxBytes;
|
||||
m_totalUpload = currentTxBytes;
|
||||
// 仅当间隔合法时计算速度并累加会话流量,避免除零;间隔为 0 时保持上次值
|
||||
if (elapsedSec > 0) {
|
||||
// 计数器回绕/接口重置时差值可能为负,钳制为 0 避免显示负速度
|
||||
// 触发场景:网卡重启、/proc/net/dev 计数器溢出、USB 网卡拔出重插
|
||||
const qint64 rxDelta = currentRxBytes - m_lastRxBytes;
|
||||
const qint64 txDelta = currentTxBytes - m_lastTxBytes;
|
||||
const qint64 rxDeltaClamped = rxDelta > 0 ? rxDelta : 0;
|
||||
const qint64 txDeltaClamped = txDelta > 0 ? txDelta : 0;
|
||||
m_downloadSpeed = rxDeltaClamped / elapsedSec;
|
||||
m_uploadSpeed = txDeltaClamped / elapsedSec;
|
||||
// 累加会话总量:原实现直接取计数器值,切换网卡/网卡重启时总量跳变;
|
||||
// 改为累加增量后,总量 = 本次会话期间所有活动接口的流量总和,不再跳变
|
||||
m_totalDownload += rxDeltaClamped;
|
||||
m_totalUpload += txDeltaClamped;
|
||||
}
|
||||
|
||||
m_lastRxBytes = currentRxBytes;
|
||||
m_lastTxBytes = currentTxBytes;
|
||||
m_lastTimestampMs = nowMs;
|
||||
|
||||
// 追加采样点到当前活动接口的环形缓冲
|
||||
// 设计原因:applet 启动即持续采集,用户随时打开趋势图都能看到完整 5 分钟数据
|
||||
if (!m_activeInterface.isEmpty()) {
|
||||
SpeedSample sample;
|
||||
sample.timestamp = QDateTime::currentSecsSinceEpoch();
|
||||
sample.downloadSpeed = m_downloadSpeed;
|
||||
sample.uploadSpeed = 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();
|
||||
@@ -11,6 +11,11 @@
|
||||
#include <QTimer>
|
||||
#include <QVariantList>
|
||||
#include <QMap>
|
||||
#include <QHash>
|
||||
#include <QPointF>
|
||||
#include <QDateTime>
|
||||
#include <QColor>
|
||||
#include <QSettings>
|
||||
|
||||
DS_BEGIN_NAMESPACE
|
||||
|
||||
@@ -26,6 +31,15 @@ struct NetworkInterface {
|
||||
qint64 txDropped; // 发送丢弃数
|
||||
};
|
||||
|
||||
// 单个速度采样点:用于趋势图的环形缓冲
|
||||
// timestamp: 自 epoch 的秒级时间戳,作为 X 轴定位
|
||||
// downloadSpeed / uploadSpeed: 当时的瞬时速度(字节/秒),作为两条折线的 Y 值
|
||||
struct SpeedSample {
|
||||
qint64 timestamp;
|
||||
double downloadSpeed;
|
||||
double uploadSpeed;
|
||||
};
|
||||
|
||||
class NetworkMonitorApplet : public DApplet
|
||||
{
|
||||
Q_OBJECT
|
||||
@@ -37,11 +51,23 @@ class NetworkMonitorApplet : public DApplet
|
||||
Q_PROPERTY(QStringList networkInterfaces READ networkInterfaces NOTIFY interfacesChanged)
|
||||
Q_PROPERTY(QStringList interfaceStats READ interfaceStats NOTIFY statsChanged)
|
||||
Q_PROPERTY(bool ready READ ready NOTIFY readyChanged)
|
||||
// 当前活动接口,用户在弹窗或设置窗口选择后持久化到 ~/.config/jnetapplet/settings.ini,
|
||||
// 下次启动自动恢复;若保存的接口已不存在则回退到自动选择
|
||||
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);
|
||||
@@ -60,6 +86,16 @@ public:
|
||||
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);
|
||||
@@ -73,6 +109,9 @@ signals:
|
||||
void activeInterfaceChanged();
|
||||
void ipAddressChanged();
|
||||
void ipv6AddressChanged();
|
||||
void speedHistoryChanged();
|
||||
// 任务栏网速字体颜色变化时通知 QML 更新
|
||||
void textColorChanged();
|
||||
|
||||
private:
|
||||
void readNetworkStats();
|
||||
@@ -96,8 +135,11 @@ private:
|
||||
// 速度计算
|
||||
qint64 m_lastRxBytes;
|
||||
qint64 m_lastTxBytes;
|
||||
qint64 m_downloadSpeed;
|
||||
qint64 m_uploadSpeed;
|
||||
// 上次采样的毫秒时间戳,用于按真实间隔计算速度(避免定时器抖动失真)
|
||||
qint64 m_lastTimestampMs;
|
||||
// 当前瞬时速度(字节/秒),用 double 存储以保留按真实间隔除算的小数精度
|
||||
double m_downloadSpeed;
|
||||
double m_uploadSpeed;
|
||||
|
||||
// 总量
|
||||
qint64 m_totalDownload;
|
||||
@@ -105,6 +147,16 @@ private:
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user