diff --git a/docs/需求追踪/需求进度追踪-AI聊天发送快捷键-20260428.md b/docs/需求追踪/需求进度追踪-AI聊天发送快捷键-20260428.md new file mode 100644 index 0000000..ead34b9 --- /dev/null +++ b/docs/需求追踪/需求进度追踪-AI聊天发送快捷键-20260428.md @@ -0,0 +1,73 @@ +# 需求进度追踪 - AI聊天发送快捷键 + +## 1. 需求摘要 +- 需求名称:AI 聊天发送快捷键 +- 提出日期:2026-04-28 +- 负责人:Claude Code +- 目标:将 AI 聊天发送快捷键纳入工具中心快捷键管理,支持录制自定义 Enter 相关组合键,降低输入法 Enter 上屏时误发送的风险。 +- 非目标:不调整后端 AI 服务配置,不改发送按钮行为,不把 AI 发送快捷键放在 AI 设置弹窗的独立入口。 + +## 2. 范围与验收 +- 范围:工具中心快捷键管理、AI 聊天输入框、本地前端偏好持久化。 +- 验收标准:工具中心出现“AI 聊天发送”快捷键;默认 Enter 发送;可录制 Enter / Cmd+Enter / Ctrl+Enter / Alt+Enter 等 Enter 相关组合;普通字符键不可录制为 AI 发送;Shift+Enter 始终换行;输入法 composing 状态不发送;刷新后快捷键保持;AI 设置弹窗不再出现独立“聊天输入”快捷键入口。 +- 依赖与约束:沿用 Zustand `lite-db-storage` 中的 `shortcutOptions` 持久化;保持现有 AI 后端接口不变。 + +## 3. 里程碑与进度 +- [x] 阶段 1(需求澄清):确认输入法 Enter 上屏导致误发送,需要支持录制自定义快捷键,并复用工具中心快捷键体系。 +- [x] 阶段 2(影响分析):影响工具中心快捷键配置、AIChatPanel、AIChatInput、store 和相关测试。 +- [x] 阶段 3(方案设计):采用共享 `shortcutOptions` action,AI 输入框局部消费,不走全局快捷键执行器。 +- [x] 阶段 4(实施计划):计划已按用户反馈调整为工具中心统一方案。 +- [x] 阶段 5(实现与自检):目标红灯测试已补充,新方案核心实现已完成。 +- [x] 阶段 6(评审与交付):已完成代码审查反馈修复、目标测试、全量测试、构建、diff 检查和浏览器手工验证。 +- [ ] 阶段 7(发布与观察):发布后观察用户输入法场景反馈。 + +## 4. 变更清单 +- 已完成:新增工具中心 AI 发送 action 目标测试;实现 Enter 默认快捷键、Enter 组合录制规则、AI 输入框按 `shortcutOptions` 判定发送;移除 AI 设置独立入口;修复刷新后录制值被启动配置刷新覆盖的问题;限制 AI 发送快捷键只能录制 0 或 1 个修饰键的 Enter 组合;消费 AI 发送快捷键后阻止事件继续冒泡;更新 store、工具函数和输入框提示测试。 +- 进行中:无。 +- 待处理:发布后观察输入法场景反馈。 + +## 5. 风险与阻塞 +- 风险:默认 Enter 发送在少数未标记 composing 的输入法中仍可能误发。 +- 阻塞:无。 +- 缓解措施:用户可在工具中心录制 Cmd+Enter / Ctrl+Enter / Alt+Enter,普通 Enter 不再触发发送;AI 发送录制限制为 Enter 相关组合并保留 Shift+Enter 换行;输入法 composing 状态始终不发送。 + +## 6. 决策记录 +- 决策 1:AI 发送快捷键作为工具中心快捷键 action 持久化,不写入后端 AI provider 配置。 +- 决策 2:`sendAIChatMessage` 仅由 AI 输入框处理,全局快捷键执行器跳过该局部 action。 +- 决策 3:AI 发送快捷键允许默认无修饰键 Enter,但录制时只接受 Enter 相关组合,拒绝普通字符键和含 Shift 的组合。 +- 决策 4:输入法 composing 状态始终不发送。 +- 决策 5:AI 发送快捷键仅允许 Enter / Ctrl+Enter / Cmd+Enter / Alt+Enter,拒绝 Ctrl+Alt+Enter 等多修饰键组合,避免扩大局部快捷键冲突面。 +- 决策 6:AI 输入框命中发送快捷键后同时执行 `preventDefault` 和 `stopPropagation`,避免事件继续冒泡到全局快捷键处理器。 + +## 7. 验证记录 +- 验证项:初版两档下拉方案红灯测试。 +- 结果:已确认旧实现失败。 +- 证据:`aiChatSendShortcut.test.ts` 缺模块失败;`store.test.ts` 新增字段缺失失败;`AIChatInput.notice.test.tsx` placeholder 仍为 Enter 失败。 +- 验证项:工具中心统一方案红灯测试。 +- 结果:已确认旧实现失败。 +- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts` 显示缺少 `sendAIChatMessage` action、`canRecordShortcutForAction` 和自定义 binding 判定失败;`src/store.test.ts` 显示 `shortcutOptions.sendAIChatMessage` 缺失;`src/components/ai/AIChatInput.notice.test.tsx` 显示 placeholder 未渲染 `Meta+Enter 发送`。 +- 验证项:工具中心统一方案目标绿灯测试。 +- 结果:已通过。 +- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts`(6 passed)、`src/components/ai/AIChatInput.notice.test.tsx`(2 passed)、`src/store.test.ts`(10 passed)。 +- 验证项:代码审查反馈红灯测试。 +- 结果:已确认旧实现失败。 +- 证据:多修饰键 Enter 组合被误放行、缺少 `consumeAIChatSendShortcutOnKeyDown`、脏持久化 `sendAIChatMessage: A` 未回退到 Enter。 +- 验证项:代码审查反馈修复后目标测试。 +- 结果:已通过。 +- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts src/components/ai/AIChatInput.notice.test.tsx src/store.test.ts`(3 files passed,22 tests passed)。 +- 验证项:浏览器手工验证。 +- 结果:已通过。 +- 证据:工具中心录制 `Meta+Enter` 后刷新仍保持;AI 输入框 placeholder 显示 `输入消息... (Meta+Enter 发送,Shift+Enter 换行,/ 快捷命令)`;普通 Enter 和 Shift+Enter 不触发发送;Meta+Enter 触发发送、调用 `preventDefault` 且事件不冒泡。 +- 验证项:前端全量测试。 +- 结果:已通过。 +- 证据:`npm --prefix frontend test -- --run`(88 files passed,421 tests passed)。 +- 验证项:diff 空白检查。 +- 结果:已通过。 +- 证据:`git diff --check` 无输出。 +- 验证项:生产构建。 +- 结果:已通过。 +- 证据:`npm --prefix frontend run build` 通过,仅有既有 dynamic import / chunk size 警告。 + +## 8. 下一步 +- 下一步行动:提交并推送本次改动,发布后观察用户输入法场景反馈。 +- 负责人:Claude Code diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d6c584f..2b0ef39 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,8 +33,10 @@ "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@types/react-resizable": "^3.0.8", + "@types/react-test-renderer": "^18.0.7", "@types/uuid": "^9.0.7", "@vitejs/plugin-react": "^4.2.1", + "react-test-renderer": "^18.2.0", "typescript": "^5.2.2", "vite": "^5.0.8", "vitest": "^3.2.4" @@ -2037,6 +2039,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-test-renderer": { + "version": "18.0.7", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.0.7.tgz", + "integrity": "sha512-1+ANPOWc6rB3IkSnElhjv6VLlKg2dSv/OWClUyZimbLsQyBn8Js9Vtdsi3UICJ2rIQ3k2la06dkB+C92QfhKmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -5645,6 +5657,20 @@ "react-dom": ">= 16.3" } }, + "node_modules/react-shallow-renderer": { + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", + "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-syntax-highlighter": { "version": "16.1.1", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", @@ -5665,6 +5691,21 @@ "react": ">= 0.14.0" } }, + "node_modules/react-test-renderer": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.2.0.tgz", + "integrity": "sha512-JWD+aQ0lh2gvh4NM3bBM42Kx+XybOxCpgYK7F8ugAlpaTSnWsX+39Z4XkOykGZAHrjwwTZT3x3KxswVWxHPUqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^18.2.0", + "react-shallow-renderer": "^16.15.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, "node_modules/recharts": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index daddbfb..4181217 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,8 +35,10 @@ "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@types/react-resizable": "^3.0.8", + "@types/react-test-renderer": "^18.0.7", "@types/uuid": "^9.0.7", "@vitejs/plugin-react": "^4.2.1", + "react-test-renderer": "^18.2.0", "typescript": "^5.2.2", "vite": "^5.0.8", "vitest": "^3.2.4" diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index fa8a8b2..bed8925 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -571d014306268cf67665967059cda912 \ No newline at end of file +0295a42fd931778d85157816d79d29e5 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4992151..72a9481 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -62,9 +62,9 @@ import { SHORTCUT_ACTION_META, SHORTCUT_ACTION_ORDER, ShortcutAction, + canRecordShortcutForAction, eventToShortcut, getShortcutDisplay, - hasModifierKey, isEditableElement, isShortcutMatch, normalizeShortcutCombo, @@ -2387,11 +2387,15 @@ function App() { useEffect(() => { const handleGlobalShortcut = (event: KeyboardEvent) => { const matchedAction = SHORTCUT_ACTION_ORDER.find((action) => { + const meta = SHORTCUT_ACTION_META[action]; + if (meta.scope && meta.scope !== 'global') { + return false; + } const binding = shortcutOptions[action]; if (!binding?.enabled) { return false; } - if (isEditableElement(event.target) && !SHORTCUT_ACTION_META[action].allowInEditable) { + if (isEditableElement(event.target) && !meta.allowInEditable) { return false; } return isShortcutMatch(event, binding.combo); @@ -2455,12 +2459,15 @@ function App() { if (!combo) { return; } - if (!hasModifierKey(combo)) { - void message.warning('快捷键至少包含 Ctrl / Alt / Shift / Meta 之一'); - return; - } const normalizedCombo = normalizeShortcutCombo(combo); + if (!canRecordShortcutForAction(capturingShortcutAction, normalizedCombo)) { + const meta = SHORTCUT_ACTION_META[capturingShortcutAction]; + void message.warning(meta.scope === 'aiComposer' + ? 'AI 聊天发送快捷键仅支持 Enter / Ctrl+Enter / Cmd+Enter / Alt+Enter,Shift+Enter 保留换行' + : '快捷键至少包含 Ctrl / Alt / Shift / Meta 之一'); + return; + } const conflictAction = SHORTCUT_ACTION_ORDER.find((action) => { if (action === capturingShortcutAction) { return false; @@ -3482,7 +3489,7 @@ function App() {
- 点击“录制”后按下快捷键。按 Esc 可取消录制。建议至少包含一个修饰键(Ctrl/Alt/Shift/Meta)。 + 点击“录制”后按下快捷键。按 Esc 可取消录制。全局快捷键建议包含修饰键;AI 聊天发送仅支持 Enter 相关组合,Shift+Enter 保留换行。
{SHORTCUT_ACTION_ORDER.map((action) => { diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 8f722b0..752f1ad 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -25,6 +25,9 @@ import { buildMissingProviderNotice, buildModelFetchFailedNotice, } from '../utils/aiComposerNotice'; +import { buildAIReadonlyPreviewSQL } from '../utils/aiSqlLimit'; +import { resolveAITableSchemaToolResult } from '../utils/aiTableSchemaTool'; +import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut'; interface AIChatPanelProps { width?: number; @@ -254,6 +257,7 @@ export const AIChatPanel: React.FC = ({ const tabs = useStore(state => state.tabs); const activeTabId = useStore(state => state.activeTabId); const aiPanelVisible = useStore(state => state.aiPanelVisible); + const aiChatSendShortcutBinding = useStore(state => state.shortcutOptions.sendAIChatMessage); const getCurrentJVMPlanContext = useCallback((): JVMAIPlanContext | undefined => { const state = useStore.getState(); @@ -1145,12 +1149,15 @@ SELECT * FROM users WHERE status = 1; try { const safeDbName = args.dbName ? String(args.dbName).trim() : ''; const safeTable = args.tableName ? String(args.tableName).trim() : ''; - const { DBShowCreateTable } = await import('../../wailsjs/go/app/App'); - const ddlRes = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeTable); - if (ddlRes?.success) { - resStr = typeof ddlRes.data === 'string' ? ddlRes.data : JSON.stringify(ddlRes.data); - success = true; - } else { resStr = ddlRes?.message || 'Failed to fetch DDL'; } + const { DBShowCreateTable, DBGetColumns } = await import('../../wailsjs/go/app/App'); + const rpcConfig = buildRpcConnectionConfig(conn.config) as any; + const toolResult = await resolveAITableSchemaToolResult({ + tableName: safeTable, + fetchDDL: () => DBShowCreateTable(rpcConfig, safeDbName, safeTable), + fetchColumns: () => DBGetColumns(rpcConfig, safeDbName, safeTable), + }); + resStr = toolResult.content; + success = toolResult.success; } catch (e: any) { resStr = `获取建表语句失败: ${e?.message || e}`; } @@ -1173,14 +1180,8 @@ SELECT * FROM users WHERE status = 1; } } const { DBQuery } = await import('../../wailsjs/go/app/App'); - // 只对只读查询自动追加 LIMIT,写操作(UPDATE/DELETE/INSERT等)不追加 - const sqlTrimmed = safeSql.replace(/;\s*$/, ''); // 去掉末尾分号防止拼接出 "; LIMIT 50" - const sqlFirstWord = sqlTrimmed.trimStart().split(/\s/)[0]?.toLowerCase() || ''; - const isReadQuery = ['select', 'show', 'describe', 'desc', 'explain', 'with'].includes(sqlFirstWord); - const finalSql = (isReadQuery && !sqlTrimmed.toLowerCase().includes('limit')) - ? sqlTrimmed + ' LIMIT 50' - : sqlTrimmed; - const qRes = await DBQuery(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeSql + (safeSql.toLowerCase().includes('limit') ? '' : ' LIMIT 50')); + const finalSql = buildAIReadonlyPreviewSQL(conn.config?.type || '', safeSql, 50, conn.config?.driver || ''); + const qRes = await DBQuery(buildRpcConnectionConfig(conn.config) as any, safeDbName, finalSql); if (qRes?.success) { const rows = Array.isArray(qRes.data) ? qRes.data : []; const limitedRows = rows.slice(0, 50); @@ -1471,11 +1472,8 @@ SELECT * FROM users WHERE status = 1; ]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }, [handleSend]); + consumeAIChatSendShortcutOnKeyDown(aiChatSendShortcutBinding, e, handleSend); + }, [aiChatSendShortcutBinding, handleSend]); const handleStop = useCallback(async () => { try { @@ -1706,6 +1704,7 @@ SELECT * FROM users WHERE status = 1; activeProvider={activeProvider} dynamicModels={dynamicModels} loadingModels={loadingModels} + sendShortcutBinding={aiChatSendShortcutBinding} composerNotice={composerNotice} onModelChange={handleModelChange} onFetchModels={fetchDynamicModels} diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 9a55cae..f73d4aa 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -20,7 +20,6 @@ import { } from '../utils/aiSettingsPresetLayout'; import { resolveProviderSecretDraft } from '../utils/providerSecretDraft'; import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState'; - import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; interface AISettingsModalProps { diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx new file mode 100644 index 0000000..c1f727b --- /dev/null +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -0,0 +1,312 @@ +import React from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import DataGrid from './DataGrid'; + +const storeState = vi.hoisted(() => ({ + connections: [ + { + id: 'conn-1', + name: 'local', + config: { + type: 'mysql', + host: '127.0.0.1', + port: 3306, + user: 'root', + password: '', + database: 'main', + }, + }, + ], + addSqlLog: vi.fn(), + theme: 'light', + appearance: { + enabled: true, + opacity: 1, + blur: 0, + showDataTableVerticalBorders: false, + dataTableColumnWidthMode: 'standard', + }, + queryOptions: { + showColumnComment: false, + showColumnType: false, + }, + setQueryOptions: vi.fn(), + tableColumnOrders: {}, + enableColumnOrderMemory: false, + setTableColumnOrder: vi.fn(), + setEnableColumnOrderMemory: vi.fn(), + clearTableColumnOrder: vi.fn(), + tableHiddenColumns: {}, + enableHiddenColumnMemory: false, + setTableHiddenColumns: vi.fn(), + setEnableHiddenColumnMemory: vi.fn(), + clearTableHiddenColumns: vi.fn(), + aiPanelVisible: false, + setAIPanelVisible: vi.fn(), +})); + +const backendApp = vi.hoisted(() => ({ + ImportData: vi.fn(), + ExportTable: vi.fn(), + ExportData: vi.fn(), + ExportQuery: vi.fn(), + ApplyChanges: vi.fn(), + DBGetColumns: vi.fn(), + DBGetIndexes: vi.fn(), + DBShowCreateTable: vi.fn(), +})); + +const messageApi = vi.hoisted(() => ({ + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + loading: vi.fn(() => vi.fn()), +})); + +vi.mock('../store', () => ({ + useStore: (selector: (state: typeof storeState) => any) => selector(storeState), +})); + +vi.mock('../../wailsjs/go/app/App', () => backendApp); + +vi.mock('@monaco-editor/react', () => ({ + default: ({ value }: { value?: string }) =>
{value}
, +})); + +vi.mock('./ImportPreviewModal', () => ({ + default: () => null, +})); + +vi.mock('@ant-design/icons', () => { + const Icon = () => ; + return { + ReloadOutlined: Icon, + ImportOutlined: Icon, + ExportOutlined: Icon, + DownOutlined: Icon, + PlusOutlined: Icon, + DeleteOutlined: Icon, + SaveOutlined: Icon, + UndoOutlined: Icon, + FilterOutlined: Icon, + CloseOutlined: Icon, + ConsoleSqlOutlined: Icon, + FileTextOutlined: Icon, + CopyOutlined: Icon, + ClearOutlined: Icon, + EditOutlined: Icon, + VerticalAlignBottomOutlined: Icon, + LeftOutlined: Icon, + RightOutlined: Icon, + RobotOutlined: Icon, + SearchOutlined: Icon, + }; +}); + +vi.mock('@dnd-kit/core', () => ({ + DndContext: ({ children }: any) => <>{children}, + PointerSensor: vi.fn(), + MouseSensor: vi.fn(), + TouchSensor: vi.fn(), + useSensor: vi.fn(() => ({})), + useSensors: vi.fn(() => []), + closestCenter: vi.fn(), +})); + +vi.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }: any) => <>{children}, + useSortable: vi.fn(() => ({ + attributes: {}, + listeners: {}, + setNodeRef: vi.fn(), + transform: null, + transition: undefined, + isDragging: false, + })), + horizontalListSortingStrategy: vi.fn(), + arrayMove: (items: any[], from: number, to: number) => { + const next = [...items]; + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + return next; + }, +})); + +vi.mock('@dnd-kit/utilities', () => ({ + CSS: { + Transform: { + toString: () => '', + }, + }, +})); + +vi.mock('antd', () => { + const Button = ({ children, disabled, loading, onClick, type, ...rest }: any) => ( + + ); + const Input: any = ({ value, onChange, placeholder, ...rest }: any) => ( + + ); + Input.TextArea = ({ value, onChange, placeholder }: any) => ( +