diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index a00acd0..d526a85 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -2,6 +2,18 @@ ## [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 - 方案: [202604160350_workflow-service-scoped-docker-builds](archive/2026-04/202604160350_workflow-service-scoped-docker-builds/) diff --git a/.helloagents/archive/2026-04/202604190201_connection-password-visibility-toggle/.status.json b/.helloagents/archive/2026-04/202604190201_connection-password-visibility-toggle/.status.json new file mode 100644 index 0000000..12dc973 --- /dev/null +++ b/.helloagents/archive/2026-04/202604190201_connection-password-visibility-toggle/.status.json @@ -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"} diff --git a/.helloagents/archive/2026-04/202604190201_connection-password-visibility-toggle/proposal.md b/.helloagents/archive/2026-04/202604190201_connection-password-visibility-toggle/proposal.md new file mode 100644 index 0000000..a044aa9 --- /dev/null +++ b/.helloagents/archive/2026-04/202604190201_connection-password-visibility-toggle/proposal.md @@ -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,确保仅靠图标也能被辅助工具识别。 +- **响应式**: 在现有表单宽度下保持按钮内嵌,不引入移动端换行或遮挡输入内容。 diff --git a/.helloagents/archive/2026-04/202604190201_connection-password-visibility-toggle/tasks.md b/.helloagents/archive/2026-04/202604190201_connection-password-visibility-toggle/tasks.md new file mode 100644 index 0000000..04254de --- /dev/null +++ b/.helloagents/archive/2026-04/202604190201_connection-password-visibility-toggle/tasks.md @@ -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` 后端未运行,浏览器端出现代理拒绝连接,无法自动进入登录后的连接管理页,因此“点击小眼睛切换明文”仍建议在你的已登录环境手动确认一次。 diff --git a/.helloagents/archive/2026-04/202604190208_quickcommands-drag-reorder/.status.json b/.helloagents/archive/2026-04/202604190208_quickcommands-drag-reorder/.status.json new file mode 100644 index 0000000..adbb669 --- /dev/null +++ b/.helloagents/archive/2026-04/202604190208_quickcommands-drag-reorder/.status.json @@ -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"} diff --git a/.helloagents/archive/2026-04/202604190208_quickcommands-drag-reorder/proposal.md b/.helloagents/archive/2026-04/202604190208_quickcommands-drag-reorder/proposal.md new file mode 100644 index 0000000..29f20c0 --- /dev/null +++ b/.helloagents/archive/2026-04/202604190208_quickcommands-drag-reorder/proposal.md @@ -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` | 返回给前端的“标签 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` 执行与右键菜单入口。 +- **响应式**: 继续兼容现有紧凑模式与工作台不同宽度场景,句柄区域不能挤占命令文本的主要可读空间。 diff --git a/.helloagents/archive/2026-04/202604190208_quickcommands-drag-reorder/tasks.md b/.helloagents/archive/2026-04/202604190208_quickcommands-drag-reorder/tasks.md new file mode 100644 index 0000000..6ca504d --- /dev/null +++ b/.helloagents/archive/2026-04/202604190208_quickcommands-drag-reorder/tasks.md @@ -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,记录快捷指令拖拽排序能力 | + +--- + +## 执行备注 + +> 本次实现对多标签命令采用“关联表局部顺序 + 命令表全局顺序”的双层建模:标签组内拖拽只影响该标签关联顺序,未标记分组和扁平列表拖拽则回写全局命令顺序,从而避免多标签命令在不同分组中的排序语义互相覆盖。 diff --git a/.helloagents/archive/2026-04/202604190210_connection-card-default-test-button/.status.json b/.helloagents/archive/2026-04/202604190210_connection-card-default-test-button/.status.json new file mode 100644 index 0000000..5fd9bc2 --- /dev/null +++ b/.helloagents/archive/2026-04/202604190210_connection-card-default-test-button/.status.json @@ -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"} diff --git a/.helloagents/archive/2026-04/202604190210_connection-card-default-test-button/proposal.md b/.helloagents/archive/2026-04/202604190210_connection-card-default-test-button/proposal.md new file mode 100644 index 0000000..01bf151 --- /dev/null +++ b/.helloagents/archive/2026-04/202604190210_connection-card-default-test-button/proposal.md @@ -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` 的响应式布局策略 diff --git a/.helloagents/archive/2026-04/202604190210_connection-card-default-test-button/tasks.md b/.helloagents/archive/2026-04/202604190210_connection-card-default-test-button/tasks.md new file mode 100644 index 0000000..9111e02 --- /dev/null +++ b/.helloagents/archive/2026-04/202604190210_connection-card-default-test-button/tasks.md @@ -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`,因此本次对连接卡片交互的最终运行态确认仍需在有登录态的环境中补一次人工目视检查。 diff --git a/.helloagents/archive/_index.md b/.helloagents/archive/_index.md index 57b5aaf..c2c5b74 100644 --- a/.helloagents/archive/_index.md +++ b/.helloagents/archive/_index.md @@ -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 | - | - | - | ✅完成 | | 202604152323 | status-monitor-reference-layout-parity | implementation | frontend | status-monitor-reference-layout-parity#D001 | ✅完成 | | 202604152147 | status-monitor-process-manager-modal | - | - | - | ✅完成 | @@ -56,6 +59,8 @@ ## 按月归档 ### 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/) - 将右侧状态监控默认视图重排为更贴近参考图的窄屏监控布局,修正顶部信息区与模块内部左右关系 - [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 时显示完整命令 diff --git a/.helloagents/modules/backend.md b/.helloagents/modules/backend.md index d3c291e..5f33ca1 100644 --- a/.helloagents/modules/backend.md +++ b/.helloagents/modules/backend.md @@ -80,3 +80,8 @@ **条件**: `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` 指令,返回完整进程列表及结束/强制结束结果。 **结果**: 前端默认状态监控可以展示更完整的小屏监控信息,而“查看全部”进程管理 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-tag,quick-command-tags 业务域新增 /api/v1/quick-command-tags/reorder。同时标签关联写入从“先删后插”调整为增量同步,保留命令已存在标签关联的原组内顺序,仅为新增关联追加新的末尾顺序。 +**结果**: 后端可以稳定表达“标签顺序”“命令全局顺序”和“命令在某个标签组内的局部顺序”三层语义,并保证历史数据库升级后也能直接承接前端拖拽排序能力。 \ No newline at end of file diff --git a/.helloagents/modules/frontend.md b/.helloagents/modules/frontend.md index 9c995b3..2cea0d1 100644 --- a/.helloagents/modules/frontend.md +++ b/.helloagents/modules/frontend.md @@ -36,7 +36,7 @@ ### 工作区交互 **条件**: 用户进入 `/workspace` 或相关管理页面。 -**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 继续整合快捷指令、命令历史、文件管理和编辑器四个面板,导航入口保持为纯图标按钮,但已调整为位于 `Workbench` 标题区上方的横向 icon rail,四个入口自左向右排列、默认仅显示图标并通过 tooltip 暴露名称,默认激活快捷指令。`CommandInputBar.vue` 当前已将底部命令框升级为支持会话级草稿保留的多行 `textarea`:普通 `Enter` 插入换行,`Ctrl+Shift+Enter` 发送当前命令,输入框会按内容自动增高至约 6 行,超出后在输入框内部滚动,并继续兼容快捷指令/命令历史同步与选中发送逻辑。应用根组件 `App.vue` 现在还新增了全局服务器快捷检索:已登录页面按下 `Ctrl+Shift+F` 会打开 `GlobalConnectionQuickSearch.vue`,通过 `utils/connectionSearch.ts` 对连接名称、主机、用户名、类型和标签做本地模糊排序,并直接复用 `sessionStore.handleConnectRequest()` 触发 SSH 工作区跳转或 RDP / VNC 弹窗连接;该检索弹层现在还会复用 `tags.store.ts` 读取标签名称映射,在结果卡片内补充显示每台服务器的标签 chips,便于快速区分同名或近似主机。快捷指令相关能力目前由 `AddEditQuickCommandForm.vue`、`QuickCommandsView.vue` 与新增的 `utils/quickCommandTemplate.ts` 协同实现:编辑弹窗左侧既可维护自定义 `${变量名}`,也提供 `${{date}}`、`${{time}}`、`${{timestamp}}`、`${{week}}`、`${{uuid}}`、`${{random:8}}`、`${{clipboard}}`、`${{password}}` 等动态变量的一键插入;实际执行时会统一走共享解析器,覆盖编辑弹窗执行、列表直接执行、粘贴到命令输入框和发送到全部服务器等链路,并对未定义变量、无法读取的剪贴板或不可用密码给出非阻断告警。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button`、`text-button-text`、`hover:bg-button-hover`、`hover:bg-border` 等主题变量类,避免写死黑白 hover 色值;该视图当前还支持命令项右键菜单,并已修正为实底卡片式上下文菜单,提供立即执行、粘贴到命令输入框(不自动发送)、复制命令、发送到全部服务器、编辑和删除等动作。快捷命令列表的鼠标主交互当前已从“单击立即执行”收紧为“单击仅更新选中态、双击才执行”,从而继续兼容键盘 `Enter` 的选中执行路径并降低误触风险;每条命令项同时会把完整 `command` 文本挂到浏览器原生 tooltip 上,便于在名称或命令被截断时直接 hover 核对完整内容。`Terminal.vue` 现在会跟踪 xterm 相对底部的视口偏移与贴底状态,在终端标签切换、重新激活和 `fit()` 后按原滚动意图恢复;当隐藏标签在后台持续追加日志时,重新激活会基于“距底部偏移”而不是过期的绝对行号恢复 viewport,避免用户继续向下滚动时无法回到底部。组件同时继续在渲染层为带 `xterm-fg-*` class 或内联 `style.color` 的显式前景色字符打标记,让终端文字描边/阴影仅作用于默认前景文本,不覆盖 ANSI 彩色输出;`session.store` 当前会为同一 SSH 连接下的新终端分配递增的 `terminalIndex`。当前顶部 `TerminalTabBar.vue` 已改为服务器级入口:SSH 项只负责在不同服务器之间切换,全局 `+` 继续负责选择其他服务器;同一服务器下的多个终端则下沉到 `LayoutRenderer.vue` 的终端面板内部,以次级标签条承载切换、关闭和新增,从而让“进入服务器后再管理该服务器的多个终端”成为主要交互模型。服务器组头现在除主点击切换外,还额外提供了一个 hover 后出现的 `X` 按钮,点击后会复用既有 `session:close` 事件逐个关闭该 `connectionId` 下的全部终端。当前终端标签右键菜单继续复用 `WorkspaceView.vue` 中转的会话关闭链路,除关闭当前、关闭其他、关闭左右侧外,也支持直接触发“关闭全部”来清空当前工作区中的全部终端标签。连接新增弹窗中的脚本模式则继续由 `useAddConnectionForm.ts` 统一清洗输入:会先剔除空行、Markdown 代码围栏行,再按单引号/双引号感知切分参数,并去掉成对包裹值的外层引号,避免像 `-p '$Moka1998A'` 这样的输入把 `'` 一并保存。`ConnectionsView.vue` 已升级为“左侧范围树 + 顶部搜索工具条 + 右侧结果列表”的双栏管理台,当前左侧进一步支持基于标签名路径分隔符推导的多级标签树、树节点展开状态持久化、分组 scope 恢复,以及树工具栏中的展开全部、收起全部和重置范围控制;近期又补上了独立的左侧树搜索、命中节点及祖先路径过滤、命中链路自动展开、节点计数高亮,以及更接近资源管理器的树头部布局;本轮继续为树节点加入 hover 工具按钮、资源管理器式分隔标题行与拖拽重排占位反馈;右侧结果列表则同时支持顶部排序控件、列头点击排序,并将行内操作整理为“连接”主按钮加“更多”菜单(编辑/测试/克隆/删除);连接页顶部工具条当前又补上了独立“标签管理”入口,打开 `ManageConnectionTagsModal.vue` 后可按标签名搜索、多选、批量删除标签,并通过显式危险开关决定删除标签时是否连带删除命中的连接;`tags.store.ts` 在该链路里会统一刷新标签与连接缓存,而 `ConnectionsView.vue` 会在当前 scope 指向已删标签或分组时自动回退到 `all`。`FileManager.vue` 当前已进一步收敛为固定 `/` 根节点的单栏资源管理器树,组件加载时会优先拉取 `/` 目录,树中按“目录在前、文件在后”同时显示目录和文件节点,点击目录只展开与聚焦,点击文件则沿用现有工作区文件打开链路;文件右键菜单链路则已补齐图标化菜单结构、危险态删除项、终端子菜单(执行 `cd` 命令到终端 / 新建终端到当前目录)、复制文件名与复制绝对路径等动作,并继续复用现有下载、权限、新建、上传和删除逻辑,同时又新增了独立“上传文件夹”入口:前端会先将本地目录打包为 zip,再复用现有 `sftp:upload` 链路上传,并在上传成功后自动调用远端解压、尝试清理临时压缩包;外部拖拽文件或目录上传时,则会按鼠标当前悬停的目录作为目标路径,其中目录同样走“先压缩再上传”的路径,从而显著降低小文件很多时的扫描与上传耗时;本轮又补上了拖拽上传前的目标路径确认、桌面端右键子菜单点击展开,以及目录删除时“仅删空目录 / 强制递归删除”的显式二选一;当前右键菜单的关闭职责已经收敛到 `FileManagerContextMenu.vue` 组件层处理,`useFileManagerContextMenu.ts` 不再额外注册捕获阶段的全局点击关闭监听,以避免“终端 / 上传 / 压缩”等带子菜单项在展开或点击前被提前关闭;同时 `useSftpActions.ts` 会在删除目录后自动回退当前或待加载的失效路径,避免文件树持续对已删除目录刷出 `No such file`。样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。 +**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 继续整合快捷指令、命令历史、文件管理和编辑器四个面板,导航入口保持为纯图标按钮,但已调整为位于 `Workbench` 标题区上方的横向 icon rail,四个入口自左向右排列、默认仅显示图标并通过 tooltip 暴露名称,默认激活快捷指令。`CommandInputBar.vue` 当前已将底部命令框升级为支持会话级草稿保留的多行 `textarea`:普通 `Enter` 插入换行,`Ctrl+Shift+Enter` 发送当前命令,输入框会按内容自动增高至约 6 行,超出后在输入框内部滚动,并继续兼容快捷指令/命令历史同步与选中发送逻辑。应用根组件 `App.vue` 现在还新增了全局服务器快捷检索:已登录页面按下 `Ctrl+Shift+F` 会打开 `GlobalConnectionQuickSearch.vue`,通过 `utils/connectionSearch.ts` 对连接名称、主机、用户名、类型和标签做本地模糊排序,并直接复用 `sessionStore.handleConnectRequest()` 触发 SSH 工作区跳转或 RDP / VNC 弹窗连接;该检索弹层现在还会复用 `tags.store.ts` 读取标签名称映射,在结果卡片内补充显示每台服务器的标签 chips,便于快速区分同名或近似主机。快捷指令相关能力目前由 `AddEditQuickCommandForm.vue`、`QuickCommandsView.vue` 与新增的 `utils/quickCommandTemplate.ts` 协同实现:编辑弹窗左侧既可维护自定义 `${变量名}`,也提供 `${{date}}`、`${{time}}`、`${{timestamp}}`、`${{week}}`、`${{uuid}}`、`${{random:8}}`、`${{clipboard}}`、`${{password}}` 等动态变量的一键插入;实际执行时会统一走共享解析器,覆盖编辑弹窗执行、列表直接执行、粘贴到命令输入框和发送到全部服务器等链路,并对未定义变量、无法读取的剪贴板或不可用密码给出非阻断告警。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button`、`text-button-text`、`hover:bg-button-hover`、`hover:bg-border` 等主题变量类,避免写死黑白 hover 色值;该视图当前还支持命令项右键菜单,并已修正为实底卡片式上下文菜单,提供立即执行、粘贴到命令输入框(不自动发送)、复制命令、发送到全部服务器、编辑和删除等动作。快捷命令列表的鼠标主交互当前已从“单击立即执行”收紧为“单击仅更新选中态、双击才执行”,从而继续兼容键盘 `Enter` 的选中执行路径并降低误触风险;每条命令项同时会把完整 `command` 文本挂到浏览器原生 tooltip 上,便于在名称或命令被截断时直接 hover 核对完整内容。`Terminal.vue` 现在会跟踪 xterm 相对底部的视口偏移与贴底状态,在终端标签切换、重新激活和 `fit()` 后按原滚动意图恢复;当隐藏标签在后台持续追加日志时,重新激活会基于“距底部偏移”而不是过期的绝对行号恢复 viewport,避免用户继续向下滚动时无法回到底部。组件同时继续在渲染层为带 `xterm-fg-*` class 或内联 `style.color` 的显式前景色字符打标记,让终端文字描边/阴影仅作用于默认前景文本,不覆盖 ANSI 彩色输出;`session.store` 当前会为同一 SSH 连接下的新终端分配递增的 `terminalIndex`。当前顶部 `TerminalTabBar.vue` 已改为服务器级入口:SSH 项只负责在不同服务器之间切换,全局 `+` 继续负责选择其他服务器;同一服务器下的多个终端则下沉到 `LayoutRenderer.vue` 的终端面板内部,以次级标签条承载切换、关闭和新增,从而让“进入服务器后再管理该服务器的多个终端”成为主要交互模型。服务器组头现在除主点击切换外,还额外提供了一个 hover 后出现的 `X` 按钮,点击后会复用既有 `session:close` 事件逐个关闭该 `connectionId` 下的全部终端。当前终端标签右键菜单继续复用 `WorkspaceView.vue` 中转的会话关闭链路,除关闭当前、关闭其他、关闭左右侧外,也支持直接触发“关闭全部”来清空当前工作区中的全部终端标签。连接新增弹窗中的脚本模式则继续由 `useAddConnectionForm.ts` 统一清洗输入:会先剔除空行、Markdown 代码围栏行,再按单引号/双引号感知切分参数,并去掉成对包裹值的外层引号,避免像 `-p '$Moka1998A'` 这样的输入把 `'` 一并保存。`ConnectionsView.vue` 已升级为“左侧范围树 + 顶部搜索工具条 + 右侧结果列表”的双栏管理台,当前左侧进一步支持基于标签名路径分隔符推导的多级标签树、树节点展开状态持久化、分组 scope 恢复,以及树工具栏中的展开全部、收起全部和重置范围控制;近期又补上了独立的左侧树搜索、命中节点及祖先路径过滤、命中链路自动展开、节点计数高亮,以及更接近资源管理器的树头部布局;本轮继续为树节点加入 hover 工具按钮、资源管理器式分隔标题行与拖拽重排占位反馈;右侧结果列表则同时支持顶部排序控件、列头点击排序,并将行内操作整理为“连接”主按钮加“更多”菜单,其中 SSH 连接卡片默认进一步提升为“连接 / 测试 / 更多”三按钮结构,复用既有单连接测试状态,编辑/克隆/删除等次级操作保留在更多菜单中;连接页顶部工具条当前又补上了独立“标签管理”入口,打开 `ManageConnectionTagsModal.vue` 后可按标签名搜索、多选、批量删除标签,并通过显式危险开关决定删除标签时是否连带删除命中的连接;`tags.store.ts` 在该链路里会统一刷新标签与连接缓存,而 `ConnectionsView.vue` 会在当前 scope 指向已删标签或分组时自动回退到 `all`。`FileManager.vue` 当前已进一步收敛为固定 `/` 根节点的单栏资源管理器树,组件加载时会优先拉取 `/` 目录,树中按“目录在前、文件在后”同时显示目录和文件节点,点击目录只展开与聚焦,点击文件则沿用现有工作区文件打开链路;文件右键菜单链路则已补齐图标化菜单结构、危险态删除项、终端子菜单(执行 `cd` 命令到终端 / 新建终端到当前目录)、复制文件名与复制绝对路径等动作,并继续复用现有下载、权限、新建、上传和删除逻辑,同时又新增了独立“上传文件夹”入口:前端会先将本地目录打包为 zip,再复用现有 `sftp:upload` 链路上传,并在上传成功后自动调用远端解压、尝试清理临时压缩包;外部拖拽文件或目录上传时,则会按鼠标当前悬停的目录作为目标路径,其中目录同样走“先压缩再上传”的路径,从而显著降低小文件很多时的扫描与上传耗时;本轮又补上了拖拽上传前的目标路径确认、桌面端右键子菜单点击展开,以及目录删除时“仅删空目录 / 强制递归删除”的显式二选一;当前右键菜单的关闭职责已经收敛到 `FileManagerContextMenu.vue` 组件层处理,`useFileManagerContextMenu.ts` 不再额外注册捕获阶段的全局点击关闭监听,以避免“终端 / 上传 / 压缩”等带子菜单项在展开或点击前被提前关闭;同时 `useSftpActions.ts` 会在删除目录后自动回退当前或待加载的失效路径,避免文件树持续对已删除目录刷出 `No such file`。样式编辑器中的终端文字描边/阴影默认开关也已与新的黑绿终端风格保持默认开启。 **结果**: 页面逻辑分散在 `views/`、`components/`、`stores/` 与 `composables/`,其中工作区终端行为和标签交互优先落在 `session.store.ts`、`session/actions/sessionActions.ts`、`session/getters.ts`、`TerminalTabBar.vue`、`WorkspaceView.vue`、`Terminal.vue` 与相关 locale 文件。 ### 仪表盘总览 @@ -46,8 +46,8 @@ ### 登录凭证工作流 **条件**: 用户在连接管理页、新增连接弹窗或批量编辑中需要复用登录配置。 -**行为**: 前端新增 `loginCredentials.store.ts`、`LoginCredentialSelector.vue` 和 `LoginCredentialManagementModal.vue`,在 `ConnectionsView.vue` 顶部增加“登录凭证”入口;`AddConnectionFormAuth.vue` 当前把认证区拆成“直填账号密码 / 密钥”和“使用已保存凭证”两种来源,`useAddConnectionForm.ts` 在保存和测试连接时会根据 `credential_source` 自动决定提交 `login_credential_id` 还是直填字段;`BatchEditConnectionForm.vue` 也补充了批量应用已保存凭证的能力,并限制为同一种连接类型批量使用。 -**结果**: 用户既可以继续沿用原来的直填方式,也可以把常用账号沉淀成独立凭证并在连接或批量编辑时快速套用,连接管理台的凭证入口和表单行为保持一致。 +**行为**: 前端新增 `loginCredentials.store.ts`、`LoginCredentialSelector.vue` 和 `LoginCredentialManagementModal.vue`,在 `ConnectionsView.vue` 顶部增加“登录凭证”入口;`AddConnectionFormAuth.vue` 当前把认证区拆成“直填账号密码 / 密钥”和“使用已保存凭证”两种来源,`useAddConnectionForm.ts` 在保存和测试连接时会根据 `credential_source` 自动决定提交 `login_credential_id` 还是直填字段;`AddConnectionFormAuth.vue` 与 `LoginCredentialManagementModal.vue` 现在还会为直填密码输入提供眼睛显隐切换,默认仍保持掩码,仅在本地输入框显示层切换明文,方便用户核对输入内容;`BatchEditConnectionForm.vue` 也补充了批量应用已保存凭证的能力,并限制为同一种连接类型批量使用。 +**结果**: 用户既可以继续沿用原来的直填方式,也可以把常用账号沉淀成独立凭证并在连接或批量编辑时快速套用,同时在录入密码时可即时核对输入是否正确,而不会改变默认安全策略或后端返回行为。 ## 依赖关系 @@ -61,3 +61,8 @@ **行为**: `StatusMonitor.vue` 当前已从通用卡片栅格重排为更接近参考图的窄屏监控结构:顶部改为成对的信息条,资源概览改为带编号的紧凑使用率行,内存/网络/磁盘模块都采用明显的左右分区关系,分别展示环形占比+统计堆叠、监控屏风格网络面板+上下行速率堆叠,以及设备视觉块+紧凑磁盘摘要;默认视图底部继续保留“进程管理”概览与高占用进程预览,并通过“查看全部”打开 `ProcessManagerModal.vue`。该 modal 继续采用深色控制台式表格布局,支持搜索 PID / 用户 / 命令、自动刷新、手动刷新,以及对单个进程执行“结束”或“强制结束”操作,并通过当前活动 SSH 会话的 `wsManager` 与后端 `process:list` / `process:signal` 消息交互。 **结果**: 前端状态监控形成了“更贴近参考图的默认小屏监控 + 独立进程管理页”的双层结构:默认面板先解决左右布局和视觉层级问题,而完整进程管理继续独立存在,不挤占侧栏本体。 +### 快捷指令拖拽排序 +**条件**: 用户在 Workbench 的快捷指令视图中浏览分组或扁平命令列表,且当前未启用搜索过滤。 +**行为**: `QuickCommandsView.vue` 现已支持拖动已标记分组、标签组内命令,以及关闭标签展示后的扁平命令列表;拖拽完成后会通过 `quickCommands.store.ts` 与 `quickCommandTags.store.ts` 分别调用 `/api/v1/quick-command-tags/reorder`、`/api/v1/quick-commands/reorder` 和 `/api/v1/quick-commands/reorder-by-tag` 回写顺序。列表排序模式同步扩展为 `manual / name / last_used`,其中拖拽结果会自动落回 `manual` 视图承接。 +**结果**: 快捷指令分组顺序、组内顺序和扁平列表顺序在刷新后保持一致,而搜索过滤态继续保持只读展示,避免局部结果重排污染全量顺序。 + diff --git a/packages/backend/src/database/migrations.ts b/packages/backend/src/database/migrations.ts index 09918f1..61304d8 100644 --- a/packages/backend/src/database/migrations.ts +++ b/packages/backend/src/database/migrations.ts @@ -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; ` + }, + { + id: 12, + name: 'Add sort_order column to quick_commands table', + check: async (db: Database): Promise => { + 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 => { + 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 => { + 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; + ` } ]; diff --git a/packages/backend/src/database/schema.ts b/packages/backend/src/database/schema.ts index f84aac9..8fb55b2 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -187,6 +187,7 @@ CREATE TABLE IF NOT EXISTS quick_commands ( command TEXT NOT NULL, -- 指令必选 usage_count INTEGER NOT NULL DEFAULT 0, -- 使用频率 variables TEXT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, created_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 ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, + sort_order INTEGER NOT NULL DEFAULT 0, created_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 ( quick_command_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (quick_command_id, tag_id), FOREIGN KEY (quick_command_id) REFERENCES quick_commands(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES quick_command_tags(id) ON DELETE CASCADE diff --git a/packages/backend/src/quick-command-tags/quick-command-tag.controller.ts b/packages/backend/src/quick-command-tags/quick-command-tag.controller.ts index 268ab84..db283f5 100644 --- a/packages/backend/src/quick-command-tags/quick-command-tag.controller.ts +++ b/packages/backend/src/quick-command-tags/quick-command-tag.controller.ts @@ -1,25 +1,21 @@ import { Request, Response } from 'express'; 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 => { try { const tags = await QuickCommandTagService.getAllQuickCommandTags(); res.status(200).json(tags); } catch (error: any) { - console.error('[Controller] 获取快捷指令标签列表失败:', error.message); + console.error('[QuickCommandTagController] 获取快捷指令标签列表失败:', error.message); res.status(500).json({ message: error.message || '无法获取快捷指令标签列表' }); } }; -/** - * 处理添加新快捷指令标签的请求 - */ export const addQuickCommandTag = async (req: Request, res: Response): Promise => { const { name } = req.body; - if (!name || typeof name !== 'string' || name.trim().length === 0) { res.status(400).json({ message: '标签名称不能为空且必须是字符串' }); return; @@ -27,37 +23,33 @@ export const addQuickCommandTag = async (req: Request, res: Response): Promise => { - const id = parseInt(req.params.id, 10); + const id = Number.parseInt(req.params.id, 10); const { name } = req.body; - if (isNaN(id)) { + if (Number.isNaN(id)) { res.status(400).json({ message: '无效的标签 ID' }); return; } + if (!name || typeof name !== 'string' || name.trim().length === 0) { res.status(400).json({ message: '标签名称不能为空且必须是字符串' }); return; @@ -65,66 +57,67 @@ export const updateQuickCommandTag = async (req: Request, res: Response): Promis try { const success = await QuickCommandTagService.updateQuickCommandTag(id, name); - 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 { - // 检查标签是否真的不存在 + if (!success) { const tagExists = await QuickCommandTagService.getQuickCommandTagById(id); - if (!tagExists) { - res.status(404).json({ message: '未找到要更新的快捷指令标签' }); - } else { - // 如果标签存在但更新失败(理论上不太可能,除非并发问题),返回服务器错误 - console.error(`[Controller] 更新快捷指令标签 ${id} 失败,但标签存在。`); - res.status(500).json({ message: '更新快捷指令标签时发生未知错误' }); - } + res.status(tagExists ? 500 : 404).json({ + message: tagExists ? '更新快捷指令标签时发生未知错误' : '未找到要更新的快捷指令标签', + }); + return; } + + const updatedTag = await QuickCommandTagService.getQuickCommandTagById(id); + if (updatedTag) { + res.status(200).json({ message: '快捷指令标签已更新', tag: updatedTag }); + return; + } + + res.status(200).json({ message: '快捷指令标签已更新,但无法检索更新后的记录' }); } catch (error: any) { - console.error('[Controller] 更新快捷指令标签失败:', error.message); - // 检查是否是名称重复错误 - if (error.message && error.message.includes('已存在')) { - res.status(409).json({ message: error.message }); // 409 Conflict - } else { - res.status(500).json({ message: error.message || '无法更新快捷指令标签' }); + console.error('[QuickCommandTagController] 更新快捷指令标签失败:', error.message); + if (error.message?.includes('已存在')) { + res.status(409).json({ message: error.message }); + return; } + res.status(500).json({ message: error.message || '无法更新快捷指令标签' }); } }; -/** - * 处理删除快捷指令标签的请求 - */ export const deleteQuickCommandTag = async (req: Request, res: Response): Promise => { - const id = parseInt(req.params.id, 10); - - if (isNaN(id)) { + const id = Number.parseInt(req.params.id, 10); + if (Number.isNaN(id)) { res.status(400).json({ message: '无效的标签 ID' }); return; } try { - // 先检查标签是否存在,以便返回 404 const tagExists = await QuickCommandTagService.getQuickCommandTagById(id); if (!tagExists) { - res.status(404).json({ message: '未找到要删除的快捷指令标签' }); - return; + res.status(404).json({ message: '未找到要删除的快捷指令标签' }); + return; } const success = await QuickCommandTagService.deleteQuickCommandTag(id); - if (success) { - res.status(200).json({ message: '快捷指令标签已删除' }); - } else { - // 如果上面检查存在但删除失败,说明有内部错误 - console.error(`[Controller] 删除快捷指令标签 ${id} 失败,但标签存在。`); - res.status(500).json({ message: '删除快捷指令标签时发生未知错误' }); - } + res.status(success ? 200 : 500).json({ + message: success ? '快捷指令标签已删除' : '删除快捷指令标签时发生未知错误', + }); } catch (error: any) { - console.error('[Controller] 删除快捷指令标签失败:', error.message); + console.error('[QuickCommandTagController] 删除快捷指令标签失败:', error.message); res.status(500).json({ message: error.message || '无法删除快捷指令标签' }); } -}; \ No newline at end of file +}; + +export const reorderQuickCommandTags = async (req: Request, res: Response): Promise => { + 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 || '无法更新快捷指令标签顺序' }); + } +}; diff --git a/packages/backend/src/quick-command-tags/quick-command-tag.repository.ts b/packages/backend/src/quick-command-tags/quick-command-tag.repository.ts index a2a6fb3..deadb96 100644 --- a/packages/backend/src/quick-command-tags/quick-command-tag.repository.ts +++ b/packages/backend/src/quick-command-tags/quick-command-tag.repository.ts @@ -1,197 +1,268 @@ -import { Database } from 'sqlite3'; import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; -// 定义 Quick Command Tag 类型 export interface QuickCommandTag { id: number; name: string; + sort_order: number; created_at: number; updated_at: number; } -/** - * 获取所有快捷指令标签 - */ +interface CommandTagAssociationRow { + tag_id: number; + sort_order: number; +} + +const getNextTagSortOrder = async (db: Awaited>): Promise => { + 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>, + tagId: number, +): Promise => { + 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 => { try { const db = await getDbInstance(); - const rows = await allDb(db, `SELECT * FROM quick_command_tags ORDER BY name ASC`); - return rows; + return await allDb( + db, + 'SELECT * FROM quick_command_tags ORDER BY sort_order ASC, name ASC', + ); } catch (err: any) { - console.error('[仓库] 查询快捷指令标签列表时出错:', err.message); + console.error('[QuickCommandTagRepository] 查询快捷指令标签列表失败:', err.message); throw new Error('获取快捷指令标签列表失败'); } }; -/** - * 根据 ID 获取单个快捷指令标签 - */ export const findQuickCommandTagById = async (id: number): Promise => { - try { - const db = await getDbInstance(); - const row = await getDbRow(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 => { - const now = Math.floor(Date.now() / 1000); - const sql = `INSERT INTO quick_command_tags (name, created_at, updated_at) VALUES (?, ?, ?)`; try { const db = await getDbInstance(); - const result = await runDb(db, sql, [name, now, now]); + const row = await getDbRow(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 => { + 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) { - throw new Error('创建快捷指令标签后未能获取有效的 lastID'); + throw new Error('创建快捷指令标签后未能获取有效的 lastID'); } + return result.lastID; } catch (err: any) { - console.error('[仓库] 创建快捷指令标签时出错:', err.message); + console.error('[QuickCommandTagRepository] 创建快捷指令标签失败:', err.message); if (err.message.includes('UNIQUE constraint failed')) { - throw new Error(`快捷指令标签名称 "${name}" 已存在。`); + throw new Error(`快捷指令标签名称 "${name}" 已存在。`); } throw new Error(`创建快捷指令标签失败: ${err.message}`); } }; -/** - * 更新快捷指令标签名称 - */ export const updateQuickCommandTag = async (id: number, name: string): Promise => { - const now = Math.floor(Date.now() / 1000); - const sql = `UPDATE quick_command_tags SET name = ?, updated_at = ? WHERE id = ?`; try { 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; } catch (err: any) { - console.error(`[仓库] 更新快捷指令标签 ${id} 时出错:`, err.message); - if (err.message.includes('UNIQUE constraint failed')) { - throw new Error(`快捷指令标签名称 "${name}" 已存在。`); - } - throw new Error(`更新快捷指令标签失败: ${err.message}`); + console.error(`[QuickCommandTagRepository] 更新快捷指令标签 ${id} 失败:`, err.message); + if (err.message.includes('UNIQUE constraint failed')) { + throw new Error(`快捷指令标签名称 "${name}" 已存在。`); + } + throw new Error(`更新快捷指令标签失败: ${err.message}`); } }; -/** - * 删除快捷指令标签 (同时会通过外键 CASCADE 删除关联) - */ export const deleteQuickCommandTag = async (id: number): Promise => { - const sql = `DELETE FROM quick_command_tags WHERE id = ?`; try { const db = await getDbInstance(); - // 由于 quick_command_tag_associations 设置了 ON DELETE CASCADE, - // 删除 quick_command_tags 中的记录会自动删除关联表中的相关记录。 - const result = await runDb(db, sql, [id]); + const result = await runDb(db, 'DELETE FROM quick_command_tags WHERE id = ?', [id]); return result.changes > 0; } catch (err: any) { - console.error(`[仓库] 删除快捷指令标签 ${id} 时出错:`, err.message); + console.error(`[QuickCommandTagRepository] 删除快捷指令标签 ${id} 失败:`, err.message); throw new Error('删除快捷指令标签失败'); } }; -/** - * 设置单个快捷指令的标签关联 (先删除旧关联,再插入新关联) - * @param commandId - 快捷指令 ID - * @param tagIds - 新的标签 ID 数组 (空数组表示清除所有关联) - * @returns Promise - */ -export const setCommandTagAssociations = async (commandId: number, tagIds: number[]): Promise => { - 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 (?, ?)`; +export const reorderQuickCommandTags = async (tagIds: number[]): Promise => { + const normalizedTagIds = Array.from(new Set(tagIds.filter((tagId) => Number.isInteger(tagId) && tagId > 0))); + if (normalizedTagIds.length === 0) { + return; + } + const db = await getDbInstance(); try { await runDb(db, 'BEGIN TRANSACTION'); - // 1. 删除该指令的所有旧关联 - await runDb(db, deleteSql, [commandId]); - - // 2. 插入新关联 (如果 tagIds 不为空) - if (tagIds && tagIds.length > 0) { - 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(); + for (let index = 0; index < normalizedTagIds.length; index += 1) { + await runDb( + db, + 'UPDATE quick_command_tags SET sort_order = ?, updated_at = strftime(\'%s\', \'now\') WHERE id = ?', + [index + 1, normalizedTagIds[index]], + ); } await runDb(db, 'COMMIT'); } 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 => { + const normalizedTagIds = Array.from(new Set(tagIds.filter((tagId) => Number.isInteger(tagId) && tagId > 0))); + const db = await getDbInstance(); + + try { + const existingAssociations = await allDb( + 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('无法设置快捷指令标签关联'); } }; -/** - * 将单个标签批量添加到多个快捷指令 - * @param commandIds - 需要添加标签的快捷指令 ID 数组 - * @param tagId - 要添加的标签 ID - * @returns Promise - */ export const addTagToCommands = async (commandIds: number[], tagId: number): Promise => { - if (!commandIds || commandIds.length === 0) { - return; // 没有指令需要关联 + const normalizedCommandIds = Array.from( + 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 insertSql = `INSERT OR IGNORE INTO quick_command_tag_associations (quick_command_id, tag_id) VALUES (?, ?)`; try { await runDb(db, 'BEGIN TRANSACTION'); - // 准备批量插入语句 - const stmt = await db.prepare(insertSql); - for (const commandId of commandIds) { - // 验证 commandId 和 tagId 是否为有效数字(可选,但推荐) - if (typeof commandId !== 'number' || isNaN(commandId) || typeof tagId !== 'number' || isNaN(tagId)) { - console.warn(`[Repo] addTagToCommands: 无效的 commandId (${commandId}) 或 tagId (${tagId}),跳过关联。`); - continue; + for (const commandId of normalizedCommandIds) { + const existingAssociation = await getDbRow<{ quick_command_id: number }>( + db, + 'SELECT quick_command_id FROM quick_command_tag_associations WHERE quick_command_id = ? AND tag_id = ?', + [commandId, tagId], + ); + + 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'); - console.log(`[Repo] addTagToCommands: 成功将标签 ${tagId} 关联到 ${commandIds.length} 个指令。`); } catch (err: any) { - console.error(`[Repo] addTagToCommands: 批量关联标签 ${tagId} 到指令时出错:`, err.message); await runDb(db, 'ROLLBACK'); + console.error(`[QuickCommandTagRepository] 批量关联标签 ${tagId} 失败:`, err.message); throw new Error('无法批量关联标签到快捷指令'); } }; -/** - * 更新指定快捷指令的标签关联 (使用事务) - * @param commandId 快捷指令 ID - * @param tagIds 新的快捷指令标签 ID 数组 (空数组表示清除所有标签) - */ -// Removed the duplicate function declaration that returned Promise +export const reorderCommandsInTag = async (tagId: number, commandIds: number[]): Promise => { + const normalizedCommandIds = Array.from( + new Set(commandIds.filter((commandId) => Number.isInteger(commandId) && commandId > 0)), + ); + + 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 => { 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 JOIN quick_command_tag_associations ta ON t.id = ta.tag_id WHERE ta.quick_command_id = ? - ORDER BY t.name ASC`; + ORDER BY ta.sort_order ASC, t.name ASC`; + try { const db = await getDbInstance(); - const rows = await allDb(db, sql, [commandId]); - return rows; + return await allDb(db, sql, [commandId]); } catch (err: any) { - console.error(`Repository: 查询快捷指令 ${commandId} 的标签时出错:`, err.message); + console.error(`[QuickCommandTagRepository] 查询快捷指令 ${commandId} 的标签失败:`, err.message); throw new Error('获取快捷指令标签失败'); } -}; \ No newline at end of file +}; diff --git a/packages/backend/src/quick-command-tags/quick-command-tag.routes.ts b/packages/backend/src/quick-command-tags/quick-command-tag.routes.ts index a104322..238f926 100644 --- a/packages/backend/src/quick-command-tags/quick-command-tag.routes.ts +++ b/packages/backend/src/quick-command-tags/quick-command-tag.routes.ts @@ -1,19 +1,13 @@ import express from 'express'; import * as QuickCommandTagController from './quick-command-tag.controller'; -import { isAuthenticated } from '../auth/auth.middleware'; // 假设需要认证 +import { isAuthenticated } from '../auth/auth.middleware'; const router = express.Router(); -// 获取所有快捷指令标签 router.get('/', isAuthenticated, QuickCommandTagController.getAllQuickCommandTags); - -// 添加新的快捷指令标签 router.post('/', isAuthenticated, QuickCommandTagController.addQuickCommandTag); - -// 更新快捷指令标签 +router.put('/reorder', isAuthenticated, QuickCommandTagController.reorderQuickCommandTags); router.put('/:id', isAuthenticated, QuickCommandTagController.updateQuickCommandTag); - -// 删除快捷指令标签 router.delete('/:id', isAuthenticated, QuickCommandTagController.deleteQuickCommandTag); -export default router; \ No newline at end of file +export default router; diff --git a/packages/backend/src/quick-command-tags/quick-command-tag.service.ts b/packages/backend/src/quick-command-tags/quick-command-tag.service.ts index 58789f9..6973e50 100644 --- a/packages/backend/src/quick-command-tags/quick-command-tag.service.ts +++ b/packages/backend/src/quick-command-tags/quick-command-tag.service.ts @@ -1,118 +1,50 @@ import * as QuickCommandTagRepository from '../quick-command-tags/quick-command-tag.repository'; import { QuickCommandTag } from '../quick-command-tags/quick-command-tag.repository'; -/** - * 获取所有快捷指令标签 - */ export const getAllQuickCommandTags = async (): Promise => { return QuickCommandTagRepository.findAllQuickCommandTags(); }; -/** - * 根据 ID 获取单个快捷指令标签 - */ export const getQuickCommandTagById = async (id: number): Promise => { return QuickCommandTagRepository.findQuickCommandTagById(id); }; -/** - * 添加新的快捷指令标签 - * @param name 标签名称 - * @returns 返回新标签的 ID - */ export const addQuickCommandTag = async (name: string): Promise => { if (!name || name.trim().length === 0) { throw new Error('标签名称不能为空'); } - const trimmedName = 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 响应 - } + + return QuickCommandTagRepository.createQuickCommandTag(name.trim()); }; -/** - * 更新快捷指令标签 - * @param id 标签 ID - * @param name 新的标签名称 - * @returns 返回是否成功更新 - */ export const updateQuickCommandTag = async (id: number, name: string): Promise => { if (!name || name.trim().length === 0) { throw new Error('标签名称不能为空'); } - const trimmedName = 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; - } + + return QuickCommandTagRepository.updateQuickCommandTag(id, name.trim()); }; -/** - * 删除快捷指令标签 - * @param id 标签 ID - * @returns 返回是否成功删除 - */ export const deleteQuickCommandTag = async (id: number): Promise => { - try { - 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; - } + return QuickCommandTagRepository.deleteQuickCommandTag(id); }; -/** - * 设置指定快捷指令的标签关联 - * @param commandId 快捷指令 ID - * @param tagIds 新的快捷指令标签 ID 数组 - * @returns Promise - */ export const setCommandTags = async (commandId: number, tagIds: number[]): Promise => { - // 验证 tagIds 是否为数字数组 (基本验证) - if (!Array.isArray(tagIds) || !tagIds.every(id => typeof id === 'number')) { - throw new Error('标签 ID 列表必须是一个数字数组'); + if (!Array.isArray(tagIds) || !tagIds.every((id) => typeof id === 'number')) { + throw new Error('标签 ID 列表必须是数字数组'); } - // 可以在这里添加更复杂的验证,例如检查 tagIds 是否都存在于 quick_command_tags 表中 - // 但 Repository 中的 setCommandTagAssociations 已包含基本的检查和错误处理 - try { - // 直接调用 Repository 处理关联更新 (Repository 函数现在返回 void) - await QuickCommandTagRepository.setCommandTagAssociations(commandId, tagIds); - // Service 函数也返回 void,所以不需要 return - } catch (error: any) { - console.error(`[Service] 设置快捷指令 ${commandId} 的标签失败:`, error.message); - throw error; - } + await QuickCommandTagRepository.setCommandTagAssociations(commandId, tagIds); }; -/** - * 获取指定快捷指令的所有标签 - * @param commandId 快捷指令 ID - * @returns 标签对象数组 - */ export const getTagsForCommand = async (commandId: number): Promise => { - try { - return await QuickCommandTagRepository.findTagsByCommandId(commandId); - } catch (error: any) { - console.error(`[Service] 获取快捷指令 ${commandId} 的标签失败:`, error.message); - throw error; + return QuickCommandTagRepository.findTagsByCommandId(commandId); +}; + +export const reorderQuickCommandTags = async (tagIds: number[]): Promise => { + if (!Array.isArray(tagIds) || !tagIds.every((id) => typeof id === 'number')) { + throw new Error('tagIds 必须是数字数组'); } -}; \ No newline at end of file + + await QuickCommandTagRepository.reorderQuickCommandTags(tagIds); +}; diff --git a/packages/backend/src/quick-commands/quick-commands.controller.ts b/packages/backend/src/quick-commands/quick-commands.controller.ts index 44e214d..18e26a2 100644 --- a/packages/backend/src/quick-commands/quick-commands.controller.ts +++ b/packages/backend/src/quick-commands/quick-commands.controller.ts @@ -2,202 +2,202 @@ import { Request, Response } from 'express'; import * as QuickCommandsService 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 => { - // 从请求体中解构出 name, command, 以及可选的 tagIds 和 variables const { name, command, tagIds, variables } = req.body; - // --- 基本验证 --- if (!command || typeof command !== 'string' || command.trim().length === 0) { res.status(400).json({ message: '指令内容不能为空' }); return; } - // 名称可以是 null 或 string + if (name !== null && typeof name !== 'string') { - 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 必须是一个数字数组' }); + res.status(400).json({ message: '名称必须是字符串或 null' }); return; } - // 验证 variables (如果提供的话) + + 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 必须是一个对象' }); + res.status(400).json({ message: 'variables 必须是对象' }); return; } - try { - // 将 tagIds 和 variables 传递给 Service 层 const newId = await QuickCommandsService.addQuickCommand(name, command, tagIds, variables); - // 尝试获取新创建的带标签的指令信息返回 const newCommand = await QuickCommandsService.getQuickCommandById(newId); + if (newCommand) { res.status(201).json({ message: '快捷指令已添加', command: newCommand }); - } else { - console.error(`[Controller] 添加快捷指令后未能找到 ID: ${newId}`); - res.status(201).json({ message: '快捷指令已添加,但无法检索新记录', id: newId }); + return; } + + res.status(201).json({ message: '快捷指令已添加,但无法检索新记录', id: newId }); } catch (error: any) { - console.error('[Controller] 添加快捷指令失败:', error.message); + console.error('[QuickCommandsController] 添加快捷指令失败:', error.message); res.status(500).json({ message: error.message || '无法添加快捷指令' }); } }; -/** - * 处理获取所有快捷指令的请求 (支持排序) - */ export const getAllQuickCommands = async (req: Request, res: Response): Promise => { const sortBy = req.query.sortBy as QuickCommandSortBy | undefined; - // 验证 sortBy 参数 - const validSortBy: QuickCommandSortBy = (sortBy === 'name' || sortBy === 'usage_count') ? sortBy : 'name'; + const validSortBy: QuickCommandSortBy = + sortBy === 'name' || sortBy === 'usage_count' || sortBy === 'manual' ? sortBy : 'manual'; try { const commands = await QuickCommandsService.getAllQuickCommands(validSortBy); res.status(200).json(commands); } catch (error: any) { - console.error('获取快捷指令控制器出错:', error); + console.error('[QuickCommandsController] 获取快捷指令失败:', error.message); res.status(500).json({ message: error.message || '无法获取快捷指令' }); } }; -/** - * 处理更新快捷指令的请求 - */ export const updateQuickCommand = async (req: Request, res: Response): Promise => { - const id = parseInt(req.params.id, 10); - // 从请求体中解构出 name, command, 以及可选的 tagIds 和 variables + const id = Number.parseInt(req.params.id, 10); const { name, command, tagIds, variables } = req.body; - // --- 基本验证 --- - if (isNaN(id)) { + if (Number.isNaN(id)) { res.status(400).json({ message: '无效的 ID' }); return; } + if (!command || typeof command !== 'string' || command.trim().length === 0) { res.status(400).json({ message: '指令内容不能为空' }); return; } + if (name !== null && typeof name !== 'string') { - 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 必须是一个数字数组' }); + res.status(400).json({ message: '名称必须是字符串或 null' }); return; } - // 验证 variables (如果提供的话) - // undefined 表示不更新 variables, null 或对象表示要更新 - if (variables !== undefined && variables !== null && (typeof variables !== 'object' || Array.isArray(variables))) { + + if (tagIds !== undefined && !isNumberArray(tagIds)) { + 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' }); return; } try { - // 将 tagIds 和 variables 传递给 Service 层 const success = await QuickCommandsService.updateQuickCommand(id, name, command, tagIds, variables); - 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 { - // 检查指令是否真的不存在 + if (!success) { const commandExists = await QuickCommandsService.getQuickCommandById(id); - if (!commandExists) { - res.status(404).json({ message: '未找到要更新的快捷指令' }); - } else { - console.error(`[Controller] 更新快捷指令 ${id} 失败,但指令存在。`); - res.status(500).json({ message: '更新快捷指令时发生未知错误' }); - } + res.status(commandExists ? 500 : 404).json({ + message: commandExists ? '更新快捷指令时发生未知错误' : '未找到要更新的快捷指令', + }); + return; } + + const updatedCommand = await QuickCommandsService.getQuickCommandById(id); + if (updatedCommand) { + res.status(200).json({ message: '快捷指令已更新', command: updatedCommand }); + return; + } + + res.status(200).json({ message: '快捷指令已更新,但无法检索更新后的记录' }); } catch (error: any) { - console.error('更新快捷指令控制器出错:', error); + console.error('[QuickCommandsController] 更新快捷指令失败:', error.message); res.status(500).json({ message: error.message || '无法更新快捷指令' }); } }; -/** - * 处理删除快捷指令的请求 - */ export const deleteQuickCommand = async (req: Request, res: Response): Promise => { - const id = parseInt(req.params.id, 10); - - if (isNaN(id)) { + const id = Number.parseInt(req.params.id, 10); + if (Number.isNaN(id)) { res.status(400).json({ message: '无效的 ID' }); return; } try { const success = await QuickCommandsService.deleteQuickCommand(id); - if (success) { - res.status(200).json({ message: '快捷指令已删除' }); - } else { - res.status(404).json({ message: '未找到要删除的快捷指令' }); - } + res.status(success ? 200 : 404).json({ + message: success ? '快捷指令已删除' : '未找到要删除的快捷指令', + }); } catch (error: any) { - console.error('删除快捷指令控制器出错:', error); + console.error('[QuickCommandsController] 删除快捷指令失败:', error.message); res.status(500).json({ message: error.message || '无法删除快捷指令' }); } }; -/** - * 处理增加快捷指令使用次数的请求 - */ export const incrementUsage = async (req: Request, res: Response): Promise => { - const id = parseInt(req.params.id, 10); - - if (isNaN(id)) { + const id = Number.parseInt(req.params.id, 10); + if (Number.isNaN(id)) { res.status(400).json({ message: '无效的 ID' }); return; } try { const success = await QuickCommandsService.incrementUsageCount(id); - if (success) { - res.status(200).json({ message: '使用次数已增加' }); - } else { - // 即使没找到也可能返回成功,避免不必要的错误提示 - console.warn(`尝试增加不存在的快捷指令 (ID: ${id}) 的使用次数`); - res.status(200).json({ message: '使用次数已记录 (或指令不存在)' }); - } + res.status(200).json({ + message: success ? '使用次数已增加' : '使用次数已记录(或指令不存在)', + }); } catch (error: any) { - console.error('增加快捷指令使用次数控制器出错:', error); + console.error('[QuickCommandsController] 增加快捷指令使用次数失败:', error.message); res.status(500).json({ message: error.message || '无法增加使用次数' }); } }; -/** - * 批量将标签分配给多个快捷指令 - */ -export const assignTagToCommands = async (req: Request, res: Response): Promise => { // Add : Promise +export const assignTagToCommands = async (req: Request, res: Response): Promise => { const { commandIds, tagId } = req.body; - // 基本验证 - if (!Array.isArray(commandIds) || commandIds.length === 0 || typeof tagId !== 'number') { - res.status(400).json({ success: false, message: '请求体必须包含 commandIds (非空数组) 和 tagId (数字)。' }); - return; // Use return without value to exit early + if (!isNumberArray(commandIds) || commandIds.length === 0 || typeof tagId !== 'number') { + res.status(400).json({ success: false, message: '请求体必须包含 commandIds 和 tagId' }); + return; } try { - // 调用 Service 函数处理批量分配 - console.log(`[Controller] assignTagToCommands: Received commandIds: ${JSON.stringify(commandIds)}, tagId: ${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) { - console.error('[Controller] 批量分配标签时出错:', error.message); - // 根据错误类型返回不同的状态码可能更好,但这里简化处理 - res.status(500).json({ success: false, message: error.message || '批量分配标签时发生内部服务器错误。' }); - // No return needed here, error handling completes the response + console.error('[QuickCommandsController] 批量分配标签失败:', error.message); + res.status(500).json({ success: false, message: error.message || '批量分配标签失败' }); + } +}; + +export const reorderQuickCommands = async (req: Request, res: Response): Promise => { + 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 => { + 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 || '无法更新标签内快捷指令顺序' }); } }; diff --git a/packages/backend/src/quick-commands/quick-commands.repository.ts b/packages/backend/src/quick-commands/quick-commands.repository.ts index 6c7184f..bed0ba6 100644 --- a/packages/backend/src/quick-commands/quick-commands.repository.ts +++ b/packages/backend/src/quick-commands/quick-commands.repository.ts @@ -1,193 +1,247 @@ import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; -// 定义基础快捷指令接口 export interface QuickCommand { id: number; - name: string | null; // 名称可选 + name: string | null; command: string; usage_count: number; - variables?: string; // 存储 JSON 格式的变量键值对 - created_at: number; // Unix 时间戳 (秒) - updated_at: number; // Unix 时间戳 (秒) + sort_order: number; + variables?: string | null; + created_at: number; + updated_at: number; } -// 定义包含标签 ID 和解析后变量的接口 export type QuickCommandWithTags = Omit & { tagIds: number[]; - variables: Record | null; // API 层面使用对象 + tagOrders: Record; + variables: Record | null; }; -// 用于从数据库获取带 tag_ids_str 的行 -interface DbQuickCommandWithTagsRow extends QuickCommand { - tag_ids_str: string | null; - // variables 字段已包含在 QuickCommand 中,这里不需要重复定义,因为 QuickCommand 将包含 variables?: string +interface QuickCommandTagOrderRow { + quick_command_id: number; + tag_id: number; + sort_order: number; } +type QuickCommandSortBy = 'manual' | 'name' | 'usage_count'; -/** - * 添加一条新的快捷指令 - * @param name - 指令名称 (可选) - * @param command - 指令内容 - * @param variables - 变量对象 (可选) - * @returns 返回插入记录的 ID - */ -export const addQuickCommand = async (name: string | null, command: string, variables?: Record): Promise => { - const sql = `INSERT INTO quick_commands (name, command, variables, created_at, updated_at) VALUES (?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now'))`; +const parseVariables = (variables: string | null | undefined, commandId: number): Record | null => { + if (!variables) { + return null; + } + + try { + return JSON.parse(variables); + } catch (error) { + console.error(`[QuickCommandsRepository] 解析快捷指令 ${commandId} 的 variables 失败:`, error); + return null; + } +}; + +const getNextQuickCommandSortOrder = async (db: Awaited>): Promise => { + 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>, + commandIds: number[], +): Promise }>> => { + const tagState = new Map }>(); + + if (commandIds.length === 0) { + return tagState; + } + + const placeholders = commandIds.map(() => '?').join(', '); + const rows = await allDb( + 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>, + rows: QuickCommand[], +): Promise => { + 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, +): Promise => { try { const db = await getDbInstance(); 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) { - throw new Error('添加快捷指令后未能获取有效的 lastID'); + throw new Error('添加快捷指令后未能获取有效的 lastID'); } + return result.lastID; } catch (err: any) { - console.error('添加快捷指令时出错:', err.message); + console.error('[QuickCommandsRepository] 添加快捷指令失败:', err.message); throw new Error('无法添加快捷指令'); } }; -/** - * 更新指定的快捷指令 - * @param id - 要更新的记录 ID - * @param name - 新的指令名称 (可选) - * @param command - 新的指令内容 - * @param variables - 新的变量对象 (可选) - * @returns 返回是否成功更新 (true/false) - */ -export const updateQuickCommand = async (id: number, name: string | null, command: string, variables?: Record): Promise => { - const sql = `UPDATE quick_commands SET name = ?, command = ?, variables = ?, updated_at = strftime('%s', 'now') WHERE id = ?`; +export const updateQuickCommand = async ( + id: number, + name: string | null, + command: string, + variables?: Record, +): Promise => { try { const db = await getDbInstance(); 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; } catch (err: any) { - console.error('更新快捷指令时出错:', err.message); + console.error('[QuickCommandsRepository] 更新快捷指令失败:', err.message); throw new Error('无法更新快捷指令'); } }; -/** - * 根据 ID 删除指定的快捷指令 - * @param id - 要删除的记录 ID - * @returns 返回是否成功删除 (true/false) - */ export const deleteQuickCommand = async (id: number): Promise => { - const sql = `DELETE FROM quick_commands WHERE id = ?`; try { 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; } catch (err: any) { - console.error('删除快捷指令时出错:', err.message); + console.error('[QuickCommandsRepository] 删除快捷指令失败:', err.message); throw new Error('无法删除快捷指令'); } }; -/** - * 获取所有快捷指令及其关联的标签 ID - * @param sortBy - 排序字段 ('name' 或 'usage_count') - * @returns 返回包含所有快捷指令条目及标签 ID 的数组 - */ -export const getAllQuickCommands = async (sortBy: 'name' | 'usage_count' = 'name'): Promise => { - let orderByClause = 'ORDER BY qc.name ASC'; // 默认按名称升序 +export const getAllQuickCommands = async (sortBy: QuickCommandSortBy = 'manual'): Promise => { + let orderByClause = 'ORDER BY sort_order ASC, id ASC'; 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 { const db = await getDbInstance(); - const rows = await allDb(db, sql); - // 将 tag_ids_str 解析为数字数组,并解析 variables - return rows.map(row => { - let parsedVariables: Record | null = null; - if (row.variables) { - try { - parsedVariables = JSON.parse(row.variables); - } 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)) : [] - }; - }); + const rows = await allDb( + db, + `SELECT id, name, command, usage_count, sort_order, variables, created_at, updated_at + FROM quick_commands + ${orderByClause}`, + ); + return await buildQuickCommandsWithTags(db, rows); } catch (err: any) { - console.error('获取快捷指令(带标签)时出错:', err.message); + console.error('[QuickCommandsRepository] 获取快捷指令失败:', err.message); throw new Error('无法获取快捷指令'); } }; -/** - * 增加指定快捷指令的使用次数 - * @param id - 要增加次数的记录 ID - * @returns 返回是否成功更新 (true/false) - */ export const incrementUsageCount = async (id: number): Promise => { - const sql = `UPDATE quick_commands SET usage_count = usage_count + 1, updated_at = strftime('%s', 'now') WHERE id = ?`; try { 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; } catch (err: any) { - console.error('增加快捷指令使用次数时出错:', err.message); + console.error('[QuickCommandsRepository] 增加快捷指令使用次数失败:', err.message); throw new Error('无法增加快捷指令使用次数'); } }; -/** - * 根据 ID 查找快捷指令及其关联的标签 ID - * @param id - 要查找的记录 ID - * @returns 返回找到的快捷指令条目及标签 ID,如果未找到则返回 undefined - */ export const findQuickCommandById = async (id: number): Promise => { - // 使用 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 { const db = await getDbInstance(); - const row = await getDbRow(db, sql, [id]); - if (row && typeof row.id !== 'undefined') { - // 将 tag_ids_str 解析为数字数组,并解析 variables - let parsedVariables: Record | null = null; - if (row.variables) { - try { - parsedVariables = JSON.parse(row.variables); - } 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)) : [] - }; - } else { + const row = await getDbRow( + db, + `SELECT id, name, command, usage_count, sort_order, variables, created_at, updated_at + FROM quick_commands + WHERE id = ?`, + [id], + ); + + if (!row) { return undefined; } + + const [hydratedRow] = await buildQuickCommandsWithTags(db, [row]); + return hydratedRow; } catch (err: any) { - console.error('查找快捷指令(带标签)时出错:', err.message); - throw new Error('无法查找快捷指令'); + console.error('[QuickCommandsRepository] 查询快捷指令失败:', err.message); + throw new Error('无法查询快捷指令'); + } +}; + +export const reorderQuickCommands = async (commandIds: number[]): Promise => { + 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('无法更新快捷指令顺序'); } }; diff --git a/packages/backend/src/quick-commands/quick-commands.routes.ts b/packages/backend/src/quick-commands/quick-commands.routes.ts index bd50f2f..9f554aa 100644 --- a/packages/backend/src/quick-commands/quick-commands.routes.ts +++ b/packages/backend/src/quick-commands/quick-commands.routes.ts @@ -9,7 +9,9 @@ router.use(isAuthenticated); // 定义路由 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.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 diff --git a/packages/backend/src/quick-commands/quick-commands.service.ts b/packages/backend/src/quick-commands/quick-commands.service.ts index e8ca43a..363f2fa 100644 --- a/packages/backend/src/quick-commands/quick-commands.service.ts +++ b/packages/backend/src/quick-commands/quick-commands.service.ts @@ -1,133 +1,102 @@ import * as QuickCommandsRepository 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 { QuickCommandWithTags } from '../quick-commands/quick-commands.repository'; +import * as QuickCommandTagRepository from '../quick-command-tags/quick-command-tag.repository'; -// 定义排序类型 -export type QuickCommandSortBy = 'name' | 'usage_count'; +export type QuickCommandSortBy = 'manual' | 'name' | 'usage_count'; -/** - * 添加快捷指令 - * @param name - 指令名称 (可选) - * @param command - 指令内容 - * @param tagIds - 关联的快捷指令标签 ID 数组 (可选) - * @param variables - 变量对象 (可选) - * @returns 返回添加记录的 ID - */ -export const addQuickCommand = async (name: string | null, command: string, tagIds?: number[], variables?: Record): Promise => { +export const addQuickCommand = async ( + name: string | null, + command: string, + tagIds?: number[], + variables?: Record, +): Promise => { if (!command || command.trim().length === 0) { throw new Error('指令内容不能为空'); } - // 如果 name 是空字符串,则视为 null + const finalName = name && name.trim().length > 0 ? name.trim() : null; const commandId = await QuickCommandsRepository.addQuickCommand(finalName, command.trim(), variables); - // 添加成功后,设置标签关联 if (commandId > 0 && tagIds && Array.isArray(tagIds)) { try { await QuickCommandTagRepository.setCommandTagAssociations(commandId, tagIds); } catch (tagError: any) { - // 如果标签关联失败,可以选择记录警告或回滚(但通常不回滚主记录) - console.warn(`[Service] 添加快捷指令 ${commandId} 成功,但设置标签关联失败:`, tagError.message); - // 可以考虑是否需要通知用户部分操作失败 + console.warn(`[QuickCommandsService] 快捷指令 ${commandId} 已创建,但设置标签关联失败:`, tagError.message); } } + return commandId; }; -/** - * 更新快捷指令 - * @param id - 要更新的记录 ID - * @param name - 新的指令名称 (可选) - * @param command - 新的指令内容 - * @param tagIds - 新的关联标签 ID 数组 (可选, undefined 表示不更新标签) - * @param variables - 新的变量对象 (可选) - * @returns 返回是否成功更新 (更新行数 > 0) - */ -export const updateQuickCommand = async (id: number, name: string | null, command: string, tagIds?: number[], variables?: Record): Promise => { +export const updateQuickCommand = async ( + id: number, + name: string | null, + command: string, + tagIds?: number[], + variables?: Record, +): Promise => { if (!command || command.trim().length === 0) { throw new Error('指令内容不能为空'); } + const finalName = name && name.trim().length > 0 ? name.trim() : null; const commandUpdated = await QuickCommandsRepository.updateQuickCommand(id, finalName, command.trim(), variables); - // 如果指令更新成功,并且提供了 tagIds (即使是空数组也表示要更新),则更新标签关联 if (commandUpdated && typeof tagIds !== 'undefined') { - try { + try { await QuickCommandTagRepository.setCommandTagAssociations(id, tagIds); - } catch (tagError: any) { - console.warn(`[Service] 更新快捷指令 ${id} 成功,但更新标签关联失败:`, tagError.message); - // 即使标签更新失败,主记录已更新,通常返回 true - } + } catch (tagError: any) { + console.warn(`[QuickCommandsService] 快捷指令 ${id} 已更新,但更新标签关联失败:`, tagError.message); + } } - // 返回主记录是否更新成功 + return commandUpdated; }; -/** - * 删除快捷指令 - * @param id - 要删除的记录 ID - * @returns 返回是否成功删除 (删除行数 > 0) - */ export const deleteQuickCommand = async (id: number): Promise => { - const changes = await QuickCommandsRepository.deleteQuickCommand(id); - return changes; + return QuickCommandsRepository.deleteQuickCommand(id); }; -/** - * 获取所有快捷指令,并按指定方式排序 - * @param sortBy - 排序字段 ('name' 或 'usage_count') - * @returns 返回排序后的快捷指令数组 (包含 tagIds) - */ -export const getAllQuickCommands = async (sortBy: QuickCommandSortBy = 'name'): Promise => { - // Repository 已返回带 tagIds 的数据 +export const getAllQuickCommands = async (sortBy: QuickCommandSortBy = 'manual'): Promise => { return QuickCommandsRepository.getAllQuickCommands(sortBy); }; -/** - * 增加快捷指令的使用次数 - * @param id - 记录 ID - * @returns 返回是否成功更新 (更新行数 > 0) - */ export const incrementUsageCount = async (id: number): Promise => { - const changes = await QuickCommandsRepository.incrementUsageCount(id); - return changes; + return QuickCommandsRepository.incrementUsageCount(id); }; -/** - * 根据 ID 获取单个快捷指令 (可能用于编辑) - * @param id - 记录 ID - * @returns 返回找到的快捷指令 (包含 tagIds),或 undefined - */ export const getQuickCommandById = async (id: number): Promise => { - // Repository 已返回带 tagIds 的数据 return QuickCommandsRepository.findQuickCommandById(id); }; -/** - * 将单个标签批量关联到多个快捷指令 - * @param commandIds - 需要添加标签的快捷指令 ID 数组 - * @param tagId - 要添加的标签 ID - * @returns Promise - */ export const assignTagToCommands = async (commandIds: number[], tagId: number): Promise => { - try { - // 基本验证 - 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 (!Array.isArray(commandIds) || commandIds.some((id) => typeof id !== 'number' || Number.isNaN(id))) { + throw new Error('无效的指令 ID 列表'); } + + if (typeof tagId !== 'number' || Number.isNaN(tagId)) { + throw new Error('无效的标签 ID'); + } + + await QuickCommandTagRepository.addTagToCommands(commandIds, tagId); +}; + +export const reorderQuickCommands = async (commandIds: number[]): Promise => { + 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 => { + 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); }; diff --git a/packages/frontend/src/components/AddConnectionFormAuth.vue b/packages/frontend/src/components/AddConnectionFormAuth.vue index aa7a7eb..308572c 100644 --- a/packages/frontend/src/components/AddConnectionFormAuth.vue +++ b/packages/frontend/src/components/AddConnectionFormAuth.vue @@ -1,4 +1,5 @@ diff --git a/packages/frontend/src/components/AddEditQuickCommandForm.vue b/packages/frontend/src/components/AddEditQuickCommandForm.vue index 681eb8e..ecc1c2d 100644 --- a/packages/frontend/src/components/AddEditQuickCommandForm.vue +++ b/packages/frontend/src/components/AddEditQuickCommandForm.vue @@ -4,9 +4,9 @@ ref="modalContentRef" class="bg-background text-foreground p-6 rounded-xl border border-border/50 shadow-2xl flex flex-col" :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, - maxWidth: 'calc(100vw - 2rem)', + maxWidth: `calc(100vw - ${MODAL_VIEWPORT_GUTTER_PX}px)`, maxHeight: 'calc(100vh - 2rem)', }" > @@ -183,6 +183,8 @@ const modalContentRef = ref(null); const commandTextareaRef = ref(null); const R_MIN_WIDTH = 580; // 可调整大小的最小宽度 (像素) 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 { width: resizableWidth, height: resizableHeight } = useResizable(modalContentRef, { @@ -239,7 +241,7 @@ watch(() => formData.command, (newCommand) => { // 初始化表单数据 (如果是编辑模式) onMounted(() => { 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 initialW = Math.max(R_MIN_WIDTH, initialW); diff --git a/packages/frontend/src/components/LoginCredentialManagementModal.vue b/packages/frontend/src/components/LoginCredentialManagementModal.vue index 3e05dab..c2a04dc 100644 --- a/packages/frontend/src/components/LoginCredentialManagementModal.vue +++ b/packages/frontend/src/components/LoginCredentialManagementModal.vue @@ -39,6 +39,19 @@ const initialFormData: LoginCredentialInput = { const formData = reactive({ ...initialFormData }); const formError = ref(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) => { if (!credentialToEdit.value && newValue) { @@ -47,12 +60,17 @@ watch(() => props.initialType, (newValue) => { }); watch(() => formData.type, (newType) => { + resetPasswordVisibility(); if (newType !== 'SSH') { formData.auth_method = 'password'; formData.ssh_key_id = null; } }); +watch(() => formData.auth_method, () => { + resetPasswordVisibility(); +}); + onMounted(() => { loginCredentialsStore.fetchLoginCredentials(); }); @@ -60,6 +78,7 @@ onMounted(() => { const resetForm = () => { Object.assign(formData, initialFormData, { type: props.initialType || 'SSH' }); formError.value = null; + resetPasswordVisibility(); }; const showAddForm = () => { @@ -71,6 +90,7 @@ const showAddForm = () => { const showEditForm = async (credential: LoginCredentialBasicInfo) => { formError.value = null; credentialToEdit.value = credential; + resetPasswordVisibility(); const details = await loginCredentialsStore.fetchLoginCredentialDetails(credential.id); if (!details) { @@ -300,7 +320,19 @@ const cancelForm = () => {
- +
+ + +
@@ -313,7 +345,19 @@ const cancelForm = () => {
- +
+ + +
diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index b323691..659e7db 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -265,6 +265,8 @@ "authMethodPassword": "Password", "authMethodKey": "SSH Key", "password": "Password:", + "showPassword": "Show password", + "hidePassword": "Hide password", "privateKey": "Private Key:", "passphrase": "Passphrase:", "vncPassword": "VNC Password", diff --git a/packages/frontend/src/locales/ja-JP.json b/packages/frontend/src/locales/ja-JP.json index d112467..3eab34c 100644 --- a/packages/frontend/src/locales/ja-JP.json +++ b/packages/frontend/src/locales/ja-JP.json @@ -196,6 +196,8 @@ "optional": "オプション", "passphrase": "パスフレーズ:", "password": "パスワード:", + "showPassword": "パスワードを表示", + "hidePassword": "パスワードを隠す", "port": "ポート:", "privateKey": "秘密鍵:", "noSshKey":"SSHキーなし", diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index 2cc571a..4b35490 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -266,6 +266,8 @@ "authMethodPassword": "密码", "authMethodKey": "SSH 密钥", "password": "密码:", + "showPassword": "显示密码", + "hidePassword": "隐藏密码", "privateKey": "私钥:", "passphrase": "私钥密码:", "vncPassword": "VNC 密码:", diff --git a/packages/frontend/src/stores/quickCommandTags.store.ts b/packages/frontend/src/stores/quickCommandTags.store.ts index 61ce5f7..68ee6c9 100644 --- a/packages/frontend/src/stores/quickCommandTags.store.ts +++ b/packages/frontend/src/stores/quickCommandTags.store.ts @@ -3,62 +3,58 @@ import { ref } from 'vue'; import apiClient from '../utils/apiClient'; import { useUiNotificationsStore } from './uiNotifications.store'; -// 定义快捷指令标签接口 (与后端 QuickCommandTag 对应) export interface QuickCommandTag { id: number; name: string; + sort_order: number; created_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', () => { const tags = ref([]); const isLoading = ref(false); const error = ref(null); const uiNotificationsStore = useUiNotificationsStore(); - // 获取快捷指令标签列表 (带缓存) async function fetchTags() { - const cacheKey = 'quickCommandTagsCache'; error.value = null; - // 1. 尝试从 localStorage 加载缓存 try { - const cachedData = localStorage.getItem(cacheKey); + const cachedData = localStorage.getItem(TAG_CACHE_KEY); if (cachedData) { - tags.value = JSON.parse(cachedData); - isLoading.value = false; - } else { - isLoading.value = true; + const parsedData = JSON.parse(cachedData); + if (Array.isArray(parsedData)) { + tags.value = parsedData.map(normalizeTag); + } } - } catch (e) { - console.error('[QuickCmdTagStore] Failed to load or parse cache:', e); - localStorage.removeItem(cacheKey); - isLoading.value = true; + } catch (cacheError) { + console.error('[QuickCommandTagsStore] 读取标签缓存失败:', cacheError); + localStorage.removeItem(TAG_CACHE_KEY); } - // 2. 后台获取最新数据 isLoading.value = true; try { - // 使用新的 API 端点 const response = await apiClient.get('/quick-command-tags'); - const freshData = response.data; - const freshDataString = JSON.stringify(freshData); - - // 3. 对比并更新 - const currentDataString = JSON.stringify(tags.value); - if (currentDataString !== freshDataString) { - tags.value = freshData; - localStorage.setItem(cacheKey, freshDataString); - } else { - } - error.value = null; + const freshTags = Array.isArray(response.data) ? response.data.map(normalizeTag) : []; + tags.value = freshTags; + localStorage.setItem(TAG_CACHE_KEY, JSON.stringify(freshTags)); return true; } catch (err: any) { - console.error('[QuickCmdTagStore] Failed to fetch tags:', err); + console.error('[QuickCommandTagsStore] 获取标签失败:', err); error.value = err.response?.data?.message || err.message || '获取快捷指令标签列表失败'; - if (error.value) { // Check if error.value is not null - uiNotificationsStore.showError(error.value); // 显示错误通知 + if (error.value) { + uiNotificationsStore.showError(error.value); } return false; } finally { @@ -66,22 +62,19 @@ export const useQuickCommandTagsStore = defineStore('quickCommandTags', () => { } } - // 添加新快捷指令标签 (添加后清除缓存) async function addTag(name: string): Promise { isLoading.value = true; error.value = null; try { - // 使用新的 API 端点 - const response = await apiClient.post<{ message: string, tag: QuickCommandTag }>('/quick-command-tags', { name }); - const newTag = response.data.tag; - localStorage.removeItem('quickCommandTagsCache'); // 清除缓存 - await fetchTags(); // 重新获取以更新列表 + const response = await apiClient.post<{ message: string; tag: QuickCommandTag }>('/quick-command-tags', { name }); + localStorage.removeItem(TAG_CACHE_KEY); + await fetchTags(); uiNotificationsStore.showSuccess('快捷指令标签已添加'); - return newTag; + return response.data.tag ? normalizeTag(response.data.tag) : null; } catch (err: any) { - console.error('[QuickCmdTagStore] Failed to add tag:', err); + console.error('[QuickCommandTagsStore] 添加标签失败:', err); 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 null; @@ -90,21 +83,19 @@ export const useQuickCommandTagsStore = defineStore('quickCommandTags', () => { } } - // 更新快捷指令标签 async function updateTag(id: number, name: string): Promise { isLoading.value = true; error.value = null; try { - // 使用新的 API 端点 await apiClient.put(`/quick-command-tags/${id}`, { name }); - localStorage.removeItem('quickCommandTagsCache'); + localStorage.removeItem(TAG_CACHE_KEY); await fetchTags(); uiNotificationsStore.showSuccess('快捷指令标签已更新'); return true; } catch (err: any) { - console.error('[QuickCmdTagStore] Failed to update tag:', err); + console.error('[QuickCommandTagsStore] 更新标签失败:', err); 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; @@ -113,21 +104,43 @@ export const useQuickCommandTagsStore = defineStore('quickCommandTags', () => { } } - // 删除快捷指令标签 async function deleteTag(id: number): Promise { isLoading.value = true; error.value = null; try { - // 使用新的 API 端点 await apiClient.delete(`/quick-command-tags/${id}`); - localStorage.removeItem('quickCommandTagsCache'); + localStorage.removeItem(TAG_CACHE_KEY); await fetchTags(); uiNotificationsStore.showSuccess('快捷指令标签已删除'); return true; } catch (err: any) { - console.error('[QuickCmdTagStore] Failed to delete tag:', err); + console.error('[QuickCommandTagsStore] 删除标签失败:', err); 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 { + 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); } return false; @@ -144,5 +157,6 @@ export const useQuickCommandTagsStore = defineStore('quickCommandTags', () => { addTag, updateTag, deleteTag, + reorderTags, }; -}); \ No newline at end of file +}); diff --git a/packages/frontend/src/stores/quickCommands.store.ts b/packages/frontend/src/stores/quickCommands.store.ts index 05866e1..d64652d 100644 --- a/packages/frontend/src/stores/quickCommands.store.ts +++ b/packages/frontend/src/stores/quickCommands.store.ts @@ -1,170 +1,221 @@ import { defineStore } from 'pinia'; -import apiClient from '../utils/apiClient'; -import { ref, computed, watch } from 'vue'; +import apiClient from '../utils/apiClient'; +import { ref, computed, watch } from 'vue'; import { useUiNotificationsStore } from './uiNotifications.store'; -import { useQuickCommandTagsStore, type QuickCommandTag } from './quickCommandTags.store'; -import { useI18n } from 'vue-i18n'; +import { useQuickCommandTagsStore } from './quickCommandTags.store'; +import { useSettingsStore } from './settings.store'; +import { useI18n } from 'vue-i18n'; - - -// 定义前端使用的快捷指令接口 (包含 tagIds) -export interface QuickCommandFE { // Renamed from QuickCommand if necessary +export interface QuickCommandFE { id: number; name: string | null; command: string; usage_count: number; + sort_order: number; created_at: number; updated_at: number; - tagIds: number[]; // +++ Add tagIds +++ - variables?: Record; // New: Add variables + tagIds: number[]; + tagOrders: Record; + variables?: Record | null; } -// 定义排序类型 -export type QuickCommandSortByType = 'name' | 'usage_count' | 'last_used'; +export type QuickCommandSortByType = 'manual' | 'name' | 'usage_count' | 'last_used'; -// 定义分组后的数据结构 export interface GroupedQuickCommands { groupName: string; - tagId: number | null; // null for "Untagged" group + tagId: number | null; commands: QuickCommandFE[]; } -// +++ localStorage key for expanded groups +++ const EXPANDED_GROUPS_STORAGE_KEY = 'quickCommandsExpandedGroups'; +const QUICK_COMMANDS_CACHE_KEY = 'quickCommandsListCache'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const normalizeTagOrders = (tagOrders: unknown): Record => { + if (!isRecord(tagOrders)) { + return {}; + } + + return Object.entries(tagOrders).reduce>((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) : 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', () => { - const quickCommandsList = ref([]); // Should now contain QuickCommandFE with tagIds + const quickCommandsList = ref([]); const searchTerm = ref(''); - const sortBy = ref('name'); // 默认按名称排序 + const sortBy = ref('manual'); const isLoading = ref(false); const error = ref(null); - const uiNotificationsStore = useUiNotificationsStore(); - const quickCommandTagsStore = useQuickCommandTagsStore(); // +++ Inject new tag store +++ - const { t } = useI18n(); // +++ For "Untagged" translation +++ - const selectedIndex = ref(-1); // Index in the flatVisibleCommands list - - // +++ State for expanded groups +++ + const selectedIndex = ref(-1); const expandedGroups = ref>({}); - // --- Getters --- + const uiNotificationsStore = useUiNotificationsStore(); + const quickCommandTagsStore = useQuickCommandTagsStore(); + const settingsStore = useSettingsStore(); + const { t } = useI18n(); - // +++ 重写 Getter: 过滤、分组、排序指令 +++ - const filteredAndGroupedCommands = computed((): GroupedQuickCommands[] => { + const filteredCommands = computed(() => { const term = searchTerm.value.toLowerCase().trim(); - const allTags = quickCommandTagsStore.tags; // 获取快捷指令专属标签 - const tagMap = new Map(allTags.map(tag => [tag.id, tag.name])); - const untaggedGroupName = t('quickCommands.untagged', '未标记'); // 获取 "未标记" 的翻译 + if (!term) { + return quickCommandsList.value; + } - // 1. 过滤 (New logic: filter by command name, command content, OR tag name) - let filtered = quickCommandsList.value; - if (term) { - filtered = filtered.filter(cmd => { - // Check command name - if (cmd.name && cmd.name.toLowerCase().includes(term)) { - return true; - } - // Check command content - if (cmd.command.toLowerCase().includes(term)) { - return true; - } - // Check associated tag names - 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 - } - } - } - // No match found - return false; + const tagMap = new Map(quickCommandTagsStore.tags.map((tag) => [tag.id, tag.name])); + return quickCommandsList.value.filter((command) => { + if (command.name && command.name.toLowerCase().includes(term)) { + return true; + } + + if (command.command.toLowerCase().includes(term)) { + return true; + } + + return command.tagIds.some((tagId) => { + const tagName = tagMap.get(tagId); + return typeof tagName === 'string' && tagName.toLowerCase().includes(term); + }); + }); + }); + + const sortedFlatCommands = computed(() => { + return [...filteredCommands.value].sort((a, b) => compareCommands(a, b, sortBy.value)); + }); + + const filteredAndGroupedCommands = computed((): GroupedQuickCommands[] => { + 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(); + 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. 分组 - const groups: Record = {}; - const untaggedCommands: QuickCommandFE[] = []; - - filtered.forEach(cmd => { - 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; - } - }); + for (const command of filteredCommands.value) { + const validTagIds = command.tagIds.filter((tagId) => groups.has(tagId)); + if (validTagIds.length === 0) { + untaggedCommands.push(command); + continue; } - if (!isTagged) { - untaggedCommands.push(cmd); + + for (const tagId of validTagIds) { + groups.get(tagId)!.commands.push(command); } - }); + } - // 3. 排序分组内指令 & 格式化输出 - const sortedGroupNames = Object.keys(groups).sort((a, b) => a.localeCompare(b)); - const result: GroupedQuickCommands[] = sortedGroupNames.map(groupName => { - const groupData = groups[groupName]; - // 组内排序 - groupData.commands.sort((a, b) => { - if (sortBy.value === 'usage_count') { - 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; // 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 - }; - }); + const result: GroupedQuickCommands[] = []; + for (const tag of sortedTags) { + const group = groups.get(tag.id); + if (!group || group.commands.length === 0) { + continue; + } + group.commands.sort((a, b) => compareCommands(a, b, sortBy.value, tag.id)); + result.push(group); + } - // 4. 处理未标记的分组 if (untaggedCommands.length > 0) { - // 初始化展开状态 (如果未定义,默认为 true) - if (expandedGroups.value[untaggedGroupName] === undefined) { - expandedGroups.value[untaggedGroupName] = true; - } - // 组内排序 - untaggedCommands.sort((a, b) => { - if (sortBy.value === 'usage_count') { - 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 - }); + if (expandedGroups.value[untaggedGroupName] === undefined) { + expandedGroups.value[untaggedGroupName] = true; + } + result.push({ + groupName: untaggedGroupName, + tagId: null, + commands: [...untaggedCommands].sort((a, b) => compareCommands(a, b, sortBy.value, null)), + }); } return result; }); - // +++ Getter: 获取当前可见的扁平指令列表 (用于键盘导航) +++ const flatVisibleCommands = computed((): QuickCommandFE[] => { + if (!settingsStore.showQuickCommandTagsBoolean) { + return sortedFlatCommands.value; + } + const flatList: QuickCommandFE[] = []; - filteredAndGroupedCommands.value.forEach(group => { - // 只添加已展开分组中的指令 + filteredAndGroupedCommands.value.forEach((group) => { if (expandedGroups.value[group.groupName]) { flatList.push(...group.commands); } @@ -172,130 +223,91 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => { return flatList; }); - - // --- Actions --- - - // +++ Load initial expanded groups state from localStorage +++ const loadExpandedGroups = () => { try { const storedState = localStorage.getItem(EXPANDED_GROUPS_STORAGE_KEY); if (storedState) { const parsedState = JSON.parse(storedState); - if (typeof parsedState === 'object' && parsedState !== null) { - expandedGroups.value = parsedState; - console.log('[QuickCmdStore] Loaded expanded groups state from localStorage.'); + if (isRecord(parsedState)) { + expandedGroups.value = Object.entries(parsedState).reduce>((result, [key, value]) => { + result[key] = Boolean(value); + return result; + }, {}); return; } } - } catch (e) { - console.error('[QuickCmdStore] Failed to load or parse expanded groups state:', e); + } catch (cacheError) { + console.error('[QuickCommandsStore] 读取分组展开状态失败:', cacheError); localStorage.removeItem(EXPANDED_GROUPS_STORAGE_KEY); } - // Default to empty object if no valid state found + expandedGroups.value = {}; }; - // +++ Save expanded groups state to localStorage +++ const saveExpandedGroups = () => { try { localStorage.setItem(EXPANDED_GROUPS_STORAGE_KEY, JSON.stringify(expandedGroups.value)); - } catch (e) { - console.error('[QuickCmdStore] Failed to save expanded groups state:', e); + } catch (cacheError) { + console.error('[QuickCommandsStore] 保存分组展开状态失败:', cacheError); } }; - // +++ Watch for changes and save +++ watch(expandedGroups, saveExpandedGroups, { deep: true }); - // +++ Action to toggle group expansion +++ const toggleGroup = (groupName: string) => { - // Ensure the group exists in the state before toggling - if (expandedGroups.value[groupName] === undefined) { - // Default to true if toggling a group that wasn't explicitly set (e.g., newly appeared group) - 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; + expandedGroups.value[groupName] = expandedGroups.value[groupName] === undefined + ? false + : !expandedGroups.value[groupName]; }; - // Action to select the next command in the *visible* flat list const selectNextCommand = () => { - const commands = flatVisibleCommands.value; // Use the flat visible list + const commands = flatVisibleCommands.value; if (commands.length === 0) { selectedIndex.value = -1; return; } + selectedIndex.value = (selectedIndex.value + 1) % commands.length; }; - // Action to select the previous command in the *visible* flat list const selectPreviousCommand = () => { - const commands = flatVisibleCommands.value; // Use the flat visible list + const commands = flatVisibleCommands.value; if (commands.length === 0) { selectedIndex.value = -1; return; } + selectedIndex.value = (selectedIndex.value - 1 + commands.length) % commands.length; }; - // 从后端获取快捷指令 (包含 tagIds,不再发送 sortBy) + const clearQuickCommandsCache = () => { + localStorage.removeItem(QUICK_COMMANDS_CACHE_KEY); + }; + const fetchQuickCommands = async () => { - // 简化缓存:只缓存原始列表,不再区分排序 - const cacheKey = 'quickCommandsListCache'; error.value = null; - // 1. 尝试从 localStorage 加载缓存 try { - const cachedData = localStorage.getItem(cacheKey); + const cachedData = localStorage.getItem(QUICK_COMMANDS_CACHE_KEY); if (cachedData) { - // 确保解析后的数据符合 QuickCommandFE 结构 (特别是 tagIds 和 variables) - const parsedData = JSON.parse(cachedData) as QuickCommandFE[]; - // 基本验证,确保 tagIds 是数组,variables 是对象或undefined - 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; + const parsedData = JSON.parse(cachedData); + if (Array.isArray(parsedData)) { + quickCommandsList.value = parsedData.map(normalizeQuickCommand); } - } else { - isLoading.value = true; } - } catch (e) { - console.error('[QuickCmdStore] Failed to load or parse commands cache:', e); - localStorage.removeItem(cacheKey); - isLoading.value = true; + } catch (cacheError) { + console.error('[QuickCommandsStore] 读取快捷指令缓存失败:', cacheError); + clearQuickCommandsCache(); } - // 2. 后台获取最新数据 isLoading.value = true; try { - console.log(`[QuickCmdStore] Fetching latest commands from server...`); - // 不再发送 sortBy 参数 const response = await apiClient.get('/quick-commands'); - // 确保返回的数据包含 tagIds 数组和 variables 对象 - const freshData = response.data.map(cmd => ({ - ...cmd, - 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; + const freshData = Array.isArray(response.data) ? response.data.map(normalizeQuickCommand) : []; + quickCommandsList.value = freshData; + localStorage.setItem(QUICK_COMMANDS_CACHE_KEY, JSON.stringify(freshData)); } catch (err: any) { - console.error('[QuickCmdStore] 获取快捷指令失败:', err); + console.error('[QuickCommandsStore] 获取快捷指令失败:', err); error.value = err.response?.data?.message || '获取快捷指令时发生错误'; if (error.value) { uiNotificationsStore.showError(error.value); @@ -305,109 +317,154 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => { } }; - // 清除快捷指令列表缓存 - const clearQuickCommandsCache = () => { - localStorage.removeItem('quickCommandsListCache'); - console.log('[QuickCmdStore] Cleared quick commands list cache.'); - }; - - - // 添加快捷指令 (发送 tagIds 和 variables) - const addQuickCommand = async (name: string | null, command: string, tagIds?: number[], variables?: Record): Promise => { + const addQuickCommand = async ( + name: string | null, + command: string, + tagIds?: number[], + variables?: Record, + ): Promise => { try { - // 在请求体中包含 tagIds 和 variables - const response = await apiClient.post<{ message: string, command: QuickCommandFE }>('/quick-commands', { name, command, tagIds, variables }); - // 后端现在返回完整的 command 对象,可以直接使用或触发刷新 - clearQuickCommandsCache(); // 清除缓存 - await fetchQuickCommands(); // 重新获取以确保数据同步 + await apiClient.post('/quick-commands', { name, command, tagIds, variables }); + clearQuickCommandsCache(); + await fetchQuickCommands(); uiNotificationsStore.showSuccess('快捷指令已添加'); return true; } catch (err: any) { - console.error('添加快捷指令失败:', err); + console.error('[QuickCommandsStore] 添加快捷指令失败:', err); const message = err.response?.data?.message || '添加快捷指令时发生错误'; uiNotificationsStore.showError(message); return false; } }; - // 更新快捷指令 (发送 tagIds 和 variables) - const updateQuickCommand = async (id: number, name: string | null, command: string, tagIds?: number[], variables?: Record): Promise => { - try { - // 在请求体中包含 tagIds 和 variables (即使是 undefined 也要发送,让后端知道是否要更新) - const response = await apiClient.put<{ message: string, command: QuickCommandFE }>(`/quick-commands/${id}`, { name, command, tagIds, variables }); - // 后端现在返回完整的 command 对象 - clearQuickCommandsCache(); // 清除缓存 - await fetchQuickCommands(); // 重新获取以确保数据同步 + const updateQuickCommand = async ( + id: number, + name: string | null, + command: string, + tagIds?: number[], + variables?: Record, + ): Promise => { + try { + await apiClient.put(`/quick-commands/${id}`, { name, command, tagIds, variables }); + clearQuickCommandsCache(); + await fetchQuickCommands(); uiNotificationsStore.showSuccess('快捷指令已更新'); return true; } catch (err: any) { - console.error('更新快捷指令失败:', err); + console.error('[QuickCommandsStore] 更新快捷指令失败:', err); const message = err.response?.data?.message || '更新快捷指令时发生错误'; uiNotificationsStore.showError(message); return false; } }; - // 删除快捷指令 const deleteQuickCommand = async (id: number) => { try { await apiClient.delete(`/quick-commands/${id}`); - clearQuickCommandsCache(); // 清除所有排序缓存 - // 从本地列表中移除 - const index = quickCommandsList.value.findIndex(cmd => cmd.id === id); - if (index !== -1) { - quickCommandsList.value.splice(index, 1); - } + quickCommandsList.value = quickCommandsList.value.filter((command) => command.id !== id); + clearQuickCommandsCache(); uiNotificationsStore.showSuccess('快捷指令已删除'); } catch (err: any) { - console.error('删除快捷指令失败:', err); + console.error('[QuickCommandsStore] 删除快捷指令失败:', err); const message = err.response?.data?.message || '删除快捷指令时发生错误'; uiNotificationsStore.showError(message); } }; - // 增加使用次数 (调用 API,然后更新本地数据) const incrementUsage = async (id: number) => { - try { - await apiClient.post(`/quick-commands/${id}/increment-usage`); // 使用 apiClient - // 更新本地计数,避免重新请求整个列表 - const command = quickCommandsList.value.find(cmd => cmd.id === id); + try { + await apiClient.post(`/quick-commands/${id}/increment-usage`); + const command = quickCommandsList.value.find((item) => item.id === id); if (command) { command.usage_count += 1; - // 如果当前是按使用次数排序,可能需要重新排序或刷新列表 - if (sortBy.value === 'usage_count') { - // 清除所有排序缓存并重新获取当前排序 - clearQuickCommandsCache(); - await fetchQuickCommands(); - } + command.updated_at = Math.floor(Date.now() / 1000); } - } catch (err: any) { - console.error('增加使用次数失败:', err); - // 这里可以选择不提示用户错误,因为这是一个后台操作 + } catch (err) { + console.error('[QuickCommandsStore] 增加快捷指令使用次数失败:', err); } }; - // 设置搜索词 const setSearchTerm = (term: string) => { searchTerm.value = term; - selectedIndex.value = -1; // Reset selection when search term changes + selectedIndex.value = -1; }; - // 设置排序方式 (只更新本地状态,不再重新获取数据) const setSortBy = (newSortBy: QuickCommandSortByType) => { if (sortBy.value !== newSortBy) { sortBy.value = newSortBy; - // 排序现在由 filteredAndGroupedCommands getter 处理,无需重新 fetch - selectedIndex.value = -1; // Reset selection when sort changes + selectedIndex.value = -1; } }; - // Action to reset the selection const resetSelection = () => { selectedIndex.value = -1; }; - // Removed duplicate resetSelection definition + const reorderQuickCommands = async (commandIds: number[]): Promise => { + 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 => { + 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 => { + 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 { quickCommandsList, @@ -415,10 +472,10 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => { sortBy, isLoading, error, - filteredAndGroupedCommands, // Expose the grouped data - flatVisibleCommands, // Expose the flat visible list for navigation logic if needed outside - selectedIndex, // Index within flatVisibleCommands - expandedGroups, // Expose expanded groups state + filteredAndGroupedCommands, + flatVisibleCommands, + selectedIndex, + expandedGroups, fetchQuickCommands, addQuickCommand, updateQuickCommand, @@ -429,60 +486,11 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => { selectNextCommand, selectPreviousCommand, resetSelection, - toggleGroup, // +++ Expose toggleGroup action +++ - loadExpandedGroups, // +++ Expose load action +++ - - // +++ Action to assign a tag to multiple commands +++ - async assignCommandsToTagAction(commandIds: number[], tagId: number): Promise { - if (!commandIds || commandIds.length === 0) { - 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; - } - }, + toggleGroup, + loadExpandedGroups, + clearQuickCommandsCache, + reorderQuickCommands, + reorderCommandsInTag, + assignCommandsToTagAction, }; }); diff --git a/packages/frontend/src/views/ConnectionsView.vue b/packages/frontend/src/views/ConnectionsView.vue index ee649c6..046d095 100644 --- a/packages/frontend/src/views/ConnectionsView.vue +++ b/packages/frontend/src/views/ConnectionsView.vue @@ -1597,6 +1597,17 @@ onBeforeUnmount(() => { {{ t('connections.actions.connect', '连接') }} + +
-