@@ -381,6 +389,7 @@ vi.mock('@ant-design/icons', () => {
SettingOutlined: Icon,
CloseOutlined: Icon,
StopOutlined: Icon,
+ DownOutlined: Icon,
RobotOutlined: Icon,
SearchOutlined: Icon,
DatabaseOutlined: Icon,
@@ -434,6 +443,7 @@ vi.mock('antd', () => {
) : null),
Input: ({ value, onChange, placeholder }: any) =>
,
+ Segmented: () => null,
Form,
Dropdown: ({ children, menu }: any) => (
<>
@@ -3539,6 +3549,40 @@ describe('QueryEditor external SQL save', () => {
expect(messageApi.warning).not.toHaveBeenCalled();
});
+ it('gives a multiline single-table result an independent column pin scope without making it editable', async () => {
+ const sql = [
+ 'SELECT a.COMPID, a.MEMCARDNO,',
+ ' a.MODIFYUSER, a.MODIFYTIME',
+ 'FROM D_MEMBER_CARDTYPE_MODFIY_LOG a',
+ ].join('\n');
+ backendApp.DBQueryMulti.mockResolvedValueOnce({
+ success: true,
+ data: [{
+ columns: ['COMPID', 'MEMCARDNO', 'MODIFYUSER', 'MODIFYTIME'],
+ rows: [{ COMPID: 1, MEMCARDNO: 'M-1', MODIFYUSER: 'admin', MODIFYTIME: '2026-07-10' }],
+ }],
+ });
+
+ 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();
+ });
+
+ expect(dataGridState.latestProps?.tableName).toBeUndefined();
+ expect(dataGridState.latestProps?.readOnly).toBe(true);
+ expect(dataGridState.latestProps?.columnPinScope).toMatch(/^query-result:[a-f0-9]+$/);
+ expect(dataGridState.latestProps?.columnPinScope).not.toContain('D_MEMBER_CARDTYPE_MODFIY_LOG');
+ renderer!.unmount();
+ });
+
it.each([
'mysql',
'mariadb',
diff --git a/frontend/src/components/QueryEditorResultsPanel.tsx b/frontend/src/components/QueryEditorResultsPanel.tsx
index 2d7d5b04..3464d680 100644
--- a/frontend/src/components/QueryEditorResultsPanel.tsx
+++ b/frontend/src/components/QueryEditorResultsPanel.tsx
@@ -5,6 +5,7 @@ import { BugOutlined, CloseOutlined, CopyOutlined, EyeInvisibleOutlined, RobotOu
import type { EditRowLocator } from '../utils/rowLocator';
import type { QueryResultPaginationState } from '../utils/queryResultPagination';
import { filterColumnNamesByGlobalHiddenColumns, useGlobalHiddenColumns } from '../utils/globalHiddenColumns';
+import { buildQueryResultColumnPinScope } from '../utils/queryResultColumnPinScope';
import { t as defaultTranslate } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
import {
@@ -440,6 +441,7 @@ const QueryEditorResultsPanel: React.FC
= ({
);
}
const visibleColumns = resolveVisibleQueryResultColumns(rs.columns, globalHiddenColumns);
+ const resultTableName = rs.metadataTableName || rs.tableName;
return (
{Array.isArray(rs.messages) && rs.messages.length > 0 ? (
@@ -456,7 +458,12 @@ const QueryEditorResultsPanel: React.FC = ({
data={rs.rows}
columnNames={visibleColumns}
loading={loading || rs.page?.loading === true}
- tableName={rs.metadataTableName || rs.tableName}
+ tableName={resultTableName}
+ columnPinScope={resultTableName ? undefined : buildQueryResultColumnPinScope({
+ sql: rs.exportSql || rs.sql,
+ sourceStatementIndex: rs.sourceStatementIndex,
+ statementResultIndex: rs.statementResultIndex,
+ })}
exportScope="queryResult"
resultSql={rs.exportSql || rs.sql}
resultExportAllSql={rs.page?.exportAllSql}
diff --git a/frontend/src/components/useDataGridV2Actions.ts b/frontend/src/components/useDataGridV2Actions.ts
index 01290ed5..7b591288 100644
--- a/frontend/src/components/useDataGridV2Actions.ts
+++ b/frontend/src/components/useDataGridV2Actions.ts
@@ -101,6 +101,7 @@ export const useDataGridV2Actions = (ctx: DataGridV2ActionsContext) => {
supportsCopyInsert,
supportsSqlQueryExport,
tableName,
+ pinnedLeftColumnScope,
toggleColumnVisibility,
pinnedLeftColumnNames,
setTablePinnedLeftColumns,
@@ -137,7 +138,7 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
autoFitColumnWidth(columnName);
break;
case 'pin-column-left': {
- if (!connectionId || !dbName || !tableName) {
+ if (!connectionId || !dbName || !pinnedLeftColumnScope) {
void message.info(translateDataGrid('data_grid.message.pin_column_unavailable'));
break;
}
@@ -145,13 +146,13 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
...pinnedLeftColumnNames.filter((col: string) => col !== columnName),
columnName,
];
- setTablePinnedLeftColumns(connectionId, dbName, tableName, nextPinned);
+ setTablePinnedLeftColumns(connectionId, dbName, pinnedLeftColumnScope, nextPinned);
break;
}
case 'unpin-column-left': {
- if (!connectionId || !dbName || !tableName) break;
+ if (!connectionId || !dbName || !pinnedLeftColumnScope) break;
const nextPinned = pinnedLeftColumnNames.filter((col: string) => col !== columnName);
- setTablePinnedLeftColumns(connectionId, dbName, tableName, nextPinned);
+ setTablePinnedLeftColumns(connectionId, dbName, pinnedLeftColumnScope, nextPinned);
break;
}
case 'hide-column':
@@ -190,7 +191,7 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
pinnedLeftColumnNames,
setQueryOptions,
setTablePinnedLeftColumns,
- tableName,
+ pinnedLeftColumnScope,
translateDataGrid,
toggleColumnVisibility,
]);
diff --git a/frontend/src/utils/queryResultColumnPinScope.test.ts b/frontend/src/utils/queryResultColumnPinScope.test.ts
new file mode 100644
index 00000000..70c188b6
--- /dev/null
+++ b/frontend/src/utils/queryResultColumnPinScope.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildQueryResultColumnPinScope } from './queryResultColumnPinScope';
+
+describe('buildQueryResultColumnPinScope', () => {
+ it('is stable for the same SQL without persisting the SQL text', () => {
+ const input = {
+ sql: 'SELECT u.id FROM users u JOIN orders o ON o.user_id = u.id',
+ sourceStatementIndex: 1,
+ statementResultIndex: 1,
+ };
+ const scope = buildQueryResultColumnPinScope(input);
+
+ expect(scope).toBe(buildQueryResultColumnPinScope(input));
+ expect(scope).toMatch(/^query-result:[a-f0-9]+$/);
+ expect(scope).not.toContain('users');
+ });
+
+ it('keeps distinct result sets in separate scopes', () => {
+ const base = {
+ sql: 'SELECT u.id FROM users u JOIN orders o ON o.user_id = u.id',
+ sourceStatementIndex: 1,
+ statementResultIndex: 1,
+ };
+
+ expect(buildQueryResultColumnPinScope(base)).not.toBe(
+ buildQueryResultColumnPinScope({ ...base, statementResultIndex: 2 }),
+ );
+ expect(buildQueryResultColumnPinScope(base)).not.toBe(
+ buildQueryResultColumnPinScope({ ...base, sourceStatementIndex: 2 }),
+ );
+ });
+});
diff --git a/frontend/src/utils/queryResultColumnPinScope.ts b/frontend/src/utils/queryResultColumnPinScope.ts
new file mode 100644
index 00000000..7b843b6e
--- /dev/null
+++ b/frontend/src/utils/queryResultColumnPinScope.ts
@@ -0,0 +1,32 @@
+export type QueryResultColumnPinScopeInput = {
+ sql: string;
+ sourceStatementIndex?: number;
+ statementResultIndex?: number;
+};
+
+const normalizeResultIndex = (value: number | undefined): number => {
+ const parsed = Math.floor(Number(value));
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
+};
+
+const hashQueryResultIdentity = (value: string): string => {
+ let hash = 2166136261;
+ for (let index = 0; index < value.length; index += 1) {
+ hash ^= value.charCodeAt(index);
+ hash = Math.imul(hash, 16777619);
+ }
+ return (hash >>> 0).toString(16);
+};
+
+export const buildQueryResultColumnPinScope = ({
+ sql,
+ sourceStatementIndex,
+ statementResultIndex,
+}: QueryResultColumnPinScopeInput): string => {
+ const identity = [
+ normalizeResultIndex(sourceStatementIndex),
+ normalizeResultIndex(statementResultIndex),
+ String(sql || ''),
+ ].join('\u0000');
+ return `query-result:${hashQueryResultIdentity(identity)}`;
+};