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 }); /> - +
- - - -