From 2233e3fa4f412dce81188423d91671383e673019 Mon Sep 17 00:00:00 2001 From: yinjianm Date: Fri, 1 May 2026 22:54:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E9=87=8D=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E5=99=A8=E4=B9=A6=E7=AD=BE?= =?UTF-8?q?=E4=B8=8E=E4=BC=A0=E8=BE=93=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增书签作用域与连接关联,后端为 favorite_paths 补充 scope 和 connection_id 字段及查询写入支持 前端重构书签弹窗与编辑表单,支持本地/云端筛选、 作用域选择与多语言文案更新 文件管理器工具栏改为紧凑图标样式,上传入口合并为 下拉菜单,并新增底部传输面板统一展示上传任务 同时优化 SSH 终端运行态为显式状态机,并为短命令 补充最短可见时间,避免运行中标记闪烁难以感知 --- .helloagents/CHANGELOG.md | 3 + .../.status.json | 12 + .../proposal.md | 150 ++ .../tasks.md | 53 + .helloagents/archive/_index.md | 1 + .helloagents/modules/frontend.md | 17 +- .../.status.json | 2 +- .../tasks.md | 6 +- .../.status.json | 13 + .../proposal.md | 98 + .../tasks.md | 104 + .helloagents/user/.kb_sync_needed | 1 + packages/backend/src/database/migrations.ts | 12 + packages/backend/src/database/schema.ts | 8 +- .../favorite-paths.controller.ts | 13 +- .../favorite-paths.repository.ts | 54 +- .../favorite-paths/favorite-paths.service.ts | 13 +- .../components/AddEditFavoritePathForm.vue | 84 +- .../src/components/FavoritePathsModal.vue | 191 +- .../frontend/src/components/FileManager.vue | 139 +- .../frontend/src/components/StatusMonitor.vue | 1886 ++++++----------- .../StatusMonitorCpuHistoryChart.vue | 24 +- .../StatusMonitorNetworkHistoryChart.vue | 24 +- .../frontend/src/components/TransferPanel.vue | 128 ++ .../src/composables/useSshTerminal.ts | 135 +- packages/frontend/src/locales/en-US.json | 50 +- packages/frontend/src/locales/ja-JP.json | 60 +- packages/frontend/src/locales/zh-CN.json | 52 +- .../src/stores/favoritePaths.store.ts | 25 +- .../stores/session/actions/sessionActions.ts | 3 +- .../frontend/src/stores/session/getters.ts | 4 +- .../frontend/src/stores/session/runtime.ts | 40 + packages/frontend/src/stores/session/types.ts | 4 +- 33 files changed, 1868 insertions(+), 1541 deletions(-) create mode 100644 .helloagents/archive/2026-04/202604210531_ssh-terminal-runtime-state-machine/.status.json create mode 100644 .helloagents/archive/2026-04/202604210531_ssh-terminal-runtime-state-machine/proposal.md create mode 100644 .helloagents/archive/2026-04/202604210531_ssh-terminal-runtime-state-machine/tasks.md create mode 100644 .helloagents/plan/202605012111_file-manager-ui-redesign/.status.json create mode 100644 .helloagents/plan/202605012111_file-manager-ui-redesign/proposal.md create mode 100644 .helloagents/plan/202605012111_file-manager-ui-redesign/tasks.md create mode 100644 .helloagents/user/.kb_sync_needed create mode 100644 packages/frontend/src/components/TransferPanel.vue create mode 100644 packages/frontend/src/stores/session/runtime.ts diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index 26e1a53..2dfd7c0 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +- **[frontend]**: 将 SSH 终端 `%` 运行中提示从单布尔派生升级为显式 `commandRuntimePhase` 状态机,并为极短命令补上最短可见窗口,避免标签提示一闪而过几乎不可感知 — by yinjianm + - 方案: [202604210531_ssh-terminal-runtime-state-machine](archive/2026-04/202604210531_ssh-terminal-runtime-state-machine/) + - **[frontend]**: 将 `packages/frontend` 的 Vite 开发代理改为支持通过 `VITE_DEV_PROXY_TARGET`、`VITE_DEV_WS_PROXY_TARGET` 与 `VITE_API_BASE_URL` 切换远端联调目标,并验证 `focus-switcher-sequence`、登录链路与默认白色主题可在本地前端联调时正常工作 — by yinjianm - 方案: [202604210440_frontend-dev-api-theme-verification](archive/2026-04/202604210440_frontend-dev-api-theme-verification/) diff --git a/.helloagents/archive/2026-04/202604210531_ssh-terminal-runtime-state-machine/.status.json b/.helloagents/archive/2026-04/202604210531_ssh-terminal-runtime-state-machine/.status.json new file mode 100644 index 0000000..b311125 --- /dev/null +++ b/.helloagents/archive/2026-04/202604210531_ssh-terminal-runtime-state-machine/.status.json @@ -0,0 +1,12 @@ +{ + "status": "completed", + "completed": 5, + "failed": 0, + "skipped": 0, + "pending": 0, + "total": 5, + "done": 5, + "percent": 100, + "current": "-", + "updated_at": "2026-04-21 05:43:00" +} diff --git a/.helloagents/archive/2026-04/202604210531_ssh-terminal-runtime-state-machine/proposal.md b/.helloagents/archive/2026-04/202604210531_ssh-terminal-runtime-state-machine/proposal.md new file mode 100644 index 0000000..f30737d --- /dev/null +++ b/.helloagents/archive/2026-04/202604210531_ssh-terminal-runtime-state-machine/proposal.md @@ -0,0 +1,150 @@ +# 变更提案: ssh-terminal-runtime-state-machine + +## 元信息 +```yaml +类型: 修复/优化 +方案类型: implementation +优先级: P1 +状态: 实施中 +创建: 2026-04-21 +``` + +--- + +## 1. 需求 + +### 背景 +仓库已经在 2026-04-19 做过一轮 `%` 运行中提示增强,当前 [TerminalTabBar.vue](/E:/code/vue/nexus-terminal/packages/frontend/src/components/TerminalTabBar.vue) 和 [LayoutRenderer.vue](/E:/code/vue/nexus-terminal/packages/frontend/src/components/LayoutRenderer.vue) 也已经接上了 `%` UI。但现有实现仍把运行态建模成单个 `isCommandRunning` 布尔值,再叠加 `terminalInputBuffer` 和 prompt 正则做启停判断。这个模型无法区分“刚提交命令但还没输出”“命令正在持续输出”“连接异常断开”“错误导致提前结束”等阶段,导致一些真实场景下 `%` 要么瞬时闪烁,要么被过早清掉,用户体感上接近“没有实际作用”。 + +### 目标 +- 将 SSH 命令运行态从单个布尔值升级成显式状态机,至少能区分 `idle / typing / pending / running / disconnected / error`。 +- 继续基于发送命令、shell prompt、断连与错误链路派生运行态,但收口到统一的状态转移逻辑,而不是分散地手改布尔值。 +- 让顶部服务器标签和服务器内部终端标签继续显示 `%`,但由“运行态处于活动阶段”统一派生,确保两层显示一致。 +- 解决“快速命令根本看不到 `%`”的问题,让极短命令也能有足够短但可感知的可视反馈。 + +### 约束条件 +```yaml +时间约束: 本轮限定在 packages/frontend 内完成,不扩展 backend WebSocket 协议 +性能约束: 继续沿用轻量字符串缓冲与尾部 prompt 检测,不引入全终端内容扫描 +兼容性约束: RDP/VNC 标签行为不变,现有 WorkspaceView / Terminal / session getter 数据流尽量少破坏 +业务约束: `%` 仍是前端派生提示,不承诺成为服务端权威任务状态 +``` + +### 验收标准 +- [ ] SSH 会话在底部命令输入框、快捷指令、文件管理器和终端内回车等现有发送入口触发后,会进入显式运行态活动阶段并显示 `%` +- [ ] shell prompt 返回、连接断开、SSH 错误和 `Ctrl+C` 中断后,运行态会按统一状态机退出活动阶段,顶部服务器标签和内部终端标签同步消失 `%` +- [ ] 极短命令不会因为“提交后立刻命中 prompt”而完全看不到 `%`,标签上至少存在一个可感知的短暂运行中提示 +- [ ] `npm --workspace @nexus-terminal/frontend run build` 通过,且没有新增类型或模板错误 + +--- + +## 2. 方案 + +### 技术方案 +本轮不再继续扩展旧的 `isCommandRunning` 布尔值,而是引入一层显式 SSH 运行态模型,并把发送输入、输出探测、断连和错误全部收口到同一套 reducer 风格的状态转移函数中。 + +实现分成三层: + +1. 在 `session` 模块中新增 SSH 运行态类型与状态字段,至少包含 `phase`、`lastTransitionAt`、`lastCompletedAt` 和输入缓冲;`isCommandRunning` 改为从 `phase` 派生,而不是作为主状态独立维护。 +2. 在 `useSshTerminal.ts` 中把“发送非空命令”“收到输出”“命中 prompt”“中断”“断连”“错误”统一转换成状态机事件,避免旧实现那种一边写布尔、一边清空输入缓存的分散逻辑。 +3. 在标签 UI 层继续复用 `%`,但由 `isRuntimeActive(phase)` 统一判定。对于极快结束的命令,增加一个很短的最小可见窗口,让 `%` 不至于只闪过一个渲染帧。 + +### 影响范围 +```yaml +涉及模块: + - frontend/session: 调整 SSH 会话运行态模型与 getter 派生字段 + - frontend/composables: 重写 useSshTerminal.ts 内的运行态转移逻辑 + - frontend/ui: 顶部服务器标签与服务器内终端标签改为消费新的派生活动态 + - frontend/knowledge: 同步 frontend.md 与 CHANGELOG.md 中的运行态描述 +预计变更文件: 6-8 +``` + +### 风险评估 +| 风险 | 等级 | 应对 | +|------|------|------| +| prompt 检测仍可能覆盖不到个别定制 shell | 中 | 保持 prompt 识别只负责“退出活动态”,同时由断连、错误和中断链路兜底清理 | +| 新状态机与旧 getter 混用导致 UI 不更新或语义冲突 | 中 | 收敛 getter,只保留一个派生活动态出口,模板层继续吃布尔结果以减小改动面 | +| 为了让短命令可见而增加最短展示时间后,可能让极快命令多亮几百毫秒 | 低 | 将最短窗口控制在短阈值,只解决“完全看不到”的问题,不把标签做成长时延迟态 | + +--- + +## 3. 技术设计 + +### 架构设计 +```mermaid +flowchart TD + A[CommandInputBar / QuickCommands / FileManager / Terminal] --> B[terminalManager.sendData or handleTerminalData] + B --> C[SSH Runtime Reducer] + C --> D[session.commandRuntimePhase] + E[ssh:output] --> C + F[ssh:disconnected / ssh:error / Ctrl+C] --> C + D --> G[session getter 派生 isCommandRunning] + G --> H[TerminalTabBar 服务器标签 %] + G --> I[LayoutRenderer 内部终端标签 %] +``` + +### 数据模型 +| 字段 | 类型 | 说明 | +|------|------|------| +| `commandRuntimePhase` | `Ref<'idle' \| 'typing' \| 'pending' \| 'running' \| 'disconnected' \| 'error'>` | SSH 终端当前所处的显式运行阶段 | +| `commandRuntimeReason` | `Ref<'init' \| 'input' \| 'submit' \| 'output' \| 'prompt' \| 'interrupt' \| 'disconnect' \| 'error' \| 'connected'>` | 最近一次状态迁移的原因,便于调试与后续扩展 | +| `commandRuntimeVisibleUntil` | `Ref` | 运行中提示至少显示到的时间点,用于避免极短命令完全不可见 | +| `terminalInputBuffer` | `Ref` | 当前一行尚未提交的终端输入缓冲,继续用于判断回车是否提交了非空命令 | + +--- + +## 4. 核心场景 + +### 场景: 快捷命令或底部命令输入框发送非空命令 +**模块**: frontend +**条件**: 用户在某个 SSH 会话上通过底部命令输入框、快捷指令、命令历史或文件管理器触发 `terminalManager.sendData(...)` +**行为**: 状态机收到“提交命令”事件,切换到 `pending`,并立即打开 `%` 运行中提示 +**结果**: 顶部服务器标签和当前服务器内部终端标签都能同步看到 `%` + +### 场景: 命令快速结束但提示仍可见 +**模块**: frontend +**条件**: 用户发送一个几乎立即返回 prompt 的短命令 +**行为**: 状态机在提交后进入 `pending`,即使命中 prompt 也会遵守最短可见窗口再退出活动阶段 +**结果**: `%` 不会只闪过一个渲染帧,用户能明确感知“刚刚执行过” + +### 场景: shell prompt、断连和错误统一退出活动态 +**模块**: frontend +**条件**: SSH 会话收到 prompt 尾部、`ssh:disconnected`、`ssh:error` 或 `Ctrl+C` +**行为**: 状态机依据事件原因切换到 `idle / disconnected / error` 等非活动阶段 +**结果**: 顶部服务器标签与内部终端标签的 `%` 同步消失,不再残留旧状态 + +--- + +## 5. 技术决策 + +### ssh-terminal-runtime-state-machine#D001: 用显式运行态阶段替代单一 `isCommandRunning` 布尔值 +**日期**: 2026-04-21 +**状态**: ✅采纳 +**背景**: 当前 `%` 标签虽然已经渲染到 UI,但旧实现只能用 `true/false` 表达“正在运行”,导致“刚提交命令但还没输出”“命令执行中”“连接已断开”“错误终止”等语义全部混在一个布尔值里,既难维护,也难稳定派生 UI。 +**选项分析**: +| 选项 | 优点 | 缺点 | +|------|------|------| +| A: 继续沿用布尔值并追加更多 if/else | 改动最少 | 状态语义继续混乱,问题很容易回归 | +| B: 引入显式运行态阶段和统一转移函数 | 状态来源清晰,便于覆盖 prompt/断连/错误等链路 | 需要调整 session 类型和 useSshTerminal 逻辑 | +**决策**: 选择方案 B +**理由**: 用户当前反馈的根因不是“少一个 if 判断”,而是模型过弱。只有把 SSH 运行态从布尔提升为阶段状态,才能真正稳定地驱动 `%`。 +**影响**: 影响 `session` 类型定义、getter 派生逻辑、`useSshTerminal.ts` 的输入/输出处理以及两个标签组件的消费方式 + +### ssh-terminal-runtime-state-machine#D002: 为极短命令增加最短可见窗口,而不是 prompt 一到就立刻灭掉 `%` +**日期**: 2026-04-21 +**状态**: ✅采纳 +**背景**: 用户明确反馈“整个没看到实际作用”,说明仅靠“提交置位、prompt 清除”的旧策略在短命令场景下可见性太差,即便逻辑成立也没有体感价值。 +**选项分析**: +| 选项 | 优点 | 缺点 | +|------|------|------| +| A: prompt 命中后立即清除 `%` | 语义最直接 | 短命令常常一闪而过,用户几乎感知不到 | +| B: 运行态活动阶段增加一个很短的最小可见窗口 | 保持真实链路派生,同时确保用户能看到反馈 | 极快命令会多保留极短时间 | +**决策**: 选择方案 B +**理由**: 本轮目标不是做“理论上存在过”的状态,而是让用户真的看到 `%` 起作用。短暂的最小展示时间能显著提升感知质量,而且不需要改变后端协议。 +**影响**: 需要在前端状态机里记录时间戳,并在 prompt/错误/断连清理时考虑延迟退出 + +--- + +## 6. 成果设计 + +N/A。本轮不新增视觉体系,只保持现有深色终端工作台内的 `%` 提示语义,并增强其可见性与状态来源准确度。 diff --git a/.helloagents/archive/2026-04/202604210531_ssh-terminal-runtime-state-machine/tasks.md b/.helloagents/archive/2026-04/202604210531_ssh-terminal-runtime-state-machine/tasks.md new file mode 100644 index 0000000..b9469a7 --- /dev/null +++ b/.helloagents/archive/2026-04/202604210531_ssh-terminal-runtime-state-machine/tasks.md @@ -0,0 +1,53 @@ +# 任务清单: ssh-terminal-runtime-state-machine + +> **@status:** completed | 2026-04-21 05:47 + +```yaml +@feature: ssh-terminal-runtime-state-machine +@created: 2026-04-21 +@status: completed +@mode: R2 +``` + +## 进度概览 + +| 完成 | 失败 | 跳过 | 总数 | +|------|------|------|------| +| 5 | 0 | 0 | 5 | + +--- + +## 任务列表 + +### 1. 运行态模型重构 + +- [√] 1.1 在 `packages/frontend/src/stores/session/types.ts` 与 `packages/frontend/src/stores/session/actions/sessionActions.ts` 中引入 SSH 显式运行态字段,并移除旧的主布尔状态入口 | depends_on: [] +- [√] 1.2 在 `packages/frontend/src/composables/useSshTerminal.ts` 中收口发送、prompt、断连、错误与中断链路,改为统一状态机转移逻辑 | depends_on: [1.1] + +### 2. 标签派生与可见性修复 + +- [√] 2.1 在 `packages/frontend/src/stores/session/getters.ts` 中改为从 `commandRuntimePhase` 派生活动态,并保持 `TerminalTabBar.vue` 与 `LayoutRenderer.vue` 继续复用现有 `%` 模板显示一致 | depends_on: [1.2] +- [√] 2.2 为极短命令加入最短可见窗口,确保 `%` 不会只闪烁一个渲染帧 | depends_on: [1.2] + +### 3. 验证与知识库同步 + +- [√] 3.1 运行 `npm --workspace @nexus-terminal/frontend run build` 验证前端编译通过,并同步 `.helloagents/modules/frontend.md` 与 `.helloagents/CHANGELOG.md` | depends_on: [2.1, 2.2] + +--- + +## 执行日志 + +| 时间 | 任务 | 状态 | 备注 | +|------|------|------|------| +| 2026-04-21 05:31 | 方案包创建 | 完成 | 已创建 `202604210531_ssh-terminal-runtime-state-machine`,准备进入开发实施 | +| 2026-04-21 05:40 | 1.1 / 1.2 | 完成 | 已引入 `commandRuntimePhase` 显式状态,并将发送、prompt、断连、错误与中断链路统一收口到状态转移逻辑 | +| 2026-04-21 05:41 | 2.1 / 2.2 | 完成 | getter 现已从 `commandRuntimePhase` 派生活动态,并为极短命令补上最短可见窗口,保持两层 `%` 继续复用现有模板 | +| 2026-04-21 05:43 | 3.1 | 完成 | `npm --workspace @nexus-terminal/frontend run build` 通过,并同步 `frontend.md` 与 `CHANGELOG.md` | + +--- + +## 执行备注 + +- 本轮为前端单包修复,不修改 backend WebSocket 协议。 +- 历史方案 `202604192106_terminal-running-indicator` 已完成,但现场反馈显示旧布尔模型体感无效,本轮在其基础上做显式状态机收敛。 +- 顶部服务器标签与内部终端标签继续保留 `%` 设计,不新增新的运行态 UI 组件。 diff --git a/.helloagents/archive/_index.md b/.helloagents/archive/_index.md index 543c07d..bb5e54a 100644 --- a/.helloagents/archive/_index.md +++ b/.helloagents/archive/_index.md @@ -7,6 +7,7 @@ | 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 | |--------|------|------|---------|------|------| +| 202604210531 | ssh-terminal-runtime-state-machine | - | - | - | ✅完成 | | 202604210440 | frontend-dev-api-theme-verification | - | - | - | ✅完成 | | 202604192106 | terminal-running-indicator | - | - | - | ✅完成 | | 202604190520 | status-monitor-cpu-summary-modal | - | - | - | ✅完成 | diff --git a/.helloagents/modules/frontend.md b/.helloagents/modules/frontend.md index 30681d6..40664c5 100644 --- a/.helloagents/modules/frontend.md +++ b/.helloagents/modules/frontend.md @@ -39,9 +39,24 @@ **行为**: `vite.config.ts` 会按环境变量为 `/api`、`/uploads` 与 `/ws` 配置开发代理;业务代码继续保留相对路径请求,`appearance.store.ts` 和 `LayoutRenderer.vue` 中需要拼接后端资源地址的逻辑则读取 `VITE_API_BASE_URL`。 **结果**: 前端可以在不改业务接口路径的前提下切换到远端测试后端联调,本地验证时仍可通过移除或修改本地环境变量回退到默认 `localhost:3001`。 +### Playwright CLI 联调注意事项 +**条件**: 开发者使用 `playwright-cli` 对本地 `packages/frontend` 页面做登录、主题或接口联调验收。 +**行为**: +- `snapshot` 生成的 `eXXX` 引用在跳转、登录、登出或 UI 重绘后容易失效;发生页面状态切换后应立即重新抓取快照,不要复用旧引用。 +- `snapshot` 命中的 `eXXX` 可能是输入框外层容器而不是可编辑节点;遇到 `fill` 失败时,应改用重新 `snapshot`、语义定位或小粒度 `eval` 校验真实元素。 +- `type` 依赖当前焦点,不适合并行输入用户名和密码;表单填充应串行执行,先确认焦点,再输入,避免文本串入错误字段。 +- 做登录链路验证前应先显式登出或清理会话状态;否则残留 cookie 会让“登录成功”与“主题是否生效”的结论失真。 +- 主题验收不能只看登录页,必须区分登录前与登录后;用户侧外观持久化配置可能覆盖默认主题,需要在干净登录后再次检查页面背景和主容器颜色。 +- PowerShell 下 `eval` / `run-code` 的引号与转义容易导致语法错误;复杂表达式应拆成更小的调用,优先读取单个样式值或执行单一步骤。 +- `screenshot` 命令的参数位容易被误当成目标选择器;若只想截图当前视口,直接使用无目标参数的截图命令更稳。 +- `network` 记录展示的是本地 Vite 地址,不会直接显示被代理的远端域名;联调远端后端时应结合接口返回码、登录结果和页面行为判断代理是否成功。 +- 控制台中的 warning / error 可能来自业务前端而非 `playwright-cli` 自身;排障时需区分“自动化控制失败”和“应用自身日志”。 +- 视觉验收不要只凭主观观察,最好同时读取 `body`、主容器和卡片的 `backgroundColor` / `color`,形成可复核证据。 +**结果**: 本地浏览器联调时可显著降低引用失效、输入串焦点、会话污染和主题误判等问题,提高登录与主题验收的可重复性。 + ### 工作区交互 **条件**: 用户进入 `/workspace` 或相关管理页面。 -**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 继续整合快捷指令、命令历史、文件管理和编辑器四个面板,导航入口保持为纯图标按钮,但已调整为位于 `Workbench` 标题区上方的横向 icon rail,四个入口自左向右排列、默认仅显示图标并通过 tooltip 暴露名称,默认激活快捷指令。`CommandInputBar.vue` 当前已将底部命令框升级为支持会话级草稿保留的多行 `textarea`:普通 `Enter` 插入换行,`Ctrl+Shift+Enter` 发送当前命令,输入框会按内容自动增高至约 6 行,超出后在输入框内部滚动,并继续兼容快捷指令/命令历史同步与选中发送逻辑。围绕这条发送链路,`session` 派生状态现已补上 `isCommandRunning` 与 `terminalInputBuffer`,用于在发送非空命令后标记运行中,并在收到常见 shell prompt、`Ctrl+C`、断连或错误时清理运行态。应用根组件 `App.vue` 现在还新增了全局服务器快捷检索:已登录页面按下 `Ctrl+Shift+F` 会打开 `GlobalConnectionQuickSearch.vue`,通过 `utils/connectionSearch.ts` 对连接名称、主机、用户名、类型和标签做本地模糊排序,并直接复用 `sessionStore.handleConnectRequest()` 触发 SSH 工作区跳转或 RDP / VNC 弹窗连接;该检索弹层现在还会复用 `tags.store.ts` 读取标签名称映射,在结果卡片内补充显示每台服务器的标签 chips,便于快速区分同名或近似主机。快捷指令相关能力目前由 `AddEditQuickCommandForm.vue`、`QuickCommandsView.vue` 与新增的 `utils/quickCommandTemplate.ts` 协同实现:编辑弹窗左侧既可维护自定义 `${变量名}`,也提供 `${{date}}`、`${{time}}`、`${{timestamp}}`、`${{week}}`、`${{uuid}}`、`${{random:8}}`、`${{clipboard}}`、`${{password}}` 等动态变量的一键插入;实际执行时会统一走共享解析器,覆盖编辑弹窗执行、列表直接执行、粘贴到命令输入框和发送到全部服务器等链路,并对未定义变量、无法读取的剪贴板或不可用密码给出非阻断告警。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button`、`text-button-text`、`hover:bg-button-hover`、`hover:bg-border` 等主题变量类,避免写死黑白 hover 色值;该视图当前还支持命令项右键菜单,并已修正为实底卡片式上下文菜单,提供立即执行、粘贴到命令输入框(不自动发送)、复制命令、发送到全部服务器、编辑和删除等动作。快捷命令列表的鼠标主交互当前已从“单击立即执行”收紧为“单击仅更新选中态、双击才执行”,从而继续兼容键盘 `Enter` 的选中执行路径并降低误触风险;每条命令项同时会把完整 `command` 文本挂到浏览器原生 tooltip 上,便于在名称或命令被截断时直接 hover 核对完整内容。`Terminal.vue` 现在会跟踪 xterm 相对底部的视口偏移与贴底状态,在终端标签切换、重新激活和 `fit()` 后按原滚动意图恢复;当隐藏标签在后台持续追加日志时,重新激活会基于“距底部偏移”而不是过期的绝对行号恢复 viewport,避免用户继续向下滚动时无法回到底部。组件同时继续在渲染层为带 `xterm-fg-*` class 或内联 `style.color` 的显式前景色字符打标记,让终端文字描边/阴影仅作用于默认前景文本,不覆盖 ANSI 彩色输出;`session.store` 当前会为同一 SSH 连接下的新终端分配递增的 `terminalIndex`。当前顶部 `TerminalTabBar.vue` 已改为服务器级入口:SSH 项只负责在不同服务器之间切换,全局 `+` 继续负责选择其他服务器;同一服务器下的多个终端则下沉到 `LayoutRenderer.vue` 的终端面板内部,以次级标签条承载切换、关闭和新增,从而让“进入服务器后再管理该服务器的多个终端”成为主要交互模型。服务器组头现在除主点击切换外,还额外提供了一个 hover 后出现的 `X` 按钮,点击后会复用既有 `session:close` 事件逐个关闭该 `connectionId` 下的全部终端;如果该服务器下任一终端仍在执行命令,组头会显示一个琥珀色 `%` 提示。当前服务器内部终端标签同样会在对应终端运行命令时显示 `%`,从而同时暴露“服务器级有活跃命令”和“具体哪个终端仍在运行”两层信号。当前终端标签右键菜单继续复用 `WorkspaceView.vue` 中转的会话关闭链路,除关闭当前、关闭其他、关闭左右侧外,也支持直接触发“关闭全部”来清空当前工作区中的全部终端标签。连接新增弹窗中的脚本模式则继续由 `useAddConnectionForm.ts` 统一清洗输入:会先剔除空行、Markdown 代码围栏行,再按单引号/双引号感知切分参数,并去掉成对包裹值的外层引号,避免像 `-p '$Moka1998A'` 这样的输入把 `'` 一并保存。`ConnectionsView.vue` 已升级为“左侧范围树 + 顶部搜索工具条 + 右侧结果列表”的双栏管理台,当前左侧进一步支持基于标签名路径分隔符推导的多级标签树、树节点展开状态持久化、分组 scope 恢复,以及树工具栏中的展开全部、收起全部和重置范围控制;近期又补上了独立的左侧树搜索、命中节点及祖先路径过滤、命中链路自动展开、节点计数高亮,以及更接近资源管理器的树头部布局;本轮继续为树节点加入 hover 工具按钮、资源管理器式分隔标题行与拖拽重排占位反馈;右侧结果列表则同时支持顶部排序控件、列头点击排序,并将行内操作整理为“连接”主按钮加“更多”菜单,其中 SSH 连接卡片默认进一步提升为“连接 / 测试 / 更多”三按钮结构,复用既有单连接测试状态,编辑/克隆/删除等次级操作保留在更多菜单中;连接页顶部工具条当前又补上了独立“标签管理”入口,打开 `ManageConnectionTagsModal.vue` 后可按标签名搜索、多选、批量删除标签,并通过显式危险开关决定删除标签时是否连带删除命中的连接;`tags.store.ts` 在该链路里会统一刷新标签与连接缓存,而 `ConnectionsView.vue` 会在当前 scope 指向已删标签或分组时自动回退到 `all`。`FileManager.vue` 当前已进一步收敛为固定 `/` 根节点的单栏资源管理器树,组件加载时会优先拉取 `/` 目录,树中按“目录在前、文件在后”同时显示目录和文件节点,点击目录只展开与聚焦,点击文件则沿用现有工作区文件打开链路;文件右键菜单链路则已补齐图标化菜单结构、危险态删除项、终端子菜单(执行 `cd` 命令到终端 / 新建终端到当前目录)、复制文件名与复制绝对路径等动作,并继续复用现有下载、权限、新建、上传和删除逻辑,同时又新增了独立“上传文件夹”入口:前端会先将本地目录打包为 zip,再复用现有 `sftp:upload` 链路上传,并在上传成功后自动调用远端解压、尝试清理临时压缩包;外部拖拽文件或目录上传时,则会按鼠标当前悬停的目录作为目标路径,其中目录同样走“先压缩再上传”的路径,从而显著降低小文件很多时的扫描与上传耗时;本轮又补上了拖拽上传前的目标路径确认、桌面端右键子菜单点击展开,以及目录删除时“仅删空目录 / 强制递归删除”的显式二选一;当前右键菜单的关闭职责已经收敛到 `FileManagerContextMenu.vue` 组件层处理,`useFileManagerContextMenu.ts` 不再额外注册捕获阶段的全局点击关闭监听,以避免“终端 / 上传 / 压缩”等带子菜单项在展开或点击前被提前关闭;同时 `useSftpActions.ts` 会在删除目录后自动回退当前或待加载的失效路径,避免文件树持续对已删除目录刷出 `No such file`。样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。 +**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 继续整合快捷指令、命令历史、文件管理和编辑器四个面板,导航入口保持为纯图标按钮,但已调整为位于 `Workbench` 标题区上方的横向 icon rail,四个入口自左向右排列、默认仅显示图标并通过 tooltip 暴露名称,默认激活快捷指令。`CommandInputBar.vue` 当前已将底部命令框升级为支持会话级草稿保留的多行 `textarea`:普通 `Enter` 插入换行,`Ctrl+Shift+Enter` 发送当前命令,输入框会按内容自动增高至约 6 行,超出后在输入框内部滚动,并继续兼容快捷指令/命令历史同步与选中发送逻辑。围绕这条发送链路,`session` 现已将 SSH 运行态从旧的 `isCommandRunning` 单布尔值升级为显式 `commandRuntimePhase` 阶段模型,并继续保留 `terminalInputBuffer` 作为本地输入缓冲:前端会在发送非空命令时进入 `pending / running` 活动态,在命中常见 shell prompt、`Ctrl+C`、断连或错误时统一退出活动阶段;对于极短命令,还会维持一个很短的最小可见窗口,避免顶部和内部终端标签上的 `%` 运行中提示只闪过一个渲染帧。应用根组件 `App.vue` 现在还新增了全局服务器快捷检索:已登录页面按下 `Ctrl+Shift+F` 会打开 `GlobalConnectionQuickSearch.vue`,通过 `utils/connectionSearch.ts` 对连接名称、主机、用户名、类型和标签做本地模糊排序,并直接复用 `sessionStore.handleConnectRequest()` 触发 SSH 工作区跳转或 RDP / VNC 弹窗连接;该检索弹层现在还会复用 `tags.store.ts` 读取标签名称映射,在结果卡片内补充显示每台服务器的标签 chips,便于快速区分同名或近似主机。快捷指令相关能力目前由 `AddEditQuickCommandForm.vue`、`QuickCommandsView.vue` 与新增的 `utils/quickCommandTemplate.ts` 协同实现:编辑弹窗左侧既可维护自定义 `${变量名}`,也提供 `${{date}}`、`${{time}}`、`${{timestamp}}`、`${{week}}`、`${{uuid}}`、`${{random:8}}`、`${{clipboard}}`、`${{password}}` 等动态变量的一键插入;实际执行时会统一走共享解析器,覆盖编辑弹窗执行、列表直接执行、粘贴到命令输入框和发送到全部服务器等链路,并对未定义变量、无法读取的剪贴板或不可用密码给出非阻断告警。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button`、`text-button-text`、`hover:bg-button-hover`、`hover:bg-border` 等主题变量类,避免写死黑白 hover 色值;该视图当前还支持命令项右键菜单,并已修正为实底卡片式上下文菜单,提供立即执行、粘贴到命令输入框(不自动发送)、复制命令、发送到全部服务器、编辑和删除等动作。快捷命令列表的鼠标主交互当前已从“单击立即执行”收紧为“单击仅更新选中态、双击才执行”,从而继续兼容键盘 `Enter` 的选中执行路径并降低误触风险;每条命令项同时会把完整 `command` 文本挂到浏览器原生 tooltip 上,便于在名称或命令被截断时直接 hover 核对完整内容。`Terminal.vue` 现在会跟踪 xterm 相对底部的视口偏移与贴底状态,在终端标签切换、重新激活和 `fit()` 后按原滚动意图恢复;当隐藏标签在后台持续追加日志时,重新激活会基于“距底部偏移”而不是过期的绝对行号恢复 viewport,避免用户继续向下滚动时无法回到底部。组件同时继续在渲染层为带 `xterm-fg-*` class 或内联 `style.color` 的显式前景色字符打标记,让终端文字描边/阴影仅作用于默认前景文本,不覆盖 ANSI 彩色输出;`session.store` 当前会为同一 SSH 连接下的新终端分配递增的 `terminalIndex`。当前顶部 `TerminalTabBar.vue` 已改为服务器级入口:SSH 项只负责在不同服务器之间切换,全局 `+` 继续负责选择其他服务器;同一服务器下的多个终端则下沉到 `LayoutRenderer.vue` 的终端面板内部,以次级标签条承载切换、关闭和新增,从而让“进入服务器后再管理该服务器的多个终端”成为主要交互模型。服务器组头现在除主点击切换外,还额外提供了一个 hover 后出现的 `X` 按钮,点击后会复用既有 `session:close` 事件逐个关闭该 `connectionId` 下的全部终端;如果该服务器下任一终端仍在执行命令,组头会显示一个琥珀色 `%` 提示。当前服务器内部终端标签同样会在对应终端运行命令时显示 `%`,从而同时暴露“服务器级有活跃命令”和“具体哪个终端仍在运行”两层信号。当前终端标签右键菜单继续复用 `WorkspaceView.vue` 中转的会话关闭链路,除关闭当前、关闭其他、关闭左右侧外,也支持直接触发“关闭全部”来清空当前工作区中的全部终端标签。连接新增弹窗中的脚本模式则继续由 `useAddConnectionForm.ts` 统一清洗输入:会先剔除空行、Markdown 代码围栏行,再按单引号/双引号感知切分参数,并去掉成对包裹值的外层引号,避免像 `-p '$Moka1998A'` 这样的输入把 `'` 一并保存。`ConnectionsView.vue` 已升级为“左侧范围树 + 顶部搜索工具条 + 右侧结果列表”的双栏管理台,当前左侧进一步支持基于标签名路径分隔符推导的多级标签树、树节点展开状态持久化、分组 scope 恢复,以及树工具栏中的展开全部、收起全部和重置范围控制;近期又补上了独立的左侧树搜索、命中节点及祖先路径过滤、命中链路自动展开、节点计数高亮,以及更接近资源管理器的树头部布局;本轮继续为树节点加入 hover 工具按钮、资源管理器式分隔标题行与拖拽重排占位反馈;右侧结果列表则同时支持顶部排序控件、列头点击排序,并将行内操作整理为“连接”主按钮加“更多”菜单,其中 SSH 连接卡片默认进一步提升为“连接 / 测试 / 更多”三按钮结构,复用既有单连接测试状态,编辑/克隆/删除等次级操作保留在更多菜单中;连接页顶部工具条当前又补上了独立“标签管理”入口,打开 `ManageConnectionTagsModal.vue` 后可按标签名搜索、多选、批量删除标签,并通过显式危险开关决定删除标签时是否连带删除命中的连接;`tags.store.ts` 在该链路里会统一刷新标签与连接缓存,而 `ConnectionsView.vue` 会在当前 scope 指向已删标签或分组时自动回退到 `all`。`FileManager.vue` 当前已进一步收敛为固定 `/` 根节点的单栏资源管理器树,组件加载时会优先拉取 `/` 目录,树中按“目录在前、文件在后”同时显示目录和文件节点,点击目录只展开与聚焦,点击文件则沿用现有工作区文件打开链路;文件右键菜单链路则已补齐图标化菜单结构、危险态删除项、终端子菜单(执行 `cd` 命令到终端 / 新建终端到当前目录)、复制文件名与复制绝对路径等动作,并继续复用现有下载、权限、新建、上传和删除逻辑,同时又新增了独立“上传文件夹”入口:前端会先将本地目录打包为 zip,再复用现有 `sftp:upload` 链路上传,并在上传成功后自动调用远端解压、尝试清理临时压缩包;外部拖拽文件或目录上传时,则会按鼠标当前悬停的目录作为目标路径,其中目录同样走“先压缩再上传”的路径,从而显著降低小文件很多时的扫描与上传耗时;本轮又补上了拖拽上传前的目标路径确认、桌面端右键子菜单点击展开,以及目录删除时“仅删空目录 / 强制递归删除”的显式二选一;当前右键菜单的关闭职责已经收敛到 `FileManagerContextMenu.vue` 组件层处理,`useFileManagerContextMenu.ts` 不再额外注册捕获阶段的全局点击关闭监听,以避免“终端 / 上传 / 压缩”等带子菜单项在展开或点击前被提前关闭;同时 `useSftpActions.ts` 会在删除目录后自动回退当前或待加载的失效路径,避免文件树持续对已删除目录刷出 `No such file`。样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。 **结果**: 页面逻辑分散在 `views/`、`components/`、`stores/` 与 `composables/`,其中工作区终端行为和标签交互优先落在 `session.store.ts`、`session/actions/sessionActions.ts`、`session/getters.ts`、`TerminalTabBar.vue`、`WorkspaceView.vue`、`Terminal.vue` 与相关 locale 文件。 ### 仪表盘总览 diff --git a/.helloagents/plan/202603260324_file-manager-delete-upload-stability/.status.json b/.helloagents/plan/202603260324_file-manager-delete-upload-stability/.status.json index 1b43c22..e90f981 100644 --- a/.helloagents/plan/202603260324_file-manager-delete-upload-stability/.status.json +++ b/.helloagents/plan/202603260324_file-manager-delete-upload-stability/.status.json @@ -9,5 +9,5 @@ "done": 6, "percent": 100, "current": "-", - "updated_at": "2026-04-21 04:28:38" + "updated_at": "2026-05-01 21:07:56" } \ No newline at end of file diff --git a/.helloagents/plan/202603260324_file-manager-delete-upload-stability/tasks.md b/.helloagents/plan/202603260324_file-manager-delete-upload-stability/tasks.md index 9cd5604..7aaa66a 100644 --- a/.helloagents/plan/202603260324_file-manager-delete-upload-stability/tasks.md +++ b/.helloagents/plan/202603260324_file-manager-delete-upload-stability/tasks.md @@ -41,8 +41,8 @@ | 时间 | 事件 | 详情 | |------|------|------| -| 2026-03-26 04:05 | 2.2 | 完成 | 拖拽上传前新增目标目录确认,并在当前可见目录上传完成后主动刷新 | -| 2026-03-26 04:08 | 3.1 | 完成 | 目录删除改为“仅删空目录 / 强制递归删除”双确认,后端 `sftp:rmdir` 接收 `recursive` 标志 | -| 2026-03-26 04:10 | 3.2 | 完成 | 删除目录后若当前/待加载路径失效,前端自动回退父目录,终止持续 `No such file` 重试 | | 2026-03-26 04:14 | 4.1 | 完成 | `npm run build --workspace @nexus-terminal/frontend` 与 `@nexus-terminal/backend` 均通过 | | 2026-04-21 04:28:38 | 进度快照(自动) | 完成:6 失败:0 跳过:0 待做:0 (100%) | +| 2026-05-01 21:01:18 | 进度快照(自动) | 完成:6 失败:0 跳过:0 待做:0 (100%) | +| 2026-05-01 21:02:20 | 进度快照(自动) | 完成:6 失败:0 跳过:0 待做:0 (100%) | +| 2026-05-01 21:07:56 | PreCompact快照 | 完成:6 失败:0 跳过:0 待做:0 (100%) | diff --git a/.helloagents/plan/202605012111_file-manager-ui-redesign/.status.json b/.helloagents/plan/202605012111_file-manager-ui-redesign/.status.json new file mode 100644 index 0000000..4bfc7e2 --- /dev/null +++ b/.helloagents/plan/202605012111_file-manager-ui-redesign/.status.json @@ -0,0 +1,13 @@ +{ + "status": "pending", + "completed": 0, + "failed": 0, + "skipped": 0, + "pending": 10, + "uncertain": 0, + "total": 10, + "done": 0, + "percent": 0, + "current": "重设计目录树样式 — 紧凑树视图", + "updated_at": "2026-05-01 21:35:15" +} \ No newline at end of file diff --git a/.helloagents/plan/202605012111_file-manager-ui-redesign/proposal.md b/.helloagents/plan/202605012111_file-manager-ui-redesign/proposal.md new file mode 100644 index 0000000..4742679 --- /dev/null +++ b/.helloagents/plan/202605012111_file-manager-ui-redesign/proposal.md @@ -0,0 +1,98 @@ +# 方案包: file-manager-ui-redesign + +- 创建日期: 2026-05-01 21:11 +- 类型: implementation +- 决策ID: file-manager-ui-redesign#D001 + +## 1. 需求 + +### 背景 +用户提供了 5 张目标截图,要求重新设计工作台文件管理器的 UI,涵盖目录树、工具栏、书签系统、新增书签弹窗、传输管理面板五个区域。当前实现使用卡片式行布局和文字+图标按钮,与目标的紧凑树视图和纯图标工具栏存在较大差异。 + +### 目标 +- 目录树:从卡片行布局转为紧凑传统文件树(黄色文件夹图标、绿色高亮活动目录、符号链接显示目标、更紧密间距、无卡片边框) +- 工具栏:从文字+图标按钮转为紧凑纯图标工具栏(tooltip 提示),上传按钮合并为下拉菜单("上传文件"/"上传文件夹") +- 书签系统:重新设计为"书签列表 N"头部、"本地"/"云端"标签切换(本地=仅当前服务器、云端=全局共享)、scope 标签、更丰富的卡片布局和操作按钮 +- 新增书签弹窗:新增"记录位置"scope 选择器(仅当前服务器/全局共享) +- 传输管理面板:新增底部抽屉面板,含"全部/上传/下载"标签,统一展示上传和传输任务,空态"暂无传输任务" + +### 约束条件 +- 使用项目现有技术栈:Vue 3 + Composition API、Pinia、Tailwind CSS、vue-i18n +- 书签 scope 功能需要后端数据库 migration(新增 scope 和 connection_id 字段) +- 保持所有现有功能不变,仅改变 UI 呈现和增加 scope 功能 +- 遵循项目现有主题变量系统 + +### 验收标准 +- 目录树视觉效果匹配目标截图:紧凑行高、黄色文件夹图标、绿色活动行高亮 +- 工具栏为纯图标按钮,上传按钮为下拉菜单 +- 书签支持本地/云端 scope 切换和筛选 +- 传输面板在底部以抽屉形式展开,支持三个 tab 筛选 +- 所有 i18n 键已添加(zh-CN、en-US、ja-JP) + +## 2. 方案 + +### 技术方案 + +#### 任务1:目录树 UI 重设计 +- 修改 `FileManager.vue` 模板中的 `explorerTreeRows` 渲染区域 +- 移除卡片样式(`rounded-lg border`),改为紧凑行(`py-0.5`) +- 图标颜色:目录用 `text-yellow-500`(黄色文件夹),文件保持现有图标 +- 活动行:使用 `bg-green-600/20 text-green-400` 高亮 +- 符号链接:在文件名后显示 `→ target` +- 调整树头部样式使其更紧凑 + +#### 任务2:工具栏重设计 +- 将工具栏区域的文字+图标按钮改为纯图标按钮(统一 `w-7 h-7`) +- 为每个按钮添加 `title` tooltip +- 上传文件和上传文件夹合并为单个按钮 + 下拉菜单 +- 新增 `uploadMenuOpen` ref 控制下拉菜单显隐 + +#### 任务3:书签系统重构 +- 后端:`favorite_paths` 表新增 `scope` (TEXT, DEFAULT 'global') 和 `connection_id` (INTEGER, NULLABLE) 字段 +- 后端:API 支持 `?scope=local&connectionId=X` 查询参数 +- 前端 store:扩展 `FavoritePathItem` 类型,添加 scope 和 connectionId 字段 +- 前端 `FavoritePathsModal.vue`:重设计为含头部计数、本地/云端 tab、scope 标签的布局 +- 前端 `AddEditFavoritePathForm.vue`:新增 scope 选择器 + +#### 任务4:传输管理面板 +- 新建 `TransferPanel.vue` 组件:底部抽屉面板 +- 含"全部/上传/下载"标签切换 +- 统一展示 `uploads`(来自 FileUploadPopup)和 `transferTasks`(来自 TransferProgressModal) +- 空态显示"暂无传输任务" +- 集成到 `FileManager.vue` 底部 + +### 影响范围 +- `packages/frontend/src/components/FileManager.vue` — 目录树+工具栏+传输面板集成 +- `packages/frontend/src/components/FavoritePathsModal.vue` — 书签列表重设计 +- `packages/frontend/src/components/AddEditFavoritePathForm.vue` — 新增 scope 选择器 +- `packages/frontend/src/components/TransferPanel.vue` — 新组件 +- `packages/frontend/src/stores/favoritePaths.store.ts` — scope 支持 +- `packages/backend/src/database/schema.ts` — 数据库 migration +- `packages/backend/src/favorite-paths/` — API 扩展 +- `packages/frontend/src/locales/` — i18n 键 + +### 风险评估 +- 数据库 migration 需谨慎处理现有数据兼容性(现有书签默认 scope='global') +- FileManager.vue 文件较大(~2570 行),修改需精确定位避免副作用 +- 传输面板需同时消费两个不同数据源(uploads + transferTasks) + +### 方案取舍 +- 选择在 `FileManager.vue` 内直接修改而非拆分子组件,因为目录树渲染与父组件状态紧密耦合,拆分成本高于收益 +- 书签 scope 选择直接内联到现有表单而非独立组件,保持简单 +- 传输面板选择新建独立组件,因为它的数据源和生命周期独立于文件管理器其他部分 + +### 验证策略 +- verifyMode: review-first +- reviewerFocus: UI 视觉匹配目标截图、样式一致性、响应式表现 +- testerFocus: 书签 CRUD 功能完整性、scope 筛选正确性、传输面板数据展示 +- 风险边界: 数据库 migration 回滚方案为手动删除新增字段 + +## 成果设计 + +### 美学基调 +延续项目现有的暗色终端风格,紧凑高信息密度,使用项目主题变量系统保持一致性。 + +### 视觉要素 +- 配色:遵循项目现有 `--color-*` CSS 变量体系;目录树活动行使用绿色系,文件夹图标使用黄色系 +- 布局:紧凑垂直排列,减少 padding 和 margin,提升信息密度 +- 交互:hover 状态使用 `bg-background` 微弱高亮,保持轻量 diff --git a/.helloagents/plan/202605012111_file-manager-ui-redesign/tasks.md b/.helloagents/plan/202605012111_file-manager-ui-redesign/tasks.md new file mode 100644 index 0000000..3d3978b --- /dev/null +++ b/.helloagents/plan/202605012111_file-manager-ui-redesign/tasks.md @@ -0,0 +1,104 @@ +@feature: file-manager-ui-redesign +@created: 2026-05-01 21:11 +@status: pending +@mode: implementation + +## 进度概览 + +- 完成: 0 / 失败: 0 / 跳过: 0 / 总数: 9 + +## 任务列表 + +### 阶段1: 目录树与工具栏 UI 重设计 + +- [ ] 1.1 重设计目录树样式 — 紧凑树视图 + - 文件: `packages/frontend/src/components/FileManager.vue` (模板 lines 2476-2530) + - 预期变更: 移除卡片边框和圆角,改为紧凑行布局;文件夹图标改为黄色;活动行改为绿色高亮;调整缩进和行高;优化树头部样式 + - 完成标准: 目录树视觉匹配目标截图(紧凑行、黄色文件夹、绿色活动高亮、无卡片边框) + - 验证方式: 视觉对比目标截图 + - depends_on: [] + +- [ ] 1.2 重设计工具栏 — 紧凑纯图标按钮 + - 文件: `packages/frontend/src/components/FileManager.vue` (模板 lines 2371-2441) + - 预期变更: 移除按钮文字标签,保留纯图标;统一按钮尺寸 `w-7 h-7`;上传文件和上传文件夹合并为下拉菜单按钮 + - 完成标准: 工具栏为纯图标按钮行,上传为下拉菜单 + - 验证方式: 视觉对比目标截图,检查 tooltip 显示 + - depends_on: [] + +### 阶段2: 传输管理面板 + +- [ ] 2.1 创建 TransferPanel.vue 组件 + - 文件: `packages/frontend/src/components/TransferPanel.vue` (新建) + - 预期变更: 底部抽屉面板组件,含"全部/上传/下载"tab,统一展示上传和传输任务,空态"暂无传输任务" + - 完成标准: 组件独立运行,支持 tab 切换和空态展示 + - 验证方式: 组件接收 props 正确渲染 + - depends_on: [] + +- [ ] 2.2 集成 TransferPanel 到 FileManager + - 文件: `packages/frontend/src/components/FileManager.vue` + - 预期变更: 在文件管理器底部集成 TransferPanel,替代原 FileUploadPopup 的固定定位弹窗;添加传输面板展开/收起切换 + - 完成标准: 传输面板在文件管理器底部正确展示,上传任务和传输任务统一显示 + - 验证方式: 触发上传后传输面板展示任务 + - depends_on: [2.1] + +### 阶段3: 书签系统重构 + +- [ ] 3.1 后端数据库 migration — 添加 scope 字段 + - 文件: `packages/backend/src/database/schema.ts`, `packages/backend/src/database/migrations/` + - 预期变更: `favorite_paths` 表新增 `scope` TEXT 字段(默认 'global')和 `connection_id` INTEGER 字段(nullable);编写 migration 脚本确保现有数据兼容 + - 完成标准: 数据库表结构包含新字段,现有数据 scope 默认为 'global' + - 验证方式: 检查 schema 定义和 migration 逻辑 + - depends_on: [] + +- [ ] 3.2 后端 API 扩展 — scope 查询支持 + - 文件: `packages/backend/src/favorite-paths/favorite-paths.routes.ts`, `packages/backend/src/favorite-paths/favorite-paths.repository.ts` + - 预期变更: GET /favorite-paths 支持 `?scope=local&connectionId=X` 查询参数;POST/PUT 支持 scope 和 connection_id 字段 + - 完成标准: API 正确按 scope 和 connectionId 过滤/保存书签 + - 验证方式: API 请求测试 + - depends_on: [3.1] + +- [ ] 3.3 前端 store 扩展 — scope 支持 + - 文件: `packages/frontend/src/stores/favoritePaths.store.ts` + - 预期变更: `FavoritePathItem` 类型添加 `scope` 和 `connectionId` 字段;CRUD 方法传递 scope 参数;新增 `activeTab` 状态和 `fetchByScope` 方法 + - 完成标准: Store 支持按 scope 筛选和保存书签 + - 验证方式: Store 方法调用正确传参 + - depends_on: [3.2] + +- [ ] 3.4 重设计 FavoritePathsModal — 书签列表 UI + - 文件: `packages/frontend/src/components/FavoritePathsModal.vue` + - 预期变更: 头部显示"书签列表 N";添加"本地"/"云端"tab 切换;每个书签卡片显示 scope 标签和操作按钮 + - 完成标准: 书签列表 UI 匹配目标截图,支持 tab 切换筛选 + - 验证方式: 视觉对比目标截图 + - depends_on: [3.3] + +- [ ] 3.5 重设计 AddEditFavoritePathForm — 添加 scope 选择器 + - 文件: `packages/frontend/src/components/AddEditFavoritePathForm.vue` + - 预期变更: 在表单中新增"记录位置"scope 选择器(仅当前服务器/全局共享),保存时传递 scope 和 connectionId + - 完成标准: 表单支持选择书签 scope + - 验证方式: 创建书签时 scope 正确保存 + - depends_on: [3.3] + +### 阶段4: i18n 与收尾 + +- [ ] 4.1 添加 i18n 键 + - 文件: `packages/frontend/src/locales/zh-CN.json`, `en-US.json`, `ja-JP.json` + - 预期变更: 添加传输面板、书签 scope、工具栏 tooltip 相关的所有新 i18n 键 + - 完成标准: 所有新增 UI 文本均使用 i18n 键,三种语言文件同步更新 + - 验证方式: 搜索硬编码字符串 + - depends_on: [1.1, 1.2, 2.1, 3.4, 3.5] + +## 执行日志 + +| 时间 | 事件 | 详情 | +|------|------|------| +| 2026-05-01 21:25:50 | 进度快照(自动) | 完成:0 失败:0 跳过:0 待做:10 (0%) | +| 2026-05-01 21:27:46 | 进度快照(自动) | 完成:0 失败:0 跳过:0 待做:10 (0%) | +| 2026-05-01 21:28:56 | 进度快照(自动) | 完成:0 失败:0 跳过:0 待做:10 (0%) | +| 2026-05-01 21:33:06 | 进度快照(自动) | 完成:0 失败:0 跳过:0 待做:10 (0%) | +| 2026-05-01 21:35:15 | 进度快照(自动) | 完成:0 失败:0 跳过:0 待做:10 (0%) | + +## 执行备注 + +- FileManager.vue 约 2570 行,修改时需精确定位模板区域避免副作用 +- 数据库 migration 需确保 SQLite 兼容(ALTER TABLE ADD COLUMN) +- 传输面板需同时消费 uploads(本地 ref)和 transferTasks(API 轮询)两个数据源 diff --git a/.helloagents/user/.kb_sync_needed b/.helloagents/user/.kb_sync_needed new file mode 100644 index 0000000..0022978 --- /dev/null +++ b/.helloagents/user/.kb_sync_needed @@ -0,0 +1 @@ +2026-05-01T21:36:14.234134 \ No newline at end of file diff --git a/packages/backend/src/database/migrations.ts b/packages/backend/src/database/migrations.ts index 61304d8..59318cc 100644 --- a/packages/backend/src/database/migrations.ts +++ b/packages/backend/src/database/migrations.ts @@ -370,6 +370,18 @@ const definedMigrations: Migration[] = [ ALTER TABLE quick_command_tag_associations ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0; UPDATE quick_command_tag_associations SET sort_order = rowid WHERE sort_order = 0; ` + }, + { + id: 15, + name: 'Add scope and connection_id columns to favorite_paths table', + check: async (db: Database): Promise => { + const scopeExists = await columnExists(db, 'favorite_paths', 'scope'); + return !scopeExists; + }, + sql: ` + ALTER TABLE favorite_paths ADD COLUMN scope TEXT NOT NULL DEFAULT 'global'; + ALTER TABLE favorite_paths ADD COLUMN connection_id INTEGER NULL; + ` } ]; diff --git a/packages/backend/src/database/schema.ts b/packages/backend/src/database/schema.ts index 8fb55b2..9b0b61d 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -261,9 +261,11 @@ CREATE TABLE IF NOT EXISTS appearance_settings ( export const createFavoritePathsTableSQL = ` CREATE TABLE IF NOT EXISTS favorite_paths ( id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NULL, - path TEXT NOT NULL, - last_used_at INTEGER NULL, + name TEXT NULL, + path TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'global', + connection_id INTEGER NULL, + last_used_at INTEGER NULL, created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) ); diff --git a/packages/backend/src/favorite-paths/favorite-paths.controller.ts b/packages/backend/src/favorite-paths/favorite-paths.controller.ts index bfbc8b7..cd8766a 100644 --- a/packages/backend/src/favorite-paths/favorite-paths.controller.ts +++ b/packages/backend/src/favorite-paths/favorite-paths.controller.ts @@ -6,7 +6,7 @@ import { FavoritePathSortBy } from '../favorite-paths/favorite-paths.service'; * 处理添加新收藏路径的请求 */ export const createFavoritePath = async (req: Request, res: Response): Promise => { - const { name, path } = req.body; + const { name, path, scope, connectionId } = req.body; if (!path || typeof path !== 'string' || path.trim().length === 0) { res.status(400).json({ message: '路径内容不能为空' }); @@ -18,7 +18,7 @@ export const createFavoritePath = async (req: Request, res: Response): Promise => { const sortBy = req.query.sortBy as FavoritePathSortBy | undefined; + const scope = req.query.scope as string | undefined; + const connectionIdStr = req.query.connectionId as string | undefined; + const connectionId = connectionIdStr ? parseInt(connectionIdStr, 10) : undefined; const validSortByOptions: FavoritePathSortBy[] = ['name', 'last_used_at']; const validSortBy: FavoritePathSortBy = sortBy && validSortByOptions.includes(sortBy) ? sortBy : 'name'; try { - const favoritePaths = await FavoritePathsService.getAllFavoritePaths(validSortBy); + const favoritePaths = await FavoritePathsService.getAllFavoritePaths(validSortBy, scope, connectionId); res.status(200).json(favoritePaths); } catch (error: any) { console.error('获取收藏路径控制器出错:', error); @@ -79,7 +82,7 @@ export const getFavoritePathById = async (req: Request, res: Response): Promise< */ export const updateFavoritePath = async (req: Request, res: Response): Promise => { const id = parseInt(req.params.id, 10); - const { name, path } = req.body; + const { name, path, scope, connectionId } = req.body; if (isNaN(id)) { res.status(400).json({ message: '无效的 ID' }); @@ -95,7 +98,7 @@ export const updateFavoritePath = async (req: Request, res: Response): Promise => { - const sql = `INSERT INTO favorite_paths (name, path, created_at, updated_at) VALUES (?, ?, strftime('%s', 'now'), strftime('%s', 'now'))`; +export const addFavoritePath = async (name: string | null, path: string, scope: string = 'global', connectionId: number | null = null): Promise => { + const sql = `INSERT INTO favorite_paths (name, path, scope, connection_id, created_at, updated_at) VALUES (?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))`; try { const db = await getDbInstance(); - const result = await runDb(db, sql, [name, path]); + const result = await runDb(db, sql, [name, path, scope, connectionId]); if (typeof result.lastID !== 'number' || result.lastID <= 0) { throw new Error('添加收藏路径后未能获取有效的 lastID'); } @@ -38,11 +40,22 @@ export const addFavoritePath = async (name: string | null, path: string): Promis * @param path - 新的路径内容 * @returns 返回是否成功更新 (true/false) */ -export const updateFavoritePath = async (id: number, name: string | null, path: string): Promise => { - const sql = `UPDATE favorite_paths SET name = ?, path = ?, updated_at = strftime('%s', 'now') WHERE id = ?`; +export const updateFavoritePath = async (id: number, name: string | null, path: string, scope?: string, connectionId?: number | null): Promise => { + const fields = ['name = ?', 'path = ?', "updated_at = strftime('%s', 'now')"]; + const params: any[] = [name, path]; + if (scope !== undefined) { + fields.push('scope = ?'); + params.push(scope); + } + if (connectionId !== undefined) { + fields.push('connection_id = ?'); + params.push(connectionId); + } + params.push(id); + const sql = `UPDATE favorite_paths SET ${fields.join(', ')} WHERE id = ?`; try { const db = await getDbInstance(); - const result = await runDb(db, sql, [name, path, id]); + const result = await runDb(db, sql, params); return result.changes > 0; } catch (err: any) { console.error('更新收藏路径时出错:', err.message); @@ -72,15 +85,26 @@ export const deleteFavoritePath = async (id: number): Promise => { * @param sortBy - 排序字段 ('name' 或 'usage_count') * @returns 返回包含所有收藏路径条目的数组 */ -export const getAllFavoritePaths = async (sortBy: 'name' | 'last_used_at' = 'name'): Promise => { - let orderByClause = 'ORDER BY name ASC'; // 默认按名称升序 +export const getAllFavoritePaths = async (sortBy: 'name' | 'last_used_at' = 'name', scope?: string, connectionId?: number): Promise => { + let orderByClause = 'ORDER BY name ASC'; if (sortBy === 'last_used_at') { - orderByClause = 'ORDER BY last_used_at DESC, name ASC'; // 按上次使用时间降序,同时间的按名称升序 + orderByClause = 'ORDER BY last_used_at DESC, name ASC'; } - const sql = `SELECT id, name, path, last_used_at, created_at, updated_at FROM favorite_paths ${orderByClause}`; + const conditions: string[] = []; + const params: any[] = []; + if (scope) { + conditions.push('scope = ?'); + params.push(scope); + } + if (connectionId !== undefined) { + conditions.push('connection_id = ?'); + params.push(connectionId); + } + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const sql = `SELECT id, name, path, scope, connection_id, last_used_at, created_at, updated_at FROM favorite_paths ${whereClause} ${orderByClause}`; try { const db = await getDbInstance(); - const rows = await allDb(db, sql); + const rows = await allDb(db, sql, params); return rows; } catch (err: any) { console.error('获取收藏路径时出错:', err.message); diff --git a/packages/backend/src/favorite-paths/favorite-paths.service.ts b/packages/backend/src/favorite-paths/favorite-paths.service.ts index 62192ca..5840d6b 100644 --- a/packages/backend/src/favorite-paths/favorite-paths.service.ts +++ b/packages/backend/src/favorite-paths/favorite-paths.service.ts @@ -10,13 +10,12 @@ export type FavoritePathSortBy = 'name' | 'last_used_at'; * @param path - 路径内容 * @returns 返回添加记录的 ID */ -export const addFavoritePath = async (name: string | null, path: string): Promise => { +export const addFavoritePath = async (name: string | null, path: string, scope: string = 'global', connectionId: number | null = null): Promise => { if (!path || path.trim().length === 0) { throw new Error('路径内容不能为空'); } - // 如果 name 是空字符串,则视为 null const finalName = name && name.trim().length > 0 ? name.trim() : null; - const favoritePathId = await FavoritePathsRepository.addFavoritePath(finalName, path.trim()); + const favoritePathId = await FavoritePathsRepository.addFavoritePath(finalName, path.trim(), scope, connectionId); return favoritePathId; }; @@ -27,12 +26,12 @@ export const addFavoritePath = async (name: string | null, path: string): Promis * @param path - 新的路径内容 * @returns 返回是否成功更新 (更新行数 > 0) */ -export const updateFavoritePath = async (id: number, name: string | null, path: string): Promise => { +export const updateFavoritePath = async (id: number, name: string | null, path: string, scope?: string, connectionId?: number | null): Promise => { if (!path || path.trim().length === 0) { throw new Error('路径内容不能为空'); } const finalName = name && name.trim().length > 0 ? name.trim() : null; - const pathUpdated = await FavoritePathsRepository.updateFavoritePath(id, finalName, path.trim()); + const pathUpdated = await FavoritePathsRepository.updateFavoritePath(id, finalName, path.trim(), scope, connectionId); return pathUpdated; }; @@ -51,8 +50,8 @@ export const deleteFavoritePath = async (id: number): Promise => { * @param sortBy - 排序字段 ('name' 或 'usage_count') * @returns 返回排序后的收藏路径数组 */ -export const getAllFavoritePaths = async (sortBy: FavoritePathSortBy = 'name'): Promise => { - return FavoritePathsRepository.getAllFavoritePaths(sortBy); +export const getAllFavoritePaths = async (sortBy: FavoritePathSortBy = 'name', scope?: string, connectionId?: number): Promise => { + return FavoritePathsRepository.getAllFavoritePaths(sortBy, scope, connectionId); }; /** diff --git a/packages/frontend/src/components/AddEditFavoritePathForm.vue b/packages/frontend/src/components/AddEditFavoritePathForm.vue index e2a2d25..2a49718 100644 --- a/packages/frontend/src/components/AddEditFavoritePathForm.vue +++ b/packages/frontend/src/components/AddEditFavoritePathForm.vue @@ -23,24 +23,25 @@ const form = ref({ id: '', path: '', name: '', + scope: 'global' as 'global' | 'local', }); const isEditMode = computed(() => !!props.pathData?.id); const isLoading = ref(false); const errorMessage = ref(null); - watch(() => props.isVisible, (newValue) => { if (newValue) { - errorMessage.value = null; // Reset error on open + errorMessage.value = null; if (props.pathData) { - form.value = { - id: props.pathData.id, - path: props.pathData.path, - name: props.pathData.name || '' + form.value = { + id: props.pathData.id, + path: props.pathData.path, + name: props.pathData.name || '', + scope: (props.pathData.scope as 'global' | 'local') || 'global', }; } else { - form.value = { id: '', path: '', name: '' }; + form.value = { id: '', path: '', name: '', scope: 'global' }; } } }, { immediate: true }); @@ -50,7 +51,6 @@ const validateForm = (): boolean => { errorMessage.value = t('favoritePaths.addEditForm.validation.pathRequired', 'Path is required.'); return false; } - // Add other validation rules if needed errorMessage.value = null; return true; }; @@ -65,12 +65,14 @@ const handleSubmit = async () => { if (isEditMode.value && form.value.id) { await favoritePathsStore.updateFavoritePath(form.value.id, { path: form.value.path, - name: form.value.name || undefined, // Send undefined if empty to allow backend to handle + name: form.value.name || undefined, + scope: form.value.scope, }, t); } else { await favoritePathsStore.addFavoritePath({ path: form.value.path, name: form.value.name || undefined, + scope: form.value.scope, }, t); } emit('saveSuccess'); @@ -78,37 +80,35 @@ const handleSubmit = async () => { } catch (error: any) { console.error('Error saving favorite path:', error); errorMessage.value = error.message || t('favoritePaths.addEditForm.errors.genericSaveError', 'Failed to save favorite path.'); - // Notification is usually handled by the store, but we can show a local error too. } finally { isLoading.value = false; } }; const closeModal = () => { - if (!isLoading.value) { // Prevent closing while loading + if (!isLoading.value) { emit('close'); } }; - - \ No newline at end of file + \ No newline at end of file diff --git a/packages/frontend/src/components/FavoritePathsModal.vue b/packages/frontend/src/components/FavoritePathsModal.vue index b67ab7e..af4ba4e 100644 --- a/packages/frontend/src/components/FavoritePathsModal.vue +++ b/packages/frontend/src/components/FavoritePathsModal.vue @@ -7,7 +7,7 @@ import AddEditFavoritePathForm from './AddEditFavoritePathForm.vue'; import { useWorkspaceEventEmitter } from '../composables/workspaceEvents'; import { useConfirmDialog } from '../composables/useConfirmDialog'; -const PADDING = 8; // px +const PADDING = 8; const props = defineProps({ isVisible: { @@ -34,28 +34,27 @@ const editingPathItem = ref(null); const modalContentRef = ref(null); const modalStyle = ref>({}); +const scopeTabs = computed(() => [ + { key: 'all' as const, label: t('favoritePaths.scopeAll', '全部') }, + { key: 'local' as const, label: t('favoritePaths.scopeLocal', '本地') }, + { key: 'global' as const, label: t('favoritePaths.scopeGlobal', '云端') }, +]); const filteredPaths = computed(() => { - if (!searchTerm.value) { - return favoritePathsStore.favoritePaths; - } - const lowerSearchTerm = searchTerm.value.toLowerCase(); - return favoritePathsStore.favoritePaths.filter( - (p) => - p.path.toLowerCase().includes(lowerSearchTerm) || - (p.name && p.name.toLowerCase().includes(lowerSearchTerm)) - ); + return favoritePathsStore.filteredFavoritePaths.filter(p => { + if (!searchTerm.value) return true; + const lowerSearchTerm = searchTerm.value.toLowerCase(); + return p.path.toLowerCase().includes(lowerSearchTerm) || + (p.name && p.name.toLowerCase().includes(lowerSearchTerm)); + }); }); -// Computed property for sort button icon and title const currentSortBy = computed(() => favoritePathsStore.currentSortBy); const sortButtonIcon = computed(() => { return currentSortBy.value === 'name' ? 'fas fa-sort-alpha-down' : 'fas fa-clock'; }); - - const toggleSort = () => { const newSortBy = currentSortBy.value === 'name' ? 'last_used_at' : 'name'; favoritePathsStore.setSortBy(newSortBy); @@ -63,11 +62,9 @@ const toggleSort = () => { const handleItemClick = async (pathItem: FavoritePathItem) => { try { - // Mark path as used before navigating await favoritePathsStore.markPathAsUsed(pathItem.id, t); } catch (error) { console.error('Failed to mark path as used:', error); - // Optionally, inform the user about the failure, though navigation will still proceed. } emit('navigateToPath', pathItem.path); closeModal(); @@ -109,7 +106,7 @@ const handleSendToTerminal = (pathItem: FavoritePathItem) => { } else { console.warn('[FavoritePathsModal] No active session with a terminal manager found to send path to.'); } - closeModal(); + closeModal(); }; const closeModal = () => { @@ -125,58 +122,47 @@ const updatePosition = () => { const modalWidth = modalContentRef.value.offsetWidth; const modalHeight = modalContentRef.value.offsetHeight; - // If dimensions are zero when modal is supposed to be visible, - // it might mean content affecting size isn't ready. Retry once. if (modalWidth === 0 && modalHeight === 0 && props.isVisible) { nextTick(updatePosition); return; } - + const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - let top = triggerRect.bottom + 2; // Default position below trigger, with a small 2px gap + let top = triggerRect.bottom + 2; let left = triggerRect.left; - // Check for bottom overflow if (top + modalHeight + PADDING > viewportHeight) { - // Try to position above the trigger - top = triggerRect.top - modalHeight - 2; // Position above trigger, with a small 2px gap + top = triggerRect.top - modalHeight - 2; } - // If positioning above also causes top overflow (e.g., trigger is near the top and modal is tall) if (top < PADDING) { - top = PADDING; // Align to viewport top with padding - // Note: If modalHeight is still greater than viewportHeight - 2*PADDING, - // it will overflow downwards. The `max-h-80` class on the modal - // should generally prevent the modal itself from being excessively tall. + top = PADDING; } - // Check for right overflow if (left + modalWidth + PADDING > viewportWidth) { - left = viewportWidth - modalWidth - PADDING; // Align to viewport right edge + left = viewportWidth - modalWidth - PADDING; } - // Check for left overflow (less likely with initial left alignment to trigger, but good for robustness) if (left < PADDING) { - left = PADDING; // Align to viewport left edge + left = PADDING; } modalStyle.value = { - position: 'fixed', // Position relative to the viewport + position: 'fixed', top: `${top}px`, left: `${left}px`, }; }; -// --- Click Outside Logic --- const handleClickOutside = (event: MouseEvent) => { if (props.triggerElement && props.triggerElement.contains(event.target as Node)) { return; } if (modalContentRef.value && !modalContentRef.value.contains(event.target as Node)) { - if (!showAddEditModal.value) { + if (!showAddEditModal.value) { closeModal(); } } @@ -186,21 +172,21 @@ watch(() => props.isVisible, (newValue: boolean) => { if (newValue) { searchTerm.value = ''; document.addEventListener('mousedown', handleClickOutside); - nextTick(() => { // Ensure DOM is ready for measurements - updatePosition(); // Calculate initial position - window.addEventListener('resize', updatePosition); // Adjust position on window resize + nextTick(() => { + updatePosition(); + window.addEventListener('resize', updatePosition); }); } else { document.removeEventListener('mousedown', handleClickOutside); - window.removeEventListener('resize', updatePosition); // Clean up resize listener + window.removeEventListener('resize', updatePosition); } }); onMounted(() => { if (props.isVisible) { - searchTerm.value = ''; + searchTerm.value = ''; document.addEventListener('mousedown', handleClickOutside); - nextTick(() => { + nextTick(() => { updatePosition(); window.addEventListener('resize', updatePosition); }); @@ -209,94 +195,119 @@ onMounted(() => { onBeforeUnmount(() => { document.removeEventListener('mousedown', handleClickOutside); - window.removeEventListener('resize', updatePosition); // Ensure resize listener is cleaned up + window.removeEventListener('resize', updatePosition); }); - - + + \ No newline at end of file diff --git a/packages/frontend/src/components/FileManager.vue b/packages/frontend/src/components/FileManager.vue index 30edd17..c01dcab 100644 --- a/packages/frontend/src/components/FileManager.vue +++ b/packages/frontend/src/components/FileManager.vue @@ -15,6 +15,7 @@ import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileMa import { useFileManagerKeyboardNavigation } from '../composables/file-manager/useFileManagerKeyboardNavigation'; import { createFolderArchive, type FolderArchiveSource } from '../composables/file-manager/useFolderArchiveUpload'; import FileUploadPopup from './FileUploadPopup.vue'; +import TransferPanel from './TransferPanel.vue'; import FileManagerContextMenu from './FileManagerContextMenu.vue'; import FileManagerActionModal from './FileManagerActionModal.vue'; import type { FileListItem } from '../types/sftp.types'; @@ -150,6 +151,8 @@ const editablePath = ref(''); const fileListContainerRef = ref(null); // 文件列表容器引用 const dropOverlayRef = ref(null); // +++ 拖拽蒙版引用 +++ const isFolderUploadBusy = ref(false); +const uploadMenuOpen = ref(false); +const showTransferPanel = ref(false); // +++ Favorite Paths Modal State +++ const showFavoritePathsModal = ref(false); @@ -1741,6 +1744,7 @@ onMounted(() => { }; unregisterPathFocusAction = focusSwitcherStore.registerFocusAction('fileManagerPathInput', focusPathActionWrapper); document.addEventListener('click', handleClickOutsidePathInput); + document.addEventListener('click', handleClickOutsideUploadMenu); }); onBeforeUnmount(() => { @@ -1758,6 +1762,7 @@ onBeforeUnmount(() => { } unregisterPathFocusAction = null; document.removeEventListener('click', handleClickOutsidePathInput); + document.removeEventListener('click', handleClickOutsideUploadMenu); sessionStore.removeSftpManager(props.sessionId, props.instanceId); }); @@ -2000,6 +2005,12 @@ const handleClickOutsidePathInput = (event: MouseEvent) => { } }; +const handleClickOutsideUploadMenu = (event: MouseEvent) => { + if (uploadMenuOpen.value) { + uploadMenuOpen.value = false; + } +}; + // --- 搜索框激活/取消逻辑 --- const activateSearch = () => { @@ -2368,7 +2379,7 @@ watch( -
+
@@ -2377,62 +2388,63 @@ watch( @click="openPopupEditor" :disabled="!currentSftpManager || !props.wsDeps.isConnected.value" :title="t('fileManager.actions.openEditor', 'Open Popup Editor')" - class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary" - :class="{ 'px-1.5': props.isMobile }" + class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground" > - - {{ t('fileManager.actions.openEditor', 'Open Editor') }} - - - - + +
+ +
+ + +
+
- - - + -
-
{{ row.name }}
-
- {{ row.path }} -
-
+ {{ row.name }}→ {{ row.description }}
- - + + {{ t('statusMonitor.loading') }} -
-
-
-
- {{ t('statusMonitor.title') }} -

{{ displayOsName }}

+
+ +
+
+
+ + {{ t('statusMonitor.title') }}
- -
+
- - - - LIVE - +
-
- {{ networkInterfaceDisplay }} - {{ displayCpuCores }} +
+ {{ displayOsName }}
-

{{ displayCpuModel }}

- -
-
- {{ item.label }} - {{ item.value }} -
+
+ {{ t('statusMonitor.timezoneLabel') }} {{ timezoneDisplay }} + {{ t('statusMonitor.uptimeLabel') }} {{ uptimeDisplay }}
-
-
-
- {{ t('statusMonitor.cpuLabel') }} + +
+
+
+ + CPU
- {{ displayCpuCores }} +
-
- - -
-
-
- {{ item.label }} - {{ item.value }} -
+
+
+ {{ item.index }} +
+
+
+ {{ item.value }} +
+
+ + +
+ + +
+
+
+ + {{ t('statusMonitor.memoryCardTitle') }} +
+ {{ memoryTotalDisplay }} +
+ +
+
+
{{ memoryPercentDisplay }}
+
+ +
+
+ + {{ item.label }} + {{ item.value }}
-
-
-
-
-
- {{ t('statusMonitor.memoryCardTitle') }} -
{{ t('statusMonitor.memoryUsedStat') }} / {{ t('statusMonitor.memoryFreeStat') }}
-
- {{ memoryTotalDisplay }} + +
+
+
+ + {{ t('statusMonitor.networkLabel') }}
+ +
-
-
-
-
{{ memoryPercentDisplay }}
-
- {{ t('statusMonitor.memoryCardTitle') }} -
+
+
+ + {{ t('statusMonitor.networkSpeedTitleUnit', { unit: '' }).replace(/[()]/g, '').trim() }} + {{ t('statusMonitor.totalTrafficLabel') }} +
+
+ + + {{ item.label }} + + {{ item.value }} + {{ item.totalValue }} +
+
+
-
-
-
- - {{ item.label }} -
-
{{ item.value }}
-
+ +
+
+
+ + {{ t('statusMonitor.processManager.title') }} +
+ +
+ +
+
+ CPU + MEM + CMD +
+
+ {{ item.cpu.toFixed(1) }}% + {{ item.memPercent !== undefined ? item.memPercent.toFixed(1) + '%' : formatProcessMemory(item.memMb) }} + + {{ truncateCommand(item.command) }} + PID {{ item.pid }} + +
+
+
+ {{ t('statusMonitor.processManager.empty') }} +
+
+ + +
+
+
+ + {{ t('statusMonitor.diskCardTitle') }} +
+ {{ diskUsageDisplay }} +
+ +
+ {{ diskMountPointDisplay }} + {{ t('statusMonitor.diskTypeLabel') }} {{ diskFsTypeDisplay }} +
+ +
+
+
+
+ {{ t('statusMonitor.diskReadRateLabel') }} + {{ diskReadRateDisplay }}
-
- -
-
-
- {{ t('statusMonitor.networkLabel') }} -
- {{ t('statusMonitor.networkSpeedTitleUnit', { unit: networkRateUnitLabel }) }} -
- -
- - -
-
- {{ networkInterfaceDisplay }} - {{ t('statusMonitor.downloadLabel') }} / {{ t('statusMonitor.uploadLabel') }} -
-
- - {{ t('statusMonitor.networkSpeedTitleUnit', { unit: networkRateUnitLabel }) }} - {{ t('statusMonitor.totalTrafficLabel') }} -
- -
-
- - - {{ item.label }} - - {{ item.value }} - {{ item.totalValue }} -
-
+
+
+
+ {{ t('statusMonitor.diskWriteRateLabel') }} + {{ diskWriteRateDisplay }}
-
+
-
-
-
- {{ t('statusMonitor.diskCardTitle') }} -
{{ diskUsageDisplay }}
-
- {{ diskPercentDisplay }} +
+
+ {{ t('statusMonitor.diskMountLabel') }} + {{ t('statusMonitor.diskSizeLabel') }} + {{ t('statusMonitor.diskAvailableLabel') }} + {{ t('statusMonitor.diskUsedPercentLabel') }}
- -
-
-
- {{ diskMountPointDisplay }} - {{ diskFsTypeDisplay }} -
-
-
-
-
-
{{ diskDeviceAccent }}
-
-
- -
- {{ t('statusMonitor.diskReadRateLabel') }} - {{ diskReadRateDisplay }} -
- -
- {{ t('statusMonitor.diskWriteRateLabel') }} - {{ diskWriteRateDisplay }} -
+
+ {{ diskMountPointDisplay }} + {{ diskSizeDisplay }} + {{ diskAvailableDisplay }} + {{ diskPercentDisplay }}
- -
-
- {{ t('statusMonitor.diskMountLabel') }} - {{ t('statusMonitor.diskSizeLabel') }} - {{ t('statusMonitor.diskAvailableLabel') }} - {{ t('statusMonitor.diskUsedPercentLabel') }} -
-
- {{ diskMountPointDisplay }} - {{ diskSizeDisplay }} - {{ diskAvailableDisplay }} - {{ diskPercentDisplay }} -
-
-
- -
-
-
- {{ t('statusMonitor.processManager.title') }} -
-
-
- - {{ item.label }} {{ item.value }} - -
- -
-
- -
-
-
- PID {{ item.pid }} - {{ item.user }} - {{ item.state }} -
-
-
{{ item.command }}
-
- {{ item.cpu.toFixed(1) }}% - {{ formatProcessMemory(item.memMb) }} -
-
-
-
-
- {{ t('statusMonitor.processManager.empty') }} -
-
-
- +
+
{ - if (typeof value !== 'number' || !Number.isFinite(value)) { - return 0; - } + if (typeof value !== 'number' || !Number.isFinite(value)) return 0; return Math.max(0, Math.min(100, value)); }; @@ -339,28 +303,12 @@ const cachedOsName = ref(null); watch(currentServerStatus, newData => { if (!newData) return; - if (newData.cpuModel) { - cachedCpuModel.value = newData.cpuModel; - } - if (typeof newData.cpuCores === 'number' && Number.isFinite(newData.cpuCores)) { - cachedCpuCores.value = newData.cpuCores; - } - if (newData.osName) { - cachedOsName.value = newData.osName; - } + if (newData.cpuModel) cachedCpuModel.value = newData.cpuModel; + if (typeof newData.cpuCores === 'number' && Number.isFinite(newData.cpuCores)) cachedCpuCores.value = newData.cpuCores; + if (newData.osName) cachedOsName.value = newData.osName; }, { immediate: true }); -const displayCpuModel = computed(() => (currentServerStatus.value?.cpuModel ?? cachedCpuModel.value) || t('statusMonitor.notAvailable')); -const displayCpuCores = computed(() => { - const cpuCores = currentServerStatus.value?.cpuCores ?? cachedCpuCores.value; - if (typeof cpuCores !== 'number' || !Number.isFinite(cpuCores)) { - return t('statusMonitor.notAvailable'); - } - - return t('statusMonitor.cpuCoresValue', { count: Math.round(cpuCores) }); -}); const displayOsName = computed(() => (currentServerStatus.value?.osName ?? cachedOsName.value) || t('statusMonitor.notAvailable')); -const networkInterfaceDisplay = computed(() => currentServerStatus.value?.netInterface || t('statusMonitor.notAvailable')); const formatBytesPerSecond = (bytes?: number): string => { if (bytes === undefined || bytes === null || isNaN(bytes)) return t('statusMonitor.notAvailable'); @@ -401,51 +349,46 @@ const formatStorageSizeFromKb = (kb?: number, compact = false): string => { const formatMemorySize = (mb?: number): string => { if (mb === undefined || mb === null || isNaN(mb)) return t('statusMonitor.notAvailable'); - if (mb < 1024) { - return `${mb.toFixed(1)} ${t('statusMonitor.megaBytes')}`; - } + if (mb < 1024) return `${mb.toFixed(1)} ${t('statusMonitor.megaBytes')}`; return `${(mb / 1024).toFixed(1)} ${t('statusMonitor.gigaBytes')}`; }; const formatUptime = (seconds?: number): string => { - if (seconds === undefined || seconds === null || !Number.isFinite(seconds) || seconds < 0) { - return t('statusMonitor.notAvailable'); - } - + if (seconds === undefined || seconds === null || !Number.isFinite(seconds) || seconds < 0) return t('statusMonitor.notAvailable'); const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); - - if (days > 0) { - return `${days}${t('statusMonitor.uptimeDaySuffix')} ${hours}${t('statusMonitor.uptimeHourSuffix')}`; - } - if (hours > 0) { - return `${hours}${t('statusMonitor.uptimeHourSuffix')} ${minutes}${t('statusMonitor.uptimeMinuteSuffix')}`; - } + if (days > 0) return `${days}${t('statusMonitor.uptimeDaySuffix')} ${hours}${t('statusMonitor.uptimeHourSuffix')}`; + if (hours > 0) return `${hours}${t('statusMonitor.uptimeHourSuffix')} ${minutes}${t('statusMonitor.uptimeMinuteSuffix')}`; return `${minutes}${t('statusMonitor.uptimeMinuteSuffix')}`; }; const formatProcessMemory = (mb?: number): string => { - if (mb === undefined || mb === null || !Number.isFinite(mb)) { - return t('statusMonitor.notAvailable'); - } - if (mb < 1024) { - return `${mb.toFixed(1)} M`; - } + if (mb === undefined || mb === null || !Number.isFinite(mb)) return t('statusMonitor.notAvailable'); + if (mb < 1024) return `${mb.toFixed(1)} M`; return `${(mb / 1024).toFixed(1)} G`; }; +const truncateCommand = (cmd: string): string => { + if (!cmd) return ''; + const parts = cmd.split('/'); + const basename = parts[parts.length - 1] || cmd; + return basename.length > 24 ? basename.slice(0, 22) + '...' : basename; +}; + +const getCpuFillClass = (percent: number): string => { + if (percent >= 90) return 'sm-cpu-core__fill--critical'; + if (percent >= 60) return 'sm-cpu-core__fill--warn'; + return 'sm-cpu-core__fill--normal'; +}; + const memoryTotalValue = computed(() => currentServerStatus.value?.memTotal ?? 0); const memoryUsedValue = computed(() => currentServerStatus.value?.memUsed ?? 0); const memoryCachedValue = computed(() => currentServerStatus.value?.memCached ?? 0); const memoryFreeValue = computed(() => { const data = currentServerStatus.value; - if (data?.memFree !== undefined) { - return data.memFree; - } - if (data?.memTotal !== undefined && data?.memUsed !== undefined) { - return Math.max(data.memTotal - data.memUsed - (data.memCached ?? 0), 0); - } + if (data?.memFree !== undefined) return data.memFree; + if (data?.memTotal !== undefined && data?.memUsed !== undefined) return Math.max(data.memTotal - data.memUsed - (data.memCached ?? 0), 0); return 0; }); @@ -454,18 +397,12 @@ const memoryPercentDisplay = computed(() => `${Math.round(displayMemoryPercent.v const memoryRingStyle = computed(() => { const total = memoryTotalValue.value; - if (total <= 0) { - return { background: 'conic-gradient(#334155 0% 100%)' }; - } - + if (total <= 0) return { background: 'conic-gradient(#334155 0% 100%)' }; const usedPercent = Math.min(100, (memoryUsedValue.value / total) * 100); const cachedPercent = Math.min(100 - usedPercent, (memoryCachedValue.value / total) * 100); const usedEnd = usedPercent; const cacheEnd = usedPercent + cachedPercent; - - return { - background: `conic-gradient(#ef5350 0 ${usedEnd}%, #8f96a3 ${usedEnd}% ${cacheEnd}%, #4ade80 ${cacheEnd}% 100%)`, - }; + return { background: `conic-gradient(#ef5350 0 ${usedEnd}%, #8f96a3 ${usedEnd}% ${cacheEnd}%, #4ade80 ${cacheEnd}% 100%)` }; }); const memoryStatItems = computed(() => [ @@ -476,13 +413,10 @@ const memoryStatItems = computed(() => [ const diskUsageDisplay = computed(() => { const data = currentServerStatus.value; - if (!data || data.diskUsed === undefined || data.diskTotal === undefined) { - return t('statusMonitor.notAvailable'); - } + if (!data || data.diskUsed === undefined || data.diskTotal === undefined) return t('statusMonitor.notAvailable'); return `${formatStorageSizeFromKb(data.diskUsed, true)} / ${formatStorageSizeFromKb(data.diskTotal, true)}`; }); -const diskDeviceDisplay = computed(() => currentServerStatus.value?.diskDevice || t('statusMonitor.notAvailable')); const diskFsTypeDisplay = computed(() => currentServerStatus.value?.diskFsType || t('statusMonitor.notAvailable')); const diskReadRateDisplay = computed(() => formatCompactBytes(currentServerStatus.value?.diskReadRate)); const diskWriteRateDisplay = computed(() => formatCompactBytes(currentServerStatus.value?.diskWriteRate)); @@ -493,15 +427,9 @@ const diskPercentDisplay = computed(() => `${Math.round(displayDiskPercent.value const sessionIpAddress = computed(() => { const sessionState = currentSessionState.value; - if (!sessionState?.connectionId) { - return null; - } - + if (!sessionState?.connectionId) return null; const connectionIdAsNumber = parseInt(sessionState.connectionId, 10); - if (isNaN(connectionIdAsNumber)) { - return null; - } - + if (isNaN(connectionIdAsNumber)) return null; const connectionInfo = connectionsStore.connections.find(conn => conn.id === connectionIdAsNumber); return connectionInfo?.host || null; }); @@ -509,88 +437,30 @@ const sessionIpAddress = computed(() => { const uptimeDisplay = computed(() => formatUptime(currentServerStatus.value?.uptimeSeconds)); const topProcessPreview = computed(() => currentServerStatus.value?.topProcesses ?? []); -const systemCardMetaItems = computed(() => [ - { key: 'timezone', label: t('statusMonitor.timezoneLabel'), value: timezoneDisplay.value }, - { key: 'uptime', label: t('statusMonitor.uptimeLabel'), value: uptimeDisplay.value }, -]); - const cpuCoreItems = computed(() => { const rawPercents = currentServerStatus.value?.cpuCorePercents; const fallbackCoreCount = (() => { const currentCores = currentServerStatus.value?.cpuCores; - if (typeof currentCores !== 'number' || !Number.isFinite(currentCores) || currentCores <= 0) { - return 0; - } + if (typeof currentCores !== 'number' || !Number.isFinite(currentCores) || currentCores <= 0) return 0; return Math.round(currentCores); })(); - const normalizedPercents = Array.isArray(rawPercents) && rawPercents.length > 0 ? rawPercents : Array.from({ length: fallbackCoreCount }, () => 0); - return normalizedPercents.map((percent, index) => { + return normalizedPercents.map((percent, idx) => { const clampedPercent = clampPercent(percent); return { - key: `cpu-core-${index + 1}`, - label: t('statusMonitor.cpuCoreLabel', { index: index + 1 }), + key: `cpu-core-${idx}`, + index: idx, + label: t('statusMonitor.cpuCoreLabel', { index: idx + 1 }), value: `${Math.round(clampedPercent)}%`, percent: clampedPercent, }; }); }); -const cpuAveragePercent = computed(() => { - if (cpuCoreItems.value.length === 0) { - return displayCpuPercent.value; - } - - const total = cpuCoreItems.value.reduce((sum, item) => sum + item.percent, 0); - return total / cpuCoreItems.value.length; -}); - -const cpuBusiestCore = computed(() => { - if (cpuCoreItems.value.length === 0) { - return null; - } - - return cpuCoreItems.value.reduce((highest, item) => (item.percent > highest.percent ? item : highest)); -}); - -const cpuSummaryItems = computed(() => { - const items = [ - { - key: 'current', - label: t('statusMonitor.cpuCurrentStat'), - value: `${displayCpuPercent.value.toFixed(1)}%`, - }, - ]; - - if (cpuBusiestCore.value) { - items.push({ - key: 'busiest', - label: t('statusMonitor.cpuBusiestCoreStat'), - value: `${cpuBusiestCore.value.label} / ${cpuBusiestCore.value.value}`, - }); - } - - items.push({ - key: 'average', - label: t('statusMonitor.cpuAverageStat'), - value: `${cpuAveragePercent.value.toFixed(1)}%`, - }); - - return items; -}); - const networkFlowItems = computed(() => [ - { - key: 'download', - label: t('statusMonitor.downloadLabel'), - value: formatBytesPerSecond(currentServerStatus.value?.netRxRate), - totalValue: formatBytes(currentServerStatus.value?.netRxTotalBytes), - tone: 'down', - icon: 'fa-arrow-down', - }, { key: 'upload', label: t('statusMonitor.uploadLabel'), @@ -599,6 +469,14 @@ const networkFlowItems = computed(() => [ tone: 'up', icon: 'fa-arrow-up', }, + { + key: 'download', + label: t('statusMonitor.downloadLabel'), + value: formatBytesPerSecond(currentServerStatus.value?.netRxRate), + totalValue: formatBytes(currentServerStatus.value?.netRxTotalBytes), + tone: 'down', + icon: 'fa-arrow-down', + }, ]); const networkRateUnitLabel = computed(() => { @@ -606,23 +484,6 @@ const networkRateUnitLabel = computed(() => { return maxRate >= 1024 * 1024 ? 'MB/s' : 'KB/s'; }); -const diskDeviceAccent = computed(() => { - const raw = currentServerStatus.value?.diskDevice; - - if (!raw) { - return 'N/A'; - } - - const normalized = raw.replace(/^\/dev\//, '').split('/').pop() || raw; - return normalized.toUpperCase(); -}); - -const processSummaryItems = computed(() => [ - { key: 'total', label: t('statusMonitor.processManager.total'), value: String(processTotalDisplay.value) }, - { key: 'running', label: t('statusMonitor.processManager.running'), value: String(processRunningDisplay.value) }, - { key: 'sleeping', label: t('statusMonitor.processManager.sleeping'), value: String(processSleepingDisplay.value) }, -]); - const processPreviewItems = computed(() => topProcessPreview.value.slice(0, 4)); const copyIpToClipboard = async (ipAddress: string | null) => { @@ -641,24 +502,20 @@ const copyIpToClipboard = async (ipAddress: string | null) => { .status-monitor { height: 100%; overflow-y: auto; - padding: 14px; - background: - radial-gradient(circle at top right, rgba(52, 211, 153, 0.08), transparent 28%), - radial-gradient(circle at bottom left, rgba(59, 130, 246, 0.08), transparent 24%), - linear-gradient(180deg, rgba(16, 21, 27, 0.98), rgba(13, 17, 23, 0.98)); - color: #ecf3f7; + padding: 10px; + background: linear-gradient(180deg, #0f1419 0%, #0b1015 100%); + color: #e2e8f0; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif; } .status-monitor--inactive { - background: - radial-gradient(circle at top right, rgba(148, 163, 184, 0.08), transparent 28%), - linear-gradient(180deg, rgba(17, 24, 39, 0.96), rgba(15, 23, 42, 0.96)); + background: linear-gradient(180deg, #111827, #0f172a); } -.status-monitor-shell { - display: grid; - gap: 12px; - container-type: inline-size; +.sm-shell { + display: flex; + flex-direction: column; + gap: 2px; } .status-state { @@ -669,971 +526,522 @@ const copyIpToClipboard = async (ipAddress: string | null) => { justify-content: center; gap: 10px; text-align: center; - color: var(--text-secondary-color, #9ca3af); + color: #9ca3af; } -.status-state--error { - color: #f87171; +.status-state--error { color: #f87171; } +.status-state__icon { font-size: 28px; } +.status-state__title { font-size: 14px; font-weight: 600; } + +/* ── Header ── */ +.sm-header { + padding: 10px 12px; + border-bottom: 1px solid rgba(148, 163, 184, 0.08); } -.status-state__icon { - font-size: 28px; +.sm-header__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; } -.status-state__title { - font-size: 14px; +.sm-header__left { + display: flex; + align-items: center; + gap: 6px; +} + +.sm-header__icon { + color: #64748b; + font-size: 12px; +} + +.sm-header__label { + color: #94a3b8; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; +} + +.sm-header__right { + display: flex; + align-items: center; + gap: 8px; +} + +.sm-chip { + padding: 2px 8px; + border-radius: 4px; + background: rgba(148, 163, 184, 0.1); + color: #94a3b8; + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + border: none; +} + +.sm-chip--interactive { + cursor: pointer; + transition: color 0.15s; +} + +.sm-chip--interactive:hover { color: #e2e8f0; } + +.sm-live-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #4ade80; + box-shadow: 0 0 6px rgba(74, 222, 128, 0.6); + flex-shrink: 0; +} + +.sm-header__tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.sm-tag { + display: inline-block; + padding: 3px 8px; + border-radius: 4px; + border: 1px solid rgba(148, 163, 184, 0.12); + background: rgba(148, 163, 184, 0.06); + color: #cbd5e1; + font-size: 12px; + font-weight: 500; +} + +.sm-header__meta { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin-top: 8px; + color: #64748b; + font-size: 11px; +} + +.sm-meta { + white-space: nowrap; +} + +/* ── Section (shared) ── */ +.sm-section { + padding: 10px 12px; + border-bottom: 1px solid rgba(148, 163, 184, 0.08); +} + +.sm-section:last-child { border-bottom: none; } + +.sm-section__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.sm-section__title-row { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.sm-section__icon { + color: #64748b; + font-size: 11px; +} + +.sm-section__title { + color: #e2e8f0; + font-size: 13px; font-weight: 600; } -.monitor-header, -.monitor-module, -.status-monitor__charts { - border: 1px solid rgba(148, 163, 184, 0.12); - background: - linear-gradient(180deg, rgba(19, 26, 33, 0.96), rgba(11, 16, 22, 0.96)), - linear-gradient(90deg, rgba(52, 211, 153, 0.04), transparent 35%); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.05), - 0 16px 30px rgba(0, 0, 0, 0.24); +.sm-badge { + padding: 2px 8px; + border-radius: 4px; + background: rgba(148, 163, 184, 0.1); + color: #94a3b8; + font-size: 11px; + font-weight: 600; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + white-space: nowrap; } -.monitor-header { - display: grid; - gap: 12px; - border-radius: 20px; - padding: 14px; -} - -.monitor-header__top { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.monitor-header__identity { +.sm-mini-chart { + flex: 1; min-width: 0; + max-width: 160px; + height: 28px; + overflow: hidden; } -.monitor-header__eyebrow, -.monitor-module__eyebrow { - display: inline-block; - color: #7dd3a5; - font-size: 10px; - font-weight: 800; - letter-spacing: 0.18em; - text-transform: uppercase; -} - -.monitor-header__title { - margin: 10px 0 0; - font-size: 18px; - font-weight: 800; - line-height: 1.15; -} - -.monitor-header__chip-row { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.monitor-header__subtitle { - margin: 0; - color: #8fa0b3; - font-size: 12px; - line-height: 1.4; -} - -.monitor-header__badge, -.monitor-module__pill, -.monitor-chip { +.sm-link-btn { display: inline-flex; align-items: center; - gap: 6px; - min-height: 28px; - border-radius: 999px; - padding: 0 10px; + gap: 5px; + padding: 0; + margin-top: 6px; + border: none; + background: none; + color: #64748b; font-size: 11px; - font-weight: 700; - line-height: 1.1; -} - -.monitor-header__badge, -.monitor-module__pill { - border: 1px solid rgba(74, 222, 128, 0.18); - background: rgba(26, 92, 62, 0.34); - color: #d7ffe6; -} - -.monitor-header__badge--subtle { - border-color: rgba(96, 165, 250, 0.16); - background: rgba(30, 64, 175, 0.14); - color: #dbeafe; -} - -.monitor-chip { - border: 1px solid rgba(96, 165, 250, 0.18); - background: rgba(30, 64, 175, 0.2); - color: #dbeafe; -} - -.monitor-chip--interactive, -.monitor-action-button { cursor: pointer; - transition: transform 0.2s ease, border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease; + transition: color 0.15s; } -.monitor-chip--interactive:hover, -.monitor-action-button:hover { - transform: translateY(-1px); -} +.sm-link-btn:hover { color: #94a3b8; } -.monitor-chip--interactive:hover { - border-color: rgba(96, 165, 250, 0.38); - color: #ffffff; -} +.sm-link-btn--icon i { font-size: 10px; } -.monitor-chip__label { - color: #bfdbfe; - font-size: 10px; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.monitor-chip__value { - max-width: 180px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.monitor-header__status { +/* ── CPU ── */ +.sm-cpu-cores { display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 8px; + flex-direction: column; + gap: 3px; } -.monitor-live-pill { - display: inline-flex; +.sm-cpu-core { + display: flex; align-items: center; gap: 6px; - min-height: 28px; - border-radius: 999px; - border: 1px solid rgba(74, 222, 128, 0.18); - background: rgba(15, 118, 110, 0.16); - padding: 0 10px; - color: #dcfce7; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.14em; } -.monitor-live-pill__dot { - width: 8px; - height: 8px; - border-radius: 999px; - background: #4ade80; - box-shadow: 0 0 10px rgba(74, 222, 128, 0.7); +.sm-cpu-core__index { + width: 12px; + color: #64748b; + font-size: 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + text-align: right; + flex-shrink: 0; } -.monitor-header__meta-line { - display: flex; - flex-wrap: wrap; - gap: 18px; - padding-top: 10px; - border-top: 1px solid rgba(148, 163, 184, 0.1); -} - -.monitor-header__meta-item { - display: inline-flex; - align-items: baseline; - gap: 8px; - min-width: 0; -} - -.monitor-header__meta-label, -.usage-lane__helper, -.memory-stat__label, -.network-table__header, -.network-table__columns, -.disk-io-card__label, -.disk-summary-table__head, -.process-summary-item__label { - color: #8ea0b1; - font-size: 11px; -} - -.monitor-header__meta-label { - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.monitor-header__meta-value { - overflow: hidden; - color: #f7fbff; - font-size: 13px; - font-weight: 700; - line-height: 1.35; - text-overflow: ellipsis; -} - -.monitor-module-grid { - display: grid; - gap: 10px; - grid-template-columns: 1fr; -} - -.monitor-module { - display: grid; - gap: 10px; - min-width: 0; - border-radius: 18px; - padding: 12px; - container-type: inline-size; -} - -.monitor-module--usage, -.monitor-module--memory, -.monitor-module--network, -.monitor-module--disk { - min-height: clamp(188px, 62cqw, 220px); -} - -.monitor-module--usage { - grid-template-rows: auto minmax(0, 1fr); - max-height: 320px; - gap: 8px; +.sm-cpu-core__bar { + flex: 1; + height: 10px; + border-radius: 2px; + background: rgba(148, 163, 184, 0.08); overflow: hidden; } -.monitor-module--process { - min-height: clamp(340px, 116cqw, 420px); +.sm-cpu-core__fill { + height: 100%; + border-radius: 2px; + transition: width 0.4s ease; + min-width: 1px; } -.monitor-module__heading { +.sm-cpu-core__fill--normal { background: #22c55e; } +.sm-cpu-core__fill--warn { background: #f59e0b; } +.sm-cpu-core__fill--critical { background: #ef4444; } + +.sm-cpu-core__val { + width: 36px; + color: #cbd5e1; + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + text-align: right; + flex-shrink: 0; +} + +/* ── Memory ── */ +.sm-memory-row { display: flex; - align-items: flex-start; - justify-content: space-between; + align-items: center; gap: 12px; } -.monitor-module__heading-actions { - display: inline-flex; - flex-wrap: wrap; - justify-content: flex-end; - align-items: flex-start; - gap: 8px; -} - -.process-summary-pills { - display: inline-flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 8px; -} - -.monitor-module__title { - margin: 6px 0 0; - color: #f8fbff; - font-size: 15px; - font-weight: 800; - line-height: 1.3; -} - -.memory-stat-stack, -.network-stat-stack, -.disk-stat-stack, -.process-preview-list { - display: grid; - gap: 8px; -} - -.usage-lane { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 8px; - align-items: center; - border-radius: 14px; - border: 1px solid rgba(148, 163, 184, 0.08); - background: rgba(255, 255, 255, 0.03); - padding: 10px 10px 8px; -} - -.usage-lane__index, -.usage-lane__value, -.memory-stat__value, -.process-summary-item__value { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; -} - -.usage-lane__index { - color: rgba(226, 232, 240, 0.52); - font-size: 18px; - font-weight: 800; - letter-spacing: 0.06em; -} - -.usage-lane__content { - display: grid; - gap: 8px; - min-width: 0; -} - -.usage-lane__meta { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - min-width: 0; -} - -.usage-lane__label, -.network-stat__label, -.process-preview-item__rail { - color: #d9e5f1; - font-size: 12px; - font-weight: 700; -} - -.usage-lane__helper { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.usage-lane__value-inline { - color: #f8fbff; - font-size: 17px; - font-weight: 800; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; -} - -.usage-lane__track, -.disk-device-card__icon { +.sm-memory-ring { position: relative; - overflow: hidden; - border-radius: 12px; - background: rgba(51, 65, 85, 0.6); + width: 52px; + height: 52px; + border-radius: 50%; + flex-shrink: 0; } -.usage-lane__track { - height: 8px; - border-radius: 999px; -} - -.usage-lane__fill, -.disk-device-card__icon-fill { - position: absolute; - inset: 0 auto 0 0; - border-radius: inherit; -} - -.cpu-summary-panel { - display: grid; - gap: 10px; - min-height: 0; - 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.04), transparent 60%); - padding: 10px; - overflow: hidden; -} - -.cpu-summary-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.cpu-summary-card:last-child:nth-child(odd) { - grid-column: 1 / -1; -} - -.cpu-summary-card { - display: grid; - gap: 6px; - min-width: 0; - border-radius: 12px; - border: 1px solid rgba(148, 163, 184, 0.08); - background: rgba(255, 255, 255, 0.03); - padding: 10px; -} - -.cpu-summary-card__label { - color: #8ea0b1; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.cpu-summary-card__value { - color: #f8fbff; - font-size: 14px; - font-weight: 800; - line-height: 1.35; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; -} - -.cpu-summary-action { - width: 100%; -} - -.module-split { - display: grid; - gap: 12px; -} - -.module-split--memory { - grid-template-columns: minmax(110px, 0.88fr) minmax(0, 1.12fr); - align-items: stretch; -} - -.module-split--cpu { - grid-template-columns: 1fr; - grid-template-rows: minmax(0, 126px) auto; - min-height: 0; - align-content: start; - gap: 8px; - overflow: hidden; -} - -.module-split--network { - grid-template-columns: 1fr; - grid-template-rows: auto minmax(0, 1fr); - min-height: 0; - align-content: start; - gap: 8px; - overflow: hidden; -} - -.monitor-module--network { - grid-template-rows: auto minmax(0, 1fr); - height: 316px; - min-height: 316px; - max-height: 316px; - gap: 8px; - overflow: hidden; -} - -.memory-ring-panel, -.disk-device-card, -.disk-io-card, -.network-table { - 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(52, 211, 153, 0.04), transparent 58%); - padding: 12px; -} - -.memory-ring-panel { - display: grid; - justify-items: center; - align-content: center; - gap: 6px; - padding: 10px; -} - -.memory-ring { - position: relative; - width: 76px; - height: 76px; - border-radius: 999px; - padding: 3px; -} - -.memory-ring::after { +.sm-memory-ring::after { content: ''; position: absolute; - inset: 13px; - border-radius: 999px; - background: rgba(9, 14, 20, 0.96); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); + inset: 9px; + border-radius: 50%; + background: #0f1419; } -.memory-ring__center { +.sm-memory-ring__center { position: absolute; inset: 0; z-index: 1; display: flex; align-items: center; justify-content: center; - color: #f8fbff; - font-size: 13px; - font-weight: 800; -} - -.memory-ring-panel__caption { - color: #9cb0c2; - font-size: 10px; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.memory-stat, -.network-stat, -.process-summary-item, -.process-preview-item { - min-width: 0; - border-radius: 14px; - border: 1px solid rgba(148, 163, 184, 0.08); - background: rgba(255, 255, 255, 0.03); -} - -.memory-stat { - padding: 8px 9px; - border-radius: 10px; -} - -.memory-stat-stack { - grid-template-columns: repeat(3, minmax(0, 1fr)); - align-content: center; - gap: 6px; -} - -.memory-stat__label { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 10px; -} - -.memory-stat__dot { - width: 8px; - height: 8px; - flex: 0 0 auto; - border-radius: 999px; -} - -.memory-stat__dot--used { - background: #ef5350; -} - -.memory-stat__dot--cached { - background: #9ca3af; -} - -.memory-stat__dot--free { - background: #4ade80; -} - -.memory-stat__value, -.disk-summary-table__row span, -.disk-io-card__value { - display: block; - margin-top: 4px; - overflow-wrap: anywhere; - color: #f8fbff; - font-size: 14px; - font-weight: 800; - line-height: 1.15; -} - -.network-table { - display: grid; - grid-template-rows: auto auto minmax(0, 1fr); - gap: 3px; - min-height: 0; - height: 100%; - padding: 7px 9px; - overflow: hidden; -} - -.disk-summary-table__head, -.disk-summary-table__row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.network-table__header { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px; - padding-bottom: 3px; - border-bottom: 1px solid rgba(148, 163, 184, 0.1); + color: #f8fafc; font-size: 11px; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; -} - -.network-table__columns { - display: grid; - grid-template-columns: minmax(0, 0.78fr) repeat(2, minmax(0, 0.61fr)); - align-items: center; - gap: 6px; - padding-top: 0; - color: #9cb0c2; - font-size: 9px; font-weight: 700; } -.network-stat-stack { - min-height: 0; - align-content: start; - gap: 3px; - overflow-y: auto; - padding-right: 1px; -} - -.network-table__columns span, -.network-stat span, -.disk-summary-table__head span, -.disk-summary-table__row span { +.sm-memory-stats { + display: flex; + gap: 12px; + flex: 1; min-width: 0; } -.network-table__header span:first-child, -.network-table__columns span:first-child, -.network-stat__label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.network-table__header span:last-child, -.network-table__columns span:not(:first-child), -.network-stat__value, -.network-stat__total { - justify-self: end; - text-align: right; -} - -.network-stat { - display: grid; - grid-template-columns: minmax(0, 0.78fr) repeat(2, minmax(0, 0.61fr)); - align-items: center; - gap: 6px; - border-radius: 10px; - border: 1px solid rgba(148, 163, 184, 0.06); - background: rgba(255, 255, 255, 0.03); - padding: 5px 7px; -} - -.network-stat__label { - display: inline-flex; +.sm-memory-stat { + display: flex; align-items: center; gap: 5px; - color: #d9e5f1; + white-space: nowrap; +} + +.sm-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.sm-dot--used { background: #ef5350; } +.sm-dot--cached { background: #9ca3af; } +.sm-dot--free { background: #4ade80; } + +.sm-memory-stat__label { + color: #94a3b8; + font-size: 10px; +} + +.sm-memory-stat__value { + color: #e2e8f0; font-size: 11px; + font-weight: 700; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } -.network-stat__label i { - width: 12px; - text-align: center; -} - -.network-stat__value, -.network-stat__total { - color: #f8fbff; - font-size: 12px; - font-weight: 800; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; -} - -.network-stat--up .network-stat__label i { - color: #34d399; -} - -.network-stat--down .network-stat__label i { - color: #3b82f6; -} - -.disk-compact-top { - display: grid; - grid-template-columns: minmax(108px, 0.92fr) repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.disk-device-card { - display: grid; - gap: 10px; - padding: 10px; -} - -.disk-device-card__head { +/* ── Network ── */ +.sm-net-table { display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; + flex-direction: column; + gap: 2px; } -.disk-device-card__mount { - color: #d9e5f1; - font-size: 14px; - font-weight: 800; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; -} - -.disk-device-card__type { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 24px; - border-radius: 8px; - padding: 0 8px; - background: rgba(111, 76, 15, 0.32); - color: #facc15; - font-size: 11px; - font-weight: 800; -} - -.disk-device-card__body { - display: flex; - align-items: flex-end; - gap: 10px; -} - -.disk-device-card__icon { - flex: 0 0 24px; - width: 24px; - height: 54px; - border: 1px solid rgba(203, 213, 225, 0.22); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(226, 232, 240, 0.88)); -} - -.disk-device-card__icon-fill { - inset: auto 0 0; - background: linear-gradient(180deg, rgba(134, 239, 172, 0.95), rgba(34, 197, 94, 1)); -} - -.disk-device-card__device { - color: #9db0c1; - font-size: 12px; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - overflow-wrap: anywhere; -} - -.disk-io-card { +.sm-net-table__head { display: grid; - align-content: center; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px; - padding: 10px; + padding: 0 0 4px; + border-bottom: 1px solid rgba(148, 163, 184, 0.06); + color: #64748b; + font-size: 10px; } -.disk-io-card__value { - margin-top: 0; - font-size: 15px; -} +.sm-net-table__head span:not(:first-child) { text-align: right; } -.disk-summary-table { +.sm-net-row { display: grid; - gap: 8px; - border-radius: 12px; - border: 1px solid rgba(148, 163, 184, 0.08); - background: rgba(255, 255, 255, 0.025); - padding: 10px; -} - -.disk-summary-table__head { - padding-bottom: 8px; - border-bottom: 1px solid rgba(148, 163, 184, 0.1); - font-weight: 700; -} - -.disk-summary-table__row { - color: #f8fbff; - font-size: 14px; - font-weight: 700; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; -} - -.disk-summary-table__mount { - color: #86efac; -} - -.disk-summary-table__used { - justify-self: start; - display: inline-flex; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); + gap: 6px; + padding: 4px 0; align-items: center; - justify-content: center; - min-height: 24px; - padding: 0 8px; - border-radius: 8px; - border: 1px solid rgba(74, 222, 128, 0.2); - background: rgba(26, 92, 62, 0.34); - color: #d7ffe6; } -.monitor-action-button { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 32px; - border-radius: 10px; - border: 1px solid rgba(96, 165, 250, 0.22); - background: rgba(37, 99, 235, 0.18); - padding: 0 12px; - color: #dbeafe; - font-size: 12px; - font-weight: 700; -} - -.monitor-action-button:hover { - border-color: rgba(96, 165, 250, 0.42); - color: #ffffff; -} - -.process-preview-item { - display: grid; - gap: 8px; - padding: 10px 12px; -} - -.process-preview-item__rail { - display: flex; - flex-wrap: wrap; - gap: 10px; - color: #9fb0bf; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; -} - -.process-preview-item__main { +.sm-net-row__label { display: flex; align-items: center; - justify-content: space-between; - gap: 8px; + gap: 5px; + font-size: 11px; + font-weight: 600; + color: #cbd5e1; +} + +.sm-net-row__label i { width: 10px; text-align: center; font-size: 10px; } +.sm-net-row__label--up i { color: #34d399; } +.sm-net-row__label--down i { color: #60a5fa; } + +.sm-net-row__val, +.sm-net-row__total { + text-align: right; + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: #e2e8f0; + font-weight: 600; +} + +.sm-net-row__total { color: #94a3b8; font-weight: 400; } + +/* ── Process ── */ +.sm-proc-table { display: flex; flex-direction: column; gap: 0; } + +.sm-proc-head { + display: grid; + grid-template-columns: 60px 52px minmax(0, 1fr); + gap: 6px; + padding: 0 0 4px; + border-bottom: 1px solid rgba(148, 163, 184, 0.06); + color: #64748b; + font-size: 10px; + font-weight: 600; +} + +.sm-proc-row { + display: grid; + grid-template-columns: 60px 52px minmax(0, 1fr); + gap: 6px; + padding: 5px 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.04); + align-items: center; + font-size: 11px; +} + +.sm-proc-row:last-child { border-bottom: none; } + +.sm-proc-row__cpu { + color: #e2e8f0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 700; +} + +.sm-proc-row__cpu--hot { color: #f87171; } + +.sm-proc-row__mem { + color: #94a3b8; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.sm-proc-row__cmd { + display: flex; + flex-direction: column; min-width: 0; } -.process-preview-item__command { - min-width: 0; +.sm-proc-row__cmd-text { + color: #cbd5e1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: #f8fbff; - font-size: 13px; + font-size: 11px; } -.process-preview-item__stats { - display: inline-flex; - flex: 0 0 auto; - align-items: center; - gap: 10px; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +.sm-proc-row__pid { + color: #475569; + font-size: 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.sm-empty { + padding: 10px; + color: #64748b; font-size: 12px; - font-weight: 700; -} - -.process-preview-item__cpu { - color: #bfdbfe; -} - -.process-preview-item__mem { - color: #fde68a; -} - -.process-preview-empty { - border-radius: 12px; - border: 1px dashed rgba(148, 163, 184, 0.16); - padding: 14px; - color: #8fa0b3; - font-size: 13px; text-align: center; } -.status-monitor__charts { - overflow: hidden; - border-radius: 20px; +/* ── Disk ── */ +.sm-disk-device { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; } -@container (min-width: 520px) { - .monitor-header { - grid-template-columns: minmax(0, 1fr) auto; - align-items: start; - } - - .monitor-header__status { - justify-content: flex-end; - } - - .module-split--memory { - grid-template-columns: minmax(150px, 0.92fr) minmax(0, 1.08fr); - align-items: stretch; - } - - .disk-summary-table__head span, - .disk-summary-table__row span { - text-align: left; - } +.sm-disk-device__mount { + color: #e2e8f0; + font-size: 14px; + font-weight: 700; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } -@container (max-width: 250px) { - .module-split--memory, - .module-split--cpu, - .module-split--network, - .disk-compact-top { - grid-template-columns: 1fr; - } - - .memory-stat-stack { - grid-template-columns: 1fr; - } - - .cpu-summary-grid { - grid-template-columns: 1fr; - } - - .network-table__header, - .network-table__columns, - .network-stat, - .disk-summary-table__head, - .disk-summary-table__row, - .process-preview-item__main { - flex-direction: column; - align-items: flex-start; - } - - .network-table__columns span, - .network-stat span, - .disk-summary-table__head span, - .disk-summary-table__row span { - width: 100%; - flex: none; - } - - .monitor-module--network .network-table__header, - .monitor-module--network .network-table__columns, - .monitor-module--network .network-stat { - align-items: center; - } - - .monitor-module--network .network-table__columns span, - .monitor-module--network .network-stat span { - width: auto; - } +.sm-disk-device__type { + padding: 1px 6px; + border-radius: 3px; + background: rgba(148, 163, 184, 0.1); + color: #94a3b8; + font-size: 10px; + font-weight: 600; } -@container (max-width: 440px) { - .monitor-chip { - max-width: 100%; - } - - .monitor-chip__value, - .usage-lane__helper, - .disk-summary-table__row span { - white-space: normal; - } - +.sm-disk-io { + display: flex; + gap: 16px; + margin-bottom: 10px; } -@container (max-width: 360px) { - .process-summary-strip { - grid-template-columns: 1fr; - } +.sm-disk-io__item { + display: flex; + align-items: center; + gap: 8px; +} + +.sm-disk-io__icon { + width: 16px; + height: 24px; + border-radius: 2px; + background: rgba(148, 163, 184, 0.12); + flex-shrink: 0; +} + +.sm-disk-io__col { display: flex; flex-direction: column; } + +.sm-disk-io__label { + color: #64748b; + font-size: 10px; +} + +.sm-disk-io__val { + color: #e2e8f0; + font-size: 13px; + font-weight: 700; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.sm-disk-summary { + border-top: 1px solid rgba(148, 163, 184, 0.06); + padding-top: 8px; +} + +.sm-disk-summary__head { + display: grid; + grid-template-columns: minmax(0, 0.8fr) repeat(3, minmax(0, 1fr)); + gap: 6px; + padding-bottom: 4px; + color: #64748b; + font-size: 10px; + font-weight: 600; +} + +.sm-disk-summary__row { + display: grid; + grid-template-columns: minmax(0, 0.8fr) repeat(3, minmax(0, 1fr)); + gap: 6px; + color: #e2e8f0; + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 600; +} + +.sm-disk-summary__mount { color: #86efac; } + +.sm-disk-summary__pct { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 6px; + border-radius: 3px; + background: rgba(74, 222, 128, 0.12); + color: #86efac; + font-size: 10px; + width: fit-content; +} + +/* ── Responsive ── */ +@container (max-width: 220px) { + .sm-memory-row { flex-direction: column; align-items: flex-start; gap: 8px; } + .sm-memory-stats { flex-wrap: wrap; } + .sm-disk-io { flex-direction: column; gap: 8px; } } @media (max-width: 640px) { - .status-monitor { - padding: 12px; - } + .status-monitor { padding: 8px; } } diff --git a/packages/frontend/src/components/StatusMonitorCpuHistoryChart.vue b/packages/frontend/src/components/StatusMonitorCpuHistoryChart.vue index 67b5094..e5cf20f 100644 --- a/packages/frontend/src/components/StatusMonitorCpuHistoryChart.vue +++ b/packages/frontend/src/components/StatusMonitorCpuHistoryChart.vue @@ -1,6 +1,6 @@