重构(前端): 持久化快速命令排序和密码切换

添加持久化排序字段并重新排序快速命令和标签的端点,更新前端以支持手动拖放排序,并为连接和凭据表单添加密码可见性切换。此外,将 SSH 连接测试作为连接列表中的默认操作,并刷新相关模块文档和更改日志。
This commit is contained in:
yinjianm
2026-04-19 02:50:44 +08:00
parent 00d7c6c2f3
commit 8ce007a305
33 changed files with 1996 additions and 975 deletions
+12
View File
@@ -2,6 +2,18 @@
## [Unreleased] ## [Unreleased]
- **[frontend]**: 为快捷指令视图新增分组拖拽排序、组内命令拖拽排序与扁平命令列表拖拽排序,并在拖拽完成后自动切换到手动顺序视图以保持刷新后顺序一致 — by yinjianm
- 方案: [202604190208_quickcommands-drag-reorder](archive/2026-04/202604190208_quickcommands-drag-reorder/)
- **[backend]**: 为快捷指令、快捷指令标签及其关联表补充 `sort_order` 持久化字段,并新增分组重排、全局命令重排和标签内命令重排接口,同时保留既有标签关联顺序 — by yinjianm
- 方案: [202604190208_quickcommands-drag-reorder](archive/2026-04/202604190208_quickcommands-drag-reorder/)
- **[frontend]**: 将连接管理页 SSH 连接卡片的默认操作区调整为“连接 / 测试 / 更多”,并把重复的测试入口从更多菜单移除,减少常用测试操作的额外点击 — by yinjianm
- 方案: [202604190210_connection-card-default-test-button](archive/2026-04/202604190210_connection-card-default-test-button/)
- **[frontend]**: 为连接新增/编辑表单以及登录凭证管理弹窗的直填密码输入补充“小眼睛”显隐切换,默认仍隐藏,仅在本地输入端切换明文核对 — by yinjianm
- 方案: [202604190201_connection-password-visibility-toggle](archive/2026-04/202604190201_connection-password-visibility-toggle/)
- **[workspace-root]**: 为 Docker 镜像发布 workflow 增加按路径检测的动态构建矩阵,仅在共享根文件或对应服务目录变更时构建受影响镜像,手动触发仍保留全量发布能力 - by yinjianm - **[workspace-root]**: 为 Docker 镜像发布 workflow 增加按路径检测的动态构建矩阵,仅在共享根文件或对应服务目录变更时构建受影响镜像,手动触发仍保留全量发布能力 - by yinjianm
- 方案: [202604160350_workflow-service-scoped-docker-builds](archive/2026-04/202604160350_workflow-service-scoped-docker-builds/) - 方案: [202604160350_workflow-service-scoped-docker-builds](archive/2026-04/202604160350_workflow-service-scoped-docker-builds/)
@@ -0,0 +1 @@
{"status":"completed","completed":4,"failed":0,"pending":0,"total":4,"done":4,"percent":100,"current":"已完成连接新增/编辑表单与登录凭证管理弹窗的密码显隐切换,并通过前端构建验证","updated_at":"2026-04-19 02:08:00"}
@@ -0,0 +1,121 @@
# 变更提案: connection-password-visibility-toggle
## 元信息
```yaml
类型: 优化
方案类型: implementation
优先级: P2
状态: 已确认
创建: 2026-04-19
```
---
## 1. 需求
### 背景
连接新增/编辑表单以及登录凭证管理弹窗中的密码输入当前只能以掩码形式录入,用户在手工填写时无法即时核对内容是否正确。用户明确要求仅在前端输入端增加“小眼睛”显隐切换,用于检查自己刚输入的密码,而不是让表单默认返回明文或让后端补发已有密码。
### 目标
- 在连接新增/编辑表单的直填密码输入框中增加显隐切换,覆盖 SSH 密码、RDP 密码和 VNC 密码。
- 在登录凭证管理弹窗中增加同样的显隐切换,覆盖 SSH 密码与通用密码输入。
- 保持默认 `password` 掩码行为不变,不改现有提交、测试、保存和后端接口链路。
### 约束条件
```yaml
时间约束: 本轮限定为前端增量改动,不扩展到批量编辑、代理配置或登录页
性能约束: 仅增加本地响应式布尔状态,不引入额外依赖
兼容性约束: 不改变现有表单校验、编辑模式留空保留密码逻辑和已保存凭证模式
业务约束: 不请求后端返回已有明文密码,不改变默认数据返回策略
```
### 验收标准
- [ ] 连接新增/编辑表单的直填密码字段右侧存在可点击的小眼睛按钮,默认隐藏,点击后可切换明文显示。
- [ ] 登录凭证管理弹窗中的密码字段具备相同的显隐切换能力,且不影响原有保存逻辑。
- [ ] 显隐切换仅作用于当前输入框的本地显示状态,不改 API payload 结构,不新增后端接口返回。
- [ ] 前端构建校验通过;若出现与本次无关的既有问题,需在执行记录中明确标注。
---
## 2. 方案
### 技术方案
`AddConnectionFormAuth.vue``LoginCredentialManagementModal.vue` 内分别维护局部的密码可见状态,通过动态切换 input 的 `type``password` / `text`)实现显隐。每个密码输入框右侧内嵌一个次级 icon 按钮,按钮文案走 i18n,点击后只改变当前字段的显示方式,不写入 store、不触发额外请求,也不改变既有表单数据结构。
### 影响范围
```yaml
涉及模块:
- frontend: 连接认证表单与登录凭证管理弹窗的输入交互增强
- frontend-i18n: 显隐密码按钮的中英日文案补充
预计变更文件: 5
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 眼睛按钮被当成表单提交按钮,导致误触提交 | 中 | 所有切换按钮显式使用 `type="button"` |
| 在现有表单布局中加入尾部按钮后造成输入框样式错位 | 中 | 复用现有边框/背景 token,采用相同高度的绝对定位尾部按钮 |
| 编辑态切换后保留上一次可见状态,影响下一次打开表单体验 | 低 | 在表单重置、取消或切换编辑对象时回收为默认隐藏 |
---
## 3. 技术设计(可选)
N/A。本次不涉及架构、API 或数据模型变更。
---
## 4. 核心场景
> 执行完成后同步到对应模块文档
### 场景: 连接表单核对密码输入
**模块**: frontend
**条件**: 用户在新增连接或编辑连接时使用直填认证来源,并在 SSH / RDP / VNC 密码字段中录入密码。
**行为**: 用户点击密码框右侧的小眼睛按钮,在掩码和明文显示之间切换,以便核对当前输入内容。
**结果**: 用户可以在不改变默认安全策略的前提下确认自己录入的密码是否正确。
### 场景: 登录凭证管理时核对密码输入
**模块**: frontend
**条件**: 用户在登录凭证管理弹窗中新增或编辑密码型凭证。
**行为**: 用户点击密码输入框右侧的小眼睛按钮查看或隐藏当前输入的密码。
**结果**: 用户能够在保存前自检输入内容,同时原有保存和校验逻辑保持不变。
---
## 5. 技术决策
> 本方案涉及的技术决策,归档后成为决策的唯一完整记录
### connection-password-visibility-toggle#D001: 在现有表单内局部实现密码显隐切换
**日期**: 2026-04-19
**状态**: ✅采纳
**背景**: 新增/编辑连接与登录凭证管理都需要密码可见性切换,但两处表单结构和字段命名并不完全一致。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 抽离公共密码输入组件 | 逻辑集中、后续可复用 | 本轮只是局部交互增强,会引入额外抽象和迁移成本 |
| B: 在两个现有组件中局部实现显隐状态 | 修改范围小,能最大限度保持既有表单行为和样式结构 | 两处按钮结构会有少量重复 |
**决策**: 选择方案 B
**理由**: 本轮目标明确且影响范围小,局部实现能最快落地,并降低对现有认证流程的扰动。
**影响**: 仅影响前端连接表单与登录凭证管理弹窗,不涉及 store、API 和后端数据结构。
---
## 6. 成果设计
### 设计方向
- **美学基调**: 延续现有连接表单的克制型工具化界面,在输入框尾部加入低干扰、可发现的小眼睛操作,不打断原表单阅读节奏。
- **记忆点**: 密码框右侧内嵌的眼睛切换按钮,作为“录入后可即时自检”的明确交互锚点。
- **参考**: 延续项目现有表单 token、边框和 hover/focus 行为,不额外引入新的视觉体系。
### 视觉要素
- **配色**: 继续使用现有 `bg-background``border-border``text-text-secondary``text-foreground``focus:ring-primary` 体系。
- **字体**: 沿用项目现有全局字体与表单字号,不新增展示字体或排版层级。
- **布局**: 输入框保持单行主结构,右侧以内嵌尾部按钮承载显隐操作,避免新增独立行或破坏表单宽度。
- **动效**: 仅保留项目已有 hover/focus 状态反馈,不新增独立动画。
- **氛围**: 不改变当前表单面板氛围,保持与现有认证区和管理弹窗的一致性。
### 技术约束
- **可访问性**: 显隐按钮需提供可读 title/aria-label,确保仅靠图标也能被辅助工具识别。
- **响应式**: 在现有表单宽度下保持按钮内嵌,不引入移动端换行或遮挡输入内容。
@@ -0,0 +1,51 @@
# 任务清单: connection-password-visibility-toggle
> **@status:** completed | 2026-04-19 02:08
```yaml
@feature: connection-password-visibility-toggle
@created: 2026-04-19
@status: completed
@mode: R2
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 4 | 0 | 0 | 4 |
---
## 任务列表
### 1. 前端表单实现
- [√] 1.1 在 `packages/frontend/src/components/AddConnectionFormAuth.vue` 中为直填 SSH / RDP / VNC 密码输入增加本地显隐切换 | depends_on: []
- [√] 1.2 在 `packages/frontend/src/components/LoginCredentialManagementModal.vue` 中为登录凭证密码输入增加同样的显隐切换与重置逻辑 | depends_on: [1.1]
### 2. 文案与验证
- [√] 2.1 在 `packages/frontend/src/locales/zh-CN.json``packages/frontend/src/locales/en-US.json``packages/frontend/src/locales/ja-JP.json` 中补充显隐密码文案 | depends_on: [1.1]
- [√] 2.2 运行 `packages/frontend` 构建校验并记录结果 | depends_on: [1.2, 2.1]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-04-19 02:03 | design | completed | 已创建 implementation 方案包,范围锁定为连接新增/编辑表单与登录凭证管理弹窗的密码显隐切换 |
| 2026-04-19 02:06 | 1.1 | completed | 已为连接表单中的 SSH、RDP、VNC 直填密码输入增加显隐切换按钮与本地显示状态 |
| 2026-04-19 02:07 | 1.2 | completed | 已为登录凭证管理弹窗中的密码输入增加显隐切换,并在表单重置/切换时恢复默认隐藏 |
| 2026-04-19 02:07 | 2.1 | completed | 已补充连接表单显隐密码按钮的中英日文案 |
| 2026-04-19 02:08 | 2.2 | completed | `npm --workspace @nexus-terminal/frontend run build` 通过,仅保留既有 chunk size 警告 |
---
## 执行备注
> 记录执行过程中的重要说明、决策变更、风险提示等
- 仓库中存在历史遗留的未完成方案包 `202603252311_terminal-group-and-broadcast-dedupe`,其备注指向 `ConnectionsView.vue duplicate attribute` 既有验证问题;若本轮构建再次命中,按既有问题记录,不视为本次功能回归。
- 运行态补充验证: 已启动 `vite` 前端并通过 Playwright 访问到 `http://127.0.0.1:4173/login`;但本地 `localhost:3001` 后端未运行,浏览器端出现代理拒绝连接,无法自动进入登录后的连接管理页,因此“点击小眼睛切换明文”仍建议在你的已登录环境手动确认一次。
@@ -0,0 +1 @@
{"status":"completed","completed":8,"failed":0,"pending":0,"total":8,"done":8,"percent":100,"current":"Completed - 快捷指令分组与命令拖拽排序已实现并通过构建验证","updated_at":"2026-04-19 03:12:00"}
@@ -0,0 +1,208 @@
# 变更提案: quickcommands-drag-reorder
## 元信息
```yaml
类型: 新功能
方案类型: implementation
优先级: P1
状态: 已完成
创建: 2026-04-19
完成: 2026-04-19
```
---
## 1. 需求
### 背景
当前快捷指令视图已经支持按标签分组、按名称或最近使用时间浏览命令,并提供双击执行、右键菜单、动态变量与标签编辑等能力,但分组顺序仍然按标签名派生,组内命令顺序也只能依赖计算排序,用户无法把高频分组和常用命令固定在自己习惯的位置。随着工作台内快捷指令数量增加,这会降低定位效率,也和连接树等区域正在逐步支持拖拽重排的交互方向不一致。
### 目标
- 让快捷指令分组支持拖动排序,并在刷新后保持自定义顺序。
- 让快捷指令支持在分组内拖动排序,并在刷新后保持自定义顺序。
- 让关闭标签分组后的扁平列表同样支持拖动排序。
- 在一条命令可绑定多个标签的前提下,明确全局顺序和标签内顺序的持久化语义。
### 约束条件
```yaml
时间约束: 本轮完成前后端联动实现与基础构建验证
性能约束: 不新增依赖,复用仓库已有的 vuedraggable
兼容性约束: 需要兼容现有 SQLite 数据库,通过 migration 为历史数据补齐顺序字段
业务约束: 搜索过滤结果不参与拖拽重排,避免只对局部可见子集排序造成顺序污染
```
### 验收标准
- [ ] 开启标签分组时,已标记分组支持拖动排序,刷新页面后顺序保持不变。
- [ ] 开启标签分组时,组内命令支持拖动排序,刷新页面后顺序保持不变。
- [ ] 关闭标签分组时,扁平命令列表支持拖动排序,刷新页面后顺序保持不变。
- [ ] 多标签命令在不同标签组内可拥有各自的组内顺序,不因编辑命令或补标签而丢失既有顺序。
- [ ] `packages/backend``packages/frontend` 的构建验证通过。
---
## 2. 方案
### 技术方案
本次改动分三层落地。
第一层是数据层,为 `quick_commands``quick_command_tags``quick_command_tag_associations` 三张表分别新增 `sort_order` 字段,并通过 migration 为历史数据回填稳定初始顺序。命令表顺序用于扁平列表与“未标记”分组,标签表顺序用于分组顺序,关联表顺序用于“命令在某个标签组中的局部位置”。
第二层是接口层,在现有快捷指令与快捷指令标签业务域中新增三个重排接口: 标签重排、全局命令重排、标签内命令重排。同时改造现有标签关联写入逻辑,避免编辑命令时通过“先删后插”破坏已有组内顺序。
第三层是前端交互层,在 `QuickCommandsView.vue` 中接入拖拽句柄和持久化回写逻辑,并把命令排序模式扩展为 `manual``name``last_used`。用户完成拖拽后自动切回 `manual` 模式,确保拖拽结果可以立即可见且稳定保留。
### 影响范围
```yaml
涉及模块:
- backend: quick-commands / quick-command-tags / database migration 需要新增顺序字段与重排接口
- frontend: quickCommands.store / quickCommandTags.store / QuickCommandsView 需要支持手动顺序与拖拽持久化
- knowledge-base: 需要同步 frontend/backend 模块文档与 CHANGELOG
预计变更文件: 10-14
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 多标签命令在多个分组中复用,若仍把组内顺序放在命令表会产生语义冲突 | 高 | 将“标签内顺序”存储到关联表,命令表只承载全局顺序 |
| 历史数据库没有顺序字段,直接读取会导致老数据顺序混乱 | 中 | 通过 migration 补字段并按现有主键或 rowid 回填初始顺序 |
| 搜索过滤结果下拖拽会导致只重排局部子集,用户感知混乱 | 中 | 搜索态禁用拖拽,只允许在完整列表状态下重排 |
| 编辑命令标签时若继续删除并重建关联,会丢失既有组内顺序 | 中 | 改为增量同步标签关联,保留未移除标签的原顺序 |
---
## 3. 技术设计
### 架构设计
```mermaid
flowchart LR
A[QuickCommandsView drag end] --> B[quickCommandsStore / quickCommandTagsStore]
B --> C[reorder APIs]
C --> D[service]
D --> E[repository]
E --> F[(SQLite sort_order fields)]
```
### API设计
#### PUT /api/v1/quick-command-tags/reorder
- **请求**:
```json
{
"tagIds": [3, 1, 5]
}
```
- **响应**:
```json
{
"message": "快捷指令标签顺序已更新"
}
```
#### PUT /api/v1/quick-commands/reorder
- **请求**:
```json
{
"commandIds": [11, 7, 9, 2]
}
```
- **响应**:
```json
{
"message": "快捷指令顺序已更新"
}
```
#### PUT /api/v1/quick-commands/reorder-by-tag
- **请求**:
```json
{
"tagId": 3,
"commandIds": [7, 11, 9]
}
```
- **响应**:
```json
{
"message": "标签内快捷指令顺序已更新"
}
```
### 数据模型
| 字段 | 类型 | 说明 |
|------|------|------|
| `quick_commands.sort_order` | `INTEGER` | 快捷指令的全局手动顺序,供扁平视图与未标记分组使用 |
| `quick_command_tags.sort_order` | `INTEGER` | 快捷指令分组的手动顺序 |
| `quick_command_tag_associations.sort_order` | `INTEGER` | 某条命令在某个标签分组内的手动顺序 |
| `QuickCommandWithTags.tagOrders` | `Record<number, number>` | 返回给前端的“标签 ID -> 组内顺序”映射 |
| `QuickCommandSortByType` | `'manual' | 'name' | 'usage_count' | 'last_used'` | 前端命令列表排序模式 |
---
## 4. 核心场景
### 场景: 调整分组顺序
**模块**: frontend / backend
**条件**: 用户开启快捷指令标签展示,且当前不处于搜索过滤状态。
**行为**: 用户拖动某个已标记分组的标题句柄,前端更新本地拖拽列表并调用标签重排接口。
**结果**: 分组按用户定义的顺序显示,刷新后保持一致,“未标记”分组继续固定在已标记分组之后。
### 场景: 调整标签组内命令顺序
**模块**: frontend / backend
**条件**: 用户在某个标签分组内拖动命令项,且当前不处于搜索过滤状态。
**行为**: 前端将该分组内的命令 ID 顺序提交到标签内重排接口,并自动切换到手动排序模式。
**结果**: 当前分组内的命令顺序立即更新,后续刷新后仍保持该顺序,不影响其它标签组的局部顺序。
### 场景: 调整扁平命令顺序
**模块**: frontend / backend
**条件**: 用户关闭“显示快捷指令标签”设置后浏览扁平列表。
**行为**: 用户拖动命令项,前端提交全局命令重排接口。
**结果**: 扁平列表与“未标记”分组的手动顺序保持一致,仍可切换回名称或最近使用排序查看其它视图。
---
## 5. 技术决策
### quickcommands-drag-reorder#D001: 将“标签内命令顺序”存储在关联表,而不是 quick_commands 主表
**日期**: 2026-04-19
**状态**: 已采纳
**背景**: 一条快捷指令可以绑定多个标签,因此同一条命令可能同时出现在多个分组中。如果组内顺序只存到 `quick_commands` 主表,就无法表达“同一条命令在 A 组和 B 组中的位置不同”。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 仅在 `quick_commands` 存一个 `sort_order` | 结构简单,接口少 | 无法支持多标签命令在不同分组中的不同顺序 |
| B: 在 `quick_command_tag_associations` 存组内 `sort_order`,命令表保留全局 `sort_order` | 语义完整,能兼容分组视图与扁平视图 | 实现比单表排序稍复杂,需要额外重排接口 |
**决策**: 选择方案 B
**理由**: 这是唯一能同时满足“分组内可排序”和“命令可绑定多个标签”两项约束的方案,同时仍能通过命令表上的全局顺序支持扁平视图。
**影响**: backend, frontend
### quickcommands-drag-reorder#D002: 保留现有名称与最近使用排序,同时新增手动排序模式承接拖拽结果
**日期**: 2026-04-19
**状态**: 已采纳
**背景**: 现有快捷指令视图已经有按名称与最近使用查看命令的需求,直接移除会造成回归;但拖拽排序又必须有一个稳定的“手动顺序”视图承接。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 用拖拽排序完全替代名称和最近使用排序 | 交互简单 | 会删除已有浏览方式,造成功能回退 |
| B: 增加 `manual` 模式,并在拖拽完成后自动切换到该模式 | 兼容现有浏览模式,同时让拖拽结果立即可见 | 排序状态管理略复杂 |
**决策**: 选择方案 B
**理由**: 能在不删除现有排序入口的前提下,为拖拽排序提供明确而可持久化的展示模式。
**影响**: frontend
---
## 6. 成果设计
### 设计方向
- **美学基调**: 延续现有工作台深色工具面板风格,在不改变整体布局语言的前提下,加入轻量的“可拖动”信号,让快捷指令列表更像可编排控制台而不是静态目录。
- **记忆点**: 分组标题和命令行都带有低干扰的拖拽句柄,拖动时保持现有卡片与高亮体系,仅在当前位置给出明确占位反馈。
- **参考**: 当前 `QuickCommandsView.vue` 的工作台卡片样式,以及连接树拖拽占位反馈的交互方向。
### 视觉要素
- **配色**: 继续复用现有主题变量与 hover/highlight 色,不新增独立色板,只让拖拽句柄在 hover 与拖拽时增强对比。
- **字体**: 沿用当前工作台文本和命令 monospace 字体体系,避免因交互增强引入新的字体层级。
- **布局**: 不改动现有控制栏、分组头和命令项的主体结构,仅在左侧插入窄句柄区域,并在拖拽态显示占位边框。
- **动效**: 复用 `vuedraggable` 的位移动画与现有过渡时间,保证拖放反馈清晰但不过度跳动。
- **氛围**: 保持克制、专业的终端工作台气质,把“可重排”表达为操作强化,而不是视觉重设计。
### 技术约束
- **可访问性**: 拖拽增强不能破坏现有单击选中、双击执行、键盘 `Enter` 执行与右键菜单入口。
- **响应式**: 继续兼容现有紧凑模式与工作台不同宽度场景,句柄区域不能挤占命令文本的主要可读空间。
@@ -0,0 +1,61 @@
# 任务清单: quickcommands-drag-reorder
> **@status:** completed | 2026-04-19 02:49
```yaml
@feature: quickcommands-drag-reorder
@created: 2026-04-19
@status: completed
@mode: R2
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 8 | 0 | 0 | 8 |
---
## 任务列表
### 1. 数据层与迁移
- [√] 1.1 在 `packages/backend/src/database/schema.ts``packages/backend/src/database/migrations.ts` 中为快捷指令、快捷指令标签和标签关联表增加 `sort_order` 字段,并为历史数据回填稳定初始顺序 | depends_on: []
- [√] 1.2 在 `packages/backend/src/quick-commands/quick-commands.repository.ts``packages/backend/src/quick-command-tags/quick-command-tag.repository.ts` 中扩展顺序读取、写入和标签关联保序逻辑 | depends_on: [1.1]
### 2. 后端重排接口
- [√] 2.1 在 `packages/backend/src/quick-commands/` 业务域中新增全局命令重排和标签内命令重排接口 | depends_on: [1.2]
- [√] 2.2 在 `packages/backend/src/quick-command-tags/` 业务域中新增标签分组重排接口,并让新增标签默认追加到末尾 | depends_on: [1.2]
### 3. 前端状态与交互
- [√] 3.1 在 `packages/frontend/src/stores/quickCommands.store.ts` 中新增手动排序模式、顺序元数据解析和命令重排 action | depends_on: [2.1]
- [√] 3.2 在 `packages/frontend/src/stores/quickCommandTags.store.ts` 中支持标签顺序元数据和分组重排 action | depends_on: [2.2]
- [√] 3.3 在 `packages/frontend/src/views/QuickCommandsView.vue` 中接入分组拖拽、组内命令拖拽、扁平列表拖拽与搜索态禁用逻辑 | depends_on: [3.1, 3.2]
### 4. 验证与同步
- [√] 4.1 执行 `npm run build --workspace @nexus-terminal/backend``npm run build --workspace @nexus-terminal/frontend`,验证类型检查和构建通过 | depends_on: [3.3]
- [√] 4.2 同步更新 `.helloagents/modules/frontend.md``.helloagents/modules/backend.md``.helloagents/CHANGELOG.md`,记录本次拖拽排序能力 | depends_on: [4.1]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-04-19 02:08 | DESIGN | completed | 已确认采用“标签顺序 + 全局命令顺序 + 标签内命令顺序”三层持久化方案 |
| 2026-04-19 02:28 | 1.1 / 1.2 | completed | 已为三张快捷指令相关表补齐 `sort_order` 字段,并完成 repository 层顺序读写与标签关联保序改造 |
| 2026-04-19 02:39 | 2.1 / 2.2 | completed | 已补齐快捷指令与快捷指令标签的重排接口和路由,新增标签默认追加到末尾 |
| 2026-04-19 02:58 | 3.1 / 3.2 | completed | 前端 store 已支持 `manual / name / last_used` 排序模式、顺序元数据解析与重排 action |
| 2026-04-19 03:06 | 3.3 | completed | `QuickCommandsView.vue` 已支持分组拖拽、组内命令拖拽、扁平列表拖拽,并在搜索态禁用重排 |
| 2026-04-19 03:09 | 4.1 | completed | `npm run build --workspace @nexus-terminal/backend``npm run build --workspace @nexus-terminal/frontend` 均通过;前端仅保留既有 chunk size warnings |
| 2026-04-19 03:12 | 4.2 | completed | 已同步 frontend/backend 模块文档与 CHANGELOG,记录快捷指令拖拽排序能力 |
---
## 执行备注
> 本次实现对多标签命令采用“关联表局部顺序 + 命令表全局顺序”的双层建模:标签组内拖拽只影响该标签关联顺序,未标记分组和扁平列表拖拽则回写全局命令顺序,从而避免多标签命令在不同分组中的排序语义互相覆盖。
@@ -0,0 +1 @@
{"status":"completed","completed":4,"failed":0,"pending":0,"total":4,"done":4,"percent":100,"current":"连接卡片默认测试按钮调整已完成,待归档方案包","updated_at":"2026-04-19 02:18:00"}
@@ -0,0 +1,108 @@
# 变更提案: connection-card-default-test-button
## 元信息
```yaml
类型: 修复增强
方案类型: implementation
优先级: P2
状态: 已完成
状态说明: SSH 连接卡片默认显示“连接 / 测试 / 更多”,并从更多菜单移除重复测试入口
创建: 2026-04-19
```
---
## 1. 需求
### 背景
连接管理页当前已将连接卡片行内操作收敛为“连接”主按钮和“更多”菜单,但 SSH 连接常用的单连接“测试”仍被藏在更多菜单里。用户当前希望默认直接看到“连接 / 测试 / 更多”三段式操作区,减少进入二级菜单才能测试的额外点击。
### 目标
- 让 SSH 连接卡片默认显示“连接 / 测试 / 更多”三个按钮。
- 复用现有单连接测试状态、文案和禁用逻辑,不新增后端接口或测试链路。
- 保持非 SSH 连接卡片的默认操作区不变,避免把不支持的测试入口暴露出来。
### 约束条件
```yaml
范围约束: 仅调整 packages/frontend/src/views/ConnectionsView.vue 的连接卡片操作区与相关知识库记录
交互约束: “测试”默认显示仅适用于 SSH 连接,RDP/VNC 仍不提供默认测试按钮
实现约束: 必须复用现有 getSingleTestButtonInfo() 与 handleTestSingleConnection() 逻辑
兼容约束: 保持当前 flex-wrap 布局、主题 token 和批量模式下的现有交互行为
```
### 验收标准
- [x] SSH 连接卡片默认操作区显示“连接 / 测试 / 更多”
- [x] SSH 连接的“测试”状态继续显示既有的测试中 spinner、禁用态与 title
- [x] 更多菜单不再重复显示 SSH 的“测试”项,非 SSH 连接的默认按钮区保持现状
- [x] 前端构建通过,连接管理页相关知识库描述已同步
---
## 2. 方案
### 技术方案
`ConnectionsView.vue` 的连接卡片操作区中,将原本只在 SSH 更多菜单里出现的单连接“测试”操作提升到行内按钮区,插入到“连接”和“更多”之间,并继续通过 `getSingleTestButtonInfo()` 复用文本、图标、禁用状态和提示文案,通过 `handleTestSingleConnection()` 复用既有测试执行链路。与此同时,从 SSH 的更多菜单中移除重复的“测试”项,避免同一动作出现两个入口。
### 影响范围
```yaml
涉及模块:
- frontend: ConnectionsView.vue
- frontend: .helloagents/modules/frontend.md
- workspace-root: .helloagents/CHANGELOG.md 与 archive 归档记录
预计变更文件: 5-7
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 行内按钮增加后在窄宽度卡片上更容易换行 | 低 | 保持现有 `flex flex-wrap` 布局与紧凑按钮尺寸,让小屏自动折行 |
| 若保留菜单里的测试项会导致同一动作重复暴露 | 低 | 将 SSH 更多菜单中的测试入口一并移除,只保留默认按钮 |
| 非 SSH 连接误显示测试入口会造成无效操作认知 | 低 | 明确限定 `conn.type === 'SSH'` 时才显示默认测试按钮 |
---
## 3. 技术设计(可选)
> N/A,本次不涉及架构、接口或数据模型变更。
---
## 4. 核心场景
### 场景: SSH 连接卡片的默认操作区
**模块**: frontend
**条件**: 用户进入连接管理页,列表中存在 SSH 类型连接
**行为**: 每张 SSH 连接卡片默认显示“连接 / 测试 / 更多”三个按钮,点击测试后继续走现有单连接测试逻辑
**结果**: 用户无需进入更多菜单即可直接测试 SSH 连接
### 场景: 非 SSH 连接卡片保持现状
**模块**: frontend
**条件**: 用户进入连接管理页,列表中存在 RDP 或 VNC 类型连接
**行为**: 卡片默认操作区仍保持“连接 / 更多”结构,不显示无效的测试按钮
**结果**: 非 SSH 连接不会暴露不支持的测试入口
---
## 5. 技术决策
> 本方案不涉及新的架构级技术决策;沿用现有连接测试能力与连接管理页设计体系。
---
## 6. 成果设计
### 设计方向
- **美学基调**: 延续当前连接管理页的深色运维控制台风格,通过“一主两辅”的按钮层级表达操作优先级,保持克制而明确的操作密度
- **记忆点**: SSH 连接卡片在行尾固定呈现“连接 / 测试 / 更多”的三按钮条,常用操作一眼可达
- **参考**: 现有 `ConnectionsView.vue` 卡片样式与主题 token 体系
### 视觉要素
- **配色**: 保留主操作按钮 `bg-button` / `text-button-text`,测试与更多按钮继续使用 `bg-background``border-border``hover:bg-border`
- **字体**: 沿用项目现有界面字体体系与 `text-sm` 层级,不引入新的字体表达
- **布局**: 维持右侧按钮区 `flex-wrap`,在主按钮和更多菜单之间插入同级的测试按钮,保证桌面端横排、窄宽度时可自然换行
- **动效**: 延续现有 hover 过渡和测试中 spinner 反馈,不额外引入新动效
- **氛围**: 保持当前卡片式深色面板与边框层次,不改变页面整体视觉基调
### 技术约束
- **可访问性**: 按钮需保留明确文本标签、title 与禁用态视觉反馈
- **响应式**: 不破坏当前 `justify-start xl:justify-end``flex-wrap` 的响应式布局策略
@@ -0,0 +1,50 @@
# 任务清单: connection-card-default-test-button
> **@status:** completed | 2026-04-19 02:18
```yaml
@feature: connection-card-default-test-button
@created: 2026-04-19
@status: completed
@mode: R2
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 4 | 0 | 0 | 4 |
---
## 任务列表
### 1. 方案与范围确认
- [√] 1.1 创建连接卡片默认测试按钮方案包并锁定范围到连接管理页行内操作区 | depends_on: []
### 2. 前端交互调整
- [√] 2.1 将 SSH 连接卡片的“测试”操作提升为默认行内按钮,顺序调整为“连接 / 测试 / 更多” | depends_on: [1.1]
- [√] 2.2 移除 SSH 更多菜单中的重复“测试”入口,并保持非 SSH 连接卡片默认操作区不变 | depends_on: [2.1]
### 3. 验证与同步
- [√] 3.1 运行前端构建并做连接管理页按钮结构验证,同步 frontend 模块文档、CHANGELOG 与归档记录 | depends_on: [2.2]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-04-19 02:10 | 1.1 | 完成 | 创建 implementation 方案包,范围锁定为 SSH 连接卡片默认按钮区调整 |
| 2026-04-19 02:13 | 2.1 | 完成 | 在 `ConnectionsView.vue` 中将 SSH 单连接测试提升为默认行内按钮,位于“连接”和“更多”之间 |
| 2026-04-19 02:14 | 2.2 | 完成 | 删除 SSH 更多菜单中的重复“测试”入口,保留非 SSH 卡片为“连接 / 更多” |
| 2026-04-19 02:18 | 3.1 | 完成 | `npm run build --workspace @nexus-terminal/frontend` 通过;预览环境因缺少后端认证接口仅验证到应用启动与路由守卫重定向 |
---
## 执行备注
> 用户已确认仅 SSH 连接默认显示“测试”按钮,RDP/VNC 保持当前“连接 / 更多”结构。运行态预览可启动,但由于本地预览缺少后端认证接口,`/connections` 会被路由守卫重定向到 `/login`,因此本次对连接卡片交互的最终运行态确认仍需在有登录态的环境中补一次人工目视检查。
+5
View File
@@ -7,6 +7,9 @@
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 | | 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|--------|------|------|---------|------|------| |--------|------|------|---------|------|------|
| 202604190208 | quickcommands-drag-reorder | - | - | - | ✅完成 |
| 202604190210 | connection-card-default-test-button | implementation | frontend | - | ✅完成 |
| 202604190201 | connection-password-visibility-toggle | - | - | - | ✅完成 |
| 202604160350 | workflow-service-scoped-docker-builds | - | - | - | ✅完成 | | 202604160350 | workflow-service-scoped-docker-builds | - | - | - | ✅完成 |
| 202604152323 | status-monitor-reference-layout-parity | implementation | frontend | status-monitor-reference-layout-parity#D001 | ✅完成 | | 202604152323 | status-monitor-reference-layout-parity | implementation | frontend | status-monitor-reference-layout-parity#D001 | ✅完成 |
| 202604152147 | status-monitor-process-manager-modal | - | - | - | ✅完成 | | 202604152147 | status-monitor-process-manager-modal | - | - | - | ✅完成 |
@@ -56,6 +59,8 @@
## 按月归档 ## 按月归档
### 2026-04 ### 2026-04
- [202604190210_connection-card-default-test-button](./2026-04/202604190210_connection-card-default-test-button/) - 将连接管理页 SSH 连接卡片的默认操作区调整为“连接 / 测试 / 更多”,并移除更多菜单中的重复测试入口
- [202604190201_connection-password-visibility-toggle](./2026-04/202604190201_connection-password-visibility-toggle/) - 为连接新增/编辑表单与登录凭证管理弹窗补充密码显隐切换,默认仍隐藏,仅在本地输入端切换明文核对
- [202604152323_status-monitor-reference-layout-parity](./2026-04/202604152323_status-monitor-reference-layout-parity/) - 将右侧状态监控默认视图重排为更贴近参考图的窄屏监控布局,修正顶部信息区与模块内部左右关系 - [202604152323_status-monitor-reference-layout-parity](./2026-04/202604152323_status-monitor-reference-layout-parity/) - 将右侧状态监控默认视图重排为更贴近参考图的窄屏监控布局,修正顶部信息区与模块内部左右关系
- [202604122248_connections-tag-batch-management](./2026-04/202604122248_connections-tag-batch-management/) - 为连接管理页新增标签批量管理弹窗,并补齐后端批量标签删除策略 - [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 时显示完整命令 - [202604120709_quickcommands-double-click-tooltip](./2026-04/202604120709_quickcommands-double-click-tooltip/) - 将快捷命令列表改为单击选中、双击执行,并在 hover 时显示完整命令
+5
View File
@@ -80,3 +80,8 @@
**条件**: `StatusMonitorService` 为前端工作区持续轮询服务器状态。 **条件**: `StatusMonitorService` 为前端工作区持续轮询服务器状态。
**行为**: 当前状态采集链路除 `free``df``/proc/stat``/proc/net/dev` 外,还会补充解析 `memFree``memCached``diskAvailable``diskMountPoint``diskFsType``diskDevice`,并基于 `/proc/diskstats` 计算根设备的磁盘读写速率;CPU 规格信息则会先读取 CPU 型号,再通过 `nproc``getconf _NPROCESSORS_ONLN``grep -c '^processor' /proc/cpuinfo``lscpu` 多级回退获取 `cpuCores`;本轮还新增服务器时区、运行时间和默认进程摘要采集。与此同时,`websocket/connection.ts` 新增 `process:list``process:signal` 消息分发,后端会在当前活动 SSH 会话上下文中执行 `ps``kill` 指令,返回完整进程列表及结束/强制结束结果。 **行为**: 当前状态采集链路除 `free``df``/proc/stat``/proc/net/dev` 外,还会补充解析 `memFree``memCached``diskAvailable``diskMountPoint``diskFsType``diskDevice`,并基于 `/proc/diskstats` 计算根设备的磁盘读写速率;CPU 规格信息则会先读取 CPU 型号,再通过 `nproc``getconf _NPROCESSORS_ONLN``grep -c '^processor' /proc/cpuinfo``lscpu` 多级回退获取 `cpuCores`;本轮还新增服务器时区、运行时间和默认进程摘要采集。与此同时,`websocket/connection.ts` 新增 `process:list``process:signal` 消息分发,后端会在当前活动 SSH 会话上下文中执行 `ps``kill` 指令,返回完整进程列表及结束/强制结束结果。
**结果**: 前端默认状态监控可以展示更完整的小屏监控信息,而“查看全部”进程管理 modal 也能沿同一 SSH 会话上下文安全复用进程查询与操作能力。 **结果**: 前端默认状态监控可以展示更完整的小屏监控信息,而“查看全部”进程管理 modal 也能沿同一 SSH 会话上下文安全复用进程查询与操作能力。
### 快捷指令顺序持久化
**条件**: 前端快捷指令视图提交分组拖拽、标签内命令拖拽或扁平列表命令拖拽结果。
**行为**: packages/backend/src/database/schema.ts 与 migrations.ts 现在为 quick_commands、quick_command_tags 与 quick_command_tag_associations 三张表补齐 sort_order 字段;quick-commands 业务域新增 /api/v1/quick-commands/reorder 与 /api/v1/quick-commands/reorder-by-tagquick-command-tags 业务域新增 /api/v1/quick-command-tags/reorder。同时标签关联写入从“先删后插”调整为增量同步,保留命令已存在标签关联的原组内顺序,仅为新增关联追加新的末尾顺序。
**结果**: 后端可以稳定表达“标签顺序”“命令全局顺序”和“命令在某个标签组内的局部顺序”三层语义,并保证历史数据库升级后也能直接承接前端拖拽排序能力。
File diff suppressed because one or more lines are too long
@@ -334,6 +334,42 @@ const definedMigrations: Migration[] = [
ALTER TABLE connections ADD COLUMN login_credential_id INTEGER NULL REFERENCES login_credentials(id) ON DELETE SET NULL; ALTER TABLE connections ADD COLUMN login_credential_id INTEGER NULL REFERENCES login_credentials(id) ON DELETE SET NULL;
` `
},
{
id: 12,
name: 'Add sort_order column to quick_commands table',
check: async (db: Database): Promise<boolean> => {
const columnAlreadyExists = await columnExists(db, 'quick_commands', 'sort_order');
return !columnAlreadyExists;
},
sql: `
ALTER TABLE quick_commands ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
UPDATE quick_commands SET sort_order = id WHERE sort_order = 0;
`
},
{
id: 13,
name: 'Add sort_order column to quick_command_tags table',
check: async (db: Database): Promise<boolean> => {
const columnAlreadyExists = await columnExists(db, 'quick_command_tags', 'sort_order');
return !columnAlreadyExists;
},
sql: `
ALTER TABLE quick_command_tags ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
UPDATE quick_command_tags SET sort_order = id WHERE sort_order = 0;
`
},
{
id: 14,
name: 'Add sort_order column to quick_command_tag_associations table',
check: async (db: Database): Promise<boolean> => {
const columnAlreadyExists = await columnExists(db, 'quick_command_tag_associations', 'sort_order');
return !columnAlreadyExists;
},
sql: `
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;
`
} }
]; ];
+3
View File
@@ -187,6 +187,7 @@ CREATE TABLE IF NOT EXISTS quick_commands (
command TEXT NOT NULL, -- 指令必选 command TEXT NOT NULL, -- 指令必选
usage_count INTEGER NOT NULL DEFAULT 0, -- 使用频率 usage_count INTEGER NOT NULL DEFAULT 0, -- 使用频率
variables TEXT NULL, variables TEXT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
); );
@@ -198,6 +199,7 @@ export const createQuickCommandTagsTableSQL = `
CREATE TABLE IF NOT EXISTS quick_command_tags ( CREATE TABLE IF NOT EXISTS quick_command_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
); );
@@ -207,6 +209,7 @@ export const createQuickCommandTagAssociationsTableSQL = `
CREATE TABLE IF NOT EXISTS quick_command_tag_associations ( CREATE TABLE IF NOT EXISTS quick_command_tag_associations (
quick_command_id INTEGER NOT NULL, quick_command_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL, tag_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (quick_command_id, tag_id), PRIMARY KEY (quick_command_id, tag_id),
FOREIGN KEY (quick_command_id) REFERENCES quick_commands(id) ON DELETE CASCADE, FOREIGN KEY (quick_command_id) REFERENCES quick_commands(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES quick_command_tags(id) ON DELETE CASCADE FOREIGN KEY (tag_id) REFERENCES quick_command_tags(id) ON DELETE CASCADE
@@ -1,25 +1,21 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as QuickCommandTagService from './quick-command-tag.service'; import * as QuickCommandTagService from './quick-command-tag.service';
/** const isNumberArray = (value: unknown): value is number[] =>
* 处理获取所有快捷指令标签的请求 Array.isArray(value) && value.every((item) => typeof item === 'number' && Number.isFinite(item));
*/
export const getAllQuickCommandTags = async (req: Request, res: Response): Promise<void> => { export const getAllQuickCommandTags = async (req: Request, res: Response): Promise<void> => {
try { try {
const tags = await QuickCommandTagService.getAllQuickCommandTags(); const tags = await QuickCommandTagService.getAllQuickCommandTags();
res.status(200).json(tags); res.status(200).json(tags);
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 获取快捷指令标签列表失败:', error.message); console.error('[QuickCommandTagController] 获取快捷指令标签列表失败:', error.message);
res.status(500).json({ message: error.message || '无法获取快捷指令标签列表' }); res.status(500).json({ message: error.message || '无法获取快捷指令标签列表' });
} }
}; };
/**
* 处理添加新快捷指令标签的请求
*/
export const addQuickCommandTag = async (req: Request, res: Response): Promise<void> => { export const addQuickCommandTag = async (req: Request, res: Response): Promise<void> => {
const { name } = req.body; const { name } = req.body;
if (!name || typeof name !== 'string' || name.trim().length === 0) { if (!name || typeof name !== 'string' || name.trim().length === 0) {
res.status(400).json({ message: '标签名称不能为空且必须是字符串' }); res.status(400).json({ message: '标签名称不能为空且必须是字符串' });
return; return;
@@ -27,37 +23,33 @@ export const addQuickCommandTag = async (req: Request, res: Response): Promise<v
try { try {
const newId = await QuickCommandTagService.addQuickCommandTag(name); const newId = await QuickCommandTagService.addQuickCommandTag(name);
// 成功添加后,获取新创建的标签信息返回给前端
const newTag = await QuickCommandTagService.getQuickCommandTagById(newId); const newTag = await QuickCommandTagService.getQuickCommandTagById(newId);
if (newTag) { if (newTag) {
res.status(201).json({ message: '快捷指令标签已添加', tag: newTag }); res.status(201).json({ message: '快捷指令标签已添加', tag: newTag });
} else { return;
// 理论上不应该发生,但作为健壮性检查
console.error(`[Controller] 添加快捷指令标签后未能找到 ID: ${newId}`);
res.status(201).json({ message: '快捷指令标签已添加,但无法检索新记录', id: newId });
} }
res.status(201).json({ message: '快捷指令标签已添加,但无法检索新记录', id: newId });
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 添加快捷指令标签失败:', error.message); console.error('[QuickCommandTagController] 添加快捷指令标签失败:', error.message);
// 检查是否是名称重复错误 if (error.message?.includes('已存在')) {
if (error.message && error.message.includes('已存在')) { res.status(409).json({ message: error.message });
res.status(409).json({ message: error.message }); // 409 Conflict return;
} else {
res.status(500).json({ message: error.message || '无法添加快捷指令标签' });
} }
res.status(500).json({ message: error.message || '无法添加快捷指令标签' });
} }
}; };
/**
* 处理更新快捷指令标签的请求
*/
export const updateQuickCommandTag = async (req: Request, res: Response): Promise<void> => { export const updateQuickCommandTag = async (req: Request, res: Response): Promise<void> => {
const id = parseInt(req.params.id, 10); const id = Number.parseInt(req.params.id, 10);
const { name } = req.body; const { name } = req.body;
if (isNaN(id)) { if (Number.isNaN(id)) {
res.status(400).json({ message: '无效的标签 ID' }); res.status(400).json({ message: '无效的标签 ID' });
return; return;
} }
if (!name || typeof name !== 'string' || name.trim().length === 0) { if (!name || typeof name !== 'string' || name.trim().length === 0) {
res.status(400).json({ message: '标签名称不能为空且必须是字符串' }); res.status(400).json({ message: '标签名称不能为空且必须是字符串' });
return; return;
@@ -65,66 +57,67 @@ export const updateQuickCommandTag = async (req: Request, res: Response): Promis
try { try {
const success = await QuickCommandTagService.updateQuickCommandTag(id, name); const success = await QuickCommandTagService.updateQuickCommandTag(id, name);
if (success) { if (!success) {
// 成功更新后,获取更新后的标签信息返回给前端
const updatedTag = await QuickCommandTagService.getQuickCommandTagById(id);
if (updatedTag) {
res.status(200).json({ message: '快捷指令标签已更新', tag: updatedTag });
} else {
console.error(`[Controller] 更新快捷指令标签后未能找到 ID: ${id}`);
res.status(200).json({ message: '快捷指令标签已更新,但无法检索更新后的记录' });
}
} else {
// 检查标签是否真的不存在
const tagExists = await QuickCommandTagService.getQuickCommandTagById(id); const tagExists = await QuickCommandTagService.getQuickCommandTagById(id);
if (!tagExists) { res.status(tagExists ? 500 : 404).json({
res.status(404).json({ message: '未找到要更新的快捷指令标签' }); message: tagExists ? '更新快捷指令标签时发生未知错误' : '未找到要更新的快捷指令标签',
} else { });
// 如果标签存在但更新失败(理论上不太可能,除非并发问题),返回服务器错误 return;
console.error(`[Controller] 更新快捷指令标签 ${id} 失败,但标签存在。`);
res.status(500).json({ message: '更新快捷指令标签时发生未知错误' });
}
} }
const updatedTag = await QuickCommandTagService.getQuickCommandTagById(id);
if (updatedTag) {
res.status(200).json({ message: '快捷指令标签已更新', tag: updatedTag });
return;
}
res.status(200).json({ message: '快捷指令标签已更新,但无法检索更新后的记录' });
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 更新快捷指令标签失败:', error.message); console.error('[QuickCommandTagController] 更新快捷指令标签失败:', error.message);
// 检查是否是名称重复错误 if (error.message?.includes('已存在')) {
if (error.message && error.message.includes('已存在')) { res.status(409).json({ message: error.message });
res.status(409).json({ message: error.message }); // 409 Conflict return;
} else {
res.status(500).json({ message: error.message || '无法更新快捷指令标签' });
} }
res.status(500).json({ message: error.message || '无法更新快捷指令标签' });
} }
}; };
/**
* 处理删除快捷指令标签的请求
*/
export const deleteQuickCommandTag = async (req: Request, res: Response): Promise<void> => { export const deleteQuickCommandTag = async (req: Request, res: Response): Promise<void> => {
const id = parseInt(req.params.id, 10); const id = Number.parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
if (isNaN(id)) {
res.status(400).json({ message: '无效的标签 ID' }); res.status(400).json({ message: '无效的标签 ID' });
return; return;
} }
try { try {
// 先检查标签是否存在,以便返回 404
const tagExists = await QuickCommandTagService.getQuickCommandTagById(id); const tagExists = await QuickCommandTagService.getQuickCommandTagById(id);
if (!tagExists) { if (!tagExists) {
res.status(404).json({ message: '未找到要删除的快捷指令标签' }); res.status(404).json({ message: '未找到要删除的快捷指令标签' });
return; return;
} }
const success = await QuickCommandTagService.deleteQuickCommandTag(id); const success = await QuickCommandTagService.deleteQuickCommandTag(id);
if (success) { res.status(success ? 200 : 500).json({
res.status(200).json({ message: '快捷指令标签已删除' }); message: success ? '快捷指令标签已删除' : '删除快捷指令标签时发生未知错误',
} else { });
// 如果上面检查存在但删除失败,说明有内部错误
console.error(`[Controller] 删除快捷指令标签 ${id} 失败,但标签存在。`);
res.status(500).json({ message: '删除快捷指令标签时发生未知错误' });
}
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 删除快捷指令标签失败:', error.message); console.error('[QuickCommandTagController] 删除快捷指令标签失败:', error.message);
res.status(500).json({ message: error.message || '无法删除快捷指令标签' }); res.status(500).json({ message: error.message || '无法删除快捷指令标签' });
} }
}; };
export const reorderQuickCommandTags = async (req: Request, res: Response): Promise<void> => {
const { tagIds } = req.body;
if (!isNumberArray(tagIds) || tagIds.length === 0) {
res.status(400).json({ message: 'tagIds 必须是非空数字数组' });
return;
}
try {
await QuickCommandTagService.reorderQuickCommandTags(tagIds);
res.status(200).json({ message: '快捷指令标签顺序已更新' });
} catch (error: any) {
console.error('[QuickCommandTagController] 更新快捷指令标签顺序失败:', error.message);
res.status(500).json({ message: error.message || '无法更新快捷指令标签顺序' });
}
};
@@ -1,197 +1,268 @@
import { Database } from 'sqlite3';
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
// 定义 Quick Command Tag 类型
export interface QuickCommandTag { export interface QuickCommandTag {
id: number; id: number;
name: string; name: string;
sort_order: number;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
} }
/** interface CommandTagAssociationRow {
* 获取所有快捷指令标签 tag_id: number;
*/ sort_order: number;
}
const getNextTagSortOrder = async (db: Awaited<ReturnType<typeof getDbInstance>>): Promise<number> => {
const row = await getDbRow<{ nextSortOrder?: number }>(
db,
'SELECT COALESCE(MAX(sort_order), 0) + 1 AS nextSortOrder FROM quick_command_tags'
);
return row?.nextSortOrder ?? 1;
};
const getNextAssociationSortOrder = async (
db: Awaited<ReturnType<typeof getDbInstance>>,
tagId: number,
): Promise<number> => {
const row = await getDbRow<{ nextSortOrder?: number }>(
db,
'SELECT COALESCE(MAX(sort_order), 0) + 1 AS nextSortOrder FROM quick_command_tag_associations WHERE tag_id = ?',
[tagId],
);
return row?.nextSortOrder ?? 1;
};
export const findAllQuickCommandTags = async (): Promise<QuickCommandTag[]> => { export const findAllQuickCommandTags = async (): Promise<QuickCommandTag[]> => {
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const rows = await allDb<QuickCommandTag>(db, `SELECT * FROM quick_command_tags ORDER BY name ASC`); return await allDb<QuickCommandTag>(
return rows; db,
'SELECT * FROM quick_command_tags ORDER BY sort_order ASC, name ASC',
);
} catch (err: any) { } catch (err: any) {
console.error('[仓库] 查询快捷指令标签列表时出错:', err.message); console.error('[QuickCommandTagRepository] 查询快捷指令标签列表失败:', err.message);
throw new Error('获取快捷指令标签列表失败'); throw new Error('获取快捷指令标签列表失败');
} }
}; };
/**
* 根据 ID 获取单个快捷指令标签
*/
export const findQuickCommandTagById = async (id: number): Promise<QuickCommandTag | null> => { export const findQuickCommandTagById = async (id: number): Promise<QuickCommandTag | null> => {
try {
const db = await getDbInstance();
const row = await getDbRow<QuickCommandTag>(db, `SELECT * FROM quick_command_tags WHERE id = ?`, [id]);
return row || null;
} catch (err: any) {
console.error(`[仓库] 查询快捷指令标签 ${id} 时出错:`, err.message);
throw new Error('获取快捷指令标签信息失败');
}
};
/**
* 创建新快捷指令标签
*/
export const createQuickCommandTag = async (name: string): Promise<number> => {
const now = Math.floor(Date.now() / 1000);
const sql = `INSERT INTO quick_command_tags (name, created_at, updated_at) VALUES (?, ?, ?)`;
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const result = await runDb(db, sql, [name, now, now]); const row = await getDbRow<QuickCommandTag>(db, 'SELECT * FROM quick_command_tags WHERE id = ?', [id]);
return row ?? null;
} catch (err: any) {
console.error(`[QuickCommandTagRepository] 查询快捷指令标签 ${id} 失败:`, err.message);
throw new Error('获取快捷指令标签信息失败');
}
};
export const createQuickCommandTag = async (name: string): Promise<number> => {
try {
const db = await getDbInstance();
const now = Math.floor(Date.now() / 1000);
const sortOrder = await getNextTagSortOrder(db);
const result = await runDb(
db,
'INSERT INTO quick_command_tags (name, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?)',
[name, sortOrder, now, now],
);
if (typeof result.lastID !== 'number' || result.lastID <= 0) { if (typeof result.lastID !== 'number' || result.lastID <= 0) {
throw new Error('创建快捷指令标签后未能获取有效的 lastID'); throw new Error('创建快捷指令标签后未能获取有效的 lastID');
} }
return result.lastID; return result.lastID;
} catch (err: any) { } catch (err: any) {
console.error('[仓库] 创建快捷指令标签时出错:', err.message); console.error('[QuickCommandTagRepository] 创建快捷指令标签失败:', err.message);
if (err.message.includes('UNIQUE constraint failed')) { if (err.message.includes('UNIQUE constraint failed')) {
throw new Error(`快捷指令标签名称 "${name}" 已存在。`); throw new Error(`快捷指令标签名称 "${name}" 已存在。`);
} }
throw new Error(`创建快捷指令标签失败: ${err.message}`); throw new Error(`创建快捷指令标签失败: ${err.message}`);
} }
}; };
/**
* 更新快捷指令标签名称
*/
export const updateQuickCommandTag = async (id: number, name: string): Promise<boolean> => { export const updateQuickCommandTag = async (id: number, name: string): Promise<boolean> => {
const now = Math.floor(Date.now() / 1000);
const sql = `UPDATE quick_command_tags SET name = ?, updated_at = ? WHERE id = ?`;
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const result = await runDb(db, sql, [name, now, id]); const now = Math.floor(Date.now() / 1000);
const result = await runDb(
db,
'UPDATE quick_command_tags SET name = ?, updated_at = ? WHERE id = ?',
[name, now, id],
);
return result.changes > 0; return result.changes > 0;
} catch (err: any) { } catch (err: any) {
console.error(`[仓库] 更新快捷指令标签 ${id} 时出错:`, err.message); console.error(`[QuickCommandTagRepository] 更新快捷指令标签 ${id} 失败:`, err.message);
if (err.message.includes('UNIQUE constraint failed')) { if (err.message.includes('UNIQUE constraint failed')) {
throw new Error(`快捷指令标签名称 "${name}" 已存在。`); throw new Error(`快捷指令标签名称 "${name}" 已存在。`);
} }
throw new Error(`更新快捷指令标签失败: ${err.message}`); throw new Error(`更新快捷指令标签失败: ${err.message}`);
} }
}; };
/**
* 删除快捷指令标签 (同时会通过外键 CASCADE 删除关联)
*/
export const deleteQuickCommandTag = async (id: number): Promise<boolean> => { export const deleteQuickCommandTag = async (id: number): Promise<boolean> => {
const sql = `DELETE FROM quick_command_tags WHERE id = ?`;
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
// 由于 quick_command_tag_associations 设置了 ON DELETE CASCADE, const result = await runDb(db, 'DELETE FROM quick_command_tags WHERE id = ?', [id]);
// 删除 quick_command_tags 中的记录会自动删除关联表中的相关记录。
const result = await runDb(db, sql, [id]);
return result.changes > 0; return result.changes > 0;
} catch (err: any) { } catch (err: any) {
console.error(`[仓库] 删除快捷指令标签 ${id} 时出错:`, err.message); console.error(`[QuickCommandTagRepository] 删除快捷指令标签 ${id} 失败:`, err.message);
throw new Error('删除快捷指令标签失败'); throw new Error('删除快捷指令标签失败');
} }
}; };
/** export const reorderQuickCommandTags = async (tagIds: number[]): Promise<void> => {
* 设置单个快捷指令的标签关联 (先删除旧关联,再插入新关联) const normalizedTagIds = Array.from(new Set(tagIds.filter((tagId) => Number.isInteger(tagId) && tagId > 0)));
* @param commandId - 快捷指令 ID if (normalizedTagIds.length === 0) {
* @param tagIds - 新的标签 ID 数组 (空数组表示清除所有关联) return;
* @returns Promise<void> }
*/
export const setCommandTagAssociations = async (commandId: number, tagIds: number[]): Promise<void> => {
const db = await getDbInstance();
const deleteSql = `DELETE FROM quick_command_tag_associations WHERE quick_command_id = ?`;
const insertSql = `INSERT INTO quick_command_tag_associations (quick_command_id, tag_id) VALUES (?, ?)`;
const db = await getDbInstance();
try { try {
await runDb(db, 'BEGIN TRANSACTION'); await runDb(db, 'BEGIN TRANSACTION');
// 1. 删除该指令的所有旧关联 for (let index = 0; index < normalizedTagIds.length; index += 1) {
await runDb(db, deleteSql, [commandId]); await runDb(
db,
// 2. 插入新关联 (如果 tagIds 不为空) 'UPDATE quick_command_tags SET sort_order = ?, updated_at = strftime(\'%s\', \'now\') WHERE id = ?',
if (tagIds && tagIds.length > 0) { [index + 1, normalizedTagIds[index]],
const stmt = await db.prepare(insertSql); );
for (const tagId of tagIds) {
// 验证 tagId 是否为有效数字
if (typeof tagId !== 'number' || isNaN(tagId)) {
console.warn(`[Repo] setCommandTagAssociations: 无效的 tagId (${tagId}),跳过关联到指令 ${commandId}`);
continue;
}
await stmt.run(commandId, tagId);
}
await stmt.finalize();
} }
await runDb(db, 'COMMIT'); await runDb(db, 'COMMIT');
} catch (err: any) { } catch (err: any) {
console.error('设置快捷指令标签关联时出错:', err.message); await runDb(db, 'ROLLBACK');
await runDb(db, 'ROLLBACK'); // 出错时回滚 console.error('[QuickCommandTagRepository] 重排快捷指令标签失败:', err.message);
throw new Error('无法更新快捷指令标签顺序');
}
};
export const setCommandTagAssociations = async (commandId: number, tagIds: number[]): Promise<void> => {
const normalizedTagIds = Array.from(new Set(tagIds.filter((tagId) => Number.isInteger(tagId) && tagId > 0)));
const db = await getDbInstance();
try {
const existingAssociations = await allDb<CommandTagAssociationRow>(
db,
'SELECT tag_id, sort_order FROM quick_command_tag_associations WHERE quick_command_id = ?',
[commandId],
);
const existingTagIds = new Set(existingAssociations.map((association) => association.tag_id));
await runDb(db, 'BEGIN TRANSACTION');
if (normalizedTagIds.length === 0) {
await runDb(db, 'DELETE FROM quick_command_tag_associations WHERE quick_command_id = ?', [commandId]);
} else {
const placeholders = normalizedTagIds.map(() => '?').join(', ');
await runDb(
db,
`DELETE FROM quick_command_tag_associations WHERE quick_command_id = ? AND tag_id NOT IN (${placeholders})`,
[commandId, ...normalizedTagIds],
);
for (const tagId of normalizedTagIds) {
if (existingTagIds.has(tagId)) {
continue;
}
const nextSortOrder = await getNextAssociationSortOrder(db, tagId);
await runDb(
db,
'INSERT INTO quick_command_tag_associations (quick_command_id, tag_id, sort_order) VALUES (?, ?, ?)',
[commandId, tagId, nextSortOrder],
);
}
}
await runDb(db, 'COMMIT');
} catch (err: any) {
await runDb(db, 'ROLLBACK');
console.error('[QuickCommandTagRepository] 设置快捷指令标签关联失败:', err.message);
throw new Error('无法设置快捷指令标签关联'); throw new Error('无法设置快捷指令标签关联');
} }
}; };
/**
* 将单个标签批量添加到多个快捷指令
* @param commandIds - 需要添加标签的快捷指令 ID 数组
* @param tagId - 要添加的标签 ID
* @returns Promise<void>
*/
export const addTagToCommands = async (commandIds: number[], tagId: number): Promise<void> => { export const addTagToCommands = async (commandIds: number[], tagId: number): Promise<void> => {
if (!commandIds || commandIds.length === 0) { const normalizedCommandIds = Array.from(
return; // 没有指令需要关联 new Set(commandIds.filter((commandId) => Number.isInteger(commandId) && commandId > 0)),
);
if (normalizedCommandIds.length === 0 || !Number.isInteger(tagId) || tagId <= 0) {
return;
} }
const db = await getDbInstance(); const db = await getDbInstance();
const insertSql = `INSERT OR IGNORE INTO quick_command_tag_associations (quick_command_id, tag_id) VALUES (?, ?)`;
try { try {
await runDb(db, 'BEGIN TRANSACTION'); await runDb(db, 'BEGIN TRANSACTION');
// 准备批量插入语句 for (const commandId of normalizedCommandIds) {
const stmt = await db.prepare(insertSql); const existingAssociation = await getDbRow<{ quick_command_id: number }>(
for (const commandId of commandIds) { db,
// 验证 commandId 和 tagId 是否为有效数字(可选,但推荐) 'SELECT quick_command_id FROM quick_command_tag_associations WHERE quick_command_id = ? AND tag_id = ?',
if (typeof commandId !== 'number' || isNaN(commandId) || typeof tagId !== 'number' || isNaN(tagId)) { [commandId, tagId],
console.warn(`[Repo] addTagToCommands: 无效的 commandId (${commandId}) 或 tagId (${tagId}),跳过关联。`); );
continue;
if (existingAssociation) {
continue;
} }
await stmt.run(commandId, tagId);
const nextSortOrder = await getNextAssociationSortOrder(db, tagId);
await runDb(
db,
'INSERT INTO quick_command_tag_associations (quick_command_id, tag_id, sort_order) VALUES (?, ?, ?)',
[commandId, tagId, nextSortOrder],
);
} }
await stmt.finalize(); // 完成批量插入
await runDb(db, 'COMMIT'); await runDb(db, 'COMMIT');
console.log(`[Repo] addTagToCommands: 成功将标签 ${tagId} 关联到 ${commandIds.length} 个指令。`);
} catch (err: any) { } catch (err: any) {
console.error(`[Repo] addTagToCommands: 批量关联标签 ${tagId} 到指令时出错:`, err.message);
await runDb(db, 'ROLLBACK'); await runDb(db, 'ROLLBACK');
console.error(`[QuickCommandTagRepository] 批量关联标签 ${tagId} 失败:`, err.message);
throw new Error('无法批量关联标签到快捷指令'); throw new Error('无法批量关联标签到快捷指令');
} }
}; };
/** export const reorderCommandsInTag = async (tagId: number, commandIds: number[]): Promise<void> => {
* 更新指定快捷指令的标签关联 (使用事务) const normalizedCommandIds = Array.from(
* @param commandId 快捷指令 ID new Set(commandIds.filter((commandId) => Number.isInteger(commandId) && commandId > 0)),
* @param tagIds 新的快捷指令标签 ID 数组 (空数组表示清除所有标签) );
*/
// Removed the duplicate function declaration that returned Promise<boolean> if (!Number.isInteger(tagId) || tagId <= 0 || normalizedCommandIds.length === 0) {
return;
}
const db = await getDbInstance();
try {
await runDb(db, 'BEGIN TRANSACTION');
for (let index = 0; index < normalizedCommandIds.length; index += 1) {
await runDb(
db,
'UPDATE quick_command_tag_associations SET sort_order = ? WHERE tag_id = ? AND quick_command_id = ?',
[index + 1, tagId, normalizedCommandIds[index]],
);
}
await runDb(db, 'COMMIT');
} catch (err: any) {
await runDb(db, 'ROLLBACK');
console.error(`[QuickCommandTagRepository] 重排标签 ${tagId} 内命令失败:`, err.message);
throw new Error('无法更新标签内快捷指令顺序');
}
};
/**
* 查找指定快捷指令的所有标签
* @param commandId 快捷指令 ID
* @returns 标签对象数组 { id: number, name: string }[]
*/
export const findTagsByCommandId = async (commandId: number): Promise<QuickCommandTag[]> => { export const findTagsByCommandId = async (commandId: number): Promise<QuickCommandTag[]> => {
const sql = ` const sql = `
SELECT t.id, t.name, t.created_at, t.updated_at SELECT t.id, t.name, t.sort_order, t.created_at, t.updated_at
FROM quick_command_tags t FROM quick_command_tags t
JOIN quick_command_tag_associations ta ON t.id = ta.tag_id JOIN quick_command_tag_associations ta ON t.id = ta.tag_id
WHERE ta.quick_command_id = ? WHERE ta.quick_command_id = ?
ORDER BY t.name ASC`; ORDER BY ta.sort_order ASC, t.name ASC`;
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const rows = await allDb<QuickCommandTag>(db, sql, [commandId]); return await allDb<QuickCommandTag>(db, sql, [commandId]);
return rows;
} catch (err: any) { } catch (err: any) {
console.error(`Repository: 查询快捷指令 ${commandId} 的标签时出错:`, err.message); console.error(`[QuickCommandTagRepository] 查询快捷指令 ${commandId} 的标签失败:`, err.message);
throw new Error('获取快捷指令标签失败'); throw new Error('获取快捷指令标签失败');
} }
}; };
@@ -1,19 +1,13 @@
import express from 'express'; import express from 'express';
import * as QuickCommandTagController from './quick-command-tag.controller'; import * as QuickCommandTagController from './quick-command-tag.controller';
import { isAuthenticated } from '../auth/auth.middleware'; // 假设需要认证 import { isAuthenticated } from '../auth/auth.middleware';
const router = express.Router(); const router = express.Router();
// 获取所有快捷指令标签
router.get('/', isAuthenticated, QuickCommandTagController.getAllQuickCommandTags); router.get('/', isAuthenticated, QuickCommandTagController.getAllQuickCommandTags);
// 添加新的快捷指令标签
router.post('/', isAuthenticated, QuickCommandTagController.addQuickCommandTag); router.post('/', isAuthenticated, QuickCommandTagController.addQuickCommandTag);
router.put('/reorder', isAuthenticated, QuickCommandTagController.reorderQuickCommandTags);
// 更新快捷指令标签
router.put('/:id', isAuthenticated, QuickCommandTagController.updateQuickCommandTag); router.put('/:id', isAuthenticated, QuickCommandTagController.updateQuickCommandTag);
// 删除快捷指令标签
router.delete('/:id', isAuthenticated, QuickCommandTagController.deleteQuickCommandTag); router.delete('/:id', isAuthenticated, QuickCommandTagController.deleteQuickCommandTag);
export default router; export default router;
@@ -1,118 +1,50 @@
import * as QuickCommandTagRepository from '../quick-command-tags/quick-command-tag.repository'; import * as QuickCommandTagRepository from '../quick-command-tags/quick-command-tag.repository';
import { QuickCommandTag } from '../quick-command-tags/quick-command-tag.repository'; import { QuickCommandTag } from '../quick-command-tags/quick-command-tag.repository';
/**
* 获取所有快捷指令标签
*/
export const getAllQuickCommandTags = async (): Promise<QuickCommandTag[]> => { export const getAllQuickCommandTags = async (): Promise<QuickCommandTag[]> => {
return QuickCommandTagRepository.findAllQuickCommandTags(); return QuickCommandTagRepository.findAllQuickCommandTags();
}; };
/**
* 根据 ID 获取单个快捷指令标签
*/
export const getQuickCommandTagById = async (id: number): Promise<QuickCommandTag | null> => { export const getQuickCommandTagById = async (id: number): Promise<QuickCommandTag | null> => {
return QuickCommandTagRepository.findQuickCommandTagById(id); return QuickCommandTagRepository.findQuickCommandTagById(id);
}; };
/**
* 添加新的快捷指令标签
* @param name 标签名称
* @returns 返回新标签的 ID
*/
export const addQuickCommandTag = async (name: string): Promise<number> => { export const addQuickCommandTag = async (name: string): Promise<number> => {
if (!name || name.trim().length === 0) { if (!name || name.trim().length === 0) {
throw new Error('标签名称不能为空'); throw new Error('标签名称不能为空');
} }
const trimmedName = name.trim();
// 可以在这里添加更多验证逻辑,例如检查名称格式等 return QuickCommandTagRepository.createQuickCommandTag(name.trim());
try {
const newId = await QuickCommandTagRepository.createQuickCommandTag(trimmedName);
return newId;
} catch (error: any) {
// Service 层可以重新抛出或处理 Repository 抛出的错误
console.error(`[Service] 添加快捷指令标签 "${trimmedName}" 失败:`, error.message);
throw error; // 重新抛出,让 Controller 处理 HTTP 响应
}
}; };
/**
* 更新快捷指令标签
* @param id 标签 ID
* @param name 新的标签名称
* @returns 返回是否成功更新
*/
export const updateQuickCommandTag = async (id: number, name: string): Promise<boolean> => { export const updateQuickCommandTag = async (id: number, name: string): Promise<boolean> => {
if (!name || name.trim().length === 0) { if (!name || name.trim().length === 0) {
throw new Error('标签名称不能为空'); throw new Error('标签名称不能为空');
} }
const trimmedName = name.trim();
// 可以在这里添加更多验证逻辑 return QuickCommandTagRepository.updateQuickCommandTag(id, name.trim());
try {
const success = await QuickCommandTagRepository.updateQuickCommandTag(id, trimmedName);
if (!success) {
// 可能需要检查标签是否存在,或者让 Repository 处理
console.warn(`[Service] 尝试更新不存在的快捷指令标签 ID: ${id}`);
}
return success;
} catch (error: any) {
console.error(`[Service] 更新快捷指令标签 ${id} 失败:`, error.message);
throw error;
}
}; };
/**
* 删除快捷指令标签
* @param id 标签 ID
* @returns 返回是否成功删除
*/
export const deleteQuickCommandTag = async (id: number): Promise<boolean> => { export const deleteQuickCommandTag = async (id: number): Promise<boolean> => {
try { return QuickCommandTagRepository.deleteQuickCommandTag(id);
const success = await QuickCommandTagRepository.deleteQuickCommandTag(id);
if (!success) {
console.warn(`[Service] 尝试删除不存在的快捷指令标签 ID: ${id}`);
}
return success;
} catch (error: any) {
console.error(`[Service] 删除快捷指令标签 ${id} 失败:`, error.message);
throw error;
}
}; };
/**
* 设置指定快捷指令的标签关联
* @param commandId 快捷指令 ID
* @param tagIds 新的快捷指令标签 ID 数组
* @returns Promise<void>
*/
export const setCommandTags = async (commandId: number, tagIds: number[]): Promise<void> => { export const setCommandTags = async (commandId: number, tagIds: number[]): Promise<void> => {
// 验证 tagIds 是否为数字数组 (基本验证) if (!Array.isArray(tagIds) || !tagIds.every((id) => typeof id === 'number')) {
if (!Array.isArray(tagIds) || !tagIds.every(id => typeof id === 'number')) { throw new Error('标签 ID 列表必须是数字数组');
throw new Error('标签 ID 列表必须是一个数字数组');
} }
// 可以在这里添加更复杂的验证,例如检查 tagIds 是否都存在于 quick_command_tags 表中
// 但 Repository 中的 setCommandTagAssociations 已包含基本的检查和错误处理
try { await QuickCommandTagRepository.setCommandTagAssociations(commandId, tagIds);
// 直接调用 Repository 处理关联更新 (Repository 函数现在返回 void)
await QuickCommandTagRepository.setCommandTagAssociations(commandId, tagIds);
// Service 函数也返回 void,所以不需要 return
} catch (error: any) {
console.error(`[Service] 设置快捷指令 ${commandId} 的标签失败:`, error.message);
throw error;
}
}; };
/**
* 获取指定快捷指令的所有标签
* @param commandId 快捷指令 ID
* @returns 标签对象数组
*/
export const getTagsForCommand = async (commandId: number): Promise<QuickCommandTag[]> => { export const getTagsForCommand = async (commandId: number): Promise<QuickCommandTag[]> => {
try { return QuickCommandTagRepository.findTagsByCommandId(commandId);
return await QuickCommandTagRepository.findTagsByCommandId(commandId); };
} catch (error: any) {
console.error(`[Service] 获取快捷指令 ${commandId} 的标签失败:`, error.message); export const reorderQuickCommandTags = async (tagIds: number[]): Promise<void> => {
throw error; if (!Array.isArray(tagIds) || !tagIds.every((id) => typeof id === 'number')) {
} throw new Error('tagIds 必须是数字数组');
}
await QuickCommandTagRepository.reorderQuickCommandTags(tagIds);
}; };
@@ -2,202 +2,202 @@ import { Request, Response } from 'express';
import * as QuickCommandsService from './quick-commands.service'; import * as QuickCommandsService from './quick-commands.service';
import { QuickCommandSortBy } from './quick-commands.service'; import { QuickCommandSortBy } from './quick-commands.service';
/** const isNumberArray = (value: unknown): value is number[] =>
* 处理添加新快捷指令的请求 Array.isArray(value) && value.every((item) => typeof item === 'number' && Number.isFinite(item));
*/
export const addQuickCommand = async (req: Request, res: Response): Promise<void> => { export const addQuickCommand = async (req: Request, res: Response): Promise<void> => {
// 从请求体中解构出 name, command, 以及可选的 tagIds 和 variables
const { name, command, tagIds, variables } = req.body; const { name, command, tagIds, variables } = req.body;
// --- 基本验证 ---
if (!command || typeof command !== 'string' || command.trim().length === 0) { if (!command || typeof command !== 'string' || command.trim().length === 0) {
res.status(400).json({ message: '指令内容不能为空' }); res.status(400).json({ message: '指令内容不能为空' });
return; return;
} }
// 名称可以是 null 或 string
if (name !== null && typeof name !== 'string') { if (name !== null && typeof name !== 'string') {
res.status(400).json({ message: '名称必须是字符串或 null' }); res.status(400).json({ message: '名称必须是字符串或 null' });
return;
}
// 验证 tagIds (如果提供的话)
if (tagIds !== undefined && (!Array.isArray(tagIds) || !tagIds.every(id => typeof id === 'number'))) {
res.status(400).json({ message: 'tagIds 必须是一个数字数组' });
return;
}
// 验证 variables (如果提供的话)
if (variables !== undefined && (typeof variables !== 'object' || variables === null || Array.isArray(variables))) {
res.status(400).json({ message: 'variables 必须是一个对象' });
return; return;
} }
if (tagIds !== undefined && !isNumberArray(tagIds)) {
res.status(400).json({ message: 'tagIds 必须是数字数组' });
return;
}
if (variables !== undefined && (typeof variables !== 'object' || variables === null || Array.isArray(variables))) {
res.status(400).json({ message: 'variables 必须是对象' });
return;
}
try { try {
// 将 tagIds 和 variables 传递给 Service 层
const newId = await QuickCommandsService.addQuickCommand(name, command, tagIds, variables); const newId = await QuickCommandsService.addQuickCommand(name, command, tagIds, variables);
// 尝试获取新创建的带标签的指令信息返回
const newCommand = await QuickCommandsService.getQuickCommandById(newId); const newCommand = await QuickCommandsService.getQuickCommandById(newId);
if (newCommand) { if (newCommand) {
res.status(201).json({ message: '快捷指令已添加', command: newCommand }); res.status(201).json({ message: '快捷指令已添加', command: newCommand });
} else { return;
console.error(`[Controller] 添加快捷指令后未能找到 ID: ${newId}`);
res.status(201).json({ message: '快捷指令已添加,但无法检索新记录', id: newId });
} }
res.status(201).json({ message: '快捷指令已添加,但无法检索新记录', id: newId });
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 添加快捷指令失败:', error.message); console.error('[QuickCommandsController] 添加快捷指令失败:', error.message);
res.status(500).json({ message: error.message || '无法添加快捷指令' }); res.status(500).json({ message: error.message || '无法添加快捷指令' });
} }
}; };
/**
* 处理获取所有快捷指令的请求 (支持排序)
*/
export const getAllQuickCommands = async (req: Request, res: Response): Promise<void> => { export const getAllQuickCommands = async (req: Request, res: Response): Promise<void> => {
const sortBy = req.query.sortBy as QuickCommandSortBy | undefined; const sortBy = req.query.sortBy as QuickCommandSortBy | undefined;
// 验证 sortBy 参数 const validSortBy: QuickCommandSortBy =
const validSortBy: QuickCommandSortBy = (sortBy === 'name' || sortBy === 'usage_count') ? sortBy : 'name'; sortBy === 'name' || sortBy === 'usage_count' || sortBy === 'manual' ? sortBy : 'manual';
try { try {
const commands = await QuickCommandsService.getAllQuickCommands(validSortBy); const commands = await QuickCommandsService.getAllQuickCommands(validSortBy);
res.status(200).json(commands); res.status(200).json(commands);
} catch (error: any) { } catch (error: any) {
console.error('获取快捷指令控制器出错:', error); console.error('[QuickCommandsController] 获取快捷指令失败:', error.message);
res.status(500).json({ message: error.message || '无法获取快捷指令' }); res.status(500).json({ message: error.message || '无法获取快捷指令' });
} }
}; };
/**
* 处理更新快捷指令的请求
*/
export const updateQuickCommand = async (req: Request, res: Response): Promise<void> => { export const updateQuickCommand = async (req: Request, res: Response): Promise<void> => {
const id = parseInt(req.params.id, 10); const id = Number.parseInt(req.params.id, 10);
// 从请求体中解构出 name, command, 以及可选的 tagIds 和 variables
const { name, command, tagIds, variables } = req.body; const { name, command, tagIds, variables } = req.body;
// --- 基本验证 --- if (Number.isNaN(id)) {
if (isNaN(id)) {
res.status(400).json({ message: '无效的 ID' }); res.status(400).json({ message: '无效的 ID' });
return; return;
} }
if (!command || typeof command !== 'string' || command.trim().length === 0) { if (!command || typeof command !== 'string' || command.trim().length === 0) {
res.status(400).json({ message: '指令内容不能为空' }); res.status(400).json({ message: '指令内容不能为空' });
return; return;
} }
if (name !== null && typeof name !== 'string') { if (name !== null && typeof name !== 'string') {
res.status(400).json({ message: '名称必须是字符串或 null' }); res.status(400).json({ message: '名称必须是字符串或 null' });
return;
}
// 验证 tagIds (如果提供的话)
// 注意: tagIds 为 undefined 表示不更新标签,空数组 [] 表示清除所有标签
if (tagIds !== undefined && (!Array.isArray(tagIds) || !tagIds.every(id => typeof id === 'number'))) {
res.status(400).json({ message: 'tagIds 必须是一个数字数组' });
return; return;
} }
// 验证 variables (如果提供的话)
// undefined 表示不更新 variables, null 或对象表示要更新 if (tagIds !== undefined && !isNumberArray(tagIds)) {
if (variables !== undefined && variables !== null && (typeof variables !== 'object' || Array.isArray(variables))) { res.status(400).json({ message: 'tagIds 必须是数字数组' });
return;
}
if (
variables !== undefined &&
variables !== null &&
(typeof variables !== 'object' || Array.isArray(variables))
) {
res.status(400).json({ message: 'variables 必须是对象或 null' }); res.status(400).json({ message: 'variables 必须是对象或 null' });
return; return;
} }
try { try {
// 将 tagIds 和 variables 传递给 Service 层
const success = await QuickCommandsService.updateQuickCommand(id, name, command, tagIds, variables); const success = await QuickCommandsService.updateQuickCommand(id, name, command, tagIds, variables);
if (success) { if (!success) {
// 尝试获取更新后的带标签的指令信息返回
const updatedCommand = await QuickCommandsService.getQuickCommandById(id);
if (updatedCommand) {
res.status(200).json({ message: '快捷指令已更新', command: updatedCommand });
} else {
console.error(`[Controller] 更新快捷指令后未能找到 ID: ${id}`);
res.status(200).json({ message: '快捷指令已更新,但无法检索更新后的记录' });
}
} else {
// 检查指令是否真的不存在
const commandExists = await QuickCommandsService.getQuickCommandById(id); const commandExists = await QuickCommandsService.getQuickCommandById(id);
if (!commandExists) { res.status(commandExists ? 500 : 404).json({
res.status(404).json({ message: '未找到要更新的快捷指令' }); message: commandExists ? '更新快捷指令时发生未知错误' : '未找到要更新的快捷指令',
} else { });
console.error(`[Controller] 更新快捷指令 ${id} 失败,但指令存在。`); return;
res.status(500).json({ message: '更新快捷指令时发生未知错误' });
}
} }
const updatedCommand = await QuickCommandsService.getQuickCommandById(id);
if (updatedCommand) {
res.status(200).json({ message: '快捷指令已更新', command: updatedCommand });
return;
}
res.status(200).json({ message: '快捷指令已更新,但无法检索更新后的记录' });
} catch (error: any) { } catch (error: any) {
console.error('更新快捷指令控制器出错:', error); console.error('[QuickCommandsController] 更新快捷指令失败:', error.message);
res.status(500).json({ message: error.message || '无法更新快捷指令' }); res.status(500).json({ message: error.message || '无法更新快捷指令' });
} }
}; };
/**
* 处理删除快捷指令的请求
*/
export const deleteQuickCommand = async (req: Request, res: Response): Promise<void> => { export const deleteQuickCommand = async (req: Request, res: Response): Promise<void> => {
const id = parseInt(req.params.id, 10); const id = Number.parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
if (isNaN(id)) {
res.status(400).json({ message: '无效的 ID' }); res.status(400).json({ message: '无效的 ID' });
return; return;
} }
try { try {
const success = await QuickCommandsService.deleteQuickCommand(id); const success = await QuickCommandsService.deleteQuickCommand(id);
if (success) { res.status(success ? 200 : 404).json({
res.status(200).json({ message: '快捷指令已删除' }); message: success ? '快捷指令已删除' : '未找到要删除的快捷指令',
} else { });
res.status(404).json({ message: '未找到要删除的快捷指令' });
}
} catch (error: any) { } catch (error: any) {
console.error('删除快捷指令控制器出错:', error); console.error('[QuickCommandsController] 删除快捷指令失败:', error.message);
res.status(500).json({ message: error.message || '无法删除快捷指令' }); res.status(500).json({ message: error.message || '无法删除快捷指令' });
} }
}; };
/**
* 处理增加快捷指令使用次数的请求
*/
export const incrementUsage = async (req: Request, res: Response): Promise<void> => { export const incrementUsage = async (req: Request, res: Response): Promise<void> => {
const id = parseInt(req.params.id, 10); const id = Number.parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
if (isNaN(id)) {
res.status(400).json({ message: '无效的 ID' }); res.status(400).json({ message: '无效的 ID' });
return; return;
} }
try { try {
const success = await QuickCommandsService.incrementUsageCount(id); const success = await QuickCommandsService.incrementUsageCount(id);
if (success) { res.status(200).json({
res.status(200).json({ message: '使用次数已增加' }); message: success ? '使用次数已增加' : '使用次数已记录(或指令不存在)',
} else { });
// 即使没找到也可能返回成功,避免不必要的错误提示
console.warn(`尝试增加不存在的快捷指令 (ID: ${id}) 的使用次数`);
res.status(200).json({ message: '使用次数已记录 (或指令不存在)' });
}
} catch (error: any) { } catch (error: any) {
console.error('增加快捷指令使用次数控制器出错:', error); console.error('[QuickCommandsController] 增加快捷指令使用次数失败:', error.message);
res.status(500).json({ message: error.message || '无法增加使用次数' }); res.status(500).json({ message: error.message || '无法增加使用次数' });
} }
}; };
/** export const assignTagToCommands = async (req: Request, res: Response): Promise<void> => {
* 批量将标签分配给多个快捷指令
*/
export const assignTagToCommands = async (req: Request, res: Response): Promise<void> => { // Add : Promise<void>
const { commandIds, tagId } = req.body; const { commandIds, tagId } = req.body;
// 基本验证 if (!isNumberArray(commandIds) || commandIds.length === 0 || typeof tagId !== 'number') {
if (!Array.isArray(commandIds) || commandIds.length === 0 || typeof tagId !== 'number') { res.status(400).json({ success: false, message: '请求体必须包含 commandIds 和 tagId' });
res.status(400).json({ success: false, message: '请求体必须包含 commandIds (非空数组) 和 tagId (数字)。' }); return;
return; // Use return without value to exit early
} }
try { try {
// 调用 Service 函数处理批量分配
console.log(`[Controller] assignTagToCommands: Received commandIds: ${JSON.stringify(commandIds)}, tagId: ${tagId}`);
await QuickCommandsService.assignTagToCommands(commandIds, tagId); await QuickCommandsService.assignTagToCommands(commandIds, tagId);
res.status(200).json({ success: true, message: `标签 ${tagId} 已成功尝试关联到 ${commandIds.length} 个指令。` }); res.status(200).json({
success: true,
message: `标签 ${tagId} 已成功关联到 ${commandIds.length} 个指令`,
});
} catch (error: any) { } catch (error: any) {
console.error('[Controller] 批量分配标签时出错:', error.message); console.error('[QuickCommandsController] 批量分配标签失败:', error.message);
// 根据错误类型返回不同的状态码可能更好,但这里简化处理 res.status(500).json({ success: false, message: error.message || '批量分配标签失败' });
res.status(500).json({ success: false, message: error.message || '批量分配标签时发生内部服务器错误。' }); }
// No return needed here, error handling completes the response };
export const reorderQuickCommands = async (req: Request, res: Response): Promise<void> => {
const { commandIds } = req.body;
if (!isNumberArray(commandIds) || commandIds.length === 0) {
res.status(400).json({ message: 'commandIds 必须是非空数字数组' });
return;
}
try {
await QuickCommandsService.reorderQuickCommands(commandIds);
res.status(200).json({ message: '快捷指令顺序已更新' });
} catch (error: any) {
console.error('[QuickCommandsController] 更新快捷指令顺序失败:', error.message);
res.status(500).json({ message: error.message || '无法更新快捷指令顺序' });
}
};
export const reorderCommandsByTag = async (req: Request, res: Response): Promise<void> => {
const { tagId, commandIds } = req.body;
if (typeof tagId !== 'number' || !isNumberArray(commandIds) || commandIds.length === 0) {
res.status(400).json({ message: 'tagId 和 commandIds 必须有效' });
return;
}
try {
await QuickCommandsService.reorderCommandsByTag(tagId, commandIds);
res.status(200).json({ message: '标签内快捷指令顺序已更新' });
} catch (error: any) {
console.error('[QuickCommandsController] 更新标签内快捷指令顺序失败:', error.message);
res.status(500).json({ message: error.message || '无法更新标签内快捷指令顺序' });
} }
}; };
@@ -1,193 +1,247 @@
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
// 定义基础快捷指令接口
export interface QuickCommand { export interface QuickCommand {
id: number; id: number;
name: string | null; // 名称可选 name: string | null;
command: string; command: string;
usage_count: number; usage_count: number;
variables?: string; // 存储 JSON 格式的变量键值对 sort_order: number;
created_at: number; // Unix 时间戳 (秒) variables?: string | null;
updated_at: number; // Unix 时间戳 (秒) created_at: number;
updated_at: number;
} }
// 定义包含标签 ID 和解析后变量的接口
export type QuickCommandWithTags = Omit<QuickCommand, 'variables'> & { export type QuickCommandWithTags = Omit<QuickCommand, 'variables'> & {
tagIds: number[]; tagIds: number[];
variables: Record<string, string> | null; // API 层面使用对象 tagOrders: Record<number, number>;
variables: Record<string, string> | null;
}; };
// 用于从数据库获取带 tag_ids_str 的行 interface QuickCommandTagOrderRow {
interface DbQuickCommandWithTagsRow extends QuickCommand { quick_command_id: number;
tag_ids_str: string | null; tag_id: number;
// variables 字段已包含在 QuickCommand 中,这里不需要重复定义,因为 QuickCommand 将包含 variables?: string sort_order: number;
} }
type QuickCommandSortBy = 'manual' | 'name' | 'usage_count';
/** const parseVariables = (variables: string | null | undefined, commandId: number): Record<string, string> | null => {
* 添加一条新的快捷指令 if (!variables) {
* @param name - 指令名称 (可选) return null;
* @param command - 指令内容 }
* @param variables - 变量对象 (可选)
* @returns 返回插入记录的 ID try {
*/ return JSON.parse(variables);
export const addQuickCommand = async (name: string | null, command: string, variables?: Record<string, string>): Promise<number> => { } catch (error) {
const sql = `INSERT INTO quick_commands (name, command, variables, created_at, updated_at) VALUES (?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))`; console.error(`[QuickCommandsRepository] 解析快捷指令 ${commandId} 的 variables 失败:`, error);
return null;
}
};
const getNextQuickCommandSortOrder = async (db: Awaited<ReturnType<typeof getDbInstance>>): Promise<number> => {
const row = await getDbRow<{ nextSortOrder?: number }>(
db,
'SELECT COALESCE(MAX(sort_order), 0) + 1 AS nextSortOrder FROM quick_commands',
);
return row?.nextSortOrder ?? 1;
};
const getTagOrderMap = async (
db: Awaited<ReturnType<typeof getDbInstance>>,
commandIds: number[],
): Promise<Map<number, { tagIds: number[]; tagOrders: Record<number, number> }>> => {
const tagState = new Map<number, { tagIds: number[]; tagOrders: Record<number, number> }>();
if (commandIds.length === 0) {
return tagState;
}
const placeholders = commandIds.map(() => '?').join(', ');
const rows = await allDb<QuickCommandTagOrderRow>(
db,
`SELECT quick_command_id, tag_id, sort_order
FROM quick_command_tag_associations
WHERE quick_command_id IN (${placeholders})
ORDER BY quick_command_id ASC, sort_order ASC, tag_id ASC`,
commandIds,
);
for (const row of rows) {
if (!tagState.has(row.quick_command_id)) {
tagState.set(row.quick_command_id, { tagIds: [], tagOrders: {} });
}
const currentState = tagState.get(row.quick_command_id)!;
currentState.tagIds.push(row.tag_id);
currentState.tagOrders[row.tag_id] = row.sort_order;
}
return tagState;
};
const buildQuickCommandsWithTags = async (
db: Awaited<ReturnType<typeof getDbInstance>>,
rows: QuickCommand[],
): Promise<QuickCommandWithTags[]> => {
const commandIds = rows.map((row) => row.id);
const tagState = await getTagOrderMap(db, commandIds);
return rows.map((row) => {
const { variables, ...rest } = row;
const currentTagState = tagState.get(row.id);
return {
...rest,
variables: parseVariables(variables, row.id),
tagIds: currentTagState?.tagIds ?? [],
tagOrders: currentTagState?.tagOrders ?? {},
};
});
};
export const addQuickCommand = async (
name: string | null,
command: string,
variables?: Record<string, string>,
): Promise<number> => {
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const variablesJson = variables ? JSON.stringify(variables) : null; const variablesJson = variables ? JSON.stringify(variables) : null;
const result = await runDb(db, sql, [name, command, variablesJson]); const sortOrder = await getNextQuickCommandSortOrder(db);
const result = await runDb(
db,
`INSERT INTO quick_commands (name, command, usage_count, variables, sort_order, created_at, updated_at)
VALUES (?, ?, 0, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))`,
[name, command, variablesJson, sortOrder],
);
if (typeof result.lastID !== 'number' || result.lastID <= 0) { if (typeof result.lastID !== 'number' || result.lastID <= 0) {
throw new Error('添加快捷指令后未能获取有效的 lastID'); throw new Error('添加快捷指令后未能获取有效的 lastID');
} }
return result.lastID; return result.lastID;
} catch (err: any) { } catch (err: any) {
console.error('添加快捷指令时出错:', err.message); console.error('[QuickCommandsRepository] 添加快捷指令失败:', err.message);
throw new Error('无法添加快捷指令'); throw new Error('无法添加快捷指令');
} }
}; };
/** export const updateQuickCommand = async (
* 更新指定的快捷指令 id: number,
* @param id - 要更新的记录 ID name: string | null,
* @param name - 新的指令名称 (可选) command: string,
* @param command - 新的指令内容 variables?: Record<string, string>,
* @param variables - 新的变量对象 (可选) ): Promise<boolean> => {
* @returns 返回是否成功更新 (true/false)
*/
export const updateQuickCommand = async (id: number, name: string | null, command: string, variables?: Record<string, string>): Promise<boolean> => {
const sql = `UPDATE quick_commands SET name = ?, command = ?, variables = ?, updated_at = strftime('%s', 'now') WHERE id = ?`;
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const variablesJson = variables ? JSON.stringify(variables) : null; const variablesJson = variables ? JSON.stringify(variables) : null;
const result = await runDb(db, sql, [name, command, variablesJson, id]); const result = await runDb(
db,
'UPDATE quick_commands SET name = ?, command = ?, variables = ?, updated_at = strftime(\'%s\', \'now\') WHERE id = ?',
[name, command, variablesJson, id],
);
return result.changes > 0; return result.changes > 0;
} catch (err: any) { } catch (err: any) {
console.error('更新快捷指令时出错:', err.message); console.error('[QuickCommandsRepository] 更新快捷指令失败:', err.message);
throw new Error('无法更新快捷指令'); throw new Error('无法更新快捷指令');
} }
}; };
/**
* 根据 ID 删除指定的快捷指令
* @param id - 要删除的记录 ID
* @returns 返回是否成功删除 (true/false)
*/
export const deleteQuickCommand = async (id: number): Promise<boolean> => { export const deleteQuickCommand = async (id: number): Promise<boolean> => {
const sql = `DELETE FROM quick_commands WHERE id = ?`;
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const result = await runDb(db, sql, [id]); const result = await runDb(db, 'DELETE FROM quick_commands WHERE id = ?', [id]);
return result.changes > 0; return result.changes > 0;
} catch (err: any) { } catch (err: any) {
console.error('删除快捷指令时出错:', err.message); console.error('[QuickCommandsRepository] 删除快捷指令失败:', err.message);
throw new Error('无法删除快捷指令'); throw new Error('无法删除快捷指令');
} }
}; };
/** export const getAllQuickCommands = async (sortBy: QuickCommandSortBy = 'manual'): Promise<QuickCommandWithTags[]> => {
* 获取所有快捷指令及其关联的标签 ID let orderByClause = 'ORDER BY sort_order ASC, id ASC';
* @param sortBy - 排序字段 ('name' 或 'usage_count')
* @returns 返回包含所有快捷指令条目及标签 ID 的数组
*/
export const getAllQuickCommands = async (sortBy: 'name' | 'usage_count' = 'name'): Promise<QuickCommandWithTags[]> => {
let orderByClause = 'ORDER BY qc.name ASC'; // 默认按名称升序
if (sortBy === 'usage_count') { if (sortBy === 'usage_count') {
orderByClause = 'ORDER BY qc.usage_count DESC, qc.name ASC'; // 按使用频率降序,同频率按名称升序 orderByClause = 'ORDER BY usage_count DESC, name ASC, id ASC';
} else if (sortBy === 'name') {
orderByClause = 'ORDER BY name ASC, id ASC';
} }
// 使用 LEFT JOIN 连接关联表,并使用 GROUP_CONCAT 获取标签 ID 字符串
const sql = `
SELECT
qc.id, qc.name, qc.command, qc.usage_count, qc.variables, qc.created_at, qc.updated_at,
GROUP_CONCAT(qta.tag_id) as tag_ids_str
FROM quick_commands qc
LEFT JOIN quick_command_tag_associations qta ON qc.id = qta.quick_command_id
GROUP BY qc.id
${orderByClause}`;
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const rows = await allDb<DbQuickCommandWithTagsRow>(db, sql); const rows = await allDb<QuickCommand>(
// 将 tag_ids_str 解析为数字数组,并解析 variables db,
return rows.map(row => { `SELECT id, name, command, usage_count, sort_order, variables, created_at, updated_at
let parsedVariables: Record<string, string> | null = null; FROM quick_commands
if (row.variables) { ${orderByClause}`,
try { );
parsedVariables = JSON.parse(row.variables); return await buildQuickCommandsWithTags(db, rows);
} catch (e) {
console.error(`Error parsing variables for quick command ${row.id}:`, e);
//保持 parsedVariables 为 null
}
}
const { variables, ...restOfRow } = row; // 从 row 中移除原始的 string 类型的 variables
return {
...restOfRow,
variables: parsedVariables,
tagIds: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number).filter(id => !isNaN(id)) : []
};
});
} catch (err: any) { } catch (err: any) {
console.error('获取快捷指令(带标签)时出错:', err.message); console.error('[QuickCommandsRepository] 获取快捷指令失败:', err.message);
throw new Error('无法获取快捷指令'); throw new Error('无法获取快捷指令');
} }
}; };
/**
* 增加指定快捷指令的使用次数
* @param id - 要增加次数的记录 ID
* @returns 返回是否成功更新 (true/false)
*/
export const incrementUsageCount = async (id: number): Promise<boolean> => { export const incrementUsageCount = async (id: number): Promise<boolean> => {
const sql = `UPDATE quick_commands SET usage_count = usage_count + 1, updated_at = strftime('%s', 'now') WHERE id = ?`;
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const result = await runDb(db, sql, [id]); const result = await runDb(
db,
'UPDATE quick_commands SET usage_count = usage_count + 1, updated_at = strftime(\'%s\', \'now\') WHERE id = ?',
[id],
);
return result.changes > 0; return result.changes > 0;
} catch (err: any) { } catch (err: any) {
console.error('增加快捷指令使用次数时出错:', err.message); console.error('[QuickCommandsRepository] 增加快捷指令使用次数失败:', err.message);
throw new Error('无法增加快捷指令使用次数'); throw new Error('无法增加快捷指令使用次数');
} }
}; };
/**
* 根据 ID 查找快捷指令及其关联的标签 ID
* @param id - 要查找的记录 ID
* @returns 返回找到的快捷指令条目及标签 ID,如果未找到则返回 undefined
*/
export const findQuickCommandById = async (id: number): Promise<QuickCommandWithTags | undefined> => { export const findQuickCommandById = async (id: number): Promise<QuickCommandWithTags | undefined> => {
// 使用 LEFT JOIN 连接关联表,并使用 GROUP_CONCAT 获取标签 ID 字符串
const sql = `
SELECT
qc.id, qc.name, qc.command, qc.usage_count, qc.variables, qc.created_at, qc.updated_at,
GROUP_CONCAT(qta.tag_id) as tag_ids_str
FROM quick_commands qc
LEFT JOIN quick_command_tag_associations qta ON qc.id = qta.quick_command_id
WHERE qc.id = ?
GROUP BY qc.id`;
try { try {
const db = await getDbInstance(); const db = await getDbInstance();
const row = await getDbRow<DbQuickCommandWithTagsRow>(db, sql, [id]); const row = await getDbRow<QuickCommand>(
if (row && typeof row.id !== 'undefined') { db,
// 将 tag_ids_str 解析为数字数组,并解析 variables `SELECT id, name, command, usage_count, sort_order, variables, created_at, updated_at
let parsedVariables: Record<string, string> | null = null; FROM quick_commands
if (row.variables) { WHERE id = ?`,
try { [id],
parsedVariables = JSON.parse(row.variables); );
} catch (e) {
console.error(`Error parsing variables for quick command ${row.id}:`, e); if (!row) {
//保持 parsedVariables 为 null
}
}
const { variables, ...restOfRow } = row; // 从 row 中移除原始的 string 类型的 variables
return {
...restOfRow,
variables: parsedVariables,
tagIds: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number).filter(id => !isNaN(id)) : []
};
} else {
return undefined; return undefined;
} }
const [hydratedRow] = await buildQuickCommandsWithTags(db, [row]);
return hydratedRow;
} catch (err: any) { } catch (err: any) {
console.error('查找快捷指令(带标签)时出错:', err.message); console.error('[QuickCommandsRepository] 查询快捷指令失败:', err.message);
throw new Error('无法查快捷指令'); throw new Error('无法查快捷指令');
}
};
export const reorderQuickCommands = async (commandIds: number[]): Promise<void> => {
const normalizedCommandIds = Array.from(
new Set(commandIds.filter((commandId) => Number.isInteger(commandId) && commandId > 0)),
);
if (normalizedCommandIds.length === 0) {
return;
}
const db = await getDbInstance();
try {
await runDb(db, 'BEGIN TRANSACTION');
for (let index = 0; index < normalizedCommandIds.length; index += 1) {
await runDb(
db,
'UPDATE quick_commands SET sort_order = ?, updated_at = strftime(\'%s\', \'now\') WHERE id = ?',
[index + 1, normalizedCommandIds[index]],
);
}
await runDb(db, 'COMMIT');
} catch (err: any) {
await runDb(db, 'ROLLBACK');
console.error('[QuickCommandsRepository] 重排快捷指令失败:', err.message);
throw new Error('无法更新快捷指令顺序');
} }
}; };
@@ -9,7 +9,9 @@ router.use(isAuthenticated);
// 定义路由 // 定义路由
router.post('/', QuickCommandsController.addQuickCommand); // POST /api/v1/quick-commands router.post('/', QuickCommandsController.addQuickCommand); // POST /api/v1/quick-commands
router.get('/', QuickCommandsController.getAllQuickCommands); // GET /api/v1/quick-commands?sortBy=name|usage_count router.get('/', QuickCommandsController.getAllQuickCommands); // GET /api/v1/quick-commands?sortBy=manual|name|usage_count
router.put('/reorder', QuickCommandsController.reorderQuickCommands); // PUT /api/v1/quick-commands/reorder
router.put('/reorder-by-tag', QuickCommandsController.reorderCommandsByTag); // PUT /api/v1/quick-commands/reorder-by-tag
router.put('/:id', QuickCommandsController.updateQuickCommand); // PUT /api/v1/quick-commands/:id router.put('/:id', QuickCommandsController.updateQuickCommand); // PUT /api/v1/quick-commands/:id
router.delete('/:id', QuickCommandsController.deleteQuickCommand); // DELETE /api/v1/quick-commands/:id router.delete('/:id', QuickCommandsController.deleteQuickCommand); // DELETE /api/v1/quick-commands/:id
router.post('/:id/increment-usage', QuickCommandsController.incrementUsage); // POST /api/v1/quick-commands/:id/increment-usage router.post('/:id/increment-usage', QuickCommandsController.incrementUsage); // POST /api/v1/quick-commands/:id/increment-usage
@@ -2,132 +2,101 @@ import * as QuickCommandsRepository from '../quick-commands/quick-commands.repos
import { QuickCommandWithTags } from '../quick-commands/quick-commands.repository'; import { QuickCommandWithTags } from '../quick-commands/quick-commands.repository';
import * as QuickCommandTagRepository from '../quick-command-tags/quick-command-tag.repository'; import * as QuickCommandTagRepository from '../quick-command-tags/quick-command-tag.repository';
// 定义排序类型 export type QuickCommandSortBy = 'manual' | 'name' | 'usage_count';
export type QuickCommandSortBy = 'name' | 'usage_count';
/** export const addQuickCommand = async (
* 添加快捷指令 name: string | null,
* @param name - 指令名称 (可选) command: string,
* @param command - 指令内容 tagIds?: number[],
* @param tagIds - 关联的快捷指令标签 ID 数组 (可选) variables?: Record<string, string>,
* @param variables - 变量对象 (可选) ): Promise<number> => {
* @returns 返回添加记录的 ID
*/
export const addQuickCommand = async (name: string | null, command: string, tagIds?: number[], variables?: Record<string, string>): Promise<number> => {
if (!command || command.trim().length === 0) { if (!command || command.trim().length === 0) {
throw new Error('指令内容不能为空'); throw new Error('指令内容不能为空');
} }
// 如果 name 是空字符串,则视为 null
const finalName = name && name.trim().length > 0 ? name.trim() : null; const finalName = name && name.trim().length > 0 ? name.trim() : null;
const commandId = await QuickCommandsRepository.addQuickCommand(finalName, command.trim(), variables); const commandId = await QuickCommandsRepository.addQuickCommand(finalName, command.trim(), variables);
// 添加成功后,设置标签关联
if (commandId > 0 && tagIds && Array.isArray(tagIds)) { if (commandId > 0 && tagIds && Array.isArray(tagIds)) {
try { try {
await QuickCommandTagRepository.setCommandTagAssociations(commandId, tagIds); await QuickCommandTagRepository.setCommandTagAssociations(commandId, tagIds);
} catch (tagError: any) { } catch (tagError: any) {
// 如果标签关联失败,可以选择记录警告或回滚(但通常不回滚主记录) console.warn(`[QuickCommandsService] 快捷指令 ${commandId} 已创建,但设置标签关联失败:`, tagError.message);
console.warn(`[Service] 添加快捷指令 ${commandId} 成功,但设置标签关联失败:`, tagError.message);
// 可以考虑是否需要通知用户部分操作失败
} }
} }
return commandId; return commandId;
}; };
/** export const updateQuickCommand = async (
* 更新快捷指令 id: number,
* @param id - 要更新的记录 ID name: string | null,
* @param name - 新的指令名称 (可选) command: string,
* @param command - 新的指令内容 tagIds?: number[],
* @param tagIds - 新的关联标签 ID 数组 (可选, undefined 表示不更新标签) variables?: Record<string, string>,
* @param variables - 新的变量对象 (可选) ): Promise<boolean> => {
* @returns 返回是否成功更新 (更新行数 > 0)
*/
export const updateQuickCommand = async (id: number, name: string | null, command: string, tagIds?: number[], variables?: Record<string, string>): Promise<boolean> => {
if (!command || command.trim().length === 0) { if (!command || command.trim().length === 0) {
throw new Error('指令内容不能为空'); throw new Error('指令内容不能为空');
} }
const finalName = name && name.trim().length > 0 ? name.trim() : null; const finalName = name && name.trim().length > 0 ? name.trim() : null;
const commandUpdated = await QuickCommandsRepository.updateQuickCommand(id, finalName, command.trim(), variables); const commandUpdated = await QuickCommandsRepository.updateQuickCommand(id, finalName, command.trim(), variables);
// 如果指令更新成功,并且提供了 tagIds (即使是空数组也表示要更新),则更新标签关联
if (commandUpdated && typeof tagIds !== 'undefined') { if (commandUpdated && typeof tagIds !== 'undefined') {
try { try {
await QuickCommandTagRepository.setCommandTagAssociations(id, tagIds); await QuickCommandTagRepository.setCommandTagAssociations(id, tagIds);
} catch (tagError: any) { } catch (tagError: any) {
console.warn(`[Service] 更新快捷指令 ${id} 成功,但更新标签关联失败:`, tagError.message); console.warn(`[QuickCommandsService] 快捷指令 ${id} 已更新,但更新标签关联失败:`, tagError.message);
// 即使标签更新失败,主记录已更新,通常返回 true }
}
} }
// 返回主记录是否更新成功
return commandUpdated; return commandUpdated;
}; };
/**
* 删除快捷指令
* @param id - 要删除的记录 ID
* @returns 返回是否成功删除 (删除行数 > 0)
*/
export const deleteQuickCommand = async (id: number): Promise<boolean> => { export const deleteQuickCommand = async (id: number): Promise<boolean> => {
const changes = await QuickCommandsRepository.deleteQuickCommand(id); return QuickCommandsRepository.deleteQuickCommand(id);
return changes;
}; };
/** export const getAllQuickCommands = async (sortBy: QuickCommandSortBy = 'manual'): Promise<QuickCommandWithTags[]> => {
* 获取所有快捷指令,并按指定方式排序
* @param sortBy - 排序字段 ('name' 或 'usage_count')
* @returns 返回排序后的快捷指令数组 (包含 tagIds)
*/
export const getAllQuickCommands = async (sortBy: QuickCommandSortBy = 'name'): Promise<QuickCommandWithTags[]> => {
// Repository 已返回带 tagIds 的数据
return QuickCommandsRepository.getAllQuickCommands(sortBy); return QuickCommandsRepository.getAllQuickCommands(sortBy);
}; };
/**
* 增加快捷指令的使用次数
* @param id - 记录 ID
* @returns 返回是否成功更新 (更新行数 > 0)
*/
export const incrementUsageCount = async (id: number): Promise<boolean> => { export const incrementUsageCount = async (id: number): Promise<boolean> => {
const changes = await QuickCommandsRepository.incrementUsageCount(id); return QuickCommandsRepository.incrementUsageCount(id);
return changes;
}; };
/**
* 根据 ID 获取单个快捷指令 (可能用于编辑)
* @param id - 记录 ID
* @returns 返回找到的快捷指令 (包含 tagIds),或 undefined
*/
export const getQuickCommandById = async (id: number): Promise<QuickCommandWithTags | undefined> => { export const getQuickCommandById = async (id: number): Promise<QuickCommandWithTags | undefined> => {
// Repository 已返回带 tagIds 的数据
return QuickCommandsRepository.findQuickCommandById(id); return QuickCommandsRepository.findQuickCommandById(id);
}; };
/**
* 将单个标签批量关联到多个快捷指令
* @param commandIds - 需要添加标签的快捷指令 ID 数组
* @param tagId - 要添加的标签 ID
* @returns Promise<void>
*/
export const assignTagToCommands = async (commandIds: number[], tagId: number): Promise<void> => { export const assignTagToCommands = async (commandIds: number[], tagId: number): Promise<void> => {
try { if (!Array.isArray(commandIds) || commandIds.some((id) => typeof id !== 'number' || Number.isNaN(id))) {
// 基本验证 throw new Error('无效的指令 ID 列表');
if (!Array.isArray(commandIds) || commandIds.some(id => typeof id !== 'number' || isNaN(id))) {
throw new Error('无效的指令 ID 列表');
}
if (typeof tagId !== 'number' || isNaN(tagId)) {
throw new Error('无效的标签 ID');
}
// 调用 Repository 函数执行批量关联
// 注意:这里需要导入 QuickCommandTagRepository
console.log(`[Service] assignTagToCommands: Calling repo with commandIds: ${JSON.stringify(commandIds)}, tagId: ${tagId}`);
await QuickCommandTagRepository.addTagToCommands(commandIds, tagId);
console.log(`[Service] assignTagToCommands: Repo call finished for tag ${tagId}.`); // +++ 修改日志 +++
// 可以在这里添加额外的业务逻辑,例如发送事件通知等
} catch (error: any) {
console.error(`[Service] assignTagToCommands: 批量关联标签 ${tagId} 到指令时出错:`, error.message);
// 向上抛出错误,让 Controller 处理 HTTP 响应
throw error;
} }
if (typeof tagId !== 'number' || Number.isNaN(tagId)) {
throw new Error('无效的标签 ID');
}
await QuickCommandTagRepository.addTagToCommands(commandIds, tagId);
};
export const reorderQuickCommands = async (commandIds: number[]): Promise<void> => {
if (!Array.isArray(commandIds) || commandIds.some((id) => typeof id !== 'number' || Number.isNaN(id))) {
throw new Error('commandIds 必须是数字数组');
}
await QuickCommandsRepository.reorderQuickCommands(commandIds);
};
export const reorderCommandsByTag = async (tagId: number, commandIds: number[]): Promise<void> => {
if (typeof tagId !== 'number' || Number.isNaN(tagId)) {
throw new Error('tagId 必须是数字');
}
if (!Array.isArray(commandIds) || commandIds.some((id) => typeof id !== 'number' || Number.isNaN(id))) {
throw new Error('commandIds 必须是数字数组');
}
await QuickCommandTagRepository.reorderCommandsInTag(tagId, commandIds);
}; };
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import SshKeySelector from './SshKeySelector.vue'; // Assuming SshKeySelector is used here import SshKeySelector from './SshKeySelector.vue'; // Assuming SshKeySelector is used here
import LoginCredentialSelector from './LoginCredentialSelector.vue'; import LoginCredentialSelector from './LoginCredentialSelector.vue';
@@ -19,6 +20,26 @@ const props = defineProps<{
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const visiblePasswordFields = reactive({
sshPassword: false,
rdpPassword: false,
vncPassword: false,
});
const resetPasswordVisibility = (): void => {
visiblePasswordFields.sshPassword = false;
visiblePasswordFields.rdpPassword = false;
visiblePasswordFields.vncPassword = false;
};
const togglePasswordVisibility = (field: keyof typeof visiblePasswordFields): void => {
visiblePasswordFields[field] = !visiblePasswordFields[field];
};
watch(() => props.formData.type, resetPasswordVisibility);
watch(() => props.formData.auth_method, resetPasswordVisibility);
watch(() => props.formData.credential_source, resetPasswordVisibility);
</script> </script>
<template> <template>
@@ -86,8 +107,20 @@ const { t } = useI18n();
<div v-if="props.formData.auth_method === 'password'"> <div v-if="props.formData.auth_method === 'password'">
<label for="conn-password" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password') }}</label> <label for="conn-password" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password') }}</label>
<input type="password" id="conn-password" v-model="props.formData.password" :required="props.formData.auth_method === 'password' && !isEditMode" autocomplete="new-password" <div class="relative">
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" /> <input :type="visiblePasswordFields.sshPassword ? 'text' : 'password'" id="conn-password" v-model="props.formData.password" :required="props.formData.auth_method === 'password' && !isEditMode" autocomplete="new-password"
class="w-full px-3 py-2 pr-11 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center px-3 text-text-secondary hover:text-foreground focus:outline-none focus:text-foreground"
:title="visiblePasswordFields.sshPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-label="visiblePasswordFields.sshPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-pressed="visiblePasswordFields.sshPassword"
@click="togglePasswordVisibility('sshPassword')"
>
<i :class="visiblePasswordFields.sshPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div> </div>
<div v-if="props.formData.auth_method === 'key'" class="space-y-4"> <div v-if="props.formData.auth_method === 'key'" class="space-y-4">
@@ -102,8 +135,20 @@ const { t } = useI18n();
<template v-if="props.formData.type === 'RDP'"> <template v-if="props.formData.type === 'RDP'">
<div> <div>
<label for="conn-password-rdp" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password') }}</label> <label for="conn-password-rdp" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password') }}</label>
<input type="password" id="conn-password-rdp" v-model="props.formData.password" :required="!isEditMode" autocomplete="new-password" <div class="relative">
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" /> <input :type="visiblePasswordFields.rdpPassword ? 'text' : 'password'" id="conn-password-rdp" v-model="props.formData.password" :required="!isEditMode" autocomplete="new-password"
class="w-full px-3 py-2 pr-11 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center px-3 text-text-secondary hover:text-foreground focus:outline-none focus:text-foreground"
:title="visiblePasswordFields.rdpPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-label="visiblePasswordFields.rdpPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-pressed="visiblePasswordFields.rdpPassword"
@click="togglePasswordVisibility('rdpPassword')"
>
<i :class="visiblePasswordFields.rdpPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div> </div>
</template> </template>
@@ -111,8 +156,20 @@ const { t } = useI18n();
<template v-if="props.formData.type === 'VNC'"> <template v-if="props.formData.type === 'VNC'">
<div> <div>
<label for="conn-password-vnc" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.vncPassword', 'VNC 密码') }}</label> <label for="conn-password-vnc" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.vncPassword', 'VNC 密码') }}</label>
<input type="password" id="conn-password-vnc" v-model="props.formData.vncPassword" :required="!isEditMode" autocomplete="new-password" <div class="relative">
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" /> <input :type="visiblePasswordFields.vncPassword ? 'text' : 'password'" id="conn-password-vnc" v-model="props.formData.vncPassword" :required="!isEditMode" autocomplete="new-password"
class="w-full px-3 py-2 pr-11 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center px-3 text-text-secondary hover:text-foreground focus:outline-none focus:text-foreground"
:title="visiblePasswordFields.vncPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-label="visiblePasswordFields.vncPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-pressed="visiblePasswordFields.vncPassword"
@click="togglePasswordVisibility('vncPassword')"
>
<i :class="visiblePasswordFields.vncPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div> </div>
</template> </template>
</template> </template>
@@ -4,9 +4,9 @@
ref="modalContentRef" ref="modalContentRef"
class="bg-background text-foreground p-6 rounded-xl border border-border/50 shadow-2xl flex flex-col" class="bg-background text-foreground p-6 rounded-xl border border-border/50 shadow-2xl flex flex-col"
:style="{ :style="{
width: resizableWidth ? `${resizableWidth}px` : undefined, width: resizableWidth ? `${resizableWidth}px` : `min(calc(100vw - ${MODAL_VIEWPORT_GUTTER_PX}px), ${MODAL_DEFAULT_WIDTH_RATIO * 100}vw)`,
height: resizableHeight ? `${resizableHeight}px` : undefined, height: resizableHeight ? `${resizableHeight}px` : undefined,
maxWidth: 'calc(100vw - 2rem)', maxWidth: `calc(100vw - ${MODAL_VIEWPORT_GUTTER_PX}px)`,
maxHeight: 'calc(100vh - 2rem)', maxHeight: 'calc(100vh - 2rem)',
}" }"
> >
@@ -183,6 +183,8 @@ const modalContentRef = ref<HTMLElement | null>(null);
const commandTextareaRef = ref<HTMLTextAreaElement | null>(null); const commandTextareaRef = ref<HTMLTextAreaElement | null>(null);
const R_MIN_WIDTH = 580; // 可调整大小的最小宽度 (像素) const R_MIN_WIDTH = 580; // 可调整大小的最小宽度 (像素)
const R_MIN_HEIGHT = 440; // 可调整大小的最小高度 (像素) const R_MIN_HEIGHT = 440; // 可调整大小的最小高度 (像素)
const MODAL_DEFAULT_WIDTH_RATIO = 0.6;
const MODAL_VIEWPORT_GUTTER_PX = 32;
const placeholder = t('quickCommands.form.commandPlaceholder') + 'echo "Hello,\${USERNAME}"' const placeholder = t('quickCommands.form.commandPlaceholder') + 'echo "Hello,\${USERNAME}"'
const { width: resizableWidth, height: resizableHeight } = useResizable(modalContentRef, { const { width: resizableWidth, height: resizableHeight } = useResizable(modalContentRef, {
@@ -239,7 +241,7 @@ watch(() => formData.command, (newCommand) => {
// 初始化表单数据 (如果是编辑模式) // 初始化表单数据 (如果是编辑模式)
onMounted(() => { onMounted(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
let initialW = Math.min(window.innerWidth * 0.74, 860); // 目标 74vw,最大 860px let initialW = Math.min(window.innerWidth * MODAL_DEFAULT_WIDTH_RATIO, window.innerWidth - MODAL_VIEWPORT_GUTTER_PX);
let initialH = Math.min(window.innerHeight * 0.68, 600); // 目标 68vh,最大 600px let initialH = Math.min(window.innerHeight * 0.68, 600); // 目标 68vh,最大 600px
initialW = Math.max(R_MIN_WIDTH, initialW); initialW = Math.max(R_MIN_WIDTH, initialW);
@@ -39,6 +39,19 @@ const initialFormData: LoginCredentialInput = {
const formData = reactive({ ...initialFormData }); const formData = reactive({ ...initialFormData });
const formError = ref<string | null>(null); const formError = ref<string | null>(null);
const visiblePasswordFields = reactive({
sshPassword: false,
genericPassword: false,
});
const resetPasswordVisibility = (): void => {
visiblePasswordFields.sshPassword = false;
visiblePasswordFields.genericPassword = false;
};
const togglePasswordVisibility = (field: keyof typeof visiblePasswordFields): void => {
visiblePasswordFields[field] = !visiblePasswordFields[field];
};
watch(() => props.initialType, (newValue) => { watch(() => props.initialType, (newValue) => {
if (!credentialToEdit.value && newValue) { if (!credentialToEdit.value && newValue) {
@@ -47,12 +60,17 @@ watch(() => props.initialType, (newValue) => {
}); });
watch(() => formData.type, (newType) => { watch(() => formData.type, (newType) => {
resetPasswordVisibility();
if (newType !== 'SSH') { if (newType !== 'SSH') {
formData.auth_method = 'password'; formData.auth_method = 'password';
formData.ssh_key_id = null; formData.ssh_key_id = null;
} }
}); });
watch(() => formData.auth_method, () => {
resetPasswordVisibility();
});
onMounted(() => { onMounted(() => {
loginCredentialsStore.fetchLoginCredentials(); loginCredentialsStore.fetchLoginCredentials();
}); });
@@ -60,6 +78,7 @@ onMounted(() => {
const resetForm = () => { const resetForm = () => {
Object.assign(formData, initialFormData, { type: props.initialType || 'SSH' }); Object.assign(formData, initialFormData, { type: props.initialType || 'SSH' });
formError.value = null; formError.value = null;
resetPasswordVisibility();
}; };
const showAddForm = () => { const showAddForm = () => {
@@ -71,6 +90,7 @@ const showAddForm = () => {
const showEditForm = async (credential: LoginCredentialBasicInfo) => { const showEditForm = async (credential: LoginCredentialBasicInfo) => {
formError.value = null; formError.value = null;
credentialToEdit.value = credential; credentialToEdit.value = credential;
resetPasswordVisibility();
const details = await loginCredentialsStore.fetchLoginCredentialDetails(credential.id); const details = await loginCredentialsStore.fetchLoginCredentialDetails(credential.id);
if (!details) { if (!details) {
@@ -300,7 +320,19 @@ const cancelForm = () => {
</div> </div>
<div v-if="formData.auth_method === 'password'"> <div v-if="formData.auth_method === 'password'">
<label for="credential-password" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password', '密码') }}</label> <label for="credential-password" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password', '密码') }}</label>
<input id="credential-password" v-model="formData.password" type="password" autocomplete="new-password" class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" /> <div class="relative">
<input id="credential-password" v-model="formData.password" :type="visiblePasswordFields.sshPassword ? 'text' : 'password'" autocomplete="new-password" class="w-full px-3 py-2 pr-11 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center px-3 text-text-secondary hover:text-foreground focus:outline-none focus:text-foreground"
:title="visiblePasswordFields.sshPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-label="visiblePasswordFields.sshPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-pressed="visiblePasswordFields.sshPassword"
@click="togglePasswordVisibility('sshPassword')"
>
<i :class="visiblePasswordFields.sshPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div> </div>
<div v-else> <div v-else>
<label class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.sshKey', 'SSH 密钥') }}</label> <label class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.sshKey', 'SSH 密钥') }}</label>
@@ -313,7 +345,19 @@ const cancelForm = () => {
<div v-else> <div v-else>
<label for="credential-password-generic" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password', '密码') }}</label> <label for="credential-password-generic" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password', '密码') }}</label>
<input id="credential-password-generic" v-model="formData.password" type="password" autocomplete="new-password" class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" /> <div class="relative">
<input id="credential-password-generic" v-model="formData.password" :type="visiblePasswordFields.genericPassword ? 'text' : 'password'" autocomplete="new-password" class="w-full px-3 py-2 pr-11 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
<button
type="button"
class="absolute inset-y-0 right-0 flex items-center px-3 text-text-secondary hover:text-foreground focus:outline-none focus:text-foreground"
:title="visiblePasswordFields.genericPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-label="visiblePasswordFields.genericPassword ? t('connections.form.hidePassword', '隐藏密码') : t('connections.form.showPassword', '显示密码')"
:aria-pressed="visiblePasswordFields.genericPassword"
@click="togglePasswordVisibility('genericPassword')"
>
<i :class="visiblePasswordFields.genericPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div> </div>
<div> <div>
+2
View File
@@ -265,6 +265,8 @@
"authMethodPassword": "Password", "authMethodPassword": "Password",
"authMethodKey": "SSH Key", "authMethodKey": "SSH Key",
"password": "Password:", "password": "Password:",
"showPassword": "Show password",
"hidePassword": "Hide password",
"privateKey": "Private Key:", "privateKey": "Private Key:",
"passphrase": "Passphrase:", "passphrase": "Passphrase:",
"vncPassword": "VNC Password", "vncPassword": "VNC Password",
+2
View File
@@ -196,6 +196,8 @@
"optional": "オプション", "optional": "オプション",
"passphrase": "パスフレーズ:", "passphrase": "パスフレーズ:",
"password": "パスワード:", "password": "パスワード:",
"showPassword": "パスワードを表示",
"hidePassword": "パスワードを隠す",
"port": "ポート:", "port": "ポート:",
"privateKey": "秘密鍵:", "privateKey": "秘密鍵:",
"noSshKey":"SSHキーなし", "noSshKey":"SSHキーなし",
+2
View File
@@ -266,6 +266,8 @@
"authMethodPassword": "密码", "authMethodPassword": "密码",
"authMethodKey": "SSH 密钥", "authMethodKey": "SSH 密钥",
"password": "密码:", "password": "密码:",
"showPassword": "显示密码",
"hidePassword": "隐藏密码",
"privateKey": "私钥:", "privateKey": "私钥:",
"passphrase": "私钥密码:", "passphrase": "私钥密码:",
"vncPassword": "VNC 密码:", "vncPassword": "VNC 密码:",
@@ -3,62 +3,58 @@ import { ref } from 'vue';
import apiClient from '../utils/apiClient'; import apiClient from '../utils/apiClient';
import { useUiNotificationsStore } from './uiNotifications.store'; import { useUiNotificationsStore } from './uiNotifications.store';
// 定义快捷指令标签接口 (与后端 QuickCommandTag 对应)
export interface QuickCommandTag { export interface QuickCommandTag {
id: number; id: number;
name: string; name: string;
sort_order: number;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
} }
const TAG_CACHE_KEY = 'quickCommandTagsCache';
const normalizeTag = (tag: any): QuickCommandTag => ({
id: Number(tag.id),
name: typeof tag.name === 'string' ? tag.name : '',
sort_order: Number.isFinite(tag.sort_order) ? Number(tag.sort_order) : 0,
created_at: Number(tag.created_at ?? 0),
updated_at: Number(tag.updated_at ?? 0),
});
export const useQuickCommandTagsStore = defineStore('quickCommandTags', () => { export const useQuickCommandTagsStore = defineStore('quickCommandTags', () => {
const tags = ref<QuickCommandTag[]>([]); const tags = ref<QuickCommandTag[]>([]);
const isLoading = ref(false); const isLoading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const uiNotificationsStore = useUiNotificationsStore(); const uiNotificationsStore = useUiNotificationsStore();
// 获取快捷指令标签列表 (带缓存)
async function fetchTags() { async function fetchTags() {
const cacheKey = 'quickCommandTagsCache';
error.value = null; error.value = null;
// 1. 尝试从 localStorage 加载缓存
try { try {
const cachedData = localStorage.getItem(cacheKey); const cachedData = localStorage.getItem(TAG_CACHE_KEY);
if (cachedData) { if (cachedData) {
tags.value = JSON.parse(cachedData); const parsedData = JSON.parse(cachedData);
isLoading.value = false; if (Array.isArray(parsedData)) {
} else { tags.value = parsedData.map(normalizeTag);
isLoading.value = true; }
} }
} catch (e) { } catch (cacheError) {
console.error('[QuickCmdTagStore] Failed to load or parse cache:', e); console.error('[QuickCommandTagsStore] 读取标签缓存失败:', cacheError);
localStorage.removeItem(cacheKey); localStorage.removeItem(TAG_CACHE_KEY);
isLoading.value = true;
} }
// 2. 后台获取最新数据
isLoading.value = true; isLoading.value = true;
try { try {
// 使用新的 API 端点
const response = await apiClient.get<QuickCommandTag[]>('/quick-command-tags'); const response = await apiClient.get<QuickCommandTag[]>('/quick-command-tags');
const freshData = response.data; const freshTags = Array.isArray(response.data) ? response.data.map(normalizeTag) : [];
const freshDataString = JSON.stringify(freshData); tags.value = freshTags;
localStorage.setItem(TAG_CACHE_KEY, JSON.stringify(freshTags));
// 3. 对比并更新
const currentDataString = JSON.stringify(tags.value);
if (currentDataString !== freshDataString) {
tags.value = freshData;
localStorage.setItem(cacheKey, freshDataString);
} else {
}
error.value = null;
return true; return true;
} catch (err: any) { } catch (err: any) {
console.error('[QuickCmdTagStore] Failed to fetch tags:', err); console.error('[QuickCommandTagsStore] 获取标签失败:', err);
error.value = err.response?.data?.message || err.message || '获取快捷指令标签列表失败'; error.value = err.response?.data?.message || err.message || '获取快捷指令标签列表失败';
if (error.value) { // Check if error.value is not null if (error.value) {
uiNotificationsStore.showError(error.value); // 显示错误通知 uiNotificationsStore.showError(error.value);
} }
return false; return false;
} finally { } finally {
@@ -66,22 +62,19 @@ export const useQuickCommandTagsStore = defineStore('quickCommandTags', () => {
} }
} }
// 添加新快捷指令标签 (添加后清除缓存)
async function addTag(name: string): Promise<QuickCommandTag | null> { async function addTag(name: string): Promise<QuickCommandTag | null> {
isLoading.value = true; isLoading.value = true;
error.value = null; error.value = null;
try { try {
// 使用新的 API 端点 const response = await apiClient.post<{ message: string; tag: QuickCommandTag }>('/quick-command-tags', { name });
const response = await apiClient.post<{ message: string, tag: QuickCommandTag }>('/quick-command-tags', { name }); localStorage.removeItem(TAG_CACHE_KEY);
const newTag = response.data.tag; await fetchTags();
localStorage.removeItem('quickCommandTagsCache'); // 清除缓存
await fetchTags(); // 重新获取以更新列表
uiNotificationsStore.showSuccess('快捷指令标签已添加'); uiNotificationsStore.showSuccess('快捷指令标签已添加');
return newTag; return response.data.tag ? normalizeTag(response.data.tag) : null;
} catch (err: any) { } catch (err: any) {
console.error('[QuickCmdTagStore] Failed to add tag:', err); console.error('[QuickCommandTagsStore] 添加标签失败:', err);
error.value = err.response?.data?.message || err.message || '添加快捷指令标签失败'; error.value = err.response?.data?.message || err.message || '添加快捷指令标签失败';
if (error.value) { // Check if error.value is not null if (error.value) {
uiNotificationsStore.showError(error.value); uiNotificationsStore.showError(error.value);
} }
return null; return null;
@@ -90,21 +83,19 @@ export const useQuickCommandTagsStore = defineStore('quickCommandTags', () => {
} }
} }
// 更新快捷指令标签
async function updateTag(id: number, name: string): Promise<boolean> { async function updateTag(id: number, name: string): Promise<boolean> {
isLoading.value = true; isLoading.value = true;
error.value = null; error.value = null;
try { try {
// 使用新的 API 端点
await apiClient.put(`/quick-command-tags/${id}`, { name }); await apiClient.put(`/quick-command-tags/${id}`, { name });
localStorage.removeItem('quickCommandTagsCache'); localStorage.removeItem(TAG_CACHE_KEY);
await fetchTags(); await fetchTags();
uiNotificationsStore.showSuccess('快捷指令标签已更新'); uiNotificationsStore.showSuccess('快捷指令标签已更新');
return true; return true;
} catch (err: any) { } catch (err: any) {
console.error('[QuickCmdTagStore] Failed to update tag:', err); console.error('[QuickCommandTagsStore] 更新标签失败:', err);
error.value = err.response?.data?.message || err.message || '更新快捷指令标签失败'; error.value = err.response?.data?.message || err.message || '更新快捷指令标签失败';
if (error.value) { // Check if error.value is not null if (error.value) {
uiNotificationsStore.showError(error.value); uiNotificationsStore.showError(error.value);
} }
return false; return false;
@@ -113,21 +104,43 @@ export const useQuickCommandTagsStore = defineStore('quickCommandTags', () => {
} }
} }
// 删除快捷指令标签
async function deleteTag(id: number): Promise<boolean> { async function deleteTag(id: number): Promise<boolean> {
isLoading.value = true; isLoading.value = true;
error.value = null; error.value = null;
try { try {
// 使用新的 API 端点
await apiClient.delete(`/quick-command-tags/${id}`); await apiClient.delete(`/quick-command-tags/${id}`);
localStorage.removeItem('quickCommandTagsCache'); localStorage.removeItem(TAG_CACHE_KEY);
await fetchTags(); await fetchTags();
uiNotificationsStore.showSuccess('快捷指令标签已删除'); uiNotificationsStore.showSuccess('快捷指令标签已删除');
return true; return true;
} catch (err: any) { } catch (err: any) {
console.error('[QuickCmdTagStore] Failed to delete tag:', err); console.error('[QuickCommandTagsStore] 删除标签失败:', err);
error.value = err.response?.data?.message || err.message || '删除快捷指令标签失败'; error.value = err.response?.data?.message || err.message || '删除快捷指令标签失败';
if (error.value) { // Check if error.value is not null if (error.value) {
uiNotificationsStore.showError(error.value);
}
return false;
} finally {
isLoading.value = false;
}
}
async function reorderTags(tagIds: number[]): Promise<boolean> {
if (!Array.isArray(tagIds) || tagIds.length === 0) {
return false;
}
isLoading.value = true;
error.value = null;
try {
await apiClient.put('/quick-command-tags/reorder', { tagIds });
localStorage.removeItem(TAG_CACHE_KEY);
await fetchTags();
return true;
} catch (err: any) {
console.error('[QuickCommandTagsStore] 更新标签顺序失败:', err);
error.value = err.response?.data?.message || err.message || '更新快捷指令标签顺序失败';
if (error.value) {
uiNotificationsStore.showError(error.value); uiNotificationsStore.showError(error.value);
} }
return false; return false;
@@ -144,5 +157,6 @@ export const useQuickCommandTagsStore = defineStore('quickCommandTags', () => {
addTag, addTag,
updateTag, updateTag,
deleteTag, deleteTag,
reorderTags,
}; };
}); });
@@ -2,169 +2,220 @@ import { defineStore } from 'pinia';
import apiClient from '../utils/apiClient'; import apiClient from '../utils/apiClient';
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import { useUiNotificationsStore } from './uiNotifications.store'; import { useUiNotificationsStore } from './uiNotifications.store';
import { useQuickCommandTagsStore, type QuickCommandTag } from './quickCommandTags.store'; import { useQuickCommandTagsStore } from './quickCommandTags.store';
import { useSettingsStore } from './settings.store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
export interface QuickCommandFE {
// 定义前端使用的快捷指令接口 (包含 tagIds)
export interface QuickCommandFE { // Renamed from QuickCommand if necessary
id: number; id: number;
name: string | null; name: string | null;
command: string; command: string;
usage_count: number; usage_count: number;
sort_order: number;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
tagIds: number[]; // +++ Add tagIds +++ tagIds: number[];
variables?: Record<string, string>; // New: Add variables tagOrders: Record<number, number>;
variables?: Record<string, string> | null;
} }
// 定义排序类型 export type QuickCommandSortByType = 'manual' | 'name' | 'usage_count' | 'last_used';
export type QuickCommandSortByType = 'name' | 'usage_count' | 'last_used';
// 定义分组后的数据结构
export interface GroupedQuickCommands { export interface GroupedQuickCommands {
groupName: string; groupName: string;
tagId: number | null; // null for "Untagged" group tagId: number | null;
commands: QuickCommandFE[]; commands: QuickCommandFE[];
} }
// +++ localStorage key for expanded groups +++
const EXPANDED_GROUPS_STORAGE_KEY = 'quickCommandsExpandedGroups'; const EXPANDED_GROUPS_STORAGE_KEY = 'quickCommandsExpandedGroups';
const QUICK_COMMANDS_CACHE_KEY = 'quickCommandsListCache';
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const normalizeTagOrders = (tagOrders: unknown): Record<number, number> => {
if (!isRecord(tagOrders)) {
return {};
}
return Object.entries(tagOrders).reduce<Record<number, number>>((result, [tagId, sortOrder]) => {
const numericTagId = Number(tagId);
const numericSortOrder = Number(sortOrder);
if (Number.isInteger(numericTagId) && Number.isFinite(numericSortOrder)) {
result[numericTagId] = numericSortOrder;
}
return result;
}, {});
};
const normalizeQuickCommand = (command: any): QuickCommandFE => ({
id: Number(command.id),
name: typeof command.name === 'string' ? command.name : null,
command: typeof command.command === 'string' ? command.command : '',
usage_count: Number(command.usage_count ?? 0),
sort_order: Number(command.sort_order ?? 0),
created_at: Number(command.created_at ?? 0),
updated_at: Number(command.updated_at ?? 0),
tagIds: Array.isArray(command.tagIds)
? Array.from(new Set(command.tagIds.filter((tagId: unknown) => Number.isInteger(tagId) && Number(tagId) > 0)))
: [],
tagOrders: normalizeTagOrders(command.tagOrders),
variables: isRecord(command.variables) ? (command.variables as Record<string, string>) : undefined,
});
const compareByLabel = (a: QuickCommandFE, b: QuickCommandFE): number => {
const labelA = a.name ?? a.command;
const labelB = b.name ?? b.command;
return labelA.localeCompare(labelB);
};
const compareCommands = (
a: QuickCommandFE,
b: QuickCommandFE,
sortBy: QuickCommandSortByType,
tagId?: number | null,
): number => {
if (sortBy === 'manual') {
if (typeof tagId === 'number') {
const tagOrderA = a.tagOrders[tagId];
const tagOrderB = b.tagOrders[tagId];
if (typeof tagOrderA === 'number' && typeof tagOrderB === 'number' && tagOrderA !== tagOrderB) {
return tagOrderA - tagOrderB;
}
if (typeof tagOrderA === 'number' && typeof tagOrderB !== 'number') {
return -1;
}
if (typeof tagOrderA !== 'number' && typeof tagOrderB === 'number') {
return 1;
}
}
if (a.sort_order !== b.sort_order) {
return a.sort_order - b.sort_order;
}
}
if (sortBy === 'usage_count' && a.usage_count !== b.usage_count) {
return b.usage_count - a.usage_count;
}
if (sortBy === 'last_used' && a.updated_at !== b.updated_at) {
return b.updated_at - a.updated_at;
}
return compareByLabel(a, b);
};
export const useQuickCommandsStore = defineStore('quickCommands', () => { export const useQuickCommandsStore = defineStore('quickCommands', () => {
const quickCommandsList = ref<QuickCommandFE[]>([]); // Should now contain QuickCommandFE with tagIds const quickCommandsList = ref<QuickCommandFE[]>([]);
const searchTerm = ref(''); const searchTerm = ref('');
const sortBy = ref<QuickCommandSortByType>('name'); // 默认按名称排序 const sortBy = ref<QuickCommandSortByType>('manual');
const isLoading = ref(false); const isLoading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const uiNotificationsStore = useUiNotificationsStore(); const selectedIndex = ref<number>(-1);
const quickCommandTagsStore = useQuickCommandTagsStore(); // +++ Inject new tag store +++
const { t } = useI18n(); // +++ For "Untagged" translation +++
const selectedIndex = ref<number>(-1); // Index in the flatVisibleCommands list
// +++ State for expanded groups +++
const expandedGroups = ref<Record<string, boolean>>({}); const expandedGroups = ref<Record<string, boolean>>({});
// --- Getters --- const uiNotificationsStore = useUiNotificationsStore();
const quickCommandTagsStore = useQuickCommandTagsStore();
const settingsStore = useSettingsStore();
const { t } = useI18n();
// +++ 重写 Getter: 过滤、分组、排序指令 +++ const filteredCommands = computed(() => {
const filteredAndGroupedCommands = computed((): GroupedQuickCommands[] => {
const term = searchTerm.value.toLowerCase().trim(); const term = searchTerm.value.toLowerCase().trim();
const allTags = quickCommandTagsStore.tags; // 获取快捷指令专属标签 if (!term) {
const tagMap = new Map(allTags.map(tag => [tag.id, tag.name])); return quickCommandsList.value;
const untaggedGroupName = t('quickCommands.untagged', '未标记'); // 获取 "未标记" 的翻译 }
// 1. 过滤 (New logic: filter by command name, command content, OR tag name) const tagMap = new Map(quickCommandTagsStore.tags.map((tag) => [tag.id, tag.name]));
let filtered = quickCommandsList.value; return quickCommandsList.value.filter((command) => {
if (term) { if (command.name && command.name.toLowerCase().includes(term)) {
filtered = filtered.filter(cmd => { return true;
// Check command name }
if (cmd.name && cmd.name.toLowerCase().includes(term)) {
return true; if (command.command.toLowerCase().includes(term)) {
} return true;
// Check command content }
if (cmd.command.toLowerCase().includes(term)) {
return true; return command.tagIds.some((tagId) => {
} const tagName = tagMap.get(tagId);
// Check associated tag names return typeof tagName === 'string' && tagName.toLowerCase().includes(term);
if (cmd.tagIds && cmd.tagIds.length > 0) { });
for (const tagId of cmd.tagIds) { });
const tagName = tagMap.get(tagId); });
if (tagName && tagName.toLowerCase().includes(term)) {
return true; // Match found in tag name const sortedFlatCommands = computed(() => {
} return [...filteredCommands.value].sort((a, b) => compareCommands(a, b, sortBy.value));
} });
}
// No match found const filteredAndGroupedCommands = computed((): GroupedQuickCommands[] => {
return false; const untaggedGroupName = t('quickCommands.untagged', '未标记');
const sortedTags = [...quickCommandTagsStore.tags].sort((a, b) => {
if (a.sort_order !== b.sort_order) {
return a.sort_order - b.sort_order;
}
return a.name.localeCompare(b.name);
});
const groups = new Map<number, GroupedQuickCommands>();
const untaggedCommands: QuickCommandFE[] = [];
for (const tag of sortedTags) {
if (expandedGroups.value[tag.name] === undefined) {
expandedGroups.value[tag.name] = true;
}
groups.set(tag.id, {
groupName: tag.name,
tagId: tag.id,
commands: [],
}); });
} }
// 2. 分组 for (const command of filteredCommands.value) {
const groups: Record<string, { commands: QuickCommandFE[], tagId: number | null }> = {}; const validTagIds = command.tagIds.filter((tagId) => groups.has(tagId));
const untaggedCommands: QuickCommandFE[] = []; if (validTagIds.length === 0) {
untaggedCommands.push(command);
filtered.forEach(cmd => { continue;
let isTagged = false;
if (cmd.tagIds && cmd.tagIds.length > 0) {
cmd.tagIds.forEach(tagId => {
const tagName = tagMap.get(tagId);
if (tagName) {
if (!groups[tagName]) {
groups[tagName] = { commands: [], tagId: tagId };
// 初始化展开状态 (如果未定义,默认为 true)
if (expandedGroups.value[tagName] === undefined) {
expandedGroups.value[tagName] = true;
}
}
// 避免重复添加(如果一个指令有多个相同标签ID? 不太可能但做个防御)
if (!groups[tagName].commands.some(c => c.id === cmd.id)) {
groups[tagName].commands.push(cmd);
}
isTagged = true;
}
});
} }
if (!isTagged) {
untaggedCommands.push(cmd); for (const tagId of validTagIds) {
groups.get(tagId)!.commands.push(command);
} }
}); }
// 3. 排序分组内指令 & 格式化输出 const result: GroupedQuickCommands[] = [];
const sortedGroupNames = Object.keys(groups).sort((a, b) => a.localeCompare(b)); for (const tag of sortedTags) {
const result: GroupedQuickCommands[] = sortedGroupNames.map(groupName => { const group = groups.get(tag.id);
const groupData = groups[groupName]; if (!group || group.commands.length === 0) {
// 组内排序 continue;
groupData.commands.sort((a, b) => { }
if (sortBy.value === 'usage_count') { group.commands.sort((a, b) => compareCommands(a, b, sortBy.value, tag.id));
if (b.usage_count !== a.usage_count) return b.usage_count - a.usage_count; result.push(group);
} else if (sortBy.value === 'last_used') { }
if (b.updated_at !== a.updated_at) return b.updated_at - a.updated_at;
}
const nameA = a.name ?? a.command; // Fallback to command if name is null
const nameB = b.name ?? b.command;
return nameA.localeCompare(nameB);
});
return {
groupName: groupName,
tagId: groupData.tagId,
commands: groupData.commands
};
});
// 4. 处理未标记的分组
if (untaggedCommands.length > 0) { if (untaggedCommands.length > 0) {
// 初始化展开状态 (如果未定义,默认为 true) if (expandedGroups.value[untaggedGroupName] === undefined) {
if (expandedGroups.value[untaggedGroupName] === undefined) { expandedGroups.value[untaggedGroupName] = true;
expandedGroups.value[untaggedGroupName] = true; }
} result.push({
// 组内排序 groupName: untaggedGroupName,
untaggedCommands.sort((a, b) => { tagId: null,
if (sortBy.value === 'usage_count') { commands: [...untaggedCommands].sort((a, b) => compareCommands(a, b, sortBy.value, null)),
if (b.usage_count !== a.usage_count) return b.usage_count - a.usage_count; });
} else if (sortBy.value === 'last_used') {
if (b.updated_at !== a.updated_at) return b.updated_at - a.updated_at;
}
const nameA = a.name ?? a.command;
const nameB = b.name ?? b.command;
return nameA.localeCompare(nameB);
});
result.push({
groupName: untaggedGroupName,
tagId: null,
commands: untaggedCommands
});
} }
return result; return result;
}); });
// +++ Getter: 获取当前可见的扁平指令列表 (用于键盘导航) +++
const flatVisibleCommands = computed((): QuickCommandFE[] => { const flatVisibleCommands = computed((): QuickCommandFE[] => {
if (!settingsStore.showQuickCommandTagsBoolean) {
return sortedFlatCommands.value;
}
const flatList: QuickCommandFE[] = []; const flatList: QuickCommandFE[] = [];
filteredAndGroupedCommands.value.forEach(group => { filteredAndGroupedCommands.value.forEach((group) => {
// 只添加已展开分组中的指令
if (expandedGroups.value[group.groupName]) { if (expandedGroups.value[group.groupName]) {
flatList.push(...group.commands); flatList.push(...group.commands);
} }
@@ -172,130 +223,91 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
return flatList; return flatList;
}); });
// --- Actions ---
// +++ Load initial expanded groups state from localStorage +++
const loadExpandedGroups = () => { const loadExpandedGroups = () => {
try { try {
const storedState = localStorage.getItem(EXPANDED_GROUPS_STORAGE_KEY); const storedState = localStorage.getItem(EXPANDED_GROUPS_STORAGE_KEY);
if (storedState) { if (storedState) {
const parsedState = JSON.parse(storedState); const parsedState = JSON.parse(storedState);
if (typeof parsedState === 'object' && parsedState !== null) { if (isRecord(parsedState)) {
expandedGroups.value = parsedState; expandedGroups.value = Object.entries(parsedState).reduce<Record<string, boolean>>((result, [key, value]) => {
console.log('[QuickCmdStore] Loaded expanded groups state from localStorage.'); result[key] = Boolean(value);
return result;
}, {});
return; return;
} }
} }
} catch (e) { } catch (cacheError) {
console.error('[QuickCmdStore] Failed to load or parse expanded groups state:', e); console.error('[QuickCommandsStore] 读取分组展开状态失败:', cacheError);
localStorage.removeItem(EXPANDED_GROUPS_STORAGE_KEY); localStorage.removeItem(EXPANDED_GROUPS_STORAGE_KEY);
} }
// Default to empty object if no valid state found
expandedGroups.value = {}; expandedGroups.value = {};
}; };
// +++ Save expanded groups state to localStorage +++
const saveExpandedGroups = () => { const saveExpandedGroups = () => {
try { try {
localStorage.setItem(EXPANDED_GROUPS_STORAGE_KEY, JSON.stringify(expandedGroups.value)); localStorage.setItem(EXPANDED_GROUPS_STORAGE_KEY, JSON.stringify(expandedGroups.value));
} catch (e) { } catch (cacheError) {
console.error('[QuickCmdStore] Failed to save expanded groups state:', e); console.error('[QuickCommandsStore] 保存分组展开状态失败:', cacheError);
} }
}; };
// +++ Watch for changes and save +++
watch(expandedGroups, saveExpandedGroups, { deep: true }); watch(expandedGroups, saveExpandedGroups, { deep: true });
// +++ Action to toggle group expansion +++
const toggleGroup = (groupName: string) => { const toggleGroup = (groupName: string) => {
// Ensure the group exists in the state before toggling expandedGroups.value[groupName] = expandedGroups.value[groupName] === undefined
if (expandedGroups.value[groupName] === undefined) { ? false
// Default to true if toggling a group that wasn't explicitly set (e.g., newly appeared group) : !expandedGroups.value[groupName];
expandedGroups.value[groupName] = false; // Start collapsed if toggled first time? Or true? Let's start true.
} else {
expandedGroups.value[groupName] = !expandedGroups.value[groupName];
}
// The watcher will automatically save the state
// Reset selection when a group is toggled? Maybe not necessary.
// selectedIndex.value = -1;
}; };
// Action to select the next command in the *visible* flat list
const selectNextCommand = () => { const selectNextCommand = () => {
const commands = flatVisibleCommands.value; // Use the flat visible list const commands = flatVisibleCommands.value;
if (commands.length === 0) { if (commands.length === 0) {
selectedIndex.value = -1; selectedIndex.value = -1;
return; return;
} }
selectedIndex.value = (selectedIndex.value + 1) % commands.length; selectedIndex.value = (selectedIndex.value + 1) % commands.length;
}; };
// Action to select the previous command in the *visible* flat list
const selectPreviousCommand = () => { const selectPreviousCommand = () => {
const commands = flatVisibleCommands.value; // Use the flat visible list const commands = flatVisibleCommands.value;
if (commands.length === 0) { if (commands.length === 0) {
selectedIndex.value = -1; selectedIndex.value = -1;
return; return;
} }
selectedIndex.value = (selectedIndex.value - 1 + commands.length) % commands.length; selectedIndex.value = (selectedIndex.value - 1 + commands.length) % commands.length;
}; };
// 从后端获取快捷指令 (包含 tagIds,不再发送 sortBy) const clearQuickCommandsCache = () => {
localStorage.removeItem(QUICK_COMMANDS_CACHE_KEY);
};
const fetchQuickCommands = async () => { const fetchQuickCommands = async () => {
// 简化缓存:只缓存原始列表,不再区分排序
const cacheKey = 'quickCommandsListCache';
error.value = null; error.value = null;
// 1. 尝试从 localStorage 加载缓存
try { try {
const cachedData = localStorage.getItem(cacheKey); const cachedData = localStorage.getItem(QUICK_COMMANDS_CACHE_KEY);
if (cachedData) { if (cachedData) {
// 确保解析后的数据符合 QuickCommandFE 结构 (特别是 tagIds 和 variables) const parsedData = JSON.parse(cachedData);
const parsedData = JSON.parse(cachedData) as QuickCommandFE[]; if (Array.isArray(parsedData)) {
// 基本验证,确保 tagIds 是数组,variables 是对象或undefined quickCommandsList.value = parsedData.map(normalizeQuickCommand);
if (Array.isArray(parsedData) && parsedData.every(item => Array.isArray(item.tagIds) && (item.variables === undefined || typeof item.variables === 'object'))) {
quickCommandsList.value = parsedData;
isLoading.value = false;
} else {
console.warn('[QuickCmdStore] Cached data format invalid, ignoring cache.');
localStorage.removeItem(cacheKey);
isLoading.value = true;
} }
} else {
isLoading.value = true;
} }
} catch (e) { } catch (cacheError) {
console.error('[QuickCmdStore] Failed to load or parse commands cache:', e); console.error('[QuickCommandsStore] 读取快捷指令缓存失败:', cacheError);
localStorage.removeItem(cacheKey); clearQuickCommandsCache();
isLoading.value = true;
} }
// 2. 后台获取最新数据
isLoading.value = true; isLoading.value = true;
try { try {
console.log(`[QuickCmdStore] Fetching latest commands from server...`);
// 不再发送 sortBy 参数
const response = await apiClient.get<QuickCommandFE[]>('/quick-commands'); const response = await apiClient.get<QuickCommandFE[]>('/quick-commands');
// 确保返回的数据包含 tagIds 数组和 variables 对象 const freshData = Array.isArray(response.data) ? response.data.map(normalizeQuickCommand) : [];
const freshData = response.data.map(cmd => ({ quickCommandsList.value = freshData;
...cmd, localStorage.setItem(QUICK_COMMANDS_CACHE_KEY, JSON.stringify(freshData));
tagIds: Array.isArray(cmd.tagIds) ? cmd.tagIds : [], // 确保 tagIds 是数组
variables: typeof cmd.variables === 'object' ? cmd.variables : undefined // 确保 variables 是对象或 undefined
}));
const freshDataString = JSON.stringify(freshData);
// 3. 对比并更新
const currentDataString = JSON.stringify(quickCommandsList.value);
if (currentDataString !== freshDataString) {
console.log('[QuickCmdStore] Commands data changed, updating state and cache.');
quickCommandsList.value = freshData;
localStorage.setItem(cacheKey, freshDataString); // 更新缓存
} else {
}
error.value = null;
} catch (err: any) { } catch (err: any) {
console.error('[QuickCmdStore] 获取快捷指令失败:', err); console.error('[QuickCommandsStore] 获取快捷指令失败:', err);
error.value = err.response?.data?.message || '获取快捷指令时发生错误'; error.value = err.response?.data?.message || '获取快捷指令时发生错误';
if (error.value) { if (error.value) {
uiNotificationsStore.showError(error.value); uiNotificationsStore.showError(error.value);
@@ -305,109 +317,154 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
} }
}; };
// 清除快捷指令列表缓存 const addQuickCommand = async (
const clearQuickCommandsCache = () => { name: string | null,
localStorage.removeItem('quickCommandsListCache'); command: string,
console.log('[QuickCmdStore] Cleared quick commands list cache.'); tagIds?: number[],
}; variables?: Record<string, string>,
): Promise<boolean> => {
// 添加快捷指令 (发送 tagIds 和 variables)
const addQuickCommand = async (name: string | null, command: string, tagIds?: number[], variables?: Record<string, string>): Promise<boolean> => {
try { try {
// 在请求体中包含 tagIds 和 variables await apiClient.post('/quick-commands', { name, command, tagIds, variables });
const response = await apiClient.post<{ message: string, command: QuickCommandFE }>('/quick-commands', { name, command, tagIds, variables }); clearQuickCommandsCache();
// 后端现在返回完整的 command 对象,可以直接使用或触发刷新 await fetchQuickCommands();
clearQuickCommandsCache(); // 清除缓存
await fetchQuickCommands(); // 重新获取以确保数据同步
uiNotificationsStore.showSuccess('快捷指令已添加'); uiNotificationsStore.showSuccess('快捷指令已添加');
return true; return true;
} catch (err: any) { } catch (err: any) {
console.error('添加快捷指令失败:', err); console.error('[QuickCommandsStore] 添加快捷指令失败:', err);
const message = err.response?.data?.message || '添加快捷指令时发生错误'; const message = err.response?.data?.message || '添加快捷指令时发生错误';
uiNotificationsStore.showError(message); uiNotificationsStore.showError(message);
return false; return false;
} }
}; };
// 更新快捷指令 (发送 tagIds 和 variables) const updateQuickCommand = async (
const updateQuickCommand = async (id: number, name: string | null, command: string, tagIds?: number[], variables?: Record<string, string>): Promise<boolean> => { id: number,
try { name: string | null,
// 在请求体中包含 tagIds 和 variables (即使是 undefined 也要发送,让后端知道是否要更新) command: string,
const response = await apiClient.put<{ message: string, command: QuickCommandFE }>(`/quick-commands/${id}`, { name, command, tagIds, variables }); tagIds?: number[],
// 后端现在返回完整的 command 对象 variables?: Record<string, string>,
clearQuickCommandsCache(); // 清除缓存 ): Promise<boolean> => {
await fetchQuickCommands(); // 重新获取以确保数据同步 try {
await apiClient.put(`/quick-commands/${id}`, { name, command, tagIds, variables });
clearQuickCommandsCache();
await fetchQuickCommands();
uiNotificationsStore.showSuccess('快捷指令已更新'); uiNotificationsStore.showSuccess('快捷指令已更新');
return true; return true;
} catch (err: any) { } catch (err: any) {
console.error('更新快捷指令失败:', err); console.error('[QuickCommandsStore] 更新快捷指令失败:', err);
const message = err.response?.data?.message || '更新快捷指令时发生错误'; const message = err.response?.data?.message || '更新快捷指令时发生错误';
uiNotificationsStore.showError(message); uiNotificationsStore.showError(message);
return false; return false;
} }
}; };
// 删除快捷指令
const deleteQuickCommand = async (id: number) => { const deleteQuickCommand = async (id: number) => {
try { try {
await apiClient.delete(`/quick-commands/${id}`); await apiClient.delete(`/quick-commands/${id}`);
clearQuickCommandsCache(); // 清除所有排序缓存 quickCommandsList.value = quickCommandsList.value.filter((command) => command.id !== id);
// 从本地列表中移除 clearQuickCommandsCache();
const index = quickCommandsList.value.findIndex(cmd => cmd.id === id);
if (index !== -1) {
quickCommandsList.value.splice(index, 1);
}
uiNotificationsStore.showSuccess('快捷指令已删除'); uiNotificationsStore.showSuccess('快捷指令已删除');
} catch (err: any) { } catch (err: any) {
console.error('删除快捷指令失败:', err); console.error('[QuickCommandsStore] 删除快捷指令失败:', err);
const message = err.response?.data?.message || '删除快捷指令时发生错误'; const message = err.response?.data?.message || '删除快捷指令时发生错误';
uiNotificationsStore.showError(message); uiNotificationsStore.showError(message);
} }
}; };
// 增加使用次数 (调用 API,然后更新本地数据)
const incrementUsage = async (id: number) => { const incrementUsage = async (id: number) => {
try { try {
await apiClient.post(`/quick-commands/${id}/increment-usage`); // 使用 apiClient await apiClient.post(`/quick-commands/${id}/increment-usage`);
// 更新本地计数,避免重新请求整个列表 const command = quickCommandsList.value.find((item) => item.id === id);
const command = quickCommandsList.value.find(cmd => cmd.id === id);
if (command) { if (command) {
command.usage_count += 1; command.usage_count += 1;
// 如果当前是按使用次数排序,可能需要重新排序或刷新列表 command.updated_at = Math.floor(Date.now() / 1000);
if (sortBy.value === 'usage_count') {
// 清除所有排序缓存并重新获取当前排序
clearQuickCommandsCache();
await fetchQuickCommands();
}
} }
} catch (err: any) { } catch (err) {
console.error('增加使用次数失败:', err); console.error('[QuickCommandsStore] 增加快捷指令使用次数失败:', err);
// 这里可以选择不提示用户错误,因为这是一个后台操作
} }
}; };
// 设置搜索词
const setSearchTerm = (term: string) => { const setSearchTerm = (term: string) => {
searchTerm.value = term; searchTerm.value = term;
selectedIndex.value = -1; // Reset selection when search term changes selectedIndex.value = -1;
}; };
// 设置排序方式 (只更新本地状态,不再重新获取数据)
const setSortBy = (newSortBy: QuickCommandSortByType) => { const setSortBy = (newSortBy: QuickCommandSortByType) => {
if (sortBy.value !== newSortBy) { if (sortBy.value !== newSortBy) {
sortBy.value = newSortBy; sortBy.value = newSortBy;
// 排序现在由 filteredAndGroupedCommands getter 处理,无需重新 fetch selectedIndex.value = -1;
selectedIndex.value = -1; // Reset selection when sort changes
} }
}; };
// Action to reset the selection
const resetSelection = () => { const resetSelection = () => {
selectedIndex.value = -1; selectedIndex.value = -1;
}; };
// Removed duplicate resetSelection definition const reorderQuickCommands = async (commandIds: number[]): Promise<boolean> => {
if (!Array.isArray(commandIds) || commandIds.length === 0) {
return false;
}
try {
setSortBy('manual');
await apiClient.put('/quick-commands/reorder', { commandIds });
clearQuickCommandsCache();
await fetchQuickCommands();
return true;
} catch (err: any) {
console.error('[QuickCommandsStore] 更新快捷指令顺序失败:', err);
const message = err.response?.data?.message || '更新快捷指令顺序失败';
uiNotificationsStore.showError(message);
return false;
}
};
const reorderCommandsInTag = async (tagId: number, commandIds: number[]): Promise<boolean> => {
if (!Number.isInteger(tagId) || !Array.isArray(commandIds) || commandIds.length === 0) {
return false;
}
try {
setSortBy('manual');
await apiClient.put('/quick-commands/reorder-by-tag', { tagId, commandIds });
clearQuickCommandsCache();
await fetchQuickCommands();
return true;
} catch (err: any) {
console.error('[QuickCommandsStore] 更新标签内快捷指令顺序失败:', err);
const message = err.response?.data?.message || '更新标签内快捷指令顺序失败';
uiNotificationsStore.showError(message);
return false;
}
};
const assignCommandsToTagAction = async (commandIds: number[], tagId: number): Promise<boolean> => {
if (!Array.isArray(commandIds) || commandIds.length === 0) {
return false;
}
isLoading.value = true;
error.value = null;
try {
const response = await apiClient.post('/quick-commands/bulk-assign-tag', { commandIds, tagId });
if (!response.data?.success) {
throw new Error(response.data?.message || '批量分配标签失败');
}
clearQuickCommandsCache();
await fetchQuickCommands();
return true;
} catch (err: any) {
console.error('[QuickCommandsStore] 批量分配标签失败:', err);
error.value = err.response?.data?.message || err.message || '批量分配标签失败';
if (error.value) {
uiNotificationsStore.showError(error.value);
}
return false;
} finally {
isLoading.value = false;
}
};
return { return {
quickCommandsList, quickCommandsList,
@@ -415,10 +472,10 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
sortBy, sortBy,
isLoading, isLoading,
error, error,
filteredAndGroupedCommands, // Expose the grouped data filteredAndGroupedCommands,
flatVisibleCommands, // Expose the flat visible list for navigation logic if needed outside flatVisibleCommands,
selectedIndex, // Index within flatVisibleCommands selectedIndex,
expandedGroups, // Expose expanded groups state expandedGroups,
fetchQuickCommands, fetchQuickCommands,
addQuickCommand, addQuickCommand,
updateQuickCommand, updateQuickCommand,
@@ -429,60 +486,11 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => {
selectNextCommand, selectNextCommand,
selectPreviousCommand, selectPreviousCommand,
resetSelection, resetSelection,
toggleGroup, // +++ Expose toggleGroup action +++ toggleGroup,
loadExpandedGroups, // +++ Expose load action +++ loadExpandedGroups,
clearQuickCommandsCache,
// +++ Action to assign a tag to multiple commands +++ reorderQuickCommands,
async assignCommandsToTagAction(commandIds: number[], tagId: number): Promise<boolean> { reorderCommandsInTag,
if (!commandIds || commandIds.length === 0) { assignCommandsToTagAction,
console.warn('[Store] assignCommandsToTagAction: No command IDs provided.');
return false;
}
isLoading.value = true; // Use the store's isLoading state
error.value = null; // Use the store's error state
try {
const response = await apiClient.post('/quick-commands/bulk-assign-tag', { commandIds, tagId });
if (response.data.success) {
console.log(`[Store] Successfully assigned tag ${tagId} to ${commandIds.length} commands via API.`);
// --- Manual state update for immediate UI feedback ---
let updatedCount = 0;
commandIds.forEach(cmdId => {
const commandIndex = quickCommandsList.value.findIndex(cmd => cmd.id === cmdId);
if (commandIndex !== -1) {
const command = quickCommandsList.value[commandIndex];
// Ensure tagIds exists and add the new tagId if not already present
if (!Array.isArray(command.tagIds)) {
command.tagIds = [];
}
if (!command.tagIds.includes(tagId)) {
command.tagIds.push(tagId);
updatedCount++;
}
} else {
console.warn(`[Store] assignCommandsToTagAction: Command ID ${cmdId} not found in local list for manual update.`);
}
});
console.log(`[Store] Manually updated tagIds for ${updatedCount} commands in local state.`);
// Optionally, still fetch for full consistency, but UI should update based on manual change first.
// clearQuickCommandsCache();
// await fetchQuickCommands();
return true;
} else {
// This case might not happen if backend throws errors instead
error.value = response.data.message || '批量分配标签失败 (未知)';
if (error.value) uiNotificationsStore.showError(error.value); // Check if error.value is not null
return false;
}
} catch (err: any) {
console.error('[Store] Error assigning tag to commands:', err);
error.value = err.response?.data?.message || err.message || '批量分配标签时发生网络或服务器错误';
if (error.value) uiNotificationsStore.showError(error.value); // Check if error.value is not null
return false;
} finally {
isLoading.value = false;
}
},
}; };
}); });
+11 -10
View File
@@ -1597,6 +1597,17 @@ onBeforeUnmount(() => {
<span>{{ t('connections.actions.connect', '连接') }}</span> <span>{{ t('connections.actions.connect', '连接') }}</span>
</button> </button>
<button
v-if="conn.type === 'SSH'"
:disabled="getSingleTestButtonInfo(conn.id, conn.type).disabled"
:title="getSingleTestButtonInfo(conn.id, conn.type).title"
class="px-4 py-2 rounded-lg border border-border bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
@click.stop="handleTestSingleConnection(conn)"
>
<i :class="getSingleTestButtonInfo(conn.id, conn.type).iconClass"></i>
<span>{{ getSingleTestButtonInfo(conn.id, conn.type).text }}</span>
</button>
<div class="relative"> <div class="relative">
<button <button
@click.stop="toggleMoreMenu(conn.id)" @click.stop="toggleMoreMenu(conn.id)"
@@ -1618,16 +1629,6 @@ onBeforeUnmount(() => {
<i class="fas fa-pen w-4 text-center"></i> <i class="fas fa-pen w-4 text-center"></i>
<span>{{ t('connections.actions.edit', '编辑') }}</span> <span>{{ t('connections.actions.edit', '编辑') }}</span>
</button> </button>
<button
v-if="conn.type === 'SSH'"
:disabled="getSingleTestButtonInfo(conn.id, conn.type).disabled"
:title="getSingleTestButtonInfo(conn.id, conn.type).title"
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-header transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
@click.stop="handleTestSingleConnection(conn); closeMoreMenu()"
>
<i :class="[getSingleTestButtonInfo(conn.id, conn.type).iconClass, 'w-4 text-center']"></i>
<span>{{ getSingleTestButtonInfo(conn.id, conn.type).text }}</span>
</button>
<button <button
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-header transition-colors flex items-center gap-2" class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-header transition-colors flex items-center gap-2"
@click.stop="handleCloneConnection(conn); closeMoreMenu()" @click.stop="handleCloneConnection(conn); closeMoreMenu()"
@@ -68,8 +68,17 @@
<div <div
class="group font-semibold flex items-center text-foreground rounded-md hover:bg-header/80 transition-colors duration-150" class="group font-semibold flex items-center text-foreground rounded-md hover:bg-header/80 transition-colors duration-150"
:style="{ padding: isCompactMode ? `calc(0.25rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` : `calc(0.5rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` }" :style="{ padding: isCompactMode ? `calc(0.25rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` : `calc(0.5rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` }"
:class="{ 'cursor-pointer': editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) }" :draggable="groupData.tagId !== null && !dragDisabledBySearch"
:class="{
'cursor-pointer': editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId),
'cursor-grab': groupData.tagId !== null && !dragDisabledBySearch,
'qc-drop-target': isGroupDropTarget(groupData.tagId),
}"
@click="editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) ? toggleGroup(groupData.groupName) : null" @click="editingTagId !== (groupData.tagId === null ? 'untagged' : groupData.tagId) ? toggleGroup(groupData.groupName) : null"
@dragstart="handleGroupDragStart($event, groupData.tagId)"
@dragover.prevent="handleGroupDragOver(groupData.tagId)"
@drop.prevent="handleGroupDrop(groupData.tagId)"
@dragend="resetDragState"
> >
<i <i
:class="['fas', expandedGroups[groupData.groupName] ? 'fa-chevron-down' : 'fa-chevron-right', 'mr-2 w-4 text-center text-text-secondary group-hover:text-foreground transition-transform duration-200 ease-in-out', {'transform rotate-0': !expandedGroups[groupData.groupName]}]" :class="['fas', expandedGroups[groupData.groupName] ? 'fa-chevron-down' : 'fa-chevron-right', 'mr-2 w-4 text-center text-text-secondary group-hover:text-foreground transition-transform duration-200 ease-in-out', {'transform rotate-0': !expandedGroups[groupData.groupName]}]"
@@ -114,10 +123,20 @@
:title="cmd.command" :title="cmd.command"
class="group flex justify-between items-center mb-1 cursor-pointer rounded-md hover:bg-primary/10 transition-colors duration-150" class="group flex justify-between items-center mb-1 cursor-pointer rounded-md hover:bg-primary/10 transition-colors duration-150"
:style="{ padding: isCompactMode ? `calc(0.1rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` : `calc(0.625rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` }" :style="{ padding: isCompactMode ? `calc(0.1rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` : `calc(0.625rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` }"
:class="{ 'bg-primary/20 font-medium': isCommandSelected(cmd.id) }" :draggable="!dragDisabledBySearch"
:class="{
'bg-primary/20 font-medium': isCommandSelected(cmd.id),
'cursor-grab': !dragDisabledBySearch,
'qc-drop-target': isCommandDropTarget(cmd.id, groupData.tagId),
'opacity-70': isDraggingCommand(cmd.id, groupData.tagId),
}"
@click="selectCommand(cmd.id)" @click="selectCommand(cmd.id)"
@dblclick="executeCommand(cmd)" @dblclick="executeCommand(cmd)"
@contextmenu.prevent="showQuickCommandContextMenu($event, cmd)" @contextmenu.prevent="showQuickCommandContextMenu($event, cmd)"
@dragstart="handleCommandDragStart($event, cmd.id, groupData.tagId)"
@dragover.prevent="handleCommandDragOver(cmd.id, groupData.tagId)"
@drop.prevent="handleCommandDrop(cmd.id, groupData.tagId)"
@dragend="resetDragState"
> >
<!-- Command Info --> <!-- Command Info -->
<div class="flex flex-col overflow-hidden mr-2 flex-grow"> <div class="flex flex-col overflow-hidden mr-2 flex-grow">
@@ -162,10 +181,20 @@
:title="cmd.command" :title="cmd.command"
class="group flex justify-between items-center mb-1 cursor-pointer rounded-md hover:bg-primary/10 transition-colors duration-150" class="group flex justify-between items-center mb-1 cursor-pointer rounded-md hover:bg-primary/10 transition-colors duration-150"
:style="{ padding: isCompactMode ? `calc(0.1rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` : `calc(0.625rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` }" :style="{ padding: isCompactMode ? `calc(0.1rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` : `calc(0.625rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` }"
:class="{ 'bg-primary/20 font-medium': isCommandSelected(cmd.id) }" :draggable="!dragDisabledBySearch"
:class="{
'bg-primary/20 font-medium': isCommandSelected(cmd.id),
'cursor-grab': !dragDisabledBySearch,
'qc-drop-target': isCommandDropTarget(cmd.id, null),
'opacity-70': isDraggingCommand(cmd.id, null),
}"
@click="selectCommand(cmd.id)" @click="selectCommand(cmd.id)"
@dblclick="executeCommand(cmd)" @dblclick="executeCommand(cmd)"
@contextmenu.prevent="showQuickCommandContextMenu($event, cmd)" @contextmenu.prevent="showQuickCommandContextMenu($event, cmd)"
@dragstart="handleCommandDragStart($event, cmd.id, null)"
@dragover.prevent="handleCommandDragOver(cmd.id, null)"
@drop.prevent="handleCommandDrop(cmd.id, null)"
@dragend="resetDragState"
> >
<!-- Command Info --> <!-- Command Info -->
<div class="flex flex-col overflow-hidden mr-2 flex-grow"> <div class="flex flex-col overflow-hidden mr-2 flex-grow">
@@ -379,6 +408,17 @@ const flatFilteredCommands = computed(() => {
return quickCommandsStore.flatVisibleCommands; return quickCommandsStore.flatVisibleCommands;
}); });
const dragDisabledBySearch = computed(() => searchTerm.value.trim().length > 0);
const draggingGroupTagId = ref<number | null>(null);
const groupDropTargetTagId = ref<number | null>(null);
const draggingCommand = ref<{ commandId: number; groupTagId: number | null } | null>(null);
const commandDropTarget = ref<{ commandId: number; groupTagId: number | null } | null>(null);
const dragDisabledTitle = computed(() =>
dragDisabledBySearch.value
? t('quickCommands.dragDisabledBySearch', '搜索结果中不可拖动排序')
: t('quickCommands.dragCommand', '拖动排序快捷指令')
);
// --- Compact Mode --- // --- Compact Mode ---
const isCompactMode = computed(() => quickCommandsCompactModeBoolean.value); const isCompactMode = computed(() => quickCommandsCompactModeBoolean.value);
@@ -403,6 +443,163 @@ const selectCommand = (commandId: number) => {
// --- --- // --- ---
const resetDragState = () => {
draggingGroupTagId.value = null;
groupDropTargetTagId.value = null;
draggingCommand.value = null;
commandDropTarget.value = null;
};
const moveById = <T extends { id: number }>(items: T[], sourceId: number, targetId: number): T[] => {
const clonedItems = [...items];
const sourceIndex = clonedItems.findIndex((item) => item.id === sourceId);
const targetIndex = clonedItems.findIndex((item) => item.id === targetId);
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
return clonedItems;
}
const [sourceItem] = clonedItems.splice(sourceIndex, 1);
clonedItems.splice(targetIndex, 0, sourceItem);
return clonedItems;
};
const isGroupDropTarget = (tagId: number | null): boolean =>
tagId !== null && groupDropTargetTagId.value === tagId;
const isDraggingCommand = (commandId: number, groupTagId: number | null): boolean =>
draggingCommand.value?.commandId === commandId && draggingCommand.value?.groupTagId === groupTagId;
const isCommandDropTarget = (commandId: number, groupTagId: number | null): boolean =>
commandDropTarget.value?.commandId === commandId && commandDropTarget.value?.groupTagId === groupTagId;
const handleGroupDragStart = (event: DragEvent, tagId: number | null) => {
if (dragDisabledBySearch.value || tagId === null) {
event.preventDefault();
return;
}
draggingGroupTagId.value = tagId;
groupDropTargetTagId.value = null;
event.dataTransfer?.setData('text/plain', String(tagId));
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
};
const handleGroupDragOver = (tagId: number | null) => {
if (draggingGroupTagId.value === null || dragDisabledBySearch.value || tagId === null || tagId === draggingGroupTagId.value) {
return;
}
groupDropTargetTagId.value = tagId;
};
const handleGroupDrop = async (tagId: number | null) => {
if (draggingGroupTagId.value === null || dragDisabledBySearch.value || tagId === null || tagId === draggingGroupTagId.value) {
resetDragState();
return;
}
const taggedGroups = filteredAndGroupedCommands.value
.filter((group) => group.tagId !== null)
.map((group) => ({ ...group, id: group.tagId as number }));
const reorderedGroups = moveById(taggedGroups, draggingGroupTagId.value, tagId);
const reorderedVisibleTagIds = reorderedGroups.map((group) => group.id);
const globalTagIds = [...quickCommandTagsStore.tags]
.sort((a, b) => (a.sort_order - b.sort_order) || (a.id - b.id))
.map((tag) => tag.id);
const visibleTagIdSet = new Set(reorderedVisibleTagIds);
let visibleIndex = 0;
const mergedTagIds = globalTagIds.map((existingTagId) => {
if (!visibleTagIdSet.has(existingTagId)) {
return existingTagId;
}
const nextVisibleTagId = reorderedVisibleTagIds[visibleIndex];
visibleIndex += 1;
return nextVisibleTagId ?? existingTagId;
});
await quickCommandTagsStore.reorderTags(mergedTagIds);
resetDragState();
};
const handleCommandDragStart = (event: DragEvent, commandId: number, groupTagId: number | null) => {
if (dragDisabledBySearch.value) {
event.preventDefault();
return;
}
draggingCommand.value = { commandId, groupTagId };
commandDropTarget.value = null;
event.dataTransfer?.setData('text/plain', String(commandId));
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
};
const handleCommandDragOver = (commandId: number, groupTagId: number | null) => {
if (!draggingCommand.value || dragDisabledBySearch.value) {
return;
}
if (draggingCommand.value.groupTagId !== groupTagId || draggingCommand.value.commandId === commandId) {
return;
}
commandDropTarget.value = { commandId, groupTagId };
};
const handleCommandDrop = async (commandId: number, groupTagId: number | null) => {
if (!draggingCommand.value || dragDisabledBySearch.value) {
resetDragState();
return;
}
if (draggingCommand.value.groupTagId !== groupTagId || draggingCommand.value.commandId === commandId) {
resetDragState();
return;
}
let currentCommands: QuickCommandFE[] = [];
if (showQuickCommandTagsBoolean.value) {
currentCommands = filteredAndGroupedCommands.value.find((group) => group.tagId === groupTagId)?.commands ?? [];
} else {
currentCommands = flatFilteredCommands.value;
}
const reorderedCommands = moveById(currentCommands, draggingCommand.value.commandId, commandId);
if (showQuickCommandTagsBoolean.value) {
if (groupTagId !== null) {
await quickCommandsStore.reorderCommandsInTag(groupTagId, reorderedCommands.map((item) => item.id));
} else {
const reorderedUntaggedIds = reorderedCommands.map((item) => item.id);
const globalCommandIds = [...quickCommandsStore.quickCommandsList]
.sort((a, b) => (a.sort_order - b.sort_order) || (a.id - b.id))
.map((command) => command.id);
let untaggedIndex = 0;
const mergedCommandIds = globalCommandIds.map((existingCommandId) => {
const command = quickCommandsStore.quickCommandsList.find((item) => item.id === existingCommandId);
if (!command || command.tagIds.length > 0) {
return existingCommandId;
}
const nextUntaggedId = reorderedUntaggedIds[untaggedIndex];
untaggedIndex += 1;
return nextUntaggedId ?? existingCommandId;
});
await quickCommandsStore.reorderQuickCommands(mergedCommandIds);
}
} else {
await quickCommandsStore.reorderQuickCommands(reorderedCommands.map((item) => item.id));
}
resetDragState();
};
onMounted(async () => { // Make onMounted async onMounted(async () => { // Make onMounted async
// Load expanded groups state first // Load expanded groups state first
quickCommandsStore.loadExpandedGroups(); quickCommandsStore.loadExpandedGroups();
@@ -518,8 +715,10 @@ const handleSearchInputBlur = () => {
// (Action remains the same, store handles the logic change) // (Action remains the same, store handles the logic change)
const toggleSortBy = () => { const toggleSortBy = () => {
const newSortBy = sortBy.value === 'name' ? 'last_used' : 'name'; const sortModes: QuickCommandSortByType[] = ['manual', 'name', 'last_used'];
quickCommandsStore.setSortBy(newSortBy); const currentIndex = sortModes.indexOf(sortBy.value);
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % sortModes.length;
quickCommandsStore.setSortBy(sortModes[nextIndex]);
}; };
// +++ Action to toggle group expansion +++ // +++ Action to toggle group expansion +++
@@ -896,3 +1095,10 @@ const handleQuickCommandMenuAction = async (action: QuickCommandContextAction, c
}; };
</script> </script>
<style scoped>
.qc-drop-target {
outline: 1px dashed color-mix(in srgb, var(--color-primary, #3b82f6) 72%, transparent);
outline-offset: -2px;
}
</style>