feat: 重构数据空间与 SQL 工作台

- 提供连接管理、逻辑表编排和轻量 SQL 工作台

- 增加 SQL 补全、执行分析、结果分栏与导出交互

- 统一数据空间导航、图标和编辑器体验
This commit is contained in:
2026-08-25 01:06:53 +08:00
parent c27e97bcc2
commit 98b34bd4bb
26 changed files with 8122 additions and 0 deletions

View 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,
});
}

View File

@@ -1,2 +1,3 @@
export * from './ai';
export * from './core';
export * from './dataspace';

View File

@@ -99,6 +99,8 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
fulfilled: async (config) => {
const accessStore = useAccessStore();
// 明确要求 JSON避免可选的二进制消息转换器在通配 Accept 下抢占响应序列化。
config.headers.Accept = 'application/json';
config.headers['easyflow-token'] = formatToken(accessStore.accessToken);
config.headers['Accept-Language'] = preferences.app.locale;
const workflowShareKey = readWorkflowShareKey();

View File

@@ -27,6 +27,7 @@
"bots": "ChatAssistant",
"title": "AI",
"resources": "Resources",
"dataspace": "Dataspace",
"datacenter": "Datacenter",
"workflow": "Workflow",
"plugin": "Plugin",

View File

@@ -27,6 +27,7 @@
"bots": "聊天助手",
"title": "AI能力",
"resources": "素材库",
"dataspace": "数据空间",
"datacenter": "数据中枢",
"workflow": "工作流",
"plugin": "插件",

View 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;

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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(
'连接凭据已失效,请编辑连接并重新输入数据库密码',
);
});
});

View File

@@ -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 } : {};
}

View File

@@ -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('已存在');
});
});

View File

@@ -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 '';
}

View File

@@ -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",
);
});
});

View 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/, '');
}