Files
EasyFlow/easyflow-ui-admin/app/src/views/datacenter/components/ConnectionTree.vue
陈子默 7e7c236c2a fix: 修复管理端前端 lint 与构建问题
- 收敛 easyflow-ui-admin 的 lint、格式和类型问题

- 修正 demo 页面与管理端前端构建失败点

- 验证 pnpm lint 与 pnpm build 均已通过
2026-04-05 21:39:13 +08:00

475 lines
11 KiB
Vue

<script setup lang="ts">
import type { TreeNode } from '../composables/use-connection-tree';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { EasyFlowButton } from '@easyflow-core/shadcn-ui';
import { Plus, RefreshRight, Search } from '@element-plus/icons-vue';
import { ElIcon, ElInput, ElTooltip } from 'element-plus';
import { formatSourceType } from '../composables/datacenter-constants';
import SourceBrandIcon from './SourceBrandIcon.vue';
const props = defineProps<{
selectedKey: string;
treeData: TreeNode[];
}>();
const emit = defineEmits<{
create: [];
edit: [node: TreeNode];
refresh: [];
remove: [node: TreeNode];
select: [node: TreeNode];
}>();
const searchText = ref('');
const expandedKeys = ref<Set<string>>(new Set());
const contextMenuVisible = ref(false);
const contextMenuNode = ref<null | TreeNode>(null);
const contextMenuX = ref(0);
const contextMenuY = ref(0);
const filteredTree = computed(() => {
const keyword = searchText.value.trim().toLowerCase();
if (!keyword) return props.treeData;
return props.treeData.filter((node) =>
node.label.toLowerCase().includes(keyword),
);
});
function toggleExpand(nodeId: string) {
if (expandedKeys.value.has(nodeId)) {
expandedKeys.value.delete(nodeId);
} else {
expandedKeys.value.add(nodeId);
}
}
function handleClick(node: TreeNode) {
if (node.children && node.children.length > 0) {
toggleExpand(node.id);
}
emit('select', node);
}
function handleContextMenu(event: MouseEvent, node: TreeNode) {
event.preventDefault();
if (node.type !== 'source' || node.meta?.builtinFlag) return;
emit('select', node);
contextMenuNode.value = node;
contextMenuX.value = Math.min(event.clientX, window.innerWidth - 156);
contextMenuY.value = Math.min(event.clientY, window.innerHeight - 108);
contextMenuVisible.value = true;
}
function isExpanded(nodeId: string) {
return expandedKeys.value.has(nodeId);
}
function closeContextMenu() {
contextMenuVisible.value = false;
contextMenuNode.value = null;
}
function handleEdit() {
if (contextMenuNode.value) {
emit('edit', contextMenuNode.value);
}
closeContextMenu();
}
function handleRemove() {
if (contextMenuNode.value) {
emit('remove', contextMenuNode.value);
}
closeContextMenu();
}
function getNodeIcon(node: TreeNode): string {
if (node.type === 'source') {
return node.icon === 'file'
? 'i-lucide-file-spreadsheet'
: 'i-lucide-database';
}
return 'i-lucide-table';
}
function getSourceMetaLabel(node: TreeNode) {
const sourceType = node.meta?.sourceType;
const sourceName = (node.label || '').trim();
const typeLabel = formatSourceType(sourceType);
if (!typeLabel || typeLabel === sourceName) {
return '';
}
return typeLabel;
}
function isSourceUnavailable(node: TreeNode) {
return Boolean(node.type === 'source' && node.meta?.runtimeUnavailable);
}
onMounted(() => {
window.addEventListener('click', closeContextMenu);
window.addEventListener('resize', closeContextMenu);
window.addEventListener('scroll', closeContextMenu, true);
});
onBeforeUnmount(() => {
window.removeEventListener('click', closeContextMenu);
window.removeEventListener('resize', closeContextMenu);
window.removeEventListener('scroll', closeContextMenu, true);
});
</script>
<template>
<div class="connection-tree">
<div class="tree-search">
<div class="search-actions">
<EasyFlowButton size="sm" class="create-action" @click="emit('create')">
<ElIcon class="action-icon"><Plus /></ElIcon>
新增连接
</EasyFlowButton>
<EasyFlowButton
size="sm"
variant="outline"
class="refresh-action"
aria-label="刷新"
@click="emit('refresh')"
>
<ElIcon class="action-icon"><RefreshRight /></ElIcon>
</EasyFlowButton>
</div>
<div class="search-row">
<ElInput v-model="searchText" placeholder="搜索连接..." clearable>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
</div>
</div>
<div class="tree-scroll">
<div v-if="filteredTree.length > 0" class="tree-list">
<template v-for="source in filteredTree" :key="source.id">
<div
class="tree-node tree-node--source"
:class="{ 'is-selected': selectedKey === source.id }"
@click="handleClick(source)"
@contextmenu="handleContextMenu($event, source)"
>
<span class="node-media">
<SourceBrandIcon :source-type="source.meta?.sourceType" />
</span>
<span class="node-content">
<span class="node-label">{{ source.label }}</span>
<span v-if="getSourceMetaLabel(source)" class="node-meta">
{{ getSourceMetaLabel(source) }}
</span>
</span>
<ElTooltip
v-if="isSourceUnavailable(source)"
content="连接不可用"
placement="top"
>
<span class="node-status-dot" aria-label="连接不可用"></span>
</ElTooltip>
</div>
<!-- 表节点 -->
<template v-if="isExpanded(source.id) && source.children">
<div
v-for="table in source.children"
:key="table.id"
class="tree-node tree-node--table"
:class="{ 'is-selected': selectedKey === table.id }"
@click="handleClick(table)"
@contextmenu="handleContextMenu($event, table)"
>
<span class="node-icon" :class="[getNodeIcon(table)]"></span>
<span class="node-label">{{ table.label }}</span>
</div>
</template>
</template>
</div>
<div v-else class="tree-empty">
{{ searchText ? '无匹配连接' : '还没有数据连接' }}
</div>
</div>
<div
v-if="contextMenuVisible && contextMenuNode"
class="connection-context-menu"
:style="{ left: `${contextMenuX}px`, top: `${contextMenuY}px` }"
@click.stop
>
<button type="button" class="context-menu-item" @click="handleEdit">
编辑
</button>
<button
type="button"
class="context-menu-item context-menu-item--danger"
@click="handleRemove"
>
删除
</button>
</div>
</div>
</template>
<style scoped>
.connection-tree {
display: flex;
flex-direction: column;
height: 100%;
padding: 0 12px 0 0;
background: transparent;
}
.tree-search {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 0 10px;
}
.search-actions {
display: flex;
gap: 6px;
align-items: center;
}
.search-row :deep(.el-input) {
width: 100%;
}
.search-row :deep(.el-input__wrapper) {
min-height: 36px;
}
.action-icon {
flex-shrink: 0;
width: 14px;
height: 14px;
}
.create-action {
flex: 1;
padding-inline: 12px;
box-shadow: none;
}
.refresh-action {
width: 36px;
min-width: 36px;
height: 36px;
padding-inline: 0;
color: hsl(var(--text-strong));
background: hsl(var(--card));
border-color: hsl(var(--border) / 90%);
box-shadow: none;
}
.refresh-action:hover {
color: hsl(var(--foreground));
background: hsl(var(--surface-subtle));
}
.tree-scroll {
flex: 1;
min-height: 0;
padding-right: 4px;
overflow: auto;
}
.tree-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0;
}
.tree-node {
display: flex;
gap: 12px;
align-items: center;
min-height: 62px;
padding: 12px 14px;
color: hsl(var(--foreground));
cursor: pointer;
user-select: none;
background: hsl(var(--card) / 92%);
border: 1px solid hsl(var(--border) / 52%);
border-radius: 14px;
box-shadow: 0 10px 22px -22px hsl(var(--foreground) / 22%);
transition:
transform 0.16s,
background-color 0.16s,
color 0.16s,
border-color 0.16s,
box-shadow 0.16s;
}
.tree-node:hover {
background: hsl(var(--card));
border-color: hsl(var(--border) / 74%);
box-shadow: 0 16px 30px -24px hsl(var(--foreground) / 22%);
transform: translateY(-1px);
}
.tree-node.is-selected {
color: hsl(var(--foreground));
background: hsl(var(--primary) / 8%);
border-color: hsl(var(--primary) / 24%);
box-shadow:
inset 0 0 0 1px hsl(var(--primary) / 12%),
0 18px 34px -28px hsl(var(--primary) / 24%);
}
.tree-node--source {
font-weight: 600;
}
.tree-node--table {
align-items: center;
margin-left: 20px;
}
.node-arrow {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
transition: transform 0.15s;
}
.node-arrow.is-expanded {
transform: rotate(90deg);
}
.arrow-icon {
width: 12px;
height: 12px;
color: hsl(var(--text-muted));
}
.node-media {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
margin-top: 1px;
background: hsl(var(--surface-subtle) / 92%);
border-radius: 11px;
}
.tree-node.is-selected .node-media {
background: hsl(var(--primary) / 12%);
}
.node-media :deep(svg) {
width: 22px;
height: 22px;
}
.node-icon {
flex-shrink: 0;
width: 17px;
height: 17px;
color: hsl(var(--text-muted));
}
.tree-node.is-selected .node-icon {
color: hsl(var(--primary));
}
.node-content {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
align-items: flex-start;
min-width: 0;
}
.node-label {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 2;
font-size: 14px;
line-height: 1.3;
letter-spacing: 0.01em;
white-space: normal;
-webkit-box-orient: vertical;
}
.node-meta {
font-size: 12px;
font-weight: 500;
line-height: 1.2;
color: hsl(var(--text-muted));
}
.node-status-dot {
flex-shrink: 0;
width: 8px;
height: 8px;
margin-left: auto;
background: hsl(var(--destructive));
border-radius: 999px;
box-shadow: 0 0 0 3px hsl(var(--destructive) / 12%);
}
.tree-empty {
padding: 32px 16px;
font-size: 13px;
color: hsl(var(--text-muted));
text-align: center;
}
.connection-context-menu {
position: fixed;
z-index: 30;
min-width: 136px;
padding: 6px;
background: hsl(var(--popover));
border: 1px solid hsl(var(--border) / 70%);
border-radius: 12px;
box-shadow: 0 18px 34px -28px hsl(var(--foreground) / 26%);
backdrop-filter: blur(10px);
}
.context-menu-item {
display: flex;
align-items: center;
width: 100%;
min-height: 34px;
padding: 0 10px;
font-size: 13px;
color: hsl(var(--foreground));
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 8px;
}
.context-menu-item:hover {
background: hsl(var(--accent) / 86%);
}
.context-menu-item--danger {
color: hsl(var(--destructive));
}
.context-menu-item--danger:hover {
background: hsl(var(--destructive) / 10%);
}
</style>