feat: 重构数据空间与 SQL 工作台
- 提供连接管理、逻辑表编排和轻量 SQL 工作台 - 增加 SQL 补全、执行分析、结果分栏与导出交互 - 统一数据空间导航、图标和编辑器体验
This commit is contained in:
380
easyflow-ui-admin/app/src/api/dataspace.ts
Normal file
380
easyflow-ui-admin/app/src/api/dataspace.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<DataspaceConnectionView[]>(
|
||||||
|
'/api/v1/dataspaceConnection/list',
|
||||||
|
{ params: { keyword } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDataspaceConnection(id: DataspaceId) {
|
||||||
|
return requestClient.get<DataspaceConnectionView>(
|
||||||
|
'/api/v1/dataspaceConnection/detail',
|
||||||
|
{ params: { id } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testDataspaceConnection(
|
||||||
|
definition: DataspaceConnectionDefinition,
|
||||||
|
) {
|
||||||
|
return requestClient.post<DataspaceProbe>(
|
||||||
|
'/api/v1/dataspaceConnection/test',
|
||||||
|
definition,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDataspaceConnection(
|
||||||
|
definition: DataspaceConnectionDefinition,
|
||||||
|
) {
|
||||||
|
return requestClient.post<DataspaceConnectionView>(
|
||||||
|
'/api/v1/dataspaceConnection/save',
|
||||||
|
definition,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDataspaceConnectionEnabled(
|
||||||
|
id: DataspaceId,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
return requestClient.post<DataspaceConnectionView>(
|
||||||
|
'/api/v1/dataspaceConnection/status',
|
||||||
|
{ enabled, id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeDataspaceConnection(id: DataspaceId) {
|
||||||
|
return requestClient.post<null>('/api/v1/dataspaceConnection/remove', { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDataspaceObjects(connectionId: DataspaceId, keyword = '') {
|
||||||
|
return requestClient.get<DataspaceObjectView[]>(
|
||||||
|
'/api/v1/dataspaceConnection/objects',
|
||||||
|
{ params: { connectionId, keyword } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshDataspaceMetadata(
|
||||||
|
connectionId: DataspaceId,
|
||||||
|
expectedRevision: number,
|
||||||
|
) {
|
||||||
|
return requestClient.post<DataspaceObjectView[]>(
|
||||||
|
'/api/v1/dataspaceConnection/refreshMetadata',
|
||||||
|
{ connectionId, expectedRevision },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDataspaces(keyword = '') {
|
||||||
|
return requestClient.get<DataspaceSummary[]>('/api/v1/dataspace/list', {
|
||||||
|
params: { keyword },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDataspace(id: DataspaceId) {
|
||||||
|
return requestClient.get<DataspaceView>('/api/v1/dataspace/detail', {
|
||||||
|
params: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDataspace(definition: DataspaceDefinition) {
|
||||||
|
return requestClient.post<DataspaceView>(
|
||||||
|
'/api/v1/dataspace/save',
|
||||||
|
definition,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDataspaceEnabled(id: DataspaceId, enabled: boolean) {
|
||||||
|
return requestClient.post<null>('/api/v1/dataspace/status', {
|
||||||
|
enabled,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeDataspace(id: DataspaceId) {
|
||||||
|
return requestClient.post<null>('/api/v1/dataspace/remove', { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryDataspace(request: DataspaceQueryRequest) {
|
||||||
|
return requestClient.post<DataspaceQueryResult>(
|
||||||
|
'/api/v1/dataspaceSql/query',
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function explainDataspace(request: DataspaceQueryRequest) {
|
||||||
|
return requestClient.post<DataspaceExplainResult>(
|
||||||
|
'/api/v1/dataspaceSql/explain',
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeDataspaceSql(request: DataspaceSqlCompletionRequest) {
|
||||||
|
return requestClient.post<DataspaceSqlCompletionResult>(
|
||||||
|
'/api/v1/dataspaceSql/complete',
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelDataspaceQuery(queryId: string) {
|
||||||
|
return requestClient.post<boolean>('/api/v1/dataspaceSql/cancel', {
|
||||||
|
queryId,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './ai';
|
export * from './ai';
|
||||||
export * from './core';
|
export * from './core';
|
||||||
|
export * from './dataspace';
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
|||||||
fulfilled: async (config) => {
|
fulfilled: async (config) => {
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
|
|
||||||
|
// 明确要求 JSON,避免可选的二进制消息转换器在通配 Accept 下抢占响应序列化。
|
||||||
|
config.headers.Accept = 'application/json';
|
||||||
config.headers['easyflow-token'] = formatToken(accessStore.accessToken);
|
config.headers['easyflow-token'] = formatToken(accessStore.accessToken);
|
||||||
config.headers['Accept-Language'] = preferences.app.locale;
|
config.headers['Accept-Language'] = preferences.app.locale;
|
||||||
const workflowShareKey = readWorkflowShareKey();
|
const workflowShareKey = readWorkflowShareKey();
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"bots": "ChatAssistant",
|
"bots": "ChatAssistant",
|
||||||
"title": "AI",
|
"title": "AI",
|
||||||
"resources": "Resources",
|
"resources": "Resources",
|
||||||
|
"dataspace": "Dataspace",
|
||||||
"datacenter": "Datacenter",
|
"datacenter": "Datacenter",
|
||||||
"workflow": "Workflow",
|
"workflow": "Workflow",
|
||||||
"plugin": "Plugin",
|
"plugin": "Plugin",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"bots": "聊天助手",
|
"bots": "聊天助手",
|
||||||
"title": "AI能力",
|
"title": "AI能力",
|
||||||
"resources": "素材库",
|
"resources": "素材库",
|
||||||
|
"dataspace": "数据空间",
|
||||||
"datacenter": "数据中枢",
|
"datacenter": "数据中枢",
|
||||||
"workflow": "工作流",
|
"workflow": "工作流",
|
||||||
"plugin": "插件",
|
"plugin": "插件",
|
||||||
|
|||||||
19
easyflow-ui-admin/app/src/router/routes/modules/dataspace.ts
Normal file
19
easyflow-ui-admin/app/src/router/routes/modules/dataspace.ts
Normal file
@@ -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;
|
||||||
128
easyflow-ui-admin/app/src/views/dataspace/DataspaceWorkspace.vue
Normal file
128
easyflow-ui-admin/app/src/views/dataspace/DataspaceWorkspace.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DataspaceId } from '#/api/dataspace';
|
||||||
|
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import ConnectionPanel from './components/ConnectionPanel.vue';
|
||||||
|
import DataspaceModeNav from './components/DataspaceModeNav.vue';
|
||||||
|
import DataspacePanel from './components/DataspacePanel.vue';
|
||||||
|
import SqlWorkbenchPanel from './components/SqlWorkbenchPanel.vue';
|
||||||
|
|
||||||
|
type WorkspaceTab = 'connections' | 'spaces' | 'sql';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const activeTab = ref<WorkspaceTab>('connections');
|
||||||
|
const workbenchDataspaceId = ref<DataspaceId>();
|
||||||
|
|
||||||
|
/** 切换工作区,并把可恢复状态写入当前路由查询参数。 */
|
||||||
|
function switchTab(tab: WorkspaceTab) {
|
||||||
|
activeTab.value = tab;
|
||||||
|
void router.replace({
|
||||||
|
query: {
|
||||||
|
...route.query,
|
||||||
|
dataspaceId:
|
||||||
|
tab === 'sql' && workbenchDataspaceId.value
|
||||||
|
? String(workbenchDataspaceId.value)
|
||||||
|
: undefined,
|
||||||
|
tab,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从数据空间详情直接打开对应 SQL 工作台。 */
|
||||||
|
function openWorkbench(dataspaceId: DataspaceId) {
|
||||||
|
workbenchDataspaceId.value = dataspaceId;
|
||||||
|
switchTab('sql');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从页面内模式导航切换工作区,并保留当前数据空间上下文。 */
|
||||||
|
function switchWorkspaceTab(tab: WorkspaceTab, dataspaceId?: DataspaceId) {
|
||||||
|
if (tab === 'sql' && dataspaceId) {
|
||||||
|
openWorkbench(dataspaceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switchTab(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const tab = String(route.query.tab || 'connections');
|
||||||
|
if (['connections', 'spaces', 'sql'].includes(tab)) {
|
||||||
|
activeTab.value = tab as WorkspaceTab;
|
||||||
|
}
|
||||||
|
if (route.query.dataspaceId) {
|
||||||
|
workbenchDataspaceId.value = String(route.query.dataspaceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dataspace-workspace">
|
||||||
|
<div class="workspace-content">
|
||||||
|
<ConnectionPanel v-if="activeTab === 'connections'">
|
||||||
|
<template #workspace-navigation>
|
||||||
|
<DataspaceModeNav
|
||||||
|
:model-value="activeTab"
|
||||||
|
@update:model-value="switchTab"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ConnectionPanel>
|
||||||
|
<DataspacePanel
|
||||||
|
v-else-if="activeTab === 'spaces'"
|
||||||
|
@open-workbench="openWorkbench"
|
||||||
|
>
|
||||||
|
<template #workspace-navigation="{ dataspaceId }">
|
||||||
|
<DataspaceModeNav
|
||||||
|
:model-value="activeTab"
|
||||||
|
@update:model-value="switchWorkspaceTab($event, dataspaceId)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DataspacePanel>
|
||||||
|
<SqlWorkbenchPanel v-else :initial-dataspace-id="workbenchDataspaceId">
|
||||||
|
<template #workspace-navigation>
|
||||||
|
<DataspaceModeNav
|
||||||
|
:model-value="activeTab"
|
||||||
|
@update:model-value="switchTab"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SqlWorkbenchPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dataspace-workspace {
|
||||||
|
--dataspace-toolbar-height: 52px;
|
||||||
|
--dataspace-toolbar-padding-inline: var(--space-4);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
height: var(--easyflow-content-height, 100%);
|
||||||
|
max-height: var(--easyflow-content-height, 100%);
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content > :deep(*) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.dataspace-workspace {
|
||||||
|
--dataspace-toolbar-padding-inline: var(--space-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
DataspaceConnectionDefinition,
|
||||||
|
DataspaceConnectionView,
|
||||||
|
DataspaceProbe,
|
||||||
|
} from '#/api/dataspace';
|
||||||
|
|
||||||
|
import { computed, reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
CircleCheck,
|
||||||
|
CircleX,
|
||||||
|
LockKeyhole,
|
||||||
|
X,
|
||||||
|
} from '@easyflow/icons';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElDialog,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElInputNumber,
|
||||||
|
ElMessage,
|
||||||
|
ElPopover,
|
||||||
|
ElSwitch,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import {
|
||||||
|
saveDataspaceConnection,
|
||||||
|
testDataspaceConnection,
|
||||||
|
} from '#/api/dataspace';
|
||||||
|
import mysqlIcon from '#/assets/datacenter/mysql-icon.svg';
|
||||||
|
import postgresqlIcon from '#/assets/datacenter/postgresql-icon.svg';
|
||||||
|
|
||||||
|
import { dataspaceErrorCode, dataspaceErrorMessage } from '../dataspace-errors';
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
saved: [connection: DataspaceConnectionView];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const testing = ref(false);
|
||||||
|
const showProbe = ref(false);
|
||||||
|
const probe = ref<DataspaceProbe>();
|
||||||
|
const credentialError = ref('');
|
||||||
|
const CREDENTIAL_UNAVAILABLE_ERROR_CODE = 40_905;
|
||||||
|
const CREDENTIAL_UNAVAILABLE_MESSAGE = '连接凭据已失效,请重新输入数据库密码';
|
||||||
|
const form = reactive<DataspaceConnectionDefinition>({
|
||||||
|
databaseName: '',
|
||||||
|
databaseType: 'MYSQL',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
name: '',
|
||||||
|
options: {},
|
||||||
|
password: '',
|
||||||
|
port: 3306,
|
||||||
|
sslEnabled: false,
|
||||||
|
username: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEditing = computed(() => form.id !== undefined);
|
||||||
|
const title = computed(() => (isEditing.value ? '编辑连接' : '新建连接'));
|
||||||
|
const databaseTypes = [
|
||||||
|
{ type: 'MYSQL' as const, label: 'MySQL', icon: mysqlIcon },
|
||||||
|
{
|
||||||
|
type: 'POSTGRESQL' as const,
|
||||||
|
label: 'PostgreSQL',
|
||||||
|
icon: postgresqlIcon,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
const selectedDatabaseType = computed(
|
||||||
|
() =>
|
||||||
|
databaseTypes.find((item) => item.type === form.databaseType) ??
|
||||||
|
databaseTypes[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 打开连接表单并同步编辑数据。 */
|
||||||
|
function open(connection?: DataspaceConnectionView) {
|
||||||
|
Object.assign(form, {
|
||||||
|
databaseName: connection?.databaseName || '',
|
||||||
|
databaseType: connection?.databaseType || 'MYSQL',
|
||||||
|
expectedRevision: connection?.definitionRevision,
|
||||||
|
host: connection?.host || '127.0.0.1',
|
||||||
|
id: connection?.id,
|
||||||
|
name: connection?.name || '',
|
||||||
|
options: {},
|
||||||
|
password: '',
|
||||||
|
port: connection?.port || 3306,
|
||||||
|
sslEnabled: connection?.sslEnabled || false,
|
||||||
|
username: connection?.username || '',
|
||||||
|
});
|
||||||
|
probe.value = undefined;
|
||||||
|
credentialError.value = '';
|
||||||
|
showProbe.value = false;
|
||||||
|
visible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换数据库类型并填入对应默认端口。 */
|
||||||
|
function chooseType(type: 'MYSQL' | 'POSTGRESQL') {
|
||||||
|
if (isEditing.value) return;
|
||||||
|
form.databaseType = type;
|
||||||
|
form.port = type === 'MYSQL' ? 3306 : 5432;
|
||||||
|
probe.value = undefined;
|
||||||
|
showProbe.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回当前候选连接的安全副本。 */
|
||||||
|
function payload(): DataspaceConnectionDefinition {
|
||||||
|
return {
|
||||||
|
...form,
|
||||||
|
databaseName: form.databaseName.trim(),
|
||||||
|
host: form.host.trim(),
|
||||||
|
name: form.name.trim(),
|
||||||
|
options: { ...form.options },
|
||||||
|
password: form.password || undefined,
|
||||||
|
username: form.username.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验用户必填连接字段。 */
|
||||||
|
function validate() {
|
||||||
|
if (!form.name.trim()) throw new Error('请输入连接名称');
|
||||||
|
if (!form.host.trim()) throw new Error('请输入主机地址');
|
||||||
|
if (!form.port) throw new Error('请输入端口');
|
||||||
|
if (!form.databaseName.trim()) throw new Error('请输入数据库');
|
||||||
|
if (!form.username.trim()) throw new Error('请输入用户名');
|
||||||
|
if (!form.id && !form.password) throw new Error('请输入密码');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 识别凭据失效响应,并在密码输入框附近提供恢复提示。 */
|
||||||
|
function showCredentialRecovery(error: unknown): boolean {
|
||||||
|
if (dataspaceErrorCode(error) !== CREDENTIAL_UNAVAILABLE_ERROR_CODE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
credentialError.value = CREDENTIAL_UNAVAILABLE_MESSAGE;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试候选连接并展示轻量悬浮结果。 */
|
||||||
|
async function handleTest() {
|
||||||
|
try {
|
||||||
|
validate();
|
||||||
|
testing.value = true;
|
||||||
|
probe.value = await testDataspaceConnection(payload());
|
||||||
|
showProbe.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (showCredentialRecovery(error)) return;
|
||||||
|
ElMessage.error(dataspaceErrorMessage(error, '连接测试失败'));
|
||||||
|
} finally {
|
||||||
|
testing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存已验证的连接定义。 */
|
||||||
|
async function handleSave() {
|
||||||
|
try {
|
||||||
|
validate();
|
||||||
|
saving.value = true;
|
||||||
|
const saved = await saveDataspaceConnection(payload());
|
||||||
|
visible.value = false;
|
||||||
|
emit('saved', saved);
|
||||||
|
ElMessage.success('连接已保存');
|
||||||
|
} catch (error) {
|
||||||
|
if (showCredentialRecovery(error)) return;
|
||||||
|
ElMessage.error(dataspaceErrorMessage(error, '连接保存失败'));
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElDialog
|
||||||
|
v-model="visible"
|
||||||
|
class="dataspace-connection-dialog"
|
||||||
|
:title="title"
|
||||||
|
width="720px"
|
||||||
|
append-to-body
|
||||||
|
destroy-on-close
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@closed="showProbe = false"
|
||||||
|
>
|
||||||
|
<ElForm class="connection-form" label-position="top" @submit.prevent>
|
||||||
|
<ElFormItem label="数据库类型">
|
||||||
|
<div v-if="isEditing" class="readonly-database-type">
|
||||||
|
<img
|
||||||
|
:src="selectedDatabaseType.icon"
|
||||||
|
:alt="`${selectedDatabaseType.label} logo`"
|
||||||
|
/>
|
||||||
|
<span>{{ selectedDatabaseType.label }}</span>
|
||||||
|
<LockKeyhole aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="database-type-grid">
|
||||||
|
<button
|
||||||
|
v-for="item in databaseTypes"
|
||||||
|
:key="item.type"
|
||||||
|
class="database-type-card"
|
||||||
|
:class="{ active: form.databaseType === item.type }"
|
||||||
|
type="button"
|
||||||
|
:aria-pressed="form.databaseType === item.type"
|
||||||
|
@click="chooseType(item.type)"
|
||||||
|
>
|
||||||
|
<img :src="item.icon" :alt="`${item.label} logo`" />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<CircleCheck
|
||||||
|
v-if="form.databaseType === item.type"
|
||||||
|
class="type-check"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<ElFormItem class="span-2" label="连接名称">
|
||||||
|
<ElInput
|
||||||
|
v-model="form.name"
|
||||||
|
maxlength="64"
|
||||||
|
placeholder="例如:销售主库"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="主机地址">
|
||||||
|
<ElInput
|
||||||
|
v-model="form.host"
|
||||||
|
:class="{ 'readonly-control': isEditing }"
|
||||||
|
placeholder="127.0.0.1"
|
||||||
|
:readonly="isEditing"
|
||||||
|
>
|
||||||
|
<template v-if="isEditing" #suffix>
|
||||||
|
<LockKeyhole aria-hidden="true" />
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="端口">
|
||||||
|
<ElInput
|
||||||
|
v-if="isEditing"
|
||||||
|
class="readonly-control"
|
||||||
|
:model-value="String(form.port)"
|
||||||
|
readonly
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<LockKeyhole aria-hidden="true" />
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
<ElInputNumber
|
||||||
|
v-else
|
||||||
|
v-model="form.port"
|
||||||
|
:min="1"
|
||||||
|
:max="65_535"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="数据库">
|
||||||
|
<ElInput v-model="form.databaseName" placeholder="database" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="用户名">
|
||||||
|
<ElInput
|
||||||
|
v-model="form.username"
|
||||||
|
autocomplete="off"
|
||||||
|
name="dataspace-database-user"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem
|
||||||
|
class="span-2"
|
||||||
|
:error="credentialError || undefined"
|
||||||
|
label="密码"
|
||||||
|
>
|
||||||
|
<ElInput
|
||||||
|
v-model="form.password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
name="dataspace-database-password"
|
||||||
|
:placeholder="
|
||||||
|
credentialError
|
||||||
|
? '请重新输入数据库密码'
|
||||||
|
: form.id
|
||||||
|
? '留空则保留原密码'
|
||||||
|
: '请输入密码'
|
||||||
|
"
|
||||||
|
show-password
|
||||||
|
type="password"
|
||||||
|
@input="credentialError = ''"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="switch-row">
|
||||||
|
<span>SSL 连接</span>
|
||||||
|
<ElSwitch v-model="form.sslEnabled" />
|
||||||
|
</div>
|
||||||
|
</ElForm>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<ElPopover
|
||||||
|
v-model:visible="showProbe"
|
||||||
|
placement="top-start"
|
||||||
|
:width="300"
|
||||||
|
trigger="click"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<ElButton :icon="Activity" :loading="testing" @click="handleTest">
|
||||||
|
测试连接
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
<div v-if="probe" class="probe-popover">
|
||||||
|
<div
|
||||||
|
class="probe-title"
|
||||||
|
:class="[probe.success ? 'success' : 'failed']"
|
||||||
|
>
|
||||||
|
<CircleCheck v-if="probe.success" />
|
||||||
|
<CircleX v-else />
|
||||||
|
{{ probe.success ? '连接成功' : '连接失败' }}
|
||||||
|
<button
|
||||||
|
aria-label="关闭测试结果"
|
||||||
|
type="button"
|
||||||
|
@click="showProbe = false"
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<dt>响应时间</dt>
|
||||||
|
<dd>{{ probe.latencyMillis }} ms</dd>
|
||||||
|
<dt>数据库版本</dt>
|
||||||
|
<dd>{{ probe.databaseVersion || '-' }}</dd>
|
||||||
|
<dt>驱动版本</dt>
|
||||||
|
<dd>{{ probe.driverVersion || '-' }}</dd>
|
||||||
|
<template v-if="probe.message">
|
||||||
|
<dt>信息</dt>
|
||||||
|
<dd>{{ probe.message }}</dd>
|
||||||
|
</template>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</ElPopover>
|
||||||
|
<div class="footer-actions">
|
||||||
|
<ElButton @click="visible = false">取消</ElButton>
|
||||||
|
<ElButton type="primary" :loading="saving" @click="handleSave">
|
||||||
|
保存连接
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.connection-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-form :deep(.el-form-item__label) {
|
||||||
|
padding-bottom: 0;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.database-type-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
column-gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.database-type-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-height: 56px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
border: 1px solid hsl(var(--line-subtle));
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: hsl(var(--surface-panel));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
background-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.database-type-card:hover,
|
||||||
|
.database-type-card:focus-visible {
|
||||||
|
border-color: hsl(var(--primary) / 0.64);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.database-type-card.active {
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
background: hsl(var(--primary) / 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.database-type-card img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-check {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: var(--space-3);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-database-type {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border: 1px solid hsl(var(--line-subtle));
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: hsl(var(--surface-subtle));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-database-type img {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-database-type svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-left: auto;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid :deep(.el-input-number) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-control :deep(.el-input__wrapper) {
|
||||||
|
background: hsl(var(--surface-subtle));
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-control :deep(.el-input__inner) {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-control :deep(.el-input__suffix svg) {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid .span-2 {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-row {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 32px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer,
|
||||||
|
.footer-actions,
|
||||||
|
.probe-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-actions {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.probe-title {
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.probe-title.success {
|
||||||
|
color: hsl(var(--success));
|
||||||
|
}
|
||||||
|
|
||||||
|
.probe-title.failed {
|
||||||
|
color: hsl(var(--destructive));
|
||||||
|
}
|
||||||
|
|
||||||
|
.probe-title button {
|
||||||
|
margin-left: auto;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.probe-popover dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin: 16px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.probe-popover dt {
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.probe-popover dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.database-type-grid,
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid .span-2 {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,929 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
DataspaceConnectionView,
|
||||||
|
DataspaceObjectView,
|
||||||
|
} from '#/api/dataspace';
|
||||||
|
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Columns3,
|
||||||
|
Database,
|
||||||
|
Ellipsis,
|
||||||
|
KeyRound,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
ScanEye,
|
||||||
|
Search,
|
||||||
|
Table2,
|
||||||
|
} from '@easyflow/icons';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElDropdown,
|
||||||
|
ElDropdownItem,
|
||||||
|
ElDropdownMenu,
|
||||||
|
ElEmpty,
|
||||||
|
ElInput,
|
||||||
|
ElMessage,
|
||||||
|
ElMessageBox,
|
||||||
|
ElOption,
|
||||||
|
ElSelect,
|
||||||
|
ElSkeleton,
|
||||||
|
ElSkeletonItem,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import {
|
||||||
|
listDataspaceConnections,
|
||||||
|
listDataspaceObjects,
|
||||||
|
refreshDataspaceMetadata,
|
||||||
|
removeDataspaceConnection,
|
||||||
|
setDataspaceConnectionEnabled,
|
||||||
|
testDataspaceConnection,
|
||||||
|
} from '#/api/dataspace';
|
||||||
|
import mysqlIcon from '#/assets/datacenter/mysql-icon.svg';
|
||||||
|
import postgresqlIcon from '#/assets/datacenter/postgresql-icon.svg';
|
||||||
|
|
||||||
|
import ConnectionDialog from './ConnectionDialog.vue';
|
||||||
|
|
||||||
|
interface ObjectGroup {
|
||||||
|
label: string;
|
||||||
|
objects: DataspaceObjectView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
changed: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dialogRef = ref<InstanceType<typeof ConnectionDialog>>();
|
||||||
|
const loading = ref(false);
|
||||||
|
const objectLoading = ref(false);
|
||||||
|
const testing = ref(false);
|
||||||
|
const refreshing = ref(false);
|
||||||
|
const statusChanging = ref(false);
|
||||||
|
const connections = ref<DataspaceConnectionView[]>([]);
|
||||||
|
const selectedConnectionId = ref<number | string>();
|
||||||
|
const objects = ref<DataspaceObjectView[]>([]);
|
||||||
|
const selectedObjectId = ref<number | string>();
|
||||||
|
const keyword = ref('');
|
||||||
|
const expandedGroups = ref<Set<string>>(new Set());
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let objectLoadEpoch = 0;
|
||||||
|
|
||||||
|
const selectedConnection = computed(() =>
|
||||||
|
connections.value.find(
|
||||||
|
(item) => String(item.id) === String(selectedConnectionId.value),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedObject = computed(() =>
|
||||||
|
objects.value.find(
|
||||||
|
(item) => String(item.id) === String(selectedObjectId.value),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedConnectionHealth = computed(() => {
|
||||||
|
const connection = selectedConnection.value;
|
||||||
|
if (!connection || connection.status !== 'ENABLED') {
|
||||||
|
return { label: '连接已禁用', state: 'disabled' };
|
||||||
|
}
|
||||||
|
if (connection.lastTestStatus === 'SUCCESS') {
|
||||||
|
return { label: '连接正常', state: 'success' };
|
||||||
|
}
|
||||||
|
if (connection.lastTestStatus === 'FAILED') {
|
||||||
|
return { label: '连接异常', state: 'error' };
|
||||||
|
}
|
||||||
|
return { label: '连接未检测', state: 'unknown' };
|
||||||
|
});
|
||||||
|
const objectGroups = computed<ObjectGroup[]>(() => {
|
||||||
|
const groups = new Map<string, DataspaceObjectView[]>();
|
||||||
|
for (const object of objects.value) {
|
||||||
|
const key = object.schemaName || object.catalogName || 'default';
|
||||||
|
const list = groups.get(key) || [];
|
||||||
|
list.push(object);
|
||||||
|
groups.set(key, list);
|
||||||
|
}
|
||||||
|
return [...groups.entries()]
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([label, items]) => ({
|
||||||
|
label,
|
||||||
|
objects: [...items].sort((left, right) =>
|
||||||
|
left.objectName.localeCompare(right.objectName),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
/** 加载连接列表并维持当前选择。 */
|
||||||
|
async function loadConnections(preferredId?: number | string) {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const rows = await listDataspaceConnections();
|
||||||
|
connections.value = rows;
|
||||||
|
const target = preferredId ?? selectedConnectionId.value;
|
||||||
|
selectedConnectionId.value = rows.some(
|
||||||
|
(item) => String(item.id) === String(target),
|
||||||
|
)
|
||||||
|
? target
|
||||||
|
: rows[0]?.id;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载当前连接的数据库对象,使用 epoch 丢弃过期响应。 */
|
||||||
|
async function loadObjects() {
|
||||||
|
const connectionId = selectedConnectionId.value;
|
||||||
|
if (!connectionId) {
|
||||||
|
objects.value = [];
|
||||||
|
selectedObjectId.value = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const epoch = ++objectLoadEpoch;
|
||||||
|
objectLoading.value = true;
|
||||||
|
try {
|
||||||
|
const rows = await listDataspaceObjects(connectionId, keyword.value.trim());
|
||||||
|
if (epoch !== objectLoadEpoch) return;
|
||||||
|
objects.value = rows;
|
||||||
|
const currentExists = rows.some(
|
||||||
|
(item) => String(item.id) === String(selectedObjectId.value),
|
||||||
|
);
|
||||||
|
selectedObjectId.value = currentExists
|
||||||
|
? selectedObjectId.value
|
||||||
|
: rows[0]?.id;
|
||||||
|
expandedGroups.value = new Set(
|
||||||
|
objectGroups.value.map((group) => group.label),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (epoch === objectLoadEpoch) objectLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 延迟搜索 Schema 与表名,避免高频元数据请求。 */
|
||||||
|
function scheduleSearch() {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => void loadObjects(), 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换数据库对象分组展开状态。 */
|
||||||
|
function toggleGroup(label: string) {
|
||||||
|
const next = new Set(expandedGroups.value);
|
||||||
|
if (next.has(label)) next.delete(label);
|
||||||
|
else next.add(label);
|
||||||
|
expandedGroups.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存连接后刷新工作区并选中新记录。 */
|
||||||
|
async function handleSaved(connection: DataspaceConnectionView) {
|
||||||
|
await loadConnections(connection.id);
|
||||||
|
await loadObjects();
|
||||||
|
emit('changed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试当前连接可用性。 */
|
||||||
|
async function testCurrentConnection() {
|
||||||
|
const current = selectedConnection.value;
|
||||||
|
if (!current) return;
|
||||||
|
testing.value = true;
|
||||||
|
try {
|
||||||
|
const probe = await testDataspaceConnection({
|
||||||
|
databaseName: current.databaseName,
|
||||||
|
databaseType: current.databaseType,
|
||||||
|
expectedRevision: current.definitionRevision,
|
||||||
|
host: current.host,
|
||||||
|
id: current.id,
|
||||||
|
name: current.name,
|
||||||
|
port: current.port,
|
||||||
|
sslEnabled: current.sslEnabled,
|
||||||
|
username: current.username,
|
||||||
|
});
|
||||||
|
if (probe.success)
|
||||||
|
ElMessage.success(`连接正常 · ${probe.latencyMillis} ms`);
|
||||||
|
else ElMessage.error(probe.message || '连接测试失败');
|
||||||
|
} finally {
|
||||||
|
testing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新当前连接元数据。 */
|
||||||
|
async function refreshMetadata() {
|
||||||
|
const current = selectedConnection.value;
|
||||||
|
if (!current) return;
|
||||||
|
refreshing.value = true;
|
||||||
|
try {
|
||||||
|
await refreshDataspaceMetadata(current.id, current.definitionRevision);
|
||||||
|
await loadConnections(current.id);
|
||||||
|
await loadObjects();
|
||||||
|
emit('changed');
|
||||||
|
ElMessage.success('元数据已刷新');
|
||||||
|
} finally {
|
||||||
|
refreshing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换当前连接的查询可用状态。 */
|
||||||
|
async function toggleCurrentConnection() {
|
||||||
|
const current = selectedConnection.value;
|
||||||
|
if (!current) return;
|
||||||
|
const enabled = current.status !== 'ENABLED';
|
||||||
|
statusChanging.value = true;
|
||||||
|
try {
|
||||||
|
await setDataspaceConnectionEnabled(current.id, enabled);
|
||||||
|
await loadConnections(current.id);
|
||||||
|
emit('changed');
|
||||||
|
ElMessage.success(enabled ? '连接已启用' : '连接已禁用');
|
||||||
|
} finally {
|
||||||
|
statusChanging.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除没有被数据空间引用的当前连接。 */
|
||||||
|
async function removeCurrentConnection() {
|
||||||
|
const current = selectedConnection.value;
|
||||||
|
if (!current) return;
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认删除“${current.name}”?`, '删除连接', {
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await removeDataspaceConnection(current.id);
|
||||||
|
await loadConnections();
|
||||||
|
await loadObjects();
|
||||||
|
emit('changed');
|
||||||
|
ElMessage.success('连接已删除');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理低频连接操作,避免危险操作长期占据主工具栏。 */
|
||||||
|
async function handleConnectionCommand(command: number | object | string) {
|
||||||
|
if (command === 'toggle') {
|
||||||
|
await toggleCurrentConnection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === 'delete') await removeCurrentConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedConnectionId, () => {
|
||||||
|
keyword.value = '';
|
||||||
|
void loadObjects();
|
||||||
|
});
|
||||||
|
watch(keyword, scheduleSearch);
|
||||||
|
onMounted(() => void loadConnections());
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
objectLoadEpoch += 1;
|
||||||
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({ loadConnections });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="connection-panel">
|
||||||
|
<div class="section-toolbar">
|
||||||
|
<slot name="workspace-navigation"></slot>
|
||||||
|
<ElSelect
|
||||||
|
v-if="connections.length > 0"
|
||||||
|
v-model="selectedConnectionId"
|
||||||
|
class="connection-picker"
|
||||||
|
value-key="id"
|
||||||
|
placeholder="选择连接"
|
||||||
|
>
|
||||||
|
<template #label>
|
||||||
|
<div v-if="selectedConnection" class="selected-connection">
|
||||||
|
<img
|
||||||
|
:src="
|
||||||
|
selectedConnection.databaseType === 'POSTGRESQL'
|
||||||
|
? postgresqlIcon
|
||||||
|
: mysqlIcon
|
||||||
|
"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<span>{{ selectedConnection.name }}</span>
|
||||||
|
<i
|
||||||
|
class="connection-health"
|
||||||
|
:class="selectedConnectionHealth.state"
|
||||||
|
role="img"
|
||||||
|
:aria-label="selectedConnectionHealth.label"
|
||||||
|
:title="selectedConnectionHealth.label"
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElOption
|
||||||
|
v-for="connection in connections"
|
||||||
|
:key="String(connection.id)"
|
||||||
|
:label="connection.name"
|
||||||
|
:value="connection.id"
|
||||||
|
>
|
||||||
|
<div class="connection-option">
|
||||||
|
<img
|
||||||
|
:src="
|
||||||
|
connection.databaseType === 'POSTGRESQL'
|
||||||
|
? postgresqlIcon
|
||||||
|
: mysqlIcon
|
||||||
|
"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<span>{{ connection.name }}</span>
|
||||||
|
<small>{{
|
||||||
|
connection.databaseType === 'POSTGRESQL' ? 'PostgreSQL' : 'MySQL'
|
||||||
|
}}</small>
|
||||||
|
</div>
|
||||||
|
</ElOption>
|
||||||
|
</ElSelect>
|
||||||
|
<ElButton
|
||||||
|
v-if="connections.length > 0"
|
||||||
|
class="metadata-refresh"
|
||||||
|
:icon="RefreshCw"
|
||||||
|
:loading="refreshing"
|
||||||
|
:disabled="!selectedConnection"
|
||||||
|
text
|
||||||
|
circle
|
||||||
|
aria-label="刷新元数据"
|
||||||
|
title="刷新元数据"
|
||||||
|
@click="refreshMetadata"
|
||||||
|
/>
|
||||||
|
<div class="toolbar-spacer"></div>
|
||||||
|
<div class="connection-actions">
|
||||||
|
<ElButton :icon="Plus" type="primary" @click="dialogRef?.open()">
|
||||||
|
新建连接
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
:icon="Activity"
|
||||||
|
:loading="testing"
|
||||||
|
:disabled="!selectedConnection"
|
||||||
|
@click="testCurrentConnection"
|
||||||
|
>
|
||||||
|
测试
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
:icon="Pencil"
|
||||||
|
:disabled="!selectedConnection"
|
||||||
|
@click="dialogRef?.open(selectedConnection)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</ElButton>
|
||||||
|
<ElDropdown
|
||||||
|
trigger="click"
|
||||||
|
:disabled="!selectedConnection || statusChanging"
|
||||||
|
@command="handleConnectionCommand"
|
||||||
|
>
|
||||||
|
<ElButton
|
||||||
|
class="more-action"
|
||||||
|
:icon="Ellipsis"
|
||||||
|
:loading="statusChanging"
|
||||||
|
aria-label="更多连接操作"
|
||||||
|
/>
|
||||||
|
<template #dropdown>
|
||||||
|
<ElDropdownMenu>
|
||||||
|
<ElDropdownItem command="toggle">
|
||||||
|
{{
|
||||||
|
selectedConnection?.status === 'ENABLED'
|
||||||
|
? '禁用连接'
|
||||||
|
: '启用连接'
|
||||||
|
}}
|
||||||
|
</ElDropdownItem>
|
||||||
|
<ElDropdownItem command="delete" divided>
|
||||||
|
删除连接
|
||||||
|
</ElDropdownItem>
|
||||||
|
</ElDropdownMenu>
|
||||||
|
</template>
|
||||||
|
</ElDropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElSkeleton v-if="loading" :rows="5" animated />
|
||||||
|
<div v-else-if="selectedConnection" class="connection-content">
|
||||||
|
<div class="object-browser">
|
||||||
|
<div class="browser-content">
|
||||||
|
<aside class="object-tree-pane">
|
||||||
|
<ElInput
|
||||||
|
v-model="keyword"
|
||||||
|
clearable
|
||||||
|
placeholder="搜索数据库或表"
|
||||||
|
aria-label="搜索数据库或表"
|
||||||
|
>
|
||||||
|
<template #prefix><Search /></template>
|
||||||
|
</ElInput>
|
||||||
|
<div v-if="objectLoading" class="tree-loading">
|
||||||
|
<ElSkeletonItem v-for="index in 7" :key="index" variant="text" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="object-tree" role="tree">
|
||||||
|
<div
|
||||||
|
v-for="group in objectGroups"
|
||||||
|
:key="group.label"
|
||||||
|
class="tree-group"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="tree-group-row"
|
||||||
|
type="button"
|
||||||
|
role="treeitem"
|
||||||
|
:aria-expanded="expandedGroups.has(group.label)"
|
||||||
|
@click="toggleGroup(group.label)"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
v-if="expandedGroups.has(group.label)"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<ChevronRight v-else aria-hidden="true" />
|
||||||
|
<Database aria-hidden="true" />
|
||||||
|
<span>{{ group.label }}</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="expandedGroups.has(group.label)"
|
||||||
|
class="tree-table-list"
|
||||||
|
role="group"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="object in group.objects"
|
||||||
|
:key="String(object.id)"
|
||||||
|
type="button"
|
||||||
|
role="treeitem"
|
||||||
|
:aria-selected="
|
||||||
|
String(selectedObjectId) === String(object.id)
|
||||||
|
"
|
||||||
|
:class="{
|
||||||
|
active: String(selectedObjectId) === String(object.id),
|
||||||
|
}"
|
||||||
|
@click="selectedObjectId = object.id"
|
||||||
|
>
|
||||||
|
<ScanEye
|
||||||
|
v-if="object.objectType === 'VIEW'"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<Table2 v-else aria-hidden="true" />
|
||||||
|
<span>{{ object.objectName }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElEmpty
|
||||||
|
v-if="objectGroups.length === 0"
|
||||||
|
description="暂无匹配对象"
|
||||||
|
:image-size="64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main v-if="selectedObject" class="object-detail-pane">
|
||||||
|
<header class="object-detail-header">
|
||||||
|
<div class="object-path">
|
||||||
|
<span>{{
|
||||||
|
selectedObject.schemaName ||
|
||||||
|
selectedObject.catalogName ||
|
||||||
|
'default'
|
||||||
|
}}</span>
|
||||||
|
<ChevronRight aria-hidden="true" />
|
||||||
|
<strong>{{ selectedObject.objectName }}</strong>
|
||||||
|
</div>
|
||||||
|
<p v-if="selectedObject.remarks">
|
||||||
|
{{ selectedObject.remarks }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div class="field-list" aria-label="字段列表">
|
||||||
|
<div
|
||||||
|
v-for="column in selectedObject.columns"
|
||||||
|
:key="column.name"
|
||||||
|
class="field-row"
|
||||||
|
>
|
||||||
|
<KeyRound
|
||||||
|
v-if="column.primaryKey"
|
||||||
|
class="primary"
|
||||||
|
aria-label="主键"
|
||||||
|
/>
|
||||||
|
<Columns3 v-else aria-hidden="true" />
|
||||||
|
<div class="field-name">
|
||||||
|
<strong>{{ column.name }}</strong>
|
||||||
|
<small v-if="column.remarks">{{ column.remarks }}</small>
|
||||||
|
</div>
|
||||||
|
<span class="field-type">
|
||||||
|
{{ column.typeName || 'unknown' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ElEmpty
|
||||||
|
v-if="selectedObject.columns.length === 0"
|
||||||
|
description="暂无字段信息"
|
||||||
|
:image-size="64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<div v-else class="object-empty">
|
||||||
|
<ElEmpty description="请选择一个数据库对象" :image-size="72" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<ElEmpty description="还没有数据连接">
|
||||||
|
<ElButton type="primary" @click="dialogRef?.open()">新建连接</ElButton>
|
||||||
|
</ElEmpty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConnectionDialog ref="dialogRef" @saved="handleSaved" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.connection-panel {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: 0 var(--dataspace-toolbar-padding-inline) var(--space-3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-toolbar,
|
||||||
|
.connection-option,
|
||||||
|
.selected-connection,
|
||||||
|
.connection-actions,
|
||||||
|
.tree-group-row,
|
||||||
|
.tree-table-list button,
|
||||||
|
.object-path,
|
||||||
|
.field-row,
|
||||||
|
.field-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-toolbar {
|
||||||
|
min-height: var(--dataspace-toolbar-height);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-picker {
|
||||||
|
width: 264px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-picker :deep(.el-select__selection),
|
||||||
|
.connection-picker :deep(.el-select__selected-item),
|
||||||
|
.connection-picker :deep(.el-select__placeholder) {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-connection {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-connection img {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-connection span {
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-health {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-health.success {
|
||||||
|
background: hsl(var(--success));
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-health.error {
|
||||||
|
background: hsl(var(--danger));
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-health.disabled,
|
||||||
|
.connection-health.unknown {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-option {
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-option img {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-option small {
|
||||||
|
margin-left: auto;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-refresh {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-actions {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-actions :deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-actions :deep(.el-button) {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-actions :deep(.el-button .el-icon) {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-actions .more-action {
|
||||||
|
width: 34px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-content {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-browser {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: hsl(var(--surface-panel));
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-content {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
grid-template-columns: 304px minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-tree-pane {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid hsl(var(--line-subtle) / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-tree-pane :deep(.el-input) {
|
||||||
|
width: auto;
|
||||||
|
margin: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-tree-pane :deep(.el-input__wrapper) {
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-tree {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0 var(--space-1) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-group-row,
|
||||||
|
.tree-table-list button {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||||
|
color var(--motion-duration-fast) var(--motion-ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-group-row:hover,
|
||||||
|
.tree-table-list button:hover {
|
||||||
|
background: hsl(var(--nav-item-hover));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-group-row:focus-visible,
|
||||||
|
.tree-table-list button:focus-visible {
|
||||||
|
outline: 2px solid hsl(var(--primary) / 0.45);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-group-row svg,
|
||||||
|
.tree-table-list button svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-group-row span,
|
||||||
|
.tree-table-list button span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-table-list {
|
||||||
|
padding-left: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-table-list button.active {
|
||||||
|
background: hsl(var(--primary) / 0.08);
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-loading {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-detail-pane {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-detail-header {
|
||||||
|
display: flex;
|
||||||
|
min-height: 52px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-path {
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-path span {
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-path svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-path strong,
|
||||||
|
.object-path span,
|
||||||
|
.object-detail-header p {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-detail-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-list {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0 var(--space-2) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
min-height: 44px;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background-color var(--motion-duration-fast)
|
||||||
|
var(--motion-ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row:hover {
|
||||||
|
background: hsl(var(--nav-item-hover));
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row > svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row > svg.primary {
|
||||||
|
color: hsl(var(--warning));
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-name {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-name strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 550;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-name small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-type {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 1px var(--space-1);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: hsl(var(--nav-item-hover));
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
font-family: var(--font-family-mono, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-empty,
|
||||||
|
.empty-state {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.section-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-content {
|
||||||
|
grid-template-columns: 272px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.connection-panel {
|
||||||
|
padding: 0 var(--dataspace-toolbar-padding-inline) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-picker {
|
||||||
|
width: min(264px, calc(100% - 160px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-content {
|
||||||
|
grid-template-rows: minmax(200px, 34%) minmax(0, 1fr);
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-tree-pane {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid hsl(var(--line-subtle) / 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Database, GitCompareArrows, SquareTerminal } from '@easyflow/icons';
|
||||||
|
|
||||||
|
type DataspaceWorkspaceTab = 'connections' | 'spaces' | 'sql';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
modelValue: DataspaceWorkspaceTab;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [tab: DataspaceWorkspaceTab];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
icon: GitCompareArrows,
|
||||||
|
key: 'connections' as const,
|
||||||
|
label: '连接',
|
||||||
|
title: '数据连接',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Database,
|
||||||
|
key: 'spaces' as const,
|
||||||
|
label: '空间',
|
||||||
|
title: '数据空间',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: SquareTerminal,
|
||||||
|
key: 'sql' as const,
|
||||||
|
label: 'SQL',
|
||||||
|
title: 'SQL 工作台',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mode-navigation-wrap">
|
||||||
|
<nav class="mode-navigation" aria-label="数据空间功能导航">
|
||||||
|
<button
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
class="mode-navigation-item"
|
||||||
|
:class="{ active: modelValue === item.key }"
|
||||||
|
:aria-current="modelValue === item.key ? 'page' : undefined"
|
||||||
|
:aria-label="item.title"
|
||||||
|
:title="item.title"
|
||||||
|
@click="emit('update:modelValue', item.key)"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" aria-hidden="true" />
|
||||||
|
<span class="mode-navigation-label">{{ item.label }}</span>
|
||||||
|
<span
|
||||||
|
v-if="modelValue === item.key"
|
||||||
|
class="mode-navigation-indicator"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<span class="mode-navigation-divider" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mode-navigation-wrap,
|
||||||
|
.mode-navigation,
|
||||||
|
.mode-navigation-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-navigation-wrap {
|
||||||
|
min-width: max-content;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-navigation {
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-navigation-item {
|
||||||
|
min-height: 32px;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: transparent;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 20px;
|
||||||
|
transition:
|
||||||
|
color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||||
|
background-color var(--motion-duration-fast) var(--motion-ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-navigation-item:hover {
|
||||||
|
background: hsl(var(--nav-item-hover));
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-navigation-item:focus-visible {
|
||||||
|
outline: 2px solid hsl(var(--primary) / 0.45);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-navigation-item.active {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-navigation-item svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-navigation-indicator {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-navigation-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: hsl(var(--line-subtle));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.mode-navigation-wrap {
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-navigation-item {
|
||||||
|
width: 32px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-navigation-label,
|
||||||
|
.mode-navigation-indicator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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: '<div><slot /></div>' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||||
|
'连接凭据已失效,请编辑连接并重新输入数据库密码',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
interface ApiErrorPayload {
|
||||||
|
errorCode?: number | string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recoveryByCode: Record<number, string> = {
|
||||||
|
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 } : {};
|
||||||
|
}
|
||||||
@@ -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('已存在');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>,
|
||||||
|
) {
|
||||||
|
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<string>,
|
||||||
|
) {
|
||||||
|
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 '';
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
84
easyflow-ui-admin/app/src/views/dataspace/dataspace-sql.ts
Normal file
84
easyflow-ui-admin/app/src/views/dataspace/dataspace-sql.ts
Normal file
@@ -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/, '');
|
||||||
|
}
|
||||||
@@ -5,6 +5,11 @@ import type { IconifyIcon } from '@iconify/vue/offline';
|
|||||||
* the small set of full local collections loaded by the application.
|
* the small set of full local collections loaded by the application.
|
||||||
*/
|
*/
|
||||||
export const LOCAL_ICON_DATA: Record<string, IconifyIcon> = {
|
export const LOCAL_ICON_DATA: Record<string, IconifyIcon> = {
|
||||||
|
'lucide:database-zap': {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 12 2.84M21 5v3m0 4l-3 5h4l-3 5M3 12a9 3 0 0 0 11.59 2.87"/></g>',
|
||||||
|
},
|
||||||
'carbon:workspace': {
|
'carbon:workspace': {
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export {
|
export {
|
||||||
|
Activity,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowDownToLine,
|
ArrowDownToLine,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -16,13 +17,20 @@ export {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronsLeft,
|
ChevronsLeft,
|
||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
|
ChevronUp,
|
||||||
Circle,
|
Circle,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
|
CircleCheck,
|
||||||
CircleCheckBig,
|
CircleCheckBig,
|
||||||
|
CircleDot,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
CircleX,
|
CircleX,
|
||||||
|
Clock3,
|
||||||
|
Columns3,
|
||||||
Copy,
|
Copy,
|
||||||
CornerDownLeft,
|
CornerDownLeft,
|
||||||
|
Database,
|
||||||
|
Download,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
EllipsisVertical,
|
EllipsisVertical,
|
||||||
Expand,
|
Expand,
|
||||||
@@ -37,6 +45,7 @@ export {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
FoldHorizontal,
|
FoldHorizontal,
|
||||||
Fullscreen,
|
Fullscreen,
|
||||||
|
GitCompareArrows,
|
||||||
Github,
|
Github,
|
||||||
Grip,
|
Grip,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
@@ -44,6 +53,7 @@ export {
|
|||||||
Inbox,
|
Inbox,
|
||||||
Info,
|
Info,
|
||||||
InspectionPanel,
|
InspectionPanel,
|
||||||
|
KeyRound,
|
||||||
Languages,
|
Languages,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
@@ -57,14 +67,20 @@ export {
|
|||||||
Minimize,
|
Minimize,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
MoonStar,
|
MoonStar,
|
||||||
|
MousePointer2,
|
||||||
|
Move,
|
||||||
NotebookTabs,
|
NotebookTabs,
|
||||||
Palette,
|
Palette,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
|
Pencil,
|
||||||
Pin,
|
Pin,
|
||||||
PinOff,
|
PinOff,
|
||||||
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
|
ScanEye,
|
||||||
Search,
|
Search,
|
||||||
SearchX,
|
SearchX,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -72,11 +88,15 @@ export {
|
|||||||
Square,
|
Square,
|
||||||
SquareCheckBig,
|
SquareCheckBig,
|
||||||
SquareMinus,
|
SquareMinus,
|
||||||
|
SquareTerminal,
|
||||||
Sun,
|
Sun,
|
||||||
SunMoon,
|
SunMoon,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
|
Table2,
|
||||||
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
UserRoundPen,
|
UserRoundPen,
|
||||||
|
WandSparkles,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-vue-next';
|
} from 'lucide-vue-next';
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.20.0",
|
||||||
"@codemirror/commands": "^6.10.2",
|
"@codemirror/commands": "^6.10.2",
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
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 { beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import CodeEditor from './CodeEditor.vue';
|
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);
|
expect(wrapper.findAll('.cm-editor')).toHaveLength(1);
|
||||||
wrapper.unmount();
|
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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { CompletionSource } from '@codemirror/autocomplete';
|
||||||
import type { Extension } from '@codemirror/state';
|
import type { Extension } from '@codemirror/state';
|
||||||
|
|
||||||
import type { EditorLanguage } from './types';
|
import type { EditorLanguage } from './types';
|
||||||
|
|
||||||
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
||||||
|
|
||||||
|
import { acceptCompletion, autocompletion } from '@codemirror/autocomplete';
|
||||||
import {
|
import {
|
||||||
defaultKeymap,
|
defaultKeymap,
|
||||||
history,
|
history,
|
||||||
@@ -42,7 +44,9 @@ import {
|
|||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
|
completionSource?: CompletionSource;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
focusRing?: boolean;
|
||||||
language?: EditorLanguage;
|
language?: EditorLanguage;
|
||||||
lineWrapping?: boolean;
|
lineWrapping?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -52,7 +56,9 @@ const props = withDefaults(
|
|||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
autofocus: false,
|
autofocus: false,
|
||||||
|
completionSource: undefined,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
focusRing: true,
|
||||||
language: 'text',
|
language: 'text',
|
||||||
lineWrapping: true,
|
lineWrapping: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -74,6 +80,7 @@ const languageLoading = ref(false);
|
|||||||
const languageSlot = new Compartment();
|
const languageSlot = new Compartment();
|
||||||
const readonlySlot = new Compartment();
|
const readonlySlot = new Compartment();
|
||||||
const wrappingSlot = new Compartment();
|
const wrappingSlot = new Compartment();
|
||||||
|
const completionSlot = new Compartment();
|
||||||
let languageRequest = 0;
|
let languageRequest = 0;
|
||||||
let syncingFromOutside = false;
|
let syncingFromOutside = false;
|
||||||
|
|
||||||
@@ -140,6 +147,17 @@ function wrappingExtension(enabled: boolean): Extension {
|
|||||||
return enabled ? EditorView.lineWrapping : [];
|
return enabled ? EditorView.lineWrapping : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function completionExtension(source?: CompletionSource): Extension {
|
||||||
|
return source
|
||||||
|
? autocompletion({
|
||||||
|
activateOnTyping: true,
|
||||||
|
activateOnTypingDelay: 160,
|
||||||
|
maxRenderedOptions: 36,
|
||||||
|
override: [source],
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
function createState(doc: string, language: Extension) {
|
function createState(doc: string, language: Extension) {
|
||||||
return EditorState.create({
|
return EditorState.create({
|
||||||
doc,
|
doc,
|
||||||
@@ -176,6 +194,7 @@ function createState(doc: string, language: Extension) {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ key: 'Tab', run: acceptCompletion },
|
||||||
indentWithTab,
|
indentWithTab,
|
||||||
...defaultKeymap,
|
...defaultKeymap,
|
||||||
...historyKeymap,
|
...historyKeymap,
|
||||||
@@ -185,6 +204,7 @@ function createState(doc: string, language: Extension) {
|
|||||||
languageSlot.of(language),
|
languageSlot.of(language),
|
||||||
readonlySlot.of(readonlyExtensions(isReadonly())),
|
readonlySlot.of(readonlyExtensions(isReadonly())),
|
||||||
wrappingSlot.of(wrappingExtension(props.lineWrapping)),
|
wrappingSlot.of(wrappingExtension(props.lineWrapping)),
|
||||||
|
completionSlot.of(completionExtension(props.completionSource)),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged && !syncingFromOutside) {
|
if (update.docChanged && !syncingFromOutside) {
|
||||||
emit('update:modelValue', update.state.doc.toString());
|
emit('update:modelValue', update.state.doc.toString());
|
||||||
@@ -232,6 +252,40 @@ function createState(doc: string, language: Extension) {
|
|||||||
color: 'hsl(var(--foreground))',
|
color: 'hsl(var(--foreground))',
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
},
|
},
|
||||||
|
'.cm-tooltip-autocomplete': {
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'hsl(var(--surface-elevated))',
|
||||||
|
border: '1px solid hsl(var(--line-subtle))',
|
||||||
|
borderRadius: 'var(--radius-control)',
|
||||||
|
boxShadow: 'var(--shadow-popover)',
|
||||||
|
},
|
||||||
|
'.cm-tooltip-autocomplete > ul': {
|
||||||
|
maxHeight: '248px',
|
||||||
|
padding: '4px',
|
||||||
|
fontFamily: 'var(--font-family-sans)',
|
||||||
|
},
|
||||||
|
'.cm-tooltip-autocomplete > ul > li': {
|
||||||
|
minHeight: '30px',
|
||||||
|
padding: '5px 8px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
},
|
||||||
|
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
backgroundColor: 'hsl(var(--nav-item-hover))',
|
||||||
|
},
|
||||||
|
'.cm-completionLabel': {
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
},
|
||||||
|
'.cm-completionMatchedText': {
|
||||||
|
color: 'hsl(var(--primary))',
|
||||||
|
textDecoration: 'none',
|
||||||
|
},
|
||||||
|
'.cm-completionDetail': {
|
||||||
|
color: 'hsl(var(--text-muted))',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontStyle: 'normal',
|
||||||
|
marginLeft: '16px',
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -337,6 +391,15 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.completionSource,
|
||||||
|
(source) => {
|
||||||
|
viewRef.value?.dispatch({
|
||||||
|
effects: completionSlot.reconfigure(completionExtension(source)),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.lineWrapping,
|
() => props.lineWrapping,
|
||||||
(value) => {
|
(value) => {
|
||||||
@@ -361,6 +424,7 @@ defineExpose({ focus, openSearch, scrollToLine });
|
|||||||
class="easyflow-code-editor"
|
class="easyflow-code-editor"
|
||||||
:class="{
|
:class="{
|
||||||
'is-disabled': disabled,
|
'is-disabled': disabled,
|
||||||
|
'is-focus-ring-hidden': !focusRing,
|
||||||
'is-loading': loading || languageLoading,
|
'is-loading': loading || languageLoading,
|
||||||
}"
|
}"
|
||||||
:aria-busy="loading || languageLoading"
|
:aria-busy="loading || languageLoading"
|
||||||
@@ -405,6 +469,11 @@ defineExpose({ focus, openSearch, scrollToLine });
|
|||||||
box-shadow: 0 0 0 2px hsl(var(--primary) / 12%);
|
box-shadow: 0 0 0 2px hsl(var(--primary) / 12%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.easyflow-code-editor.is-focus-ring-hidden:focus-within {
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.easyflow-code-editor.is-disabled {
|
.easyflow-code-editor.is-disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.62;
|
opacity: 0.62;
|
||||||
|
|||||||
@@ -4,3 +4,9 @@ export * from './markdown-security';
|
|||||||
export { default as MarkdownLiveEditor } from './MarkdownLiveEditor.vue';
|
export { default as MarkdownLiveEditor } from './MarkdownLiveEditor.vue';
|
||||||
export { default as MarkdownSourceEditor } from './MarkdownSourceEditor.vue';
|
export { default as MarkdownSourceEditor } from './MarkdownSourceEditor.vue';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
export type {
|
||||||
|
Completion,
|
||||||
|
CompletionContext,
|
||||||
|
CompletionResult,
|
||||||
|
CompletionSource,
|
||||||
|
} from '@codemirror/autocomplete';
|
||||||
|
|||||||
3
easyflow-ui-admin/pnpm-lock.yaml
generated
3
easyflow-ui-admin/pnpm-lock.yaml
generated
@@ -1177,6 +1177,9 @@ importers:
|
|||||||
|
|
||||||
packages/@core/ui-kit/editor-ui:
|
packages/@core/ui-kit/editor-ui:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@codemirror/autocomplete':
|
||||||
|
specifier: ^6.20.0
|
||||||
|
version: 6.20.0
|
||||||
'@codemirror/commands':
|
'@codemirror/commands':
|
||||||
specifier: ^6.10.2
|
specifier: ^6.10.2
|
||||||
version: 6.10.2
|
version: 6.10.2
|
||||||
|
|||||||
Reference in New Issue
Block a user