From 026ed949fb422adc5ba2098b607b10829717a6a7 Mon Sep 17 00:00:00 2001 From: Baobhan Sith <80159437+Heavrnl@users.noreply.github.com> Date: Sat, 3 May 2025 15:18:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E6=8C=87=E4=BB=A4=E7=9A=84=E6=A0=87=E7=AD=BE=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to #5 --- packages/backend/src/database/migrations.ts | 35 +- packages/backend/src/database/schema.ts | 24 ++ packages/backend/src/index.ts | 4 +- .../quick-command-tag.controller.ts | 130 ++++++ .../quick-command-tag.routes.ts | 19 + .../quick-commands.controller.ts | 81 +++- .../quick-commands/quick-commands.routes.ts | 1 + .../quick-command-tag.repository.ts | 197 +++++++++ .../repositories/quick-commands.repository.ts | 73 +++- .../src/services/quick-command-tag.service.ts | 118 ++++++ .../src/services/quick-commands.service.ts | 77 +++- .../src/components/AddConnectionForm.vue | 43 +- .../components/AddEditQuickCommandForm.vue | 78 +++- packages/frontend/src/components/TagInput.vue | 189 ++++----- .../components/WorkspaceConnectionList.vue | 24 +- packages/frontend/src/locales/en-US.json | 8 +- packages/frontend/src/locales/ja-JP.json | 6 + packages/frontend/src/locales/zh-CN.json | 8 +- .../src/stores/quickCommandTags.store.ts | 152 +++++++ .../src/stores/quickCommands.store.ts | 397 ++++++++++++++---- .../frontend/src/views/QuickCommandsView.vue | 354 +++++++++++++--- quick-commands-independent-tags-plan.md | 106 +++++ 22 files changed, 1828 insertions(+), 296 deletions(-) create mode 100644 packages/backend/src/quick-command-tags/quick-command-tag.controller.ts create mode 100644 packages/backend/src/quick-command-tags/quick-command-tag.routes.ts create mode 100644 packages/backend/src/repositories/quick-command-tag.repository.ts create mode 100644 packages/backend/src/services/quick-command-tag.service.ts create mode 100644 packages/frontend/src/stores/quickCommandTags.store.ts create mode 100644 quick-commands-independent-tags-plan.md diff --git a/packages/backend/src/database/migrations.ts b/packages/backend/src/database/migrations.ts index 11a0df3..df72037 100644 --- a/packages/backend/src/database/migrations.ts +++ b/packages/backend/src/database/migrations.ts @@ -73,8 +73,41 @@ const definedMigrations: Migration[] = [ -- UPDATE connections SET encrypted_passphrase = NULL WHERE encrypted_passphrase = ''; -- 示例 ` }, + // --- Quick Command Tags Migrations --- + { + id: 2, + name: 'Create quick_command_tags table', + check: async (db: Database): Promise => { + const tableAlreadyExists = await tableExists(db, 'quick_command_tags'); + return !tableAlreadyExists; // Only run if the table does NOT exist + }, + sql: ` + CREATE TABLE IF NOT EXISTS quick_command_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + ); + ` + }, + { + id: 3, + name: 'Create quick_command_tag_associations table', + check: async (db: Database): Promise => { + const tableAlreadyExists = await tableExists(db, 'quick_command_tag_associations'); + return !tableAlreadyExists; // Only run if the table does NOT exist + }, + sql: ` + CREATE TABLE IF NOT EXISTS quick_command_tag_associations ( + quick_command_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + 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 + ); + ` + } // --- 未来可以添加更多迁移 --- - // { id: 2, name: '...', sql: '...' }, ]; /** diff --git a/packages/backend/src/database/schema.ts b/packages/backend/src/database/schema.ts index ddd5ab4..7925657 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -152,6 +152,30 @@ CREATE TABLE IF NOT EXISTS quick_commands ( ); `; +// --- Quick Command Tags --- + +export const createQuickCommandTagsTableSQL = ` +CREATE TABLE IF NOT EXISTS quick_command_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); +`; + +export const createQuickCommandTagAssociationsTableSQL = ` +CREATE TABLE IF NOT EXISTS quick_command_tag_associations ( + quick_command_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + 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 +); +`; + +// --- End Quick Command Tags --- + + // 从 database.ts 移动过来的,保持一致性 export const createTerminalThemesTableSQL = ` CREATE TABLE IF NOT EXISTS terminal_themes ( diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 843d18b..a7daa95 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -21,6 +21,7 @@ import quickCommandsRoutes from './quick-commands/quick-commands.routes'; import terminalThemeRoutes from './terminal-themes/terminal-theme.routes'; import appearanceRoutes from './appearance/appearance.routes'; import sshKeysRouter from './ssh_keys/ssh_keys.routes'; // +++ Import SSH Key routes +++ +import quickCommandTagRoutes from './quick-command-tags/quick-command-tag.routes'; // +++ Import Quick Command Tag routes +++ import { initializeWebSocket } from './websocket'; import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware'; @@ -261,7 +262,8 @@ const startServer = () => { app.use('/api/v1/terminal-themes', terminalThemeRoutes); app.use('/api/v1/appearance', appearanceRoutes); app.use('/api/v1/ssh-keys', sshKeysRouter); // +++ Register SSH Key routes +++ - + app.use('/api/v1/quick-command-tags', quickCommandTagRoutes); // +++ Register Quick Command Tag routes +++ + // 状态检查接口 app.get('/api/v1/status', (req: Request, res: Response) => { res.json({ status: '后端服务运行中!' }); 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 new file mode 100644 index 0000000..d9ee415 --- /dev/null +++ b/packages/backend/src/quick-command-tags/quick-command-tag.controller.ts @@ -0,0 +1,130 @@ +import { Request, Response } from 'express'; +import * as QuickCommandTagService from '../services/quick-command-tag.service'; + +/** + * 处理获取所有快捷指令标签的请求 + */ +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); + 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; + } + + try { + const newId = await QuickCommandTagService.addQuickCommandTag(name); + // 成功添加后,获取新创建的标签信息返回给前端 + const newTag = await QuickCommandTagService.getQuickCommandTagById(newId); + if (newTag) { + res.status(201).json({ message: '快捷指令标签已添加', tag: newTag }); + } else { + // 理论上不应该发生,但作为健壮性检查 + console.error(`[Controller] 添加快捷指令标签后未能找到 ID: ${newId}`); + res.status(201).json({ message: '快捷指令标签已添加,但无法检索新记录', id: newId }); + } + } 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 || '无法添加快捷指令标签' }); + } + } +}; + +/** + * 处理更新快捷指令标签的请求 + */ +export const updateQuickCommandTag = async (req: Request, res: Response): Promise => { + const id = parseInt(req.params.id, 10); + const { name } = req.body; + + if (isNaN(id)) { + res.status(400).json({ message: '无效的标签 ID' }); + return; + } + if (!name || typeof name !== 'string' || name.trim().length === 0) { + res.status(400).json({ message: '标签名称不能为空且必须是字符串' }); + return; + } + + 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 { + // 检查标签是否真的不存在 + const tagExists = await QuickCommandTagService.getQuickCommandTagById(id); + if (!tagExists) { + res.status(404).json({ message: '未找到要更新的快捷指令标签' }); + } else { + // 如果标签存在但更新失败(理论上不太可能,除非并发问题),返回服务器错误 + console.error(`[Controller] 更新快捷指令标签 ${id} 失败,但标签存在。`); + res.status(500).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 || '无法更新快捷指令标签' }); + } + } +}; + +/** + * 处理删除快捷指令标签的请求 + */ +export const deleteQuickCommandTag = async (req: Request, res: Response): Promise => { + const id = parseInt(req.params.id, 10); + + if (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; + } + + const success = await QuickCommandTagService.deleteQuickCommandTag(id); + if (success) { + res.status(200).json({ message: '快捷指令标签已删除' }); + } else { + // 如果上面检查存在但删除失败,说明有内部错误 + console.error(`[Controller] 删除快捷指令标签 ${id} 失败,但标签存在。`); + res.status(500).json({ message: '删除快捷指令标签时发生未知错误' }); + } + } catch (error: any) { + console.error('[Controller] 删除快捷指令标签失败:', error.message); + res.status(500).json({ message: error.message || '无法删除快捷指令标签' }); + } +}; \ 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 new file mode 100644 index 0000000..a104322 --- /dev/null +++ b/packages/backend/src/quick-command-tags/quick-command-tag.routes.ts @@ -0,0 +1,19 @@ +import express from 'express'; +import * as QuickCommandTagController from './quick-command-tag.controller'; +import { isAuthenticated } from '../auth/auth.middleware'; // 假设需要认证 + +const router = express.Router(); + +// 获取所有快捷指令标签 +router.get('/', isAuthenticated, QuickCommandTagController.getAllQuickCommandTags); + +// 添加新的快捷指令标签 +router.post('/', isAuthenticated, QuickCommandTagController.addQuickCommandTag); + +// 更新快捷指令标签 +router.put('/:id', isAuthenticated, QuickCommandTagController.updateQuickCommandTag); + +// 删除快捷指令标签 +router.delete('/:id', isAuthenticated, QuickCommandTagController.deleteQuickCommandTag); + +export default router; \ No newline at end of file diff --git a/packages/backend/src/quick-commands/quick-commands.controller.ts b/packages/backend/src/quick-commands/quick-commands.controller.ts index b52067b..978b403 100644 --- a/packages/backend/src/quick-commands/quick-commands.controller.ts +++ b/packages/backend/src/quick-commands/quick-commands.controller.ts @@ -6,8 +6,10 @@ import { QuickCommandSortBy } from '../services/quick-commands.service'; * 处理添加新快捷指令的请求 */ export const addQuickCommand = async (req: Request, res: Response): Promise => { - const { name, command } = req.body; + // 从请求体中解构出 name, command, 以及可选的 tagIds + const { name, command, tagIds } = req.body; + // --- 基本验证 --- if (!command || typeof command !== 'string' || command.trim().length === 0) { res.status(400).json({ message: '指令内容不能为空' }); return; @@ -17,12 +19,26 @@ export const addQuickCommand = async (req: Request, res: Response): Promise typeof id === 'number'))) { + res.status(400).json({ message: 'tagIds 必须是一个数字数组' }); + return; + } + // --- 结束验证 --- try { - const newId = await QuickCommandsService.addQuickCommand(name, command); - res.status(201).json({ id: newId, message: '快捷指令已添加' }); + // 将 tagIds 传递给 Service 层 + const newId = await QuickCommandsService.addQuickCommand(name, command, tagIds); + // 尝试获取新创建的带标签的指令信息返回 + 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 }); + } } catch (error: any) { - console.error('添加快捷指令控制器出错:', error); + console.error('[Controller] 添加快捷指令失败:', error.message); res.status(500).json({ message: error.message || '无法添加快捷指令' }); } }; @@ -49,8 +65,10 @@ export const getAllQuickCommands = async (req: Request, res: Response): Promise< */ export const updateQuickCommand = async (req: Request, res: Response): Promise => { const id = parseInt(req.params.id, 10); - const { name, command } = req.body; + // 从请求体中解构出 name, command, 以及可选的 tagIds + const { name, command, tagIds } = req.body; + // --- 基本验证 --- if (isNaN(id)) { res.status(400).json({ message: '无效的 ID' }); return; @@ -63,13 +81,35 @@ export const updateQuickCommand = async (req: Request, res: Response): Promise typeof id === 'number'))) { + res.status(400).json({ message: 'tagIds 必须是一个数字数组' }); + return; + } + // --- 结束验证 --- try { - const success = await QuickCommandsService.updateQuickCommand(id, name, command); + // 将 tagIds 传递给 Service 层 + const success = await QuickCommandsService.updateQuickCommand(id, name, command, tagIds); if (success) { - res.status(200).json({ message: '快捷指令已更新' }); + // 尝试获取更新后的带标签的指令信息返回 + 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 { - res.status(404).json({ message: '未找到要更新的快捷指令' }); + // 检查指令是否真的不存在 + const commandExists = await QuickCommandsService.getQuickCommandById(id); + if (!commandExists) { + res.status(404).json({ message: '未找到要更新的快捷指令' }); + } else { + console.error(`[Controller] 更新快捷指令 ${id} 失败,但指令存在。`); + res.status(500).json({ message: '更新快捷指令时发生未知错误' }); + } } } catch (error: any) { console.error('更新快捷指令控制器出错:', error); @@ -126,3 +166,28 @@ export const incrementUsage = async (req: Request, res: Response): Promise res.status(500).json({ message: error.message || '无法增加使用次数' }); } }; + +/** + * 批量将标签分配给多个快捷指令 + */ +export const assignTagToCommands = async (req: Request, res: Response): Promise => { // Add : 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 + } + + 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} 个指令。` }); + } 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 + } +}; diff --git a/packages/backend/src/quick-commands/quick-commands.routes.ts b/packages/backend/src/quick-commands/quick-commands.routes.ts index 602a2f0..bd50f2f 100644 --- a/packages/backend/src/quick-commands/quick-commands.routes.ts +++ b/packages/backend/src/quick-commands/quick-commands.routes.ts @@ -13,5 +13,6 @@ router.get('/', QuickCommandsController.getAllQuickCommands); // GET /api/v1/qui 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 +router.post('/bulk-assign-tag', QuickCommandsController.assignTagToCommands); // POST /api/v1/quick-commands/bulk-assign-tag export default router; diff --git a/packages/backend/src/repositories/quick-command-tag.repository.ts b/packages/backend/src/repositories/quick-command-tag.repository.ts new file mode 100644 index 0000000..a2a6fb3 --- /dev/null +++ b/packages/backend/src/repositories/quick-command-tag.repository.ts @@ -0,0 +1,197 @@ +import { Database } from 'sqlite3'; +import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; + +// 定义 Quick Command Tag 类型 +export interface QuickCommandTag { + id: number; + name: string; + created_at: number; + updated_at: number; +} + +/** + * 获取所有快捷指令标签 + */ +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; + } catch (err: any) { + console.error('[仓库] 查询快捷指令标签列表时出错:', 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]); + if (typeof result.lastID !== 'number' || result.lastID <= 0) { + throw new Error('创建快捷指令标签后未能获取有效的 lastID'); + } + return result.lastID; + } catch (err: any) { + console.error('[仓库] 创建快捷指令标签时出错:', err.message); + if (err.message.includes('UNIQUE constraint failed')) { + 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]); + 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}`); + } +}; + +/** + * 删除快捷指令标签 (同时会通过外键 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]); + return result.changes > 0; + } catch (err: any) { + console.error(`[仓库] 删除快捷指令标签 ${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 (?, ?)`; + + 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(); + } + await runDb(db, 'COMMIT'); + } catch (err: any) { + console.error('设置快捷指令标签关联时出错:', err.message); + await runDb(db, 'ROLLBACK'); // 出错时回滚 + 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 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; + } + await stmt.run(commandId, tagId); + } + 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'); + throw new Error('无法批量关联标签到快捷指令'); + } +}; + +/** + * 更新指定快捷指令的标签关联 (使用事务) + * @param commandId 快捷指令 ID + * @param tagIds 新的快捷指令标签 ID 数组 (空数组表示清除所有标签) + */ +// Removed the duplicate function declaration that returned Promise + +/** + * 查找指定快捷指令的所有标签 + * @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 + 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`; + try { + const db = await getDbInstance(); + const rows = await allDb(db, sql, [commandId]); + return rows; + } catch (err: any) { + console.error(`Repository: 查询快捷指令 ${commandId} 的标签时出错:`, err.message); + throw new Error('获取快捷指令标签失败'); + } +}; \ No newline at end of file diff --git a/packages/backend/src/repositories/quick-commands.repository.ts b/packages/backend/src/repositories/quick-commands.repository.ts index 5c05ca1..a587e6b 100644 --- a/packages/backend/src/repositories/quick-commands.repository.ts +++ b/packages/backend/src/repositories/quick-commands.repository.ts @@ -1,6 +1,6 @@ import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; -// 定义快捷指令的接口 +// 定义基础快捷指令接口 export interface QuickCommand { id: number; name: string | null; // 名称可选 @@ -10,7 +10,16 @@ export interface QuickCommand { updated_at: number; // Unix 时间戳 (秒) } -type DbQuickCommandRow = QuickCommand; +// 定义包含标签 ID 的接口 +export interface QuickCommandWithTags extends QuickCommand { + tagIds: number[]; +} + +// 用于从数据库获取带 tag_ids_str 的行 +interface DbQuickCommandWithTagsRow extends QuickCommand { + tag_ids_str: string | null; +} + /** * 添加一条新的快捷指令 @@ -70,22 +79,34 @@ export const deleteQuickCommand = async (id: number): Promise => { }; /** - * 获取所有快捷指令 + * 获取所有快捷指令及其关联的标签 ID * @param sortBy - 排序字段 ('name' 或 'usage_count') - * @returns 返回包含所有快捷指令条目的数组 + * @returns 返回包含所有快捷指令条目及标签 ID 的数组 */ -export const getAllQuickCommands = async (sortBy: 'name' | 'usage_count' = 'name'): Promise => { - let orderByClause = 'ORDER BY name ASC'; // 默认按名称升序 +export const getAllQuickCommands = async (sortBy: 'name' | 'usage_count' = 'name'): Promise => { + let orderByClause = 'ORDER BY qc.name ASC'; // 默认按名称升序 if (sortBy === 'usage_count') { - orderByClause = 'ORDER BY usage_count DESC, name ASC'; // 按使用频率降序,同频率按名称升序 + orderByClause = 'ORDER BY qc.usage_count DESC, qc.name ASC'; // 按使用频率降序,同频率按名称升序 } - const sql = `SELECT id, name, command, usage_count, created_at, updated_at FROM quick_commands ${orderByClause}`; + // 使用 LEFT JOIN 连接关联表,并使用 GROUP_CONCAT 获取标签 ID 字符串 + const sql = ` + SELECT + qc.id, qc.name, qc.command, qc.usage_count, 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); - return rows; + const rows = await allDb(db, sql); + // 将 tag_ids_str 解析为数字数组 + return rows.map(row => ({ + ...row, + tagIds: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number).filter(id => !isNaN(id)) : [] + })); } catch (err: any) { - console.error('获取快捷指令时出错:', err.message); + console.error('获取快捷指令(带标签)时出错:', err.message); throw new Error('无法获取快捷指令'); } }; @@ -108,18 +129,34 @@ export const incrementUsageCount = async (id: number): Promise => { }; /** - * 根据 ID 查找快捷指令 (用于编辑前获取数据) + * 根据 ID 查找快捷指令及其关联的标签 ID * @param id - 要查找的记录 ID - * @returns 返回找到的快捷指令条目,如果未找到则返回 undefined + * @returns 返回找到的快捷指令条目及标签 ID,如果未找到则返回 undefined */ -export const findQuickCommandById = async (id: number): Promise => { - const sql = `SELECT id, name, command, usage_count, created_at, updated_at FROM quick_commands WHERE id = ?`; +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.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]); - return row; + const row = await getDbRow(db, sql, [id]); + if (row && typeof row.id !== 'undefined') { + // 将 tag_ids_str 解析为数字数组 + return { + ...row, + tagIds: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number).filter(id => !isNaN(id)) : [] + }; + } else { + return undefined; + } } catch (err: any) { - console.error('查找快捷指令时出错:', err.message); + console.error('查找快捷指令(带标签)时出错:', err.message); throw new Error('无法查找快捷指令'); } }; diff --git a/packages/backend/src/services/quick-command-tag.service.ts b/packages/backend/src/services/quick-command-tag.service.ts new file mode 100644 index 0000000..07a7bef --- /dev/null +++ b/packages/backend/src/services/quick-command-tag.service.ts @@ -0,0 +1,118 @@ +import * as QuickCommandTagRepository from '../repositories/quick-command-tag.repository'; +import { QuickCommandTag } from '../repositories/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 响应 + } +}; + +/** + * 更新快捷指令标签 + * @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; + } +}; + +/** + * 删除快捷指令标签 + * @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; + } +}; + +/** + * 设置指定快捷指令的标签关联 + * @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 列表必须是一个数字数组'); + } + // 可以在这里添加更复杂的验证,例如检查 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; + } +}; + +/** + * 获取指定快捷指令的所有标签 + * @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; + } +}; \ No newline at end of file diff --git a/packages/backend/src/services/quick-commands.service.ts b/packages/backend/src/services/quick-commands.service.ts index 5c4df47..9af819b 100644 --- a/packages/backend/src/services/quick-commands.service.ts +++ b/packages/backend/src/services/quick-commands.service.ts @@ -1,5 +1,6 @@ import * as QuickCommandsRepository from '../repositories/quick-commands.repository'; -import { QuickCommand } from '../repositories/quick-commands.repository'; +import { QuickCommandWithTags } from '../repositories/quick-commands.repository'; // Import the type with tags +import * as QuickCommandTagRepository from '../repositories/quick-command-tag.repository'; // Import the new tag repository // 定义排序类型 export type QuickCommandSortBy = 'name' | 'usage_count'; @@ -8,15 +9,28 @@ export type QuickCommandSortBy = 'name' | 'usage_count'; * 添加快捷指令 * @param name - 指令名称 (可选) * @param command - 指令内容 + * @param tagIds - 关联的快捷指令标签 ID 数组 (可选) * @returns 返回添加记录的 ID */ -export const addQuickCommand = async (name: string | null, command: string): Promise => { +export const addQuickCommand = async (name: string | null, command: string, tagIds?: number[]): Promise => { if (!command || command.trim().length === 0) { throw new Error('指令内容不能为空'); } // 如果 name 是空字符串,则视为 null const finalName = name && name.trim().length > 0 ? name.trim() : null; - return QuickCommandsRepository.addQuickCommand(finalName, command.trim()); + const commandId = await QuickCommandsRepository.addQuickCommand(finalName, command.trim()); + + // 添加成功后,设置标签关联 + if (commandId > 0 && tagIds && Array.isArray(tagIds)) { + try { + await QuickCommandTagRepository.setCommandTagAssociations(commandId, tagIds); + } catch (tagError: any) { + // 如果标签关联失败,可以选择记录警告或回滚(但通常不回滚主记录) + console.warn(`[Service] 添加快捷指令 ${commandId} 成功,但设置标签关联失败:`, tagError.message); + // 可以考虑是否需要通知用户部分操作失败 + } + } + return commandId; }; /** @@ -24,15 +38,27 @@ export const addQuickCommand = async (name: string | null, command: string): Pro * @param id - 要更新的记录 ID * @param name - 新的指令名称 (可选) * @param command - 新的指令内容 + * @param tagIds - 新的关联标签 ID 数组 (可选, undefined 表示不更新标签) * @returns 返回是否成功更新 (更新行数 > 0) */ -export const updateQuickCommand = async (id: number, name: string | null, command: string): Promise => { +export const updateQuickCommand = async (id: number, name: string | null, command: string, tagIds?: number[]): Promise => { if (!command || command.trim().length === 0) { throw new Error('指令内容不能为空'); } const finalName = name && name.trim().length > 0 ? name.trim() : null; - const changes = await QuickCommandsRepository.updateQuickCommand(id, finalName, command.trim()); - return changes; + const commandUpdated = await QuickCommandsRepository.updateQuickCommand(id, finalName, command.trim()); + + // 如果指令更新成功,并且提供了 tagIds (即使是空数组也表示要更新),则更新标签关联 + if (commandUpdated && typeof tagIds !== 'undefined') { + try { + await QuickCommandTagRepository.setCommandTagAssociations(id, tagIds); + } catch (tagError: any) { + console.warn(`[Service] 更新快捷指令 ${id} 成功,但更新标签关联失败:`, tagError.message); + // 即使标签更新失败,主记录已更新,通常返回 true + } + } + // 返回主记录是否更新成功 + return commandUpdated; }; /** @@ -48,9 +74,10 @@ export const deleteQuickCommand = async (id: number): Promise => { /** * 获取所有快捷指令,并按指定方式排序 * @param sortBy - 排序字段 ('name' 或 'usage_count') - * @returns 返回排序后的快捷指令数组 + * @returns 返回排序后的快捷指令数组 (包含 tagIds) */ -export const getAllQuickCommands = async (sortBy: QuickCommandSortBy = 'name'): Promise => { +export const getAllQuickCommands = async (sortBy: QuickCommandSortBy = 'name'): Promise => { + // Repository 已返回带 tagIds 的数据 return QuickCommandsRepository.getAllQuickCommands(sortBy); }; @@ -67,8 +94,38 @@ export const incrementUsageCount = async (id: number): Promise => { /** * 根据 ID 获取单个快捷指令 (可能用于编辑) * @param id - 记录 ID - * @returns 返回找到的快捷指令,或 undefined + * @returns 返回找到的快捷指令 (包含 tagIds),或 undefined */ -export const getQuickCommandById = async (id: number): Promise => { +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; + } +}; diff --git a/packages/frontend/src/components/AddConnectionForm.vue b/packages/frontend/src/components/AddConnectionForm.vue index bd37baa..bba56bc 100644 --- a/packages/frontend/src/components/AddConnectionForm.vue +++ b/packages/frontend/src/components/AddConnectionForm.vue @@ -275,6 +275,35 @@ const handleSubmit = async () => { } }; +// --- Tag Creation/Deletion Handling --- +const handleCreateTag = async (tagName: string) => { + console.log(`[ConnForm] Received create-tag event for: ${tagName}`); // +++ 添加日志 +++ + if (!tagName || tagName.trim().length === 0) return; + console.log(`[ConnForm] Calling tagsStore.addTag...`); // +++ 添加日志 +++ + const newTag = await tagsStore.addTag(tagName.trim()); // Use the correct store + if (newTag && !formData.tag_ids.includes(newTag.id)) { + console.log(`[ConnForm] New tag created (ID: ${newTag.id}), adding to selection.`); // +++ 添加日志 +++ + // Add the new tag's ID to the selected list + formData.tag_ids.push(newTag.id); + } +}; + +const handleDeleteTag = async (tagId: number) => { + const tagToDelete = tags.value.find(t => t.id === tagId); + if (!tagToDelete) return; + + if (confirm(t('tags.prompts.confirmDelete', { name: tagToDelete.name }))) { + const success = await tagsStore.deleteTag(tagId); // Use the correct store + if (success) { + // TagInput's modelValue will update automatically via watch + // No need to manually remove from formData.tag_ids here + } else { + // Optional: Show error notification if deletion fails + alert(t('tags.errorDelete', { error: tagsStore.error || '未知错误' })); + } + } +}; + // 处理测试连接 const handleTestConnection = async () => { testStatus.value = 'testing'; @@ -487,9 +516,19 @@ const testButtonText = computed(() => {
- + +
{{ t('tags.loading') }}
+
{{ t('tags.error', { error: tagStoreError }) }}
- +
diff --git a/packages/frontend/src/components/AddEditQuickCommandForm.vue b/packages/frontend/src/components/AddEditQuickCommandForm.vue index e2d85cb..2bf17bb 100644 --- a/packages/frontend/src/components/AddEditQuickCommandForm.vue +++ b/packages/frontend/src/components/AddEditQuickCommandForm.vue @@ -25,6 +25,23 @@ > {{ commandError }}
+ +
+ + + +
+
@@ -42,22 +59,26 @@ import { ref, reactive, computed, watch, onMounted } from 'vue'; import { useI18n } from 'vue-i18n'; import { useQuickCommandsStore, type QuickCommandFE } from '../stores/quickCommands.store'; +import { useQuickCommandTagsStore } from '../stores/quickCommandTags.store'; // +++ Import new tag store +++ +import TagInput from './TagInput.vue'; // +++ Import TagInput component (assuming it exists) +++ const props = defineProps<{ - commandToEdit?: QuickCommandFE | null; // 接收要编辑的指令对象 + commandToEdit?: QuickCommandFE | null; // 接收要编辑的指令对象 (should include tagIds) }>(); const emit = defineEmits(['close']); const { t } = useI18n(); const quickCommandsStore = useQuickCommandsStore(); +const quickCommandTagsStore = useQuickCommandTagsStore(); // +++ Instantiate tag store +++ const isSubmitting = ref(false); const isEditing = computed(() => !!props.commandToEdit); const formData = reactive({ - name: '', - command: '', + name: '', + command: '', + tagIds: [] as number[], // +++ Add tagIds +++ }); const commandError = ref(null); @@ -76,9 +97,54 @@ onMounted(() => { if (isEditing.value && props.commandToEdit) { formData.name = props.commandToEdit.name ?? ''; formData.command = props.commandToEdit.command; + // Initialize tagIds if editing + formData.tagIds = props.commandToEdit.tagIds ? [...props.commandToEdit.tagIds] : []; } + // Fetch tags if not already loaded (optional, might be better in parent) + // if (quickCommandTagsStore.tags.length === 0) { + // quickCommandTagsStore.fetchTags(); + // } }); +// --- Tag Creation Handling --- +// Assuming TagInput emits 'create-tag' with the tag name +const handleCreateTag = async (tagName: string) => { + console.log(`[QuickCmdForm] Received create-tag event for: ${tagName}`); // +++ 添加日志 +++ + if (!tagName || tagName.trim().length === 0) return; + console.log(`[QuickCmdForm] Calling quickCommandTagsStore.addTag...`); // +++ 添加日志 +++ + const newTag = await quickCommandTagsStore.addTag(tagName.trim()); + if (newTag && !formData.tagIds.includes(newTag.id)) { + console.log(`[QuickCmdForm] New tag created (ID: ${newTag.id}), adding to selection.`); // +++ 添加日志 +++ + // Add the new tag's ID to the selected list + formData.tagIds.push(newTag.id); + } +}; + +// --- Tag Deletion Handling --- +const handleDeleteTag = async (tagId: number) => { + console.log(`[QuickCmdForm] Received delete-tag event for ID: ${tagId}`); // +++ 添加日志 +++ + const tagToDelete = quickCommandTagsStore.tags.find(t => t.id === tagId); + if (!tagToDelete) return; + + if (confirm(t('tags.prompts.confirmDelete', { name: tagToDelete.name }))) { + console.log(`[QuickCmdForm] Calling quickCommandTagsStore.deleteTag...`); // +++ 添加日志 +++ + const success = await quickCommandTagsStore.deleteTag(tagId); + if (success) { + // If deletion is successful, TagInput's availableTags will update, + // and the tag should disappear from the input. + // We also need to remove it from the local formData.tagIds if it was selected. + const index = formData.tagIds.indexOf(tagId); + if (index > -1) { + console.log(`[QuickCmdForm] Removing deleted tag ID ${tagId} from selection.`); // +++ 添加日志 +++ + formData.tagIds.splice(index, 1); + } + } else { + // Optional: Show error notification if deletion fails + alert(t('tags.errorDelete', { error: quickCommandTagsStore.error || '未知错误' })); + } + } +}; + const handleSubmit = async () => { if (commandError.value) return; // 如果校验失败则不提交 @@ -89,9 +155,11 @@ const handleSubmit = async () => { const finalName = formData.name.trim().length > 0 ? formData.name.trim() : null; if (isEditing.value && props.commandToEdit) { - success = await quickCommandsStore.updateQuickCommand(props.commandToEdit.id, finalName, formData.command.trim()); + // Pass tagIds to update action + success = await quickCommandsStore.updateQuickCommand(props.commandToEdit.id, finalName, formData.command.trim(), formData.tagIds); } else { - success = await quickCommandsStore.addQuickCommand(finalName, formData.command.trim()); + // Pass tagIds to add action + success = await quickCommandsStore.addQuickCommand(finalName, formData.command.trim(), formData.tagIds); } isSubmitting.value = false; diff --git a/packages/frontend/src/components/TagInput.vue b/packages/frontend/src/components/TagInput.vue index c6f9fd6..41d51f8 100644 --- a/packages/frontend/src/components/TagInput.vue +++ b/packages/frontend/src/components/TagInput.vue @@ -1,24 +1,40 @@ @@ -186,13 +184,15 @@ const handleDeleteTagGlobally = async (tagToDelete: TagInfo) => { @click.stop="removeTagLocally(tag)" :title="t('tags.removeSelection')" >× +
@@ -217,9 +217,10 @@ const handleDeleteTagGlobally = async (tagToDelete: TagInfo) => { > {{ suggestion.name }} - -
{{ t('tags.loading') }}
-
{{ t('tags.error', { error: error }) }}
+ + + + diff --git a/packages/frontend/src/components/WorkspaceConnectionList.vue b/packages/frontend/src/components/WorkspaceConnectionList.vue index 117f369..3386692 100644 --- a/packages/frontend/src/components/WorkspaceConnectionList.vue +++ b/packages/frontend/src/components/WorkspaceConnectionList.vue @@ -118,11 +118,27 @@ const filteredAndGroupedConnections = computed(() => { const tagMap = new Map(tags.value.map(tag => [tag.id, tag])); const lowerSearchTerm = searchTerm.value.toLowerCase(); - // 1. 过滤连接 + // 1. 过滤连接 (New logic: filter by connection name, host, OR tag name) const filteredConnections = connections.value.filter(conn => { - const nameMatch = conn.name && conn.name.toLowerCase().includes(lowerSearchTerm); - const hostMatch = conn.host.toLowerCase().includes(lowerSearchTerm); - return nameMatch || hostMatch; + // Check connection name + if (conn.name && conn.name.toLowerCase().includes(lowerSearchTerm)) { + return true; + } + // Check connection host + if (conn.host.toLowerCase().includes(lowerSearchTerm)) { + return true; + } + // Check associated tag names + if (conn.tag_ids && conn.tag_ids.length > 0) { + for (const tagId of conn.tag_ids) { + const tag = tagMap.get(tagId); // Use the existing tagMap + if (tag && tag.name.toLowerCase().includes(lowerSearchTerm)) { + return true; // Match found in tag name + } + } + } + // No match found + return false; }); // 2. 分组过滤后的连接 diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index 3106e65..32e9939 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -906,7 +906,13 @@ "command": "Command:", "commandPlaceholder": "e.g., ls -alh /home/user", "errorCommandRequired": "Command cannot be empty", - "add": "Add" + "add": "Add", + "tags": "Tags:", + "tagsPlaceholder": "Select or create tags..." + }, + "untagged": "Untagged", + "tags": { + "clickToEditTag": "Click to edit tag name" } }, "setup": { diff --git a/packages/frontend/src/locales/ja-JP.json b/packages/frontend/src/locales/ja-JP.json index 015de9a..9f74caa 100644 --- a/packages/frontend/src/locales/ja-JP.json +++ b/packages/frontend/src/locales/ja-JP.json @@ -492,6 +492,8 @@ "empty": "クイックコマンドはありません。'+'ボタンをクリックして作成してください!", "form": { "add": "追加", + "tags": "タグ:", + "tagsPlaceholder": "タグを選択または作成...", "command": "コマンド:", "commandPlaceholder": "例:ls -alh /home/user", "errorCommandRequired": "コマンドは空にできません", @@ -500,6 +502,10 @@ "titleAdd": "クイックコマンドの追加", "titleEdit": "クイックコマンドの編集" }, + "untagged": "タグなし", + "tags": { + "clickToEditTag": "クリックしてタグ名を編集" + }, "searchPlaceholder": "名前またはコマンドを検索...", "sortByName": "名前", "sortByUsage": "使用頻度", diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index dea625a..a31c77c 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -909,7 +909,13 @@ "command": "指令:", "commandPlaceholder": "例如:ls -alh /home/user", "errorCommandRequired": "指令内容不能为空", - "add": "添加" + "add": "添加", + "tags": "标签:", + "tagsPlaceholder": "选择或创建标签..." + }, + "untagged": "未标记", + "tags": { + "clickToEditTag": "点击编辑标签名称" } }, "setup": { diff --git a/packages/frontend/src/stores/quickCommandTags.store.ts b/packages/frontend/src/stores/quickCommandTags.store.ts new file mode 100644 index 0000000..a869afb --- /dev/null +++ b/packages/frontend/src/stores/quickCommandTags.store.ts @@ -0,0 +1,152 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import apiClient from '../utils/apiClient'; +import { useUiNotificationsStore } from './uiNotifications.store'; + +// 定义快捷指令标签接口 (与后端 QuickCommandTag 对应) +export interface QuickCommandTag { + id: number; + name: string; + created_at: number; + updated_at: number; +} + +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); + if (cachedData) { + console.log('[QuickCmdTagStore] Loading quick command tags from cache.'); + tags.value = JSON.parse(cachedData); + isLoading.value = false; + } else { + isLoading.value = true; + } + } catch (e) { + console.error('[QuickCmdTagStore] Failed to load or parse cache:', e); + localStorage.removeItem(cacheKey); + isLoading.value = true; + } + + // 2. 后台获取最新数据 + isLoading.value = true; + try { + console.log('[QuickCmdTagStore] Fetching latest quick command tags from server...'); + // 使用新的 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) { + console.log('[QuickCmdTagStore] Tags data changed, updating state and cache.'); + tags.value = freshData; + localStorage.setItem(cacheKey, freshDataString); + } else { + console.log('[QuickCmdTagStore] Tags data is up-to-date.'); + } + error.value = null; + return true; + } catch (err: any) { + console.error('[QuickCmdTagStore] Failed to fetch tags:', err); + error.value = err.response?.data?.message || err.message || '获取快捷指令标签列表失败'; + if (error.value) { // Check if error.value is not null + uiNotificationsStore.showError(error.value); // 显示错误通知 + } + return false; + } finally { + isLoading.value = false; + } + } + + // 添加新快捷指令标签 (添加后清除缓存) + 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(); // 重新获取以更新列表 + uiNotificationsStore.showSuccess('快捷指令标签已添加'); + return newTag; + } catch (err: any) { + console.error('[QuickCmdTagStore] Failed to add tag:', err); + error.value = err.response?.data?.message || err.message || '添加快捷指令标签失败'; + if (error.value) { // Check if error.value is not null + uiNotificationsStore.showError(error.value); + } + return null; + } finally { + isLoading.value = false; + } + } + + // 更新快捷指令标签 + 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'); + await fetchTags(); + uiNotificationsStore.showSuccess('快捷指令标签已更新'); + return true; + } catch (err: any) { + console.error('[QuickCmdTagStore] Failed to update tag:', err); + error.value = err.response?.data?.message || err.message || '更新快捷指令标签失败'; + if (error.value) { // Check if error.value is not null + uiNotificationsStore.showError(error.value); + } + return false; + } finally { + isLoading.value = false; + } + } + + // 删除快捷指令标签 + async function deleteTag(id: number): Promise { + isLoading.value = true; + error.value = null; + try { + // 使用新的 API 端点 + await apiClient.delete(`/quick-command-tags/${id}`); + localStorage.removeItem('quickCommandTagsCache'); + await fetchTags(); + uiNotificationsStore.showSuccess('快捷指令标签已删除'); + return true; + } catch (err: any) { + console.error('[QuickCmdTagStore] Failed to delete tag:', err); + error.value = err.response?.data?.message || err.message || '删除快捷指令标签失败'; + if (error.value) { // Check if error.value is not null + uiNotificationsStore.showError(error.value); + } + return false; + } finally { + isLoading.value = false; + } + } + + return { + tags, + isLoading, + error, + fetchTags, + addTag, + updateTag, + deleteTag, + }; +}); \ 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 a4467a1..216a5c7 100644 --- a/packages/frontend/src/stores/quickCommands.store.ts +++ b/packages/frontend/src/stores/quickCommands.store.ts @@ -1,62 +1,224 @@ import { defineStore } from 'pinia'; import apiClient from '../utils/apiClient'; // 使用统一的 apiClient -import { ref, computed } from 'vue'; +import { ref, computed, watch } from 'vue'; // Import watch import { useUiNotificationsStore } from './uiNotifications.store'; -import type { QuickCommand } from '../types/quick-commands.types'; // 引入本地 QuickCommand 类型 +import { useQuickCommandTagsStore, type QuickCommandTag } from './quickCommandTags.store'; // +++ Import new tag store +++ +import { useI18n } from 'vue-i18n'; // +++ Import i18n for "Untagged" +++ +// Assuming QuickCommand type in types includes tagIds now, or define it here +// import type { QuickCommand } from '../types/quick-commands.types'; -// 定义前端使用的快捷指令接口 (可以与后端一致) -export type QuickCommandFE = QuickCommand; +// 定义前端使用的快捷指令接口 (包含 tagIds) +export interface QuickCommandFE { // Renamed from QuickCommand if necessary + id: number; + name: string | null; + command: string; + usage_count: number; + created_at: number; + updated_at: number; + tagIds: number[]; // +++ Add tagIds +++ +} // 定义排序类型 export type QuickCommandSortByType = 'name' | 'usage_count'; +// 定义分组后的数据结构 +export interface GroupedQuickCommands { + groupName: string; + tagId: number | null; // null for "Untagged" group + commands: QuickCommandFE[]; +} + +// +++ localStorage key for expanded groups +++ +const EXPANDED_GROUPS_STORAGE_KEY = 'quickCommandsExpandedGroups'; + export const useQuickCommandsStore = defineStore('quickCommands', () => { - const quickCommandsList = ref([]); + const quickCommandsList = ref([]); // Should now contain QuickCommandFE with tagIds const searchTerm = ref(''); const sortBy = ref('name'); // 默认按名称排序 const isLoading = ref(false); const error = ref(null); const uiNotificationsStore = useUiNotificationsStore(); - const selectedIndex = ref(-1); // NEW: Index of the selected command in the filtered list + 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 expandedGroups = ref>({}); // --- Getters --- - // 计算属性:根据搜索词过滤和排序指令 - const filteredAndSortedCommands = computed(() => { + // +++ 重写 Getter: 过滤、分组、排序指令 +++ + const filteredAndGroupedCommands = computed((): GroupedQuickCommands[] => { const term = searchTerm.value.toLowerCase().trim(); - let filtered = quickCommandsList.value; + const allTags = quickCommandTagsStore.tags; // 获取快捷指令专属标签 + const tagMap = new Map(allTags.map(tag => [tag.id, tag.name])); + const untaggedGroupName = t('quickCommands.untagged', '未标记'); // 获取 "未标记" 的翻译 + // 1. 过滤 (New logic: filter by command name, command content, OR tag name) + let filtered = quickCommandsList.value; if (term) { - filtered = filtered.filter(cmd => - (cmd.name && cmd.name.toLowerCase().includes(term)) || - cmd.command.toLowerCase().includes(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; + }); } - // Pinia store getter 中直接排序可能不是最佳实践,但这里为了简单起见先这样实现 - // 更好的方式可能是在 fetch 时就按需排序,或者在组件层排序 - // 注意:这里直接修改 ref 数组的顺序,如果需要在多处使用不同排序,需要创建副本 - // return [...filtered].sort((a, b) => { - // if (sortBy.value === 'usage_count') { - // // 按使用次数降序,次数相同按名称升序 - // if (b.usage_count !== a.usage_count) { - // return b.usage_count - a.usage_count; - // } - // } - // // 默认或次数相同时按名称升序 (null 名称排在前面) - // const nameA = a.name ?? ''; - // const nameB = b.name ?? ''; - // return nameA.localeCompare(nameB); - // }); - // **修正:Getter 不应修改原始数组,返回过滤后的即可,排序由 fetch 控制** - return filtered; + // 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; + } + }); + } + if (!isTagged) { + untaggedCommands.push(cmd); + } + }); + + // 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; + } + const nameA = a.name ?? a.command; // Fallback to command if name is null + const nameB = b.name ?? b.command; + return nameA.localeCompare(nameB); + }); + return { + groupName: groupName, + tagId: groupData.tagId, + commands: groupData.commands + }; + }); + + // 4. 处理未标记的分组 + if (untaggedCommands.length > 0) { + // 初始化展开状态 (如果未定义,默认为 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; + } + const nameA = a.name ?? a.command; + const nameB = b.name ?? b.command; + return nameA.localeCompare(nameB); + }); + result.push({ + groupName: untaggedGroupName, + tagId: null, + commands: untaggedCommands + }); + } + + return result; }); + // +++ 新增 Getter: 获取当前可见的扁平指令列表 (用于键盘导航) +++ + const flatVisibleCommands = computed((): QuickCommandFE[] => { + const flatList: QuickCommandFE[] = []; + filteredAndGroupedCommands.value.forEach(group => { + // 只添加已展开分组中的指令 + if (expandedGroups.value[group.groupName]) { + flatList.push(...group.commands); + } + }); + return flatList; + }); + + // --- Actions --- - // NEW: Action to select the next command in the filtered list + // +++ 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.'); + return; + } + } + } catch (e) { + console.error('[QuickCmdStore] Failed to load or parse expanded groups state:', e); + 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); + } + }; + + // +++ 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; + }; + + // Action to select the next command in the *visible* flat list const selectNextCommand = () => { - const commands = filteredAndSortedCommands.value; + const commands = flatVisibleCommands.value; // Use the flat visible list if (commands.length === 0) { selectedIndex.value = -1; return; @@ -64,9 +226,9 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => { selectedIndex.value = (selectedIndex.value + 1) % commands.length; }; - // NEW: Action to select the previous command in the filtered list + // Action to select the previous command in the *visible* flat list const selectPreviousCommand = () => { - const commands = filteredAndSortedCommands.value; + const commands = flatVisibleCommands.value; // Use the flat visible list if (commands.length === 0) { selectedIndex.value = -1; return; @@ -74,37 +236,48 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => { selectedIndex.value = (selectedIndex.value - 1 + commands.length) % commands.length; }; - // 从后端获取快捷指令 (带缓存和排序) + // 从后端获取快捷指令 (包含 tagIds,不再发送 sortBy) const fetchQuickCommands = async () => { - const cacheKey = 'quickCommandsCache'; - // 将排序方式加入缓存键,确保不同排序有不同缓存 - const cacheKeyWithSort = `${cacheKey}_${sortBy.value}`; - error.value = null; // 重置错误 + // 简化缓存:只缓存原始列表,不再区分排序 + const cacheKey = 'quickCommandsListCache'; + error.value = null; // 1. 尝试从 localStorage 加载缓存 try { - const cachedData = localStorage.getItem(cacheKeyWithSort); + const cachedData = localStorage.getItem(cacheKey); if (cachedData) { - console.log(`[QuickCmdStore] Loading commands from cache (sort: ${sortBy.value}).`); - quickCommandsList.value = JSON.parse(cachedData); - isLoading.value = false; // 先显示缓存 + console.log(`[QuickCmdStore] Loading commands from cache.`); + // 确保解析后的数据符合 QuickCommandFE 结构 (特别是 tagIds) + const parsedData = JSON.parse(cachedData) as QuickCommandFE[]; + // 基本验证,确保 tagIds 是数组 + if (Array.isArray(parsedData) && parsedData.every(item => Array.isArray(item.tagIds))) { + quickCommandsList.value = parsedData; + isLoading.value = false; + } else { + console.warn('[QuickCmdStore] Cached data format invalid, ignoring cache.'); + localStorage.removeItem(cacheKey); + isLoading.value = true; + } } else { - isLoading.value = true; // 无缓存,初始加载 + isLoading.value = true; } } catch (e) { console.error('[QuickCmdStore] Failed to load or parse commands cache:', e); - localStorage.removeItem(cacheKeyWithSort); // 解析失败则移除缓存 - isLoading.value = true; // 缓存无效,需要加载 + localStorage.removeItem(cacheKey); + isLoading.value = true; } // 2. 后台获取最新数据 - isLoading.value = true; // 标记正在后台获取 + isLoading.value = true; try { - console.log(`[QuickCmdStore] Fetching latest commands from server (sort: ${sortBy.value})...`); - const response = await apiClient.get('/quick-commands', { - params: { sortBy: sortBy.value } - }); - const freshData = response.data; + console.log(`[QuickCmdStore] Fetching latest commands from server...`); + // 不再发送 sortBy 参数 + const response = await apiClient.get('/quick-commands'); + // 确保返回的数据包含 tagIds 数组 + const freshData = response.data.map(cmd => ({ + ...cmd, + tagIds: Array.isArray(cmd.tagIds) ? cmd.tagIds : [] // 确保 tagIds 是数组 + })); const freshDataString = JSON.stringify(freshData); // 3. 对比并更新 @@ -112,37 +285,37 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => { if (currentDataString !== freshDataString) { console.log('[QuickCmdStore] Commands data changed, updating state and cache.'); quickCommandsList.value = freshData; - localStorage.setItem(cacheKeyWithSort, freshDataString); // 更新对应排序的缓存 + localStorage.setItem(cacheKey, freshDataString); // 更新缓存 } else { console.log('[QuickCmdStore] Commands data is up-to-date.'); } - error.value = null; // 清除错误 + error.value = null; } catch (err: any) { console.error('[QuickCmdStore] 获取快捷指令失败:', err); error.value = err.response?.data?.message || '获取快捷指令时发生错误'; - // 保留缓存数据,仅设置错误状态 - uiNotificationsStore.showError(error.value ?? '未知错误'); + if (error.value) { + uiNotificationsStore.showError(error.value); + } } finally { - isLoading.value = false; // 加载完成 + isLoading.value = false; } }; - // 清除所有排序的快捷指令缓存 + // 清除快捷指令列表缓存 const clearQuickCommandsCache = () => { - const cacheKeyBase = 'quickCommandsCache'; - // 移除两种排序的缓存 - localStorage.removeItem(`${cacheKeyBase}_name`); - localStorage.removeItem(`${cacheKeyBase}_usage_count`); - console.log('[QuickCmdStore] Cleared all quick commands caches.'); + localStorage.removeItem('quickCommandsListCache'); + console.log('[QuickCmdStore] Cleared quick commands list cache.'); }; - // 添加快捷指令 (添加后清除缓存) - const addQuickCommand = async (name: string | null, command: string): Promise => { + // 添加快捷指令 (发送 tagIds) + const addQuickCommand = async (name: string | null, command: string, tagIds?: number[]): Promise => { try { - await apiClient.post('/quick-commands', { name, command }); - clearQuickCommandsCache(); // 清除所有排序缓存 - await fetchQuickCommands(); // 刷新当前排序的列表和缓存 + // 在请求体中包含 tagIds + const response = await apiClient.post<{ message: string, command: QuickCommandFE }>('/quick-commands', { name, command, tagIds }); + // 后端现在返回完整的 command 对象,可以直接使用或触发刷新 + clearQuickCommandsCache(); // 清除缓存 + await fetchQuickCommands(); // 重新获取以确保数据同步 uiNotificationsStore.showSuccess('快捷指令已添加'); return true; } catch (err: any) { @@ -153,12 +326,14 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => { } }; - // 更新快捷指令 - const updateQuickCommand = async (id: number, name: string | null, command: string): Promise => { + // 更新快捷指令 (发送 tagIds) + const updateQuickCommand = async (id: number, name: string | null, command: string, tagIds?: number[]): Promise => { try { - await apiClient.put(`/quick-commands/${id}`, { name, command }); - clearQuickCommandsCache(); // 清除所有排序缓存 - await fetchQuickCommands(); // 刷新当前排序的列表和缓存 + // 在请求体中包含 tagIds (即使是 undefined 也要发送,让后端知道是否要更新) + const response = await apiClient.put<{ message: string, command: QuickCommandFE }>(`/quick-commands/${id}`, { name, command, tagIds }); + // 后端现在返回完整的 command 对象 + clearQuickCommandsCache(); // 清除缓存 + await fetchQuickCommands(); // 重新获取以确保数据同步 uiNotificationsStore.showSuccess('快捷指令已更新'); return true; } catch (err: any) { @@ -214,12 +389,12 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => { selectedIndex.value = -1; // Reset selection when search term changes }; - // 设置排序方式并重新获取数据 - const setSortBy = async (newSortBy: QuickCommandSortByType) => { + // 设置排序方式 (只更新本地状态,不再重新获取数据) + const setSortBy = (newSortBy: QuickCommandSortByType) => { if (sortBy.value !== newSortBy) { sortBy.value = newSortBy; - // 排序方式改变,不需要清除缓存,fetchQuickCommands 会读取对应排序的缓存或重新获取 - await fetchQuickCommands(); + // 排序现在由 filteredAndGroupedCommands getter 处理,无需重新 fetch + selectedIndex.value = -1; // Reset selection when sort changes } }; @@ -236,8 +411,10 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => { sortBy, isLoading, error, - filteredAndSortedCommands, // 使用计算属性 - selectedIndex, // NEW: Expose selected index + 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 fetchQuickCommands, addQuickCommand, updateQuickCommand, @@ -245,8 +422,64 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => { incrementUsage, setSearchTerm, setSortBy, - selectNextCommand, // NEW: Expose action - selectPreviousCommand, // NEW: Expose action - resetSelection, // Ensure resetSelection is exported + 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.`); + // --- End manual state update --- + + // 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; + } + }, }; }); diff --git a/packages/frontend/src/views/QuickCommandsView.vue b/packages/frontend/src/views/QuickCommandsView.vue index 54d145e..febc417 100644 --- a/packages/frontend/src/views/QuickCommandsView.vue +++ b/packages/frontend/src/views/QuickCommandsView.vue @@ -26,49 +26,89 @@
- - -
- -

{{ t('common.loading', '加载中...') }}

+ +
+ +

{{ t('common.loading', '加载中...') }}

- -
- -

{{ $t('quickCommands.empty', '没有快捷指令。') }}

- -
- -
    -
  • - -
    - {{ cmd.name }} - {{ cmd.command }} -
    - -
    - - {{ cmd.usage_count }} - - - - -
    -
  • -
+
+ +
+
+ + +
+ + + + + + {{ groupData.groupName }} + + + +
+ +
    +
  • + +
    + {{ cmd.name }} + {{ cmd.command }} +
    + +
    + {{ cmd.usage_count }} + + +
    +
  • +
+
+
@@ -84,13 +124,15 @@ diff --git a/quick-commands-independent-tags-plan.md b/quick-commands-independent-tags-plan.md new file mode 100644 index 0000000..a515b14 --- /dev/null +++ b/quick-commands-independent-tags-plan.md @@ -0,0 +1,106 @@ +# Plan: Implement Independent Tags for Quick Commands + +This plan outlines the steps to add a tagging and grouping feature for Quick Commands, ensuring the tag system is completely separate from the existing tag system used for SSH Connections. + +## 1. Database Migration (`packages/backend/src/database/migrations.ts`) + +* **Goal:** Add two new tables to the database schema to manage quick command specific tags and their associations. +* **Steps:** + * Analyze `migrations.ts` to understand the current migration process (Done - uses versioned migrations). + * Add a new migration (e.g., `id: 2`, `name: 'Create quick_command_tags table'`): + * SQL: `CREATE TABLE IF NOT EXISTS quick_command_tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')));` + * Include a `check` function using `tableExists(db, 'quick_command_tags')`. + * Add another new migration (e.g., `id: 3`, `name: 'Create quick_command_tag_associations table'`): + * SQL: `CREATE TABLE IF NOT EXISTS quick_command_tag_associations (quick_command_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, 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);` + * Include a `check` function using `tableExists(db, 'quick_command_tag_associations')`. + +## 2. Database Schema (`packages/backend/src/database/schema.ts`) + +* **Goal:** Keep schema definitions consistent. +* **Steps:** + * Add `createQuickCommandTagsTableSQL` constant with the SQL from Migration 2. + * Add `createQuickCommandTagAssociationsTableSQL` constant with the SQL from Migration 3. + +## 3. Backend (`packages/backend`) + +* **Goal:** Create dedicated modules for managing quick command tags and integrate them with the existing quick command logic. + +* **New Module: Quick Command Tags** + * **`repositories/quick-command-tag.repository.ts`:** + * Define `QuickCommandTag` interface (`id`, `name`, `created_at`, `updated_at`). + * Implement CRUD functions for `quick_command_tags` table (`findAll`, `findById`, `create`, `update`, `delete`). + * Implement functions to manage `quick_command_tag_associations`: + * `setCommandTagAssociations(commandId: number, tagIds: number[]): Promise` (Transactional: delete old, insert new). + * `findTagsByCommandId(commandId: number): Promise` (JOIN `quick_command_tag_associations` and `quick_command_tags`). + * (Optional) `addTagAssociation`, `removeTagAssociation`. + * **`services/quick-command-tag.service.ts`:** + * Inject `QuickCommandTagRepository`. + * Implement business logic for managing quick command tags (validation, orchestration). + * **`quick-command-tags/quick-command-tag.controller.ts` & `quick-command-tags/quick-command-tag.routes.ts`:** + * Create new API endpoints (e.g., `POST /quick-command-tags`, `GET /quick-command-tags`, `PUT /quick-command-tags/:id`, `DELETE /quick-command-tags/:id`). + * Inject `QuickCommandTagService`. + * Handle HTTP requests/responses. + +* **Modify Module: Quick Commands** + * **`repositories/quick-commands.repository.ts`:** + * Update `QuickCommand` interface (or create `QuickCommandWithTags`) to include `tagIds: number[]` (referencing `quick_command_tags.id`). + * Modify `getAllQuickCommands` and `findQuickCommandById`: Use `LEFT JOIN quick_command_tag_associations` and `GROUP_CONCAT` to fetch `tag_ids_str`, parse into `tagIds` array. + * **`services/quick-commands.service.ts`:** + * Inject **new** `QuickCommandTagRepository` or `QuickCommandTagService`. + * Modify `addQuickCommand` signature: accept `tagIds?: number[]`. Implementation: call repo `addQuickCommand`, then call **new** repo/service `setCommandTagAssociations`. + * Modify `updateQuickCommand` signature: accept `tagIds?: number[]`. Implementation: call repo `updateQuickCommand`, then call **new** repo/service `setCommandTagAssociations`. + * Modify `getAllQuickCommands` return type to include `tagIds`. + * **`quick-commands/quick-commands.controller.ts`:** + * Modify `addQuickCommand` and `updateQuickCommand` request validation to accept optional `tagIds: number[]`. + * Pass `tagIds` to the service layer. + * Ensure `getAllQuickCommands` response includes `tagIds`. + +## 4. Frontend (`packages/frontend`) + +* **Goal:** Create a dedicated state management for quick command tags and update the UI to display grouped commands and allow tag selection. + +* **Types (`types/quick-commands.types.ts` or similar):** + * Update `QuickCommand` interface to include `tagIds: number[]`. + +* **New Store (`stores/quickCommandTags.store.ts`):** + * Define `QuickCommandTag` interface (matching backend). + * State: `tags: ref`, `isLoading`, `error`. + * Actions: `fetchTags` (from `/quick-command-tags`), `addTag`, `updateTag`, `deleteTag`. Include caching logic if desired. + +* **Modify Store (`stores/quickCommands.store.ts`):** + * Inject **new** `useQuickCommandTagsStore`. + * State: Add `expandedGroups: ref>({})`. + * Actions: + * Add `toggleGroup(groupName: string)` action (manage `expandedGroups`, potentially save to localStorage). + * Modify `fetchQuickCommands`: Fetch data including `tagIds` from `/quick-commands`. Remove `sortBy` API parameter. + * Modify `addQuickCommand`, `updateQuickCommand`: Send `tagIds` in the request body. + * Modify `incrementUsage`: Adjust logic if sorting is now purely frontend within groups. + * Getters: + * **Rewrite** `filteredAndSortedCommands` (or create `filteredAndGroupedCommands`): + * Filter `quickCommandsList` by `searchTerm`. + * Use `quickCommandTagsStore.tags` to group filtered commands by `tagIds` (create "Untagged" group). + * Sort commands *within* each group based on `sortBy` state. + * Sort groups (e.g., alphabetically). + * Return nested structure: `Array<{ groupName: string; tagId: number | null; commands: QuickCommandFE[] }>`. + * **Add** `flatVisibleCommands`: Compute a flat list of commands only from currently expanded groups. + * Keyboard Navigation Actions (`selectNextCommand`, `selectPreviousCommand`): Modify to operate on `flatVisibleCommands` and `selectedIndex`. + * Caching: Re-evaluate or simplify caching strategy. + +* **Modify View (`views/QuickCommandsView.vue`):** + * `