This is an automated email from the ASF dual-hosted git repository.
jinsongzhou pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/amoro.git
The following commit(s) were added to refs/heads/master by this push:
new 2bda3ab8e [AMORO-3226] Provider a better table explorer view (#4053)
2bda3ab8e is described below
commit 2bda3ab8e8359605cdf7361669fbdbcd86cebb7d
Author: nathan.ma <[email protected]>
AuthorDate: Thu Jan 22 11:02:06 2026 +0800
[AMORO-3226] Provider a better table explorer view (#4053)
* Provider a better table explorer view
* chore: clean table explorer comments and dead code
- Replace Chinese-only comments in new table explorer views with English
equivalents to avoid non-ASCII comments in the codebase
- Remove unused `hideTablesMenu` helper from tables view while keeping
runtime behavior unchanged
- Re-run Maven compile (`mvn -q -DskipTests compile`) to ensure Java
modules still compile successfully after the cleanup
Co-Authored-By: Aime <[email protected]>
Change-Id: I618daa3eb9097ab9e9fe4d68f4581431cbac5bf7
* optimize experience
* fix(tables): keep previous table when switching to empty database
---------
Co-authored-by: majin.nathan <[email protected]>
Co-authored-by: Aime <[email protected]>
---
amoro-web/src/components/Layout.vue | 18 +-
amoro-web/src/components/Sidebar.vue | 64 +--
.../src/views/tables/components/TableExplorer.vue | 639 +++++++++++++++++++++
amoro-web/src/views/tables/index.vue | 226 ++++++--
4 files changed, 838 insertions(+), 109 deletions(-)
diff --git a/amoro-web/src/components/Layout.vue
b/amoro-web/src/components/Layout.vue
index 04bf83e43..65d3325e7 100644
--- a/amoro-web/src/components/Layout.vue
+++ b/amoro-web/src/components/Layout.vue
@@ -17,7 +17,8 @@
/ -->
<script lang="ts">
-import { defineComponent } from 'vue'
+import { computed, defineComponent } from 'vue'
+import { useRoute } from 'vue-router'
import SideBar from '@/components/Sidebar.vue'
import TopBar from '@/components/Topbar.vue'
@@ -37,6 +38,14 @@ export default defineComponent({
default: true,
},
},
+ setup() {
+ const route = useRoute()
+ const isTablesPage = computed(() => route.path.includes('/tables'))
+
+ return {
+ isTablesPage,
+ }
+ },
})
</script>
@@ -48,7 +57,7 @@ export default defineComponent({
<!-- topbar -->
<TopBar v-if="showTopBar" />
<!-- content -->
- <div class="content">
+ <div class="content" :class="{ 'content--workspace': isTablesPage }">
<router-view />
</div>
</div>
@@ -66,11 +75,14 @@ export default defineComponent({
flex: 1;
flex-direction: column;
transition: width 0.3s;
- overflow: auto;
+ overflow: hidden;
.content {
height: calc(100% - 48px);
overflow: auto;
}
+ .content--workspace {
+ overflow: hidden;
+ }
}
}
</style>
diff --git a/amoro-web/src/components/Sidebar.vue
b/amoro-web/src/components/Sidebar.vue
index 04176cf8b..dc0b2bd0d 100644
--- a/amoro-web/src/components/Sidebar.vue
+++ b/amoro-web/src/components/Sidebar.vue
@@ -17,11 +17,10 @@
/ -->
<script lang="ts">
-import { computed, defineComponent, nextTick, reactive, ref, toRefs,
watchEffect, onMounted, onBeforeUnmount } from 'vue'
+import { computed, defineComponent, nextTick, reactive, toRefs, watchEffect }
from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import useStore from '@/store/index'
-import TableMenu from '@/components/tables-sub-menu/TablesMenu.vue'
import { getQueryString } from '@/utils'
interface MenuItem {
@@ -32,9 +31,6 @@ interface MenuItem {
export default defineComponent({
name: 'Sidebar',
- components: {
- TableMenu,
- },
setup() {
const { t } = useI18n()
const router = useRouter()
@@ -48,7 +44,6 @@ export default defineComponent({
const hasToken = computed(() => {
return !!(getQueryString('token') || '')
})
- const timer = ref(0)
const menuList = computed(() => {
const menu: MenuItem[] = [
{
@@ -111,74 +106,28 @@ export default defineComponent({
}
const navClick = (item: MenuItem) => {
- if (item.key === 'tables') {
- nextTick(() => {
- setCurMenu()
- })
- return
- }
+ const targetPath = item.key === 'tables' ? '/tables' : `/${item.key}`
router.replace({
- path: `/${item.key}`,
+ path: targetPath,
})
nextTick(() => {
setCurMenu()
})
}
- const mouseenter = (item: MenuItem) => {
- toggleTablesMenu(item.key === 'tables')
- }
-
- const goCreatePage = () => {
- toggleTablesMenu(false)
- router.push({
- path: '/tables/create',
- })
- }
-
- function toggleTablesMenu(flag = false) {
- if (hasToken.value) {
- return
- }
- timer.value && clearTimeout(timer.value)
- const time = flag ? 0 : 200
- timer.value = setTimeout(() => {
- store.updateTablesMenu(flag)
- }, time)
- }
-
const viewOverview = () => {
router.push({
path: '/overview',
})
}
- const tableMenusRef = ref(null)
- function handleClickOutside(event: Event) {
- if (tableMenusRef?.value && !(tableMenusRef.value as
any).contains(event.target)) {
- toggleTablesMenu(false)
- }
- }
-
- onBeforeUnmount(() => {
- document.removeEventListener('click', handleClickOutside)
- })
-
- onMounted(() => {
- document.addEventListener('click', handleClickOutside)
- })
-
return {
...toRefs(state),
hasToken,
menuList,
toggleCollapsed,
navClick,
- mouseenter,
store,
- toggleTablesMenu,
- tableMenusRef,
- goCreatePage,
viewOverview,
}
},
@@ -187,7 +136,7 @@ export default defineComponent({
<template>
<div :class="{ 'side-bar-collapsed': collapsed }" class="side-bar">
- <div :class="{ 'logo-collapsed': collapsed }" class="logo g-flex-ae"
@mouseenter="toggleTablesMenu(false)" @click="viewOverview">
+ <div :class="{ 'logo-collapsed': collapsed }" class="logo g-flex-ae"
@click="viewOverview">
<img src="../assets/images/logo1.svg" class="logo-img" alt="">
<img v-show="!collapsed" src="../assets/images/arctic-dashboard1.svg"
class="arctic-name" alt="">
</div>
@@ -197,7 +146,7 @@ export default defineComponent({
theme="dark"
:inline-collapsed="collapsed"
>
- <a-menu-item v-for="item in menuList" :key="item.key" :class="{
'active-color': (store.isShowTablesMenu && item.key === 'tables'),
'table-item-tab': item.key === 'tables' }" @click="navClick(item)"
@mouseenter="mouseenter(item)">
+ <a-menu-item v-for="item in menuList" :key="item.key" :class="{
'active-color': (store.isShowTablesMenu && item.key === 'tables'),
'table-item-tab': item.key === 'tables' }" @click="navClick(item)">
<template #icon>
<svg-icon :icon-class="item.icon" class="svg-icon" />
</template>
@@ -208,9 +157,6 @@ export default defineComponent({
<MenuUnfoldOutlined v-if="collapsed" />
<MenuFoldOutlined v-else />
</a-button>
- <div ref="tableMenusRef" v-if="store.isShowTablesMenu && !hasToken"
:class="{ 'collapsed-sub-menu': collapsed }" class="tables-menu-wrap"
@click.self="toggleTablesMenu(false)" @mouseenter="toggleTablesMenu(true)">
- <TableMenu @go-create-page="goCreatePage" />
- </div>
</div>
</template>
diff --git a/amoro-web/src/views/tables/components/TableExplorer.vue
b/amoro-web/src/views/tables/components/TableExplorer.vue
new file mode 100755
index 000000000..0e36851cc
--- /dev/null
+++ b/amoro-web/src/views/tables/components/TableExplorer.vue
@@ -0,0 +1,639 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+/ -->
+
+<script setup lang="ts">
+import { computed, onBeforeMount, reactive, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { getCatalogList, getDatabaseList, getTableList } from
'@/services/table.service'
+import type { ICatalogItem } from '@/types/common.type'
+
+// Node types: Catalog / Database / Table
+type NodeType = 'catalog' | 'database' | 'table'
+
+interface TableItem {
+ name: string
+ type: string
+}
+
+interface TreeNode {
+ key: string
+ title: string
+ isLeaf?: boolean
+ children?: TreeNode[]
+ // Custom fields
+ nodeType: NodeType
+ catalog: string
+ db?: string
+ table?: string
+ tableType?: string
+}
+
+interface StorageValue {
+ catalog?: string
+ database?: string
+ tableName?: string
+ type?: string
+}
+
+const router = useRouter()
+const route = useRoute()
+
+const storageTableKey = 'easylake-menu-catalog-db-table'
+const storageCataDBTable = JSON.parse(localStorage.getItem(storageTableKey) ||
'{}') as StorageValue
+const expandedKeysSessionKey = 'tables_expanded_keys'
+
+const state = reactive({
+ loading: false,
+ searchKey: '',
+ filterKey: '',
+ treeData: [] as TreeNode[],
+ expandedKeys: [] as string[],
+ selectedKeys: [] as string[],
+ // Cache
+ catalogList: [] as string[],
+ dbListByCatalog: {} as Record<string, string[]>,
+ tablesByCatalogDb: {} as Record<string, TableItem[]>,
+})
+
+function buildCatalogNode(catalog: string): TreeNode {
+ return {
+ key: `catalog:${catalog}`,
+ title: catalog,
+ isLeaf: false,
+ nodeType: 'catalog',
+ catalog,
+ }
+}
+
+function buildDatabaseNode(catalog: string, db: string): TreeNode {
+ return {
+ key: `catalog:${catalog}/db:${db}`,
+ title: db,
+ isLeaf: false,
+ nodeType: 'database',
+ catalog,
+ db,
+ }
+}
+
+function buildTableNode(catalog: string, db: string, table: TableItem):
TreeNode {
+ return {
+ key: `catalog:${catalog}/db:${db}/table:${table.name}`,
+ title: table.name,
+ isLeaf: true,
+ nodeType: 'table',
+ catalog,
+ db,
+ table: table.name,
+ tableType: table.type,
+ }
+}
+
+function updateTreeNodeChildren(targetKey: string, children: TreeNode[]) {
+ const loop = (nodes: TreeNode[]): boolean => {
+ for (const node of nodes) {
+ if (node.key === targetKey) {
+ node.children = children
+ return true
+ }
+ if (node.children && node.children.length && loop(node.children)) {
+ return true
+ }
+ }
+ return false
+ }
+
+ loop(state.treeData)
+}
+
+async function initRootCatalogs() {
+ state.loading = true
+ try {
+ const res = await getCatalogList()
+ const catalogs = (res || []).map((item: ICatalogItem) => item.catalogName)
+ state.catalogList = catalogs
+ state.treeData = catalogs.map(catalog => buildCatalogNode(catalog))
+ }
+ finally {
+ state.loading = false
+ }
+}
+
+async function loadChildren(node: any) {
+ const data = node?.dataRef || node
+ if (!data) {
+ return
+ }
+
+ const nodeType = data.nodeType as NodeType
+ if (nodeType === 'catalog') {
+ const catalog = data.catalog as string
+ if (!catalog || state.dbListByCatalog[catalog]) {
+ return
+ }
+
+ state.loading = true
+ try {
+ const res = await getDatabaseList({ catalog, keywords: '' })
+ const dbs = (res || []) as string[]
+ state.dbListByCatalog[catalog] = dbs
+ if (!dbs.length) {
+ data.isLeaf = true
+ updateTreeNodeChildren(data.key as string, [])
+ return
+ }
+ data.isLeaf = false
+ const children = dbs.map(db => buildDatabaseNode(catalog, db))
+ updateTreeNodeChildren(data.key as string, children)
+ }
+ finally {
+ state.loading = false
+ }
+ }
+ else if (nodeType === 'database') {
+ const catalog = data.catalog as string
+ const db = data.db as string
+ if (!catalog || !db) {
+ return
+ }
+ const cacheKey = `${catalog}/${db}`
+ if (state.tablesByCatalogDb[cacheKey]) {
+ return
+ }
+
+ state.loading = true
+ try {
+ const res = await getTableList({ catalog, db, keywords: '' })
+ const tables = (res || []) as TableItem[]
+ state.tablesByCatalogDb[cacheKey] = tables
+ if (!tables.length) {
+ data.isLeaf = true
+ updateTreeNodeChildren(data.key as string, [])
+ return
+ }
+ data.isLeaf = false
+ const children = tables.map(table => buildTableNode(catalog, db, table))
+ updateTreeNodeChildren(data.key as string, children)
+ }
+ finally {
+ state.loading = false
+ }
+ }
+}
+
+function handleSelectTable(catalog: string, db: string, tableName: string,
tableType: string) {
+ if (!catalog || !db || !tableName) {
+ return
+ }
+
+ const type = tableType || 'MIXED_ICEBERG'
+
+ localStorage.setItem(storageTableKey, JSON.stringify({
+ catalog,
+ database: db,
+ tableName,
+ }))
+
+ const path = type === 'HIVE' ? '/hive-tables' : '/tables'
+ const pathQuery = {
+ path,
+ query: {
+ catalog,
+ db,
+ table: tableName,
+ type,
+ },
+ }
+
+ if (route.path.includes('tables')) {
+ router.replace(pathQuery)
+ }
+ else {
+ router.push(pathQuery)
+ }
+}
+
+function handleTreeSelect(selectedKeys: (string | number)[], info: any) {
+ const node = info?.node
+ if (!node) {
+ return
+ }
+
+ const dataRef = (node.dataRef || node) as TreeNode | undefined
+ if (!dataRef) {
+ return
+ }
+
+ if (dataRef.nodeType !== 'table') {
+ if (!dataRef.isLeaf) {
+ toggleNodeExpand(dataRef)
+ }
+ return
+ }
+
+ state.selectedKeys = selectedKeys.map(key => String(key))
+
+ const catalog = (dataRef.catalog || '') as string
+ const db = (dataRef.db || '') as string
+ const tableName = (dataRef.table || dataRef.title || '') as string
+ const tableType = (dataRef.tableType || '') as string
+
+ if (catalog && db && tableName) {
+ handleSelectTable(catalog, db, tableName, tableType)
+ }
+}
+
+async function handleTreeExpand(expandedKeys: (string | number)[], info: any) {
+ state.expandedKeys = expandedKeys.map(key => String(key))
+ try {
+ sessionStorage.setItem(expandedKeysSessionKey,
JSON.stringify(state.expandedKeys))
+ }
+ catch (e) {
+ // ignore sessionStorage write errors
+ }
+ const node = info?.node
+ if (!node || node.isLeaf || !info.expanded) {
+ return
+ }
+ await loadChildren(node)
+}
+
+async function toggleNodeExpand(dataRef: TreeNode) {
+ if (!dataRef || dataRef.isLeaf) {
+ return
+ }
+
+ const key = String(dataRef.key)
+ const hasExpanded = state.expandedKeys.includes(key)
+
+ let nextExpandedKeys: string[]
+ if (hasExpanded) {
+ nextExpandedKeys = state.expandedKeys.filter(item => item !== key)
+ }
+ else {
+ nextExpandedKeys = [...state.expandedKeys, key]
+ await loadChildren({ dataRef })
+ }
+
+ state.expandedKeys = nextExpandedKeys
+ try {
+ sessionStorage.setItem(expandedKeysSessionKey,
JSON.stringify(state.expandedKeys))
+ }
+ catch (e) {
+ // ignore sessionStorage write errors
+ }
+}
+
+function getNodeIcon(node: TreeNode) {
+ if (node.nodeType === 'catalog') {
+ return 'catalogs'
+ }
+ if (node.nodeType === 'database') {
+ return 'database'
+ }
+ return 'tables'
+}
+
+function normalizeKeyword(raw: string) {
+ return raw.trim().toLowerCase()
+}
+
+function filterBySingleKeyword(nodes: TreeNode[], keyword: string,
expandedSet: Set<string>): TreeNode[] {
+ const result: TreeNode[] = []
+
+ nodes.forEach((node) => {
+ const titleMatch = node.title.toLowerCase().includes(keyword)
+ let childrenMatches: TreeNode[] = []
+
+ if (node.children && node.children.length) {
+ childrenMatches = filterBySingleKeyword(node.children, keyword,
expandedSet)
+ }
+
+ if (titleMatch || childrenMatches.length) {
+ const cloned: TreeNode = { ...node }
+ if (childrenMatches.length) {
+ cloned.children = childrenMatches
+ expandedSet.add(node.key)
+ }
+ result.push(cloned)
+ }
+ })
+
+ return result
+}
+
+function filterByHierarchical(nodes: TreeNode[], parts: string[], expandedSet:
Set<string>): TreeNode[] {
+ const [catalogPart, dbPart, tablePart] = parts
+ const result: TreeNode[] = []
+
+ nodes.forEach((catalogNode) => {
+ if (catalogNode.nodeType !== 'catalog') {
+ return
+ }
+ if (!catalogNode.title.toLowerCase().includes(catalogPart)) {
+ return
+ }
+
+ const dbChildren = (catalogNode.children || []).filter(child =>
child.nodeType === 'database')
+ const matchedDbNodes: TreeNode[] = []
+
+ dbChildren.forEach((dbNode) => {
+ if (dbPart && !dbNode.title.toLowerCase().includes(dbPart)) {
+ return
+ }
+
+ if (!tablePart) {
+ const clonedDb: TreeNode = { ...dbNode }
+ clonedDb.children = dbNode.children
+ matchedDbNodes.push(clonedDb)
+ expandedSet.add(catalogNode.key)
+ expandedSet.add(dbNode.key)
+ return
+ }
+
+ const tableChildren = (dbNode.children || []).filter(child =>
child.title.toLowerCase().includes(tablePart))
+ if (tableChildren.length) {
+ const clonedDb: TreeNode = { ...dbNode, children: tableChildren }
+ matchedDbNodes.push(clonedDb)
+ expandedSet.add(catalogNode.key)
+ expandedSet.add(dbNode.key)
+ }
+ })
+
+ if (matchedDbNodes.length) {
+ const clonedCatalog: TreeNode = { ...catalogNode, children:
matchedDbNodes }
+ result.push(clonedCatalog)
+ }
+ })
+
+ return result
+}
+
+function filterTree(source: TreeNode[], rawKeyword: string): { tree:
TreeNode[], expandedKeys: string[] } {
+ const keyword = normalizeKeyword(rawKeyword)
+ if (!keyword) {
+ return {
+ tree: source,
+ expandedKeys: state.expandedKeys,
+ }
+ }
+
+ const expandedSet = new Set<string>()
+ const parts = keyword.split('.').map(p => p.trim()).filter(Boolean)
+
+ let filteredTree: TreeNode[] = []
+ if (parts.length > 1) {
+ filteredTree = filterByHierarchical(source, parts, expandedSet)
+ }
+ else {
+ filteredTree = filterBySingleKeyword(source, keyword, expandedSet)
+ }
+
+ return {
+ tree: filteredTree,
+ expandedKeys: Array.from(expandedSet),
+ }
+}
+
+let searchTimer: any = null
+
+watch(
+ () => state.searchKey,
+ (val) => {
+ if (searchTimer) {
+ clearTimeout(searchTimer)
+ }
+ searchTimer = setTimeout(() => {
+ state.filterKey = val || ''
+ }, 500)
+ },
+)
+
+const searchResult = computed(() => {
+ const keyword = normalizeKeyword(state.filterKey)
+ if (!keyword) {
+ return {
+ tree: state.treeData,
+ expandedKeys: state.expandedKeys,
+ }
+ }
+ return filterTree(state.treeData, keyword)
+})
+
+const displayTreeData = computed(() => searchResult.value.tree)
+const displayExpandedKeys = computed(() => searchResult.value.expandedKeys)
+
+onBeforeMount(async () => {
+ await initRootCatalogs()
+
+ let restoredExpandedKeys: string[] = []
+
+ try {
+ const stored = sessionStorage.getItem(expandedKeysSessionKey)
+ if (stored) {
+ const parsed = JSON.parse(stored)
+ if (Array.isArray(parsed)) {
+ restoredExpandedKeys = parsed.map((key: string | number) =>
String(key))
+ }
+ }
+ }
+ catch (e) {
+ // ignore sessionStorage read/parse errors
+ }
+
+ if (restoredExpandedKeys.length) {
+ const catalogKeys = restoredExpandedKeys.filter(key =>
key.startsWith('catalog:') && !key.includes('/db:'))
+ for (const catalogKey of catalogKeys) {
+ const catalogNode = state.treeData.find(node => node.key === catalogKey)
+ if (catalogNode) {
+ await loadChildren({ dataRef: catalogNode })
+ }
+ }
+
+ const dbKeys = restoredExpandedKeys.filter(key => key.includes('/db:') &&
!key.includes('/table:'))
+ for (const dbKey of dbKeys) {
+ const [catalogPart] = dbKey.split('/db:')
+ const catalogName = catalogPart.replace('catalog:', '')
+ const catalogNode = state.treeData.find(node => node.key ===
`catalog:${catalogName}`)
+ if (catalogNode && catalogNode.children && catalogNode.children.length) {
+ const dbNode = catalogNode.children.find((child: TreeNode) =>
child.key === dbKey)
+ if (dbNode) {
+ await loadChildren({ dataRef: dbNode })
+ }
+ }
+ }
+
+ state.expandedKeys = restoredExpandedKeys
+
+ // Select last visited table from route or local storage without
auto-expanding tree
+ const query = route.query || {}
+ const queryCatalog = (query.catalog as string) ||
storageCataDBTable.catalog
+ const queryDb = (query.db as string) || storageCataDBTable.database
+ const queryTable = (query.table as string) || storageCataDBTable.tableName
+
+ if (queryCatalog && queryDb && queryTable) {
+ const tableKey =
`catalog:${queryCatalog}/db:${queryDb}/table:${queryTable}`
+ state.selectedKeys = [tableKey]
+ }
+ }
+})
+</script>
+
+<template>
+ <div class="table-explorer">
+ <div class="table-explorer-header">
+ <a-input
+ v-model:value="state.searchKey"
+ placeholder="Search catalog.database.table"
+ allow-clear
+ class="search-input"
+ >
+ <template #prefix>
+ <SearchOutlined class="search-input-prefix-icon" />
+ </template>
+ </a-input>
+ </div>
+
+ <div class="table-explorer-body">
+ <a-tree
+ v-if="displayTreeData.length"
+ :tree-data="displayTreeData"
+ :expanded-keys="displayExpandedKeys"
+ :selected-keys="state.selectedKeys"
+ :show-line="{ showLeafIcon: false }"
+ :show-icon="false"
+ :auto-expand-parent="false"
+ block-node
+ @expand="handleTreeExpand"
+ @select="handleTreeSelect"
+ >
+ <template #title="{ dataRef }">
+ <span
+ class="tree-node-title"
+ :class="`node-${dataRef.nodeType}`"
+ >
+ <svg-icon
+ :icon-class="getNodeIcon(dataRef)"
+ class="tree-node-icon"
+ />
+ <span class="tree-node-text">
+ {{ dataRef.title }}
+ </span>
+ </span>
+ </template>
+ </a-tree>
+ <div v-else class="empty-placeholder">
+ <span>No results..</span>
+ </div>
+ </div>
+
+ </div>
+</template>
+
+<style lang="less" scoped>
+.table-explorer {
+ position: relative;
+ box-sizing: border-box;
+ width: 100%;
+ height: 100%;
+ padding: 0 0;
+ background-color: #fff;
+ font-size: 13px;
+ display: flex;
+ flex-direction: column;
+
+ .table-explorer-header {
+ padding: 0 12px;
+ margin-bottom: 8px;
+
+ .search-input {
+ width: 100%;
+
+ :deep(.ant-input-affix-wrapper) {
+ height: 24px;
+ }
+
+ :deep(.ant-input-prefix) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ }
+
+ :deep(.search-input-prefix-icon) {
+ font-size: 16px;
+ line-height: 24px;
+ }
+
+ :deep(.ant-input) {
+ line-height: 24px;
+ font-size: 14px;
+
+ &::placeholder {
+ font-size: 14px;
+ color: #ccc;
+ }
+ }
+ }
+ }
+
+ .table-explorer-body {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: 0 12px;
+
+ :deep(.ant-tree) {
+ background-color: #fff;
+ }
+
+ :deep(.ant-tree-indent-unit) {
+ width: 24px;
+ }
+
+ :deep(.ant-tree-switcher-line-icon) {
+ color: #d9d9e3;
+ font-size: 12px;
+ }
+
+ .tree-node-title {
+ display: inline-flex;
+ align-items: center;
+
+ .tree-node-icon {
+ margin-right: 6px;
+ }
+ }
+
+ .empty-placeholder {
+ padding: 8px 4px;
+ color: #999;
+ }
+ }
+}
+
+.table-explorer-body .tree-node-title.node-catalog .tree-node-icon,
+.table-explorer-body .tree-node-title.node-database .tree-node-icon {
transform: translateY(1px); }
+.table-explorer-body .tree-node-title.node-table .tree-node-icon { transform:
none; }
+
+/* Shift database and table rows left by 2px: indent + switcher + content
together */
+:deep(.ant-tree-treenode:has(.tree-node-title.node-database)),
+:deep(.ant-tree-treenode:has(.tree-node-title.node-table)) {
+ transform: translateX(-1px); // Adjust to -3 or -4 for a larger shift if
needed
+}
+</style>
diff --git a/amoro-web/src/views/tables/index.vue
b/amoro-web/src/views/tables/index.vue
index 8634a9cef..d8e5ffe2c 100644
--- a/amoro-web/src/views/tables/index.vue
+++ b/amoro-web/src/views/tables/index.vue
@@ -17,7 +17,7 @@ limitations under the License.
/ -->
<script lang="ts">
-import { computed, defineComponent, nextTick, onMounted, reactive, ref,
shallowReactive, toRefs, watch } from 'vue'
+import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted,
reactive, ref, shallowReactive, toRefs, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import UDetails from './components/Details.vue'
import UFiles from './components/Files.vue'
@@ -25,6 +25,7 @@ import UOperations from './components/Operations.vue'
import USnapshots from './components/Snapshots.vue'
import UOptimizing from './components/Optimizing.vue'
import UHealthScore from './components/HealthScoreDetails.vue'
+import TableExplorer from './components/TableExplorer.vue'
import useStore from '@/store/index'
import type { IBaseDetailInfo } from '@/types/common.type'
@@ -37,6 +38,7 @@ export default defineComponent({
USnapshots,
UOptimizing,
UHealthScore,
+ TableExplorer,
},
setup() {
const router = useRouter()
@@ -45,6 +47,60 @@ export default defineComponent({
const detailRef = ref()
+ const SIDEBAR_WIDTH_STORAGE_KEY = 'tables_sidebar_width'
+ const SIDEBAR_MIN_WIDTH = 320
+ const SIDEBAR_MAX_WIDTH = 800
+ const sidebarWidth = ref(512)
+
+ let isResizing = false
+ let startX = 0
+ let startWidth = sidebarWidth.value
+
+ const clampSidebarWidth = (width: number) => {
+ if (width < SIDEBAR_MIN_WIDTH) {
+ return SIDEBAR_MIN_WIDTH
+ }
+ if (width > SIDEBAR_MAX_WIDTH) {
+ return SIDEBAR_MAX_WIDTH
+ }
+ return width
+ }
+
+ const initSidebarWidth = () => {
+ const stored = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)
+ const parsed = stored ? Number.parseInt(stored, 10) : Number.NaN
+ const base = Number.isFinite(parsed) ? clampSidebarWidth(parsed) : 512
+ sidebarWidth.value = base
+ startWidth = base
+ }
+
+ const onSidebarResize = (event: MouseEvent) => {
+ if (!isResizing) {
+ return
+ }
+ const deltaX = event.clientX - startX
+ const nextWidth = clampSidebarWidth(startWidth + deltaX)
+ sidebarWidth.value = nextWidth
+ }
+
+ const stopSidebarResize = () => {
+ if (!isResizing) {
+ return
+ }
+ isResizing = false
+ document.removeEventListener('mousemove', onSidebarResize)
+ document.removeEventListener('mouseup', stopSidebarResize)
+ localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY,
String(sidebarWidth.value))
+ }
+
+ const startSidebarResize = (event: MouseEvent) => {
+ isResizing = true
+ startX = event.clientX
+ startWidth = sidebarWidth.value
+ document.addEventListener('mousemove', onSidebarResize)
+ document.addEventListener('mouseup', stopSidebarResize)
+ }
+
const tabConfigs = shallowReactive([
{ key: 'Snapshots', label: 'snapshots' },
{ key: 'Optimizing', label: 'optimizing' },
@@ -66,7 +122,7 @@ export default defineComponent({
smallFileScore: 0,
equalityDeleteScore: 0,
positionalDeleteScore: 0,
- comment: ''
+ comment: '',
} as IBaseDetailInfo,
detailLoaded: false,
})
@@ -75,7 +131,9 @@ export default defineComponent({
return state.baseInfo.tableType === 'ICEBERG'
})
- const setBaseDetailInfo = (baseInfo: IBaseDetailInfo & { comment?: string
}) => {
+ const hasSelectedTable = computed(() => !!(route.query?.catalog &&
route.query?.db && route.query?.table))
+
+ const setBaseDetailInfo = (baseInfo: IBaseDetailInfo & { comment?: string
}) => {
state.detailLoaded = true
state.baseInfo = { ...baseInfo }
}
@@ -86,10 +144,6 @@ export default defineComponent({
router.replace({ query: { ...query } })
}
- const hideTablesMenu = () => {
- store.updateTablesMenu(false)
- }
-
const goBack = () => {
state.isSecondaryNav = false
router.back()
@@ -116,25 +170,42 @@ export default defineComponent({
},
)
+ watch(
+ hasSelectedTable,
+ (value, oldVal) => {
+ if (value && !oldVal && detailRef.value) {
+ detailRef.value.getTableDetails()
+ }
+ },
+ )
+
onMounted(() => {
+ initSidebarWidth()
state.activeKey = (route.query?.tab as string) || 'Details'
nextTick(() => {
- if (detailRef.value) {
+ if (detailRef.value && hasSelectedTable.value) {
detailRef.value.getTableDetails()
}
})
})
+ onBeforeUnmount(() => {
+ document.removeEventListener('mousemove', onSidebarResize)
+ document.removeEventListener('mouseup', stopSidebarResize)
+ })
+
return {
...toRefs(state),
detailRef,
tabConfigs,
store,
isIceberg,
+ hasSelectedTable,
setBaseDetailInfo,
- hideTablesMenu,
goBack,
onChangeTab,
+ sidebarWidth,
+ startSidebarResize,
}
},
})
@@ -143,44 +214,56 @@ export default defineComponent({
<template>
<div class="tables-wrap">
<div v-if="!isSecondaryNav" class="tables-content">
- <div class="g-flex-jsb">
- <div class="g-flex-col">
- <div class="g-flex">
- <span :title="baseInfo.tableName" class="table-name
g-text-nowrap">{{ baseInfo.tableName }}</span>
- </div>
- <div v-if="baseInfo.comment" class="table-info g-flex-ac">
- <p>{{ $t('Comment') }}: <span class="text-color">{{
baseInfo.comment }}</span></p>
+ <div
+ class="tables-sidebar"
+ :style="{ width: `${sidebarWidth}px`, flex: `0 0 ${sidebarWidth}px` }"
+ >
+ <TableExplorer />
+ </div>
+ <div class="tables-divider" aria-hidden="true"
@mousedown="startSidebarResize" />
+ <div class="tables-main">
+ <template v-if="hasSelectedTable">
+ <div class="tables-main-header g-flex-jsb">
+ <div class="g-flex-col">
+ <div class="g-flex">
+ <span :title="baseInfo.tableName" class="table-name
g-text-nowrap">{{ baseInfo.tableName }}</span>
+ </div>
+ <div v-if="baseInfo.comment" class="table-info g-flex-ac">
+ <p>{{ $t('Comment') }}: <span class="text-color">{{
baseInfo.comment }}</span></p>
+ </div>
+ <div class="table-info g-flex-ac">
+ <p>{{ $t('optimizingStatus') }}: <span class="text-color">{{
baseInfo.optimizingStatus }}</span></p>
+ <a-divider type="vertical" />
+ <p>{{ $t('records') }}: <span class="text-color">{{
baseInfo.records }}</span></p>
+ <a-divider type="vertical" />
+ <template v-if="!isIceberg">
+ <p>{{ $t('createTime') }}: <span class="text-color">{{
baseInfo.createTime }}</span></p>
+ <a-divider type="vertical" />
+ </template>
+ <p>{{ $t('tableFormat') }}: <span class="text-color">{{
baseInfo.tableFormat }}</span></p>
+ <a-divider type="vertical" />
+ <p>
+ {{ $t('healthScore') }}:
+ <UHealthScore :base-info="baseInfo" />
+ </p>
+ </div>
+ </div>
</div>
- <div class="table-info g-flex-ac">
- <p>{{ $t('optimizingStatus') }}: <span class="text-color">{{
baseInfo.optimizingStatus }}</span></p>
- <a-divider type="vertical" />
- <p>{{ $t('records') }}: <span class="text-color">{{
baseInfo.records }}</span></p>
- <a-divider type="vertical" />
- <template v-if="!isIceberg">
- <p>{{ $t('createTime') }}: <span class="text-color">{{
baseInfo.createTime }}</span></p>
- <a-divider type="vertical" />
- </template>
- <p>{{ $t('tableFormat') }}: <span class="text-color">{{
baseInfo.tableFormat }}</span></p>
- <a-divider type="vertical" />
- <p>
- {{ $t('healthScore') }}:
- <UHealthScore :base-info="baseInfo" />
- </p>
+ <div class="tables-main-body">
+ <a-tabs v-model:activeKey="activeKey" destroy-inactive-tab-pane
@change="onChangeTab">
+ <a-tab-pane key="Details" :tab="$t('details')" force-render>
+ <UDetails ref="detailRef"
@set-base-detail-info="setBaseDetailInfo" />
+ </a-tab-pane>
+ <a-tab-pane v-if="detailLoaded" key="Files" :tab="$t('files')">
+ <UFiles :has-partition="baseInfo.hasPartition" />
+ </a-tab-pane>
+ <a-tab-pane v-for="tab in tabConfigs" :key="tab.key"
:tab="$t(tab.label)">
+ <component :is="`U${tab.key}`" />
+ </a-tab-pane>
+ </a-tabs>
</div>
- </div>
- </div>
- <div class="content">
- <a-tabs v-model:activeKey="activeKey" destroy-inactive-tab-pane
@change="onChangeTab">
- <a-tab-pane key="Details" :tab="$t('details')" force-render>
- <UDetails ref="detailRef"
@set-base-detail-info="setBaseDetailInfo" />
- </a-tab-pane>
- <a-tab-pane v-if="detailLoaded" key="Files" :tab="$t('files')">
- <UFiles :has-partition="baseInfo.hasPartition" />
- </a-tab-pane>
- <a-tab-pane v-for="tab in tabConfigs" :key="tab.key"
:tab="$t(tab.label)">
- <component :is="`U${tab.key}`" />
- </a-tab-pane>
- </a-tabs>
+ </template>
+ <div v-else class="empty-page" />
</div>
</div>
<!-- Create table secondary page -->
@@ -193,10 +276,59 @@ export default defineComponent({
font-size: 14px;
border: 1px solid #e8e8f0;
padding: 12px 0;
+ height: 100%;
min-height: 100%;
- .create-time {
- margin-top: 12px;
+ .tables-content {
+ display: flex;
+ height: 100%;
+ align-items: stretch;
+ }
+
+ .tables-sidebar {
+ flex: 0 0 auto;
+ height: 100%;
+ background-color: #fff;
+ position: relative;
+ }
+
+ .tables-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ height: 100%;
+ }
+
+ .tables-divider {
+ position: relative;
+ flex: 0 0 8px;
+ width: 8px;
+ height: 100%;
+ cursor: col-resize;
+ z-index: 2; // Ensure divider is above sidebar and main content so drag
area is not blocked
+ }
+
+ .tables-divider::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 50%;
+ width: 1px;
+ transform: translateX(-50%);
+ background: #e8e8f0;
+ }
+
+ .tables-main-body {
+ flex: 1;
+ min-height: 0;
+ padding: 0 24px 24px;
+ overflow: auto;
+ }
+
+ .tables-main .empty-page {
+ height: 100%;
}
.tables-menu-wrap {