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
+7
View File
@@ -1,5 +1,12 @@
# 项目上下文记录 # 项目上下文记录
## 2026-05-19:修复数据管理表格滚动和字段结构收起
- `frontend/src/components/DatabasePanel.vue` 为数据表增加明确的 `scroll-x` 宽度和底部外置横向滚动条,滚动条会与 Naive UI 表格内部横向滚动位置双向同步,避免字段结构区域或分页区域遮挡表格底部横向滚动入口。
- 字段结构区域从默认 `n-collapse` 改为受控折叠头,展开后标题显示“收起字段结构”,再次点击恢复“字段结构”,并使用本地图标箭头表示展开状态。
- 字段结构明细使用内部滚动容器限制高度,数据表主体保持 flex 占位,避免展开字段结构后挤掉分页或整页出现不必要滚动。
- 已执行 `npm run build`;已通过本机 Chrome DevTools 验证 `/database` 中表格横向滚动条可见且与表格滚动同步,字段结构展开/收起文案正常切换。
## 2026-05-19:压缩系统设置页布局高度 ## 2026-05-19:压缩系统设置页布局高度
- `frontend/src/styles.css` 调整系统设置页为固定高度布局,外层 `settings-workspace` 减小 padding 并隐藏溢出,避免主内容区出现整页滚动条。 - `frontend/src/styles.css` 调整系统设置页为固定高度布局,外层 `settings-workspace` 减小 padding 并隐藏溢出,避免主内容区出现整页滚动条。
+237 -24
View File
@@ -98,19 +98,37 @@
<div class="database-data-body" :class="{ empty: !selectedTable }"> <div class="database-data-body" :class="{ empty: !selectedTable }">
<n-empty v-if="!selectedTable" description="请选择左侧的数据表" /> <n-empty v-if="!selectedTable" description="请选择左侧的数据表" />
<template v-else> <template v-else>
<n-data-table <div ref="dataTableWrapperRef" class="database-data-table-wrap">
size="small" <n-data-table
class="database-data-table" size="small"
:columns="columns" class="database-data-table"
:data="rows" :columns="columns"
:loading="loadingData" :data="rows"
:bordered="false" :loading="loadingData"
:single-line="false" :bordered="false"
flex-height :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"> <section v-if="tableInfo" class="columns-collapse">
<n-collapse-item title="字段结构" name="columns"> <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"> <n-table size="small" :bordered="false">
<thead> <thead>
<tr> <tr>
@@ -133,8 +151,8 @@
</tr> </tr>
</tbody> </tbody>
</n-table> </n-table>
</n-collapse-item> </div>
</n-collapse> </section>
</template> </template>
</div> </div>
@@ -155,11 +173,17 @@
</template> </template>
<script setup lang="ts"> <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 { onBeforeRouteLeave } from 'vue-router';
import { useDialog, useMessage } from 'naive-ui'; import { useDialog, useMessage } from 'naive-ui';
import type { DataTableColumns, DropdownOption } 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 { apiGet, apiPost, download } from '../api/client';
import type { ApiMessage, DatabaseInfo, TableData, TableInfo } from '../types'; import type { ApiMessage, DatabaseInfo, TableData, TableInfo } from '../types';
@@ -168,6 +192,7 @@ import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
type RowData = Record<string, unknown>; type RowData = Record<string, unknown>;
type DownloadFormat = 'csv' | 'xlsx'; type DownloadFormat = 'csv' | 'xlsx';
const COLUMN_MIN_WIDTH = 140;
const message = useMessage(); const message = useMessage();
const dialog = useDialog(); const dialog = useDialog();
const databaseInfo = ref<DatabaseInfo | null>(null); const databaseInfo = ref<DatabaseInfo | null>(null);
@@ -182,7 +207,12 @@ const loadingTables = ref(false);
const loadingData = ref(false); const loadingData = ref(false);
const testing = ref(false); const testing = ref(false);
const downloadingFormat = ref<DownloadFormat | null>(null); 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 tableLoadToken = 0;
let removeTableScrollListener: (() => void) | null = null;
let syncingHorizontalScroll = false;
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))); const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
const downloading = computed(() => Boolean(downloadingFormat.value)); const downloading = computed(() => Boolean(downloadingFormat.value));
@@ -201,20 +231,29 @@ const loadDataInfileClass = computed(() => {
if (testing.value || !databaseInfo.value) return 'unknown'; if (testing.value || !databaseInfo.value) return 'unknown';
return databaseInfo.value.load_data_infile ? 'success' : 'warning'; return databaseInfo.value.load_data_infile ? 'success' : 'warning';
}); });
const columns = computed<DataTableColumns<RowData>>(() => { const columnKeys = computed(() => (
const keys = rows.value.length rows.value.length
? Object.keys(rows.value[0]) ? 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 => ({ return keys.map(key => ({
title: key, title: key,
key, key,
minWidth: 120, minWidth: COLUMN_MIN_WIDTH,
ellipsis: { tooltip: true }, ellipsis: { tooltip: true },
render: row => formatCell(row[key]) render: row => formatCell(row[key])
})); }));
}); });
watch([dataTableScrollX, rows, tableInfo], () => {
void refreshHorizontalScrollBinding();
}, { flush: 'post' });
onMounted(() => { onMounted(() => {
setPageHeader({ setPageHeader({
actions: [ actions: [
@@ -242,6 +281,7 @@ onBeforeRouteLeave(() => {
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
removeHorizontalScrollListener();
resetPageHeader(); resetPageHeader();
}); });
@@ -274,6 +314,8 @@ async function loadTables() {
tableInfo.value = null; tableInfo.value = null;
rows.value = []; rows.value = [];
total.value = 0; total.value = 0;
columnSchemaExpanded.value = false;
removeHorizontalScrollListener();
} }
} catch (error) { } catch (error) {
if (currentToken !== tableLoadToken) return; if (currentToken !== tableLoadToken) return;
@@ -293,13 +335,16 @@ function resetTableState() {
rows.value = []; rows.value = [];
total.value = 0; total.value = 0;
page.value = 1; page.value = 1;
columnSchemaExpanded.value = false;
loadingTables.value = false; loadingTables.value = false;
loadingData.value = false; loadingData.value = false;
removeHorizontalScrollListener();
} }
async function loadTable(table: string) { async function loadTable(table: string) {
selectedTable.value = table; selectedTable.value = table;
page.value = 1; page.value = 1;
columnSchemaExpanded.value = false;
await Promise.all([loadTableInfo(table), loadTableData()]); await Promise.all([loadTableInfo(table), loadTableData()]);
} }
@@ -331,9 +376,68 @@ async function loadTableData() {
message.error(error instanceof Error ? error.message : '加载表数据失败'); message.error(error instanceof Error ? error.message : '加载表数据失败');
} finally { } finally {
loadingData.value = false; 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) { function changePage(value: number) {
page.value = value; page.value = value;
void loadTableData(); void loadTableData();
@@ -401,6 +505,8 @@ async function dropTable() {
tableInfo.value = null; tableInfo.value = null;
rows.value = []; rows.value = [];
total.value = 0; total.value = 0;
columnSchemaExpanded.value = false;
removeHorizontalScrollListener();
await loadTables(); await loadTables();
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '删除失败'); message.error(error instanceof Error ? error.message : '删除失败');
@@ -426,6 +532,8 @@ async function dropAllTables() {
tableInfo.value = null; tableInfo.value = null;
rows.value = []; rows.value = [];
total.value = 0; total.value = 0;
columnSchemaExpanded.value = false;
removeHorizontalScrollListener();
await loadTables(); await loadTables();
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '删除全部数据表失败'); message.error(error instanceof Error ? error.message : '删除全部数据表失败');
@@ -693,7 +801,7 @@ function formatCell(value: unknown): string {
display: flex; display: flex;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: auto; overflow: hidden;
flex-direction: column; flex-direction: column;
} }
@@ -702,17 +810,122 @@ function formatCell(value: unknown): string {
justify-content: center; justify-content: center;
} }
.database-data-table { .database-data-table-wrap {
flex: 1; display: flex;
flex: 1 1 0;
min-width: 0;
min-height: 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 { .columns-collapse {
flex: 0 0 auto; flex: 0 0 auto;
padding: 0 14px 12px; max-height: 40%;
overflow: hidden;
border-top: 1px solid var(--td-border-color-light); 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 { .database-pagination {
display: flex; display: flex;
align-items: center; align-items: center;