diff --git a/packages/backend/src/database/migrations.ts b/packages/backend/src/database/migrations.ts index a3cfc55..117edb8 100644 --- a/packages/backend/src/database/migrations.ts +++ b/packages/backend/src/database/migrations.ts @@ -123,7 +123,6 @@ const definedMigrations: Migration[] = [ name: 'Add notes column to connections table', check: async (db: Database): Promise => { const notesColumnExists = await columnExists(db, 'connections', 'notes'); - // Only run if the column does NOT exist return !notesColumnExists; }, sql: ` @@ -297,6 +296,17 @@ const definedMigrations: Migration[] = [ return !jumpChainColumnExists || !proxyTypeColumnExists; } }, + { + id: 10, + name: 'Add variables column to quick_commands table', + check: async (db: Database): Promise => { + const columnAlreadyExists = await columnExists(db, 'quick_commands', 'variables'); + return !columnAlreadyExists; + }, + sql: ` + ALTER TABLE quick_commands ADD COLUMN variables TEXT NULL; + ` + } ]; /** diff --git a/packages/backend/src/database/schema.ts b/packages/backend/src/database/schema.ts index 6a765b5..89bd002 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -166,6 +166,7 @@ CREATE TABLE IF NOT EXISTS quick_commands ( name TEXT NULL, -- 名称可选 command TEXT NOT NULL, -- 指令必选 usage_count INTEGER NOT NULL DEFAULT 0, -- 使用频率 + variables TEXT NULL, created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) ); diff --git a/packages/backend/src/quick-commands/quick-commands.controller.ts b/packages/backend/src/quick-commands/quick-commands.controller.ts index 6862063..d605fd9 100644 --- a/packages/backend/src/quick-commands/quick-commands.controller.ts +++ b/packages/backend/src/quick-commands/quick-commands.controller.ts @@ -6,8 +6,8 @@ import { QuickCommandSortBy } from '../services/quick-commands.service'; * 处理添加新快捷指令的请求 */ export const addQuickCommand = async (req: Request, res: Response): Promise => { - // 从请求体中解构出 name, command, 以及可选的 tagIds - const { name, command, tagIds } = req.body; + // 从请求体中解构出 name, command, 以及可选的 tagIds 和 variables + const { name, command, tagIds, variables } = req.body; // --- 基本验证 --- if (!command || typeof command !== 'string' || command.trim().length === 0) { @@ -24,11 +24,16 @@ export const addQuickCommand = async (req: Request, res: Response): Promise => { const id = parseInt(req.params.id, 10); - // 从请求体中解构出 name, command, 以及可选的 tagIds - const { name, command, tagIds } = req.body; + // 从请求体中解构出 name, command, 以及可选的 tagIds 和 variables + const { name, command, tagIds, variables } = req.body; // --- 基本验证 --- if (isNaN(id)) { @@ -87,11 +92,16 @@ export const updateQuickCommand = async (req: Request, res: Response): Promise & { tagIds: number[]; -} + variables: Record | null; // API 层面使用对象 +}; // 用于从数据库获取带 tag_ids_str 的行 interface DbQuickCommandWithTagsRow extends QuickCommand { tag_ids_str: string | null; + // variables 字段已包含在 QuickCommand 中,这里不需要重复定义,因为 QuickCommand 将包含 variables?: string } @@ -25,13 +28,15 @@ interface DbQuickCommandWithTagsRow extends QuickCommand { * 添加一条新的快捷指令 * @param name - 指令名称 (可选) * @param command - 指令内容 + * @param variables - 变量对象 (可选) * @returns 返回插入记录的 ID */ -export const addQuickCommand = async (name: string | null, command: string): Promise => { - const sql = `INSERT INTO quick_commands (name, command, created_at, updated_at) VALUES (?, ?, strftime('%s', 'now'), strftime('%s', 'now'))`; +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'))`; try { const db = await getDbInstance(); - const result = await runDb(db, sql, [name, command]); + const variablesJson = variables ? JSON.stringify(variables) : null; + const result = await runDb(db, sql, [name, command, variablesJson]); if (typeof result.lastID !== 'number' || result.lastID <= 0) { throw new Error('添加快捷指令后未能获取有效的 lastID'); } @@ -47,13 +52,15 @@ export const addQuickCommand = async (name: string | null, command: string): Pro * @param id - 要更新的记录 ID * @param name - 新的指令名称 (可选) * @param command - 新的指令内容 + * @param variables - 新的变量对象 (可选) * @returns 返回是否成功更新 (true/false) */ -export const updateQuickCommand = async (id: number, name: string | null, command: string): Promise => { - const sql = `UPDATE quick_commands SET name = ?, command = ?, updated_at = strftime('%s', 'now') WHERE id = ?`; +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 = ?`; try { const db = await getDbInstance(); - const result = await runDb(db, sql, [name, command, id]); + const variablesJson = variables ? JSON.stringify(variables) : null; + const result = await runDb(db, sql, [name, command, variablesJson, id]); return result.changes > 0; } catch (err: any) { console.error('更新快捷指令时出错:', err.message); @@ -91,7 +98,7 @@ export const getAllQuickCommands = async (sortBy: 'name' | 'usage_count' = 'name // 使用 LEFT JOIN 连接关联表,并使用 GROUP_CONCAT 获取标签 ID 字符串 const sql = ` SELECT - qc.id, qc.name, qc.command, qc.usage_count, qc.created_at, qc.updated_at, + 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 @@ -100,11 +107,24 @@ export const getAllQuickCommands = async (sortBy: 'name' | 'usage_count' = 'name try { const db = await getDbInstance(); 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)) : [] - })); + // 将 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)) : [] + }; + }); } catch (err: any) { console.error('获取快捷指令(带标签)时出错:', err.message); throw new Error('无法获取快捷指令'); @@ -137,7 +157,7 @@ export const findQuickCommandById = async (id: number): Promise(db, sql, [id]); if (row && typeof row.id !== 'undefined') { - // 将 tag_ids_str 解析为数字数组 + // 将 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 { - ...row, + ...restOfRow, + variables: parsedVariables, tagIds: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number).filter(id => !isNaN(id)) : [] }; } else { diff --git a/packages/backend/src/services/quick-commands.service.ts b/packages/backend/src/services/quick-commands.service.ts index 4a02cbf..95d3f29 100644 --- a/packages/backend/src/services/quick-commands.service.ts +++ b/packages/backend/src/services/quick-commands.service.ts @@ -10,15 +10,16 @@ export type QuickCommandSortBy = '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[]): 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()); + const commandId = await QuickCommandsRepository.addQuickCommand(finalName, command.trim(), variables); // 添加成功后,设置标签关联 if (commandId > 0 && tagIds && Array.isArray(tagIds)) { @@ -39,14 +40,15 @@ export const addQuickCommand = async (name: string | null, command: string, tagI * @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[]): 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()); + const commandUpdated = await QuickCommandsRepository.updateQuickCommand(id, finalName, command.trim(), variables); // 如果指令更新成功,并且提供了 tagIds (即使是空数组也表示要更新),则更新标签关联 if (commandUpdated && typeof tagIds !== 'undefined') { diff --git a/packages/frontend/src/components/AddEditQuickCommandForm.vue b/packages/frontend/src/components/AddEditQuickCommandForm.vue index aad61b0..e940187 100644 --- a/packages/frontend/src/components/AddEditQuickCommandForm.vue +++ b/packages/frontend/src/components/AddEditQuickCommandForm.vue @@ -1,31 +1,74 @@ + diff --git a/packages/frontend/src/composables/useResizable.ts b/packages/frontend/src/composables/useResizable.ts new file mode 100644 index 0000000..44aa7ea --- /dev/null +++ b/packages/frontend/src/composables/useResizable.ts @@ -0,0 +1,204 @@ +import { ref, onMounted, onBeforeUnmount, type Ref, watch } from 'vue'; + +interface UseResizableOptions { + minWidth?: number; + minHeight?: number; + maxWidth?: number; + maxHeight?: number; + edgeThreshold?: number; // How close to an edge to consider it a drag handle + initialWidth?: number | string; // Allow string for % or vh/vw, or number for px + initialHeight?: number | string; // Allow string for % or vh/vw, or number for px +} + +type Edge = 'right' | 'bottom' | 'left' | 'top' | 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | null; + +export function useResizable( + elementRef: Ref, + options: UseResizableOptions = {} +) { + const { + minWidth = 100, // Default min width + minHeight = 100, // Default min height + maxWidth = Infinity, + maxHeight = Infinity, + edgeThreshold = 8, // pixels, sensitivity for edge detection + } = options; + + const width = ref(null); + const height = ref(null); + const isResizing = ref(false); + const currentEdge = ref(null); + + let startX = 0; + let startY = 0; + let startWidth = 0; + let startHeight = 0; + + const getEdge = (event: MouseEvent, el: HTMLElement): Edge => { + const rect = el.getBoundingClientRect(); + const { clientX, clientY } = event; + + // Check corners first + const onRight = Math.abs(clientX - rect.right) < edgeThreshold; + const onLeft = Math.abs(clientX - rect.left) < edgeThreshold; + const onBottom = Math.abs(clientY - rect.bottom) < edgeThreshold; + const onTop = Math.abs(clientY - rect.top) < edgeThreshold; + + if (onRight && onBottom) return 'bottom-right'; + if (onLeft && onBottom) return 'bottom-left'; + if (onRight && onTop) return 'top-right'; + if (onLeft && onTop) return 'top-left'; + if (onRight) return 'right'; + if (onLeft) return 'left'; + if (onBottom) return 'bottom'; + if (onTop) return 'top'; + + return null; + }; + + const updateCursorStyle = (el: HTMLElement, edge: Edge) => { + if (edge === 'left' || edge === 'right') el.style.cursor = 'ew-resize'; + else if (edge === 'top' || edge === 'bottom') el.style.cursor = 'ns-resize'; + else if (edge === 'top-left' || edge === 'bottom-right') el.style.cursor = 'nwse-resize'; + else if (edge === 'top-right' || edge === 'bottom-left') el.style.cursor = 'nesw-resize'; + else el.style.cursor = 'default'; + }; + + const handleMouseDown = (event: MouseEvent) => { + if (!elementRef.value) return; + const edge = getEdge(event, elementRef.value); + + if (!edge) return; + event.preventDefault(); // Prevent text selection, etc. + + isResizing.value = true; + currentEdge.value = edge; + startX = event.clientX; + startY = event.clientY; + + // Ensure width and height refs have current dimensions + const rect = elementRef.value.getBoundingClientRect(); + startWidth = rect.width; + startHeight = rect.height; + width.value = startWidth; + height.value = startHeight; + + elementRef.value.style.userSelect = 'none'; // Prevent text selection + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + }; + + const handleMouseMove = (event: MouseEvent) => { + if (!isResizing.value || !elementRef.value || !currentEdge.value) return; + event.preventDefault(); + + const deltaX = event.clientX - startX; + const deltaY = event.clientY - startY; + + let newWidth = width.value ?? startWidth; + let newHeight = height.value ?? startHeight; + + if (currentEdge.value.includes('right')) { + newWidth = startWidth + deltaX; + } + if (currentEdge.value.includes('left')) { + newWidth = startWidth - deltaX; + } + if (currentEdge.value.includes('bottom')) { + newHeight = startHeight + deltaY; + } + if (currentEdge.value.includes('top')) { + newHeight = startHeight - deltaY; + } + + // Apply constraints + width.value = Math.max(minWidth, Math.min(maxWidth, newWidth)); + height.value = Math.max(minHeight, Math.min(maxHeight, newHeight)); + }; + + const handleMouseUp = () => { + if (!isResizing.value) return; + isResizing.value = false; + if (elementRef.value) { + elementRef.value.style.userSelect = ''; + updateCursorStyle(elementRef.value, null); // Reset to default or hover state + } + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + + const handleElementHover = (event: MouseEvent) => { + if (!elementRef.value || isResizing.value) return; + const edge = getEdge(event, elementRef.value); + updateCursorStyle(elementRef.value, edge); + }; + + onMounted(() => { + if (elementRef.value) { + const el = elementRef.value; + // Initialize width and height from element's current computed size + // This ensures that initial CSS (like %, vw, vh, or fixed values) is respected + const computedStyle = window.getComputedStyle(el); + let parsedWidth = parseFloat(computedStyle.width); + let parsedHeight = parseFloat(computedStyle.height); + + // Fallback to minWidth/minHeight if parsing results in NaN, or ensure value is at least minWidth/minHeight + width.value = isNaN(parsedWidth) ? minWidth : Math.max(minWidth, parsedWidth); + height.value = isNaN(parsedHeight) ? minHeight : Math.max(minHeight, parsedHeight); + + el.addEventListener('mousedown', handleMouseDown); + el.addEventListener('mousemove', handleElementHover); // For cursor changes + // Reset cursor when mouse leaves the element + el.addEventListener('mouseleave', () => { + if (!isResizing.value && el) { + el.style.cursor = 'default'; + } + }); + } + }); + + onBeforeUnmount(() => { + if (elementRef.value) { + elementRef.value.removeEventListener('mousedown', handleMouseDown); + elementRef.value.removeEventListener('mousemove', handleElementHover); + elementRef.value.removeEventListener('mouseleave', () => { + if (elementRef.value) elementRef.value.style.cursor = 'default'; + }); + } + window.removeEventListener('mousemove', handleMouseMove); // Cleanup just in case + window.removeEventListener('mouseup', handleMouseUp); // Cleanup just in case + }); + + // Watch for external changes to elementRef if it can become null + watch(elementRef, (newEl, oldEl) => { + if (oldEl) { + oldEl.removeEventListener('mousedown', handleMouseDown); + oldEl.removeEventListener('mousemove', handleElementHover); + oldEl.removeEventListener('mouseleave', () => { + if (oldEl) oldEl.style.cursor = 'default'; + }); + } + if (newEl) { + const computedStyle = window.getComputedStyle(newEl); + let parsedWidth = parseFloat(computedStyle.width); + let parsedHeight = parseFloat(computedStyle.height); + + // Fallback to minWidth/minHeight if parsing results in NaN, or ensure value is at least minWidth/minHeight + width.value = isNaN(parsedWidth) ? minWidth : Math.max(minWidth, parsedWidth); + height.value = isNaN(parsedHeight) ? minHeight : Math.max(minHeight, parsedHeight); + + newEl.addEventListener('mousedown', handleMouseDown); + newEl.addEventListener('mousemove', handleElementHover); + newEl.addEventListener('mouseleave', () => { + if (newEl && !isResizing.value) newEl.style.cursor = 'default'; + }); + } + }); + + return { + width, + height, + isResizing, + }; +} \ No newline at end of file diff --git a/packages/frontend/src/composables/workspaceEvents.ts b/packages/frontend/src/composables/workspaceEvents.ts index 071413d..0f0555b 100644 --- a/packages/frontend/src/composables/workspaceEvents.ts +++ b/packages/frontend/src/composables/workspaceEvents.ts @@ -53,6 +53,9 @@ export type WorkspaceEventPayloads = { // Suspended SSH Session Events 'suspendedSession:actionCompleted': void; // Emitted when a resume/remove action is completed + + // Quick Command Events + 'quickCommand:executeProcessed': { command: string; sessionId?: string }; }; // 创建 mitt 事件发射器实例 diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index e6be4fa..755aa04 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -606,7 +606,8 @@ "createSuccess": "Tag created successfully.", "updateSuccess": "Tag updated successfully.", "deleteSuccess": "Tag \"{name}\" deleted successfully.", - "deleteFailed": "Failed to delete tag \"{name}\": {error}" + "deleteFailed": "Failed to delete tag \"{name}\": {error}", + "errorDelete": "Error deleting tag: {error}" }, "settings": { "popupFileManager": { @@ -1041,6 +1042,7 @@ "testMessageUnsaved": "Test triggered for unsaved {channelType} configuration" }, "common": { + "confirm": "Confirm", "ok": "OK", "success": "Success", "error": "Error", @@ -1293,11 +1295,19 @@ "name": "Name:", "namePlaceholder": "Optional, for quick identification", "command": "Command:", - "commandPlaceholder": "e.g., ls -alh /home/user", + "commandPlaceholder": "e.g.,", "errorCommandRequired": "Command cannot be empty", "add": "Add", "tags": "Tags:", - "tagsPlaceholder": "Select or create tags..." + "tagsPlaceholder": "Select or create tags...", + "variablesTitle": "Variable Management", + "noVariables": "No variables yet. Click the button below to add one.", + "variableNamePlaceholder": "Variable Name", + "variableValuePlaceholder": "Variable Value", + "addVariable": "+ Add Variable", + "execute": "Execute", + "warningUndefinedVariables": "Warning: Undefined variables in command template: {variables}", + "errorNoActiveSession": "No active SSH session to execute the command." }, "untagged": "Untagged", "tags": { @@ -1626,3 +1636,4 @@ "copiedError": "Failed to copy path" } } + diff --git a/packages/frontend/src/locales/ja-JP.json b/packages/frontend/src/locales/ja-JP.json index 0d90151..236d658 100644 --- a/packages/frontend/src/locales/ja-JP.json +++ b/packages/frontend/src/locales/ja-JP.json @@ -78,6 +78,7 @@ "clearTerminal": "ターミナルをクリア" }, "common": { + "confirm": "確認", "ok": "確認", "success": "成功", "error": "失敗", @@ -683,12 +684,20 @@ "tags": "タグ:", "tagsPlaceholder": "タグを選択または作成...", "command": "コマンド:", - "commandPlaceholder": "例:ls -alh /home/user", + "commandPlaceholder": "例:", "errorCommandRequired": "コマンドは空にできません", "name": "名前:", "namePlaceholder": "オプション。素早く認識するために使用", "titleAdd": "クイックコマンドの追加", - "titleEdit": "クイックコマンドの編集" + "titleEdit": "クイックコマンドの編集", + "variablesTitle": "変数管理", + "noVariables": "変数はまだありません。下のボタンをクリックして追加してください。", + "variableNamePlaceholder": "変数名", + "variableValuePlaceholder": "変数値", + "addVariable": "+ 変数を追加", + "execute": "実行", + "warningUndefinedVariables": "警告:コマンドテンプレートに未定義の変数があります: {variables}", + "errorNoActiveSession": "コマンドを実行するためのアクティブなSSHセッションがありません。" }, "untagged": "タグなし", "tags": { @@ -1373,7 +1382,8 @@ "createSuccess": "タグが正常に作成されました。", "updateSuccess": "タグが正常に更新されました。", "deleteSuccess": "タグ「{name}」が正常に削除されました。", - "deleteFailed": "タグ「{name}」の削除に失敗しました: {error}" + "deleteFailed": "タグ「{name}」の削除に失敗しました: {error}", + "errorDelete": "タグの削除中にエラーが発生しました: {error}" }, "terminalTabBar": { "selectServerTitle": "接続するサーバーを選択", diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index 032eeca..153e98a 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -606,7 +606,8 @@ "createSuccess": "标签创建成功。", "updateSuccess": "标签更新成功。", "deleteSuccess": "标签 \"{name}\" 删除成功。", - "deleteFailed": "标签 \"{name}\" 删除失败: {error}" + "deleteFailed": "标签 \"{name}\" 删除失败: {error}", + "errorDelete": "删除标签时出错: {error}" }, "settings": { "popupFileManager": { @@ -1077,6 +1078,7 @@ "success":"成功", "error":"失败", "alert":"提示", + "confirm":"确认", "updateSuccess":"更新成功" }, "layoutConfigurator": { @@ -1297,11 +1299,19 @@ "name": "名称:", "namePlaceholder": "可选,用于快速识别", "command": "指令:", - "commandPlaceholder": "例如:ls -alh /home/user", + "commandPlaceholder": "例如:", "errorCommandRequired": "指令内容不能为空", "add": "添加", "tags": "标签:", - "tagsPlaceholder": "选择或创建标签..." + "tagsPlaceholder": "选择或创建标签...", + "variablesTitle": "变量管理", + "noVariables": "暂无变量。点击下方按钮添加。", + "variableNamePlaceholder": "变量名", + "variableValuePlaceholder": "变量值", + "addVariable": "+ 添加变量", + "execute": "执行", + "warningUndefinedVariables": "警告:指令模板中存在未定义的变量: {variables}", + "errorNoActiveSession": "没有活动的SSH会话可执行指令。" }, "untagged": "未标记", "tags": { diff --git a/packages/frontend/src/stores/quickCommands.store.ts b/packages/frontend/src/stores/quickCommands.store.ts index 9ed2e54..05866e1 100644 --- a/packages/frontend/src/stores/quickCommands.store.ts +++ b/packages/frontend/src/stores/quickCommands.store.ts @@ -16,6 +16,7 @@ export interface QuickCommandFE { // Renamed from QuickCommand if necessary created_at: number; updated_at: number; tagIds: number[]; // +++ Add tagIds +++ + variables?: Record; // New: Add variables } // 定义排序类型 @@ -250,10 +251,10 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => { try { const cachedData = localStorage.getItem(cacheKey); if (cachedData) { - // 确保解析后的数据符合 QuickCommandFE 结构 (特别是 tagIds) + // 确保解析后的数据符合 QuickCommandFE 结构 (特别是 tagIds 和 variables) const parsedData = JSON.parse(cachedData) as QuickCommandFE[]; - // 基本验证,确保 tagIds 是数组 - if (Array.isArray(parsedData) && parsedData.every(item => Array.isArray(item.tagIds))) { + // 基本验证,确保 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 { @@ -276,10 +277,11 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => { console.log(`[QuickCmdStore] Fetching latest commands from server...`); // 不再发送 sortBy 参数 const response = await apiClient.get('/quick-commands'); - // 确保返回的数据包含 tagIds 数组 + // 确保返回的数据包含 tagIds 数组和 variables 对象 const freshData = response.data.map(cmd => ({ ...cmd, - tagIds: Array.isArray(cmd.tagIds) ? cmd.tagIds : [] // 确保 tagIds 是数组 + tagIds: Array.isArray(cmd.tagIds) ? cmd.tagIds : [], // 确保 tagIds 是数组 + variables: typeof cmd.variables === 'object' ? cmd.variables : undefined // 确保 variables 是对象或 undefined })); const freshDataString = JSON.stringify(freshData); @@ -310,11 +312,11 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => { }; - // 添加快捷指令 (发送 tagIds) - const addQuickCommand = async (name: string | null, command: string, tagIds?: number[]): Promise => { + // 添加快捷指令 (发送 tagIds 和 variables) + const addQuickCommand = async (name: string | null, command: string, tagIds?: number[], variables?: Record): Promise => { try { - // 在请求体中包含 tagIds - const response = await apiClient.post<{ message: string, command: QuickCommandFE }>('/quick-commands', { name, command, tagIds }); + // 在请求体中包含 tagIds 和 variables + const response = await apiClient.post<{ message: string, command: QuickCommandFE }>('/quick-commands', { name, command, tagIds, variables }); // 后端现在返回完整的 command 对象,可以直接使用或触发刷新 clearQuickCommandsCache(); // 清除缓存 await fetchQuickCommands(); // 重新获取以确保数据同步 @@ -328,11 +330,11 @@ export const useQuickCommandsStore = defineStore('quickCommands', () => { } }; - // 更新快捷指令 (发送 tagIds) - const updateQuickCommand = async (id: number, name: string | null, command: string, tagIds?: number[]): Promise => { + // 更新快捷指令 (发送 tagIds 和 variables) + const updateQuickCommand = async (id: number, name: string | null, command: string, tagIds?: number[], variables?: Record): Promise => { try { - // 在请求体中包含 tagIds (即使是 undefined 也要发送,让后端知道是否要更新) - const response = await apiClient.put<{ message: string, command: QuickCommandFE }>(`/quick-commands/${id}`, { name, command, tagIds }); + // 在请求体中包含 tagIds 和 variables (即使是 undefined 也要发送,让后端知道是否要更新) + const response = await apiClient.put<{ message: string, command: QuickCommandFE }>(`/quick-commands/${id}`, { name, command, tagIds, variables }); // 后端现在返回完整的 command 对象 clearQuickCommandsCache(); // 清除缓存 await fetchQuickCommands(); // 重新获取以确保数据同步 diff --git a/packages/frontend/src/views/QuickCommandsView.vue b/packages/frontend/src/views/QuickCommandsView.vue index c26c75a..9975c8d 100644 --- a/packages/frontend/src/views/QuickCommandsView.vue +++ b/packages/frontend/src/views/QuickCommandsView.vue @@ -552,13 +552,47 @@ const copyCommand = async (command: string) => { }; // 执行命令 -const executeCommand = (command: QuickCommandFE) => { - // 1. 增加使用次数 (后台操作,不阻塞执行) - quickCommandsStore.incrementUsage(command.id); - // 2. 发出执行事件给父组件 - emitWorkspaceEvent('terminal:sendCommand', { command: command.command }); - // Optionally reset selection after execution - // selectedIndex.value = -1; // REMOVED: Store handles index +const executeCommand = (cmd: QuickCommandFE) => { + // 1. 增加使用次数 + quickCommandsStore.incrementUsage(cmd.id); + + let processedCommand = cmd.command; + const savedVariables = cmd.variables || {}; // 使用已保存的变量 + + // 2. 执行变量替换 + for (const varName in savedVariables) { + const placeholder = new RegExp(`\\$\\{${varName}\\}`, 'g'); + processedCommand = processedCommand.replace(placeholder, savedVariables[varName]); + } + + // 3. 检查未定义变量 + const variablePlaceholders = cmd.command.match(/\$\{[^\}]+\}/g) || []; + const undefinedVariables: string[] = []; + variablePlaceholders.forEach(placeholder => { + const varName = placeholder.substring(2, placeholder.length - 1); + if (!savedVariables.hasOwnProperty(varName)) { + undefinedVariables.push(varName); + } + }); + + if (undefinedVariables.length > 0) { + uiNotificationsStore.showWarning( + t('quickCommands.form.warningUndefinedVariables', { variables: undefinedVariables.join(', ') }) + ); + } + + // 4. 获取当前激活的 SSH 会话 ID + const activeSessionId = sessionStore.activeSessionId; + if (!activeSessionId) { + uiNotificationsStore.showError(t('quickCommands.form.errorNoActiveSession', '没有活动的SSH会话可执行指令。')); + return; + } + + // 5. 触发 quickCommand:executeProcessed 事件 + emitWorkspaceEvent('quickCommand:executeProcessed', { + command: processedCommand, + sessionId: activeSessionId + }); }; // +++ 聚焦搜索框的方法 +++ diff --git a/packages/frontend/src/views/WorkspaceView.vue b/packages/frontend/src/views/WorkspaceView.vue index 00eeca2..45add48 100644 --- a/packages/frontend/src/views/WorkspaceView.vue +++ b/packages/frontend/src/views/WorkspaceView.vue @@ -174,6 +174,7 @@ onMounted(() => { subscribeToWorkspaceEvents('session:closeToLeft', (payload) => handleCloseSessionsToLeft(payload.targetSessionId)); subscribeToWorkspaceEvents('ui:openLayoutConfigurator', handleOpenLayoutConfigurator); subscribeToWorkspaceEvents('fileManager:openModalRequest', handleFileManagerOpenRequest); // +++ 订阅文件管理器打开请求 +++ + subscribeToWorkspaceEvents('quickCommand:executeProcessed', handleQuickCommandExecuteProcessed); }); onBeforeUnmount(() => { @@ -218,6 +219,7 @@ onBeforeUnmount(() => { unsubscribeFromWorkspaceEvents('session:closeToLeft', (payload) => handleCloseSessionsToLeft(payload.targetSessionId)); unsubscribeFromWorkspaceEvents('ui:openLayoutConfigurator', handleOpenLayoutConfigurator); unsubscribeFromWorkspaceEvents('fileManager:openModalRequest', handleFileManagerOpenRequest); // +++ 取消订阅文件管理器打开请求 +++ + unsubscribeFromWorkspaceEvents('quickCommand:executeProcessed', handleQuickCommandExecuteProcessed); }); const subscribeToWorkspaceEvents = useWorkspaceEventSubscriber(); // +++ 定义订阅和取消订阅函数 +++ @@ -683,6 +685,16 @@ const handleFileManagerOpenRequest = (payload: { sessionId: string }) => { console.log(`[WorkspaceView] Opening FileManager modal with props for session ${sessionId}:`, newProps); }; +// --- 处理 quickCommand:executeProcessed 事件 --- +const handleQuickCommandExecuteProcessed = (payload: WorkspaceEventPayloads['quickCommand:executeProcessed']) => { + const { command, sessionId: targetSessionId } = payload; + console.log(`[WorkspaceView] Received quickCommand:executeProcessed event. Command: "${command}", TargetSessionID: ${targetSessionId}`); + + // 使用现有的 handleSendCommand 逻辑来发送指令 + // handleSendCommand 会处理 sessionId 未定义时使用 activeSessionId 的情况 + handleSendCommand(command, targetSessionId); +}; + const closeFileManagerModal = () => { showFileManagerModal.value = false; console.log('[WorkspaceView] FileManager modal hidden (kept alive).'); @@ -902,7 +914,6 @@ const closeFileManagerModal = () => { /* 可以添加更多样式,例如背景色、边框等 */ } -/* Ensure modals are still displayed correctly (they are outside the main flow) */