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:压缩系统设置页布局高度
|
## 2026-05-19:压缩系统设置页布局高度
|
||||||
|
|
||||||
- `frontend/src/styles.css` 调整系统设置页为固定高度布局,外层 `settings-workspace` 减小 padding 并隐藏溢出,避免主内容区出现整页滚动条。
|
- `frontend/src/styles.css` 调整系统设置页为固定高度布局,外层 `settings-workspace` 减小 padding 并隐藏溢出,避免主内容区出现整页滚动条。
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
<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>
|
||||||
|
<div ref="dataTableWrapperRef" class="database-data-table-wrap">
|
||||||
<n-data-table
|
<n-data-table
|
||||||
size="small"
|
size="small"
|
||||||
class="database-data-table"
|
class="database-data-table"
|
||||||
@@ -106,11 +107,28 @@
|
|||||||
:loading="loadingData"
|
:loading="loadingData"
|
||||||
:bordered="false"
|
:bordered="false"
|
||||||
:single-line="false"
|
:single-line="false"
|
||||||
|
:scroll-x="dataTableScrollX"
|
||||||
flex-height
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user