From 3205d131c946b849d509cc9da92ac6ffd361aa7b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 23 Jun 2026 14:43:13 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B6=88=E6=81=AF=E7=BB=93=E6=9E=9C=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E4=B8=8E=E5=A4=8D=E5=88=B6=E5=85=A8=E9=80=89=E4=BA=A4?= =?UTF-8?q?=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一清洗 SQL Server 消息前缀并覆盖结果刷新与分页回填链路 - 将消息结果区改为只读文本区,补充一键复制入口 - 放行编辑器外可编辑区域的 cmd/ctrl+a,避免消息内容全选被抢占 - 补充结果面板交互与国际化回归,确保构建通过 --- .../QueryEditor.results-and-drop.test.tsx | 71 +++++++ frontend/src/components/QueryEditor.tsx | 28 +-- .../QueryEditorResultsPanel.i18n.test.ts | 3 + .../components/QueryEditorResultsPanel.tsx | 186 +++++++++++++----- .../queryEditor/QueryEditorHelpers.ts | 31 +++ shared/i18n/de-DE.json | 3 + shared/i18n/en-US.json | 3 + shared/i18n/ja-JP.json | 3 + shared/i18n/ru-RU.json | 3 + shared/i18n/zh-CN.json | 3 + shared/i18n/zh-TW.json | 3 + 11 files changed, 276 insertions(+), 61 deletions(-) diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx index fadc7aa..d5afce0 100644 --- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -8,6 +8,7 @@ import { setCurrentLanguage } from '../i18n'; import type { SavedQuery, TabData } from '../types'; import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator'; import { clearQueryTabDraft, clearSQLFileTabDraft, getQueryTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts'; +import { normalizeQueryResultMessages } from './queryEditor/QueryEditorHelpers'; import QueryEditor, { collectQueryEditorObjectDecorationCandidates, resolveQueryEditorNavigationDecorations, @@ -780,6 +781,18 @@ describe('QueryEditor external SQL save', () => { expect(dataGridState.latestProps?.columnNames).not.toEqual([]); }); + it('normalizes sqlserver mssql-prefixed message lines line-by-line', () => { + expect(normalizeQueryResultMessages([ + "mssql: select c.queryno,'' ,left(dbo.f_vendor_class(''' + b.groupid + ''',' + colname + '),", + "mssql: 'char','',''),'自动生成',0,isdefault,defaultoperator,defaultvalue,defaultvalue2,ishaving", + " where funcno = @funcno and tabname = '$vendorclass'", + ])).toEqual([ + "select c.queryno,'' ,left(dbo.f_vendor_class(''' + b.groupid + ''',' + colname + '),", + "'char','',''),'自动生成',0,isdefault,defaultoperator,defaultvalue,defaultvalue2,ishaving", + "where funcno = @funcno and tabname = '$vendorclass'", + ]); + }); + it('keeps multiple result sets from a single sqlserver statement', async () => { storeState.connections[0].config.type = 'sqlserver'; storeState.connections[0].config.database = 'master'; @@ -925,6 +938,46 @@ describe('QueryEditor external SQL save', () => { expect(dataGridState.latestProps).toBeNull(); }); + it('strips mssql prefixes before rendering sqlserver message-only results', async () => { + storeState.connections[0].config.type = 'sqlserver'; + storeState.connections[0].config.database = 'hydee'; + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [ + { + statementIndex: 1, + columns: [], + rows: [], + messages: [ + "mssql: select c.queryno,'' ,left(dbo.f_vendor_class(''' + b.groupid + ''',' + colname + '),", + "mssql: 'char','',''),'自动生成',0,isdefault,defaultoperator,defaultvalue,defaultvalue2,ishaving", + " where funcno = @funcno and tabname = '$vendorclass'", + ], + }, + ], + }); + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + await findButton(renderer!, '运行').props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const rendered = textContent(renderer!.toJSON()); + expect(rendered).toContain('消息 1'); + expect(rendered).toContain("select c.queryno,'' ,left(dbo.f_vendor_class"); + expect(rendered).toContain("'char','',''),'自动生成'"); + expect(rendered).toContain("where funcno = @funcno and tabname = '$vendorclass'"); + expect(rendered).not.toContain('mssql:'); + }); + it('renders top-level sqlserver print messages when result sets contain only status rows', async () => { storeState.connections[0].config.type = 'sqlserver'; storeState.connections[0].config.database = 'hydee'; @@ -2335,6 +2388,24 @@ describe('QueryEditor external SQL save', () => { expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tab-text {'); }); + it('keeps query message blocks explicitly left, top aligned, copyable, and textarea-based', () => { + const source = readFileSync(new URL('./QueryEditorResultsPanel.tsx', import.meta.url), 'utf8'); + + expect(source).toContain("textAlign: 'left'"); + expect(source).toContain("justifyContent: 'flex-start'"); + expect(source).toContain("data-query-result-message-textarea"); + expect(source).toContain("query_editor.results_panel.message.action.copy"); + expect(source).toContain("typeof navigator?.clipboard?.writeText !== 'function'"); + expect(source).toContain('await navigator.clipboard.writeText(safeText);'); + expect(source).toContain('event.currentTarget.select();'); + }); + + it('keeps editor select-all scoped away from non-editor editable targets', () => { + const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8'); + + expect(source).toContain("if (isEditableElement(event.target) && !inEditorPane) {"); + }); + it('embeds the sql execution log as a result tab instead of a standalone workspace panel in v2', () => { const panelSource = readFileSync(new URL('./QueryEditorResultsPanel.tsx', import.meta.url), 'utf8'); const editorSource = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8'); diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 5820dfa..aaf8117 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -95,6 +95,7 @@ import { isDocumentLevelShortcutTarget, isQueryEditorPrimaryMouseButton, normalizeCommentText, + normalizeQueryResultMessages, normalizeCompletionQualifiedName, normalizeEditorPosition, normalizeExecutedSqlKey, @@ -2832,6 +2833,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const cols = (rsData.columns && rsData.columns.length > 0) ? rsData.columns : (rows.length > 0 ? Object.keys(rows[0]) : []); + const refreshedMessages = normalizeQueryResultMessages(rsData?.messages); rows.forEach((row: any, i: number) => { if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i; }); @@ -2843,8 +2845,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc ...rs, rows, columns: cols, - messages: Array.isArray(rsData.messages) ? rsData.messages : [], - resultType: ((!Array.isArray(rsData.rows) || rsData.rows.length === 0) && (!Array.isArray(rsData.columns) || rsData.columns.length === 0) && Array.isArray(rsData.messages) && rsData.messages.length > 0) + messages: refreshedMessages, + resultType: ((!Array.isArray(rsData.rows) || rsData.rows.length === 0) && (!Array.isArray(rsData.columns) || rsData.columns.length === 0) && refreshedMessages.length > 0) ? 'message' : 'grid', truncated, @@ -2926,6 +2928,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const cols = (rsData.columns && rsData.columns.length > 0) ? rsData.columns : (rows.length > 0 ? Object.keys(rows[0]) : target.columns); + const pageMessages = normalizeQueryResultMessages(rsData?.messages); const totalState = resolveQueryResultPaginationTotal({ current: safePage, pageSize: safePageSize, @@ -2938,7 +2941,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc ...rs, rows, columns: cols, - messages: Array.isArray(rsData.messages) ? rsData.messages : [], + messages: pageMessages, resultType: 'grid', truncated: false, page: { @@ -3091,6 +3094,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc setQueryId(queryId); const res = await DBQueryWithCancel(buildRpcConnectionConfig(config) as any, currentDb, executedSql, queryId); + const legacyResultMessages = normalizeQueryResultMessages(res?.messages); const duration = Date.now() - startTime; addSqlLog({ id: `log-${Date.now()}-query-${idx + 1}`, @@ -3134,12 +3138,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc statementResultIndex: 1, rows, columns: cols, - messages: Array.isArray(res.messages) ? res.messages : [], + messages: legacyResultMessages, pkColumns: [], readOnly: true, truncated }); - } else if (Array.isArray(res.messages) && res.messages.length > 0) { + } else if (legacyResultMessages.length > 0) { nextResultSets.push({ key: `result-${idx + 1}`, sql: rawStatement, @@ -3148,7 +3152,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc statementResultIndex: 1, rows: [], columns: [], - messages: res.messages, + messages: legacyResultMessages, resultType: 'message', pkColumns: [], readOnly: true, @@ -3166,7 +3170,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc statementResultIndex: 1, rows: [row], columns: ['affectedRows'], - messages: Array.isArray(res.messages) ? res.messages : [], + messages: legacyResultMessages, pkColumns: [], readOnly: true }); @@ -3382,9 +3386,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc // res.data 是 ResultSetData[] 数组 const resultSetDataArray = Array.isArray(res.data) ? (res.data as any[]) : []; - const topLevelMessages = Array.isArray(res.messages) - ? (res.messages as any[]).map((item) => String(item ?? '').trim()).filter(Boolean) - : []; + const topLevelMessages = normalizeQueryResultMessages(res.messages); const nextResultSets: ResultSet[] = []; const maxRows = Number(queryOptions?.maxRows) || 0; let anyTruncated = false; @@ -3398,7 +3400,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const plan = executablePlans[Math.max(0, sourceStatementIndex - 1)]; const originalSql = plan?.originalSql || ''; const executedSql = plan?.executedSql || originalSql; - const resultMessages = Array.isArray(rsData?.messages) ? rsData.messages : []; + const resultMessages = normalizeQueryResultMessages(rsData?.messages); // 检查是否为 affectedRows 类结果集 const isAffectedResult = Array.isArray(rsData.rows) && rsData.rows.length === 1 @@ -3591,10 +3593,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const editorHasFocus = !!editor.hasTextFocus?.(); const inEditorPane = !!(targetNode && editorPaneRef.current?.contains(targetNode)); const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); - if (!editorHasFocus && !inEditorPane) { + if (isEditableElement(event.target) && !inEditorPane) { return; } - if (!editorHasFocus && isEditableElement(event.target) && !inEditorPane) { + if (!editorHasFocus && !inEditorPane) { return; } if (!editorHasFocus && !inQueryEditor) { diff --git a/frontend/src/components/QueryEditorResultsPanel.i18n.test.ts b/frontend/src/components/QueryEditorResultsPanel.i18n.test.ts index d1a0eca..ce1f633 100644 --- a/frontend/src/components/QueryEditorResultsPanel.i18n.test.ts +++ b/frontend/src/components/QueryEditorResultsPanel.i18n.test.ts @@ -35,6 +35,9 @@ const requiredKeys = [ 'query_editor.results_panel.tab.message', 'query_editor.results_panel.tab.result', 'query_editor.results_panel.message.title', + 'query_editor.results_panel.message.action.copy', + 'query_editor.results_panel.message.copy_unsupported', + 'query_editor.results_panel.message.copy_failed', 'query_editor.results_panel.panel.title', 'query_editor.empty_state.title', 'query_editor.empty_state.description', diff --git a/frontend/src/components/QueryEditorResultsPanel.tsx b/frontend/src/components/QueryEditorResultsPanel.tsx index 7ecc8ce..f607526 100644 --- a/frontend/src/components/QueryEditorResultsPanel.tsx +++ b/frontend/src/components/QueryEditorResultsPanel.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Button, Dropdown, Tabs, Tooltip, type MenuProps } from 'antd'; -import { BugOutlined, CloseOutlined, EyeInvisibleOutlined, RobotOutlined } from '@ant-design/icons'; +import { Button, Dropdown, Tabs, Tooltip, message, type MenuProps } from 'antd'; +import { BugOutlined, CloseOutlined, CopyOutlined, EyeInvisibleOutlined, RobotOutlined } from '@ant-design/icons'; import type { EditRowLocator } from '../utils/rowLocator'; import type { QueryResultPaginationState } from '../utils/queryResultPagination'; @@ -89,6 +89,111 @@ const QueryEditorResultsPanel: React.FC = ({ const hideTooltipTitle = toggleShortcutLabel ? t('query_editor.results_panel.tooltip.hide_with_shortcut', { shortcut: toggleShortcutLabel }) : t('query_editor.results_panel.tooltip.hide'); + const handleMessageTextareaKeyDown = (event: React.KeyboardEvent) => { + if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'a') { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.focus(); + event.currentTarget.select(); + }; + const handleCopyMessageText = async (text: string) => { + const safeText = String(text || ''); + if (!safeText.trim()) { + return; + } + try { + if (typeof navigator?.clipboard?.writeText !== 'function') { + throw new Error(t('query_editor.results_panel.message.copy_unsupported')); + } + await navigator.clipboard.writeText(safeText); + message.success(t('data_grid.message.copied_to_clipboard')); + } catch (error: any) { + message.error(t('query_editor.results_panel.message.copy_failed', { + detail: error?.message || t('common.unknown'), + })); + } + }; + const renderMessageBlock = ({ + text, + title, + fontSize, + fillHeight = false, + compact = false, + maxWidth, + color, + marginTop, + }: { + text: string; + title?: string; + fontSize: string; + fillHeight?: boolean; + compact?: boolean; + maxWidth?: number; + color: string; + marginTop?: number; + }) => ( +
+
+ {title ? {title} : } + +
+