feat: 重构数据中枢工作台与接入管理
- 新增统一的数据源、目录、纳管表与 Excel 处理后端能力 - 重建管理端数据中枢工作台并替换旧表管理页面 - 补充数据中枢迁移脚本、连接器底座与说明字段支持
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
<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;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.search-row :deep(.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-row :deep(.el-input__wrapper) {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.create-action {
|
||||
flex: 1;
|
||||
padding-inline: 12px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.refresh-action {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
padding-inline: 0;
|
||||
border-color: hsl(var(--border) / 0.9);
|
||||
background: hsl(var(--card));
|
||||
color: hsl(var(--text-strong));
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.refresh-action:hover {
|
||||
background: hsl(var(--surface-subtle));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.tree-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.tree-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 62px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid hsl(var(--border) / 0.52);
|
||||
border-radius: 14px;
|
||||
background: hsl(var(--card) / 0.92);
|
||||
box-shadow: 0 10px 22px -22px hsl(var(--foreground) / 0.22);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: hsl(var(--foreground));
|
||||
transition:
|
||||
transform 0.16s,
|
||||
background-color 0.16s,
|
||||
color 0.16s,
|
||||
border-color 0.16s,
|
||||
box-shadow 0.16s;
|
||||
}
|
||||
|
||||
.tree-node:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: hsl(var(--border) / 0.74);
|
||||
background: hsl(var(--card));
|
||||
box-shadow: 0 16px 30px -24px hsl(var(--foreground) / 0.22);
|
||||
}
|
||||
|
||||
.tree-node.is-selected {
|
||||
border-color: hsl(var(--primary) / 0.24);
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
color: hsl(var(--foreground));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px hsl(var(--primary) / 0.12),
|
||||
0 18px 34px -28px hsl(var(--primary) / 0.24);
|
||||
}
|
||||
|
||||
.tree-node--source {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tree-node--table {
|
||||
margin-left: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
border-radius: 11px;
|
||||
background: hsl(var(--surface-subtle) / 0.92);
|
||||
}
|
||||
|
||||
.tree-node.is-selected .node-media {
|
||||
background: hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
.node-media :deep(svg) {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.tree-node.is-selected .node-icon {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.node-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
white-space: normal;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.node-meta {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.node-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--destructive));
|
||||
box-shadow: 0 0 0 3px hsl(var(--destructive) / 0.12);
|
||||
}
|
||||
|
||||
.tree-empty {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.connection-context-menu {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
min-width: 136px;
|
||||
padding: 6px;
|
||||
border: 1px solid hsl(var(--border) / 0.7);
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--popover));
|
||||
box-shadow: 0 18px 34px -28px hsl(var(--foreground) / 0.26);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: hsl(var(--accent) / 0.86);
|
||||
}
|
||||
|
||||
.context-menu-item--danger {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.context-menu-item--danger:hover {
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user