- 收敛 easyflow-ui-admin 的 lint、格式和类型问题 - 修正 demo 页面与管理端前端构建失败点 - 验证 pnpm lint 与 pnpm build 均已通过
475 lines
11 KiB
Vue
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>
|