From bccc6a58afd6f19746971e272491942321189f0c Mon Sep 17 00:00:00 2001 From: Durant Date: Tue, 30 Jun 2026 23:17:07 +0800 Subject: [PATCH] feat(workflow): add keyboard shortcuts, shortcut editor, and cross-platform support - Fix undo/redo: remove duplicate handlers (LogicFlow core already handles them) - Add Ctrl+S/Cmd+S save, Ctrl+Shift+D debug, Ctrl+Shift+P publish shortcuts - Add S key for box-select mode, H key for hand/point mode - Add Ctrl+=/- zoom in/out, Ctrl+0 fit view, Ctrl+[/] collapse/expand all - Add Ctrl+Shift+L auto-layout beautify, Ctrl+F node search - Create shortcut-config.ts for customizable keybindings (localStorage) - Create ShortcutEditor.vue dialog for viewing/editing all shortcuts - Add tooltip hints for all toolbar buttons and header buttons - Cross-platform Mac support: auto-detect and show Cmd/Ctrl appropriately - Share cursor mode state via provide/inject for toolbar button sync - Update i18n for zh-CN, zh-Hant, en-US with shortcut-related keys Co-Authored-By: Claude --- ui/src/locales/lang/en-US/workflow.ts | 17 ++ ui/src/locales/lang/zh-CN/workflow.ts | 17 ++ ui/src/locales/lang/zh-Hant/workflow.ts | 17 ++ ui/src/views/application-workflow/index.vue | 50 +++- ui/src/views/knowledge-workflow/index.vue | 50 +++- ui/src/views/tool-workflow/index.vue | 50 +++- ui/src/workflow/common/NodeControl.vue | 58 ++-- ui/src/workflow/common/NodeSearch.vue | 15 +- ui/src/workflow/common/ShortcutEditor.vue | 294 ++++++++++++++++++++ ui/src/workflow/common/shortcut-config.ts | 120 ++++++++ ui/src/workflow/common/shortcut.ts | 129 ++++++++- ui/src/workflow/index.vue | 19 +- 12 files changed, 760 insertions(+), 76 deletions(-) create mode 100644 ui/src/workflow/common/ShortcutEditor.vue create mode 100644 ui/src/workflow/common/shortcut-config.ts diff --git a/ui/src/locales/lang/en-US/workflow.ts b/ui/src/locales/lang/en-US/workflow.ts index f322667c8a3..5e0ca57049b 100644 --- a/ui/src/locales/lang/en-US/workflow.ts +++ b/ui/src/locales/lang/en-US/workflow.ts @@ -48,6 +48,8 @@ export default { deleteMessage: 'This node cannot be deleted', }, control: { + dragMode: 'Box Select', + clickMode: 'Point Select', zoomOut: 'Zoom Out', zoomIn: 'Zoom In', fitView: 'Fit to Screen', @@ -55,6 +57,21 @@ export default { extend: 'Expand All', beautify: 'Auto-Arrange', }, + shortcut: { + undo: 'Undo', + redo: 'Redo', + copy: 'Copy', + paste: 'Paste', + delete: 'Delete', + title: 'Keyboard Shortcuts', + action: 'Action', + keys: 'Shortcut', + operation: 'Operation', + pressKeys: 'Press new shortcut...', + reset: 'Reset', + resetAll: 'Reset All', + reloadTip: 'Changes take effect after page reload.', + }, variable: { global: 'Global Variable', chat: 'Chat Variable', diff --git a/ui/src/locales/lang/zh-CN/workflow.ts b/ui/src/locales/lang/zh-CN/workflow.ts index 42cdf5a89f7..c8d56d1fc94 100644 --- a/ui/src/locales/lang/zh-CN/workflow.ts +++ b/ui/src/locales/lang/zh-CN/workflow.ts @@ -47,6 +47,8 @@ export default { deleteMessage: '节点不允许删除', }, control: { + dragMode: '框选模式', + clickMode: '点选模式', zoomOut: '缩小', zoomIn: '放大', fitView: '适应', @@ -54,6 +56,21 @@ export default { extend: '展开全部节点', beautify: '一键美化', }, + shortcut: { + undo: '撤销', + redo: '重做', + copy: '复制', + paste: '粘贴', + delete: '删除', + title: '快捷键设置', + action: '动作', + keys: '快捷键', + operation: '操作', + pressKeys: '按下新快捷键...', + reset: '重置', + resetAll: '恢复默认', + reloadTip: '修改后需要刷新页面才能生效。', + }, variable: { global: '全局变量', chat: '会话变量', diff --git a/ui/src/locales/lang/zh-Hant/workflow.ts b/ui/src/locales/lang/zh-Hant/workflow.ts index 6678d4536a7..9f9e9eb3496 100644 --- a/ui/src/locales/lang/zh-Hant/workflow.ts +++ b/ui/src/locales/lang/zh-Hant/workflow.ts @@ -47,6 +47,8 @@ export default { deleteMessage: '節點不允許刪除', }, control: { + dragMode: '框選模式', + clickMode: '點選模式', zoomOut: '縮小', zoomIn: '放大', fitView: '適應', @@ -54,6 +56,21 @@ export default { extend: '展開全部節點', beautify: '一鍵美化', }, + shortcut: { + undo: '撤銷', + redo: '重做', + copy: '複製', + paste: '粘貼', + delete: '刪除', + title: '快捷鍵設置', + action: '動作', + keys: '快捷鍵', + operation: '操作', + pressKeys: '按下新快捷鍵...', + reset: '重置', + resetAll: '恢復默認', + reloadTip: '修改後需要刷新頁面才能生效。', + }, variable: { global: '全局變量', chat: '會話變量', diff --git a/ui/src/views/application-workflow/index.vue b/ui/src/views/application-workflow/index.vue index 7705e1a53cf..55d546c379e 100644 --- a/ui/src/views/application-workflow/index.vue +++ b/ui/src/views/application-workflow/index.vue @@ -38,17 +38,35 @@ {{ $t('workflow.setting.addComponent') }} - - - {{ $t('common.debug') }} - - - - {{ $t('common.save') }} - - - {{ $t('common.publish') }} - + + + + {{ $t('common.debug') }} + + + + + + {{ $t('common.save') }} + + + + + {{ $t('common.publish') }} + + @@ -74,6 +92,10 @@ + + + {{ $t('workflow.shortcut.title') }} + @@ -92,7 +114,7 @@
- +
@@ -151,6 +173,7 @@ source="work_flow" @refresh="getDetail" /> + + + diff --git a/ui/src/workflow/common/shortcut-config.ts b/ui/src/workflow/common/shortcut-config.ts new file mode 100644 index 00000000000..702f0ef9863 --- /dev/null +++ b/ui/src/workflow/common/shortcut-config.ts @@ -0,0 +1,120 @@ +/** + * 快捷键配置模块 + * 定义所有可配置动作的默认键位,支持用户通过 localStorage 自定义覆盖 + */ + +export interface ShortcutBinding { + /** Mousetrap 格式的键位列表,如 ['ctrl + s', 'cmd + s'] */ + keys: string[] + /** 显示名称 */ + label: string + /** i18n key — 用于动态读取翻译 */ + labelKey: string + /** 分组 */ + group: 'general' | 'edit' | 'view' +} + +const STORAGE_KEY = 'workflowShortcuts' + +const ALL_ACTIONS: Record = { + save: { keys: ['cmd + s', 'ctrl + s'], label: '保存', labelKey: 'common.save', group: 'general' }, + debug: { keys: ['cmd + shift + d', 'ctrl + shift + d'], label: '调试', labelKey: 'common.debug', group: 'general' }, + publish: { keys: ['cmd + shift + p', 'ctrl + shift + p'], label: '发布', labelKey: 'common.publish', group: 'general' }, + search: { keys: ['cmd + f', 'ctrl + f'], label: '搜索节点', labelKey: 'workflow.tip.searchPlaceholder', group: 'general' }, + undo: { keys: ['cmd + z', 'ctrl + z'], label: '撤销', labelKey: 'workflow.shortcut.undo', group: 'edit' }, + redo: { keys: ['cmd + y', 'ctrl + y'], label: '重做', labelKey: 'workflow.shortcut.redo', group: 'edit' }, + copy: { keys: ['cmd + c', 'ctrl + c'], label: '复制', labelKey: 'workflow.shortcut.copy', group: 'edit' }, + paste: { keys: ['cmd + v', 'ctrl + v'], label: '粘贴', labelKey: 'workflow.shortcut.paste', group: 'edit' }, + delete: { keys: ['backspace', 'del', 'delete'], label: '删除', labelKey: 'workflow.shortcut.delete', group: 'edit' }, + selectMode: { keys: ['s'], label: '框选模式', labelKey: 'workflow.control.dragMode', group: 'view' }, + handMode: { keys: ['h'], label: '点选模式', labelKey: 'workflow.control.clickMode', group: 'view' }, + zoomIn: { keys: ['cmd + =', 'ctrl + ='], label: '放大', labelKey: 'workflow.control.zoomIn', group: 'view' }, + zoomOut: { keys: ['cmd + -', 'ctrl + -'], label: '缩小', labelKey: 'workflow.control.zoomOut', group: 'view' }, + fitView: { keys: ['cmd + 0', 'ctrl + 0'], label: '适应画布', labelKey: 'workflow.control.fitView', group: 'view' }, + collapseAll: { keys: ['cmd + [', 'ctrl + ['], label: '收起全部', labelKey: 'workflow.control.retract', group: 'view' }, + expandAll: { keys: ['cmd + ]', 'ctrl + ]'], label: '展开全部', labelKey: 'workflow.control.extend', group: 'view' }, + beautify: { keys: ['cmd + shift + l', 'ctrl + shift + l'], label: '一键美化', labelKey: 'workflow.control.beautify', group: 'view' }, +} + +function loadOverrides(): Record { + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? JSON.parse(raw) : {} + } catch { + return {} + } +} + +/** 获取某个动作的键位列表(含用户自定义覆盖) */ +export function getShortcutKeys(action: string): string[] { + const overrides = loadOverrides() + const binding = ALL_ACTIONS[action] + if (!binding) return [] + if (action in overrides) return overrides[action] + return binding.keys +} + +/** 设置用户自定义键位 */ +export function setShortcutKeys(action: string, keys: string[]): void { + const overrides = loadOverrides() + overrides[action] = keys + localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)) +} + +/** 重置某个动作为默认键位 */ +export function resetShortcutKeys(action: string): void { + const overrides = loadOverrides() + delete overrides[action] + localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)) +} + +/** 重置所有动作为默认键位 */ +export function resetAllShortcutKeys(): void { + localStorage.removeItem(STORAGE_KEY) +} + +/** 获取所有动作及其当前键位(含用户自定义) */ +export function getAllShortcuts(): Record { + const overrides = loadOverrides() + const result: Record = {} + for (const [action, binding] of Object.entries(ALL_ACTIONS)) { + result[action] = { + ...binding, + keys: action in overrides ? overrides[action] : binding.keys, + } + } + return result +} + +/** 将 Mousetrap 键位格式转为人类可读的显示文本 */ +export function formatKeysForDisplay(keys: string[]): string { + if (keys.length === 0) return '' + // 取第一个非 alt 的键位作为主要显示(mac / win 取当前平台匹配的) + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 + let key = keys[0] + // 如果有 cmd 版本的键,Mac 优先取 cmd;Windows 优先取 ctrl + const cmdKey = keys.find(k => k.includes('cmd')) + const ctrlKey = keys.find(k => k.includes('ctrl') && !k.includes('cmd')) + if (isMac && cmdKey) key = cmdKey + else if (!isMac && ctrlKey) key = ctrlKey + + return key + .split(' + ') + .map(part => { + const p = part.toLowerCase() + if (p === 'cmd') return isMac ? '⌘' : 'Cmd' + if (p === 'ctrl') return isMac ? '⌃' : 'Ctrl' + if (p === 'shift') return isMac ? '⇧' : 'Shift' + if (p === 'alt') return isMac ? '⌥' : 'Alt' + if (p === 'backspace') return '⌫' + if (p === 'del' || p === 'delete') return '⌦' + if (p === 'return' || p === 'enter') return '↵' + if (p === 'escape' || p === 'esc') return 'Esc' + if (p === '=') return '+' + if (p === '-') return '-' + if (p === '0') return '0' + // 格式化单个字母或数字键 + return p.length === 1 ? p.toUpperCase() : p.charAt(0).toUpperCase() + p.slice(1) + }) + .join(isMac ? '' : '+') +} diff --git a/ui/src/workflow/common/shortcut.ts b/ui/src/workflow/common/shortcut.ts index 69b69e11fe3..04f2adb1ce0 100644 --- a/ui/src/workflow/common/shortcut.ts +++ b/ui/src/workflow/common/shortcut.ts @@ -7,6 +7,18 @@ import { t } from '@/locales' import { copyClick } from '@/utils/clipboard' import { randomId } from '@/utils/common' import { getMenuNodes, workflowModelDict } from './data' + +export interface ShortcutOptions { + /** Ctrl/Cmd+S 保存回调 */ + onSave?: () => void + /** Ctrl/Cmd+Shift+D 调试回调 */ + onDebug?: () => void + /** Ctrl/Cmd+Shift+P 发布回调 */ + onPublish?: () => void + /** 光标模式 ref,用于与 NodeControl 同步按钮高亮 */ + isDragMode?: { value: boolean } +} + let activeCanvasId: string | null = null type Point = { x: number; y: number } const lastMouse = { @@ -84,7 +96,7 @@ function translationEdgeData(edgeData: any, distance: any) { const TRANSLATION_DISTANCE = 40 let CHILDREN_TRANSLATION_DISTANCE = 40 -export function initDefaultShortcut(lf: LogicFlow, graph: GraphModel) { +export function initDefaultShortcut(lf: LogicFlow, graph: GraphModel, options?: ShortcutOptions) { bindMousePosition(lf) bindCanvasActive(lf) const { keyboard } = lf @@ -340,24 +352,113 @@ export function initDefaultShortcut(lf: LogicFlow, graph: GraphModel) { return false } graph.eventCenter.on('copy_node', copy_node) - // 复制 + // 复制 — 由 LogicFlow 核心处理 keyboard.on(['cmd + c', 'ctrl + c'], copy_node) - // 粘贴 + // 粘贴 — 由 LogicFlow 核心处理 keyboard.on(['cmd + v', 'ctrl + v'], () => {}) - // undo - keyboard.on(['cmd + z', 'ctrl + z'], () => { - // if (!keyboardOptions?.enabled) return true - // if (graph.textEditElement) return true - // lf.undo() - // return false + // undo / redo — LogicFlow 核心已内置 cmd+z / ctrl+z 和 cmd+y / ctrl+y + // delete + keyboard.on(['backspace', 'del', 'delete'], delete_node) + + // ========== 新增快捷键 ========== + + // Ctrl+S / Cmd+S 保存 + keyboard.on(['cmd + s', 'ctrl + s'], () => { + if (!keyboardOptions?.enabled) return true + if (graph.textEditElement) return true + options?.onSave?.() + return false + }) + + // Ctrl+Shift+D / Cmd+Shift+D 调试 + keyboard.on(['cmd + shift + d', 'ctrl + shift + d'], () => { + if (!keyboardOptions?.enabled) return true + options?.onDebug?.() + return false + }) + + // Ctrl+Shift+P / Cmd+Shift+P 发布 + keyboard.on(['cmd + shift + p', 'ctrl + shift + p'], () => { + if (!keyboardOptions?.enabled) return true + options?.onPublish?.() + return false + }) + + // Ctrl+= / Cmd+= 放大 + keyboard.on(['ctrl + =', 'cmd + ='], () => { + if (!keyboardOptions?.enabled) return true + lf.zoom(true, [0, 0]) + return false + }) + + // Ctrl+- / Cmd+- 缩小 + keyboard.on(['ctrl + -', 'cmd + -'], () => { + if (!keyboardOptions?.enabled) return true + lf.zoom(false, [0, 0]) + return false + }) + + // Ctrl+0 / Cmd+0 适应画布 + keyboard.on(['ctrl + 0', 'cmd + 0'], () => { + if (!keyboardOptions?.enabled) return true + lf.resetZoom() + lf.resetTranslate() + lf.fitView() + return false }) - // redo - keyboard.on(['cmd + y', 'ctrl + y'], () => { + + // S 切换到缩放/选择模式(框选) + keyboard.on(['s', 'S'], () => { if (!keyboardOptions?.enabled) return true if (graph.textEditElement) return true - lf.redo() + const element = document.querySelector('.lf-drag-able') as HTMLElement | null + if (!element) return false + element.style.cursor = 'default' + lf.openSelectionSelect() + lf.extension.selectionSelect?.setSelectionSense(true, false) + if (options?.isDragMode) options.isDragMode.value = true + return false + }) + + // H 切换到点击模式(拖拽画布) + keyboard.on(['h', 'H'], () => { + if (!keyboardOptions?.enabled) return true + if (graph.textEditElement) return true + const element = document.querySelector('.lf-drag-able') as HTMLElement | null + if (!element) return false + element.style.cursor = 'pointer' + lf.closeSelectionSelect() + if (options?.isDragMode) options.isDragMode.value = false + return false + }) + + // Ctrl+[ / Cmd+[ 收起全部节点 + keyboard.on(['ctrl + [', 'cmd + ['], () => { + if (!keyboardOptions?.enabled) return true + lf.graphModel.nodes.forEach((node: any) => { + node.properties.showNode = false + }) + return false + }) + + // Ctrl+] / Cmd+] 展开全部节点 + keyboard.on(['ctrl + ]', 'cmd + ]'], () => { + if (!keyboardOptions?.enabled) return true + lf.graphModel.nodes.forEach((node: any) => { + node.properties.showNode = true + }) + return false + }) + + // Ctrl+Shift+L / Cmd+Shift+L 一键美化 + keyboard.on(['ctrl + shift + l', 'ctrl + shift + L', 'cmd + shift + l', 'cmd + shift + L'], () => { + if (!keyboardOptions?.enabled) return true + lf.extension.dagre?.layout() + lf.graphModel.nodes.forEach((node: any) => { + if (node.type === 'loop-body-node') { + node?.loopLayout?.() + } + }) return false }) - // delete - keyboard.on(['backspace'], delete_node) } diff --git a/ui/src/workflow/index.vue b/ui/src/workflow/index.vue index 2f469756435..5ecfebf43af 100644 --- a/ui/src/workflow/index.vue +++ b/ui/src/workflow/index.vue @@ -7,7 +7,8 @@