2710 lines
72 KiB
Vue
2710 lines
72 KiB
Vue
<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>
|