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
This commit is contained in:
yinjianm
2026-04-12 23:05:41 +08:00
parent 8c130adcc9
commit b660fc1f37
21 changed files with 917 additions and 28 deletions
+6
View File
@@ -11,6 +11,8 @@
- 2026-03-25:继续微调 `/workspace` Workbench,新增默认“快捷指令”标签、调整三栏宽度到更接近 xterminal 参考图,并修复终端区域鼠标悬停时指针异常消失的问题。 - 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 - **[frontend]**: 为 SSH 服务器组头补充整组关闭按钮,并修正脚本模式对单/双引号包裹值的保存行为 — by yinjianm
- 方案: [202604120656_ssh-group-close-and-script-input-sanitize](archive/2026-04/202604120656_ssh-group-close-and-script-input-sanitize/) - 方案: [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 - **[frontend]**: 将 `/workspace` Workbench 的导航从左侧竖排 icon rail 调整为 `Workbench` header 上方的横向纯图标栏,保留原有四面板切换逻辑与信息头部层级 — by yinjianm
@@ -72,6 +74,10 @@
- 文件: packages/frontend/src/components/AddEditQuickCommandForm.vue:9,184-185,242-245 - 文件: 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 - **[frontend]**: 在 `/workspace` 状态监控的 CPU 型号下方新增 CPU 核心数 badge,直接显示后端推送的服务器核数规格 — by yinjianm
- 方案: [202604120656_server-status-cpu-core-display](archive/2026-04/202604120656_server-status-cpu-core-display/) - 方案: [202604120656_server-status-cpu-core-display](archive/2026-04/202604120656_server-status-cpu-core-display/)
- **[backend]**: 扩展 `StatusMonitorService` 的 CPU 规格采集链路,新增 `cpuCores` 字段并通过多级回退命令获取逻辑核心数 — by yinjianm - **[backend]**: 扩展 `StatusMonitorService` 的 CPU 规格采集链路,新增 `cpuCores` 字段并通过多级回退命令获取逻辑核心数 — by yinjianm
@@ -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"}
@@ -1,5 +1,7 @@
# 任务清单: quickcommands-double-click-tooltip # 任务清单: quickcommands-double-click-tooltip
> **@status:** completed | 2026-04-12 07:22
```yaml ```yaml
@feature: quickcommands-double-click-tooltip @feature: quickcommands-double-click-tooltip
@created: 2026-04-12 @created: 2026-04-12
@@ -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"}
@@ -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 过渡,不引入额外复杂动画
- **氛围**: 保持现有半透明卡片和细边框风格,与连接页主界面统一
### 技术约束
- **可访问性**: 危险按钮和危险开关需保留明确文本,不能只靠颜色表达删除语义
- **响应式**: 弹窗在窄屏下需退化为单列列表与纵向底部操作区
@@ -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` 实际交互。
+4
View File
@@ -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 | - | - | - | ✅完成 | | 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 | ssh-group-close-and-script-input-sanitize | implementation | frontend | ssh-group-close-and-script-input-sanitize#D001 | ✅完成 |
| 202604120656 | server-status-cpu-core-display | - | - | - | ✅完成 | | 202604120656 | server-status-cpu-core-display | - | - | - | ✅完成 |
@@ -48,6 +50,8 @@
## 按月归档 ## 按月归档
### 2026-04 ### 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 服务器组头补充整组关闭按钮,并修正脚本模式对单/双引号包裹值的保存行为 - [202604120656_ssh-group-close-and-script-input-sanitize](./2026-04/202604120656_ssh-group-close-and-script-input-sanitize/) - 为 SSH 服务器组头补充整组关闭按钮,并修正脚本模式对单/双引号包裹值的保存行为
### 2026-03 ### 2026-03
+5
View File
@@ -54,6 +54,11 @@
**行为**: 后端新增 `packages/backend/src/login-credentials/` 业务域和 `login_credentials` 表,通过 `/api/v1/login-credentials` 提供登录凭证列表、创建、编辑、删除和详情读取接口;`connections` 表新增 `login_credential_id` 外键,连接创建、更新和未保存测试时都可以引用已保存凭证;运行时凭证解析则优先读取 `login_credentials` 的用户名、认证方式和加密凭证,再回退到连接自身保存的直填字段,因此编辑登录凭证后,引用它的连接在测试和实际连接时会自动使用新配置。 **行为**: 后端新增 `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` 保持与前端同名默认主题定义一致,作为默认外观与终端主题的镜像基线;当前默认外观中终端文字描边和阴影开关默认开启,但仅作为“无保存值时”的回退,不主动覆盖数据库里已有用户配置。 **行为**: `appearance.repository.ts` 负责写入默认 UI 外观设置,`config/default-themes.ts` 保持与前端同名默认主题定义一致,作为默认外观与终端主题的镜像基线;当前默认外观中终端文字描边和阴影开关默认开启,但仅作为“无保存值时”的回退,不主动覆盖数据库里已有用户配置。
File diff suppressed because one or more lines are too long
@@ -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"}
+98 -6
View File
@@ -10,6 +10,19 @@ export interface TagData {
updated_at: number; 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<boolean> => {
* 删除标签 * 删除标签
*/ */
export const deleteTag = async (id: number): Promise<boolean> => { export const deleteTag = async (id: number): Promise<boolean> => {
const sql = `DELETE FROM tags WHERE id = ?`; const summary = await deleteTagsBatch([id], false);
try { return summary.deleted_tags_count > 0;
};
/**
* 批量删除标签,并根据策略可选地同时删除关联连接。
*/
export const deleteTagsBatch = async (tagIds: number[], deleteConnections: boolean): Promise<BatchDeleteTagsSummary> => {
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(); const db = await getDbInstance();
const result = await runDb(db, sql, [id]); try {
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) { } catch (err: any) {
console.error(`[仓库] 删除标签 ${id} 时出错:`, err.message); try {
throw new Error('删除标签失败'); await runDb(db, 'ROLLBACK');
} catch (rollbackError: any) {
console.error('[仓库] 批量删除标签回滚失败:', rollbackError.message);
}
console.error(`[仓库] 批量删除标签时出错:`, err.message);
throw new Error(`批量删除标签失败: ${err.message}`);
} }
}; };
+13
View File
@@ -2,6 +2,7 @@ import * as TagRepository from '../tags/tag.repository';
// Re-export or define types // Re-export or define types
export interface TagData extends TagRepository.TagData {} export interface TagData extends TagRepository.TagData {}
export interface BatchDeleteTagsSummary extends TagRepository.BatchDeleteTagsSummary {}
/** /**
* 获取所有标签 * 获取所有标签
@@ -76,6 +77,18 @@ export const deleteTag = async (id: number): Promise<boolean> => {
return TagRepository.deleteTag(id); return TagRepository.deleteTag(id);
}; };
/**
* 批量删除标签,并根据策略决定是否删除关联连接。
*/
export const deleteTagsBatch = async (tagIds: number[], deleteConnections: boolean): Promise<BatchDeleteTagsSummary> => {
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));
};
/** /**
* 更新标签与连接的关联关系 * 更新标签与连接的关联关系
*/ */
@@ -131,6 +131,44 @@ export const deleteTag = async (req: Request, res: Response): Promise<void> => {
} }
}; };
/**
* 批量删除标签 (POST /api/v1/tags/bulk-delete)
*/
export const bulkDeleteTags = async (req: Request, res: Response): Promise<void> => {
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) * 更新标签与连接的关联关系 (PUT /api/v1/tags/:id/connections)
*/ */
+2
View File
@@ -6,6 +6,7 @@ import {
getTagById, getTagById,
updateTag, updateTag,
deleteTag, deleteTag,
bulkDeleteTags,
updateTagConnections // +++ 导入新的控制器方法 +++ updateTagConnections // +++ 导入新的控制器方法 +++
} from './tags.controller'; } from './tags.controller';
@@ -16,6 +17,7 @@ router.use(isAuthenticated);
// 定义标签相关的路由 // 定义标签相关的路由
router.post('/', createTag); // POST /api/v1/tags - 创建新标签 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('/', getTags); // GET /api/v1/tags - 获取标签列表
router.get('/:id', getTagById); // GET /api/v1/tags/:id - 获取单个标签 router.get('/:id', getTagById); // GET /api/v1/tags/:id - 获取单个标签
router.put('/:id', updateTag); // PUT /api/v1/tags/:id - 更新标签 router.put('/:id', updateTag); // PUT /api/v1/tags/:id - 更新标签
@@ -0,0 +1,353 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useTagsStore } from '../stores/tags.store';
import { useConnectionsStore } from '../stores/connections.store';
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
import { useConfirmDialog } from '../composables/useConfirmDialog';
const props = defineProps<{
visible: boolean;
}>();
const emit = defineEmits(['update:visible', 'deleted']);
const { t } = useI18n();
const tagsStore = useTagsStore();
const connectionsStore = useConnectionsStore();
const uiNotificationsStore = useUiNotificationsStore();
const { showConfirmDialog } = useConfirmDialog();
const { tags, isLoading: isTagsLoading } = storeToRefs(tagsStore);
const { connections, isLoading: isConnectionsLoading } = storeToRefs(connectionsStore);
const internalVisible = ref(props.visible);
const searchQuery = ref('');
const selectedTagIds = ref<number[]>([]);
const deleteConnectionsTogether = ref(false);
const isSubmitting = ref(false);
const isBusy = computed(() => isTagsLoading.value || isConnectionsLoading.value || isSubmitting.value);
const normalizedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase());
const selectedTagIdSet = computed(() => new Set(selectedTagIds.value));
const tagConnectionCountMap = computed(() => {
const counts = new Map<number, number>();
tags.value.forEach((tag) => counts.set(tag.id, 0));
connections.value.forEach((connection) => {
connection.tag_ids?.forEach((tagId) => {
counts.set(tagId, (counts.get(tagId) ?? 0) + 1);
});
});
return counts;
});
const filteredTags = computed(() => {
return tags.value
.filter((tag) => {
if (!normalizedSearchQuery.value) {
return true;
}
return tag.name.toLowerCase().includes(normalizedSearchQuery.value);
})
.map((tag) => ({
...tag,
connectionCount: tagConnectionCountMap.value.get(tag.id) ?? 0,
}))
.sort((left, right) => left.name.localeCompare(right.name));
});
const selectedTagCount = computed(() => selectedTagIds.value.length);
const affectedConnectionIds = computed(() => {
const affectedIds = new Set<number>();
connections.value.forEach((connection) => {
if (connection.tag_ids?.some((tagId) => selectedTagIdSet.value.has(tagId))) {
affectedIds.add(connection.id);
}
});
return Array.from(affectedIds);
});
const resetState = () => {
searchQuery.value = '';
selectedTagIds.value = [];
deleteConnectionsTogether.value = false;
};
const ensureDataLoaded = async () => {
if (!tags.value.length && !tagsStore.isLoading) {
await tagsStore.fetchTags();
}
if (!connections.value.length && !connectionsStore.isLoading) {
await connectionsStore.fetchConnections();
}
};
watch(() => props.visible, async (newValue) => {
internalVisible.value = newValue;
if (newValue) {
resetState();
await ensureDataLoaded();
}
});
watch(internalVisible, (newValue) => {
if (newValue !== props.visible) {
emit('update:visible', newValue);
}
});
const handleClose = () => {
internalVisible.value = false;
};
const isTagSelected = (tagId: number) => {
return selectedTagIdSet.value.has(tagId);
};
const toggleTagSelection = (tagId: number) => {
if (selectedTagIdSet.value.has(tagId)) {
selectedTagIds.value = selectedTagIds.value.filter((id) => id !== tagId);
return;
}
selectedTagIds.value = [...selectedTagIds.value, tagId];
};
const selectAllFilteredTags = () => {
selectedTagIds.value = Array.from(new Set([...selectedTagIds.value, ...filteredTags.value.map((tag) => tag.id)]));
};
const deselectAllFilteredTags = () => {
const filteredTagIds = new Set(filteredTags.value.map((tag) => tag.id));
selectedTagIds.value = selectedTagIds.value.filter((tagId) => !filteredTagIds.has(tagId));
};
const invertFilteredTagSelection = () => {
const nextSelection = new Set(selectedTagIds.value);
filteredTags.value.forEach((tag) => {
if (nextSelection.has(tag.id)) {
nextSelection.delete(tag.id);
} else {
nextSelection.add(tag.id);
}
});
selectedTagIds.value = Array.from(nextSelection);
};
const handleDeleteSelectedTags = async () => {
if (selectedTagCount.value === 0) {
uiNotificationsStore.addNotification({
type: 'warning',
message: t('connections.tagManagement.noSelection', '请先选择至少一个标签。'),
});
return;
}
const confirmed = await showConfirmDialog({
message: deleteConnectionsTogether.value
? t('connections.tagManagement.confirmDeleteWithConnections', {
tagCount: selectedTagCount.value,
connectionCount: affectedConnectionIds.value.length,
})
: t('connections.tagManagement.confirmDeleteKeepConnections', {
tagCount: selectedTagCount.value,
}),
});
if (!confirmed) {
return;
}
isSubmitting.value = true;
try {
const summary = await tagsStore.deleteTagsBatch(selectedTagIds.value, deleteConnectionsTogether.value);
if (!summary) {
uiNotificationsStore.addNotification({
type: 'error',
message: t('connections.tagManagement.errorDelete', { error: tagsStore.error || 'Unknown error' }),
});
return;
}
uiNotificationsStore.addNotification({
type: 'success',
message: deleteConnectionsTogether.value
? t('connections.tagManagement.successWithConnections', {
tagCount: summary.deleted_tags_count,
deletedConnectionsCount: summary.deleted_connections_count,
})
: t('connections.tagManagement.successKeepConnections', {
tagCount: summary.deleted_tags_count,
connectionCount: summary.affected_connections_count,
}),
});
emit('deleted');
handleClose();
} finally {
isSubmitting.value = false;
}
};
</script>
<template>
<Teleport to="body">
<div
v-if="internalVisible"
class="fixed inset-0 z-50 bg-overlay flex items-center justify-center p-4"
@click.self="handleClose"
>
<div class="w-full max-w-3xl max-h-[90vh] flex flex-col rounded-2xl border border-border bg-background text-foreground shadow-xl overflow-hidden">
<div class="px-5 py-4 border-b border-border/60 bg-header/30">
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-xl font-semibold">{{ t('connections.tagManagement.title', '批量标签管理') }}</h3>
<p class="mt-1 text-sm text-text-secondary">
{{ t('connections.tagManagement.selectionSummary', { tagCount: selectedTagCount, connectionCount: affectedConnectionIds.length }) }}
</p>
</div>
<button
class="h-10 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors"
@click="handleClose"
>
{{ t('common.cancel', '取消') }}
</button>
</div>
<div class="flex flex-col sm:flex-row gap-2">
<div class="relative flex-1">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary text-sm"></i>
<input
v-model="searchQuery"
type="text"
:placeholder="t('connections.tagManagement.searchPlaceholder', '搜索标签名称...')"
class="w-full h-11 pl-10 pr-4 rounded-xl border border-border/60 bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition"
/>
</div>
<div class="flex items-center gap-2 flex-wrap">
<button
class="h-11 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors"
@click="selectAllFilteredTags"
>
{{ t('connections.tagManagement.selectAll', '全选') }}
</button>
<button
class="h-11 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors"
@click="deselectAllFilteredTags"
>
{{ t('connections.tagManagement.deselectAll', '取消全选') }}
</button>
<button
class="h-11 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors"
@click="invertFilteredTagSelection"
>
{{ t('connections.tagManagement.invertSelection', '反选') }}
</button>
</div>
</div>
</div>
</div>
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-4">
<div v-if="isBusy && filteredTags.length === 0" class="flex items-center justify-center h-full text-text-secondary">
<i class="fas fa-spinner fa-spin mr-2"></i>{{ t('common.loading', '加载中') }}
</div>
<div
v-else-if="filteredTags.length === 0"
class="h-full min-h-[260px] rounded-2xl border border-dashed border-border/70 bg-card/40 flex flex-col items-center justify-center text-center px-6"
>
<i class="fas fa-tags text-2xl text-text-secondary mb-3"></i>
<p class="text-base font-medium text-foreground">
{{
tags.length === 0
? t('connections.tagManagement.emptyTitle', '暂无可管理标签')
: t('connections.tagManagement.emptySearch', '没有匹配的标签。')
}}
</p>
<p class="mt-2 text-sm text-text-secondary">
{{ t('connections.tagManagement.emptyDescription', '创建标签后即可在这里批量删除或清理。') }}
</p>
</div>
<ul v-else class="space-y-2">
<li v-for="tag in filteredTags" :key="tag.id">
<button
type="button"
class="w-full rounded-2xl border px-4 py-3 text-left transition-colors flex items-center gap-3"
:class="isTagSelected(tag.id)
? 'border-primary/35 bg-primary/10 text-foreground'
: 'border-border bg-card/50 text-foreground hover:bg-header/35'"
@click="toggleTagSelection(tag.id)"
>
<input
type="checkbox"
class="h-4 w-4 rounded border-border bg-background text-primary focus:ring-primary"
:checked="isTagSelected(tag.id)"
@click.stop="toggleTagSelection(tag.id)"
@change.stop
/>
<div class="min-w-0 flex-1">
<div class="truncate font-medium" :title="tag.name">{{ tag.name }}</div>
<div class="mt-1 text-sm text-text-secondary">
{{ t('connections.tagManagement.affectedConnections', { count: tag.connectionCount }) }}
</div>
</div>
<span class="px-2.5 py-1 rounded-full text-xs border border-current/15 bg-black/10">
{{ tag.connectionCount }}
</span>
</button>
</li>
</ul>
</div>
<div class="px-5 py-4 border-t border-border/60 bg-background/90">
<label class="flex items-start gap-3 rounded-2xl border border-error/20 bg-error/5 px-4 py-3">
<input
v-model="deleteConnectionsTogether"
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border bg-background text-error focus:ring-error"
/>
<span>
<span class="block text-sm font-medium text-foreground">
{{ t('connections.tagManagement.deleteConnectionsLabel', '删除标签时一并删除标签下的所有服务器') }}
</span>
<span class="mt-1 block text-xs text-text-secondary">
{{ t('connections.tagManagement.deleteConnectionsHint', '关闭后仅删除标签本身,原服务器会保留并归入“未标记”。') }}
</span>
</span>
</label>
<div class="mt-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div class="text-sm text-text-secondary">
{{ t('connections.tagManagement.selectionSummary', { tagCount: selectedTagCount, connectionCount: affectedConnectionIds.length }) }}
</div>
<div class="flex items-center justify-end gap-2">
<button
class="h-11 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors"
:disabled="isSubmitting"
@click="handleClose"
>
{{ t('common.cancel', '取消') }}
</button>
<button
class="h-11 px-4 rounded-xl border border-red-600 bg-red-600 text-white hover:bg-red-700 hover:border-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
:disabled="selectedTagCount === 0 || isSubmitting"
@click="handleDeleteSelectedTags"
>
<i :class="['fas', isSubmitting ? 'fa-spinner fa-spin' : 'fa-trash-alt']"></i>
<span>{{ t('connections.tagManagement.deleteButton', '删除选中标签') }}</span>
</button>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
+22
View File
@@ -224,6 +224,28 @@
"successMessage": "Selected connections have been successfully deleted.", "successMessage": "Selected connections have been successfully deleted.",
"errorMessage": "Batch delete connections failed: {error}" "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": { "actions": {
"testAllFiltered":"Test All", "testAllFiltered":"Test All",
"connect": "Connect", "connect": "Connect",
+22
View File
@@ -134,6 +134,28 @@
"successMessage": "選択した接続は正常に削除されました。", "successMessage": "選択した接続は正常に削除されました。",
"errorMessage": "接続の一括削除に失敗しました: {error}" "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": { "actions": {
"testAllFiltered":"すべてテスト", "testAllFiltered":"すべてテスト",
"connect": "接続", "connect": "接続",
+22
View File
@@ -225,6 +225,28 @@
"successMessage": "选中的连接已成功删除。", "successMessage": "选中的连接已成功删除。",
"errorMessage": "批量删除连接失败: {error}" "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": { "actions": {
"testAllFiltered":"测试全部", "testAllFiltered":"测试全部",
"connect": "连接", "connect": "连接",
+42 -20
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref } from 'vue'; import { ref } from 'vue';
import apiClient from '../utils/apiClient'; // 使用统一的 apiClient import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
import { useConnectionsStore } from './connections.store';
// 定义标签信息接口 // 定义标签信息接口
export interface TagInfo { export interface TagInfo {
@@ -10,10 +11,20 @@ export interface TagInfo {
updated_at: number; 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', () => { export const useTagsStore = defineStore('tags', () => {
const tags = ref<TagInfo[]>([]); const tags = ref<TagInfo[]>([]);
const isLoading = ref(false); const isLoading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const connectionsStore = useConnectionsStore();
// 获取标签列表 (带缓存) // 获取标签列表 (带缓存)
async function fetchTags() { 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<TagInfo | null> { // 修改返回类型 async function addTag(name: string): Promise<TagInfo | null> { // 修改返回类型
isLoading.value = true; isLoading.value = true;
@@ -103,18 +123,30 @@ export const useTagsStore = defineStore('tags', () => {
// 删除标签 // 删除标签
async function deleteTag(id: number): Promise<boolean> { async function deleteTag(id: number): Promise<boolean> {
const summary = await deleteTagsBatch([id], false);
return Boolean(summary && summary.deleted_tags_count > 0);
}
async function deleteTagsBatch(tagIds: number[], deleteConnections: boolean): Promise<TagBatchDeleteSummary | null> {
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; isLoading.value = true;
error.value = null; error.value = null;
try { try {
await apiClient.delete(`/tags/${id}`); // 使用 apiClient 并移除 base URL const response = await apiClient.post<{ message: string; summary: TagBatchDeleteSummary }>('/tags/bulk-delete', {
// 删除成功后,清除缓存并重新获取 tag_ids: normalizedTagIds,
localStorage.removeItem('tagsCache'); delete_connections: deleteConnections,
await fetchTags(); });
return true; await refreshRelatedConnectionData();
return response.data.summary;
} catch (err: any) { } catch (err: any) {
console.error('Failed to delete tag:', err); console.error('Failed to batch delete tags:', err);
error.value = err.response?.data?.message || err.message || '删除标签失败'; error.value = err.response?.data?.message || err.message || '批量删除标签失败';
return false; return null;
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
@@ -128,18 +160,7 @@ export const useTagsStore = defineStore('tags', () => {
// 假设后端 API 端点是 PUT /api/tags/:tagId/connections // 假设后端 API 端点是 PUT /api/tags/:tagId/connections
await apiClient.put(`/tags/${tagId}/connections`, { connection_ids: connectionIds }); await apiClient.put(`/tags/${tagId}/connections`, { connection_ids: connectionIds });
// 更新成功后,清除相关缓存并重新获取数据以确保一致性 // 更新成功后,清除相关缓存并重新获取数据以确保一致性
localStorage.removeItem('tagsCache'); // 清除标签缓存 await refreshRelatedConnectionData();
localStorage.removeItem('connectionsCache'); // 清除连接缓存,因为连接的 tag_ids 可能已更改
await fetchTags(); // 重新获取标签
// 可能还需要通知 connectionsStore 重新获取连接,或者在这里直接调用
// (这取决于您希望如何管理 store 间的依赖和数据同步)
// 例如: const connectionsStore = useConnectionsStore(); await connectionsStore.fetchConnections();
// 为简单起见,这里假设调用者会处理连接列表的刷新,或者依赖于后续的自动刷新机制。
// 或者,更健壮的做法是在此 action 成功后,让 connectionsStore 也刷新。
// 但为了减少此处的直接依赖,暂时只刷新 tagsStore。
// WorkspaceConnectionList 在模态框保存成功后会重新 fetchConnections。
return true; return true;
} catch (err: any) { } catch (err: any) {
console.error(`Failed to update connections for tag ${tagId}:`, err); console.error(`Failed to update connections for tag ${tagId}:`, err);
@@ -158,6 +179,7 @@ export const useTagsStore = defineStore('tags', () => {
addTag, addTag,
updateTag, updateTag,
deleteTag, deleteTag,
deleteTagsBatch,
updateTagConnections, // 暴露新的 action updateTagConnections, // 暴露新的 action
}; };
}); });
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import AddConnectionForm from '../components/AddConnectionForm.vue'; import AddConnectionForm from '../components/AddConnectionForm.vue';
import BatchEditConnectionForm from '../components/BatchEditConnectionForm.vue'; import BatchEditConnectionForm from '../components/BatchEditConnectionForm.vue';
import LoginCredentialManagementModal from '../components/LoginCredentialManagementModal.vue'; import LoginCredentialManagementModal from '../components/LoginCredentialManagementModal.vue';
import ManageConnectionTagsModal from '../components/ManageConnectionTagsModal.vue';
import { useConnectionsStore } from '../stores/connections.store'; import { useConnectionsStore } from '../stores/connections.store';
import { useProxiesStore } from '../stores/proxies.store'; import { useProxiesStore } from '../stores/proxies.store';
import { useSessionStore } from '../stores/session.store'; import { useSessionStore } from '../stores/session.store';
@@ -97,6 +98,7 @@ const tagsSectionExpanded = ref(true);
const showAddEditConnectionForm = ref(false); const showAddEditConnectionForm = ref(false);
const connectionToEdit = ref<ConnectionInfo | null>(null); const connectionToEdit = ref<ConnectionInfo | null>(null);
const showLoginCredentialManagement = ref(false); const showLoginCredentialManagement = ref(false);
const showTagManagement = ref(false);
const isBatchEditMode = ref(false); const isBatchEditMode = ref(false);
const selectedConnectionIdsForBatch = ref<Set<number>>(new Set()); const selectedConnectionIdsForBatch = ref<Set<number>>(new Set());
@@ -400,6 +402,22 @@ const visibleTagTreeNodes = computed<TagTreeNode[]>(() => {
return rows; return rows;
}); });
const availableScopeIds = computed(() => {
const ids = new Set<ScopeId>(['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<ScopeId[]>(() => { const expandableTreeNodeIds = computed<ScopeId[]>(() => {
const ids: ScopeId[] = []; 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) => { const selectScope = (scopeId: ScopeId) => {
selectedScope.value = scopeId; selectedScope.value = scopeId;
}; };
@@ -822,6 +850,13 @@ const handleConnectionModified = async () => {
await connectionsStore.fetchConnections(); await connectionsStore.fetchConnections();
}; };
const handleTagsDeleted = () => {
showTagManagement.value = false;
if (!availableScopeIds.value.has(selectedScope.value)) {
selectedScope.value = 'all';
}
};
const toggleBatchEditMode = () => { const toggleBatchEditMode = () => {
isBatchEditMode.value = !isBatchEditMode.value; isBatchEditMode.value = !isBatchEditMode.value;
if (!isBatchEditMode.value) { if (!isBatchEditMode.value) {
@@ -1288,6 +1323,14 @@ onBeforeUnmount(() => {
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
<span>{{ t('connections.addConnection', '新增连接') }}</span> <span>{{ t('connections.addConnection', '新增连接') }}</span>
</button> </button>
<button
@click="showTagManagement = true"
class="h-11 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2"
:title="t('connections.tagManagement.openButton', '标签管理')"
>
<i class="fas fa-tags"></i>
<span>{{ t('connections.tagManagement.openButton', '标签管理') }}</span>
</button>
<button <button
@click="showLoginCredentialManagement = true" @click="showLoginCredentialManagement = true"
class="h-11 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2" class="h-11 px-4 rounded-xl border border-border bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2"
@@ -1632,6 +1675,13 @@ onBeforeUnmount(() => {
v-if="showLoginCredentialManagement" v-if="showLoginCredentialManagement"
@close="showLoginCredentialManagement = false" @close="showLoginCredentialManagement = false"
/> />
<ManageConnectionTagsModal
v-if="showTagManagement"
:visible="showTagManagement"
@update:visible="showTagManagement = $event"
@deleted="handleTagsDeleted"
/>
</div> </div>
</div> </div>
</template> </template>