From 3b9116e259eab2346cbefdb33309c968cb8967ff Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 26 Apr 2026 20:42:14 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20perf(table-overview):=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=A4=A7=E9=87=8F=E8=A1=A8=E6=90=9C=E7=B4=A2=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 预计算表概览搜索索引与排序键 - 使用 deferred value 降低搜索输入阻塞 - 限制大结果集首批渲染数量并支持继续加载 - 增加表概览过滤与渲染上限回归测试 --- frontend/src/components/TableOverview.tsx | 96 ++++++++++++++----- .../src/utils/tableOverviewFilter.test.ts | 48 ++++++++++ frontend/src/utils/tableOverviewFilter.ts | 66 +++++++++++++ 3 files changed, 185 insertions(+), 25 deletions(-) create mode 100644 frontend/src/utils/tableOverviewFilter.test.ts create mode 100644 frontend/src/utils/tableOverviewFilter.ts diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index 73cdcae..63c7cf1 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal } from 'antd'; +import React, { useState, useEffect, useMemo, useCallback, useDeferredValue } from 'react'; +import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'antd'; import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App'; @@ -9,6 +9,14 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { noAutoCapInputProps } from '../utils/inputAutoCap'; import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions'; import { buildTableSelectQuery } from '../utils/objectQueryTemplates'; +import { + TABLE_OVERVIEW_RENDER_BATCH_SIZE, + buildTableOverviewSearchIndex, + filterAndSortTableOverviewRows, + resolveTableOverviewVisibleRows, + type TableOverviewSortField, + type TableOverviewSortOrder, +} from '../utils/tableOverviewFilter'; interface TableOverviewProps { tab: TabData; @@ -25,8 +33,8 @@ interface TableStatRow { updateTime: string; } -type SortField = 'name' | 'rows' | 'dataSize'; -type SortOrder = 'asc' | 'desc'; +type SortField = TableOverviewSortField; +type SortOrder = TableOverviewSortOrder; type ViewMode = 'card' | 'list'; const formatSize = (bytes: number): string => { @@ -166,6 +174,9 @@ const TableOverview: React.FC = ({ tab }) => { const [sortField, setSortField] = useState('name'); const [sortOrder, setSortOrder] = useState('asc'); const [viewMode, setViewMode] = useState('list'); + const [visibleTableLimit, setVisibleTableLimit] = useState(TABLE_OVERVIEW_RENDER_BATCH_SIZE); + const deferredSearchText = useDeferredValue(searchText); + const isSearchPending = searchText !== deferredSearchText; const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]); const metadataDialect = useMemo( @@ -207,21 +218,21 @@ const TableOverview: React.FC = ({ tab }) => { void loadData(); }, [autoFetchVisible, loadData]); - const sortedFiltered = useMemo(() => { - let list = [...tables]; - if (searchText.trim()) { - const kw = searchText.trim().toLowerCase(); - list = list.filter(t => t.name.toLowerCase().includes(kw) || t.comment.toLowerCase().includes(kw)); - } - list.sort((a, b) => { - let cmp = 0; - if (sortField === 'name') cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase()); - else if (sortField === 'rows') cmp = a.rows - b.rows; - else if (sortField === 'dataSize') cmp = a.dataSize - b.dataSize; - return sortOrder === 'asc' ? cmp : -cmp; - }); - return list; - }, [tables, searchText, sortField, sortOrder]); + const tableSearchIndex = useMemo(() => buildTableOverviewSearchIndex(tables), [tables]); + + const sortedFiltered = useMemo(() => ( + filterAndSortTableOverviewRows(tableSearchIndex, deferredSearchText, sortField, sortOrder) + ), [deferredSearchText, sortField, sortOrder, tableSearchIndex]); + + useEffect(() => { + setVisibleTableLimit(TABLE_OVERVIEW_RENDER_BATCH_SIZE); + }, [deferredSearchText, sortField, sortOrder, viewMode, tables]); + + const visibleOverview = useMemo(() => ( + resolveTableOverviewVisibleRows(sortedFiltered, visibleTableLimit) + ), [sortedFiltered, visibleTableLimit]); + + const visibleTables = visibleOverview.visibleRows; const openTable = useCallback((tableName: string) => { if (!connection) return; @@ -397,11 +408,11 @@ const TableOverview: React.FC = ({ tab }) => { { key: 'dataSize', label: `按大小${sortField === 'dataSize' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('dataSize') }, ]; - const totalRows = tables.reduce((s, t) => s + t.rows, 0); - const totalSize = tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0); - const maxCombinedSize = sortedFiltered.reduce((max, table) => { + const totalRows = useMemo(() => tables.reduce((s, t) => s + t.rows, 0), [tables]); + const totalSize = useMemo(() => tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0), [tables]); + const maxCombinedSize = useMemo(() => sortedFiltered.reduce((max, table) => { return Math.max(max, table.dataSize + table.indexSize); - }, 0); + }, 0), [sortedFiltered]); const allowTruncate = supportsTableTruncateAction(connection?.config?.type || '', connection?.config?.driver); if (loading) { @@ -468,6 +479,31 @@ const TableOverview: React.FC = ({ tab }) => { {/* Content Area */}
+ {sortedFiltered.length > 0 && (isSearchPending || visibleOverview.hiddenCount > 0 || deferredSearchText.trim()) && ( +
+ + {isSearchPending + ? '正在更新筛选结果...' + : `匹配 ${sortedFiltered.length} 张表,当前渲染 ${visibleTables.length} 张`} + + {visibleOverview.hiddenCount > 0 && ( + 还有 {visibleOverview.hiddenCount} 张未渲染,可继续加载或缩小搜索范围 + )} +
+ )} {sortedFiltered.length === 0 ? ( ) : viewMode === 'card' ? ( @@ -477,7 +513,7 @@ const TableOverview: React.FC = ({ tab }) => { gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 12, }}> - {sortedFiltered.map(t => ( + {visibleTables.map(t => ( = ({ tab }) => { ) : ( /* ========== 行视图 ========== */
- {sortedFiltered.map(t => { + {visibleTables.map(t => { const combinedSize = t.dataSize + t.indexSize; const sizeRatio = maxCombinedSize > 0 ? combinedSize / maxCombinedSize : 0; const fillWidth = maxCombinedSize > 0 ? `${Math.max(10, Math.round(sizeRatio * 100))}%` : '0%'; @@ -695,6 +731,16 @@ const TableOverview: React.FC = ({ tab }) => { })}
)} + {sortedFiltered.length > 0 && visibleOverview.hiddenCount > 0 && ( +
+ +
+ )}
); diff --git a/frontend/src/utils/tableOverviewFilter.test.ts b/frontend/src/utils/tableOverviewFilter.test.ts new file mode 100644 index 0000000..c29f895 --- /dev/null +++ b/frontend/src/utils/tableOverviewFilter.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { + TABLE_OVERVIEW_RENDER_BATCH_SIZE, + buildTableOverviewSearchIndex, + filterAndSortTableOverviewRows, + resolveTableOverviewVisibleRows, +} from './tableOverviewFilter'; + +const buildRows = (count: number) => Array.from({ length: count }, (_, index) => ({ + name: `table_${String(index).padStart(4, '0')}`, + comment: index === count - 1 ? 'target table comment' : 'normal table', + rows: index, + dataSize: count - index, + indexSize: 0, +})); + +describe('tableOverviewFilter', () => { + it('filters against the full table set before applying the render limit', () => { + const rows = buildRows(1200); + const indexed = buildTableOverviewSearchIndex(rows); + const filtered = filterAndSortTableOverviewRows(indexed, 'target', 'name', 'asc'); + + expect(filtered).toHaveLength(1); + expect(filtered[0].name).toBe('table_1199'); + }); + + it('caps initially rendered rows for large overview result sets', () => { + const rows = buildRows(1200); + const visible = resolveTableOverviewVisibleRows(rows, TABLE_OVERVIEW_RENDER_BATCH_SIZE); + + expect(visible.visibleRows).toHaveLength(TABLE_OVERVIEW_RENDER_BATCH_SIZE); + expect(visible.hiddenCount).toBe(1200 - TABLE_OVERVIEW_RENDER_BATCH_SIZE); + expect(visible.totalCount).toBe(1200); + }); + + it('sorts with precomputed normalized table names', () => { + const indexed = buildTableOverviewSearchIndex([ + { name: 'z_table', comment: '', rows: 1, dataSize: 10, indexSize: 0 }, + { name: 'A_table', comment: '', rows: 2, dataSize: 5, indexSize: 0 }, + ]); + + expect(filterAndSortTableOverviewRows(indexed, '', 'name', 'asc').map((item) => item.name)).toEqual([ + 'A_table', + 'z_table', + ]); + }); +}); diff --git a/frontend/src/utils/tableOverviewFilter.ts b/frontend/src/utils/tableOverviewFilter.ts new file mode 100644 index 0000000..3f75ced --- /dev/null +++ b/frontend/src/utils/tableOverviewFilter.ts @@ -0,0 +1,66 @@ +export const TABLE_OVERVIEW_RENDER_BATCH_SIZE = 300; + +export type TableOverviewSortField = 'name' | 'rows' | 'dataSize'; +export type TableOverviewSortOrder = 'asc' | 'desc'; + +export interface TableOverviewFilterRow { + name: string; + comment?: string; + rows: number; + dataSize: number; + indexSize: number; +} + +export interface TableOverviewSearchIndexItem { + row: T; + searchText: string; + sortName: string; +} + +export const buildTableOverviewSearchIndex = ( + rows: T[], +): TableOverviewSearchIndexItem[] => rows.map((row) => ({ + row, + searchText: `${row.name}\n${row.comment || ''}`.toLowerCase(), + sortName: row.name.toLowerCase(), + })); + +export const filterAndSortTableOverviewRows = ( + indexedRows: TableOverviewSearchIndexItem[], + rawSearchText: string, + sortField: TableOverviewSortField, + sortOrder: TableOverviewSortOrder, +): T[] => { + const keyword = String(rawSearchText || '').trim().toLowerCase(); + const matched = keyword + ? indexedRows.filter((item) => item.searchText.includes(keyword)) + : [...indexedRows]; + + matched.sort((a, b) => { + let cmp = 0; + if (sortField === 'name') { + cmp = a.sortName.localeCompare(b.sortName); + } else if (sortField === 'rows') { + cmp = a.row.rows - b.row.rows; + } else if (sortField === 'dataSize') { + cmp = a.row.dataSize - b.row.dataSize; + } + return sortOrder === 'asc' ? cmp : -cmp; + }); + + return matched.map((item) => item.row); +}; + +export const resolveTableOverviewVisibleRows = ( + rows: T[], + rawLimit: number, +): { visibleRows: T[]; hiddenCount: number; totalCount: number } => { + const limit = Number.isFinite(rawLimit) && rawLimit > 0 + ? Math.min(Math.floor(rawLimit), rows.length) + : Math.min(TABLE_OVERVIEW_RENDER_BATCH_SIZE, rows.length); + return { + visibleRows: rows.slice(0, limit), + hiddenCount: Math.max(0, rows.length - limit), + totalCount: rows.length, + }; +};