From b660fc1f37311660f5fef3df535e24fd1fff2a75 Mon Sep 17 00:00:00 2001 From: yinjianm Date: Sun, 12 Apr 2026 23:05:41 +0800 Subject: [PATCH] chore(docs): archive quickcommands double-click tooltip implementation move quickcommands-double-click-tooltip records from plan to archive and mark status as completed. update changelog, archive index, and frontend module documentation to reflect the finalized interaction change and traceability metadata --- .helloagents/CHANGELOG.md | 6 + .../.status.json | 1 + .../proposal.md | 0 .../tasks.md | 2 + .../.status.json | 1 + .../proposal.md | 175 +++++++++ .../tasks.md | 60 +++ .helloagents/archive/_index.md | 4 + .helloagents/modules/backend.md | 5 + .helloagents/modules/frontend.md | 2 +- .../.status.json | 1 - packages/backend/src/tags/tag.repository.ts | 104 +++++- packages/backend/src/tags/tag.service.ts | 13 + packages/backend/src/tags/tags.controller.ts | 38 ++ packages/backend/src/tags/tags.routes.ts | 2 + .../components/ManageConnectionTagsModal.vue | 353 ++++++++++++++++++ packages/frontend/src/locales/en-US.json | 22 ++ packages/frontend/src/locales/ja-JP.json | 22 ++ packages/frontend/src/locales/zh-CN.json | 22 ++ packages/frontend/src/stores/tags.store.ts | 62 ++- .../frontend/src/views/ConnectionsView.vue | 50 +++ 21 files changed, 917 insertions(+), 28 deletions(-) create mode 100644 .helloagents/archive/2026-04/202604120709_quickcommands-double-click-tooltip/.status.json rename .helloagents/{plan => archive/2026-04}/202604120709_quickcommands-double-click-tooltip/proposal.md (100%) rename .helloagents/{plan => archive/2026-04}/202604120709_quickcommands-double-click-tooltip/tasks.md (97%) create mode 100644 .helloagents/archive/2026-04/202604122248_connections-tag-batch-management/.status.json create mode 100644 .helloagents/archive/2026-04/202604122248_connections-tag-batch-management/proposal.md create mode 100644 .helloagents/archive/2026-04/202604122248_connections-tag-batch-management/tasks.md delete mode 100644 .helloagents/plan/202604120709_quickcommands-double-click-tooltip/.status.json create mode 100644 packages/frontend/src/components/ManageConnectionTagsModal.vue diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index 960104e..237adad 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -11,6 +11,8 @@ - 2026-03-25:继续微调 `/workspace` Workbench,新增默认“快捷指令”标签、调整三栏宽度到更接近 xterminal 参考图,并修复终端区域鼠标悬停时指针异常消失的问题。 ### 修复 +- **[frontend]**: 将快捷命令列表项从单击立即执行改为单击选中、双击执行,并在 hover 时补充完整命令提示,降低误触执行概率 — by yinjianm + - 方案: [202604120709_quickcommands-double-click-tooltip](archive/2026-04/202604120709_quickcommands-double-click-tooltip/) - **[frontend]**: 为 SSH 服务器组头补充整组关闭按钮,并修正脚本模式对单/双引号包裹值的保存行为 — by yinjianm - 方案: [202604120656_ssh-group-close-and-script-input-sanitize](archive/2026-04/202604120656_ssh-group-close-and-script-input-sanitize/) - **[frontend]**: 将 `/workspace` Workbench 的导航从左侧竖排 icon rail 调整为 `Workbench` header 上方的横向纯图标栏,保留原有四面板切换逻辑与信息头部层级 — by yinjianm @@ -72,6 +74,10 @@ - 文件: packages/frontend/src/components/AddEditQuickCommandForm.vue:9,184-185,242-245 ### 新增 +- **[frontend]**: 为连接管理页顶部工具条新增“标签管理”弹窗,支持按标签搜索、多选删除,并在删除时选择“仅删标签归入未标记”或“连带删除命中服务器” — by yinjianm + - 方案: [202604122248_connections-tag-batch-management](archive/2026-04/202604122248_connections-tag-batch-management/) +- **[backend]**: 新增 `/api/v1/tags/bulk-delete` 批量标签删除接口,并用统一事务处理“删标签”与“删标签+删连接”两种策略 — by yinjianm + - 方案: [202604122248_connections-tag-batch-management](archive/2026-04/202604122248_connections-tag-batch-management/) - **[frontend]**: 在 `/workspace` 状态监控的 CPU 型号下方新增 CPU 核心数 badge,直接显示后端推送的服务器核数规格 — by yinjianm - 方案: [202604120656_server-status-cpu-core-display](archive/2026-04/202604120656_server-status-cpu-core-display/) - **[backend]**: 扩展 `StatusMonitorService` 的 CPU 规格采集链路,新增 `cpuCores` 字段并通过多级回退命令获取逻辑核心数 — by yinjianm diff --git a/.helloagents/archive/2026-04/202604120709_quickcommands-double-click-tooltip/.status.json b/.helloagents/archive/2026-04/202604120709_quickcommands-double-click-tooltip/.status.json new file mode 100644 index 0000000..021af47 --- /dev/null +++ b/.helloagents/archive/2026-04/202604120709_quickcommands-double-click-tooltip/.status.json @@ -0,0 +1 @@ +{"status":"completed","completed":4,"failed":0,"pending":0,"total":4,"done":4,"percent":100,"current":"快捷命令双击执行与完整命令 hover 标签已完成","updated_at":"2026-04-12 07:15:00"} diff --git a/.helloagents/plan/202604120709_quickcommands-double-click-tooltip/proposal.md b/.helloagents/archive/2026-04/202604120709_quickcommands-double-click-tooltip/proposal.md similarity index 100% rename from .helloagents/plan/202604120709_quickcommands-double-click-tooltip/proposal.md rename to .helloagents/archive/2026-04/202604120709_quickcommands-double-click-tooltip/proposal.md diff --git a/.helloagents/plan/202604120709_quickcommands-double-click-tooltip/tasks.md b/.helloagents/archive/2026-04/202604120709_quickcommands-double-click-tooltip/tasks.md similarity index 97% rename from .helloagents/plan/202604120709_quickcommands-double-click-tooltip/tasks.md rename to .helloagents/archive/2026-04/202604120709_quickcommands-double-click-tooltip/tasks.md index 869993c..4944317 100644 --- a/.helloagents/plan/202604120709_quickcommands-double-click-tooltip/tasks.md +++ b/.helloagents/archive/2026-04/202604120709_quickcommands-double-click-tooltip/tasks.md @@ -1,5 +1,7 @@ # 任务清单: quickcommands-double-click-tooltip +> **@status:** completed | 2026-04-12 07:22 + ```yaml @feature: quickcommands-double-click-tooltip @created: 2026-04-12 diff --git a/.helloagents/archive/2026-04/202604122248_connections-tag-batch-management/.status.json b/.helloagents/archive/2026-04/202604122248_connections-tag-batch-management/.status.json new file mode 100644 index 0000000..2b00012 --- /dev/null +++ b/.helloagents/archive/2026-04/202604122248_connections-tag-batch-management/.status.json @@ -0,0 +1 @@ +{"status":"completed","completed":6,"failed":0,"pending":0,"total":6,"done":6,"percent":100,"current":"连接页标签批量管理与后端批量删除策略已完成","updated_at":"2026-04-12 23:04:00"} diff --git a/.helloagents/archive/2026-04/202604122248_connections-tag-batch-management/proposal.md b/.helloagents/archive/2026-04/202604122248_connections-tag-batch-management/proposal.md new file mode 100644 index 0000000..94a60f7 --- /dev/null +++ b/.helloagents/archive/2026-04/202604122248_connections-tag-batch-management/proposal.md @@ -0,0 +1,175 @@ +# 变更提案: connections-tag-batch-management + +## 元信息 +```yaml +类型: 新功能 +方案类型: implementation +优先级: P1 +状态: 已确认 +创建: 2026-04-12 +``` + +--- + +## 1. 需求 + +### 背景 +当前 `/connections` 页面已经支持按标签树筛选连接,也有单标签关联管理能力,但缺少面向标签本身的集中批量管理入口。用户需要在连接页直接批量处理标签,尤其是批量删除标签,并在删除时决定标签下的连接是一起删除还是仅移除标签归入“未标记”。 + +### 目标 +- 在 `ConnectionsView.vue` 顶部工具条新增“标签管理”入口,使用弹窗承载标签批量操作。 +- 支持对多个标签进行勾选、多选、搜索和批量删除。 +- 删除标签时支持两种策略:一并删除关联服务器,或仅删除标签并将关联服务器归入“未标记”。 +- 删除完成后同步刷新连接列表、标签树、标签缓存与可见范围,避免前端状态残留。 + +### 约束条件 +```yaml +时间约束: 当前回合内完成前后端实现、验证与知识库同步 +性能约束: 标签删除操作需采用事务,避免多标签批量处理中出现部分删除导致的中间态 +兼容性约束: 保持现有 `/connections` 标签树筛选、连接批量编辑、单连接编辑和标签输入组件兼容 +业务约束: 未勾选“删除关联服务器”时,不得删除任何连接记录;仅移除被删标签关联 +``` + +### 验收标准 +- [ ] 用户可在连接页顶部打开“标签管理”弹窗,并对标签执行搜索、多选、全选、反选和批量删除。 +- [ ] 删除多个标签时,若选择“同时删除标签下所有服务器”,则所有命中连接被删除;若不选择,则这些连接保留且在 UI 中显示为“未标记”或保留其它剩余标签。 +- [ ] 标签删除后,左侧标签树、右侧连接列表以及 `tags.store`、`connections.store` 的缓存状态保持一致。 +- [ ] 后端批量删除标签接口具备事务性,并返回删除标签数、删除连接数、受影响连接数等摘要信息。 + +--- + +## 2. 方案 + +### 技术方案 +- 前端新增 `ManageConnectionTagsModal.vue`,由 `ConnectionsView.vue` 顶部工具条按钮打开。 +- 弹窗展示全部标签及其关联连接数,支持按标签名搜索、批量选择和删除前二次确认。 +- 删除动作通过扩展 `tags.store.ts` 调用新的后端批量删除接口,传递 `tag_ids` 与 `delete_connections` 策略。 +- 后端在 `tags.routes.ts` / `tags.controller.ts` / `tag.service.ts` / `tag.repository.ts` 中新增批量删除入口,统一在事务中完成: + - 统计受影响的连接与标签。 + - 若 `delete_connections=true`,删除这些标签命中的全部连接记录。 + - 若 `delete_connections=false`,仅删除标签记录,依赖 `connection_tags` 级联删除关联,保留连接主记录。 +- 删除成功后前端统一刷新 `connections`、`tags` 两份数据,并在当前选中范围失效时回退到 `all` 或 `untagged`。 + +### 影响范围 +```yaml +涉及模块: + - frontend: 连接页工具条、标签管理弹窗、标签 store 和多语言文案 + - backend: 标签批量删除接口、标签与连接事务处理逻辑 + - knowledge-base: 新方案包、模块文档、CHANGELOG 记录 +预计变更文件: 9-12 +``` + +### 风险评估 +| 风险 | 等级 | 应对 | +|------|------|------| +| 多标签删除时错误删除了不应删除的连接 | 高 | 在仓库层先精确查询受影响连接集合,并通过 `delete_connections` 显式分支处理 | +| 删除后前端仍停留在失效的标签 scope | 中 | 删除成功后刷新数据并校正 `selectedScope`,失效时回退到 `all` | +| 多语言文案缺失导致界面回退到默认文案或出现空白 | 中 | 同步补齐 `zh-CN`、`en-US`、`ja-JP` 新键 | + +--- + +## 3. 技术设计 + +### 架构设计 +```mermaid +flowchart TD + A[ConnectionsView 顶部按钮] --> B[ManageConnectionTagsModal] + B --> C[tags.store.deleteTagsBatch] + C --> D[POST /api/v1/tags/bulk-delete] + D --> E[tag.service.deleteTagsBatch] + E --> F[tag.repository.deleteTagsBatch 事务] + F --> G[(tags / connection_tags / connections)] + C --> H[connections.store.fetchConnections] + C --> I[tags.store.fetchTags] +``` + +### API设计 +#### POST `/api/v1/tags/bulk-delete` +- **请求**: +```json +{ + "tag_ids": [1, 2, 3], + "delete_connections": true +} +``` +- **响应**: +```json +{ + "message": "标签批量删除成功。", + "summary": { + "deleted_tag_ids": [1, 2, 3], + "deleted_tags_count": 3, + "affected_connection_ids": [10, 12, 18], + "affected_connections_count": 3, + "deleted_connections_count": 3, + "delete_connections": true + } +} +``` + +### 数据模型 +| 字段 | 类型 | 说明 | +|------|------|------| +| tag_ids | number[] | 需要删除的标签 ID 列表 | +| delete_connections | boolean | 是否同时删除命中这些标签的连接 | +| affected_connection_ids | number[] | 本次批量删除命中的连接 ID 汇总 | +| deleted_connections_count | number | 本次实际删除的连接数量 | + +--- + +## 4. 核心场景 + +> 执行完成后同步到对应模块文档 + +### 场景: 批量删除标签并保留服务器 +**模块**: frontend / backend +**条件**: 用户在连接页打开标签管理弹窗并选中一个或多个标签,且不勾选“删除关联服务器” +**行为**: 前端提交 `tag_ids + delete_connections=false`;后端删除标签记录并清理 `connection_tags` 关联 +**结果**: 关联连接保留,若没有其它标签则在连接页显示为“未标记” + +### 场景: 批量删除标签并同时删除服务器 +**模块**: frontend / backend +**条件**: 用户在连接页打开标签管理弹窗并选中多个标签,同时勾选“删除关联服务器” +**行为**: 后端先统计标签命中的唯一连接集合,再删除这些连接与对应标签 +**结果**: 标签与关联连接一并删除,连接列表和标签树同步收敛 + +--- + +## 5. 技术决策 + +> 本方案涉及的技术决策,归档后成为决策的唯一完整记录 + +### connections-tag-batch-management#D001: 使用连接页顶部弹窗承载批量标签管理 +**日期**: 2026-04-12 +**状态**: ✅采纳 +**背景**: 当前连接页已经拥有顶部工具条和左侧标签树。如果直接在标签树上塞入多选、批量删除与危险操作,会显著抬高误触风险并增加树节点交互复杂度。 +**选项分析**: +| 选项 | 优点 | 缺点 | +|------|------|------| +| A: 顶部按钮 + 专用管理弹窗 | 操作集中、支持多选、适合承载危险选项与统计信息、不会污染树节点主交互 | 需要新增一个模态组件 | +| B: 直接在左侧树中进入多选管理模式 | 用户离标签更近,切换范围快 | 树节点交互复杂,拖拽/展开/选择/危险操作容易冲突 | +**决策**: 选择方案 A +**理由**: 该方案最符合“批量处理标签”的操作心智,也最容易容纳“删除标签时是否连带删服务器”的高风险开关与确认信息。 +**影响**: 影响 `ConnectionsView.vue` 顶部操作区、标签 store、标签路由与事务删除逻辑 + +--- + +## 6. 成果设计 + +> 含视觉产出的任务由 DESIGN Phase2 填充。非视觉任务整节标注"N/A"。 + +### 设计方向 +- **美学基调**: 冷静控制台式管理面板,在现有连接页卡片与半透明层次基础上延续“运维台”氛围 +- **记忆点**: 选中标签后的危险动作条与删除策略提示被集中压缩在弹窗底部,风险感知明确 +- **参考**: 复用现有 `ConnectionsView.vue` 的圆角卡片、边框、工具条和批量操作条语言 + +### 视觉要素 +- **配色**: 延续项目现有主题变量;常规选择态使用 `primary`,危险开关与删除按钮使用 `error` / 红色强调 +- **字体**: 复用现有应用字体体系,不引入新字体 +- **布局**: 顶部搜索与统计,中部标签列表,底部集中展示批量选择统计、策略开关与确认按钮 +- **动效**: 仅保留现有 hover / border / opacity 过渡,不引入额外复杂动画 +- **氛围**: 保持现有半透明卡片和细边框风格,与连接页主界面统一 + +### 技术约束 +- **可访问性**: 危险按钮和危险开关需保留明确文本,不能只靠颜色表达删除语义 +- **响应式**: 弹窗在窄屏下需退化为单列列表与纵向底部操作区 diff --git a/.helloagents/archive/2026-04/202604122248_connections-tag-batch-management/tasks.md b/.helloagents/archive/2026-04/202604122248_connections-tag-batch-management/tasks.md new file mode 100644 index 0000000..9772404 --- /dev/null +++ b/.helloagents/archive/2026-04/202604122248_connections-tag-batch-management/tasks.md @@ -0,0 +1,60 @@ +# 任务清单: connections-tag-batch-management + +> **@status:** completed | 2026-04-12 23:03 + +```yaml +@feature: connections-tag-batch-management +@created: 2026-04-12 +@status: completed +@mode: R2 +``` + +## 进度概览 + +| 完成 | 失败 | 跳过 | 总数 | +|------|------|------|------| +| 6 | 0 | 0 | 6 | + +--- + +## 任务列表 + +### 1. 方案与数据流确认 + +- [√] 1.1 创建并验证 `connections-tag-batch-management` 方案包,明确批量标签删除策略与接口契约 | depends_on: [] + +### 2. 后端标签批量处理 + +- [√] 2.1 在 `packages/backend/src/tags/tag.repository.ts` 中实现批量删除标签事务,支持“仅删标签”与“标签+连接一起删”两种策略,并返回摘要 | depends_on: [1.1] +- [√] 2.2 在 `packages/backend/src/tags/tag.service.ts`、`packages/backend/src/tags/tags.controller.ts`、`packages/backend/src/tags/tags.routes.ts` 中暴露批量删除接口并补齐参数校验 | depends_on: [2.1] + +### 3. 前端连接页标签管理 + +- [√] 3.1 新增连接页标签管理弹窗组件,在 `packages/frontend/src/views/ConnectionsView.vue` 接入顶部入口与删除后范围刷新逻辑 | depends_on: [1.1] +- [√] 3.2 扩展 `packages/frontend/src/stores/tags.store.ts`,支持批量删除标签并联动刷新标签/连接缓存 | depends_on: [2.2, 3.1] +- [√] 3.3 补齐 `packages/frontend/src/locales/zh-CN.json`、`packages/frontend/src/locales/en-US.json`、`packages/frontend/src/locales/ja-JP.json` 的新文案 | depends_on: [3.1] + +### 4. 验证与知识库同步 + +- [√] 4.1 运行可用构建验证,修正编译问题并同步 `.helloagents` 文档/变更记录 | depends_on: [2.2, 3.2, 3.3] + +--- + +## 执行日志 + +| 时间 | 任务 | 状态 | 备注 | +|------|------|------|------| +| 2026-04-12 22:48 | 1.1 | 完成 | 已创建 implementation 方案包,并通过 `validate_package.py` 校验 | +| 2026-04-12 22:52 | 2.1 / 2.2 | 完成 | 后端新增 `/api/v1/tags/bulk-delete`,并将单标签删除复用到同一事务逻辑 | +| 2026-04-12 22:55 | 3.1 / 3.2 / 3.3 | 完成 | 连接页已接入标签管理弹窗、批量删除策略开关与中英日文案 | +| 2026-04-12 22:58 | 4.1 | 完成 | `npm --prefix packages/backend run build` 与 `npm --prefix packages/frontend run build` 通过;本地浏览器预览因后端未启动仅完成挂载检查 | + +--- + +## 执行备注 + +> 记录执行过程中的重要说明、决策变更、风险提示等 + +- 本轮优先覆盖用户明确提出的批量标签处理与删除策略,不额外扩展标签重命名批量能力。 +- “删除关联服务器”按标签命中的唯一连接集合执行,避免多标签重叠时重复删除。 +- Playwright 仅完成本地前端预览挂载检查;由于 `vite preview` 代理的 `/api/v1/*` 在当前环境下连接被拒绝,未能继续验证登录后的 `/connections` 实际交互。 diff --git a/.helloagents/archive/_index.md b/.helloagents/archive/_index.md index 07e66ea..0c4d1e4 100644 --- a/.helloagents/archive/_index.md +++ b/.helloagents/archive/_index.md @@ -7,6 +7,8 @@ | 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 | |--------|------|------|---------|------|------| +| 202604122248 | connections-tag-batch-management | implementation | frontend, backend | connections-tag-batch-management#D001 | ✅完成 | +| 202604120709 | quickcommands-double-click-tooltip | implementation | frontend | quickcommands-double-click-tooltip#D001 | ✅完成 | | 202604120705 | terminal-scroll-viewport-restore-fix | - | - | - | ✅完成 | | 202604120656 | ssh-group-close-and-script-input-sanitize | implementation | frontend | ssh-group-close-and-script-input-sanitize#D001 | ✅完成 | | 202604120656 | server-status-cpu-core-display | - | - | - | ✅完成 | @@ -48,6 +50,8 @@ ## 按月归档 ### 2026-04 +- [202604122248_connections-tag-batch-management](./2026-04/202604122248_connections-tag-batch-management/) - 为连接管理页新增标签批量管理弹窗,并补齐后端批量标签删除策略 +- [202604120709_quickcommands-double-click-tooltip](./2026-04/202604120709_quickcommands-double-click-tooltip/) - 将快捷命令列表改为单击选中、双击执行,并在 hover 时显示完整命令 - [202604120656_ssh-group-close-and-script-input-sanitize](./2026-04/202604120656_ssh-group-close-and-script-input-sanitize/) - 为 SSH 服务器组头补充整组关闭按钮,并修正脚本模式对单/双引号包裹值的保存行为 ### 2026-03 diff --git a/.helloagents/modules/backend.md b/.helloagents/modules/backend.md index 7b39788..397eae8 100644 --- a/.helloagents/modules/backend.md +++ b/.helloagents/modules/backend.md @@ -54,6 +54,11 @@ **行为**: 后端新增 `packages/backend/src/login-credentials/` 业务域和 `login_credentials` 表,通过 `/api/v1/login-credentials` 提供登录凭证列表、创建、编辑、删除和详情读取接口;`connections` 表新增 `login_credential_id` 外键,连接创建、更新和未保存测试时都可以引用已保存凭证;运行时凭证解析则优先读取 `login_credentials` 的用户名、认证方式和加密凭证,再回退到连接自身保存的直填字段,因此编辑登录凭证后,引用它的连接在测试和实际连接时会自动使用新配置。 **结果**: 连接管理支持“直填凭证”和“引用已保存凭证”双轨并存,旧连接保持兼容,后续如需扩展凭证审计、共享或筛选能力,也有了独立数据模型承接。 +### 标签批量删除 +**条件**: 前端连接管理页对一个或多个标签执行批量删除,并指定是否同时删除标签命中的连接。 +**行为**: `packages/backend/src/tags/` 当前新增 `POST /api/v1/tags/bulk-delete`;控制器校验 `tag_ids` 与 `delete_connections` 后,将请求交给 `tag.service.ts` 与 `tag.repository.ts`。仓库层会在单个事务内先汇总命中的唯一连接集合,再按策略执行“仅删标签”或“先删连接、再删标签”,同时返回删除标签数、受影响连接数和实际删除连接数摘要;原有单标签删除也已复用同一底层批量删除事务。 +**结果**: 标签删除语义在后端被统一收口,前端可以安全支持“删除标签但保留服务器归入未标记”和“删除标签同时删除服务器”两种操作,而不会留下中间态关联数据。 + ### 外观默认值 **条件**: 数据库初始化、外观设置重置或前后端默认主题定义调整。 **行为**: `appearance.repository.ts` 负责写入默认 UI 外观设置,`config/default-themes.ts` 保持与前端同名默认主题定义一致,作为默认外观与终端主题的镜像基线;当前默认外观中终端文字描边和阴影开关默认开启,但仅作为“无保存值时”的回退,不主动覆盖数据库里已有用户配置。 diff --git a/.helloagents/modules/frontend.md b/.helloagents/modules/frontend.md index 7f5bf26..42c8e10 100644 --- a/.helloagents/modules/frontend.md +++ b/.helloagents/modules/frontend.md @@ -36,7 +36,7 @@ ### 工作区交互 **条件**: 用户进入 `/workspace` 或相关管理页面。 -**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 继续整合快捷指令、命令历史、文件管理和编辑器四个面板,导航入口保持为纯图标按钮,但已调整为位于 `Workbench` 标题区上方的横向 icon rail,四个入口自左向右排列、默认仅显示图标并通过 tooltip 暴露名称,默认激活快捷指令。`CommandInputBar.vue` 当前已将底部命令框升级为支持会话级草稿保留的多行 `textarea`:普通 `Enter` 插入换行,`Ctrl+Shift+Enter` 发送当前命令,输入框会按内容自动增高至约 6 行,超出后在输入框内部滚动,并继续兼容快捷指令/命令历史同步与选中发送逻辑。应用根组件 `App.vue` 现在还新增了全局服务器快捷检索:已登录页面按下 `Ctrl+Shift+F` 会打开 `GlobalConnectionQuickSearch.vue`,通过 `utils/connectionSearch.ts` 对连接名称、主机、用户名和类型做本地模糊排序,并直接复用 `sessionStore.handleConnectRequest()` 触发 SSH 工作区跳转或 RDP / VNC 弹窗连接。快捷指令相关能力目前由 `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 色值;该视图当前还支持命令项右键菜单,并已修正为实底卡片式上下文菜单,提供立即执行、粘贴到命令输入框(不自动发送)、复制命令、发送到全部服务器、编辑和删除等动作。`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 工具按钮、资源管理器式分隔标题行与拖拽重排占位反馈;右侧结果列表则同时支持顶部排序控件、列头点击排序,并将行内操作整理为“连接”主按钮加“更多”菜单(编辑/测试/克隆/删除);`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 行,超出后在输入框内部滚动,并继续兼容快捷指令/命令历史同步与选中发送逻辑。应用根组件 `App.vue` 现在还新增了全局服务器快捷检索:已登录页面按下 `Ctrl+Shift+F` 会打开 `GlobalConnectionQuickSearch.vue`,通过 `utils/connectionSearch.ts` 对连接名称、主机、用户名和类型做本地模糊排序,并直接复用 `sessionStore.handleConnectRequest()` 触发 SSH 工作区跳转或 RDP / VNC 弹窗连接。快捷指令相关能力目前由 `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 工具按钮、资源管理器式分隔标题行与拖拽重排占位反馈;右侧结果列表则同时支持顶部排序控件、列头点击排序,并将行内操作整理为“连接”主按钮加“更多”菜单(编辑/测试/克隆/删除);连接页顶部工具条当前又补上了独立“标签管理”入口,打开 `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/202604120709_quickcommands-double-click-tooltip/.status.json b/.helloagents/plan/202604120709_quickcommands-double-click-tooltip/.status.json deleted file mode 100644 index 45f9432..0000000 --- a/.helloagents/plan/202604120709_quickcommands-double-click-tooltip/.status.json +++ /dev/null @@ -1 +0,0 @@ -{"status":"in_progress","completed":0,"failed":0,"pending":4,"total":4,"done":0,"percent":0,"current":"正在修改快捷命令列表交互并准备构建验证","updated_at":"2026-04-12 07:10:00"} diff --git a/packages/backend/src/tags/tag.repository.ts b/packages/backend/src/tags/tag.repository.ts index c41c79a..764f20e 100644 --- a/packages/backend/src/tags/tag.repository.ts +++ b/packages/backend/src/tags/tag.repository.ts @@ -10,6 +10,19 @@ export interface TagData { updated_at: number; } +export interface BatchDeleteTagsSummary { + deleted_tag_ids: number[]; + deleted_tags_count: number; + affected_connection_ids: number[]; + affected_connections_count: number; + deleted_connections_count: number; + delete_connections: boolean; +} + +const buildInClause = (count: number): string => { + return new Array(count).fill('?').join(', '); +}; + /** * 获取所有标签 */ @@ -84,14 +97,93 @@ export const updateTag = async (id: number, name: string): Promise => { * 删除标签 */ export const deleteTag = async (id: number): Promise => { - const sql = `DELETE FROM tags WHERE id = ?`; + const summary = await deleteTagsBatch([id], false); + return summary.deleted_tags_count > 0; +}; + +/** + * 批量删除标签,并根据策略可选地同时删除关联连接。 + */ +export const deleteTagsBatch = async (tagIds: number[], deleteConnections: boolean): Promise => { + const normalizedTagIds = Array.from(new Set(tagIds.filter((tagId) => Number.isInteger(tagId) && tagId > 0))); + if (normalizedTagIds.length === 0) { + return { + deleted_tag_ids: [], + deleted_tags_count: 0, + affected_connection_ids: [], + affected_connections_count: 0, + deleted_connections_count: 0, + delete_connections: Boolean(deleteConnections), + }; + } + + const db = await getDbInstance(); try { - const db = await getDbInstance(); - const result = await runDb(db, sql, [id]); - return result.changes > 0; + await runDb(db, 'BEGIN TRANSACTION'); + + const tagPlaceholders = buildInClause(normalizedTagIds.length); + const existingTags = await allDb<{ id: number }>( + db, + `SELECT id FROM tags WHERE id IN (${tagPlaceholders}) ORDER BY id ASC`, + normalizedTagIds, + ); + const existingTagIds = existingTags.map((row) => row.id); + + if (existingTagIds.length === 0) { + await runDb(db, 'COMMIT'); + return { + deleted_tag_ids: [], + deleted_tags_count: 0, + affected_connection_ids: [], + affected_connections_count: 0, + deleted_connections_count: 0, + delete_connections: Boolean(deleteConnections), + }; + } + + const existingTagPlaceholders = buildInClause(existingTagIds.length); + const affectedConnections = await allDb<{ connection_id: number }>( + db, + `SELECT DISTINCT connection_id FROM connection_tags WHERE tag_id IN (${existingTagPlaceholders}) ORDER BY connection_id ASC`, + existingTagIds, + ); + const affectedConnectionIds = affectedConnections.map((row) => row.connection_id); + + let deletedConnectionsCount = 0; + if (deleteConnections && affectedConnectionIds.length > 0) { + const connectionPlaceholders = buildInClause(affectedConnectionIds.length); + const deleteConnectionsResult = await runDb( + db, + `DELETE FROM connections WHERE id IN (${connectionPlaceholders})`, + affectedConnectionIds, + ); + deletedConnectionsCount = deleteConnectionsResult.changes ?? 0; + } + + const deleteTagsResult = await runDb( + db, + `DELETE FROM tags WHERE id IN (${existingTagPlaceholders})`, + existingTagIds, + ); + + await runDb(db, 'COMMIT'); + + return { + deleted_tag_ids: existingTagIds, + deleted_tags_count: deleteTagsResult.changes ?? 0, + affected_connection_ids: affectedConnectionIds, + affected_connections_count: affectedConnectionIds.length, + deleted_connections_count: deletedConnectionsCount, + delete_connections: Boolean(deleteConnections), + }; } catch (err: any) { - console.error(`[仓库] 删除标签 ${id} 时出错:`, err.message); - throw new Error('删除标签失败'); + try { + await runDb(db, 'ROLLBACK'); + } catch (rollbackError: any) { + console.error('[仓库] 批量删除标签回滚失败:', rollbackError.message); + } + console.error(`[仓库] 批量删除标签时出错:`, err.message); + throw new Error(`批量删除标签失败: ${err.message}`); } }; diff --git a/packages/backend/src/tags/tag.service.ts b/packages/backend/src/tags/tag.service.ts index 8c5ea7c..f71a13e 100644 --- a/packages/backend/src/tags/tag.service.ts +++ b/packages/backend/src/tags/tag.service.ts @@ -2,6 +2,7 @@ import * as TagRepository from '../tags/tag.repository'; // Re-export or define types export interface TagData extends TagRepository.TagData {} +export interface BatchDeleteTagsSummary extends TagRepository.BatchDeleteTagsSummary {} /** * 获取所有标签 @@ -76,6 +77,18 @@ export const deleteTag = async (id: number): Promise => { return TagRepository.deleteTag(id); }; +/** + * 批量删除标签,并根据策略决定是否删除关联连接。 + */ +export const deleteTagsBatch = async (tagIds: number[], deleteConnections: boolean): Promise => { + const normalizedTagIds = Array.from(new Set(tagIds.filter((tagId) => Number.isInteger(tagId) && tagId > 0))); + if (normalizedTagIds.length === 0) { + throw new Error('至少需要提供一个有效的标签 ID。'); + } + + return TagRepository.deleteTagsBatch(normalizedTagIds, Boolean(deleteConnections)); +}; + /** * 更新标签与连接的关联关系 */ diff --git a/packages/backend/src/tags/tags.controller.ts b/packages/backend/src/tags/tags.controller.ts index 3219e29..6ce6bbc 100644 --- a/packages/backend/src/tags/tags.controller.ts +++ b/packages/backend/src/tags/tags.controller.ts @@ -131,6 +131,44 @@ export const deleteTag = async (req: Request, res: Response): Promise => { } }; +/** + * 批量删除标签 (POST /api/v1/tags/bulk-delete) + */ +export const bulkDeleteTags = async (req: Request, res: Response): Promise => { + const { tag_ids, delete_connections } = req.body; + + if (!Array.isArray(tag_ids) || !tag_ids.every((id) => typeof id === 'number')) { + res.status(400).json({ message: 'tag_ids 必须是一个数字数组。' }); + return; + } + + if (delete_connections !== undefined && typeof delete_connections !== 'boolean') { + res.status(400).json({ message: 'delete_connections 必须是布尔值。' }); + return; + } + + try { + const summary = await TagService.deleteTagsBatch(tag_ids, Boolean(delete_connections)); + if (summary.deleted_tags_count === 0) { + res.status(404).json({ message: '未找到可删除的标签。' }); + return; + } + + auditLogService.logAction('TAG_DELETED', { + mode: 'batch', + ...summary, + }); + res.status(200).json({ message: '标签批量删除成功。', summary }); + } catch (error: any) { + console.error('Controller: 批量删除标签时发生错误:', error); + if (error.message.includes('至少需要提供')) { + res.status(400).json({ message: error.message }); + } else { + res.status(500).json({ message: error.message || '批量删除标签时发生内部服务器错误。' }); + } + } +}; + /** * 更新标签与连接的关联关系 (PUT /api/v1/tags/:id/connections) */ diff --git a/packages/backend/src/tags/tags.routes.ts b/packages/backend/src/tags/tags.routes.ts index c5839fc..d8aef1a 100644 --- a/packages/backend/src/tags/tags.routes.ts +++ b/packages/backend/src/tags/tags.routes.ts @@ -6,6 +6,7 @@ import { getTagById, updateTag, deleteTag, + bulkDeleteTags, updateTagConnections // +++ 导入新的控制器方法 +++ } from './tags.controller'; @@ -16,6 +17,7 @@ router.use(isAuthenticated); // 定义标签相关的路由 router.post('/', createTag); // POST /api/v1/tags - 创建新标签 +router.post('/bulk-delete', bulkDeleteTags); // POST /api/v1/tags/bulk-delete - 批量删除标签 router.get('/', getTags); // GET /api/v1/tags - 获取标签列表 router.get('/:id', getTagById); // GET /api/v1/tags/:id - 获取单个标签 router.put('/:id', updateTag); // PUT /api/v1/tags/:id - 更新标签 diff --git a/packages/frontend/src/components/ManageConnectionTagsModal.vue b/packages/frontend/src/components/ManageConnectionTagsModal.vue new file mode 100644 index 0000000..b68eb44 --- /dev/null +++ b/packages/frontend/src/components/ManageConnectionTagsModal.vue @@ -0,0 +1,353 @@ + + + diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index 41943a6..5ea50ff 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -224,6 +224,28 @@ "successMessage": "Selected connections have been successfully deleted.", "errorMessage": "Batch delete connections failed: {error}" }, + "tagManagement": { + "openButton": "Tag Management", + "title": "Bulk Tag Management", + "searchPlaceholder": "Search tag names...", + "emptyTitle": "No tags to manage yet", + "emptyDescription": "Create tags first, then you can clean them up in bulk here.", + "emptySearch": "No matching tags.", + "selectionSummary": "{tagCount} tags selected, affecting {connectionCount} servers", + "selectAll": "Select All", + "deselectAll": "Deselect All", + "invertSelection": "Invert Selection", + "affectedConnections": "{count} servers", + "deleteConnectionsLabel": "Delete all servers under the selected tags as well", + "deleteConnectionsHint": "If disabled, only the tags are removed and the servers stay as Untagged.", + "deleteButton": "Delete Selected Tags", + "noSelection": "Select at least one tag first.", + "confirmDeleteKeepConnections": "Delete the selected {tagCount} tags? The related servers will be kept and moved to Untagged.", + "confirmDeleteWithConnections": "Delete the selected {tagCount} tags and the {connectionCount} affected servers? This cannot be undone.", + "successKeepConnections": "{tagCount} tags deleted. {connectionCount} servers were moved to Untagged.", + "successWithConnections": "{tagCount} tags deleted, and {deletedConnectionsCount} servers were deleted as well.", + "errorDelete": "Failed to batch delete tags: {error}" + }, "actions": { "testAllFiltered":"Test All", "connect": "Connect", diff --git a/packages/frontend/src/locales/ja-JP.json b/packages/frontend/src/locales/ja-JP.json index 5a5bc1a..d112467 100644 --- a/packages/frontend/src/locales/ja-JP.json +++ b/packages/frontend/src/locales/ja-JP.json @@ -134,6 +134,28 @@ "successMessage": "選択した接続は正常に削除されました。", "errorMessage": "接続の一括削除に失敗しました: {error}" }, + "tagManagement": { + "openButton": "タグ管理", + "title": "タグ一括管理", + "searchPlaceholder": "タグ名を検索...", + "emptyTitle": "管理できるタグがありません", + "emptyDescription": "タグを作成すると、ここで一括削除や整理ができます。", + "emptySearch": "一致するタグがありません。", + "selectionSummary": "{tagCount} 個のタグを選択中、{connectionCount} 台のサーバーに影響します", + "selectAll": "すべて選択", + "deselectAll": "選択解除", + "invertSelection": "選択を反転", + "affectedConnections": "{count} 台のサーバー", + "deleteConnectionsLabel": "タグ削除時に、そのタグ配下のサーバーも削除する", + "deleteConnectionsHint": "オフの場合はタグのみ削除され、サーバーは「タグなし」に残ります。", + "deleteButton": "選択したタグを削除", + "noSelection": "まず少なくとも1つのタグを選択してください。", + "confirmDeleteKeepConnections": "選択した {tagCount} 個のタグを削除しますか?関連サーバーは保持され、「タグなし」に移動します。", + "confirmDeleteWithConnections": "選択した {tagCount} 個のタグと、影響を受ける {connectionCount} 台のサーバーを削除しますか?この操作は元に戻せません。", + "successKeepConnections": "{tagCount} 個のタグを削除し、{connectionCount} 台のサーバーを「タグなし」に移動しました。", + "successWithConnections": "{tagCount} 個のタグを削除し、あわせて {deletedConnectionsCount} 台のサーバーも削除しました。", + "errorDelete": "タグの一括削除に失敗しました: {error}" + }, "actions": { "testAllFiltered":"すべてテスト", "connect": "接続", diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index e8d9d99..8a03bb4 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -225,6 +225,28 @@ "successMessage": "选中的连接已成功删除。", "errorMessage": "批量删除连接失败: {error}" }, + "tagManagement": { + "openButton": "标签管理", + "title": "批量标签管理", + "searchPlaceholder": "搜索标签名称...", + "emptyTitle": "暂无可管理标签", + "emptyDescription": "创建标签后即可在这里批量删除或清理。", + "emptySearch": "没有匹配的标签。", + "selectionSummary": "已选择 {tagCount} 个标签,命中 {connectionCount} 台服务器", + "selectAll": "全选", + "deselectAll": "取消全选", + "invertSelection": "反选", + "affectedConnections": "{count} 台服务器", + "deleteConnectionsLabel": "删除标签时一并删除标签下的所有服务器", + "deleteConnectionsHint": "关闭后仅删除标签本身,原服务器会保留并归入“未标记”。", + "deleteButton": "删除选中标签", + "noSelection": "请先选择至少一个标签。", + "confirmDeleteKeepConnections": "确定删除选中的 {tagCount} 个标签吗?关联服务器将保留并归入“未标记”。", + "confirmDeleteWithConnections": "确定删除选中的 {tagCount} 个标签及其命中的 {connectionCount} 台服务器吗?此操作不可撤销。", + "successKeepConnections": "已删除 {tagCount} 个标签,{connectionCount} 台服务器已归入“未标记”。", + "successWithConnections": "已删除 {tagCount} 个标签,并同步删除 {deletedConnectionsCount} 台服务器。", + "errorDelete": "批量删除标签失败: {error}" + }, "actions": { "testAllFiltered":"测试全部", "connect": "连接", diff --git a/packages/frontend/src/stores/tags.store.ts b/packages/frontend/src/stores/tags.store.ts index 8717ebc..2ec5c5e 100644 --- a/packages/frontend/src/stores/tags.store.ts +++ b/packages/frontend/src/stores/tags.store.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia'; import { ref } from 'vue'; import apiClient from '../utils/apiClient'; // 使用统一的 apiClient +import { useConnectionsStore } from './connections.store'; // 定义标签信息接口 export interface TagInfo { @@ -10,10 +11,20 @@ export interface TagInfo { updated_at: number; } +export interface TagBatchDeleteSummary { + deleted_tag_ids: number[]; + deleted_tags_count: number; + affected_connection_ids: number[]; + affected_connections_count: number; + deleted_connections_count: number; + delete_connections: boolean; +} + export const useTagsStore = defineStore('tags', () => { const tags = ref([]); const isLoading = ref(false); const error = ref(null); + const connectionsStore = useConnectionsStore(); // 获取标签列表 (带缓存) async function fetchTags() { @@ -62,6 +73,15 @@ export const useTagsStore = defineStore('tags', () => { } } + async function refreshRelatedConnectionData() { + localStorage.removeItem('tagsCache'); + localStorage.removeItem('connectionsCache'); + await Promise.all([ + fetchTags(), + connectionsStore.fetchConnections(), + ]); + } + // 添加新标签 (添加后清除缓存) async function addTag(name: string): Promise { // 修改返回类型 isLoading.value = true; @@ -103,18 +123,30 @@ export const useTagsStore = defineStore('tags', () => { // 删除标签 async function deleteTag(id: number): Promise { + const summary = await deleteTagsBatch([id], false); + return Boolean(summary && summary.deleted_tags_count > 0); + } + + async function deleteTagsBatch(tagIds: number[], deleteConnections: boolean): Promise { + const normalizedTagIds = Array.from(new Set(tagIds.filter((tagId) => Number.isInteger(tagId) && tagId > 0))); + if (normalizedTagIds.length === 0) { + error.value = '至少需要选择一个标签'; + return null; + } + isLoading.value = true; error.value = null; try { - await apiClient.delete(`/tags/${id}`); // 使用 apiClient 并移除 base URL - // 删除成功后,清除缓存并重新获取 - localStorage.removeItem('tagsCache'); - await fetchTags(); - return true; + const response = await apiClient.post<{ message: string; summary: TagBatchDeleteSummary }>('/tags/bulk-delete', { + tag_ids: normalizedTagIds, + delete_connections: deleteConnections, + }); + await refreshRelatedConnectionData(); + return response.data.summary; } catch (err: any) { - console.error('Failed to delete tag:', err); - error.value = err.response?.data?.message || err.message || '删除标签失败'; - return false; + console.error('Failed to batch delete tags:', err); + error.value = err.response?.data?.message || err.message || '批量删除标签失败'; + return null; } finally { isLoading.value = false; } @@ -128,18 +160,7 @@ export const useTagsStore = defineStore('tags', () => { // 假设后端 API 端点是 PUT /api/tags/:tagId/connections await apiClient.put(`/tags/${tagId}/connections`, { connection_ids: connectionIds }); // 更新成功后,清除相关缓存并重新获取数据以确保一致性 - localStorage.removeItem('tagsCache'); // 清除标签缓存 - localStorage.removeItem('connectionsCache'); // 清除连接缓存,因为连接的 tag_ids 可能已更改 - - await fetchTags(); // 重新获取标签 - // 可能还需要通知 connectionsStore 重新获取连接,或者在这里直接调用 - // (这取决于您希望如何管理 store 间的依赖和数据同步) - // 例如: const connectionsStore = useConnectionsStore(); await connectionsStore.fetchConnections(); - // 为简单起见,这里假设调用者会处理连接列表的刷新,或者依赖于后续的自动刷新机制。 - // 或者,更健壮的做法是在此 action 成功后,让 connectionsStore 也刷新。 - // 但为了减少此处的直接依赖,暂时只刷新 tagsStore。 - // WorkspaceConnectionList 在模态框保存成功后会重新 fetchConnections。 - + await refreshRelatedConnectionData(); return true; } catch (err: any) { console.error(`Failed to update connections for tag ${tagId}:`, err); @@ -158,6 +179,7 @@ export const useTagsStore = defineStore('tags', () => { addTag, updateTag, deleteTag, + deleteTagsBatch, updateTagConnections, // 暴露新的 action }; }); diff --git a/packages/frontend/src/views/ConnectionsView.vue b/packages/frontend/src/views/ConnectionsView.vue index 112d720..231e5ec 100644 --- a/packages/frontend/src/views/ConnectionsView.vue +++ b/packages/frontend/src/views/ConnectionsView.vue @@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import AddConnectionForm from '../components/AddConnectionForm.vue'; import BatchEditConnectionForm from '../components/BatchEditConnectionForm.vue'; import LoginCredentialManagementModal from '../components/LoginCredentialManagementModal.vue'; +import ManageConnectionTagsModal from '../components/ManageConnectionTagsModal.vue'; import { useConnectionsStore } from '../stores/connections.store'; import { useProxiesStore } from '../stores/proxies.store'; import { useSessionStore } from '../stores/session.store'; @@ -97,6 +98,7 @@ const tagsSectionExpanded = ref(true); const showAddEditConnectionForm = ref(false); const connectionToEdit = ref(null); const showLoginCredentialManagement = ref(false); +const showTagManagement = ref(false); const isBatchEditMode = ref(false); const selectedConnectionIdsForBatch = ref>(new Set()); @@ -400,6 +402,22 @@ const visibleTagTreeNodes = computed(() => { return rows; }); +const availableScopeIds = computed(() => { + const ids = new Set(['all', 'untagged']); + + const walkNodes = (nodes: TagTreeNode[]) => { + nodes.forEach((node) => { + ids.add(node.id); + if (node.children.length > 0) { + walkNodes(node.children); + } + }); + }; + + walkNodes(tagTreeNodes.value); + return ids; +}); + const expandableTreeNodeIds = computed(() => { const ids: ScopeId[] = []; @@ -723,6 +741,16 @@ watch([selectedScope, activeTypeFilter, searchQuery], () => { } }); +watch( + tagTreeNodes, + () => { + if (!availableScopeIds.value.has(selectedScope.value)) { + selectedScope.value = 'all'; + } + }, + { deep: true }, +); + const selectScope = (scopeId: ScopeId) => { selectedScope.value = scopeId; }; @@ -822,6 +850,13 @@ const handleConnectionModified = async () => { await connectionsStore.fetchConnections(); }; +const handleTagsDeleted = () => { + showTagManagement.value = false; + if (!availableScopeIds.value.has(selectedScope.value)) { + selectedScope.value = 'all'; + } +}; + const toggleBatchEditMode = () => { isBatchEditMode.value = !isBatchEditMode.value; if (!isBatchEditMode.value) { @@ -1288,6 +1323,14 @@ onBeforeUnmount(() => { {{ t('connections.addConnection', '新增连接') }} +