20 Commits

Author SHA1 Message Date
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
10 changed files with 1262 additions and 207 deletions
+22 -8
View File
@@ -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
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.16)
project(JNetApplet VERSION 1.0.0 LANGUAGES CXX)
project(JNetApplet VERSION 1.0.1 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.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})
# 安装 components 子目录下所有 QML 组件
# 设计原因:拆分出的子组件需随主 QML 一起安装到 dde-shell 插件目录,
# 否则 networkview.qml 的 import "components" 在运行时找不到类型
install(DIRECTORY package/components DESTINATION /usr/share/dde-shell/${PLUGIN_ID})
+7 -4
View File
@@ -65,11 +65,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 代理工作指引
+37 -11
View File
@@ -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
# 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 "完成!插件已加载。"
+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
}
}
+573
View File
@@ -0,0 +1,573 @@
// 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 轴上限计算(规范 §4.4----
// 1. vMax = 所有采样点 max(download, upload)
// 2. vMax == 0 时上限取 10241 KB/s)避免除零
// 3. 否则取 vMax * 1.2 向上取整到 1/2/5 × 10^n 序列
var vMax = 0
var i
for (i = 0; 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",
+121 -165
View File
@@ -10,17 +10,23 @@ import Qt.labs.platform as Platform
import org.deepin.ds 1.0
import org.deepin.ds.dock 1.0
import org.deepin.dtk 1.0
import "components"
AppletItem {
id: root
objectName: "network monitor applet"
property int dockOrder: 21
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
// 任务栏宽度稍宽于标准 dock 尺寸,容纳双行速度数值
// 设计原因:固定宽度避免数值长度变化导致插件宽度抖动、箭头位置漂移
implicitWidth: Math.round(dockSize * 1.35)
implicitHeight: dockSize
// 水平任务栏宽度稍宽容纳双行数值,高度匹配 dock 尺寸
// 竖向任务栏:宽度匹配 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 bool ready: applet ? applet.ready : false
@@ -31,6 +37,8 @@ AppletItem {
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 interfaceStats: applet ? (applet.interfaceStats || []) : []
@@ -132,6 +140,7 @@ AppletItem {
// 不给每行固定高度,让 RowLayout 按内容自然高度排列,
// 整组垂直居中于 dock 区域,避免两行间出现过大间隙
Column {
visible: !root.isVerticalDock
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 4
@@ -143,16 +152,16 @@ AppletItem {
Text {
text: "↓"
font.pixelSize: root.dockSize * 0.18
font.pixelSize: root.dockSize * 0.20
color: root.secondaryText
Layout.alignment: Qt.AlignVCenter
}
Text {
text: root.formatSpeedShort(root.downloadSpeed)
font.pixelSize: root.dockSize * 0.22
font.pixelSize: root.dockSize * 0.25
font.weight: Font.Medium
color: root.downloadValueColor
color: root.primaryText
Layout.alignment: Qt.AlignVCenter
horizontalAlignment: Text.AlignLeft
}
@@ -164,28 +173,93 @@ AppletItem {
Text {
text: "↑"
font.pixelSize: root.dockSize * 0.18
font.pixelSize: root.dockSize * 0.20
color: root.secondaryText
Layout.alignment: Qt.AlignVCenter
}
Text {
text: root.formatSpeedShort(root.uploadSpeed)
font.pixelSize: root.dockSize * 0.22
font.pixelSize: root.dockSize * 0.25
font.weight: Font.Medium
color: root.uploadValueColor
color: root.primaryText
Layout.alignment: Qt.AlignVCenter
horizontalAlignment: Text.AlignLeft
}
}
}
// 悬停提示:网卡名 + IP 地址一行展示
// PanelToolTip 不支持 contentItem 覆盖和文本对齐,用默认左对齐 text 属性
// 竖向任务栏布局:双列字符竖排,每列一个方向(下载|上传)
// 设计原因:竖向任务栏宽度仅 ~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.secondaryText
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.primaryText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
// 上传列:结构与下载列对称,颜色用 uploadValueColor
Column {
spacing: 0
Layout.alignment: Qt.AlignTop | Qt.AlignHCenter
Text {
text: "↑"
font.pixelSize: root.dockSize * 0.20
color: root.secondaryText
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.primaryText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
}
// 悬停提示:网卡名 + IPv4 单行,IPv6 在第二行(仅有 IPv6 时追加)
// 设计原因:IPv6 地址较长,单行容纳不下;无 IPv6 时保持单行与原视觉一致
PanelToolTip {
id: toolTip
text: root.ready
? (root.activeInterface + " · " + qsTr("IP地址:") + (root.ipAddress || qsTr("无")))
? (root.activeInterface
+ " · IPv4" + (root.ipAddress || qsTr("无"))
+ (root.ipv6Address ? "\nIPv6" + root.ipv6Address : ""))
: qsTr("未检测到网络接口")
toolTipX: DockPanelPositioner.x
toolTipY: DockPanelPositioner.y
@@ -267,8 +341,9 @@ AppletItem {
// 网卡名 + IP 地址卡片背景
Rectangle {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: 180
Layout.preferredHeight: 40
Layout.preferredWidth: 300
// 有 IPv6 时增高以容纳第三行;无 IPv6 时保持原 40px
Layout.preferredHeight: root.ipv6Address ? 54 : 40
color: root.cardBackground
radius: 8
border.width: 1
@@ -287,12 +362,21 @@ AppletItem {
}
Text {
text: root.ipAddress ? qsTr("IP地址") + root.ipAddress : ""
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
}
}
}
}
@@ -578,6 +662,16 @@ AppletItem {
}
}
// 流量波动图:屏幕居中独立窗口,展示活动接口最近 30 分钟网速趋势
Platform.MenuItem {
text: qsTr("流量波动图")
onTriggered: {
trafficChartWindow.show()
trafficChartWindow.raise()
trafficChartWindow.requestActivate()
}
}
Platform.MenuSeparator {}
Platform.MenuItem {
@@ -590,158 +684,20 @@ AppletItem {
}
}
// 关于窗口:独立顶层窗口,在桌面中间弹出,展示作者信息
// 与设置窗口同样的 frameless + 自定义标题栏模式
Window {
// 关于窗口:抽取为独立组件 package/components/AboutWindow.qml
// 依赖通过属性传入:accentColor = root.accentRedversion 取自 C++ 后端 applet.version
AboutWindow {
id: aboutWindow
width: 320
height: 230
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
}
accentColor: root.accentRed
version: root.applet ? root.applet.version : "1.0"
}
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"
MouseArea {
anchors.fill: parent
drag.target: aboutWindow
}
Text {
anchors.left: parent.left
anchors.leftMargin: 16
anchors.verticalCenter: parent.verticalCenter
text: qsTr("关于")
font.pixelSize: 15
font.weight: Font.Bold
color: "#333333"
}
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 ? root.accentRed : "#666666"
}
MouseArea {
id: aboutCloseMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: aboutWindow.hide()
}
}
}
// 内容区
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
anchors.margins: 0
spacing: 10
Layout.leftMargin: 24
Layout.rightMargin: 24
Layout.topMargin: 4
// 标题组:插件名 + 英文名紧凑排列
ColumnLayout {
spacing: 2
Layout.alignment: Qt.AlignHCenter
Text {
text: qsTr("网络速度监控")
font.pixelSize: 18
font.weight: Font.Bold
color: "#333333"
Layout.alignment: Qt.AlignHCenter
}
Text {
text: "JNetApplet"
font.pixelSize: 12
color: "#999999"
Layout.alignment: Qt.AlignHCenter
}
}
// 分隔线
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: "#e0e0e0"
}
// 信息行
ColumnLayout {
spacing: 6
Layout.fillWidth: true
Text {
text: qsTr("版本:1.0")
font.pixelSize: 12
color: "#666666"
}
Text {
text: qsTr("作者:Jokul")
font.pixelSize: 12
color: "#666666"
}
Text {
text: qsTr("描述:监控网络速度和流量")
font.pixelSize: 12
color: "#666666"
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
Text {
text: qsTr("仓库:git.jokul.space/Jokul/JNetApplet")
font.pixelSize: 11
color: "#999999"
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
}
}
}
// 流量波动图窗口:屏幕居中独立窗口,展示活动接口最近 30 分钟网速趋势
// 依赖通过属性传入:accentColor = root.accentRedapplet = root.applet
TrafficChartWindow {
id: trafficChartWindow
accentColor: root.accentRed
applet: root.applet
}
// 设置窗口:独立顶层窗口,在桌面中间弹出
@@ -110,6 +110,54 @@ 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();
}
// 返回当前活动接口的下行速度历史,转换为 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();
@@ -121,6 +169,8 @@ void NetworkMonitorApplet::setActiveInterface(const QString &interface)
m_activeInterface = interface;
m_firstUpdate = true;
emit activeInterfaceChanged();
// 接口切换后通知 QML 趋势图切换显示新接口的历史数据
emit speedHistoryChanged();
// 接口切换后立即检测新接口的 IP 地址
detectIpAddress();
}
@@ -174,6 +224,15 @@ 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);
}
}
// 自动选择活动接口
if (m_activeInterface.isEmpty() && !m_interfaceList.isEmpty()) {
// 优先选择物理网卡(wlp/wlan/enp/eth)中有流量的接口,
@@ -239,6 +298,25 @@ void NetworkMonitorApplet::calculateSpeed()
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();
}
@@ -279,21 +357,30 @@ qint64 NetworkMonitorApplet::getActiveTxBytes() const
return m_interfaces[m_activeInterface].txBytes;
}
// 检测活动接口的 IPv4 地址
// 跳过 IPv6 和 loopback取第一个有效 IPv4 地址
// IP 变化时发射 ipAddressChanged 信号通知 QML 更新
// 设计原因:DHCP 续约、网络切换等场景下 IP 可能变化,需持续检测
// 检测活动接口的 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 地址,跳过 IPv6 和 loopback
// IPv4:跳过 loopback,取第一个
if (entry.ip().protocol() == QAbstractSocket::IPv4Protocol
&& !entry.ip().isLoopback()) {
&& !entry.ip().isLoopback()
&& newIp.isEmpty()) {
newIp = entry.ip().toString();
break;
}
// IPv6:跳过 loopback 和 link-local (fe80::),取第一个全球地址
if (entry.ip().protocol() == QAbstractSocket::IPv6Protocol
&& !entry.ip().isLoopback()
&& !entry.ip().isLinkLocal()
&& newIpv6.isEmpty()) {
newIpv6 = entry.ip().toString();
}
}
}
@@ -301,6 +388,10 @@ void NetworkMonitorApplet::detectIpAddress()
m_ipAddress = newIp;
emit ipAddressChanged();
}
if (m_ipv6Address != newIpv6) {
m_ipv6Address = newIpv6;
emit ipv6AddressChanged();
}
}
// 判断是否为物理网卡
@@ -11,6 +11,9 @@
#include <QTimer>
#include <QVariantList>
#include <QMap>
#include <QHash>
#include <QPointF>
#include <QDateTime>
DS_BEGIN_NAMESPACE
@@ -26,6 +29,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
@@ -40,6 +52,15 @@ class NetworkMonitorApplet : public DApplet
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)
// 当前活动接口的下行速度历史采样点(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);
@@ -57,6 +78,13 @@ public:
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;
Q_INVOKABLE void refresh();
Q_INVOKABLE void setActiveInterface(const QString &interface);
@@ -69,12 +97,14 @@ signals:
void readyChanged();
void activeInterfaceChanged();
void ipAddressChanged();
void ipv6AddressChanged();
void speedHistoryChanged();
private:
void readNetworkStats();
void calculateSpeed();
void detectInterfaces();
// 检测活动接口的 IPv4 地址,变化时发射 ipAddressChanged
// 检测活动接口的 IPv4 与 IPv6 地址,变化时分别发射对应信号
void detectIpAddress();
// 判断是否为物理网卡(无线 wlp/wlan,有线 enp/eth),
// 用于自动选择时优先真实网卡而非虚拟代理接口(如 Meta/tun0)
@@ -87,6 +117,7 @@ private:
QStringList m_interfaceList;
QString m_activeInterface;
QString m_ipAddress; // 活动接口的 IPv4 地址
QString m_ipv6Address; // 活动接口的 IPv6 全球地址(过滤 link-local
// 速度计算
qint64 m_lastRxBytes;
@@ -100,6 +131,12 @@ private:
bool m_ready;
bool m_firstUpdate;
// 速度历史环形缓冲:每个接口独立维护一份,key 为接口名
// 设计原因:接口切换时趋势图不出现跳变,切回时仍能看到该接口历史
QHash<QString, QVector<SpeedSample>> m_speedHistory;
// 每个接口最多保留的采样点数:5 分钟 × 60 秒 = 300
static constexpr int MAX_HISTORY_SAMPLES = 300;
};
DS_END_NAMESPACE