fix: 修复数据管理表格滚动和字段结构收起

This commit is contained in:
2026-05-19 11:29:47 +08:00
parent ec658b5dac
commit f82c36c485
2 changed files with 244 additions and 24 deletions
+237 -24
View File
@@ -98,19 +98,37 @@
<div class="database-data-body" :class="{ empty: !selectedTable }">
<n-empty v-if="!selectedTable" description="请选择左侧的数据表" />
<template v-else>
<n-data-table
size="small"
class="database-data-table"
:columns="columns"
:data="rows"
:loading="loadingData"
:bordered="false"
:single-line="false"
flex-height
/>
<div ref="dataTableWrapperRef" class="database-data-table-wrap">
<n-data-table
size="small"
class="database-data-table"
:columns="columns"
:data="rows"
:loading="loadingData"
:bordered="false"
:single-line="false"
:scroll-x="dataTableScrollX"
flex-height
/>
</div>
<div
v-if="hasDataColumns"
ref="horizontalScrollRef"
class="database-horizontal-scrollbar"
aria-label="数据表横向滚动条"
@scroll="syncTableScrollFromBar"
>
<div class="database-horizontal-scrollbar-inner" :style="{ width: `${dataTableScrollX}px` }"></div>
</div>
<n-collapse v-if="tableInfo" class="columns-collapse">
<n-collapse-item title="字段结构" name="columns">
<section v-if="tableInfo" class="columns-collapse">
<button type="button" class="columns-collapse-header" @click="toggleColumnSchema">
<n-icon class="columns-collapse-icon" :class="{ expanded: columnSchemaExpanded }">
<ChevronForwardOutline />
</n-icon>
<span>{{ columnSchemaExpanded ? '收起字段结构' : '字段结构' }}</span>
</button>
<div v-if="columnSchemaExpanded" class="columns-schema-scroll">
<n-table size="small" :bordered="false">
<thead>
<tr>
@@ -133,8 +151,8 @@
</tr>
</tbody>
</n-table>
</n-collapse-item>
</n-collapse>
</div>
</section>
</template>
</div>
@@ -155,11 +173,17 @@
</template>
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import { useDialog, useMessage } from 'naive-ui';
import type { DataTableColumns, DropdownOption } from 'naive-ui';
import { ChevronDownOutline, CloudDownloadOutline, RefreshOutline, TrashOutline } from '@vicons/ionicons5';
import {
ChevronDownOutline,
ChevronForwardOutline,
CloudDownloadOutline,
RefreshOutline,
TrashOutline
} from '@vicons/ionicons5';
import { apiGet, apiPost, download } from '../api/client';
import type { ApiMessage, DatabaseInfo, TableData, TableInfo } from '../types';
@@ -168,6 +192,7 @@ import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
type RowData = Record<string, unknown>;
type DownloadFormat = 'csv' | 'xlsx';
const COLUMN_MIN_WIDTH = 140;
const message = useMessage();
const dialog = useDialog();
const databaseInfo = ref<DatabaseInfo | null>(null);
@@ -182,7 +207,12 @@ const loadingTables = ref(false);
const loadingData = ref(false);
const testing = ref(false);
const downloadingFormat = ref<DownloadFormat | null>(null);
const dataTableWrapperRef = ref<HTMLElement | null>(null);
const horizontalScrollRef = ref<HTMLElement | null>(null);
const columnSchemaExpanded = ref(false);
let tableLoadToken = 0;
let removeTableScrollListener: (() => void) | null = null;
let syncingHorizontalScroll = false;
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
const downloading = computed(() => Boolean(downloadingFormat.value));
@@ -201,20 +231,29 @@ const loadDataInfileClass = computed(() => {
if (testing.value || !databaseInfo.value) return 'unknown';
return databaseInfo.value.load_data_infile ? 'success' : 'warning';
});
const columns = computed<DataTableColumns<RowData>>(() => {
const keys = rows.value.length
const columnKeys = computed(() => (
rows.value.length
? Object.keys(rows.value[0])
: tableInfo.value?.columns.map(column => String(column.Field || '')).filter(Boolean) || [];
: tableInfo.value?.columns.map(column => String(column.Field || '')).filter(Boolean) || []
));
const hasDataColumns = computed(() => columnKeys.value.length > 0);
const dataTableScrollX = computed(() => Math.max(columnKeys.value.length * COLUMN_MIN_WIDTH, COLUMN_MIN_WIDTH));
const columns = computed<DataTableColumns<RowData>>(() => {
const keys = columnKeys.value;
return keys.map(key => ({
title: key,
key,
minWidth: 120,
minWidth: COLUMN_MIN_WIDTH,
ellipsis: { tooltip: true },
render: row => formatCell(row[key])
}));
});
watch([dataTableScrollX, rows, tableInfo], () => {
void refreshHorizontalScrollBinding();
}, { flush: 'post' });
onMounted(() => {
setPageHeader({
actions: [
@@ -242,6 +281,7 @@ onBeforeRouteLeave(() => {
});
onBeforeUnmount(() => {
removeHorizontalScrollListener();
resetPageHeader();
});
@@ -274,6 +314,8 @@ async function loadTables() {
tableInfo.value = null;
rows.value = [];
total.value = 0;
columnSchemaExpanded.value = false;
removeHorizontalScrollListener();
}
} catch (error) {
if (currentToken !== tableLoadToken) return;
@@ -293,13 +335,16 @@ function resetTableState() {
rows.value = [];
total.value = 0;
page.value = 1;
columnSchemaExpanded.value = false;
loadingTables.value = false;
loadingData.value = false;
removeHorizontalScrollListener();
}
async function loadTable(table: string) {
selectedTable.value = table;
page.value = 1;
columnSchemaExpanded.value = false;
await Promise.all([loadTableInfo(table), loadTableData()]);
}
@@ -331,9 +376,68 @@ async function loadTableData() {
message.error(error instanceof Error ? error.message : '加载表数据失败');
} finally {
loadingData.value = false;
void refreshHorizontalScrollBinding();
}
}
function toggleColumnSchema() {
columnSchemaExpanded.value = !columnSchemaExpanded.value;
void refreshHorizontalScrollBinding();
}
function findTableScrollContainer(): HTMLElement | null {
const wrapper = dataTableWrapperRef.value;
if (!wrapper) return null;
const candidates = Array.from(
wrapper.querySelectorAll<HTMLElement>('.n-scrollbar-container, .n-data-table-base-table-body, .n-data-table-base-table-header')
);
return candidates.find(element => element.scrollWidth > element.clientWidth + 1) || null;
}
function removeHorizontalScrollListener() {
removeTableScrollListener?.();
removeTableScrollListener = null;
}
async function refreshHorizontalScrollBinding() {
await nextTick();
removeHorizontalScrollListener();
const tableScroller = findTableScrollContainer();
const horizontalScroller = horizontalScrollRef.value;
if (!tableScroller || !horizontalScroller) return;
horizontalScroller.scrollLeft = tableScroller.scrollLeft;
const handleTableScroll = () => {
if (syncingHorizontalScroll) return;
syncingHorizontalScroll = true;
horizontalScroller.scrollLeft = tableScroller.scrollLeft;
requestAnimationFrame(() => {
syncingHorizontalScroll = false;
});
};
tableScroller.addEventListener('scroll', handleTableScroll, { passive: true });
removeTableScrollListener = () => {
tableScroller.removeEventListener('scroll', handleTableScroll);
};
}
function syncTableScrollFromBar() {
if (syncingHorizontalScroll) return;
const tableScroller = findTableScrollContainer();
const horizontalScroller = horizontalScrollRef.value;
if (!tableScroller || !horizontalScroller) return;
syncingHorizontalScroll = true;
tableScroller.scrollLeft = horizontalScroller.scrollLeft;
requestAnimationFrame(() => {
syncingHorizontalScroll = false;
});
}
function changePage(value: number) {
page.value = value;
void loadTableData();
@@ -401,6 +505,8 @@ async function dropTable() {
tableInfo.value = null;
rows.value = [];
total.value = 0;
columnSchemaExpanded.value = false;
removeHorizontalScrollListener();
await loadTables();
} catch (error) {
message.error(error instanceof Error ? error.message : '删除失败');
@@ -426,6 +532,8 @@ async function dropAllTables() {
tableInfo.value = null;
rows.value = [];
total.value = 0;
columnSchemaExpanded.value = false;
removeHorizontalScrollListener();
await loadTables();
} catch (error) {
message.error(error instanceof Error ? error.message : '删除全部数据表失败');
@@ -693,7 +801,7 @@ function formatCell(value: unknown): string {
display: flex;
flex: 1;
min-height: 0;
overflow: auto;
overflow: hidden;
flex-direction: column;
}
@@ -702,17 +810,122 @@ function formatCell(value: unknown): string {
justify-content: center;
}
.database-data-table {
flex: 1;
.database-data-table-wrap {
display: flex;
flex: 1 1 0;
min-width: 0;
min-height: 0;
}
.database-data-table {
flex: 1 1 0;
min-width: 0;
min-height: 0;
}
.database-horizontal-scrollbar {
height: 14px;
flex: 0 0 14px;
overflow-x: auto;
overflow-y: hidden;
background: var(--td-bg-color-container);
border-top: 1px solid var(--td-border-color-light);
scrollbar-color: var(--td-scrollbar-color) transparent;
scrollbar-width: thin;
}
.database-horizontal-scrollbar::-webkit-scrollbar {
height: 10px;
}
.database-horizontal-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.database-horizontal-scrollbar::-webkit-scrollbar-thumb {
background: var(--td-scrollbar-color);
border-radius: 8px;
}
.database-horizontal-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--td-text-color-placeholder);
}
.database-horizontal-scrollbar-inner {
height: 1px;
}
.columns-collapse {
flex: 0 0 auto;
padding: 0 14px 12px;
max-height: 40%;
overflow: hidden;
border-top: 1px solid var(--td-border-color-light);
}
.columns-collapse-header {
display: flex;
width: 100%;
min-height: 36px;
align-items: center;
gap: 8px;
padding: 0 14px;
color: var(--td-text-color-primary);
font: inherit;
font-size: 13px;
font-weight: 500;
text-align: left;
background: var(--td-bg-color-container);
border: 0;
cursor: pointer;
transition: background-color var(--td-transition), color var(--td-transition);
}
.columns-collapse-header:hover {
color: var(--td-brand-color);
background: var(--td-bg-color-container-hover);
}
.columns-collapse-icon {
flex: 0 0 auto;
color: var(--td-text-color-secondary);
font-size: 15px;
transition: transform var(--td-transition), color var(--td-transition);
}
.columns-collapse-header:hover .columns-collapse-icon {
color: var(--td-brand-color);
}
.columns-collapse-icon.expanded {
transform: rotate(90deg);
}
.columns-schema-scroll {
max-height: min(360px, 36vh);
overflow: auto;
padding: 0 14px 12px;
scrollbar-color: var(--td-scrollbar-color) transparent;
scrollbar-width: thin;
}
.columns-schema-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.columns-schema-scroll::-webkit-scrollbar-track {
background: transparent;
}
.columns-schema-scroll::-webkit-scrollbar-thumb {
background: var(--td-scrollbar-color);
border-radius: 8px;
}
.columns-schema-scroll::-webkit-scrollbar-thumb:hover {
background: var(--td-text-color-placeholder);
}
.database-pagination {
display: flex;
align-items: center;