feat(frontend): 支持快捷指令跨组拖拽并升级网络历史图
新增快捷指令跨标签组拖拽能力,支持将未标记命令直接拖入 目标标签组,并修正 manual/name/last_used 排序按钮状态映射 将状态监控内存与网络卡片的响应式阈值统一收紧到 250px, 同时用可悬浮查看最近采样点的 canvas 历史图替换网络趋势线
This commit is contained in:
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- **[frontend]**: 支持将快捷指令从一个标签组拖到另一个标签组内,允许把未标记命令直接拖入目标标签组,并修正 `manual / name / last_used` 排序按钮状态映射 — by yinjianm
|
||||||
|
- 方案: [202604190322_quickcommands-cross-group-drag-move](archive/2026-04/202604190322_quickcommands-cross-group-drag-move/)
|
||||||
|
|
||||||
|
- **[frontend]**: 将状态监控中的内存与网络卡片响应式阈值统一收紧到 250px,并把网络卡片的 SVG 趋势线升级为可 hover 查看最近 24 个采样点的 canvas 历史图 — by yinjianm
|
||||||
|
- 方案: [202604190319_status-monitor-memory-network-canvas-history](archive/2026-04/202604190319_status-monitor-memory-network-canvas-history/)
|
||||||
|
|
||||||
- **[frontend]**: 为快捷指令视图新增分组拖拽排序、组内命令拖拽排序与扁平命令列表拖拽排序,并在拖拽完成后自动切换到手动顺序视图以保持刷新后顺序一致 — by yinjianm
|
- **[frontend]**: 为快捷指令视图新增分组拖拽排序、组内命令拖拽排序与扁平命令列表拖拽排序,并在拖拽完成后自动切换到手动顺序视图以保持刷新后顺序一致 — by yinjianm
|
||||||
- 方案: [202604190208_quickcommands-drag-reorder](archive/2026-04/202604190208_quickcommands-drag-reorder/)
|
- 方案: [202604190208_quickcommands-drag-reorder](archive/2026-04/202604190208_quickcommands-drag-reorder/)
|
||||||
|
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"status":"completed","completed":5,"failed":0,"pending":0,"total":5,"done":5,"percent":100,"current":"已完成状态监控内存/网络卡片的 250px 响应式阈值调整,并接入网络 canvas 历史图","updated_at":"2026-04-19 03:33:00"}
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
# 变更提案: status-monitor-memory-network-canvas-history
|
||||||
|
|
||||||
|
## 元信息
|
||||||
|
```yaml
|
||||||
|
类型: 优化
|
||||||
|
方案类型: implementation
|
||||||
|
优先级: P1
|
||||||
|
状态: 已确认
|
||||||
|
创建: 2026-04-19
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 需求
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
当前 `/workspace` 右侧状态监控面板里,内存模块在较宽但仍偏窄的侧栏下过早切成手机式竖排,导致信息密度下降;网络模块则继续沿用 SVG sparkline,只能看趋势轮廓,无法像底部 `StatusCharts.vue` 那样通过 `canvas` 折线图查看更明确的历史采样值。用户明确要求把内存和网络模块的竖排阈值都收紧到“组件宽度低于 250px 才切手机竖排”,并把网络线图升级成可查看历史数据的 `canvas` 图表。
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
- 将内存卡片的响应式切换阈值收紧到容器宽度小于 `250px` 才改为竖排布局。
|
||||||
|
- 让网络卡片遵循同样的 `250px` 响应式阈值,并在宽度充足时保持更高信息密度的横向布局。
|
||||||
|
- 把网络卡片的 SVG 线改成 `canvas` 折线图,展示最近 24 个采样点,并支持 hover 查看上下行历史值。
|
||||||
|
- 复用现有历史采样数据链路,不新增后端接口或 websocket 消息。
|
||||||
|
|
||||||
|
### 约束条件
|
||||||
|
```yaml
|
||||||
|
时间约束: 本轮只改前端状态监控相关组件和文案
|
||||||
|
性能约束: 复用现有 Chart.js / vue-chartjs 依赖,不新增图表库
|
||||||
|
兼容性约束: 不改变 useStatusMonitor 现有历史数组结构和 StatusCharts 既有行为
|
||||||
|
业务约束: 历史图展示基于现有前端采样缓存,仅限当前会话已收到的监控数据
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [ ] 内存模块在容器宽度大于等于 `250px` 时保持横向高密度布局,仅在低于 `250px` 时切为竖排。
|
||||||
|
- [ ] 网络模块遵循同样的 `250px` 阈值,并在宽度充足时展示图表与统计信息的横向布局。
|
||||||
|
- [ ] 网络模块的折线图使用 `canvas` 渲染,数据来自最近 24 个网络历史采样点。
|
||||||
|
- [ ] 鼠标 hover 网络图时可查看上下行历史值,单位与当前历史峰值一致。
|
||||||
|
- [ ] 前端构建通过;若缺少本地后端导致运行态无法全链路验证,需要在执行备注中如实记录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 方案
|
||||||
|
|
||||||
|
### 技术方案
|
||||||
|
保留 `useStatusMonitor.ts` 的历史采样来源不变,在 `StatusMonitor.vue` 中重组内存和网络卡片布局。内存卡片改为默认双列分布,仅在模块容器宽度低于 `250px` 时切为单列。网络卡片新增一个专用的 `StatusMonitorNetworkHistoryChart.vue` 子组件,用 `vue-chartjs` + Chart.js 渲染最近 24 个采样点的上下行折线图,沿用 `StatusCharts.vue` 的单位换算思路与 hover tooltip 行为,并在 `250px` 以下退回为单列竖排。
|
||||||
|
|
||||||
|
### 影响范围
|
||||||
|
```yaml
|
||||||
|
涉及模块:
|
||||||
|
- frontend: StatusMonitor 卡片布局与网络历史图展示
|
||||||
|
- frontend-i18n: 状态监控卡片新增历史采样提示文案
|
||||||
|
预计变更文件: 6
|
||||||
|
```
|
||||||
|
|
||||||
|
### 风险评估
|
||||||
|
| 风险 | 等级 | 应对 |
|
||||||
|
|------|------|------|
|
||||||
|
| 在窄侧栏下新增 Chart.js 画布导致卡片高度或宽度挤压 | 中 | 将网络卡片拆成受 container query 控制的双列/单列布局,并限制图表包装高度 |
|
||||||
|
| 小图表与底部大图表重复渲染后造成会话切换时重绘抖动 | 中 | 仅渲染最近 24 点,关闭动画,使用轻量 options 并避免额外 watcher |
|
||||||
|
| 单位换算在小图与大图不一致,导致用户误读 | 低 | 直接复用与 `StatusCharts.vue` 一致的峰值判定和 tooltip 单位策略 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 技术设计(可选)
|
||||||
|
|
||||||
|
### 架构设计
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[useStatusMonitor 历史数组] --> B[StatusMonitor.vue]
|
||||||
|
B --> C[内存卡片 container query 布局]
|
||||||
|
B --> D[StatusMonitorNetworkHistoryChart.vue]
|
||||||
|
D --> E[Chart.js canvas 折线图]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据模型
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `netRxHistory` | `(number | null)[]` | 当前会话的下载速率历史,单位为 Bytes/sec |
|
||||||
|
| `netTxHistory` | `(number | null)[]` | 当前会话的上传速率历史,单位为 Bytes/sec |
|
||||||
|
| `memUsedHistory` | `(number | null)[]` | 当前会话的已用内存历史,继续由底部图表使用 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 核心场景
|
||||||
|
|
||||||
|
> 执行完成后同步到对应模块文档
|
||||||
|
|
||||||
|
### 场景: 侧栏宽度适中时保持高密度监控布局
|
||||||
|
**模块**: frontend
|
||||||
|
**条件**: 用户在 `/workspace` 右侧状态监控中查看内存和网络模块,模块宽度大于等于 `250px`。
|
||||||
|
**行为**: 内存卡片保持环形概览与统计块并排,网络卡片保持历史图与统计表并排。
|
||||||
|
**结果**: 用户在常见侧栏宽度下看到更高密度的监控信息,而不是过早进入手机竖排布局。
|
||||||
|
|
||||||
|
### 场景: 网络模块查看近期历史采样
|
||||||
|
**模块**: frontend
|
||||||
|
**条件**: 当前会话已积累网络历史采样,用户将鼠标移动到网络卡片中的折线图上。
|
||||||
|
**行为**: `canvas` 折线图显示最近 24 个采样点,并通过 tooltip 展示该采样点的上下行历史值。
|
||||||
|
**结果**: 用户可以在侧栏内直接查看近期网络波动,而不必只看 SVG 趋势线或切到下方大图。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 技术决策
|
||||||
|
|
||||||
|
> 本方案涉及的技术决策,归档后成为决策的唯一完整记录
|
||||||
|
|
||||||
|
### status-monitor-memory-network-canvas-history#D001: 复用 Chart.js 实现网络卡片内联历史图
|
||||||
|
**日期**: 2026-04-19
|
||||||
|
**状态**: ✅采纳
|
||||||
|
**背景**: 网络卡片需要从 SVG sparkline 升级为可 hover 查看历史值的 `canvas` 图表,但仓库已经存在底部 `StatusCharts.vue` 的 Chart.js 方案。
|
||||||
|
**选项分析**:
|
||||||
|
| 选项 | 优点 | 缺点 |
|
||||||
|
|------|------|------|
|
||||||
|
| A: 继续手写 SVG sparkline 并自建 hover 逻辑 | 依赖少、组件轻 | 需要自行处理命中区域、tooltip、单位换算和历史点映射 |
|
||||||
|
| B: 复用现有 Chart.js / vue-chartjs 体系做小型内联图 | `canvas` 输出天然满足需求,tooltip 和响应式能力现成 | 需要额外封装一个轻量子组件,避免主文件继续膨胀 |
|
||||||
|
**决策**: 选择方案 B
|
||||||
|
**理由**: 仓库已稳定使用 Chart.js,直接复用能最小成本满足“canvas + 历史值 hover”要求,同时与底部图表保持交互一致。
|
||||||
|
**影响**: 影响前端状态监控卡片结构与本地文案,不涉及后端接口和状态采样协议。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 成果设计
|
||||||
|
|
||||||
|
### 设计方向
|
||||||
|
- **美学基调**: 延续当前状态监控的深色服务器小屏风格,但把“趋势感”从装饰性线条升级为更有仪表感的真实历史图。
|
||||||
|
- **记忆点**: 网络卡片左侧嵌入式 `canvas` 折线图,在狭窄侧栏内也能像迷你监控屏一样浏览最近采样。
|
||||||
|
- **参考**: 视觉与交互参考现有 `StatusCharts.vue`,但收敛为卡片内联版本,维持当前状态监控卡片的材质和边框体系。
|
||||||
|
|
||||||
|
### 视觉要素
|
||||||
|
- **配色**: 保持深色背景和当前网络上下行的绿/蓝语义色,不引入新的主色体系。
|
||||||
|
- **字体**: 沿用现有状态监控与图表中的 monospace 数值展示。
|
||||||
|
- **布局**: 网络模块在宽度充足时采用“图表左 / 数据右”的双列结构,低于 `250px` 再退化为竖排。
|
||||||
|
- **动效**: 图表关闭动画,仅保留 hover tooltip 反馈,避免监控场景下的无意义运动。
|
||||||
|
- **氛围**: 保持服务器监控面板的控制台式深色氛围,不做额外装饰性改造。
|
||||||
|
|
||||||
|
### 技术约束
|
||||||
|
- **可访问性**: 图表容器保留可读标题和最近采样说明,避免只剩抽象线条。
|
||||||
|
- **响应式**: 以 container query 为准,`250px` 以下才切为手机竖排。
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
# 任务清单: status-monitor-memory-network-canvas-history
|
||||||
|
|
||||||
|
> **@status:** completed | 2026-04-19 03:32
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
@feature: status-monitor-memory-network-canvas-history
|
||||||
|
@created: 2026-04-19
|
||||||
|
@status: completed
|
||||||
|
@mode: R2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 进度概览
|
||||||
|
|
||||||
|
| 完成 | 失败 | 跳过 | 总数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 5 | 0 | 0 | 5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务列表
|
||||||
|
|
||||||
|
### 1. 状态监控卡片实现
|
||||||
|
|
||||||
|
- [√] 1.1 在 `packages/frontend/src/components/StatusMonitor.vue` 中把内存卡片的横向布局阈值调整为低于 250px 才切竖排 | depends_on: []
|
||||||
|
- [√] 1.2 新增 `packages/frontend/src/components/StatusMonitorNetworkHistoryChart.vue`,用 canvas 折线图展示最近 24 个网络历史采样点并支持 hover 查看值 | depends_on: [1.1]
|
||||||
|
- [√] 1.3 在 `packages/frontend/src/components/StatusMonitor.vue` 中接入网络历史图组件,并让网络模块在 250px 以下才切竖排 | depends_on: [1.2]
|
||||||
|
|
||||||
|
### 2. 文案与验证
|
||||||
|
|
||||||
|
- [√] 2.1 在 `packages/frontend/src/locales/zh-CN.json`、`packages/frontend/src/locales/en-US.json`、`packages/frontend/src/locales/ja-JP.json` 中补充网络历史采样提示文案 | depends_on: [1.3]
|
||||||
|
- [√] 2.2 运行 `packages/frontend` 构建校验并记录结果 | depends_on: [2.1]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行日志
|
||||||
|
|
||||||
|
| 时间 | 任务 | 状态 | 备注 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 2026-04-19 03:20 | design | completed | 已创建 implementation 方案包,范围锁定为状态监控内存/网络卡片的 250px 响应式阈值与网络 canvas 历史图 |
|
||||||
|
| 2026-04-19 03:26 | 1.1 | completed | 已将内存卡片默认布局改为高密度横排,仅在容器宽度低于 250px 时切竖排 |
|
||||||
|
| 2026-04-19 03:29 | 1.2 | completed | 已新增网络历史图子组件,复用 Chart.js canvas 展示最近 24 个采样点并支持 hover 查看上下行值 |
|
||||||
|
| 2026-04-19 03:31 | 1.3 | completed | 已在 StatusMonitor 中接入网络历史图,并让网络模块与内存模块共用 250px 竖排阈值 |
|
||||||
|
| 2026-04-19 03:32 | 2.1 | completed | 已补充中英日三套“最近历史采样点”文案 |
|
||||||
|
| 2026-04-19 03:33 | 2.2 | completed | `npm --workspace @nexus-terminal/frontend run build` 通过,仅保留既有 chunk 警告 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行备注
|
||||||
|
|
||||||
|
> 记录执行过程中的重要说明、决策变更、风险提示等
|
||||||
|
|
||||||
|
- 当前需求只涉及前端卡片布局与图表渲染,现有 websocket 历史采样数据链路保持不变。
|
||||||
|
- 运行态限制: 本轮未拉起完整登录后工作区环境,因此“hover 查看网络历史值”的最终交互仍建议在你的实际 `/workspace` 环境手动确认一次。
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
# 变更提案: quickcommands-cross-group-drag-move
|
||||||
|
|
||||||
|
## 元信息
|
||||||
|
```yaml
|
||||||
|
类型: 新功能
|
||||||
|
方案类型: implementation
|
||||||
|
优先级: P1
|
||||||
|
状态: 已完成
|
||||||
|
创建: 2026-04-19
|
||||||
|
完成: 2026-04-19
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 需求
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
快捷指令视图已经支持分组拖拽排序、组内命令拖拽排序和扁平列表拖拽排序,但当前命令拖放逻辑仍然把拖拽限制在“同一分组内”。这会导致用户虽然能整理每个分组内部的顺序,却不能把命令从一个标签组直接拖到另一个标签组,也不能把“未标记”命令直接拖进目标标签组,和 Workbench 中“拖到目标容器即完成归类”的直觉交互不一致。
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
- 让已标记分组中的命令可以拖到另一个已标记分组内,并在落点位置插入。
|
||||||
|
- 让“未标记”分组中的命令可以直接拖到已标记分组内,并自动添加目标标签。
|
||||||
|
- 跨分组移动后移除原分组标签,只保留目标分组与其它未受影响的既有标签。
|
||||||
|
- 修正快捷指令排序按钮在 `manual / name / last_used` 三种模式下的文案和图标映射。
|
||||||
|
|
||||||
|
### 约束条件
|
||||||
|
```yaml
|
||||||
|
时间约束: 本轮完成前端实现、构建验证与知识库同步
|
||||||
|
性能约束: 不新增依赖,继续使用现有原生 drag/drop 与 store action
|
||||||
|
兼容性约束: 优先复用现有后端增量标签同步与重排接口,不新增后端路由
|
||||||
|
业务约束: 搜索过滤态仍禁止拖拽排序;本轮暂不支持把已标记命令拖入“未标记”分组
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [ ] 开启标签分组时,把命令从标签 A 拖到标签 B 后,命令会移除标签 A、加入标签 B,并插入到标签 B 的目标位置。
|
||||||
|
- [ ] 开启标签分组时,把“未标记”命令拖到标签 B 后,命令会新增标签 B,并插入到标签 B 的目标位置。
|
||||||
|
- [ ] 当前轮次不支持把已标记命令拖入“未标记”分组,界面会给出非阻断提示而不是错误修改标签。
|
||||||
|
- [ ] 排序按钮能正确反映 `manual / name / last_used` 三种模式的标题与图标。
|
||||||
|
- [ ] `npm run build --workspace @nexus-terminal/backend` 与 `npm run build --workspace @nexus-terminal/frontend` 通过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 方案
|
||||||
|
|
||||||
|
### 技术方案
|
||||||
|
本次改动只调整前端编排,继续复用已落地的快捷指令更新与顺序持久化能力,不新增后端接口。
|
||||||
|
|
||||||
|
第一步,在 `QuickCommandsView.vue` 放开命令拖放目标的“同组限制”,并补一个支持“源项不在目标列表中时插入”的列表工具函数。这样拖拽命中目标组后,既能处理原来的组内重排,也能处理跨组插入。
|
||||||
|
|
||||||
|
第二步,在命令 drop 分支中区分三类路径:同组重排继续沿用现有 `reorderCommandsInTag` / `reorderQuickCommands`;“未标记 -> 已标记”与“已标记 A -> 已标记 B”则先通过 `updateQuickCommand(...)` 更新标签集合,再按目标组当前列表调用 `reorderCommandsInTag(targetTagId, ...)` 固定落点顺序。
|
||||||
|
|
||||||
|
第三步,保留“拖到未标记分组”禁用策略。因为本轮已确认的业务语义是“移除原标签并加入目标标签”,而未标记分组没有可加入的标签,若开放该动作就会变成“删除标签”或“清空所有标签”,和当前确认范围冲突,所以只给用户提示,不执行实际写入。
|
||||||
|
|
||||||
|
最后顺手修正排序按钮在 `manual` 模式下的标题与图标,让拖拽完成后切回 `manual` 时,控件状态与实际排序一致。
|
||||||
|
|
||||||
|
### 影响范围
|
||||||
|
```yaml
|
||||||
|
涉及模块:
|
||||||
|
- frontend: QuickCommandsView 需要实现跨分组拖放与 manual 排序按钮修正
|
||||||
|
- knowledge-base: 需要同步 frontend/backend 模块文档与 CHANGELOG
|
||||||
|
预计变更文件: 5-7
|
||||||
|
```
|
||||||
|
|
||||||
|
### 风险评估
|
||||||
|
| 风险 | 等级 | 应对 |
|
||||||
|
|------|------|------|
|
||||||
|
| 跨组移动先改标签、再改目标组顺序,中间刷新可能导致落点顺序丢失 | 中 | 在标签更新完成后立刻基于刷新后的目标组列表再次提交 `reorderCommandsInTag` |
|
||||||
|
| 目标是“未标记”分组时语义不清,可能被误实现为清空全部标签 | 高 | 明确禁止该路径,仅提示用户本轮不支持 |
|
||||||
|
| 多标签命令跨组移动时若直接覆盖 tagIds,可能误删其他标签 | 中 | 基于原 `tagIds` 做“移除 sourceTagId、加入 targetTagId”的增量计算 |
|
||||||
|
| `manual` 排序按钮状态未修正,用户会误判当前排序模式 | 低 | 同步补齐 `sortButtonTitle` 和 `sortButtonIcon` 的三态映射 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 技术设计
|
||||||
|
|
||||||
|
### 架构设计
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[QuickCommandsView drop] --> B{是否跨分组}
|
||||||
|
B -->|否| C[现有组内重排]
|
||||||
|
B -->|是| D[updateQuickCommand 同步 tagIds]
|
||||||
|
D --> E[reorderCommandsInTag 固定目标组落点]
|
||||||
|
E --> F[(现有 reorder / association API)]
|
||||||
|
```
|
||||||
|
|
||||||
|
### API设计
|
||||||
|
#### PUT /api/v1/quick-commands/:id
|
||||||
|
- **请求**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Deploy",
|
||||||
|
"command": "npm run deploy",
|
||||||
|
"tagIds": [5, 9]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "快捷指令已更新"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PUT /api/v1/quick-commands/reorder-by-tag
|
||||||
|
- **请求**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tagId": 9,
|
||||||
|
"commandIds": [12, 7, 19]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "标签内快捷指令顺序已更新"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据模型
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `QuickCommandFE.tagIds` | `number[]` | 命令当前绑定的标签集合,跨组移动时按“移除源标签 + 加入目标标签”增量计算 |
|
||||||
|
| `QuickCommandFE.tagOrders` | `Record<number, number>` | 命令在各标签组中的局部顺序,更新标签后通过目标组重排接口重新固定 |
|
||||||
|
| `GroupedQuickCommands.commands` | `QuickCommandFE[]` | 分组视图当前命令列表,跨组插入后用于计算目标顺序 |
|
||||||
|
| `QuickCommandSortByType` | `'manual' | 'name' | 'usage_count' | 'last_used'` | 排序按钮需要正确反映其中实际启用的三种前端模式 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 核心场景
|
||||||
|
|
||||||
|
### 场景: 已标记命令拖到另一个已标记分组
|
||||||
|
**模块**: frontend
|
||||||
|
**条件**: 用户开启快捷指令标签分组,当前不处于搜索过滤状态。
|
||||||
|
**行为**: 用户把标签 A 中的命令拖到标签 B 的某个命令上,前端先移除标签 A、加入标签 B,再按落点重排标签 B。
|
||||||
|
**结果**: 命令从标签 A 消失,出现在标签 B 的目标位置,刷新后保持一致。
|
||||||
|
|
||||||
|
### 场景: 未标记命令拖到已标记分组
|
||||||
|
**模块**: frontend
|
||||||
|
**条件**: 用户开启快捷指令标签分组,存在“未标记”分组且当前不处于搜索过滤状态。
|
||||||
|
**行为**: 用户把“未标记”命令拖到某个标签组内,前端为命令新增目标标签,并在目标组内固定插入顺序。
|
||||||
|
**结果**: 命令离开“未标记”分组,进入目标标签组。
|
||||||
|
|
||||||
|
### 场景: 已标记命令拖到未标记分组
|
||||||
|
**模块**: frontend
|
||||||
|
**条件**: 用户尝试把某个标签组中的命令拖到“未标记”分组。
|
||||||
|
**行为**: 前端识别为当前轮次未支持的路径,只给出提示,不提交更新请求。
|
||||||
|
**结果**: 原有标签关系保持不变,避免误清空标签。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 技术决策
|
||||||
|
|
||||||
|
### quickcommands-cross-group-drag-move#D001: 优先复用现有更新与重排接口,而不是新增“跨组移动”专用后端接口
|
||||||
|
**日期**: 2026-04-19
|
||||||
|
**状态**: 已采纳
|
||||||
|
**背景**: 现有后端已经提供 `updateQuickCommand`、`reorder-by-tag` 和增量标签关联同步能力,理论上足以表达“移除原标签、加入目标标签、固定目标组顺序”。
|
||||||
|
**选项分析**:
|
||||||
|
| 选项 | 优点 | 缺点 |
|
||||||
|
|------|------|------|
|
||||||
|
| A: 新增后端 `move-between-tags` 专用接口 | 前端调用简单,语义集中 | 需要新增 controller/service/repository 路径,改动面更大 |
|
||||||
|
| B: 复用现有更新命令 + 标签内重排接口 | 后端零新增接口,改动集中在前端编排 | 前端需要串联两步请求 |
|
||||||
|
**决策**: 选择方案 B
|
||||||
|
**理由**: 当前用户确认的是交互增强,不是后端能力缺失。复用现有接口可以最小代价完成需求,并降低本轮回归面。
|
||||||
|
**影响**: frontend
|
||||||
|
|
||||||
|
### quickcommands-cross-group-drag-move#D002: 本轮禁止“已标记 -> 未标记”拖放
|
||||||
|
**日期**: 2026-04-19
|
||||||
|
**状态**: 已采纳
|
||||||
|
**背景**: 已确认的业务语义是“拖到目标分组后,移除原分组标签并加入目标分组标签”。未标记分组没有可加入的标签,因此该路径语义不完整。
|
||||||
|
**选项分析**:
|
||||||
|
| 选项 | 优点 | 缺点 |
|
||||||
|
|------|------|------|
|
||||||
|
| A: 拖入未标记时清空全部标签 | 交互看起来完整 | 会把“移动分组”变成“清空标签”,风险高且和当前确认不一致 |
|
||||||
|
| B: 拖入未标记时只移除源标签 | 语义比清空全部标签更保守 | 对多标签命令仍不清晰,用户难以预期 |
|
||||||
|
| C: 本轮禁用该路径,只提示用户 | 范围清晰,不引入隐式删除语义 | 功能覆盖面少一步 |
|
||||||
|
**决策**: 选择方案 C
|
||||||
|
**理由**: 当前需求边界已经确认到“加入目标标签”,而未标记不具备该条件。禁用比猜测性实现更安全。
|
||||||
|
**影响**: frontend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 成果设计
|
||||||
|
|
||||||
|
### 设计方向
|
||||||
|
- **美学基调**: 延续现有快捷指令工作台的深色工具面板风格,不做视觉重设计,只强化“卡片可以被移入另一组”的操作感。
|
||||||
|
- **记忆点**: 命令卡片在跨组拖放时仍使用现有虚线高亮占位,用户可以直接感知落点是在目标组内部而不是只做选中。
|
||||||
|
- **参考**: 复用当前 `QuickCommandsView.vue` 中已存在的组内拖拽高亮和手动排序交互。
|
||||||
|
|
||||||
|
### 视觉要素
|
||||||
|
- **配色**: 继续沿用主题变量和当前 `qc-drop-target` 虚线反馈,不新增独立拖拽色板。
|
||||||
|
- **字体**: 沿用现有标题和命令 monospace 体系,不调整文本层级。
|
||||||
|
- **布局**: 保持现有分组头和命令卡结构,仅扩展拖放命中逻辑与排序按钮状态表达。
|
||||||
|
- **动效**: 继续使用现有 hover / 拖放过渡,不增加额外动画。
|
||||||
|
- **氛围**: 保持克制、偏工程控制台的交互表达,让跨组归类像“拖入目标容器”一样直接。
|
||||||
|
|
||||||
|
### 技术约束
|
||||||
|
- **可访问性**: 不能破坏现有单击选中、双击执行、右键菜单和键盘选择逻辑。
|
||||||
|
- **响应式**: 继续兼容紧凑模式与 Workbench 窄栏布局,不增加额外结构占位。
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
# 任务清单: quickcommands-cross-group-drag-move
|
||||||
|
|
||||||
|
> **@status:** completed | 2026-04-19 03:34
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
@feature: quickcommands-cross-group-drag-move
|
||||||
|
@created: 2026-04-19
|
||||||
|
@status: completed
|
||||||
|
@mode: R2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 进度概览
|
||||||
|
|
||||||
|
| 完成 | 失败 | 跳过 | 总数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 7 | 0 | 0 | 7 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务列表
|
||||||
|
|
||||||
|
### 1. 方案与范围收敛
|
||||||
|
|
||||||
|
- [√] 1.1 确认 `packages/frontend/src/views/QuickCommandsView.vue` 当前只支持同组命令拖拽,并梳理可复用的 `updateQuickCommand` / `reorderCommandsInTag` 能力 | depends_on: []
|
||||||
|
- [√] 1.2 明确跨组移动语义为“移除源标签、加入目标标签”,并将“拖入未标记”限定为本轮不支持路径 | depends_on: [1.1]
|
||||||
|
|
||||||
|
### 2. 前端交互实现
|
||||||
|
|
||||||
|
- [√] 2.1 在 `packages/frontend/src/views/QuickCommandsView.vue` 中补充支持跨组插入的列表工具函数,并放开跨组拖放命中判断 | depends_on: [1.2]
|
||||||
|
- [√] 2.2 在 `packages/frontend/src/views/QuickCommandsView.vue` 中实现“已标记 -> 已标记”和“未标记 -> 已标记”的跨组移动编排,同时为“已标记 -> 未标记”提供提示保护 | depends_on: [2.1]
|
||||||
|
- [√] 2.3 在 `packages/frontend/src/views/QuickCommandsView.vue` 中修正 `manual / name / last_used` 三态排序按钮文案与图标 | depends_on: [2.1]
|
||||||
|
|
||||||
|
### 3. 验证与同步
|
||||||
|
|
||||||
|
- [√] 3.1 执行 `npm run build --workspace @nexus-terminal/backend` 与 `npm run build --workspace @nexus-terminal/frontend`,确认现有后端与前端构建通过 | depends_on: [2.2, 2.3]
|
||||||
|
- [√] 3.2 同步更新 `.helloagents/modules/frontend.md`、`.helloagents/modules/backend.md` 与 `.helloagents/CHANGELOG.md`,记录跨分组拖放能力并归档方案包 | depends_on: [3.1]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行日志
|
||||||
|
|
||||||
|
| 时间 | 任务 | 状态 | 备注 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 2026-04-19 03:22 | EVALUATE | completed | 已按 R2 确认“跨组拖拽 = 移除原分组标签并加入目标分组标签” |
|
||||||
|
| 2026-04-19 03:24 | DESIGN | completed | 已确认优先复用现有快捷指令更新与标签内重排接口,不新增后端路由 |
|
||||||
|
| 2026-04-19 03:27 | 2.1 / 2.2 / 2.3 | completed | `QuickCommandsView.vue` 已补齐跨分组插入 helper、跨组移动分支、未标记保护提示与 manual 排序按钮映射 |
|
||||||
|
| 2026-04-19 03:31 | validate_package | completed | `validate_package.py 202604190322_quickcommands-cross-group-drag-move --path E:/code/vue/nexus-terminal` 通过 |
|
||||||
|
| 2026-04-19 03:34 | 3.1 | completed | `npm run build --workspace @nexus-terminal/backend` 与 `npm run build --workspace @nexus-terminal/frontend` 通过;前端仅保留既有 vite chunk warnings |
|
||||||
|
| 2026-04-19 03:34 | 3.2 | completed | 已同步 frontend/backend 模块文档与 CHANGELOG,准备归档方案包 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行备注
|
||||||
|
|
||||||
|
> 本轮实现以前端编排为主:同组拖拽保持原有重排路径,跨组拖拽则拆成“静默更新 tagIds”与“固定目标组顺序”两步,以最小改动复用现有后端能力,同时避免在拖放操作中弹出编辑成功提示。
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
||||||
|--------|------|------|---------|------|------|
|
|--------|------|------|---------|------|------|
|
||||||
|
| 202604190322 | quickcommands-cross-group-drag-move | implementation | frontend | quickcommands-cross-group-drag-move#D001, quickcommands-cross-group-drag-move#D002 | ✅完成 |
|
||||||
|
| 202604190319 | status-monitor-memory-network-canvas-history | - | - | - | ✅完成 |
|
||||||
| 202604190208 | quickcommands-drag-reorder | - | - | - | ✅完成 |
|
| 202604190208 | quickcommands-drag-reorder | - | - | - | ✅完成 |
|
||||||
| 202604190210 | connection-card-default-test-button | implementation | frontend | - | ✅完成 |
|
| 202604190210 | connection-card-default-test-button | implementation | frontend | - | ✅完成 |
|
||||||
| 202604190201 | connection-password-visibility-toggle | - | - | - | ✅完成 |
|
| 202604190201 | connection-password-visibility-toggle | - | - | - | ✅完成 |
|
||||||
@@ -59,6 +61,8 @@
|
|||||||
## 按月归档
|
## 按月归档
|
||||||
|
|
||||||
### 2026-04
|
### 2026-04
|
||||||
|
- [202604190322_quickcommands-cross-group-drag-move](./2026-04/202604190322_quickcommands-cross-group-drag-move/) - 支持将快捷指令从一个标签组拖到另一个标签组内,并允许把未标记命令直接拖入目标标签组
|
||||||
|
- [202604190319_status-monitor-memory-network-canvas-history](./2026-04/202604190319_status-monitor-memory-network-canvas-history/) - 将状态监控中的内存与网络卡片响应式阈值统一收紧到 250px,并把网络卡片的 SVG 趋势线升级为可 hover 查看最近 24 个采样点的 canvas 历史图
|
||||||
- [202604190210_connection-card-default-test-button](./2026-04/202604190210_connection-card-default-test-button/) - 将连接管理页 SSH 连接卡片的默认操作区调整为“连接 / 测试 / 更多”,并移除更多菜单中的重复测试入口
|
- [202604190210_connection-card-default-test-button](./2026-04/202604190210_connection-card-default-test-button/) - 将连接管理页 SSH 连接卡片的默认操作区调整为“连接 / 测试 / 更多”,并移除更多菜单中的重复测试入口
|
||||||
- [202604190201_connection-password-visibility-toggle](./2026-04/202604190201_connection-password-visibility-toggle/) - 为连接新增/编辑表单与登录凭证管理弹窗补充密码显隐切换,默认仍隐藏,仅在本地输入端切换明文核对
|
- [202604190201_connection-password-visibility-toggle](./2026-04/202604190201_connection-password-visibility-toggle/) - 为连接新增/编辑表单与登录凭证管理弹窗补充密码显隐切换,默认仍隐藏,仅在本地输入端切换明文核对
|
||||||
- [202604152323_status-monitor-reference-layout-parity](./2026-04/202604152323_status-monitor-reference-layout-parity/) - 将右侧状态监控默认视图重排为更贴近参考图的窄屏监控布局,修正顶部信息区与模块内部左右关系
|
- [202604152323_status-monitor-reference-layout-parity](./2026-04/202604152323_status-monitor-reference-layout-parity/) - 将右侧状态监控默认视图重排为更贴近参考图的窄屏监控布局,修正顶部信息区与模块内部左右关系
|
||||||
|
|||||||
@@ -84,4 +84,4 @@
|
|||||||
### 快捷指令顺序持久化
|
### 快捷指令顺序持久化
|
||||||
**条件**: 前端快捷指令视图提交分组拖拽、标签内命令拖拽或扁平列表命令拖拽结果。
|
**条件**: 前端快捷指令视图提交分组拖拽、标签内命令拖拽或扁平列表命令拖拽结果。
|
||||||
**行为**: packages/backend/src/database/schema.ts 与 migrations.ts 现在为 quick_commands、quick_command_tags 与 quick_command_tag_associations 三张表补齐 sort_order 字段;quick-commands 业务域新增 /api/v1/quick-commands/reorder 与 /api/v1/quick-commands/reorder-by-tag,quick-command-tags 业务域新增 /api/v1/quick-command-tags/reorder。同时标签关联写入从“先删后插”调整为增量同步,保留命令已存在标签关联的原组内顺序,仅为新增关联追加新的末尾顺序。
|
**行为**: packages/backend/src/database/schema.ts 与 migrations.ts 现在为 quick_commands、quick_command_tags 与 quick_command_tag_associations 三张表补齐 sort_order 字段;quick-commands 业务域新增 /api/v1/quick-commands/reorder 与 /api/v1/quick-commands/reorder-by-tag,quick-command-tags 业务域新增 /api/v1/quick-command-tags/reorder。同时标签关联写入从“先删后插”调整为增量同步,保留命令已存在标签关联的原组内顺序,仅为新增关联追加新的末尾顺序。
|
||||||
**结果**: 后端可以稳定表达“标签顺序”“命令全局顺序”和“命令在某个标签组内的局部顺序”三层语义,并保证历史数据库升级后也能直接承接前端拖拽排序能力。
|
**结果**: 后端可以稳定表达“标签顺序”“命令全局顺序”和“命令在某个标签组内的局部顺序”三层语义,并保证历史数据库升级后也能直接承接前端拖拽排序能力;这也让前端可以通过“先更新 `tagIds`、再提交目标标签内重排”复用既有接口实现跨分组移动,而无需新增专用后端路由。
|
||||||
|
|||||||
@@ -58,11 +58,11 @@
|
|||||||
|
|
||||||
### 状态监控卡片
|
### 状态监控卡片
|
||||||
**条件**: 用户在 `/workspace` 右侧状态监控面板查看服务器资源状态。
|
**条件**: 用户在 `/workspace` 右侧状态监控面板查看服务器资源状态。
|
||||||
**行为**: `StatusMonitor.vue` 当前已从通用卡片栅格重排为更接近参考图的窄屏监控结构:顶部改为成对的信息条,资源概览改为带编号的紧凑使用率行,内存/网络/磁盘模块都采用明显的左右分区关系,分别展示环形占比+统计堆叠、监控屏风格网络面板+上下行速率堆叠,以及设备视觉块+紧凑磁盘摘要;默认视图底部继续保留“进程管理”概览与高占用进程预览,并通过“查看全部”打开 `ProcessManagerModal.vue`。该 modal 继续采用深色控制台式表格布局,支持搜索 PID / 用户 / 命令、自动刷新、手动刷新,以及对单个进程执行“结束”或“强制结束”操作,并通过当前活动 SSH 会话的 `wsManager` 与后端 `process:list` / `process:signal` 消息交互。
|
**行为**: `StatusMonitor.vue` 当前已从通用卡片栅格重排为更接近参考图的窄屏监控结构:顶部改为成对的信息条,资源概览改为带编号的紧凑使用率行,内存/网络/磁盘模块都采用明显的左右分区关系;其中内存卡片现在会在容器宽度大于等于 250px 时维持环形概览与统计块的高密度横向布局,仅在低于 250px 时切为手机式竖排;网络卡片则通过新增 `StatusMonitorNetworkHistoryChart.vue` 把原本的 SVG 趋势线替换为基于 Chart.js `canvas` 的最近 24 个采样点历史图,并在宽度大于等于 250px 时保持“左侧历史图 + 右侧统计表”的横向布局,低于 250px 再切为竖排;磁盘模块继续展示设备视觉块与紧凑磁盘摘要;默认视图底部继续保留“进程管理”概览与高占用进程预览,并通过“查看全部”打开 `ProcessManagerModal.vue`。该 modal 继续采用深色控制台式表格布局,支持搜索 PID / 用户 / 命令、自动刷新、手动刷新,以及对单个进程执行“结束”或“强制结束”操作,并通过当前活动 SSH 会话的 `wsManager` 与后端 `process:list` / `process:signal` 消息交互。
|
||||||
**结果**: 前端状态监控形成了“更贴近参考图的默认小屏监控 + 独立进程管理页”的双层结构:默认面板先解决左右布局和视觉层级问题,而完整进程管理继续独立存在,不挤占侧栏本体。
|
**结果**: 前端状态监控形成了“更贴近参考图的默认小屏监控 + 独立进程管理页”的双层结构:默认面板不仅保持了侧栏内的高密度布局,还允许用户直接在网络卡片里查看近期网络历史波动,而完整进程管理继续独立存在,不挤占侧栏本体。
|
||||||
|
|
||||||
### 快捷指令拖拽排序
|
### 快捷指令拖拽排序
|
||||||
**条件**: 用户在 Workbench 的快捷指令视图中浏览分组或扁平命令列表,且当前未启用搜索过滤。
|
**条件**: 用户在 Workbench 的快捷指令视图中浏览分组或扁平命令列表,且当前未启用搜索过滤。
|
||||||
**行为**: `QuickCommandsView.vue` 现已支持拖动已标记分组、标签组内命令,以及关闭标签展示后的扁平命令列表;拖拽完成后会通过 `quickCommands.store.ts` 与 `quickCommandTags.store.ts` 分别调用 `/api/v1/quick-command-tags/reorder`、`/api/v1/quick-commands/reorder` 和 `/api/v1/quick-commands/reorder-by-tag` 回写顺序。列表排序模式同步扩展为 `manual / name / last_used`,其中拖拽结果会自动落回 `manual` 视图承接。
|
**行为**: `QuickCommandsView.vue` 现已支持拖动已标记分组、标签组内命令,以及关闭标签展示后的扁平命令列表;拖拽完成后会通过 `quickCommands.store.ts` 与 `quickCommandTags.store.ts` 分别调用 `/api/v1/quick-command-tags/reorder`、`/api/v1/quick-commands/reorder` 和 `/api/v1/quick-commands/reorder-by-tag` 回写顺序。当前在开启标签分组且未搜索时,还允许把命令从一个已标记分组拖到另一个已标记分组内,或把“未标记”命令直接拖入目标标签组;前端会先静默调用 `updateQuickCommand(...)` 调整 `tagIds`,再调用 `/api/v1/quick-commands/reorder-by-tag` 固定目标组落点顺序。列表排序模式同步扩展为 `manual / name / last_used`,其中拖拽结果会自动落回 `manual` 视图承接;当前仍禁止把已标记命令拖入“未标记”分组,避免把“移动分组”误解释为隐式清空标签。
|
||||||
**结果**: 快捷指令分组顺序、组内顺序和扁平列表顺序在刷新后保持一致,而搜索过滤态继续保持只读展示,避免局部结果重排污染全量顺序。
|
**结果**: 快捷指令分组顺序、组内顺序、跨组归类结果和扁平列表顺序在刷新后保持一致,而搜索过滤态继续保持只读展示,避免局部结果重排污染全量顺序。
|
||||||
|
|
||||||
|
|||||||
@@ -120,43 +120,45 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="monitor-module monitor-module--network">
|
<section class="monitor-module monitor-module--network">
|
||||||
<div class="network-module__hero">
|
<div class="monitor-module__heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="monitor-module__eyebrow">{{ t('statusMonitor.networkLabel') }}</span>
|
<span class="monitor-module__eyebrow">{{ t('statusMonitor.networkLabel') }}</span>
|
||||||
<h5 class="monitor-module__title">{{ t('statusMonitor.networkLabel') }}</h5>
|
<h5 class="monitor-module__title">{{ t('statusMonitor.networkLabel') }}</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="network-module__sparkline" aria-hidden="true">
|
<span class="monitor-module__pill">{{ t('statusMonitor.networkSpeedTitleUnit', { unit: networkRateUnitLabel }) }}</span>
|
||||||
<svg viewBox="0 0 160 30" preserveAspectRatio="none">
|
|
||||||
<path class="network-module__sparkline-path network-module__sparkline-path--up" :d="networkUpSparklinePath"></path>
|
|
||||||
<path class="network-module__sparkline-path network-module__sparkline-path--down" :d="networkDownSparklinePath"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="network-table">
|
<div class="module-split module-split--network">
|
||||||
<div class="network-table__header">
|
<StatusMonitorNetworkHistoryChart
|
||||||
<span>{{ networkInterfaceDisplay }}</span>
|
:download-history="currentNetRxHistory"
|
||||||
<span>{{ t('statusMonitor.downloadLabel') }} / {{ t('statusMonitor.uploadLabel') }}</span>
|
:upload-history="currentNetTxHistory"
|
||||||
</div>
|
/>
|
||||||
<div class="network-table__columns">
|
|
||||||
<span></span>
|
|
||||||
<span>{{ t('statusMonitor.networkSpeedTitleUnit', { unit: networkRateUnitLabel }) }}</span>
|
|
||||||
<span>{{ t('statusMonitor.totalTrafficLabel') }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="network-stat-stack">
|
<div class="network-table">
|
||||||
<article
|
<div class="network-table__header">
|
||||||
v-for="item in networkFlowItems"
|
<span>{{ networkInterfaceDisplay }}</span>
|
||||||
:key="item.key"
|
<span>{{ t('statusMonitor.downloadLabel') }} / {{ t('statusMonitor.uploadLabel') }}</span>
|
||||||
:class="['network-stat', `network-stat--${item.tone}`]"
|
</div>
|
||||||
>
|
<div class="network-table__columns">
|
||||||
<span class="network-stat__label">
|
<span></span>
|
||||||
<i :class="['fas', item.icon]"></i>
|
<span>{{ t('statusMonitor.networkSpeedTitleUnit', { unit: networkRateUnitLabel }) }}</span>
|
||||||
<span>{{ item.label }}</span>
|
<span>{{ t('statusMonitor.totalTrafficLabel') }}</span>
|
||||||
</span>
|
</div>
|
||||||
<span class="network-stat__value">{{ item.value }}</span>
|
|
||||||
<span class="network-stat__total">{{ item.totalValue }}</span>
|
<div class="network-stat-stack">
|
||||||
</article>
|
<article
|
||||||
|
v-for="item in networkFlowItems"
|
||||||
|
:key="item.key"
|
||||||
|
:class="['network-stat', `network-stat--${item.tone}`]"
|
||||||
|
>
|
||||||
|
<span class="network-stat__label">
|
||||||
|
<i :class="['fas', item.icon]"></i>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="network-stat__value">{{ item.value }}</span>
|
||||||
|
<span class="network-stat__total">{{ item.totalValue }}</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -273,6 +275,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import ProcessManagerModal from './ProcessManagerModal.vue';
|
import ProcessManagerModal from './ProcessManagerModal.vue';
|
||||||
import StatusCharts from './StatusCharts.vue';
|
import StatusCharts from './StatusCharts.vue';
|
||||||
|
import StatusMonitorNetworkHistoryChart from './StatusMonitorNetworkHistoryChart.vue';
|
||||||
import { useSessionStore } from '../stores/session.store';
|
import { useSessionStore } from '../stores/session.store';
|
||||||
import { useSettingsStore } from '../stores/settings.store';
|
import { useSettingsStore } from '../stores/settings.store';
|
||||||
import { useConnectionsStore } from '../stores/connections.store';
|
import { useConnectionsStore } from '../stores/connections.store';
|
||||||
@@ -554,24 +557,6 @@ const networkRateUnitLabel = computed(() => {
|
|||||||
return maxRate >= 1024 * 1024 ? 'MB/s' : 'KB/s';
|
return maxRate >= 1024 * 1024 ? 'MB/s' : 'KB/s';
|
||||||
});
|
});
|
||||||
|
|
||||||
const networkHistoryScale = computed(() => {
|
|
||||||
const values = [
|
|
||||||
...currentNetRxHistory.value.map(value => value ?? 0),
|
|
||||||
...currentNetTxHistory.value.map(value => value ?? 0),
|
|
||||||
];
|
|
||||||
return Math.max(...values, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
const networkDownSparklinePath = computed(() => {
|
|
||||||
const samples = currentNetRxHistory.value.slice(-24).map(value => Math.max(0, ((value ?? 0) / networkHistoryScale.value) * 100));
|
|
||||||
return buildSparklinePath(samples, 160, 30, 22);
|
|
||||||
});
|
|
||||||
|
|
||||||
const networkUpSparklinePath = computed(() => {
|
|
||||||
const samples = currentNetTxHistory.value.slice(-24).map(value => Math.max(0, ((value ?? 0) / networkHistoryScale.value) * 100));
|
|
||||||
return buildSparklinePath(samples, 160, 30, 22);
|
|
||||||
});
|
|
||||||
|
|
||||||
const diskDeviceAccent = computed(() => {
|
const diskDeviceAccent = computed(() => {
|
||||||
const raw = currentServerStatus.value?.diskDevice;
|
const raw = currentServerStatus.value?.diskDevice;
|
||||||
|
|
||||||
@@ -1014,6 +999,16 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-split--memory {
|
||||||
|
grid-template-columns: minmax(110px, 0.88fr) minmax(0, 1.12fr);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-split--network {
|
||||||
|
grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.memory-ring-panel,
|
.memory-ring-panel,
|
||||||
.disk-device-card,
|
.disk-device-card,
|
||||||
.disk-io-card,
|
.disk-io-card,
|
||||||
@@ -1085,6 +1080,12 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.memory-stat-stack {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
align-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.memory-stat__label {
|
.memory-stat__label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1123,46 +1124,10 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
|||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.network-module__hero {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto minmax(96px, 1fr);
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.network-module__sparkline {
|
|
||||||
height: 30px;
|
|
||||||
min-width: 0;
|
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.14);
|
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.network-module__sparkline svg {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.network-module__sparkline-path {
|
|
||||||
fill: none;
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
.network-module__sparkline-path--up {
|
|
||||||
stroke: #34d399;
|
|
||||||
filter: drop-shadow(0 0 6px rgba(52, 211, 153, 0.28));
|
|
||||||
}
|
|
||||||
|
|
||||||
.network-module__sparkline-path--down {
|
|
||||||
stroke: #60a5fa;
|
|
||||||
filter: drop-shadow(0 0 6px rgba(96, 165, 250, 0.24));
|
|
||||||
}
|
|
||||||
|
|
||||||
.network-table {
|
.network-table {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
height: 100%;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1475,12 +1440,6 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.memory-stat-stack {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
align-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.disk-summary-table__head span,
|
.disk-summary-table__head span,
|
||||||
.disk-summary-table__row span {
|
.disk-summary-table__row span {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -1489,7 +1448,7 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
|||||||
|
|
||||||
@container (max-width: 250px) {
|
@container (max-width: 250px) {
|
||||||
.module-split--memory,
|
.module-split--memory,
|
||||||
.network-module__hero,
|
.module-split--network,
|
||||||
.disk-compact-top {
|
.disk-compact-top {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -1537,10 +1496,6 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
|||||||
.process-summary-strip {
|
.process-summary-strip {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.memory-stat-stack {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="chartHostRef" class="network-history-chart">
|
||||||
|
<div class="network-history-chart__header">
|
||||||
|
<div>
|
||||||
|
<p class="network-history-chart__subtitle">{{ t('statusMonitor.networkHistoryRecentPoints', { count: displayPointCount }) }}</p>
|
||||||
|
<h6 class="network-history-chart__title">
|
||||||
|
{{ t('statusMonitor.networkSpeedTitleUnit', { unit: networkRateUnitIsMB ? 'MB/s' : 'KB/s' }) }}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="network-history-chart__legend">
|
||||||
|
<span class="network-history-chart__legend-item">
|
||||||
|
<span class="network-history-chart__legend-dot network-history-chart__legend-dot--download"></span>
|
||||||
|
{{ t('statusMonitor.downloadLabel') }}
|
||||||
|
</span>
|
||||||
|
<span class="network-history-chart__legend-item">
|
||||||
|
<span class="network-history-chart__legend-dot network-history-chart__legend-dot--upload"></span>
|
||||||
|
{{ t('statusMonitor.uploadLabel') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="network-history-chart__canvas">
|
||||||
|
<Line :data="networkChartData" :options="networkChartOptions" :key="chartKey" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Line } from 'vue-chartjs';
|
||||||
|
import {
|
||||||
|
CategoryScale,
|
||||||
|
Chart as ChartJS,
|
||||||
|
type ChartOptions,
|
||||||
|
Legend,
|
||||||
|
LineElement,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
type TooltipItem,
|
||||||
|
} from 'chart.js';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
LineElement,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
CategoryScale,
|
||||||
|
);
|
||||||
|
|
||||||
|
const DISPLAY_POINTS = 24;
|
||||||
|
const KB_TO_MB_THRESHOLD = 1024;
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
downloadHistory: {
|
||||||
|
type: Array as PropType<readonly (number | null)[]>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
uploadHistory: {
|
||||||
|
type: Array as PropType<readonly (number | null)[]>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const chartHostRef = ref<HTMLElement | null>(null);
|
||||||
|
const chartKey = ref(0);
|
||||||
|
let chartResizeObserver: ResizeObserver | null = null;
|
||||||
|
let lastChartHostWidth = 0;
|
||||||
|
|
||||||
|
const recentDownloadHistory = computed(() => props.downloadHistory.slice(-DISPLAY_POINTS));
|
||||||
|
const recentUploadHistory = computed(() => props.uploadHistory.slice(-DISPLAY_POINTS));
|
||||||
|
const displayPointCount = computed(() => Math.max(recentDownloadHistory.value.length, recentUploadHistory.value.length, DISPLAY_POINTS));
|
||||||
|
|
||||||
|
const peakHistoryRateKB = computed(() => {
|
||||||
|
const values = [
|
||||||
|
...recentDownloadHistory.value.map(value => (value ?? 0) / 1024),
|
||||||
|
...recentUploadHistory.value.map(value => (value ?? 0) / 1024),
|
||||||
|
];
|
||||||
|
return Math.max(...values, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const networkRateUnitIsMB = computed(() => peakHistoryRateKB.value >= KB_TO_MB_THRESHOLD);
|
||||||
|
|
||||||
|
const chartDivisor = computed(() => (networkRateUnitIsMB.value ? 1024 * 1024 : 1024));
|
||||||
|
|
||||||
|
const chartPrecision = computed(() => (networkRateUnitIsMB.value ? 2 : 1));
|
||||||
|
|
||||||
|
const chartLabels = computed(() =>
|
||||||
|
Array.from({ length: displayPointCount.value }, (_, index) => `${index + 1}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const networkChartData = computed(() => ({
|
||||||
|
labels: chartLabels.value,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: t('statusMonitor.networkDownloadLabelUnit', { unit: networkRateUnitIsMB.value ? 'MB/s' : 'KB/s' }),
|
||||||
|
data: recentDownloadHistory.value.map(value =>
|
||||||
|
value === null || value === undefined ? null : Number((value / chartDivisor.value).toFixed(chartPrecision.value)),
|
||||||
|
),
|
||||||
|
borderColor: 'rgba(96, 165, 250, 1)',
|
||||||
|
backgroundColor: 'rgba(96, 165, 250, 0.18)',
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
tension: 0.24,
|
||||||
|
spanGaps: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('statusMonitor.networkUploadLabelUnit', { unit: networkRateUnitIsMB.value ? 'MB/s' : 'KB/s' }),
|
||||||
|
data: recentUploadHistory.value.map(value =>
|
||||||
|
value === null || value === undefined ? null : Number((value / chartDivisor.value).toFixed(chartPrecision.value)),
|
||||||
|
),
|
||||||
|
borderColor: 'rgba(52, 211, 153, 1)',
|
||||||
|
backgroundColor: 'rgba(52, 211, 153, 0.16)',
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
tension: 0.24,
|
||||||
|
spanGaps: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const suggestedYAxisMax = computed(() => {
|
||||||
|
const allValues = [
|
||||||
|
...recentDownloadHistory.value,
|
||||||
|
...recentUploadHistory.value,
|
||||||
|
].filter((value): value is number => value !== null && value !== undefined && Number.isFinite(value))
|
||||||
|
.map(value => value / chartDivisor.value);
|
||||||
|
|
||||||
|
const currentMax = Math.max(...allValues, 0);
|
||||||
|
if (currentMax === 0) {
|
||||||
|
return networkRateUnitIsMB.value ? 1 : 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (networkRateUnitIsMB.value) {
|
||||||
|
return Math.max(1, Math.ceil(currentMax * 1.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentMax <= 100) {
|
||||||
|
return Math.max(10, Math.ceil((currentMax * 1.2) / 10) * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentMax <= 500) {
|
||||||
|
return Math.ceil((currentMax * 1.2) / 50) * 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.ceil((currentMax * 1.2) / 100) * 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
const networkChartOptions = computed<ChartOptions<'line'>>(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
displayColors: true,
|
||||||
|
callbacks: {
|
||||||
|
title: () => '',
|
||||||
|
label: (context: TooltipItem<'line'>) => {
|
||||||
|
const label = context.dataset.label ?? '';
|
||||||
|
const value = context.parsed.y;
|
||||||
|
if (value === null) {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${label}: ${Number(value).toFixed(chartPrecision.value)} ${networkRateUnitIsMB.value ? 'MB/s' : 'KB/s'}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
display: false,
|
||||||
|
grid: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
min: 0,
|
||||||
|
max: suggestedYAxisMax.value,
|
||||||
|
ticks: {
|
||||||
|
color: '#8fa0b3',
|
||||||
|
callback: value => {
|
||||||
|
const numericValue = Number(value);
|
||||||
|
return Number.isFinite(numericValue) ? numericValue.toFixed(networkRateUnitIsMB.value ? 2 : 0) : '';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(148, 163, 184, 0.12)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rerenderChart = (): void => {
|
||||||
|
chartKey.value += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [recentDownloadHistory.value, recentUploadHistory.value, suggestedYAxisMax.value],
|
||||||
|
() => {
|
||||||
|
rerenderChart();
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const host = chartHostRef.value;
|
||||||
|
if (!host || typeof ResizeObserver === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastChartHostWidth = Math.round(host.getBoundingClientRect().width);
|
||||||
|
chartResizeObserver = new ResizeObserver(entries => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextWidth = Math.round(entry.contentRect.width);
|
||||||
|
if (!nextWidth || Math.abs(nextWidth - lastChartHostWidth) < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastChartHostWidth = nextWidth;
|
||||||
|
nextTick(() => {
|
||||||
|
rerenderChart();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
chartResizeObserver.observe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
chartResizeObserver?.disconnect();
|
||||||
|
chartResizeObserver = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.network-history-chart {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.08);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.02)),
|
||||||
|
radial-gradient(circle at top left, rgba(59, 130, 246, 0.06), transparent 62%);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-history-chart__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-history-chart__subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: #8fa0b3;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-history-chart__title {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #f8fbff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-history-chart__legend {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-history-chart__legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #d9e5f1;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-history-chart__legend-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-history-chart__legend-dot--download {
|
||||||
|
background: rgba(96, 165, 250, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-history-chart__legend-dot--upload {
|
||||||
|
background: rgba(52, 211, 153, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-history-chart__canvas {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 164px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-history-chart__canvas :deep(canvas) {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 300px) {
|
||||||
|
.network-history-chart__header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-history-chart__legend {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -688,6 +688,7 @@
|
|||||||
"cpuUsageTitle": "CPU Usage",
|
"cpuUsageTitle": "CPU Usage",
|
||||||
"memoryUsageTitleUnit": "Memory Usage ({unit})",
|
"memoryUsageTitleUnit": "Memory Usage ({unit})",
|
||||||
"networkSpeedTitleUnit": "Network Speed ({unit})",
|
"networkSpeedTitleUnit": "Network Speed ({unit})",
|
||||||
|
"networkHistoryRecentPoints": "Latest {count} samples",
|
||||||
"cpuUsageLabel": "CPU Usage (%)",
|
"cpuUsageLabel": "CPU Usage (%)",
|
||||||
"memoryUsageLabelUnit": "Memory Usage ({unit})",
|
"memoryUsageLabelUnit": "Memory Usage ({unit})",
|
||||||
"networkDownloadLabelUnit": "Download ({unit})",
|
"networkDownloadLabelUnit": "Download ({unit})",
|
||||||
|
|||||||
@@ -1423,6 +1423,7 @@
|
|||||||
"cpuUsageTitle": "CPU使用率",
|
"cpuUsageTitle": "CPU使用率",
|
||||||
"memoryUsageTitleUnit": "メモリ使用状況 ({unit})",
|
"memoryUsageTitleUnit": "メモリ使用状況 ({unit})",
|
||||||
"networkSpeedTitleUnit": "ネットワーク速度 ({unit})",
|
"networkSpeedTitleUnit": "ネットワーク速度 ({unit})",
|
||||||
|
"networkHistoryRecentPoints": "直近 {count} 件のサンプル",
|
||||||
"cpuUsageLabel": "CPU使用率 (%)",
|
"cpuUsageLabel": "CPU使用率 (%)",
|
||||||
"memoryUsageLabelUnit": "メモリ使用量 ({unit})",
|
"memoryUsageLabelUnit": "メモリ使用量 ({unit})",
|
||||||
"networkDownloadLabelUnit": "ダウンロード ({unit})",
|
"networkDownloadLabelUnit": "ダウンロード ({unit})",
|
||||||
|
|||||||
@@ -688,6 +688,7 @@
|
|||||||
"cpuUsageTitle": "CPU 使用率",
|
"cpuUsageTitle": "CPU 使用率",
|
||||||
"memoryUsageTitleUnit": "内存使用情况 ({unit})",
|
"memoryUsageTitleUnit": "内存使用情况 ({unit})",
|
||||||
"networkSpeedTitleUnit": "网络速度 ({unit})",
|
"networkSpeedTitleUnit": "网络速度 ({unit})",
|
||||||
|
"networkHistoryRecentPoints": "最近 {count} 个采样点",
|
||||||
"cpuUsageLabel": "CPU 使用率 (%)",
|
"cpuUsageLabel": "CPU 使用率 (%)",
|
||||||
"memoryUsageLabelUnit": "内存使用 ({unit})",
|
"memoryUsageLabelUnit": "内存使用 ({unit})",
|
||||||
"networkDownloadLabelUnit": "下载 ({unit})",
|
"networkDownloadLabelUnit": "下载 ({unit})",
|
||||||
|
|||||||
@@ -343,12 +343,15 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
|
|||||||
command: string,
|
command: string,
|
||||||
tagIds?: number[],
|
tagIds?: number[],
|
||||||
variables?: Record<string, string>,
|
variables?: Record<string, string>,
|
||||||
|
notifySuccess = true,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
await apiClient.put(`/quick-commands/${id}`, { name, command, tagIds, variables });
|
await apiClient.put(`/quick-commands/${id}`, { name, command, tagIds, variables });
|
||||||
clearQuickCommandsCache();
|
clearQuickCommandsCache();
|
||||||
await fetchQuickCommands();
|
await fetchQuickCommands();
|
||||||
uiNotificationsStore.showSuccess('快捷指令已更新');
|
if (notifySuccess) {
|
||||||
|
uiNotificationsStore.showSuccess('快捷指令已更新');
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[QuickCommandsStore] 更新快捷指令失败:', err);
|
console.error('[QuickCommandsStore] 更新快捷指令失败:', err);
|
||||||
|
|||||||
@@ -464,6 +464,30 @@ const moveById = <T extends { id: number }>(items: T[], sourceId: number, target
|
|||||||
return clonedItems;
|
return clonedItems;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const insertOrMoveById = <T extends { id: number }>(
|
||||||
|
items: T[],
|
||||||
|
sourceId: number,
|
||||||
|
targetId: number,
|
||||||
|
getSourceItem?: () => T | undefined,
|
||||||
|
): T[] => {
|
||||||
|
const clonedItems = [...items];
|
||||||
|
const sourceIndex = clonedItems.findIndex((item) => item.id === sourceId);
|
||||||
|
const targetIndex = clonedItems.findIndex((item) => item.id === targetId);
|
||||||
|
|
||||||
|
if (targetIndex === -1) {
|
||||||
|
return clonedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceItem = sourceIndex === -1 ? getSourceItem?.() : clonedItems.splice(sourceIndex, 1)[0];
|
||||||
|
if (!sourceItem) {
|
||||||
|
return clonedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTargetIndex = clonedItems.findIndex((item) => item.id === targetId);
|
||||||
|
clonedItems.splice(nextTargetIndex === -1 ? clonedItems.length : nextTargetIndex, 0, sourceItem);
|
||||||
|
return clonedItems;
|
||||||
|
};
|
||||||
|
|
||||||
const isGroupDropTarget = (tagId: number | null): boolean =>
|
const isGroupDropTarget = (tagId: number | null): boolean =>
|
||||||
tagId !== null && groupDropTargetTagId.value === tagId;
|
tagId !== null && groupDropTargetTagId.value === tagId;
|
||||||
|
|
||||||
@@ -544,7 +568,15 @@ const handleCommandDragOver = (commandId: number, groupTagId: number | null) =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draggingCommand.value.groupTagId !== groupTagId || draggingCommand.value.commandId === commandId) {
|
if (draggingCommand.value.commandId === commandId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
showQuickCommandTagsBoolean.value
|
||||||
|
&& draggingCommand.value.groupTagId !== groupTagId
|
||||||
|
&& groupTagId === null
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,51 +584,115 @@ const handleCommandDragOver = (commandId: number, groupTagId: number | null) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCommandDrop = async (commandId: number, groupTagId: number | null) => {
|
const handleCommandDrop = async (commandId: number, groupTagId: number | null) => {
|
||||||
if (!draggingCommand.value || dragDisabledBySearch.value) {
|
const activeDraggingCommand = draggingCommand.value;
|
||||||
|
|
||||||
|
if (!activeDraggingCommand || dragDisabledBySearch.value) {
|
||||||
resetDragState();
|
resetDragState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draggingCommand.value.groupTagId !== groupTagId || draggingCommand.value.commandId === commandId) {
|
if (activeDraggingCommand.commandId === commandId) {
|
||||||
|
resetDragState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceGroupTagId = activeDraggingCommand.groupTagId;
|
||||||
|
|
||||||
|
if (
|
||||||
|
showQuickCommandTagsBoolean.value
|
||||||
|
&& sourceGroupTagId !== groupTagId
|
||||||
|
&& groupTagId === null
|
||||||
|
) {
|
||||||
|
uiNotificationsStore.showInfo(t('quickCommands.dragMoveToUntaggedUnsupported', '暂不支持把已标记命令拖入“未标记”分组。'));
|
||||||
resetDragState();
|
resetDragState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentCommands: QuickCommandFE[] = [];
|
let currentCommands: QuickCommandFE[] = [];
|
||||||
if (showQuickCommandTagsBoolean.value) {
|
if (!showQuickCommandTagsBoolean.value || sourceGroupTagId === groupTagId) {
|
||||||
currentCommands = filteredAndGroupedCommands.value.find((group) => group.tagId === groupTagId)?.commands ?? [];
|
if (showQuickCommandTagsBoolean.value) {
|
||||||
} else {
|
currentCommands = filteredAndGroupedCommands.value.find((group) => group.tagId === groupTagId)?.commands ?? [];
|
||||||
currentCommands = flatFilteredCommands.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reorderedCommands = moveById(currentCommands, draggingCommand.value.commandId, commandId);
|
|
||||||
if (showQuickCommandTagsBoolean.value) {
|
|
||||||
if (groupTagId !== null) {
|
|
||||||
await quickCommandsStore.reorderCommandsInTag(groupTagId, reorderedCommands.map((item) => item.id));
|
|
||||||
} else {
|
} else {
|
||||||
const reorderedUntaggedIds = reorderedCommands.map((item) => item.id);
|
currentCommands = flatFilteredCommands.value;
|
||||||
const globalCommandIds = [...quickCommandsStore.quickCommandsList]
|
|
||||||
.sort((a, b) => (a.sort_order - b.sort_order) || (a.id - b.id))
|
|
||||||
.map((command) => command.id);
|
|
||||||
|
|
||||||
let untaggedIndex = 0;
|
|
||||||
const mergedCommandIds = globalCommandIds.map((existingCommandId) => {
|
|
||||||
const command = quickCommandsStore.quickCommandsList.find((item) => item.id === existingCommandId);
|
|
||||||
if (!command || command.tagIds.length > 0) {
|
|
||||||
return existingCommandId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextUntaggedId = reorderedUntaggedIds[untaggedIndex];
|
|
||||||
untaggedIndex += 1;
|
|
||||||
return nextUntaggedId ?? existingCommandId;
|
|
||||||
});
|
|
||||||
|
|
||||||
await quickCommandsStore.reorderQuickCommands(mergedCommandIds);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
await quickCommandsStore.reorderQuickCommands(reorderedCommands.map((item) => item.id));
|
const reorderedCommands = moveById(currentCommands, activeDraggingCommand.commandId, commandId);
|
||||||
|
|
||||||
|
if (showQuickCommandTagsBoolean.value) {
|
||||||
|
if (groupTagId !== null) {
|
||||||
|
await quickCommandsStore.reorderCommandsInTag(groupTagId, reorderedCommands.map((item) => item.id));
|
||||||
|
} else {
|
||||||
|
const reorderedUntaggedIds = reorderedCommands.map((item) => item.id);
|
||||||
|
const globalCommandIds = [...quickCommandsStore.quickCommandsList]
|
||||||
|
.sort((a, b) => (a.sort_order - b.sort_order) || (a.id - b.id))
|
||||||
|
.map((command) => command.id);
|
||||||
|
|
||||||
|
let untaggedIndex = 0;
|
||||||
|
const mergedCommandIds = globalCommandIds.map((existingCommandId) => {
|
||||||
|
const command = quickCommandsStore.quickCommandsList.find((item) => item.id === existingCommandId);
|
||||||
|
if (!command || command.tagIds.length > 0) {
|
||||||
|
return existingCommandId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUntaggedId = reorderedUntaggedIds[untaggedIndex];
|
||||||
|
untaggedIndex += 1;
|
||||||
|
return nextUntaggedId ?? existingCommandId;
|
||||||
|
});
|
||||||
|
|
||||||
|
await quickCommandsStore.reorderQuickCommands(mergedCommandIds);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await quickCommandsStore.reorderQuickCommands(reorderedCommands.map((item) => item.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
resetDragState();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (groupTagId === null) {
|
||||||
|
resetDragState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceCommand = quickCommandsStore.quickCommandsList.find((item) => item.id === activeDraggingCommand.commandId);
|
||||||
|
if (!sourceCommand) {
|
||||||
|
resetDragState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTagIds = Array.from(
|
||||||
|
new Set([
|
||||||
|
...sourceCommand.tagIds.filter((tagId) => tagId !== sourceGroupTagId),
|
||||||
|
groupTagId,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateSuccess = await quickCommandsStore.updateQuickCommand(
|
||||||
|
sourceCommand.id,
|
||||||
|
sourceCommand.name,
|
||||||
|
sourceCommand.command,
|
||||||
|
nextTagIds,
|
||||||
|
sourceCommand.variables ?? undefined,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updateSuccess) {
|
||||||
|
resetDragState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedSourceCommand =
|
||||||
|
quickCommandsStore.quickCommandsList.find((item) => item.id === sourceCommand.id)
|
||||||
|
?? { ...sourceCommand, tagIds: nextTagIds };
|
||||||
|
const targetCommands = filteredAndGroupedCommands.value.find((group) => group.tagId === groupTagId)?.commands ?? [];
|
||||||
|
const reorderedTargetCommands = insertOrMoveById(
|
||||||
|
targetCommands,
|
||||||
|
refreshedSourceCommand.id,
|
||||||
|
commandId,
|
||||||
|
() => refreshedSourceCommand,
|
||||||
|
);
|
||||||
|
|
||||||
|
await quickCommandsStore.reorderCommandsInTag(groupTagId, reorderedTargetCommands.map((item) => item.id));
|
||||||
resetDragState();
|
resetDragState();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -745,13 +841,20 @@ const toggleGroup = (groupName: string) => {
|
|||||||
|
|
||||||
// 计算排序按钮的 title 和 icon
|
// 计算排序按钮的 title 和 icon
|
||||||
const sortButtonTitle = computed(() => {
|
const sortButtonTitle = computed(() => {
|
||||||
|
if (sortBy.value === 'manual') {
|
||||||
|
return t('quickCommands.sortByManual', '按手动顺序排序');
|
||||||
|
}
|
||||||
|
|
||||||
return sortBy.value === 'name'
|
return sortBy.value === 'name'
|
||||||
? t('quickCommands.sortByName', '按名称排序')
|
? t('quickCommands.sortByName', '按名称排序')
|
||||||
: t('quickCommands.sortByLastUsed', '按最近使用排序');
|
: t('quickCommands.sortByLastUsed', '按最近使用排序');
|
||||||
});
|
});
|
||||||
|
|
||||||
const sortButtonIcon = computed(() => {
|
const sortButtonIcon = computed(() => {
|
||||||
// 使用 Font Awesome 图标示例
|
if (sortBy.value === 'manual') {
|
||||||
|
return 'fas fa-grip-lines';
|
||||||
|
}
|
||||||
|
|
||||||
return sortBy.value === 'name' ? 'fas fa-sort-alpha-down' : 'fas fa-clock';
|
return sortBy.value === 'name' ? 'fas fa-sort-alpha-down' : 'fas fa-clock';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user