From 98b34bd4bbc3de3b19c64543f4146ce2d63ef879 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, 25 Aug 2026 01:06:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=A9=BA=E9=97=B4=E4=B8=8E=20SQL=20=E5=B7=A5=E4=BD=9C=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提供连接管理、逻辑表编排和轻量 SQL 工作台 - 增加 SQL 补全、执行分析、结果分栏与导出交互 - 统一数据空间导航、图标和编辑器体验 --- easyflow-ui-admin/app/src/api/dataspace.ts | 380 +++ easyflow-ui-admin/app/src/api/index.ts | 1 + easyflow-ui-admin/app/src/api/request.ts | 2 + .../app/src/locales/langs/en-US/menus.json | 1 + .../app/src/locales/langs/zh-CN/menus.json | 1 + .../src/router/routes/modules/dataspace.ts | 19 + .../views/dataspace/DataspaceWorkspace.vue | 128 + .../dataspace/components/ConnectionDialog.vue | 538 ++++ .../dataspace/components/ConnectionPanel.vue | 929 ++++++ .../dataspace/components/DataspaceModeNav.vue | 149 + .../dataspace/components/DataspacePanel.vue | 2709 +++++++++++++++++ .../components/SqlWorkbenchPanel.test.ts | 499 +++ .../components/SqlWorkbenchPanel.vue | 2294 ++++++++++++++ .../views/dataspace/dataspace-errors.test.ts | 43 + .../src/views/dataspace/dataspace-errors.ts | 67 + .../dataspace/dataspace-logical-name.test.ts | 35 + .../views/dataspace/dataspace-logical-name.ts | 54 + .../src/views/dataspace/dataspace-sql.test.ts | 21 + .../app/src/views/dataspace/dataspace-sql.ts | 84 + .../@core/base/icons/src/local-icons.ts | 5 + .../packages/@core/base/icons/src/lucide.ts | 20 + .../@core/ui-kit/editor-ui/package.json | 1 + .../ui-kit/editor-ui/src/CodeEditor.test.ts | 64 + .../@core/ui-kit/editor-ui/src/CodeEditor.vue | 69 + .../@core/ui-kit/editor-ui/src/index.ts | 6 + easyflow-ui-admin/pnpm-lock.yaml | 3 + 26 files changed, 8122 insertions(+) create mode 100644 easyflow-ui-admin/app/src/api/dataspace.ts create mode 100644 easyflow-ui-admin/app/src/router/routes/modules/dataspace.ts create mode 100644 easyflow-ui-admin/app/src/views/dataspace/DataspaceWorkspace.vue create mode 100644 easyflow-ui-admin/app/src/views/dataspace/components/ConnectionDialog.vue create mode 100644 easyflow-ui-admin/app/src/views/dataspace/components/ConnectionPanel.vue create mode 100644 easyflow-ui-admin/app/src/views/dataspace/components/DataspaceModeNav.vue create mode 100644 easyflow-ui-admin/app/src/views/dataspace/components/DataspacePanel.vue create mode 100644 easyflow-ui-admin/app/src/views/dataspace/components/SqlWorkbenchPanel.test.ts create mode 100644 easyflow-ui-admin/app/src/views/dataspace/components/SqlWorkbenchPanel.vue create mode 100644 easyflow-ui-admin/app/src/views/dataspace/dataspace-errors.test.ts create mode 100644 easyflow-ui-admin/app/src/views/dataspace/dataspace-errors.ts create mode 100644 easyflow-ui-admin/app/src/views/dataspace/dataspace-logical-name.test.ts create mode 100644 easyflow-ui-admin/app/src/views/dataspace/dataspace-logical-name.ts create mode 100644 easyflow-ui-admin/app/src/views/dataspace/dataspace-sql.test.ts create mode 100644 easyflow-ui-admin/app/src/views/dataspace/dataspace-sql.ts diff --git a/easyflow-ui-admin/app/src/api/dataspace.ts b/easyflow-ui-admin/app/src/api/dataspace.ts new file mode 100644 index 00000000..aee5b754 --- /dev/null +++ b/easyflow-ui-admin/app/src/api/dataspace.ts @@ -0,0 +1,380 @@ +import { requestClient } from './request'; + +export type DataspaceId = number | string; + +export interface DataspaceColumn { + jdbcType?: number; + name: string; + nullable?: boolean; + ordinalPosition?: number; + primaryKey?: boolean; + remarks?: string; + typeName?: string; +} + +export interface DataspaceConnectionDefinition { + databaseName: string; + databaseType: 'MYSQL' | 'POSTGRESQL'; + expectedRevision?: number; + host: string; + id?: DataspaceId; + name: string; + options?: Record; + password?: string; + port: number; + sslEnabled: boolean; + username: string; +} + +export interface DataspaceConnectionView { + databaseName: string; + databaseProduct?: string; + databaseType: 'MYSQL' | 'POSTGRESQL'; + databaseVersion?: string; + definitionRevision: number; + driverName?: string; + driverVersion?: string; + host: string; + id: DataspaceId; + lastTestMessage?: string; + lastTestStatus?: string; + lastTestedAt?: string; + metadataRefreshedAt?: string; + name: string; + port: number; + sslEnabled: boolean; + status: string; + username: string; +} + +export interface DataspaceProbe { + databaseProduct?: string; + databaseVersion?: string; + driverName?: string; + driverVersion?: string; + errorCode?: string; + latencyMillis: number; + message?: string; + success: boolean; +} + +export interface DataspaceObjectView { + catalogName?: string; + columns: DataspaceColumn[]; + connectionId: DataspaceId; + id: DataspaceId; + objectName: string; + objectType: string; + primaryKeys: string[]; + remarks?: string; + schemaName?: string; +} + +export interface DataspaceSummary { + currentRevision: number; + description?: string; + id: DataspaceId; + modified?: string; + name: string; + sourceCount: number; + status: string; + tableCount: number; +} + +export interface DataspaceTableView { + catalogName?: string; + columns: DataspaceColumn[]; + connectionId: DataspaceId; + connectionName: string; + databaseType: 'MYSQL' | 'POSTGRESQL'; + id: DataspaceId; + objectId: DataspaceId; + objectName: string; + positionX?: number; + positionY?: number; + issueCode?: number; + issueMessage?: string; + schemaAlias: string; + schemaName?: string; + sourceAlias: string; + status: 'ACTIVE' | 'CONNECTION_DISABLED' | 'MISSING'; + tableAlias: string; +} + +export interface DataspaceRelationView { + id: DataspaceId; + issueCode?: number; + issueMessage?: string; + joinType: string; + leftBindingId: DataspaceId; + leftColumn: string; + rightBindingId: DataspaceId; + rightColumn: string; + status: 'ACTIVE' | 'INVALID'; +} + +export interface DataspaceView { + currentRevision: number; + description?: string; + id: DataspaceId; + name: string; + relations: DataspaceRelationView[]; + status: string; + tables: DataspaceTableView[]; +} + +export interface DataspaceTableDefinition { + clientKey: string; + objectId: DataspaceId; + positionX?: number; + positionY?: number; + schemaAlias: string; + sourceAlias: string; + tableAlias: string; +} + +export interface DataspaceRelationDefinition { + joinType: string; + leftClientKey: string; + leftColumn: string; + rightClientKey: string; + rightColumn: string; +} + +export interface DataspaceDefinition { + description?: string; + expectedRevision?: number; + id?: DataspaceId; + name: string; + relations: DataspaceRelationDefinition[]; + tables: DataspaceTableDefinition[]; +} + +export interface DataspaceQueryMetrics { + databaseMillis: number; + firstRowMillis: number; + intermediateRows: number; + localMillis: number; + planCacheHit: boolean; + planningMillis: number; + queryMode: string; + returnedRows: number; + totalMillis: number; + truncated: boolean; +} + +export interface DataspaceQueryColumn { + jdbcType: number; + name: string; + nullable: boolean; + typeName: string; +} + +export interface DataspaceQueryResult { + columns: DataspaceQueryColumn[]; + metrics: DataspaceQueryMetrics; + queryId: string; + rows: unknown[][]; +} + +export interface DataspaceExplainFragment { + candidateIndexes?: string[]; + chosenIndex?: string; + diagnostic?: string; + estimateAvailable?: boolean; + estimatedOutputRows?: number; + estimatedRowWidthBytes?: number; + estimatedRows?: number; + estimatedTransferBytes?: number; + executableSql?: string; + nativePlan?: string; + pushedDownOperators?: string[]; + scanType?: string; + sourceAlias: string; + statisticsCollectedAt?: string; + statisticsSource?: string; + statisticsStatus?: string; +} + +export interface DataspaceExplainJoin { + algorithm: string; + buildSource: string; + estimatedBuildBytes: number; + leftSources?: string[]; + leftSource: string; + reason: string; + rightSources?: string[]; + rightSource: string; + stageIndex?: number; +} + +export interface DataspaceExplainResult { + diagnostic?: string; + estimateAvailable?: boolean; + estimatedLocalMemoryBytes?: number; + estimatedTransferBytes?: number; + executable: boolean; + executionPlan?: string; + fragments: DataspaceExplainFragment[]; + joins: DataspaceExplainJoin[]; + normalizedSql?: string; + queryMode: string; + statisticsStatus: string; +} + +export interface DataspaceQueryRequest { + dataspaceId: DataspaceId; + maxRows: number; + queryId: string; + sql: string; + timeoutSeconds: number; +} + +export type DataspaceSqlCompletionKind = + | 'CATALOG' + | 'COLUMN' + | 'FUNCTION' + | 'KEYWORD' + | 'OTHER' + | 'SCHEMA' + | 'TABLE' + | 'VIEW'; + +export interface DataspaceSqlCompletionItem { + insertText: string; + kind: DataspaceSqlCompletionKind; + label: string; + qualifiedName: string[]; +} + +export interface DataspaceSqlCompletionResult { + items: DataspaceSqlCompletionItem[]; + replaceEnd: number; + replaceStart: number; +} + +export interface DataspaceSqlCompletionRequest { + cursorOffset: number; + dataspaceId: DataspaceId; + sql: string; +} + +export function listDataspaceConnections(keyword = '') { + return requestClient.get( + '/api/v1/dataspaceConnection/list', + { params: { keyword } }, + ); +} + +export function getDataspaceConnection(id: DataspaceId) { + return requestClient.get( + '/api/v1/dataspaceConnection/detail', + { params: { id } }, + ); +} + +export function testDataspaceConnection( + definition: DataspaceConnectionDefinition, +) { + return requestClient.post( + '/api/v1/dataspaceConnection/test', + definition, + ); +} + +export function saveDataspaceConnection( + definition: DataspaceConnectionDefinition, +) { + return requestClient.post( + '/api/v1/dataspaceConnection/save', + definition, + ); +} + +export function setDataspaceConnectionEnabled( + id: DataspaceId, + enabled: boolean, +) { + return requestClient.post( + '/api/v1/dataspaceConnection/status', + { enabled, id }, + ); +} + +export function removeDataspaceConnection(id: DataspaceId) { + return requestClient.post('/api/v1/dataspaceConnection/remove', { id }); +} + +export function listDataspaceObjects(connectionId: DataspaceId, keyword = '') { + return requestClient.get( + '/api/v1/dataspaceConnection/objects', + { params: { connectionId, keyword } }, + ); +} + +export function refreshDataspaceMetadata( + connectionId: DataspaceId, + expectedRevision: number, +) { + return requestClient.post( + '/api/v1/dataspaceConnection/refreshMetadata', + { connectionId, expectedRevision }, + ); +} + +export function listDataspaces(keyword = '') { + return requestClient.get('/api/v1/dataspace/list', { + params: { keyword }, + }); +} + +export function getDataspace(id: DataspaceId) { + return requestClient.get('/api/v1/dataspace/detail', { + params: { id }, + }); +} + +export function saveDataspace(definition: DataspaceDefinition) { + return requestClient.post( + '/api/v1/dataspace/save', + definition, + ); +} + +export function setDataspaceEnabled(id: DataspaceId, enabled: boolean) { + return requestClient.post('/api/v1/dataspace/status', { + enabled, + id, + }); +} + +export function removeDataspace(id: DataspaceId) { + return requestClient.post('/api/v1/dataspace/remove', { id }); +} + +export function queryDataspace(request: DataspaceQueryRequest) { + return requestClient.post( + '/api/v1/dataspaceSql/query', + request, + ); +} + +export function explainDataspace(request: DataspaceQueryRequest) { + return requestClient.post( + '/api/v1/dataspaceSql/explain', + request, + ); +} + +export function completeDataspaceSql(request: DataspaceSqlCompletionRequest) { + return requestClient.post( + '/api/v1/dataspaceSql/complete', + request, + ); +} + +export function cancelDataspaceQuery(queryId: string) { + return requestClient.post('/api/v1/dataspaceSql/cancel', { + queryId, + }); +} diff --git a/easyflow-ui-admin/app/src/api/index.ts b/easyflow-ui-admin/app/src/api/index.ts index 3b785a29..2abf4c38 100644 --- a/easyflow-ui-admin/app/src/api/index.ts +++ b/easyflow-ui-admin/app/src/api/index.ts @@ -1,2 +1,3 @@ export * from './ai'; export * from './core'; +export * from './dataspace'; diff --git a/easyflow-ui-admin/app/src/api/request.ts b/easyflow-ui-admin/app/src/api/request.ts index 8265b031..dfdcc627 100644 --- a/easyflow-ui-admin/app/src/api/request.ts +++ b/easyflow-ui-admin/app/src/api/request.ts @@ -99,6 +99,8 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) { fulfilled: async (config) => { const accessStore = useAccessStore(); + // 明确要求 JSON,避免可选的二进制消息转换器在通配 Accept 下抢占响应序列化。 + config.headers.Accept = 'application/json'; config.headers['easyflow-token'] = formatToken(accessStore.accessToken); config.headers['Accept-Language'] = preferences.app.locale; const workflowShareKey = readWorkflowShareKey(); diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/menus.json b/easyflow-ui-admin/app/src/locales/langs/en-US/menus.json index 104e1e96..80e9f3dc 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/menus.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/menus.json @@ -27,6 +27,7 @@ "bots": "ChatAssistant", "title": "AI", "resources": "Resources", + "dataspace": "Dataspace", "datacenter": "Datacenter", "workflow": "Workflow", "plugin": "Plugin", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/menus.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/menus.json index 6ecd39f9..15916c34 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/menus.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/menus.json @@ -27,6 +27,7 @@ "bots": "聊天助手", "title": "AI能力", "resources": "素材库", + "dataspace": "数据空间", "datacenter": "数据中枢", "workflow": "工作流", "plugin": "插件", diff --git a/easyflow-ui-admin/app/src/router/routes/modules/dataspace.ts b/easyflow-ui-admin/app/src/router/routes/modules/dataspace.ts new file mode 100644 index 00000000..2ba7b769 --- /dev/null +++ b/easyflow-ui-admin/app/src/router/routes/modules/dataspace.ts @@ -0,0 +1,19 @@ +import type { RouteRecordRaw } from 'vue-router'; + +import { $t } from '#/locales'; + +const routes: RouteRecordRaw[] = [ + { + component: () => import('#/views/dataspace/DataspaceWorkspace.vue'), + meta: { + activePath: '/dataspace', + hideInMenu: true, + icon: 'lucide:database-zap', + title: $t('menus.ai.dataspace'), + }, + name: 'DataspaceWorkspace', + path: '/dataspace', + }, +]; + +export default routes; diff --git a/easyflow-ui-admin/app/src/views/dataspace/DataspaceWorkspace.vue b/easyflow-ui-admin/app/src/views/dataspace/DataspaceWorkspace.vue new file mode 100644 index 00000000..12bff826 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/dataspace/DataspaceWorkspace.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/dataspace/components/ConnectionDialog.vue b/easyflow-ui-admin/app/src/views/dataspace/components/ConnectionDialog.vue new file mode 100644 index 00000000..c4c573fc --- /dev/null +++ b/easyflow-ui-admin/app/src/views/dataspace/components/ConnectionDialog.vue @@ -0,0 +1,538 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/dataspace/components/ConnectionPanel.vue b/easyflow-ui-admin/app/src/views/dataspace/components/ConnectionPanel.vue new file mode 100644 index 00000000..9ae117a9 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/dataspace/components/ConnectionPanel.vue @@ -0,0 +1,929 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/dataspace/components/DataspaceModeNav.vue b/easyflow-ui-admin/app/src/views/dataspace/components/DataspaceModeNav.vue new file mode 100644 index 00000000..ab23ebd0 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/dataspace/components/DataspaceModeNav.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/dataspace/components/DataspacePanel.vue b/easyflow-ui-admin/app/src/views/dataspace/components/DataspacePanel.vue new file mode 100644 index 00000000..c835fec2 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/dataspace/components/DataspacePanel.vue @@ -0,0 +1,2709 @@ + + + + + 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 new file mode 100644 index 00000000..f02c57fb --- /dev/null +++ b/easyflow-ui-admin/app/src/views/dataspace/components/SqlWorkbenchPanel.test.ts @@ -0,0 +1,499 @@ +import { flushPromises, shallowMount } from '@vue/test-utils'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import SqlWorkbenchPanel from './SqlWorkbenchPanel.vue'; + +const apiMocks = vi.hoisted(() => ({ + cancel: vi.fn(), + complete: vi.fn(), + explain: vi.fn(), + get: vi.fn(), + list: vi.fn(), + query: vi.fn(), +})); + +vi.mock('#/api/dataspace', () => ({ + cancelDataspaceQuery: apiMocks.cancel, + completeDataspaceSql: apiMocks.complete, + explainDataspace: apiMocks.explain, + getDataspace: apiMocks.get, + listDataspaces: apiMocks.list, + queryDataspace: apiMocks.query, +})); + +const mountWorkbench = () => + shallowMount(SqlWorkbenchPanel, { + global: { + stubs: { + ElTooltip: { template: '
' }, + }, + }, + }); + +describe('sql workbench panel', () => { + beforeEach(() => { + vi.clearAllMocks(); + apiMocks.list.mockResolvedValue([ + { + currentRevision: 1, + id: 1, + name: '经营分析', + sourceCount: 1, + status: 'ENABLED', + tableCount: 1, + }, + ]); + apiMocks.get.mockResolvedValue({ + currentRevision: 1, + id: 1, + name: '经营分析', + relations: [], + status: 'ENABLED', + tables: [ + { + columns: [{ name: 'id', primaryKey: true, typeName: 'BIGINT' }], + connectionId: 1, + connectionName: '销售主库', + databaseType: 'MYSQL', + id: 11, + objectId: 21, + objectName: 'outlet', + schemaAlias: 'MAIN', + sourceAlias: 'MYSQL_1', + status: 'ACTIVE', + tableAlias: 'outlet', + }, + ], + }); + apiMocks.query.mockResolvedValue({ + columns: [ + { jdbcType: -5, name: 'id', nullable: false, typeName: 'BIGINT' }, + ], + metrics: { + databaseMillis: 3, + firstRowMillis: 4, + intermediateRows: 1, + localMillis: 0, + planCacheHit: true, + planningMillis: 2, + queryMode: 'SINGLE_SOURCE', + returnedRows: 1, + totalMillis: 6, + truncated: false, + }, + queryId: 'query-1', + rows: [[1]], + }); + apiMocks.explain.mockResolvedValue({ + diagnostic: 'Calcite validation completed', + estimateAvailable: true, + estimatedLocalMemoryBytes: 0, + estimatedTransferBytes: 61_440, + executable: true, + executionPlan: 'Calcite JdbcToEnumerableConverter', + fragments: [ + { + candidateIndexes: [], + diagnostic: 'normalized from MySQL JSON Explain', + estimateAvailable: true, + estimatedOutputRows: 480, + estimatedRows: 480, + estimatedRowWidthBytes: 128, + estimatedTransferBytes: 61_440, + executableSql: 'SELECT * FROM `data-sheet`.`outlet`', + nativePlan: '{"query_block":{"table":{"access_type":"ALL"}}}', + pushedDownOperators: ['TABLE_SCAN'], + scanType: 'ALL', + sourceAlias: 'MYSQL_1', + statisticsCollectedAt: '2026-08-23T06:00:00Z', + statisticsSource: 'database-metadata', + statisticsStatus: 'COMPLETE', + }, + ], + joins: [], + queryMode: 'SINGLE_SOURCE', + statisticsStatus: 'COMPLETE', + }); + apiMocks.complete.mockResolvedValue({ + items: [ + { + insertText: 'outlet', + kind: 'TABLE', + label: 'outlet', + qualifiedName: ['outlet'], + }, + ], + replaceEnd: 13, + replaceStart: 12, + }); + }); + + it('keeps the editor full height until a query produces output', async () => { + const wrapper = mountWorkbench(); + await flushPromises(); + + expect(wrapper.find('.query-output').exists()).toBe(false); + expect(wrapper.find('.query-splitter').exists()).toBe(false); + expect(wrapper.get('.query-area').classes()).not.toContain('has-output'); + expect(wrapper.find('[aria-label="格式化 SQL"]').exists()).toBe(true); + expect(wrapper.find('[aria-label="Explain 执行计划"]').exists()).toBe(true); + expect(wrapper.find('[aria-label="执行 SQL"]').exists()).toBe(true); + expect( + wrapper.getComponent({ name: 'CodeEditor' }).props('modelValue'), + ).toBe(''); + + Object.defineProperty(wrapper.get('.query-area').element, 'clientHeight', { + configurable: true, + value: 800, + }); + wrapper + .getComponent({ name: 'CodeEditor' }) + .vm.$emit('update:modelValue', 'SELECT * FROM outlet LIMIT 1'); + await flushPromises(); + await wrapper.get('[aria-label="执行 SQL"]').trigger('click'); + await flushPromises(); + + expect(wrapper.get('.query-area').classes()).toContain('has-output'); + expect(wrapper.find('.query-splitter').exists()).toBe(true); + expect(wrapper.get('.query-area').attributes('style')).toContain('412px'); + expect(wrapper.get('.query-output').text()).toContain('查询结果'); + expect(wrapper.get('.query-output').text()).toContain('执行信息'); + }); + + it('does not replace editor content when a logical table is double-clicked', async () => { + const wrapper = mountWorkbench(); + await flushPromises(); + + wrapper + .getComponent({ name: 'CodeEditor' }) + .vm.$emit('update:modelValue', 'SELECT id FROM outlet'); + await flushPromises(); + await wrapper.get('.logical-table-row').trigger('dblclick'); + + expect( + wrapper.getComponent({ name: 'CodeEditor' }).props('modelValue'), + ).toBe('SELECT id FROM outlet'); + }); + + it('places format, execute and explain beside the dataspace selector', async () => { + const wrapper = mountWorkbench(); + await flushPromises(); + + const actions = wrapper + .get('.query-tool-actions') + .findAll('el-button-stub'); + expect(actions.map((action) => action.attributes('aria-label'))).toEqual([ + '格式化 SQL', + '执行 SQL', + 'Explain 执行计划', + ]); + }); + + it('keeps result metrics and an icon-only export action beside the tabs', async () => { + const wrapper = mountWorkbench(); + await flushPromises(); + + wrapper + .getComponent({ name: 'CodeEditor' }) + .vm.$emit('update:modelValue', 'SELECT * FROM outlet LIMIT 1'); + await flushPromises(); + await wrapper.get('[aria-label="执行 SQL"]').trigger('click'); + await flushPromises(); + + const toolbar = wrapper.get('.output-toolbar'); + expect(toolbar.find('.output-tabs').exists()).toBe(true); + expect(toolbar.get('.result-summary').text()).toContain('1 行'); + expect(toolbar.get('.result-summary').text()).toContain('6 ms'); + + const exportAction = toolbar.get('[aria-label="导出 CSV"]'); + expect(exportAction.text()).toBe(''); + }); + + it('shows a dedicated explain surface only after Explain is requested', async () => { + const wrapper = mountWorkbench(); + await flushPromises(); + + wrapper + .getComponent({ name: 'CodeEditor' }) + .vm.$emit('update:modelValue', 'SELECT * FROM outlet LIMIT 1'); + await flushPromises(); + await wrapper.get('[aria-label="Explain 执行计划"]').trigger('click'); + await flushPromises(); + + expect(wrapper.find('.output-tabs').exists()).toBe(false); + const toolbar = wrapper.get('.explain-toolbar'); + expect(toolbar.text()).toContain('Explain'); + expect(toolbar.text()).toContain('可执行'); + expect(toolbar.text()).toContain('单源查询'); + expect(toolbar.text()).toContain('1 个执行分片'); + + const overview = wrapper.get('.explain-overview'); + expect(overview.text()).toContain('1 个全表扫描'); + expect(overview.text()).toContain('60.0 KB'); + expect(overview.text()).toContain('未使用索引'); + + const explain = wrapper.get('.explain-panel'); + expect(explain.text()).toContain('执行路径'); + expect(explain.text()).toContain('统一查询'); + expect(explain.text()).toContain('执行分片'); + expect(explain.text()).toContain('MYSQL_1'); + expect(explain.text()).toContain('全表扫描'); + expect(explain.text()).toContain('ALL'); + expect(explain.text()).toContain('480 行'); + expect(explain.text()).toContain('数据库元数据'); + expect(explain.text()).toContain('表扫描'); + expect(explain.text()).not.toContain('Calcite'); + expect(explain.text()).not.toContain('normalized from'); + expect(explain.findAll('.plan-disclosure')).toHaveLength(2); + expect(explain.find('.plan-disclosure[open]').exists()).toBe(false); + }); + + it('keeps row estimates readable when an older service omits new fields', async () => { + apiMocks.explain.mockResolvedValueOnce({ + executable: true, + fragments: [ + { + candidateIndexes: [], + executableSql: 'SELECT * FROM `data-sheet`.`outlet`', + pushedDownOperators: ['TABLE_SCAN'], + sourceAlias: 'MYSQL_1', + }, + ], + joins: [], + queryMode: 'SINGLE_SOURCE', + }); + const wrapper = mountWorkbench(); + await flushPromises(); + + wrapper + .getComponent({ name: 'CodeEditor' }) + .vm.$emit('update:modelValue', 'SELECT * FROM outlet'); + await flushPromises(); + await wrapper.get('[aria-label="Explain 执行计划"]').trigger('click'); + await flushPromises(); + + expect(wrapper.get('.explain-panel').text()).not.toContain('NaN'); + expect(wrapper.get('.explain-panel').text()).toContain('暂无估算'); + }); + + it('degrades safely when an older federated Explain omits joins', async () => { + apiMocks.explain.mockResolvedValueOnce({ + diagnostic: 'federated plan is executable with 2 fragments', + executable: true, + fragments: [{ sourceAlias: 'MYSQL_1' }, { sourceAlias: 'PG_2' }], + queryMode: 'FEDERATED', + }); + const wrapper = mountWorkbench(); + await flushPromises(); + + wrapper + .getComponent({ name: 'CodeEditor' }) + .vm.$emit('update:modelValue', 'SELECT * FROM outlet JOIN outlet_region'); + await wrapper.get('[aria-label="Explain 执行计划"]').trigger('click'); + await flushPromises(); + + const explain = wrapper.get('.explain-panel'); + expect(explain.text()).toContain('联邦查询'); + expect(explain.text()).toContain('未知'); + expect(explain.text()).toContain('MYSQL_1'); + expect(explain.text()).toContain('PG_2'); + }); + + it('does not present unavailable zero estimates as exact costs', async () => { + apiMocks.explain.mockResolvedValueOnce({ + estimateAvailable: false, + estimatedLocalMemoryBytes: 0, + estimatedTransferBytes: 0, + executable: true, + fragments: [ + { + candidateIndexes: [], + estimateAvailable: false, + estimatedOutputRows: 0, + estimatedRowWidthBytes: 0, + estimatedTransferBytes: 0, + executableSql: 'SELECT * FROM `data-sheet`.`outlet`', + pushedDownOperators: [], + sourceAlias: 'MYSQL_1', + }, + ], + joins: [], + queryMode: 'SINGLE_SOURCE', + }); + const wrapper = mountWorkbench(); + await flushPromises(); + + wrapper + .getComponent({ name: 'CodeEditor' }) + .vm.$emit('update:modelValue', 'SELECT * FROM outlet'); + await flushPromises(); + await wrapper.get('[aria-label="Explain 执行计划"]').trigger('click'); + await flushPromises(); + + const text = wrapper.get('.explain-panel').text(); + expect(text).toContain('暂无估算'); + expect(text).not.toContain('0 B'); + }); + + it('shows federated sources converging into a cross-source execution step', async () => { + apiMocks.explain.mockResolvedValueOnce({ + estimateAvailable: true, + estimatedLocalMemoryBytes: 1024, + estimatedTransferBytes: 5120, + executable: true, + executionPlan: 'EnumerableHashJoin', + fragments: [ + { + candidateIndexes: ['idx_outlet_source_row'], + chosenIndex: 'idx_outlet_source_row', + estimateAvailable: true, + estimatedOutputRows: 32, + estimatedRows: 32, + estimatedRowWidthBytes: 128, + estimatedTransferBytes: 4096, + executableSql: 'SELECT * FROM `data-sheet`.`outlet`', + pushedDownOperators: ['TABLE_SCAN', 'PROJECT'], + scanType: 'REF', + sourceAlias: 'MYSQL_1', + statisticsSource: 'catalog', + statisticsStatus: 'COMPLETE', + }, + { + candidateIndexes: [], + estimateAvailable: true, + estimatedOutputRows: 8, + estimatedRows: 8, + estimatedRowWidthBytes: 128, + estimatedTransferBytes: 1024, + executableSql: 'SELECT * FROM "dataspace_xl16"."outlet_region"', + pushedDownOperators: ['TABLE_SCAN'], + scanType: 'SEQ SCAN', + sourceAlias: 'PG_2', + statisticsSource: 'catalog', + statisticsStatus: 'COMPLETE', + }, + ], + joins: [ + { + algorithm: 'HASH_JOIN', + buildSource: 'PG_2', + estimatedBuildBytes: 1024, + leftSource: 'MYSQL_1', + reason: 'SMALLER_BUILD_SIDE', + rightSource: 'PG_2', + }, + ], + queryMode: 'FEDERATED', + statisticsStatus: 'COMPLETE', + }); + const wrapper = mountWorkbench(); + await flushPromises(); + + wrapper + .getComponent({ name: 'CodeEditor' }) + .vm.$emit('update:modelValue', 'SELECT * FROM outlet JOIN outlet_region'); + await flushPromises(); + await wrapper.get('[aria-label="Explain 执行计划"]').trigger('click'); + await flushPromises(); + + const route = wrapper.get('.execution-route'); + expect(route.classes()).toContain('is-federated'); + expect(route.findAll('.route-source')).toHaveLength(2); + expect(route.text()).toContain('MYSQL_1'); + expect(route.text()).toContain('PG_2'); + expect(route.text()).toContain('Hash Join'); + expect(route.text()).toContain('PG_2 构建侧'); + expect(route.text()).toContain('较小结果作为构建侧'); + expect(route.text()).toContain('结果'); + expect(wrapper.findAll('.fragment-card')).toHaveLength(2); + }); + + it('lists every known join step for a multi-stage federated plan', async () => { + apiMocks.explain.mockResolvedValueOnce({ + estimateAvailable: true, + estimatedLocalMemoryBytes: 2048, + estimatedTransferBytes: 8192, + executable: true, + fragments: ['MYSQL_1', 'PG_2', 'PG_3'].map((sourceAlias) => ({ + candidateIndexes: [], + estimateAvailable: true, + estimatedOutputRows: 8, + estimatedRows: 8, + estimatedRowWidthBytes: 128, + estimatedTransferBytes: 1024, + executableSql: `SELECT * FROM ${sourceAlias}`, + pushedDownOperators: ['TABLE_SCAN'], + scanType: 'SEQ SCAN', + sourceAlias, + statisticsStatus: 'COMPLETE', + })), + joins: [ + { + algorithm: 'HASH_JOIN', + buildSource: 'PG_2', + leftSources: ['MYSQL_1'], + leftSource: 'MYSQL_1', + reason: 'SMALLER_BUILD_SIDE', + rightSources: ['PG_2'], + rightSource: 'PG_2', + stageIndex: 1, + }, + { + algorithm: 'HASH_JOIN', + buildSource: 'PG_3', + leftSources: ['MYSQL_1', 'PG_2'], + leftSource: 'MYSQL_1', + reason: 'SMALLER_BUILD_SIDE', + rightSources: ['PG_3'], + rightSource: 'PG_3', + stageIndex: 2, + }, + ], + queryMode: 'FEDERATED', + statisticsStatus: 'COMPLETE', + }); + const wrapper = mountWorkbench(); + await flushPromises(); + + wrapper + .getComponent({ name: 'CodeEditor' }) + .vm.$emit( + 'update:modelValue', + 'SELECT * FROM a JOIN b ON a.id=b.id JOIN c ON b.id=c.id', + ); + await flushPromises(); + await wrapper.get('[aria-label="Explain 执行计划"]').trigger('click'); + await flushPromises(); + + expect(wrapper.get('.route-merge').text()).toContain('多阶段合并'); + const strategies = wrapper.get('.join-strategies'); + expect(strategies.findAll('article')).toHaveLength(2); + expect(strategies.text()).toContain('MYSQL_1 × PG_2'); + expect(strategies.text()).toContain('MYSQL_1 + PG_2 × PG_3'); + }); + + it('maps Calcite completion candidates into the shared editor', async () => { + const wrapper = mountWorkbench(); + await flushPromises(); + const source = wrapper + .getComponent({ name: 'CodeEditor' }) + .props('completionSource'); + const completion = await source({ + explicit: true, + pos: 13, + state: { doc: { toString: () => 'SELECT * FROM o' } }, + }); + + expect(apiMocks.complete).toHaveBeenCalledWith({ + cursorOffset: 13, + dataspaceId: 1, + sql: 'SELECT * FROM o', + }); + expect(completion).toMatchObject({ + from: 12, + options: [{ apply: 'outlet', label: 'outlet', type: 'class' }], + to: 13, + }); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/dataspace/components/SqlWorkbenchPanel.vue b/easyflow-ui-admin/app/src/views/dataspace/components/SqlWorkbenchPanel.vue new file mode 100644 index 00000000..e1d5cccc --- /dev/null +++ b/easyflow-ui-admin/app/src/views/dataspace/components/SqlWorkbenchPanel.vue @@ -0,0 +1,2294 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/dataspace/dataspace-errors.test.ts b/easyflow-ui-admin/app/src/views/dataspace/dataspace-errors.test.ts new file mode 100644 index 00000000..13c23cdc --- /dev/null +++ b/easyflow-ui-admin/app/src/views/dataspace/dataspace-errors.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { dataspaceErrorCode, dataspaceErrorMessage } from './dataspace-errors'; + +describe('dataspaceErrorMessage', () => { + it('reads the structured API error wrapped by Axios', () => { + const error = Object.assign( + new Error('Request failed with status code 409'), + { + response: { + data: { + errorCode: 40_964, + message: '关联字段已不存在', + }, + }, + }, + ); + + expect(dataspaceErrorMessage(error, '保存失败')).toBe( + '关联字段已不存在 请重新连接字段或删除失效关系。', + ); + expect(dataspaceErrorCode(error)).toBe(40_964); + }); + + it('uses the local fallback for an unstructured failure', () => { + expect(dataspaceErrorMessage({}, 'SQL 查询失败')).toBe('SQL 查询失败'); + }); + + it('keeps the precise credential recovery message without duplication', () => { + const error = { + response: { + data: { + errorCode: 40_905, + message: '连接凭据已失效,请编辑连接并重新输入数据库密码', + }, + }, + }; + + expect(dataspaceErrorMessage(error, 'SQL 查询失败')).toBe( + '连接凭据已失效,请编辑连接并重新输入数据库密码', + ); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/dataspace/dataspace-errors.ts b/easyflow-ui-admin/app/src/views/dataspace/dataspace-errors.ts new file mode 100644 index 00000000..c44a77d7 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/dataspace/dataspace-errors.ts @@ -0,0 +1,67 @@ +interface ApiErrorPayload { + errorCode?: number | string; + message?: string; +} + +const recoveryByCode: Record = { + 40_065: '请检查表、字段关系和数据源范围。', + 40_066: '请检查 SQL、数据空间和查询限制。', + 40_067: '请检查连接地址、端口、数据库和账号。', + 40_901: '请刷新当前页面后重新应用本次修改。', + 40_905: '请编辑连接并重新输入数据库密码', + 40_962: '请到数据连接中启用并测试该连接。', + 40_963: '请刷新连接元数据;对象仍不存在时移除该表。', + 40_964: '请重新连接字段或删除失效关系。', + 40_965: '请选择类型兼容的字段重新连线。', + 40_861: '请稍后重试,或降低并发查询数量。', + 40_862: '请缩小查询范围或提高筛选条件。', + 42_261: '请减少返回行数或缩小联邦查询范围。', + 42_263: '请检查网络、账密和数据库访问权限。', + 50_361: '请检查数据连接状态并重新测试连接。', + 50_362: '请检查 SQL、字段类型和数据连接状态。', + 50_363: '请确认数据库支持 Explain 且连接可用。', +}; + +/** + * 将请求层抛出的结构化错误转换为数据空间用户提示。 + * + * @param error 请求异常 + * @param fallback 无结构化响应时的回退提示 + * @returns 用户可执行的错误提示 + */ +export function dataspaceErrorMessage(error: unknown, fallback: string) { + const payload = errorPayload(error); + const message = payload.message?.trim() || fallback; + const code = Number(payload.errorCode); + const recovery = Number.isFinite(code) ? recoveryByCode[code] : undefined; + if (!recovery || message.includes(recovery)) return message; + return `${message} ${recovery}`; +} + +/** + * 读取数据空间结构化业务错误码。 + * + * @param error 请求异常 + * @returns 稳定错误码;无结构化响应时返回 undefined + */ +export function dataspaceErrorCode(error: unknown) { + const code = Number(errorPayload(error).errorCode); + return Number.isFinite(code) ? code : undefined; +} + +/** + * 从请求异常或 Axios 包装中读取统一错误响应。 + * + * @param error 请求异常 + * @returns 结构化错误响应 + */ +function errorPayload(error: unknown): ApiErrorPayload { + if (!error || typeof error !== 'object') return {}; + const direct = error as ApiErrorPayload & { + response?: { data?: ApiErrorPayload }; + }; + const responsePayload = direct.response?.data; + if (responsePayload) return responsePayload; + if (direct.errorCode !== undefined || direct.message) return direct; + return error instanceof Error ? { message: error.message } : {}; +} diff --git a/easyflow-ui-admin/app/src/views/dataspace/dataspace-logical-name.test.ts b/easyflow-ui-admin/app/src/views/dataspace/dataspace-logical-name.test.ts new file mode 100644 index 00000000..78d51fd8 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/dataspace/dataspace-logical-name.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { + createLogicalTableName, + isLogicalTableName, + logicalTableNameError, + normalizeLogicalTableName, +} from './dataspace-logical-name'; + +describe('dataspace logical table name', () => { + it('uses the physical table name when it is available', () => { + expect(createLogicalTableName('outlet', 'MYSQL_1', [])).toBe('outlet'); + }); + + it('uses source alias and a stable suffix for conflicts', () => { + expect(createLogicalTableName('outlet', 'PG_1', ['OUTLET'])).toBe( + 'PG_1_outlet', + ); + expect( + createLogicalTableName('outlet', 'PG_1', ['outlet', 'pg_1_OUTLET']), + ).toBe('PG_1_outlet_2'); + }); + + it('normalizes physical names that cannot be queried unquoted', () => { + expect(normalizeLogicalTableName('2026-sales detail')).toBe( + '_2026_sales_detail', + ); + }); + + it('validates identifier format and case-insensitive uniqueness', () => { + expect(isLogicalTableName('outlet_main')).toBe(true); + expect(logicalTableNameError('2outlet', [])).toContain('不能以数字开头'); + expect(logicalTableNameError('OUTLET', ['outlet'])).toContain('已存在'); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/dataspace/dataspace-logical-name.ts b/easyflow-ui-admin/app/src/views/dataspace/dataspace-logical-name.ts new file mode 100644 index 00000000..9fc0458a --- /dev/null +++ b/easyflow-ui-admin/app/src/views/dataspace/dataspace-logical-name.ts @@ -0,0 +1,54 @@ +const LOGICAL_NAME_PATTERN = /^[a-z_]\w*$/i; + +/** 判断名称能否作为 Calcite 未加引号逻辑表名。 */ +export function isLogicalTableName(value: string) { + return LOGICAL_NAME_PATTERN.test(value.trim()); +} + +/** 将物理表名转换为可用的默认逻辑表名。 */ +export function normalizeLogicalTableName(value: string) { + let normalized = value.trim().replaceAll(/\W/g, '_'); + if (!normalized) normalized = 'table'; + if (!/^[a-z_]/i.test(normalized)) normalized = `_${normalized}`; + return normalized; +} + +/** + * 为新加入的物理表生成数据空间内大小写不敏感的唯一逻辑表名。 + */ +export function createLogicalTableName( + physicalName: string, + sourceAlias: string, + existingNames: Iterable, +) { + const used = new Set( + [...existingNames].map((name) => name.trim().toUpperCase()), + ); + const physical = normalizeLogicalTableName(physicalName); + if (!used.has(physical.toUpperCase())) return physical; + + const prefix = normalizeLogicalTableName(sourceAlias); + const candidate = `${prefix}_${physical}`; + if (!used.has(candidate.toUpperCase())) return candidate; + + let suffix = 2; + while (used.has(`${candidate}_${suffix}`.toUpperCase())) suffix += 1; + return `${candidate}_${suffix}`; +} + +/** 返回逻辑表名的就近校验提示。 */ +export function logicalTableNameError( + value: string, + existingNames: Iterable, +) { + const normalized = value.trim(); + if (!normalized) return '逻辑表名不能为空'; + if (!isLogicalTableName(normalized)) { + return '逻辑表名只能包含字母、数字和下划线,且不能以数字开头'; + } + const upper = normalized.toUpperCase(); + if ([...existingNames].some((name) => name.trim().toUpperCase() === upper)) { + return `逻辑表名“${normalized}”已存在`; + } + return ''; +} diff --git a/easyflow-ui-admin/app/src/views/dataspace/dataspace-sql.test.ts b/easyflow-ui-admin/app/src/views/dataspace/dataspace-sql.test.ts new file mode 100644 index 00000000..52a2825a --- /dev/null +++ b/easyflow-ui-admin/app/src/views/dataspace/dataspace-sql.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { formatCalciteSql } from './dataspace-sql'; + +describe('formatCalciteSql', () => { + it('formats common Calcite query clauses', () => { + expect( + formatCalciteSql( + 'select o.id, c.name from MYSQL_1.MAIN.outlet o join PG_2.PUBLIC.customer c on c.id=o.id where o.name is not null order by o.id desc limit 100', + ), + ).toBe( + 'SELECT o.id, c.name\nFROM MYSQL_1.MAIN.outlet o\nJOIN PG_2.PUBLIC.customer c ON c.id=o.id\nWHERE o.name is not null\nORDER BY o.id DESC\nLIMIT 100;\n', + ); + }); + + it('preserves quoted keyword literals', () => { + expect(formatCalciteSql("select 'from where' as label from demo")).toBe( + "SELECT 'from where' AS label\nFROM demo;\n", + ); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/dataspace/dataspace-sql.ts b/easyflow-ui-admin/app/src/views/dataspace/dataspace-sql.ts new file mode 100644 index 00000000..e9acc982 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/dataspace/dataspace-sql.ts @@ -0,0 +1,84 @@ +const LINE_BREAK_KEYWORDS = [ + 'SELECT', + 'FROM', + 'WHERE', + 'GROUP BY', + 'HAVING', + 'ORDER BY', + 'LIMIT', + 'OFFSET', + 'INNER JOIN', + 'LEFT JOIN', + 'RIGHT JOIN', + 'FULL JOIN', + 'JOIN', + 'UNION ALL', + 'UNION', +]; + +const INLINE_KEYWORDS = ['AND', 'OR', 'ON', 'AS', 'ASC', 'DESC']; + +/** + * 对工作台中的 Calcite SQL 做轻量、引号安全的可读性格式化。 + * 该方法只处理展示排版,不尝试替代后端 Calcite 解析与校验。 + */ +export function formatCalciteSql(sql: string): string { + const segments = splitQuotedSegments(sql.trim()); + const normalized = segments + .map((segment) => + segment.quoted ? segment.value : normalizePlainSql(segment.value), + ) + .join('') + .replaceAll(/[ \t]+\n/g, '\n') + .replaceAll(/\n{2,}/g, '\n') + .trim(); + return normalized ? `${normalized.replace(/;?$/, ';')}\n` : ''; +} + +/** 将 SQL 拆分为引号内外片段,防止格式化改写字符串字面量。 */ +function splitQuotedSegments(sql: string) { + const segments: Array<{ quoted: boolean; value: string }> = []; + let current = ''; + let quote = ''; + for (let index = 0; index < sql.length; index += 1) { + const character = sql[index] || ''; + current += character; + if (quote) { + if (character === quote) { + const next = sql[index + 1]; + if (next === quote) { + current += next; + index += 1; + } else { + segments.push({ quoted: true, value: current }); + current = ''; + quote = ''; + } + } + } else if (character === "'" || character === '"' || character === '`') { + if (current.length > 1) { + segments.push({ quoted: false, value: current.slice(0, -1) }); + current = character; + } + quote = character; + } + } + if (current) segments.push({ quoted: Boolean(quote), value: current }); + return segments; +} + +/** 规范化非引号 SQL 片段的空白、关键词大小写和换行。 */ +function normalizePlainSql(value: string) { + let result = value.replaceAll(/\s+/g, ' '); + for (const keyword of LINE_BREAK_KEYWORDS) { + const pattern = new RegExp( + `\\b${keyword.replace(' ', String.raw`\s+`)}\\b`, + 'gi', + ); + result = result.replace(pattern, `\n${keyword}`); + } + for (const keyword of INLINE_KEYWORDS) { + result = result.replaceAll(new RegExp(`\\b${keyword}\\b`, 'gi'), keyword); + } + return result.replace(/^\n/, ''); +} diff --git a/easyflow-ui-admin/packages/@core/base/icons/src/local-icons.ts b/easyflow-ui-admin/packages/@core/base/icons/src/local-icons.ts index 36e079fc..b03c6169 100644 --- a/easyflow-ui-admin/packages/@core/base/icons/src/local-icons.ts +++ b/easyflow-ui-admin/packages/@core/base/icons/src/local-icons.ts @@ -5,6 +5,11 @@ import type { IconifyIcon } from '@iconify/vue/offline'; * the small set of full local collections loaded by the application. */ export const LOCAL_ICON_DATA: Record = { + 'lucide:database-zap': { + width: 24, + height: 24, + body: '', + }, 'carbon:workspace': { width: 32, height: 32, diff --git a/easyflow-ui-admin/packages/@core/base/icons/src/lucide.ts b/easyflow-ui-admin/packages/@core/base/icons/src/lucide.ts index 118abdb8..71efb08b 100644 --- a/easyflow-ui-admin/packages/@core/base/icons/src/lucide.ts +++ b/easyflow-ui-admin/packages/@core/base/icons/src/lucide.ts @@ -1,4 +1,5 @@ export { + Activity, ArrowDown, ArrowDownToLine, ArrowLeft, @@ -16,13 +17,20 @@ export { ChevronRight, ChevronsLeft, ChevronsRight, + ChevronUp, Circle, CircleAlert, + CircleCheck, CircleCheckBig, + CircleDot, CircleHelp, CircleX, + Clock3, + Columns3, Copy, CornerDownLeft, + Database, + Download, Ellipsis, EllipsisVertical, Expand, @@ -37,6 +45,7 @@ export { FolderOpen, FoldHorizontal, Fullscreen, + GitCompareArrows, Github, Grip, GripVertical, @@ -44,6 +53,7 @@ export { Inbox, Info, InspectionPanel, + KeyRound, Languages, LoaderCircle, LockKeyhole, @@ -57,14 +67,20 @@ export { Minimize, Minimize2, MoonStar, + MousePointer2, + Move, NotebookTabs, Palette, PanelLeft, PanelRight, + Pencil, Pin, PinOff, + Play, Plus, + RefreshCw, RotateCw, + ScanEye, Search, SearchX, Settings, @@ -72,11 +88,15 @@ export { Square, SquareCheckBig, SquareMinus, + SquareTerminal, Sun, SunMoon, SwatchBook, + Table2, + Trash2, Upload, UserRoundPen, + WandSparkles, X, } from 'lucide-vue-next'; diff --git a/easyflow-ui-admin/packages/@core/ui-kit/editor-ui/package.json b/easyflow-ui-admin/packages/@core/ui-kit/editor-ui/package.json index caab334d..0a978ac0 100644 --- a/easyflow-ui-admin/packages/@core/ui-kit/editor-ui/package.json +++ b/easyflow-ui-admin/packages/@core/ui-kit/editor-ui/package.json @@ -39,6 +39,7 @@ } }, "dependencies": { + "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.2", "@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-markdown": "^6.5.0", diff --git a/easyflow-ui-admin/packages/@core/ui-kit/editor-ui/src/CodeEditor.test.ts b/easyflow-ui-admin/packages/@core/ui-kit/editor-ui/src/CodeEditor.test.ts index 4b8246a1..d7bc9278 100644 --- a/easyflow-ui-admin/packages/@core/ui-kit/editor-ui/src/CodeEditor.test.ts +++ b/easyflow-ui-admin/packages/@core/ui-kit/editor-ui/src/CodeEditor.test.ts @@ -1,5 +1,7 @@ import { flushPromises, mount } from '@vue/test-utils'; +import { completionStatus, startCompletion } from '@codemirror/autocomplete'; +import { EditorView } from '@codemirror/view'; import { beforeEach, expect, it, vi } from 'vitest'; import CodeEditor from './CodeEditor.vue'; @@ -83,3 +85,65 @@ it('shows a recoverable state when a language extension fails', async () => { expect(wrapper.findAll('.cm-editor')).toHaveLength(1); wrapper.unmount(); }); + +it('supports a workbench editor without the outer focus ring', () => { + const wrapper = mount(CodeEditor, { + props: { focusRing: false, modelValue: 'SELECT 1;' }, + }); + + expect(wrapper.classes()).toContain('is-focus-ring-hidden'); + wrapper.unmount(); +}); + +it('supports dynamically enabling an asynchronous completion source', async () => { + const completionSource = vi.fn(() => null); + const wrapper = mount(CodeEditor, { + props: { modelValue: 'SELECT ' }, + }); + await flushPromises(); + + await wrapper.setProps({ completionSource }); + + expect(wrapper.find('.cm-editor').exists()).toBe(true); + wrapper.unmount(); +}); + +it.each(['Tab', 'Enter'])( + 'accepts the selected completion with %s', + async (key) => { + const completionSource = vi.fn(() => ({ + from: 0, + options: [{ label: 'SELECT' }], + })); + const wrapper = mount(CodeEditor, { + attachTo: document.body, + props: { completionSource, modelValue: 'SEL' }, + }); + await flushPromises(); + + const editorElement = wrapper.get('.cm-editor').element as HTMLElement; + const view = EditorView.findFromDOM(editorElement); + expect(view).not.toBeNull(); + if (!view) return; + + view.dispatch({ selection: { anchor: view.state.doc.length } }); + view.focus(); + startCompletion(view); + await vi.waitFor(() => { + expect(completionStatus(view.state)).toBe('active'); + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + view.contentDOM.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + code: key, + key, + }), + ); + await flushPromises(); + + expect(view.state.doc.toString()).toBe('SELECT'); + wrapper.unmount(); + }, +); diff --git a/easyflow-ui-admin/packages/@core/ui-kit/editor-ui/src/CodeEditor.vue b/easyflow-ui-admin/packages/@core/ui-kit/editor-ui/src/CodeEditor.vue index 215957c6..cb6ff65d 100644 --- a/easyflow-ui-admin/packages/@core/ui-kit/editor-ui/src/CodeEditor.vue +++ b/easyflow-ui-admin/packages/@core/ui-kit/editor-ui/src/CodeEditor.vue @@ -1,10 +1,12 @@