From 1f37b0a8ae1f4af2770ee68c132b3db78e8a4cbb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com>
Date: Tue, 1 Sep 2026 17:02:58 +0800
Subject: [PATCH] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96SQL=E5=B7=A5=E4=BD=9C?=
=?UTF-8?q?=E5=8F=B0=E5=A4=A7=E7=BB=93=E6=9E=9C=E6=B8=B2=E6=9F=93?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 提供一百到一万行返回档位
- 使用虚拟表格降低大结果集页面开销
---
.../views/dataspace/DataspaceWorkspace.vue | 1 -
.../components/SqlWorkbenchPanel.test.ts | 127 +++++++++++++++
.../components/SqlWorkbenchPanel.vue | 149 +++++++++++++++---
3 files changed, 256 insertions(+), 21 deletions(-)
diff --git a/easyflow-ui-admin/app/src/views/dataspace/DataspaceWorkspace.vue b/easyflow-ui-admin/app/src/views/dataspace/DataspaceWorkspace.vue
index 12bff826..0457972b 100644
--- a/easyflow-ui-admin/app/src/views/dataspace/DataspaceWorkspace.vue
+++ b/easyflow-ui-admin/app/src/views/dataspace/DataspaceWorkspace.vue
@@ -117,7 +117,6 @@ onMounted(() => {
width: 100%;
height: 100%;
min-height: 0;
- overflow: hidden;
}
@media (max-width: 900px) {
diff --git a/easyflow-ui-admin/app/src/views/dataspace/components/SqlWorkbenchPanel.test.ts b/easyflow-ui-admin/app/src/views/dataspace/components/SqlWorkbenchPanel.test.ts
index f02c57fb..93742c8f 100644
--- a/easyflow-ui-admin/app/src/views/dataspace/components/SqlWorkbenchPanel.test.ts
+++ b/easyflow-ui-admin/app/src/views/dataspace/components/SqlWorkbenchPanel.test.ts
@@ -22,11 +22,32 @@ vi.mock('#/api/dataspace', () => ({
queryDataspace: apiMocks.query,
}));
+vi.mock('element-plus/es/components/table-v2/style/css.mjs', () => ({}));
+
const mountWorkbench = () =>
shallowMount(SqlWorkbenchPanel, {
global: {
stubs: {
+ ElOption: {
+ name: 'ElOption',
+ props: ['label', 'value'],
+ template: '
',
+ },
+ ElSelect: {
+ name: 'ElSelect',
+ props: ['modelValue'],
+ template: '
',
+ },
ElTooltip: { template: '
' },
+ ElTableV2: {
+ name: 'ElTableV2',
+ props: ['columns', 'data', 'rowKey'],
+ template: '',
+ },
+ ElAutoResizer: {
+ name: 'ElAutoResizer',
+ template: '
',
+ },
},
},
});
@@ -190,6 +211,112 @@ describe('sql workbench panel', () => {
]);
});
+ it('offers row limits through 10000 and sends the selected value', async () => {
+ const rows = Array.from({ length: 10_000 }, (_, index) => [index + 1]);
+ apiMocks.query.mockResolvedValueOnce({
+ columns: [
+ { jdbcType: -5, name: 'id', nullable: false, typeName: 'BIGINT' },
+ ],
+ metrics: {
+ databaseMillis: 20,
+ firstRowMillis: 21,
+ intermediateRows: 10_000,
+ localMillis: 0,
+ planCacheHit: true,
+ planningMillis: 2,
+ queryMode: 'SINGLE_SOURCE',
+ returnedRows: 10_000,
+ totalMillis: 30,
+ truncated: false,
+ },
+ queryId: 'query-10000',
+ rows,
+ });
+ const wrapper = mountWorkbench();
+ await flushPromises();
+
+ const rowLimit = wrapper
+ .findAllComponents({ name: 'ElSelect' })
+ .find((component) => component.classes().includes('row-limit'));
+ expect(rowLimit?.props('modelValue')).toBe(500);
+ const labels = wrapper
+ .findAllComponents({ name: 'ElOption' })
+ .map((option) => option.props('label'))
+ .filter((label) => String(label).startsWith('最多'));
+ expect(labels).toEqual([
+ '最多 100 行',
+ '最多 500 行',
+ '最多 1000 行',
+ '最多 2000 行',
+ '最多 5000 行',
+ '最多 10000 行',
+ ]);
+
+ rowLimit?.vm.$emit('update:modelValue', 10_000);
+ wrapper
+ .getComponent({ name: 'CodeEditor' })
+ .vm.$emit('update:modelValue', 'SELECT id FROM outlet');
+ await flushPromises();
+ await wrapper.get('[aria-label="执行 SQL"]').trigger('click');
+ await flushPromises();
+
+ expect(apiMocks.query).toHaveBeenCalledWith(
+ expect.objectContaining({ maxRows: 10_000 }),
+ );
+ const virtualTable = wrapper.getComponent({ name: 'ElTableV2' });
+ expect(virtualTable.props('data')).toHaveLength(10_000);
+ expect(virtualTable.props('columns')).toHaveLength(2);
+ expect(virtualTable.props('rowKey')).toBe('rowKey');
+ });
+
+ it('exports every returned row independently of virtual rendering', async () => {
+ const rows = Array.from({ length: 10_000 }, (_, index) => [index + 1]);
+ apiMocks.query.mockResolvedValueOnce({
+ columns: [
+ { jdbcType: -5, name: 'id', nullable: false, typeName: 'BIGINT' },
+ ],
+ metrics: {
+ databaseMillis: 20,
+ firstRowMillis: 21,
+ intermediateRows: 10_000,
+ localMillis: 0,
+ planCacheHit: true,
+ planningMillis: 2,
+ queryMode: 'SINGLE_SOURCE',
+ returnedRows: 10_000,
+ totalMillis: 30,
+ truncated: false,
+ },
+ queryId: 'query-export',
+ rows,
+ });
+ const createObjectUrl = vi
+ .spyOn(URL, 'createObjectURL')
+ .mockReturnValue('blob:query-export');
+ const revokeObjectUrl = vi
+ .spyOn(URL, 'revokeObjectURL')
+ .mockImplementation(() => undefined);
+ const anchorClick = vi
+ .spyOn(HTMLAnchorElement.prototype, 'click')
+ .mockImplementation(() => undefined);
+ const wrapper = mountWorkbench();
+ await flushPromises();
+
+ wrapper
+ .getComponent({ name: 'CodeEditor' })
+ .vm.$emit('update:modelValue', 'SELECT id FROM outlet');
+ await flushPromises();
+ await wrapper.get('[aria-label="执行 SQL"]').trigger('click');
+ await flushPromises();
+ await wrapper.get('[aria-label="导出 CSV"]').trigger('click');
+
+ const blob = createObjectUrl.mock.calls[0]?.[0];
+ expect(blob).toBeInstanceOf(Blob);
+ expect((blob as Blob).size).toBeGreaterThan(48_000);
+ expect(anchorClick).toHaveBeenCalledOnce();
+ expect(revokeObjectUrl).toHaveBeenCalledWith('blob:query-export');
+ });
+
it('keeps result metrics and an icon-only export action beside the tabs', async () => {
const wrapper = mountWorkbench();
await flushPromises();
diff --git a/easyflow-ui-admin/app/src/views/dataspace/components/SqlWorkbenchPanel.vue b/easyflow-ui-admin/app/src/views/dataspace/components/SqlWorkbenchPanel.vue
index e1d5cccc..66e0c434 100644
--- a/easyflow-ui-admin/app/src/views/dataspace/components/SqlWorkbenchPanel.vue
+++ b/easyflow-ui-admin/app/src/views/dataspace/components/SqlWorkbenchPanel.vue
@@ -13,6 +13,7 @@ import type {
import {
computed,
+ h,
nextTick,
onBeforeUnmount,
onMounted,
@@ -48,10 +49,12 @@ import {
ElMessage,
ElOption,
ElSelect,
- ElTable,
- ElTableColumn,
ElTooltip,
} from 'element-plus';
+import {
+ ElAutoResizer as TableV2AutoResizer,
+ ElTableV2,
+} from 'element-plus/es/components/table-v2/index.mjs';
import {
cancelDataspaceQuery,
@@ -65,6 +68,13 @@ import {
import { dataspaceErrorMessage } from '../dataspace-errors';
import { formatCalciteSql } from '../dataspace-sql';
+import 'element-plus/es/components/table-v2/style/css.mjs';
+
+interface VirtualResultRow {
+ cells: unknown[];
+ rowKey: number;
+}
+
const props = defineProps<{
initialDataspaceId?: DataspaceId;
}>();
@@ -96,11 +106,51 @@ let resizeStartHeight = 0;
const SPLITTER_SIZE = 8;
const MIN_EDITOR_HEIGHT = 184;
const MIN_OUTPUT_HEIGHT = 176;
+const MAX_ROW_OPTIONS = [100, 500, 1000, 2000, 5000, 10_000];
+const RESULT_HEADER_HEIGHT = 40;
+const RESULT_ROW_HEIGHT = 40;
const hasOutput = computed(() =>
Boolean(result.value || explainResult.value || errorMessage.value),
);
+const virtualResultRows = computed(() =>
+ (result.value?.rows || []).map((cells, rowKey) => ({ cells, rowKey })),
+);
+
+const virtualResultColumns = computed(() => [
+ {
+ align: 'right' as const,
+ cellRenderer: ({ rowIndex }: { rowIndex: number }) =>
+ h('span', { class: 'result-index-cell' }, String(rowIndex + 1)),
+ dataKey: 'rowKey',
+ key: 'rowKey',
+ title: '#',
+ width: 56,
+ },
+ ...(result.value?.columns || []).map((column, index) => ({
+ cellRenderer: ({ rowData }: { rowData: VirtualResultRow }) => {
+ const value = rowData.cells[index];
+ const text =
+ value === null || value === undefined ? 'NULL' : String(value);
+ return h(
+ 'span',
+ {
+ class: ['result-cell-value', { 'is-null': value == null }],
+ title: text,
+ },
+ text,
+ );
+ },
+ dataKey: `column-${index}`,
+ flexGrow: 1,
+ key: `${column.name}-${index}`,
+ minWidth: 160,
+ title: column.name,
+ width: 160,
+ })),
+]);
+
const filteredTables = computed(() => {
const normalized = keyword.value.trim().toLowerCase();
if (!normalized) return detail.value?.tables || [];
@@ -462,6 +512,11 @@ function queryRequest(queryId: string) {
};
}
+/** 为虚拟结果表提供稳定的隔行底色。 */
+function resultRowClass({ rowIndex }: { rowIndex: number }) {
+ return rowIndex % 2 === 1 ? 'is-striped' : '';
+}
+
/** 执行 Calcite SQL,并展示数据与常用消耗指标。 */
async function execute() {
const epoch = ++requestEpoch;
@@ -711,9 +766,9 @@ defineExpose({ loadSpaces });
/>
-
+
-
-
-
-
- {{ row[index] ?? 'NULL' }}
+
+