Files
EasyFlow/easyflow-ui-admin/app/src/views/dataspace/components/DataspacePanel.vue
陈子默 98b34bd4bb feat: 重构数据空间与 SQL 工作台
- 提供连接管理、逻辑表编排和轻量 SQL 工作台

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

- 统一数据空间导航、图标和编辑器体验
2026-08-25 01:06:53 +08:00

2710 lines
72 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/* eslint-disable vue/html-closing-bracket-newline -- 与项目 Prettier 的 Vue 多行标签格式保持一致。 */
import type {
DataspaceColumn,
DataspaceConnectionView,
DataspaceId,
DataspaceObjectView,
DataspaceRelationDefinition,
DataspaceSummary,
DataspaceTableView,
} from '#/api/dataspace';
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
} from 'vue';
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
CircleAlert,
Columns3,
Database,
Eye,
EyeOff,
KeyRound,
Maximize,
Pencil,
Plus,
Search,
SquareTerminal,
Table2,
Trash2,
X,
} from '@easyflow/icons';
import {
ElButton,
ElEmpty,
ElInput,
ElMessage,
ElMessageBox,
ElTag,
} from 'element-plus';
import {
getDataspace,
listDataspaceConnections,
listDataspaceObjects,
listDataspaces,
removeDataspace,
saveDataspace,
setDataspaceEnabled,
} from '#/api/dataspace';
import mysqlIcon from '#/assets/datacenter/mysql-icon.svg';
import postgresqlIcon from '#/assets/datacenter/postgresql-icon.svg';
import { dataspaceErrorMessage } from '../dataspace-errors';
import {
createLogicalTableName,
logicalTableNameError,
} from '../dataspace-logical-name';
interface EditorTable {
clientKey: string;
columns: DataspaceColumn[];
connectionId: DataspaceId;
connectionName: string;
databaseType: 'MYSQL' | 'POSTGRESQL';
objectId: DataspaceId;
objectName: string;
issueCode?: number;
issueMessage?: string;
tableAlias: string;
positionX: number;
positionY: number;
schemaAlias: string;
schemaName: string;
sourceAlias: string;
status: 'ACTIVE' | 'CONNECTION_DISABLED' | 'MISSING';
}
interface EditorRelation extends DataspaceRelationDefinition {
issueCode?: number;
issueMessage?: string;
key: string;
status: 'ACTIVE' | 'INVALID';
}
interface LinkEndpoint {
clientKey: string;
column: string;
side: 'left' | 'right';
}
interface CanvasPoint {
x: number;
y: number;
}
interface SourceObjectGroup {
connection: DataspaceConnectionView;
schemas: Array<{ name: string; objects: DataspaceObjectView[] }>;
}
const emit = defineEmits<{
changed: [];
openWorkbench: [dataspaceId: DataspaceId];
}>();
defineSlots<{
'workspace-navigation'(props: { dataspaceId?: DataspaceId }): unknown;
}>();
const loading = ref(false);
const saving = ref(false);
const editorError = ref('');
const statusChangingId = ref<DataspaceId>();
const mode = ref<'editor' | 'list'>('list');
const keyword = ref('');
const spaces = ref<DataspaceSummary[]>([]);
const connections = ref<DataspaceConnectionView[]>([]);
const sourceObjects = ref<Map<string, DataspaceObjectView[]>>(new Map());
const objectKeyword = ref('');
const editorTables = ref<EditorTable[]>([]);
const editorRelations = ref<EditorRelation[]>([]);
const selectedRelationKey = ref<string>();
const editingTableKey = ref<string>();
const logicalNameDraft = ref('');
const expandedTableKeys = ref<Set<string>>(new Set());
const collapsedConnectionIds = ref<Set<string>>(new Set());
const collapsedSchemaKeys = ref<Set<string>>(new Set());
const linkStart = ref<LinkEndpoint>();
const linkPointer = reactive<CanvasPoint>({ x: 0, y: 0 });
const canvasRef = ref<HTMLElement>();
const canvasPanning = ref(false);
const canvasView = reactive({ x: 0, y: 0, zoom: 1 });
const editor = reactive({
description: '',
id: undefined as DataspaceId | undefined,
name: '',
revision: 0,
});
let listEpoch = 0;
let dragState:
| undefined
| { clientKey: string; offsetX: number; offsetY: number };
let canvasPanState:
| undefined
| {
startClientX: number;
startClientY: number;
startX: number;
startY: number;
};
let draggedObject:
| undefined
| {
connection: DataspaceConnectionView;
object: DataspaceObjectView;
};
const DEFAULT_VISIBLE_COLUMNS = 6;
const NODE_HEADER_HEIGHT = 58;
const NODE_ROW_HEIGHT = 36;
const NODE_WIDTH = 292;
const CANVAS_FIT_PADDING = 48;
const MIN_CANVAS_ZOOM = 0.4;
const MAX_CANVAS_ZOOM = 1;
const filteredSpaces = computed(() => {
const normalized = keyword.value.trim().toLowerCase();
if (!normalized) return spaces.value;
return spaces.value.filter((item) =>
[item.name, item.description].some((value) =>
String(value || '')
.toLowerCase()
.includes(normalized),
),
);
});
const sourceGroups = computed<SourceObjectGroup[]>(() => {
const normalized = objectKeyword.value.trim().toLowerCase();
return connections.value
.map((connection) => {
const schemaMap = new Map<string, DataspaceObjectView[]>();
for (const object of sourceObjects.value.get(String(connection.id)) ||
[]) {
const schema = object.schemaName || object.catalogName || 'default';
const logicalName =
findEditorTable(connection, object)?.tableAlias.toLowerCase() || '';
if (
normalized &&
!schema.toLowerCase().includes(normalized) &&
!object.objectName.toLowerCase().includes(normalized) &&
!logicalName.includes(normalized) &&
!object.columns.some((column) =>
column.name.toLowerCase().includes(normalized),
)
) {
continue;
}
const rows = schemaMap.get(schema) || [];
rows.push(object);
schemaMap.set(schema, rows);
}
return {
connection,
schemas: [...schemaMap.entries()].map(([name, objects]) => ({
name,
objects,
})),
};
})
.filter((item) => item.schemas.length > 0 || !normalized);
});
const selectedRelation = computed(() =>
editorRelations.value.find(
(relation) => relation.key === selectedRelationKey.value,
),
);
const previewPath = computed(() => {
if (!linkStart.value) return '';
const table = editorTables.value.find(
(item) => item.clientKey === linkStart.value?.clientKey,
);
if (!table) return '';
const start = endpointPoint(
table,
linkStart.value.column,
linkStart.value.side,
);
return curvePath(start, linkPointer);
});
const canvasExtentStyle = computed(() => {
let width = 0;
let height = 0;
for (const table of editorTables.value) {
const expandHeight =
table.columns.length > DEFAULT_VISIBLE_COLUMNS ? 34 : 0;
width = Math.max(width, table.positionX + NODE_WIDTH + 64);
height = Math.max(
height,
table.positionY +
NODE_HEADER_HEIGHT +
visibleColumns(table).length * NODE_ROW_HEIGHT +
expandHeight +
64,
);
}
return {
height: `max(100%, ${height}px)`,
width: `max(100%, ${width}px)`,
};
});
const canvasSceneStyle = computed(() => ({
...canvasExtentStyle.value,
transform: `translate(${canvasView.x}px, ${canvasView.y}px) scale(${canvasView.zoom})`,
}));
const canvasViewportStyle = computed(() => ({
backgroundPosition: `${canvasView.x}px ${canvasView.y}px`,
backgroundSize: `${16 * canvasView.zoom}px ${16 * canvasView.zoom}px`,
}));
/** 加载数据空间列表。 */
async function loadSpaces() {
const epoch = ++listEpoch;
loading.value = true;
try {
const rows = await listDataspaces();
if (epoch !== listEpoch) return;
spaces.value = rows;
} finally {
if (epoch === listEpoch) loading.value = false;
}
}
/** 打开空白编排器。 */
async function createSpace() {
resetEditor();
mode.value = 'editor';
await loadSources();
}
/** 打开已有数据空间当前 revision。 */
async function editSpace(id: DataspaceId) {
const detail = await getDataspace(id);
editor.id = detail.id;
editor.name = detail.name;
editor.description = detail.description || '';
editor.revision = detail.currentRevision;
editorTables.value = detail.tables.map((table) => fromTableView(table));
const keyByBinding = new Map(
detail.tables.map((table) => [String(table.id), `binding:${table.id}`]),
);
editorRelations.value = detail.relations.map((relation) => ({
issueCode: relation.issueCode,
issueMessage: relation.issueMessage,
joinType: relation.joinType,
key: `relation:${relation.id}`,
leftClientKey: keyByBinding.get(String(relation.leftBindingId)) || '',
leftColumn: relation.leftColumn,
rightClientKey: keyByBinding.get(String(relation.rightBindingId)) || '',
rightColumn: relation.rightColumn,
status: relation.status,
}));
editorError.value = '';
selectedRelationKey.value = undefined;
expandedTableKeys.value = new Set();
collapsedConnectionIds.value = new Set();
collapsedSchemaKeys.value = new Set();
resetCanvasView();
mode.value = 'editor';
await loadSources();
}
/** 将持久化表绑定转换为可编辑画布节点。 */
function fromTableView(table: DataspaceTableView): EditorTable {
return {
clientKey: `binding:${table.id}`,
columns: table.columns,
connectionId: table.connectionId,
connectionName: table.connectionName,
databaseType: table.databaseType,
issueCode: table.issueCode,
issueMessage: table.issueMessage,
objectId: table.objectId,
objectName: table.objectName,
positionX: table.positionX ?? 80,
positionY: table.positionY ?? 80,
schemaAlias: table.schemaAlias,
schemaName: table.schemaName || table.catalogName || 'default',
sourceAlias: table.sourceAlias,
tableAlias: table.tableAlias,
status: table.status,
};
}
/** 清空编排器草稿。 */
function resetEditor() {
editor.id = undefined;
editor.name = '';
editor.description = '';
editor.revision = 0;
editorTables.value = [];
editorRelations.value = [];
editorError.value = '';
selectedRelationKey.value = undefined;
expandedTableKeys.value = new Set();
collapsedConnectionIds.value = new Set();
collapsedSchemaKeys.value = new Set();
cancelLink();
resetCanvasView();
}
/** 懒加载各物理连接元数据供编排器选择。 */
async function loadSources() {
const rows = await listDataspaceConnections();
connections.value = rows.filter(
(connection) => connection.status === 'ENABLED',
);
const pairs = await Promise.all(
connections.value.map(
async (connection) =>
[
String(connection.id),
await listDataspaceObjects(connection.id),
] as const,
),
);
sourceObjects.value = new Map(pairs);
// 详情中的历史绑定按物理身份无感跟进到最新元数据对象。
editorTables.value = editorTables.value.map((table) => {
const connection = connections.value.find(
(item) => String(item.id) === String(table.connectionId),
);
const current = (
sourceObjects.value.get(String(table.connectionId)) || []
).find((object) => {
const schemaName = object.schemaName || object.catalogName || 'default';
return (
samePhysicalName(
connection?.databaseType,
schemaName,
table.schemaName,
) &&
samePhysicalName(
connection?.databaseType,
object.objectName,
table.objectName,
)
);
});
return {
...table,
databaseType: connection?.databaseType || table.databaseType,
...(current
? {
columns: current.columns,
issueCode: undefined,
issueMessage: undefined,
objectId: current.id,
status: 'ACTIVE' as const,
}
: {}),
};
});
}
/** 将物理表加入画布,位置采用确定性网格避免节点重叠。 */
function addObject(
connection: DataspaceConnectionView,
object: DataspaceObjectView,
point?: CanvasPoint,
) {
if (findEditorTable(connection, object)) {
ElMessage.info('该表已在数据空间中');
return;
}
const index = editorTables.value.length;
const sourceIndex = connections.value.findIndex(
(item) => String(item.id) === String(connection.id),
);
const sourceAlias = `${connection.databaseType === 'MYSQL' ? 'MYSQL' : 'PG'}_${sourceIndex + 1}`;
const tableAlias = createLogicalTableName(
object.objectName,
sourceAlias,
editorTables.value.map((table) => table.tableAlias),
);
const canvasColumns = Math.max(
1,
Math.floor(((canvasRef.value?.clientWidth || 760) - 80) / 340),
);
editorTables.value.push({
clientKey: `object:${object.id}`,
columns: object.columns,
connectionId: connection.id,
connectionName: connection.name,
databaseType: connection.databaseType,
issueCode: undefined,
issueMessage: undefined,
objectId: object.id,
objectName: object.objectName,
positionX: Math.max(24, point?.x ?? 48 + (index % canvasColumns) * 340),
positionY: Math.max(
24,
point?.y ?? 48 + Math.floor(index / canvasColumns) * 340,
),
schemaAlias: connection.databaseType === 'MYSQL' ? 'MAIN' : 'PUBLIC',
schemaName: object.schemaName || object.catalogName || 'default',
sourceAlias,
status: 'ACTIVE',
tableAlias,
});
}
/** 进入画布节点的逻辑表名行内编辑状态。 */
async function beginLogicalNameEdit(table: EditorTable) {
editingTableKey.value = table.clientKey;
logicalNameDraft.value = table.tableAlias;
await nextTick();
const input = document.querySelector<HTMLInputElement>(
`[data-logical-name-editor="${CSS.escape(table.clientKey)}"]`,
);
input?.focus();
input?.select();
}
/** 保存逻辑表名;无效输入保持原值并给出就近提示。 */
function commitLogicalNameEdit(table: EditorTable) {
const error = logicalTableNameError(
logicalNameDraft.value,
editorTables.value
.filter((item) => item.clientKey !== table.clientKey)
.map((item) => item.tableAlias),
);
if (error) {
ElMessage.warning(error);
logicalNameDraft.value = table.tableAlias;
} else {
table.tableAlias = logicalNameDraft.value.trim();
}
editingTableKey.value = undefined;
}
/** 放弃当前逻辑表名编辑。 */
function cancelLogicalNameEdit() {
editingTableKey.value = undefined;
logicalNameDraft.value = '';
}
/** 删除画布节点及其关系。 */
function removeEditorTable(clientKey: string) {
editorTables.value = editorTables.value.filter(
(item) => item.clientKey !== clientKey,
);
editorRelations.value = editorRelations.value.filter(
(item) =>
item.leftClientKey !== clientKey && item.rightClientKey !== clientKey,
);
if (linkStart.value?.clientKey === clientKey) {
cancelLink();
}
expandedTableKeys.value.delete(clientKey);
}
/** 开始拖动画布节点。 */
function beginDrag(event: PointerEvent, table: EditorTable) {
if (!canvasRef.value) return;
event.preventDefault();
const point = canvasPointFromPointer(event);
dragState = {
clientKey: table.clientKey,
offsetX: point.x - table.positionX,
offsetY: point.y - table.positionY,
};
window.addEventListener('pointermove', handleDrag);
window.addEventListener('pointerup', endDrag, { once: true });
}
/** 更新拖动节点位置并限制在画布内。 */
function handleDrag(event: PointerEvent) {
if (!dragState || !canvasRef.value) return;
const target = editorTables.value.find(
(item) => item.clientKey === dragState?.clientKey,
);
if (!target) return;
const point = canvasPointFromPointer(event);
target.positionX = Math.max(16, point.x - dragState.offsetX);
target.positionY = Math.max(16, point.y - dragState.offsetY);
}
/** 结束节点拖动并释放全局监听器。 */
function endDrag() {
dragState = undefined;
window.removeEventListener('pointermove', handleDrag);
}
/** 将视口指针位置转换为不受平移和缩放影响的画布逻辑坐标。 */
function canvasPointFromPointer(
event: Pick<PointerEvent, 'clientX' | 'clientY'>,
) {
if (!canvasRef.value) return { x: 0, y: 0 };
const rect = canvasRef.value.getBoundingClientRect();
return {
x: (event.clientX - rect.left - canvasView.x) / canvasView.zoom,
y: (event.clientY - rect.top - canvasView.y) / canvasView.zoom,
};
}
/** 在画布空白区域按下时开始平移视口。 */
function beginCanvasPan(event: PointerEvent) {
if (event.button !== 0 || !canvasRef.value) return;
const target = event.target as HTMLElement;
if (
target.closest(
'.table-node, .relation-hit, .relation-label, .relation-popover, .canvas-fit-button',
)
) {
return;
}
event.preventDefault();
clearCanvasSelection();
canvasPanning.value = true;
canvasPanState = {
startClientX: event.clientX,
startClientY: event.clientY,
startX: canvasView.x,
startY: canvasView.y,
};
window.addEventListener('pointermove', handleCanvasPan);
window.addEventListener('pointerup', endCanvasPan, { once: true });
}
/** 按指针位移更新画布视口。 */
function handleCanvasPan(event: PointerEvent) {
if (!canvasPanState) return;
canvasView.x =
canvasPanState.startX + event.clientX - canvasPanState.startClientX;
canvasView.y =
canvasPanState.startY + event.clientY - canvasPanState.startClientY;
}
/** 结束画布平移并释放全局监听器。 */
function endCanvasPan() {
canvasPanState = undefined;
canvasPanning.value = false;
window.removeEventListener('pointermove', handleCanvasPan);
window.removeEventListener('pointerup', endCanvasPan);
}
/** 恢复画布视口的默认平移和缩放。 */
function resetCanvasView() {
endCanvasPan();
canvasView.x = 0;
canvasView.y = 0;
canvasView.zoom = 1;
}
/** 返回节点当前渲染高度,用于计算适应画布比例。 */
function tableNodeHeight(table: EditorTable) {
const expandHeight = table.columns.length > DEFAULT_VISIBLE_COLUMNS ? 34 : 0;
return (
NODE_HEADER_HEIGHT +
visibleColumns(table).length * NODE_ROW_HEIGHT +
expandHeight
);
}
/** 将全部节点按可读比例缩放并居中到当前可视区域。 */
function fitCanvas() {
if (!canvasRef.value || editorTables.value.length === 0) return;
const minX = Math.min(...editorTables.value.map((table) => table.positionX));
const minY = Math.min(...editorTables.value.map((table) => table.positionY));
const maxX = Math.max(
...editorTables.value.map((table) => table.positionX + NODE_WIDTH),
);
const maxY = Math.max(
...editorTables.value.map(
(table) => table.positionY + tableNodeHeight(table),
),
);
const contentWidth = Math.max(1, maxX - minX);
const contentHeight = Math.max(1, maxY - minY);
const viewportWidth = Math.max(
1,
canvasRef.value.clientWidth - CANVAS_FIT_PADDING * 2,
);
const viewportHeight = Math.max(
1,
canvasRef.value.clientHeight - CANVAS_FIT_PADDING * 2,
);
const zoom = Math.min(
MAX_CANVAS_ZOOM,
Math.max(
MIN_CANVAS_ZOOM,
Math.min(viewportWidth / contentWidth, viewportHeight / contentHeight),
),
);
canvasView.zoom = zoom;
canvasView.x =
(canvasRef.value.clientWidth - contentWidth * zoom) / 2 - minX * zoom;
canvasView.y =
(canvasRef.value.clientHeight - contentHeight * zoom) / 2 - minY * zoom;
}
/** 查找物理表在当前画布中的节点。 */
function findEditorTable(
connection: DataspaceConnectionView,
object: DataspaceObjectView,
) {
const schemaName = object.schemaName || object.catalogName || 'default';
const connectionName = connection.name.trim().toLocaleLowerCase();
return editorTables.value.find((table) => {
const sameConnection =
String(table.connectionId) === String(connection.id) ||
table.connectionName.trim().toLocaleLowerCase() === connectionName;
const samePath =
samePhysicalName(connection.databaseType, table.schemaName, schemaName) &&
samePhysicalName(
connection.databaseType,
table.objectName,
object.objectName,
);
return (
sameConnection &&
(String(table.objectId) === String(object.id) || samePath)
);
});
}
/**
* 按数据库标识符规则比较物理路径PostgreSQL 保留引用标识符大小写。
*/
function samePhysicalName(
databaseType: DataspaceConnectionView['databaseType'] | undefined,
left: string,
right: string,
) {
if (databaseType === 'POSTGRESQL') return left.trim() === right.trim();
return left.trim().toLocaleLowerCase() === right.trim().toLocaleLowerCase();
}
/** 判断物理表是否已经加入当前画布。 */
function isObjectAdded(
connection: DataspaceConnectionView,
object: DataspaceObjectView,
) {
return Boolean(findEditorTable(connection, object));
}
/** 返回与物理表名不同的逻辑表名,相同时不重复展示。 */
function mappedLogicalName(
connection: DataspaceConnectionView,
object: DataspaceObjectView,
) {
const logicalName = findEditorTable(connection, object)?.tableAlias.trim();
return logicalName && logicalName !== object.objectName.trim()
? logicalName
: '';
}
/** 生成物理表选项的完整悬浮说明。 */
function tableOptionTitle(
connection: DataspaceConnectionView,
object: DataspaceObjectView,
) {
const logicalName = mappedLogicalName(connection, object);
return logicalName
? `${object.objectName}${logicalName}`
: object.objectName;
}
/** 切换物理表的画布选中状态。 */
function toggleObject(
connection: DataspaceConnectionView,
object: DataspaceObjectView,
) {
const selected = findEditorTable(connection, object);
if (selected) {
removeEditorTable(selected.clientKey);
return;
}
addObject(connection, object);
}
/** 返回数据连接分组是否展开,搜索时始终展示匹配对象。 */
function isConnectionExpanded(connectionId: DataspaceId) {
return (
Boolean(objectKeyword.value.trim()) ||
!collapsedConnectionIds.value.has(String(connectionId))
);
}
/** 切换数据连接分组的展开状态。 */
function toggleConnection(connectionId: DataspaceId) {
const key = String(connectionId);
const next = new Set(collapsedConnectionIds.value);
if (next.has(key)) next.delete(key);
else next.add(key);
collapsedConnectionIds.value = next;
}
/** 生成连接内唯一的数据库分组键。 */
function schemaKey(connectionId: DataspaceId, schemaName: string) {
return `${connectionId}:${schemaName}`;
}
/** 返回数据库分组是否展开,搜索时始终展示匹配表。 */
function isSchemaExpanded(connectionId: DataspaceId, schemaName: string) {
return (
Boolean(objectKeyword.value.trim()) ||
!collapsedSchemaKeys.value.has(schemaKey(connectionId, schemaName))
);
}
/** 切换数据库分组的展开状态。 */
function toggleSchema(connectionId: DataspaceId, schemaName: string) {
const key = schemaKey(connectionId, schemaName);
const next = new Set(collapsedSchemaKeys.value);
if (next.has(key)) next.delete(key);
else next.add(key);
collapsedSchemaKeys.value = next;
}
/** 记录从物理表面板发起的拖动。 */
function beginObjectDrag(
event: DragEvent,
connection: DataspaceConnectionView,
object: DataspaceObjectView,
) {
if (isObjectAdded(connection, object)) {
event.preventDefault();
return;
}
draggedObject = { connection, object };
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('text/plain', object.objectName);
}
}
/** 清理物理表拖动状态。 */
function endObjectDrag() {
draggedObject = undefined;
}
/** 将拖动的物理表放置到画布指针位置。 */
function dropObject(event: DragEvent) {
if (!draggedObject || !canvasRef.value) return;
const point = canvasPointFromPointer(event);
addObject(draggedObject.connection, draggedObject.object, {
x: point.x - NODE_WIDTH / 2,
y: point.y - NODE_HEADER_HEIGHT / 2,
});
endObjectDrag();
}
/** 返回表中参与关系的字段,折叠时这些字段始终可见。 */
function linkedColumnNames(table: EditorTable) {
const names = new Set<string>();
for (const relation of editorRelations.value) {
if (relation.leftClientKey === table.clientKey) {
names.add(relation.leftColumn);
}
if (relation.rightClientKey === table.clientKey) {
names.add(relation.rightColumn);
}
}
return names;
}
/** 返回节点当前需要展示的字段。 */
function visibleColumns(table: EditorTable) {
if (
expandedTableKeys.value.has(table.clientKey) ||
table.columns.length <= DEFAULT_VISIBLE_COLUMNS
) {
return table.columns;
}
const linked = linkedColumnNames(table);
return table.columns.filter(
(column, index) =>
index < DEFAULT_VISIBLE_COLUMNS || linked.has(column.name),
);
}
/** 返回节点当前省略的字段数量。 */
function hiddenColumnCount(table: EditorTable) {
return Math.max(0, table.columns.length - visibleColumns(table).length);
}
/** 切换节点字段的展开状态。 */
function toggleTableColumns(clientKey: string) {
const next = new Set(expandedTableKeys.value);
if (next.has(clientKey)) next.delete(clientKey);
else next.add(clientKey);
expandedTableKeys.value = next;
}
/** 返回字段端点在画布中的坐标。 */
function endpointPoint(
table: EditorTable,
columnName: string,
side: LinkEndpoint['side'],
): CanvasPoint {
const rowIndex = Math.max(
0,
visibleColumns(table).findIndex((column) => column.name === columnName),
);
return {
x: table.positionX + (side === 'right' ? NODE_WIDTH : 0),
y:
table.positionY +
NODE_HEADER_HEIGHT +
rowIndex * NODE_ROW_HEIGHT +
NODE_ROW_HEIGHT / 2,
};
}
/** 生成适合字段连线的平滑曲线路径。 */
function curvePath(start: CanvasPoint, end: CanvasPoint) {
const control = Math.max(56, Math.abs(end.x - start.x) * 0.45);
const direction = end.x >= start.x ? 1 : -1;
return `M ${start.x} ${start.y} C ${start.x + control * direction} ${start.y}, ${end.x - control * direction} ${end.y}, ${end.x} ${end.y}`;
}
/** 返回持久化关系的路径和中心点。 */
function relationGeometry(relation: EditorRelation) {
const left = editorTables.value.find(
(table) => table.clientKey === relation.leftClientKey,
);
const right = editorTables.value.find(
(table) => table.clientKey === relation.rightClientKey,
);
if (!left || !right) return undefined;
const leftFirst = left.positionX <= right.positionX;
const start = endpointPoint(
left,
relation.leftColumn,
leftFirst ? 'right' : 'left',
);
const end = endpointPoint(
right,
relation.rightColumn,
leftFirst ? 'left' : 'right',
);
return {
end,
midX: (start.x + end.x) / 2,
midY: (start.y + end.y) / 2,
path: curvePath(start, end),
start,
};
}
/** 返回关系标签的画布定位样式。 */
function relationLabelStyle(relation: EditorRelation) {
const geometry = relationGeometry(relation);
return geometry
? { left: `${geometry.midX}px`, top: `${geometry.midY}px` }
: undefined;
}
/** 返回字段端点是否为当前连线起点。 */
function isLinkStart(
table: EditorTable,
column: DataspaceColumn,
side: LinkEndpoint['side'],
) {
return (
linkStart.value?.clientKey === table.clientKey &&
linkStart.value.column === column.name &&
linkStart.value.side === side
);
}
/** 从字段端点开始拖拽连线。 */
function beginLink(
event: PointerEvent,
table: EditorTable,
column: DataspaceColumn,
side: LinkEndpoint['side'],
) {
event.preventDefault();
event.stopPropagation();
linkStart.value = {
clientKey: table.clientKey,
column: column.name,
side,
};
Object.assign(linkPointer, endpointPoint(table, column.name, side));
window.addEventListener('pointermove', moveLink);
window.addEventListener('pointerup', cancelLink, { once: true });
}
/** 更新正在创建的关系预览线。 */
function moveLink(event: PointerEvent) {
if (!linkStart.value || !canvasRef.value) return;
Object.assign(linkPointer, canvasPointFromPointer(event));
}
/** 取消当前字段连线。 */
function cancelLink() {
linkStart.value = undefined;
window.removeEventListener('pointermove', moveLink);
window.removeEventListener('pointerup', cancelLink);
}
/** 清理画布上的临时选择和连线状态。 */
function clearCanvasSelection() {
selectedRelationKey.value = undefined;
cancelLink();
}
/** 使用第二个字段端点完成关系创建。 */
function finishLink(
event: Event | undefined,
table: EditorTable,
column: DataspaceColumn,
) {
event?.preventDefault();
event?.stopPropagation();
const start = linkStart.value;
if (!start) return;
cancelLink();
if (start.clientKey === table.clientKey) {
if (start.column !== column.name) {
ElMessage.warning('请选择另一张表的字段');
}
return;
}
const duplicate = editorRelations.value.find(
(relation) =>
(relation.leftClientKey === start.clientKey &&
relation.leftColumn === start.column &&
relation.rightClientKey === table.clientKey &&
relation.rightColumn === column.name) ||
(relation.leftClientKey === table.clientKey &&
relation.leftColumn === column.name &&
relation.rightClientKey === start.clientKey &&
relation.rightColumn === start.column),
);
if (duplicate) {
selectedRelationKey.value = duplicate.key;
ElMessage.info('该字段关系已存在');
return;
}
const relation: EditorRelation = {
issueCode: undefined,
issueMessage: undefined,
joinType: 'INNER',
key: `relation:${Date.now()}:${editorRelations.value.length}`,
leftClientKey: start.clientKey,
leftColumn: start.column,
rightClientKey: table.clientKey,
rightColumn: column.name,
status: 'ACTIVE',
};
editorRelations.value.push(relation);
selectedRelationKey.value = relation.key;
}
/** 支持键盘依次选择两个字段端点建立关系。 */
function useKeyboardEndpoint(
event: KeyboardEvent,
table: EditorTable,
column: DataspaceColumn,
side: LinkEndpoint['side'],
) {
if (!linkStart.value) {
linkStart.value = {
clientKey: table.clientKey,
column: column.name,
side,
};
Object.assign(linkPointer, endpointPoint(table, column.name, side));
return;
}
finishLink(event, table, column);
}
/** 更新关系的 Join 类型。 */
function setJoinType(relation: EditorRelation, joinType: string) {
relation.joinType = joinType;
}
/** 返回关系端点的简洁说明。 */
function relationCaption(relation: EditorRelation) {
const left = editorTables.value.find(
(table) => table.clientKey === relation.leftClientKey,
);
const right = editorTables.value.find(
(table) => table.clientKey === relation.rightClientKey,
);
return `${left?.tableAlias || '-'}.${relation.leftColumn}${right?.tableAlias || '-'}.${relation.rightColumn}`;
}
/** 删除当前关系草稿。 */
function removeRelation(relation: EditorRelation) {
editorRelations.value = editorRelations.value.filter(
(item) => item.key !== relation.key,
);
if (selectedRelationKey.value === relation.key) {
selectedRelationKey.value = undefined;
}
}
/** 保存数据空间不可变 revision。 */
async function saveEditor() {
editorError.value = '';
if (!editor.name.trim()) {
ElMessage.warning('请输入数据空间名称');
return;
}
if (editorTables.value.length === 0) {
ElMessage.warning('请至少加入一张物理表');
return;
}
const unavailableTable = editorTables.value.find(
(table) => table.status !== 'ACTIVE',
);
if (unavailableTable) {
editorError.value =
unavailableTable.issueMessage ||
`表“${unavailableTable.objectName}”当前不可用,请移除或恢复后再保存`;
return;
}
const invalidRelation = editorRelations.value.find(
(relation) => relation.status !== 'ACTIVE',
);
if (invalidRelation) {
selectedRelationKey.value = invalidRelation.key;
editorError.value =
invalidRelation.issueMessage || '存在失效关系,请重新连线或删除后保存';
return;
}
for (const table of editorTables.value) {
const error = logicalTableNameError(
table.tableAlias,
editorTables.value
.filter((item) => item.clientKey !== table.clientKey)
.map((item) => item.tableAlias),
);
if (error) {
editorError.value = error;
return;
}
}
saving.value = true;
try {
const saved = await saveDataspace({
description: editor.description.trim(),
expectedRevision: editor.id ? editor.revision : undefined,
id: editor.id,
name: editor.name.trim(),
relations: editorRelations.value.map((relation) => ({
joinType: relation.joinType,
leftClientKey: relation.leftClientKey,
leftColumn: relation.leftColumn,
rightClientKey: relation.rightClientKey,
rightColumn: relation.rightColumn,
})),
tables: editorTables.value.map((table) => ({
clientKey: table.clientKey,
objectId: table.objectId,
positionX: Math.round(table.positionX),
positionY: Math.round(table.positionY),
schemaAlias: table.schemaAlias,
sourceAlias: table.sourceAlias,
tableAlias: table.tableAlias,
})),
});
editor.id = saved.id;
editor.revision = saved.currentRevision;
ElMessage.success('数据空间已保存');
emit('changed');
await loadSpaces();
mode.value = 'list';
} catch (error) {
editorError.value = dataspaceErrorMessage(error, '数据空间保存失败');
} finally {
saving.value = false;
}
}
/** 删除指定数据空间。 */
async function removeSpace(current: DataspaceSummary) {
try {
await ElMessageBox.confirm(`确认删除“${current.name}”?`, '删除数据空间', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
});
} catch (error) {
if (error === 'cancel' || error === 'close') return;
throw error;
}
await removeDataspace(current.id);
await loadSpaces();
emit('changed');
ElMessage.success('数据空间已删除');
}
/** 切换指定数据空间的查询可用状态。 */
async function toggleSpace(current: DataspaceSummary) {
const enabled = current.status !== 'ENABLED';
statusChangingId.value = current.id;
try {
await setDataspaceEnabled(current.id, enabled);
await loadSpaces();
emit('changed');
ElMessage.success(enabled ? '数据空间已启用' : '数据空间已禁用');
} finally {
statusChangingId.value = undefined;
}
}
/** 返回数据库品牌图标。 */
function connectionIcon(databaseType: string) {
return databaseType === 'POSTGRESQL' ? postgresqlIcon : mysqlIcon;
}
onMounted(() => void loadSpaces());
onBeforeUnmount(() => {
listEpoch += 1;
endDrag();
endCanvasPan();
cancelLink();
endObjectDrag();
});
</script>
<template>
<section v-if="mode === 'list'" class="space-list-panel">
<div class="space-list-main">
<header class="space-toolbar">
<div class="space-toolbar-main">
<slot name="workspace-navigation" :dataspace-id="undefined"></slot>
<ElInput v-model="keyword" clearable placeholder="搜索数据空间">
<template #prefix><Search /></template>
</ElInput>
</div>
<ElButton type="primary" @click="createSpace">
<Plus />
新建数据空间
</ElButton>
</header>
<div v-loading="loading" class="space-rows" role="list">
<article
v-for="row in filteredSpaces"
:key="String(row.id)"
class="space-row"
role="listitem"
>
<button
class="space-row-main"
type="button"
@click="editSpace(row.id)"
>
<span class="space-mark"><Database /></span>
<strong>{{ row.name }}</strong>
<span class="space-counts">
<span><Table2 />{{ row.tableCount }} 张表</span>
<span><Database />{{ row.sourceCount }} 个连接</span>
</span>
<span class="space-status">
<span
class="status-dot"
:class="{ disabled: row.status !== 'ENABLED' }"
></span>
{{ row.status === 'ENABLED' ? '可用' : '已禁用' }}
</span>
<time>
{{ row.modified ? new Date(row.modified).toLocaleString() : '-' }}
</time>
</button>
<div class="space-row-actions">
<ElButton
text
title="SQL 工作台"
aria-label=" SQL 工作台中打开"
:disabled="row.status !== 'ENABLED'"
@click="emit('openWorkbench', row.id)"
>
<SquareTerminal />
</ElButton>
<ElButton
text
:title="row.status === 'ENABLED' ? '禁用' : '启用'"
:aria-label="
row.status === 'ENABLED' ? '禁用数据空间' : '启用数据空间'
"
:loading="String(statusChangingId) === String(row.id)"
:disabled="
statusChangingId !== undefined &&
String(statusChangingId) !== String(row.id)
"
@click="toggleSpace(row)"
>
<EyeOff v-if="row.status === 'ENABLED'" />
<Eye v-else />
</ElButton>
<ElButton
text
type="danger"
title="删除"
aria-label="删除数据空间"
@click="removeSpace(row)"
>
<Trash2 />
</ElButton>
</div>
</article>
<ElEmpty
v-if="!loading && filteredSpaces.length === 0"
description="还没有数据空间"
/>
</div>
</div>
</section>
<section v-else class="space-editor">
<header class="editor-header">
<slot name="workspace-navigation" :dataspace-id="editor.id"></slot>
<ElButton
text
circle
class="editor-back"
aria-label="返回数据空间列表"
@click="mode = 'list'"
>
<ChevronLeft />
</ElButton>
<div class="editor-name">
<ElInput
v-model="editor.name"
maxlength="64"
placeholder="数据空间名称"
/>
<ElInput
v-model="editor.description"
maxlength="240"
placeholder="添加描述(可选)"
/>
</div>
<ElButton type="primary" :loading="saving" @click="saveEditor">
保存
</ElButton>
</header>
<div v-if="editorError" class="editor-error" role="alert">
<CircleAlert />
<span>{{ editorError }}</span>
</div>
<div class="editor-body">
<aside class="source-palette">
<div class="palette-title">
<strong>物理表</strong>
<ElTag effect="plain">{{ editorTables.length }}</ElTag>
</div>
<ElInput v-model="objectKeyword" clearable placeholder="搜索表和字段">
<template #prefix><Search /></template>
</ElInput>
<div class="source-tree">
<section
v-for="group in sourceGroups"
:key="String(group.connection.id)"
>
<button
type="button"
class="connection-toggle"
:aria-expanded="isConnectionExpanded(group.connection.id)"
@click="toggleConnection(group.connection.id)"
>
<ChevronDown
v-if="isConnectionExpanded(group.connection.id)"
class="connection-chevron"
/>
<ChevronRight v-else class="connection-chevron" />
<img
:src="connectionIcon(group.connection.databaseType)"
alt=""
/>
<strong>{{ group.connection.name }}</strong>
<small>{{
group.connection.databaseType === 'POSTGRESQL'
? 'PostgreSQL'
: 'MySQL'
}}</small>
</button>
<div v-if="isConnectionExpanded(group.connection.id)">
<div
v-for="schema in group.schemas"
:key="schema.name"
class="palette-schema"
>
<button
class="schema-toggle"
type="button"
:aria-expanded="
isSchemaExpanded(group.connection.id, schema.name)
"
@click="toggleSchema(group.connection.id, schema.name)"
>
<ChevronDown
v-if="isSchemaExpanded(group.connection.id, schema.name)"
class="schema-chevron"
/>
<ChevronRight v-else class="schema-chevron" />
<Database class="schema-icon" />
<span>{{ schema.name }}</span>
</button>
<div
v-if="isSchemaExpanded(group.connection.id, schema.name)"
class="schema-objects"
>
<button
v-for="object in schema.objects"
:key="String(object.id)"
class="table-option"
:class="{
selected: isObjectAdded(group.connection, object),
}"
type="button"
:aria-pressed="isObjectAdded(group.connection, object)"
:draggable="!isObjectAdded(group.connection, object)"
:title="tableOptionTitle(group.connection, object)"
@click="toggleObject(group.connection, object)"
@dragend="endObjectDrag"
@dragstart="
beginObjectDrag($event, group.connection, object)
"
>
<Table2 class="table-icon" />
<span class="table-option-copy">
<span class="table-physical-name">{{
object.objectName
}}</span>
<small
v-if="mappedLogicalName(group.connection, object)"
class="table-logical-name"
>
<ChevronRight />
<span>{{
mappedLogicalName(group.connection, object)
}}</span>
</small>
</span>
</button>
</div>
</div>
</div>
</section>
<ElEmpty
v-if="sourceGroups.length === 0"
description="请先创建数据连接"
:image-size="64"
/>
</div>
</aside>
<main class="model-canvas-wrap">
<div class="canvas-toolbar">
<strong>{{ editor.name || '未命名数据空间' }}</strong>
<span
>{{ editorTables.length }} 张表 ·
{{ editorRelations.length }} 条关系</span
>
</div>
<div
ref="canvasRef"
class="model-canvas"
:class="{ panning: canvasPanning }"
:style="canvasViewportStyle"
@dragover.prevent
@drop.prevent="dropObject"
@pointerdown="beginCanvasPan"
>
<div class="canvas-scene" :style="canvasSceneStyle">
<svg class="relation-layer">
<template v-for="relation in editorRelations" :key="relation.key">
<path
v-if="relationGeometry(relation)"
class="relation-path"
:class="{
active: selectedRelationKey === relation.key,
invalid: relation.status !== 'ACTIVE',
}"
:d="relationGeometry(relation)?.path"
/>
<path
v-if="relationGeometry(relation)"
class="relation-hit"
:d="relationGeometry(relation)?.path"
role="button"
tabindex="0"
:aria-label="`编辑关系 ${relationCaption(relation)}`"
@click.stop="selectedRelationKey = relation.key"
@keydown.enter.prevent="selectedRelationKey = relation.key"
/>
</template>
<path
v-if="previewPath"
class="relation-preview"
:d="previewPath"
/>
</svg>
<template
v-for="relation in editorRelations"
:key="`${relation.key}:label`"
>
<button
v-if="relationGeometry(relation)"
class="relation-label"
:class="{
active: selectedRelationKey === relation.key,
invalid: relation.status !== 'ACTIVE',
}"
:style="relationLabelStyle(relation)"
:title="relation.issueMessage"
type="button"
:aria-label="`配置 ${relationCaption(relation)}`"
@click.stop="selectedRelationKey = relation.key"
>
{{ relation.joinType }}
</button>
</template>
<div
v-if="selectedRelation && relationGeometry(selectedRelation)"
class="relation-popover"
:style="relationLabelStyle(selectedRelation)"
@pointerdown.stop
>
<span
v-if="selectedRelation.status !== 'ACTIVE'"
class="relation-issue"
>
<CircleAlert />
{{ selectedRelation.issueMessage || '关系已失效' }}
</span>
<div class="join-options" aria-label="Join 类型">
<button
v-for="type in ['INNER', 'LEFT', 'RIGHT', 'FULL']"
:key="type"
:class="{ active: selectedRelation.joinType === type }"
type="button"
@click="setJoinType(selectedRelation, type)"
>
{{ type }}
</button>
</div>
<button
class="relation-delete"
type="button"
aria-label="删除关系"
title="删除关系"
@click="removeRelation(selectedRelation)"
>
<Trash2 />
</button>
</div>
<article
v-for="table in editorTables"
:key="table.clientKey"
class="table-node"
:class="{ unavailable: table.status !== 'ACTIVE' }"
:title="table.issueMessage"
:style="{
transform: `translate(${table.positionX}px, ${table.positionY}px)`,
}"
>
<header @pointerdown="beginDrag($event, table)">
<span class="node-database-icon">
<img :src="connectionIcon(table.databaseType)" alt="" />
</span>
<span class="node-title">
<span
v-if="editingTableKey === table.clientKey"
class="node-name-editor"
>
<input
v-model="logicalNameDraft"
:data-logical-name-editor="table.clientKey"
maxlength="64"
aria-label="逻辑表名"
@blur="commitLogicalNameEdit(table)"
@keydown.enter.prevent="
($event.target as HTMLInputElement).blur()
"
@keydown.esc.prevent="cancelLogicalNameEdit"
@pointerdown.stop
/>
</span>
<button
v-else
class="node-name-button"
type="button"
:title="`修改逻辑表名 ${table.tableAlias}`"
@click="beginLogicalNameEdit(table)"
@pointerdown.stop
>
<strong>{{ table.tableAlias }}</strong>
<Pencil />
</button>
<small>
{{ table.objectName }} · {{ table.connectionName }} ·
{{ table.schemaName }} · {{ table.columns.length }} 字段
</small>
</span>
<CircleAlert
v-if="table.status !== 'ACTIVE'"
class="node-warning"
/>
<button
type="button"
aria-label="移除表"
@pointerdown.stop
@click="removeEditorTable(table.clientKey)"
>
<X />
</button>
</header>
<div class="node-columns">
<div
v-for="column in visibleColumns(table)"
:key="column.name"
class="node-column"
>
<button
class="column-handle left"
:class="{
active: isLinkStart(table, column, 'left'),
connecting: Boolean(linkStart),
}"
type="button"
:disabled="table.status !== 'ACTIVE'"
:aria-label="` ${table.tableAlias}.${column.name} 建立关系`"
@keydown.enter.prevent.stop="
useKeyboardEndpoint($event, table, column, 'left')
"
@keydown.space.prevent.stop="
useKeyboardEndpoint($event, table, column, 'left')
"
@keydown.esc.prevent.stop="cancelLink"
@pointerdown="beginLink($event, table, column, 'left')"
@pointerup="finishLink($event, table, column)"
></button>
<KeyRound v-if="column.primaryKey" class="primary-key-icon" />
<Columns3 v-else />
<span :title="column.name">{{ column.name }}</span>
<small :title="column.typeName">{{ column.typeName }}</small>
<button
class="column-handle right"
:class="{
active: isLinkStart(table, column, 'right'),
connecting: Boolean(linkStart),
}"
type="button"
:disabled="table.status !== 'ACTIVE'"
:aria-label="` ${table.tableAlias}.${column.name} 建立关系`"
@keydown.enter.prevent.stop="
useKeyboardEndpoint($event, table, column, 'right')
"
@keydown.space.prevent.stop="
useKeyboardEndpoint($event, table, column, 'right')
"
@keydown.esc.prevent.stop="cancelLink"
@pointerdown="beginLink($event, table, column, 'right')"
@pointerup="finishLink($event, table, column)"
></button>
</div>
</div>
<button
v-if="table.columns.length > DEFAULT_VISIBLE_COLUMNS"
class="node-expand"
type="button"
@pointerdown.stop
@click="toggleTableColumns(table.clientKey)"
>
<template v-if="expandedTableKeys.has(table.clientKey)">
<ChevronUp /> 收起字段
</template>
<template v-else>
<ChevronDown /> 展开 {{ hiddenColumnCount(table) }} 个字段
</template>
</button>
</article>
</div>
<button
class="canvas-fit-button"
type="button"
:disabled="editorTables.length === 0"
aria-label="适应画布"
title="适应画布"
@pointerdown.stop
@click="fitCanvas"
>
<Maximize />
</button>
<ElEmpty
v-if="editorTables.length === 0"
description="点击或拖入左侧物理表"
/>
</div>
</main>
</div>
</section>
</template>
<style scoped>
.space-list-panel {
height: 100%;
min-height: 0;
overflow: auto;
background: hsl(var(--surface-panel));
font-size: 13px;
}
.space-list-main {
min-width: 0;
padding: 0 var(--dataspace-toolbar-padding-inline) var(--space-4);
}
.space-toolbar,
.space-toolbar-main,
.editor-header,
.palette-title,
.source-palette section > header,
.palette-schema .schema-toggle,
.palette-schema .table-option,
.canvas-toolbar,
.table-node header,
.node-columns > div,
.node-expand,
.relation-popover,
.join-options {
display: flex;
align-items: center;
}
.space-toolbar {
min-height: var(--dataspace-toolbar-height);
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.space-toolbar-main {
min-width: 0;
gap: var(--space-3);
}
.space-toolbar-main :deep(.el-input) {
width: 280px;
}
.space-rows {
min-height: 120px;
border-top: 1px solid hsl(var(--divider-faint));
}
.space-row {
display: grid;
min-height: 64px;
align-items: center;
grid-template-columns: minmax(0, 1fr) auto;
padding: var(--space-1) var(--space-2);
border-bottom: 1px solid hsl(var(--divider-faint));
transition: background-color var(--motion-duration-fast)
var(--motion-ease-standard);
}
.space-row:hover {
background: hsl(var(--table-row-hover));
}
.space-row-main {
display: grid;
min-width: 0;
min-height: 56px;
align-items: center;
grid-template-columns: 32px minmax(160px, 1fr) 210px 88px 148px;
gap: var(--space-4);
padding: 0;
border: 0;
background: transparent;
color: hsl(var(--foreground));
cursor: pointer;
text-align: left;
}
.space-row-main:focus-visible {
border-radius: var(--radius-control);
outline: 2px solid hsl(var(--primary) / 0.62);
outline-offset: 2px;
}
.space-mark {
display: inline-flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border-radius: var(--radius-control);
background: hsl(var(--nav-item-active));
color: hsl(var(--nav-item-active-foreground));
}
.space-mark svg {
width: 16px;
height: 16px;
}
.space-row-main > strong {
min-width: 0;
overflow: hidden;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.space-counts,
.space-counts span,
.space-status,
.space-row-actions {
display: flex;
align-items: center;
}
.space-counts {
gap: var(--space-4);
}
.space-counts span,
.space-row-main time {
color: hsl(var(--text-muted));
font-size: 12px;
}
.space-counts span {
gap: var(--space-1);
}
.space-counts svg {
width: 14px;
height: 14px;
}
.space-status {
color: hsl(var(--text-muted));
font-size: 12px;
}
.status-dot {
display: inline-block;
width: 7px;
height: 7px;
margin-right: 6px;
border-radius: 50%;
background: hsl(var(--success));
}
.status-dot.disabled {
background: hsl(var(--text-muted));
}
.space-row-actions {
gap: 0;
opacity: 0.68;
transition: opacity var(--motion-duration-fast) var(--motion-ease-standard);
}
.space-row:hover .space-row-actions,
.space-row:focus-within .space-row-actions {
opacity: 1;
}
.space-row-actions :deep(.el-button) {
width: 32px;
height: 32px;
padding: 0;
}
.space-row-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.space-row-actions :deep(svg) {
width: 16px;
height: 16px;
}
.space-editor {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
font-size: 13px;
}
.editor-header {
min-height: var(--dataspace-toolbar-height);
gap: var(--space-2);
padding: 0 var(--dataspace-toolbar-padding-inline);
box-shadow: inset 0 -1px 0 hsl(var(--line-subtle));
}
.editor-back {
width: 32px;
height: 32px;
flex: 0 0 auto;
color: hsl(var(--text-secondary));
}
.editor-back :deep(svg) {
width: 17px;
height: 17px;
stroke-width: 1.5;
}
.editor-error {
display: flex;
min-height: 36px;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-bottom: 1px solid hsl(var(--destructive) / 0.18);
background: hsl(var(--destructive) / 0.06);
color: hsl(var(--destructive));
font-size: 12px;
}
.editor-error svg {
width: 15px;
height: 15px;
flex: 0 0 auto;
}
.editor-name {
display: flex;
min-width: 0;
flex: 1;
gap: 8px;
}
.editor-name :deep(.el-input:first-child) {
max-width: 240px;
}
.editor-body {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: 272px minmax(560px, 1fr);
}
.source-palette {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
background: hsl(var(--surface-panel));
}
.source-palette {
border-right: 1px solid hsl(var(--line-subtle));
}
.palette-title {
min-height: 48px;
justify-content: space-between;
padding: 0 var(--space-3);
}
.source-palette > :deep(.el-input) {
width: auto;
margin: 0 var(--space-3) var(--space-2);
}
.source-tree {
min-height: 0;
flex: 1;
overflow: auto;
padding: 0 8px 16px;
}
.connection-toggle {
display: flex;
width: 100%;
align-items: center;
gap: var(--space-2);
min-height: 38px;
padding: 0 var(--space-2);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: hsl(var(--foreground));
cursor: pointer;
text-align: left;
transition: background-color var(--motion-duration-fast)
var(--motion-ease-standard);
}
.connection-toggle:hover {
background: hsl(var(--nav-item-hover));
}
.connection-toggle:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.45);
outline-offset: -2px;
}
.connection-toggle img,
.table-node header img {
width: 20px;
height: 20px;
object-fit: contain;
}
.connection-toggle small {
margin-left: auto;
color: hsl(var(--text-muted));
}
.connection-chevron {
width: 13px;
height: 13px;
flex: 0 0 auto;
color: hsl(var(--text-muted));
stroke-width: 1.6;
}
.palette-schema .schema-toggle,
.palette-schema .table-option {
width: 100%;
min-height: 34px;
gap: var(--space-2);
border: 0;
border-radius: var(--radius-md);
background: transparent;
text-align: left;
transition:
background-color var(--motion-duration-fast) var(--motion-ease-standard),
color var(--motion-duration-fast) var(--motion-ease-standard);
}
.palette-schema .schema-toggle {
padding: 0 12px;
color: hsl(var(--foreground));
font-weight: 500;
cursor: pointer;
}
.palette-schema .schema-chevron {
width: 13px;
height: 13px;
flex: 0 0 auto;
color: hsl(var(--text-muted));
stroke-width: 1.6;
}
.palette-schema .schema-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: hsl(var(--primary) / 0.72);
stroke-width: 1.5;
}
.palette-schema .table-option {
padding: 4px 12px 4px 36px;
color: hsl(var(--foreground));
cursor: pointer;
}
.palette-schema .schema-toggle:hover,
.palette-schema .table-option:hover {
background: hsl(var(--nav-item-hover));
}
.palette-schema .schema-toggle:focus-visible,
.palette-schema .table-option:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.45);
outline-offset: -2px;
}
.palette-schema .schema-toggle > span,
.palette-schema .table-option > span {
flex: 1;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-option-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 1px;
}
.table-physical-name {
overflow: hidden;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-logical-name {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-1);
overflow: hidden;
color: hsl(var(--text-muted));
font-size: 10px;
line-height: 14px;
}
.table-logical-name svg {
width: 11px;
height: 11px;
flex: 0 0 auto;
stroke-width: 1.5;
}
.table-logical-name span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.palette-schema .table-icon {
width: 15px;
height: 15px;
flex: 0 0 auto;
color: hsl(var(--text-muted) / 0.78);
stroke-width: 1.4;
}
.palette-schema .table-option.selected {
background: hsl(var(--primary) / 0.08);
color: hsl(var(--primary));
}
.palette-schema .table-option.selected .table-icon {
color: hsl(var(--primary) / 0.82);
}
.palette-schema .table-option.selected .table-logical-name {
color: hsl(var(--primary) / 0.72);
}
.model-canvas-wrap {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.canvas-toolbar {
min-height: 48px;
gap: var(--space-2);
padding: 0 var(--space-3);
border-bottom: 1px solid hsl(var(--line-subtle));
}
.canvas-toolbar span {
color: hsl(var(--text-muted));
font-size: 12px;
}
.model-canvas {
position: relative;
min-height: 0;
flex: 1;
overflow: hidden;
background-color: hsl(var(--surface-subtle));
background-image: radial-gradient(
hsl(var(--line-subtle)) 1px,
transparent 1px
);
background-size: 16px 16px;
cursor: grab;
touch-action: none;
}
.model-canvas.panning {
cursor: grabbing;
user-select: none;
}
.canvas-scene {
position: absolute;
z-index: 0;
top: 0;
left: 0;
transform-origin: 0 0;
transition: transform var(--motion-duration-medium)
var(--motion-ease-standard);
will-change: transform;
}
.model-canvas.panning .canvas-scene {
transition: none;
}
.canvas-fit-button {
position: absolute;
z-index: 8;
bottom: var(--space-4);
left: 50%;
display: inline-flex;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
padding: 0;
transform: translateX(-50%);
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-pill);
background: hsl(var(--surface-elevated));
box-shadow: var(--shadow-toolbar);
color: hsl(var(--text-muted));
cursor: pointer;
transition:
border-color var(--motion-duration-fast) var(--motion-ease-standard),
background-color var(--motion-duration-fast) var(--motion-ease-standard),
color var(--motion-duration-fast) var(--motion-ease-standard);
}
.canvas-fit-button:hover:not(:disabled) {
border-color: hsl(var(--primary) / 0.36);
background: hsl(var(--surface-panel));
color: hsl(var(--primary));
}
.canvas-fit-button:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.52);
outline-offset: 2px;
}
.canvas-fit-button:disabled {
cursor: default;
opacity: 0.45;
}
.canvas-fit-button svg {
width: 16px;
height: 16px;
stroke-width: 1.7;
}
.model-canvas > :deep(.el-empty) {
position: absolute;
inset: 0;
pointer-events: none;
}
.relation-layer {
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.relation-layer path {
fill: none;
}
.relation-path {
stroke: hsl(var(--text-muted) / 0.72);
stroke-width: 1.5;
transition:
stroke var(--motion-duration-fast) var(--motion-ease-standard),
stroke-width var(--motion-duration-fast) var(--motion-ease-standard);
}
.relation-path.active {
stroke: hsl(var(--primary));
stroke-width: 2.25;
}
.relation-path.invalid {
stroke: hsl(var(--destructive) / 0.72);
stroke-dasharray: 5 4;
}
.relation-hit {
pointer-events: stroke;
stroke: transparent;
stroke-width: 16;
cursor: pointer;
}
.relation-hit:focus-visible {
stroke: hsl(var(--primary) / 0.14);
outline: none;
}
.relation-preview {
stroke: hsl(var(--primary) / 0.72);
stroke-width: 2;
stroke-dasharray: 5 4;
pointer-events: none;
}
.relation-label {
position: absolute;
z-index: 3;
min-width: 46px;
height: 22px;
padding: 0 var(--space-2);
transform: translate(-50%, -50%);
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-pill);
background: hsl(var(--surface-panel));
box-shadow: var(--shadow-subtle);
color: hsl(var(--text-muted));
cursor: pointer;
font-size: 10px;
font-weight: 650;
letter-spacing: 0.02em;
transition:
border-color var(--motion-duration-fast) var(--motion-ease-standard),
color var(--motion-duration-fast) var(--motion-ease-standard),
transform var(--motion-duration-fast) var(--motion-ease-standard);
}
.relation-label:hover,
.relation-label.active {
border-color: hsl(var(--primary) / 0.5);
color: hsl(var(--primary));
transform: translate(-50%, -50%) scale(1.04);
}
.relation-label.invalid {
border-color: hsl(var(--destructive) / 0.42);
color: hsl(var(--destructive));
}
.relation-label:focus-visible,
.relation-popover button:focus-visible,
.column-handle:focus-visible,
.node-expand:focus-visible,
.table-node header button:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.58);
outline-offset: 2px;
}
.relation-popover {
position: absolute;
z-index: 5;
width: max-content;
gap: var(--space-2);
padding: var(--space-2);
transform: translate(-50%, calc(-100% - 18px));
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-toolbar);
background: hsl(var(--surface-elevated));
box-shadow: var(--shadow-float);
}
.relation-issue {
display: inline-flex;
max-width: 260px;
align-items: center;
gap: var(--space-1);
color: hsl(var(--destructive));
font-size: 11px;
white-space: nowrap;
}
.relation-issue svg {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.join-options {
gap: 2px;
padding: 2px;
border-radius: var(--radius-control);
background: hsl(var(--surface-contrast-soft));
}
.join-options button,
.relation-delete {
height: 26px;
border: 0;
border-radius: calc(var(--radius-control) - 2px);
background: transparent;
color: hsl(var(--text-muted));
cursor: pointer;
}
.join-options button {
padding: 0 7px;
font-size: 10px;
font-weight: 600;
}
.join-options button:hover,
.join-options button.active {
background: hsl(var(--surface-panel));
color: hsl(var(--primary));
}
.relation-delete {
display: inline-flex;
width: 28px;
align-items: center;
justify-content: center;
}
.relation-delete:hover {
background: hsl(var(--destructive) / 0.08);
color: hsl(var(--destructive));
}
.relation-delete svg {
width: 14px;
height: 14px;
}
.table-node {
position: absolute;
z-index: 2;
top: 0;
left: 0;
width: 292px;
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-toolbar);
background: hsl(var(--surface-panel));
box-shadow: var(--shadow-subtle);
transition:
border-color var(--motion-duration-fast) var(--motion-ease-standard),
box-shadow var(--motion-duration-base) var(--motion-ease-standard);
}
.table-node:hover,
.table-node:focus-within {
border-color: hsl(var(--primary) / 0.3);
box-shadow: var(--shadow-toolbar);
}
.table-node.unavailable {
border-color: hsl(var(--destructive) / 0.42);
}
.table-node.unavailable:hover,
.table-node.unavailable:focus-within {
border-color: hsl(var(--destructive) / 0.62);
}
.table-node header {
height: 58px;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-toolbar) var(--radius-toolbar) 0 0;
background: hsl(var(--surface-elevated));
cursor: grab;
touch-action: none;
user-select: none;
}
.node-database-icon {
display: inline-flex;
width: 30px;
height: 30px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: var(--radius-control);
background: hsl(var(--nav-item-active));
}
.node-database-icon img {
width: 20px;
height: 20px;
}
.node-title {
display: grid;
min-width: 0;
flex: 1;
gap: 2px;
}
.node-name-button {
display: flex;
width: 100%;
min-width: 0;
max-width: 100%;
height: 22px;
align-items: center;
justify-content: flex-start;
gap: 6px;
padding: 0;
border: 0;
background: transparent;
color: hsl(var(--foreground));
cursor: text;
text-align: left;
}
.node-name-button strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-name-button svg {
width: 12px;
height: 12px;
flex: 0 0 auto;
opacity: 0;
stroke-width: 1.7;
transition: opacity var(--motion-duration-fast) var(--motion-ease-standard);
}
.node-name-button:hover svg,
.node-name-button:focus-visible svg {
opacity: 0.68;
}
.node-name-button:focus-visible {
border-radius: 3px;
outline: 2px solid hsl(var(--primary) / 0.5);
outline-offset: 2px;
}
.node-name-editor {
display: flex;
height: 22px;
align-items: center;
}
.node-name-editor input {
width: 100%;
height: 24px;
padding: 0 6px;
border: 1px solid hsl(var(--primary) / 0.52);
border-radius: 5px;
background: hsl(var(--surface-panel));
color: hsl(var(--foreground));
font: inherit;
font-size: 13px;
font-weight: 650;
outline: none;
}
.node-name-editor input:focus {
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1);
}
.node-title strong,
.node-title small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-warning {
width: 15px;
height: 15px;
flex: 0 0 auto;
color: hsl(var(--destructive));
}
.node-title strong {
font-size: 13px;
font-weight: 650;
}
.node-title small {
color: hsl(var(--text-muted));
font-size: 10px;
font-weight: 400;
}
.table-node header > button {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: hsl(var(--text-muted));
cursor: pointer;
}
.table-node header > button:hover {
background: hsl(var(--surface-contrast-soft));
color: hsl(var(--foreground));
}
.table-node header > button svg {
width: 15px;
height: 15px;
}
.node-columns > div {
position: relative;
height: 36px;
gap: 8px;
padding: 0 12px;
font-size: 12px;
transition: background-color var(--motion-duration-fast)
var(--motion-ease-standard);
}
.node-columns > div:hover {
background: hsl(var(--table-row-hover));
}
.node-columns > div > svg {
width: 14px;
height: 14px;
flex: 0 0 auto;
color: hsl(var(--text-muted));
}
.node-columns > div > .primary-key-icon {
color: hsl(var(--primary) / 0.76);
}
.node-columns > div span {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-columns small {
max-width: 86px;
overflow: hidden;
color: hsl(var(--text-muted));
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.column-handle {
position: absolute;
z-index: 3;
top: 50%;
width: 12px;
height: 12px;
padding: 0;
transform: translateY(-50%);
border: 2px solid hsl(var(--surface-panel));
border-radius: 50%;
background: hsl(var(--text-muted) / 0.72);
box-shadow: 0 0 0 1px hsl(var(--line-subtle));
cursor: crosshair;
opacity: 0.46;
transition:
background-color var(--motion-duration-fast) var(--motion-ease-standard),
box-shadow var(--motion-duration-fast) var(--motion-ease-standard),
opacity var(--motion-duration-fast) var(--motion-ease-standard),
transform var(--motion-duration-fast) var(--motion-ease-standard);
}
.column-handle.left {
left: -6px;
}
.column-handle.right {
right: -6px;
}
.node-column:hover .column-handle,
.column-handle.connecting,
.column-handle:focus-visible {
opacity: 1;
}
.column-handle:hover,
.column-handle.active {
background: hsl(var(--primary));
box-shadow: 0 0 0 4px hsl(var(--primary) / 0.14);
opacity: 1;
transform: translateY(-50%) scale(1.12);
}
.column-handle:disabled {
cursor: not-allowed;
opacity: 0.18;
pointer-events: none;
}
.node-expand {
width: 100%;
height: 34px;
justify-content: center;
gap: var(--space-1);
border-top: 1px solid hsl(var(--line-subtle));
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: 0 0 var(--radius-toolbar) var(--radius-toolbar);
background: hsl(var(--surface-subtle));
color: hsl(var(--text-muted));
cursor: pointer;
font-size: 11px;
transition:
background-color var(--motion-duration-fast) var(--motion-ease-standard),
color var(--motion-duration-fast) var(--motion-ease-standard);
}
.node-expand:hover {
background: hsl(var(--nav-item-hover));
color: hsl(var(--primary));
}
.node-expand svg {
width: 14px;
height: 14px;
}
@media (max-width: 1180px) {
.space-row-main {
grid-template-columns: 32px minmax(140px, 1fr) 180px 80px;
}
.space-row-main time {
display: none;
}
.editor-body {
grid-template-columns: 248px minmax(520px, 1fr);
}
}
@media (max-width: 860px) {
.space-list-main {
padding: 0 var(--dataspace-toolbar-padding-inline) var(--space-3);
}
.space-row-main {
grid-template-columns: 32px minmax(140px, 1fr) 80px;
}
.space-counts {
display: none;
}
}
</style>