fix: 修复数据管理表格滚动和字段结构收起
This commit is contained in:
@@ -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:压缩系统设置页布局高度
|
||||
|
||||
- `frontend/src/styles.css` 调整系统设置页为固定高度布局,外层 `settings-workspace` 减小 padding 并隐藏溢出,避免主内容区出现整页滚动条。
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user