From 8739c05c0e2aa96d69faec3b9f76b4d2c7f66108 Mon Sep 17 00:00:00 2001 From: zuiho <31877877+zuiho-kai@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:15:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=94=AF=E6=8C=81=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E5=88=97=E8=A1=A8=E6=89=8B=E5=8A=A8=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E5=88=97=E5=AE=BD=20(#5948)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 支持渠道表格列宽拖拽 * fix(web): address column resize review feedback * feat(web): auto-fit resized table columns * fix(web): reduce column resize persistence work --------- Signed-off-by: zuiho <2324465096@qq.com> --- .../data-table/core/data-table-colgroup.tsx | 24 +- .../data-table/core/data-table-header.tsx | 188 +++++++++++++++- .../data-table/core/data-table-row.tsx | 1 + .../data-table/hooks/use-data-table.ts | 206 ++++++++++++++++++ .../channels/components/channels-columns.tsx | 10 +- .../channels/components/channels-table.tsx | 5 + 6 files changed, 425 insertions(+), 9 deletions(-) diff --git a/web/default/src/components/data-table/core/data-table-colgroup.tsx b/web/default/src/components/data-table/core/data-table-colgroup.tsx index a8724588..8cda18cb 100644 --- a/web/default/src/components/data-table/core/data-table-colgroup.tsx +++ b/web/default/src/components/data-table/core/data-table-colgroup.tsx @@ -34,9 +34,12 @@ export function DataTableColgroup({ return ( {columns.map((column) => { - const width = isContentSizedColumn(column.id) - ? undefined - : getColumnWidth(column.getSize(), totalSize) + const width = getColumnWidth( + table, + column.id, + column.getSize(), + totalSize + ) return })} @@ -44,7 +47,20 @@ export function DataTableColgroup({ ) } -function getColumnWidth(columnSize: number, totalSize: number) { +function getColumnWidth( + table: TanstackTable, + columnId: string, + columnSize: number, + totalSize: number +) { + if (isContentSizedColumn(columnId)) { + return undefined + } + + if (table.options.enableColumnResizing === true) { + return `${columnSize}px` + } + if (totalSize <= 0) { return undefined } diff --git a/web/default/src/components/data-table/core/data-table-header.tsx b/web/default/src/components/data-table/core/data-table-header.tsx index 14f25faf..a8d191ba 100644 --- a/web/default/src/components/data-table/core/data-table-header.tsx +++ b/web/default/src/components/data-table/core/data-table-header.tsx @@ -21,8 +21,11 @@ import { type Header, type Table as TanstackTable, } from '@tanstack/react-table' +import type { KeyboardEvent, MouseEvent } from 'react' +import { useTranslation } from 'react-i18next' import { TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { cn } from '@/lib/utils' import { DataTableColumnHeader } from './column-header' import { isContentSizedColumn } from './content-sized-columns' @@ -43,6 +46,8 @@ export function DataTableHeader({ rowClassName, getColumnClassName, }: DataTableHeaderProps) { + const { t } = useTranslation() + return ( {table.getHeaderGroups().map((headerGroup) => ( @@ -51,10 +56,36 @@ export function DataTableHeader({ {renderHeaderContent(header)} + {shouldRenderColumnResizer(table, header) && ( +
+ handleColumnAutoSize(event, table, header) + } + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + onKeyDown={(event) => + handleColumnResizeKeyDown(event, table, header) + } + className={cn( + 'absolute top-0 right-0 h-full w-2 cursor-col-resize touch-none select-none', + 'after:bg-border hover:after:bg-primary after:absolute after:top-2 after:right-0 after:h-[calc(100%-1rem)] after:w-px after:transition-colors', + header.column.getIsResizing() && 'after:bg-primary' + )} + /> + )} ))} @@ -63,6 +94,161 @@ export function DataTableHeader({ ) } +function handleColumnResizeKeyDown( + event: KeyboardEvent, + table: TanstackTable, + header: Header +) { + const step = event.shiftKey ? 50 : 10 + + if (event.key === 'ArrowLeft') { + event.preventDefault() + resizeColumnByKeyboard(table, header, -step) + return + } + + if (event.key === 'ArrowRight') { + event.preventDefault() + resizeColumnByKeyboard(table, header, step) + return + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + autoSizeColumn(event.currentTarget, table, header) + } +} + +function resizeColumnByKeyboard( + table: TanstackTable, + header: Header, + delta: number +) { + table.setColumnSizing((previous) => ({ + ...previous, + [header.column.id]: getClampedColumnSize( + header, + header.column.getSize() + delta + ), + })) +} + +function handleColumnAutoSize( + event: MouseEvent, + table: TanstackTable, + header: Header +) { + event.preventDefault() + autoSizeColumn(event.currentTarget, table, header) +} + +function autoSizeColumn( + resizerElement: HTMLElement, + table: TanstackTable, + header: Header +) { + const measuredSize = measureColumnContentWidth( + resizerElement, + header.column.id + ) + + if (measuredSize === undefined) { + return + } + + table.setColumnSizing((previous) => ({ + ...previous, + [header.column.id]: getClampedColumnSize(header, measuredSize), + })) +} + +function getClampedColumnSize( + header: Header, + nextSize: number +) { + const { minSize, maxSize } = header.column.columnDef + + if (typeof minSize === 'number' && nextSize < minSize) { + return minSize + } + + if (typeof maxSize === 'number' && nextSize > maxSize) { + return maxSize + } + + return nextSize +} + +function measureColumnContentWidth( + resizerElement: HTMLElement, + columnId: string +) { + const tableElement = resizerElement.closest('table') + if (!tableElement) { + return undefined + } + + const cells = tableElement.querySelectorAll( + getColumnElementSelector(columnId) + ) + if (cells.length === 0) { + return undefined + } + + const measuredWidth = [...cells].reduce( + (maxWidth, cell) => Math.max(maxWidth, measureElementWidth(cell)), + 0 + ) + + return measuredWidth > 0 ? Math.ceil(measuredWidth) : undefined +} + +function measureElementWidth(element: HTMLElement) { + const clone = element.cloneNode(true) as HTMLElement + + clone.querySelectorAll('[data-column-resizer]').forEach((resizer) => { + resizer.remove() + }) + + clone.style.position = 'absolute' + clone.style.visibility = 'hidden' + clone.style.pointerEvents = 'none' + clone.style.left = '-10000px' + clone.style.top = '0' + clone.style.width = 'max-content' + clone.style.minWidth = '0' + clone.style.maxWidth = 'none' + clone.style.height = 'auto' + clone.style.whiteSpace = 'nowrap' + + document.body.append(clone) + const width = clone.scrollWidth + clone.remove() + + return width +} + +function getColumnElementSelector(columnId: string) { + const escapedColumnId = + typeof CSS !== 'undefined' && typeof CSS.escape === 'function' + ? CSS.escape(columnId) + : columnId.replaceAll('\\', '\\\\').replaceAll('"', '\\"') + + return `[data-column-id="${escapedColumnId}"]` +} + +function shouldRenderColumnResizer( + table: TanstackTable, + header: Header +) { + return ( + table.options.enableColumnResizing === true && + !header.isPlaceholder && + header.column.getCanResize() && + !isContentSizedColumn(header.column.id) + ) +} + function getHeaderSizeStyle( header: Header, applyHeaderSize: boolean | undefined diff --git a/web/default/src/components/data-table/core/data-table-row.tsx b/web/default/src/components/data-table/core/data-table-row.tsx index 308ec74c..137448b4 100644 --- a/web/default/src/components/data-table/core/data-table-row.tsx +++ b/web/default/src/components/data-table/core/data-table-row.tsx @@ -65,6 +65,7 @@ function DataTableRowInner({ return ( = Pick< | 'manualFiltering' | 'manualPagination' | 'manualSorting' + | 'enableColumnResizing' > type DataTableStateOptions = { @@ -58,6 +60,10 @@ type DataTableStateOptions = { columnVisibilityStorageKey?: string | false columnVisibility?: VisibilityState onColumnVisibilityChange?: OnChangeFn + initialColumnSizing?: ColumnSizingState + columnSizingStorageKey?: string | false + columnSizing?: ColumnSizingState + onColumnSizingChange?: OnChangeFn initialRowSelection?: RowSelectionState rowSelection?: RowSelectionState onRowSelectionChange?: OnChangeFn @@ -91,6 +97,21 @@ type UseDataTableOptions = DataTableFeatureOptions & ensurePageInRange?: (pageCount: number) => void } +type ColumnSizingBounds = Record< + string, + { + minSize?: number + maxSize?: number + } +> + +type ColumnWithSizing = ColumnDef & { + accessorKey?: string | number + columns?: ColumnDef[] +} + +const COLUMN_SIZING_PERSIST_DELAY_MS = 250 + function resolveUpdater( updater: Updater, previous: TValue @@ -149,6 +170,110 @@ function readColumnVisibility(storageKey: string | undefined): VisibilityState { } } +function getColumnId(column: ColumnDef) { + const columnWithSizing = column as ColumnWithSizing + + if (typeof columnWithSizing.id === 'string') { + return columnWithSizing.id + } + + if (typeof columnWithSizing.accessorKey === 'string') { + return columnWithSizing.accessorKey.replaceAll('.', '_') + } + + if (typeof columnWithSizing.accessorKey === 'number') { + return String(columnWithSizing.accessorKey) + } + + return undefined +} + +function buildColumnSizingBounds( + columns: ColumnDef[] +): ColumnSizingBounds { + return columns.reduce((bounds, column) => { + const columnWithSizing = column as ColumnWithSizing + const columnId = getColumnId(column) + + if (columnId) { + const minSize = + typeof columnWithSizing.minSize === 'number' && + Number.isFinite(columnWithSizing.minSize) + ? columnWithSizing.minSize + : undefined + const maxSize = + typeof columnWithSizing.maxSize === 'number' && + Number.isFinite(columnWithSizing.maxSize) + ? columnWithSizing.maxSize + : undefined + + if (minSize !== undefined || maxSize !== undefined) { + bounds[columnId] = { minSize, maxSize } + } + } + + if (Array.isArray(columnWithSizing.columns)) { + Object.assign(bounds, buildColumnSizingBounds(columnWithSizing.columns)) + } + + return bounds + }, {}) +} + +function getBoundedColumnSize( + columnId: string, + value: unknown, + bounds: ColumnSizingBounds +) { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return undefined + } + + const columnBounds = bounds[columnId] + let size = value + + if (columnBounds?.minSize !== undefined && size < columnBounds.minSize) { + size = columnBounds.minSize + } + + if (columnBounds?.maxSize !== undefined && size > columnBounds.maxSize) { + size = columnBounds.maxSize + } + + return size > 0 ? size : undefined +} + +function readColumnSizing( + storageKey: string | undefined, + bounds: ColumnSizingBounds +): ColumnSizingState { + if (!storageKey || typeof window === 'undefined') return {} + + try { + const raw = window.localStorage.getItem(storageKey) + if (!raw) return {} + + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {} + } + + return Object.entries(parsed).reduce( + (sizing, [key, value]) => { + const boundedSize = getBoundedColumnSize(key, value, bounds) + + if (boundedSize !== undefined) { + sizing[key] = boundedSize + } + return sizing + }, + {} + ) + } catch { + return {} + } +} + export function useDataTable(options: UseDataTableOptions) { const { data, @@ -161,6 +286,7 @@ export function useDataTable(options: UseDataTableOptions) { manualSorting, initialSorting = [], initialColumnVisibility = {}, + initialColumnSizing = {}, initialRowSelection = {}, initialExpanded = {}, initialPagination = { pageIndex: 0, pageSize: 20 }, @@ -175,6 +301,10 @@ export function useDataTable(options: UseDataTableOptions) { typeof options.columnVisibilityStorageKey === 'string' ? options.columnVisibilityStorageKey : undefined + const columnSizingStorageKey = + typeof options.columnSizingStorageKey === 'string' + ? options.columnSizingStorageKey + : undefined const resolvedInitialColumnVisibility = React.useMemo( () => ({ ...initialColumnVisibility, @@ -182,6 +312,17 @@ export function useDataTable(options: UseDataTableOptions) { }), [columnVisibilityStorageKey, initialColumnVisibility] ) + const columnSizingBounds = React.useMemo( + () => buildColumnSizingBounds(columns), + [columns] + ) + const resolvedInitialColumnSizing = React.useMemo( + () => ({ + ...initialColumnSizing, + ...readColumnSizing(columnSizingStorageKey, columnSizingBounds), + }), + [columnSizingBounds, columnSizingStorageKey, initialColumnSizing] + ) const [sorting, onSortingChange] = useControllableTableState( options.sorting, @@ -194,10 +335,20 @@ export function useDataTable(options: UseDataTableOptions) { resolvedInitialColumnVisibility, options.onColumnVisibilityChange ) + const [columnSizing, onColumnSizingChange] = useControllableTableState( + options.columnSizing, + resolvedInitialColumnSizing, + options.onColumnSizingChange + ) const hydratedColumnVisibilityStorageKeyRef = React.useRef( columnVisibilityStorageKey ) + const hydratedColumnSizingStorageKeyRef = React.useRef(columnSizingStorageKey) const skipNextColumnVisibilityPersistRef = React.useRef(false) + const skipNextColumnSizingPersistRef = React.useRef(false) + const columnSizingPersistTimerRef = React.useRef( + undefined + ) const [rowSelection, onRowSelectionChange] = useControllableTableState( options.rowSelection, initialRowSelection, @@ -228,6 +379,7 @@ export function useDataTable(options: UseDataTableOptions) { state: { sorting, columnVisibility, + columnSizing, rowSelection, expanded, columnFilters: options.columnFilters, @@ -242,8 +394,11 @@ export function useDataTable(options: UseDataTableOptions) { manualFiltering, manualPagination, manualSorting, + enableColumnResizing: options.enableColumnResizing, + columnResizeMode: 'onChange', onSortingChange, onColumnVisibilityChange, + onColumnSizingChange, onRowSelectionChange, onExpandedChange, onColumnFiltersChange: options.onColumnFiltersChange, @@ -290,6 +445,24 @@ export function useDataTable(options: UseDataTableOptions) { resolvedInitialColumnVisibility, ]) + React.useEffect(() => { + if ( + options.columnSizing !== undefined || + columnSizingStorageKey === hydratedColumnSizingStorageKeyRef.current + ) { + return + } + + hydratedColumnSizingStorageKeyRef.current = columnSizingStorageKey + skipNextColumnSizingPersistRef.current = true + onColumnSizingChange(() => resolvedInitialColumnSizing) + }, [ + columnSizingStorageKey, + onColumnSizingChange, + options.columnSizing, + resolvedInitialColumnSizing, + ]) + React.useEffect(() => { if (!columnVisibilityStorageKey || typeof window === 'undefined') return @@ -308,6 +481,39 @@ export function useDataTable(options: UseDataTableOptions) { } }, [columnVisibility, columnVisibilityStorageKey]) + React.useEffect(() => { + if (!columnSizingStorageKey || typeof window === 'undefined') return + + if (skipNextColumnSizingPersistRef.current) { + skipNextColumnSizingPersistRef.current = false + return + } + + if (columnSizingPersistTimerRef.current !== undefined) { + window.clearTimeout(columnSizingPersistTimerRef.current) + } + + columnSizingPersistTimerRef.current = window.setTimeout(() => { + try { + window.localStorage.setItem( + columnSizingStorageKey, + JSON.stringify(columnSizing) + ) + } catch { + // Storage can be unavailable in private mode; table controls still work. + } finally { + columnSizingPersistTimerRef.current = undefined + } + }, COLUMN_SIZING_PERSIST_DELAY_MS) + + return () => { + if (columnSizingPersistTimerRef.current !== undefined) { + window.clearTimeout(columnSizingPersistTimerRef.current) + columnSizingPersistTimerRef.current = undefined + } + } + }, [columnSizing, columnSizingStorageKey]) + return { table, } diff --git a/web/default/src/features/channels/components/channels-columns.tsx b/web/default/src/features/channels/components/channels-columns.tsx index 8cb2f340..4fcc8c02 100644 --- a/web/default/src/features/channels/components/channels-columns.tsx +++ b/web/default/src/features/channels/components/channels-columns.tsx @@ -560,6 +560,7 @@ export function useChannelsColumns( }, enableSorting: false, enableHiding: false, + enableResizing: false, size: 40, } satisfies ColumnDef, ] @@ -624,13 +625,13 @@ export function useChannelsColumns( const hasParamOverride = Boolean(channel.param_override?.trim()) return ( -
-
-
+
+
+
{isPassThrough && ( @@ -684,6 +685,7 @@ export function useChannelsColumns(
) }, + size: 260, minSize: 200, }, diff --git a/web/default/src/features/channels/components/channels-table.tsx b/web/default/src/features/channels/components/channels-table.tsx index 81a59a33..e9d058bb 100644 --- a/web/default/src/features/channels/components/channels-table.tsx +++ b/web/default/src/features/channels/components/channels-table.tsx @@ -67,6 +67,7 @@ import { DataTableBulkActions } from './data-table-bulk-actions' const route = getRouteApi('/_authenticated/channels/') const CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY = 'channels:column-visibility' +const CHANNELS_COLUMN_SIZING_STORAGE_KEY = 'channels:column-sizing' const CHANNELS_VIEW_MODE_STORAGE_KEY = 'channels:view-mode' const CHANNELS_STATUS_FILTER_STORAGE_KEY = 'channel-status-filter' @@ -316,6 +317,9 @@ export function ChannelsTable() { tag: false, }, columnVisibilityStorageKey: CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY, + columnSizingStorageKey: isMobile + ? false + : CHANNELS_COLUMN_SIZING_STORAGE_KEY, columnFilters, pagination, globalFilter, @@ -331,6 +335,7 @@ export function ChannelsTable() { manualSorting: true, manualFiltering: true, withExpandedRowModel: true, + enableColumnResizing: !isMobile, ensurePageInRange, })