feat: 重构数据空间与 SQL 工作台
- 提供连接管理、逻辑表编排和轻量 SQL 工作台 - 增加 SQL 补全、执行分析、结果分栏与导出交互 - 统一数据空间导航、图标和编辑器体验
This commit is contained in:
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/, '');
|
||||
}
|
||||
Reference in New Issue
Block a user