From 14880dfcf1c4e967ee7db3666e158f2caa599abf Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 21 May 2026 23:08:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84KtTable=E8=A1=8C?= =?UTF-8?q?=E9=AB=98=E8=B0=83=E6=95=B4=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ktTable/KtTable.tsx | 261 +++++++++++++++++- .../components/ktTable/config/constants.ts | 4 + .../components/ktTable/config/ktTableProps.ts | 20 ++ .../components/ktTable/styles/resizable.scss | 45 +++ .../src/components/ktTable/styles/table.scss | 19 ++ .../src/components/ktTable/types.ts | 6 + .../src/views/system/ktTableDemo/list.tsx | 3 + 7 files changed, 357 insertions(+), 1 deletion(-) diff --git a/apps/web-antdv-next/src/components/ktTable/KtTable.tsx b/apps/web-antdv-next/src/components/ktTable/KtTable.tsx index 06ee632..46a72fd 100644 --- a/apps/web-antdv-next/src/components/ktTable/KtTable.tsx +++ b/apps/web-antdv-next/src/components/ktTable/KtTable.tsx @@ -12,6 +12,7 @@ import type { import { computed, defineComponent, + onBeforeUnmount, onMounted, reactive, ref, @@ -68,6 +69,15 @@ type LoadOptions = { validateForm?: boolean; }; +type RowResizeState = { + frame?: number; + key: string; + nextHeight: number; + rowElement: HTMLTableRowElement; + startHeight: number; + startY: number; +}; + export default defineComponent({ name: 'KtTable', props: ktTableProps, @@ -100,6 +110,9 @@ export default defineComponent({ const tableSize = ref(props.size); const mounted = ref(false); const autoLoaded = ref(false); + const rowHeights = reactive>({}); + let rowResizeGuideElement: HTMLDivElement | null = null; + let rowResizeState: null | RowResizeState = null; const { formApi, @@ -255,6 +268,231 @@ export default defineComponent({ return resolveRowIndex(Math.max(rowIndex, 0)); } + /** + * 解析行唯一标识,行高 resize 需要用它保存每一行的独立高度。 + * + * @param record 当前行数据。 + */ + function resolveRecordKey(record: KtTableRecord) { + const { rowKey } = props; + + if (typeof rowKey === 'function') { + return rowKey(record); + } + + return record[rowKey] ?? record.key ?? rows.value.indexOf(record); + } + + /** + * 将行高限制在配置区间内,避免拖拽到不可用高度。 + * + * @param height 拖拽计算出的原始行高。 + */ + function clampRowHeight(height: number) { + const minHeight = Math.max(24, props.rowResizeMinHeight); + const maxHeight = Math.max(minHeight, props.rowResizeMaxHeight); + + return Math.min(maxHeight, Math.max(minHeight, Math.round(height))); + } + + /** + * 创建行高拖拽参考线,拖动期间只移动参考线并写当前行 DOM。 + * + * @param rowElement 当前正在调整高度的表格行。 + */ + function createRowResizeGuide(rowElement: HTMLTableRowElement) { + const tableBody = rowElement.closest('.kt-table__body'); + const bodyRect = tableBody?.getBoundingClientRect(); + if (!bodyRect) return; + + rowResizeGuideElement = document.createElement('div'); + rowResizeGuideElement.className = 'kt-table__row-resize-guide'; + rowResizeGuideElement.style.left = `${bodyRect.left}px`; + rowResizeGuideElement.style.width = `${bodyRect.width}px`; + document.body.append(rowResizeGuideElement); + } + + /** + * 按当前行和目标行高移动行高拖拽参考线。 + */ + function moveRowResizeGuide() { + const state = rowResizeState; + if (!state || !rowResizeGuideElement) return; + + const rowRect = state.rowElement.getBoundingClientRect(); + rowResizeGuideElement.style.transform = `translate3d(0, ${Math.round( + rowRect.top + state.nextHeight, + )}px, 0)`; + } + + /** + * 移除行高拖拽参考线。 + */ + function removeRowResizeGuide() { + rowResizeGuideElement?.remove(); + rowResizeGuideElement = null; + } + + /** + * 判断鼠标是否命中序号列底部的行高拖拽区域。 + * + * @param event 鼠标按下事件,用于读取当前坐标。 + * @param rowElement 当前鼠标所在表格行。 + */ + function isRowResizeHandleHit( + event: MouseEvent, + rowElement: HTMLTableRowElement, + ) { + const indexCell = rowElement.querySelector( + '.kt-table__index-column', + ) as HTMLElement | null; + if (!indexCell) return false; + + const cellRect = indexCell.getBoundingClientRect(); + const rowRect = rowElement.getBoundingClientRect(); + const inIndexCell = + event.clientX >= cellRect.left && event.clientX <= cellRect.right; + const inBottomHandle = + event.clientY >= rowRect.bottom - 8 && event.clientY <= rowRect.bottom; + + return inIndexCell && inBottomHandle; + } + + /** + * 拖拽行高时只直接写当前 tr 的内联高度,mouseup 后再写入响应式状态。 + * 这样可以避免拖拽过程中每一帧触发表格整体重算。 + */ + function applyDraggingRowHeight() { + const state = rowResizeState; + if (!state) return; + + state.frame = undefined; + state.rowElement.style.height = `${state.nextHeight}px`; + state.rowElement.style.setProperty( + '--kt-table-row-height', + `${state.nextHeight}px`, + ); + moveRowResizeGuide(); + } + + /** + * 响应行高拖拽移动。 + * + * @param event 鼠标移动事件。 + */ + function handleRowResizeMove(event: MouseEvent) { + const state = rowResizeState; + if (!state) return; + + state.nextHeight = clampRowHeight( + state.startHeight + event.clientY - state.startY, + ); + if (state.frame) return; + + state.frame = window.requestAnimationFrame(applyDraggingRowHeight); + } + + /** + * 结束行高拖拽,并把最终高度写回行高状态表。 + */ + function stopRowResize() { + const state = rowResizeState; + if (!state) return; + + if (state.frame) { + window.cancelAnimationFrame(state.frame); + state.frame = undefined; + } + + applyDraggingRowHeight(); + rowHeights[state.key] = state.nextHeight; + removeRowResizeGuide(); + rowResizeState = null; + document.removeEventListener('mousemove', handleRowResizeMove); + document.removeEventListener('mouseup', stopRowResize); + document.body.classList.remove('kt-table--row-resizing'); + } + + /** + * 开始拖拽单行行高。 + * + * @param event 行高拖拽手柄的鼠标按下事件。 + * @param record 当前行数据。 + */ + function startRowResize(event: MouseEvent, record: KtTableRecord) { + if (!props.rowResizable) return; + + event.preventDefault(); + event.stopPropagation(); + + const rowElement = (event.currentTarget as HTMLElement).closest( + 'tr', + ) as HTMLTableRowElement | null; + if (!rowElement) return; + + const key = String(resolveRecordKey(record)); + const currentHeight = + rowHeights[key] || rowElement.getBoundingClientRect().height; + const startHeight = clampRowHeight(currentHeight); + + rowResizeState = { + key, + nextHeight: startHeight, + rowElement, + startHeight, + startY: event.clientY, + }; + createRowResizeGuide(rowElement); + applyDraggingRowHeight(); + document.body.classList.add('kt-table--row-resizing'); + document.addEventListener('mousemove', handleRowResizeMove); + document.addEventListener('mouseup', stopRowResize); + } + + /** + * 处理行级鼠标按下事件,只在序号列底部命中区内启动行高拖拽。 + * + * @param event 行级鼠标按下事件。 + * @param record 当前行数据。 + */ + function handleRowResizeMouseDown( + event: MouseEvent, + record: KtTableRecord, + ) { + if (!props.rowResizable) return; + + const rowElement = (event.currentTarget as HTMLElement).closest( + 'tr', + ) as HTMLTableRowElement | null; + if (!rowElement || !isRowResizeHandleHit(event, rowElement)) return; + + startRowResize(event, record); + } + + /** + * 为可调整行高的行追加 class 和高度 CSS 变量。 + * + * @param record 当前行数据。 + */ + function resolveRowProps(record: KtTableRecord) { + if (!props.rowResizable) return {}; + + const height = rowHeights[String(resolveRecordKey(record))]; + + return { + class: 'kt-table__row--resizable', + onMousedown: (event: MouseEvent) => { + handleRowResizeMouseDown(event, record); + }, + style: height + ? { + '--kt-table-row-height': `${height}px`, + height: `${height}px`, + } + : undefined, + }; + } + /** * 读取查询参数,并按需触发表单校验。 * @@ -585,6 +823,10 @@ export default defineComponent({ autoLoadData(); }); + onBeforeUnmount(() => { + stopRowResize(); + }); + watch(api, () => { if (mounted.value) { autoLoadData(); @@ -635,6 +877,7 @@ export default defineComponent({ dataSource={rows.value} loading={loading.value} onChange={handleTableChange} + onRow={resolveRowProps} pagination={false} rowKey={props.rowKey} rowSelection={rowSelection.value} @@ -643,7 +886,23 @@ export default defineComponent({ v-slots={{ bodyCell: ({ column, index, record }: any): VNodeChild => { if (column.key === KT_TABLE_INDEX_COLUMN_KEY) { - return resolveRecordIndex(record, index); + const rowIndex = resolveRecordIndex(record, index); + + if (!props.rowResizable) return rowIndex; + + return ( +
+ {rowIndex} + { + startRowResize(event, record); + }} + role="separator" + /> +
+ ); } if (column.key === KT_TABLE_ACTION_COLUMN_KEY) { diff --git a/apps/web-antdv-next/src/components/ktTable/config/constants.ts b/apps/web-antdv-next/src/components/ktTable/config/constants.ts index e8a2d10..09483da 100644 --- a/apps/web-antdv-next/src/components/ktTable/config/constants.ts +++ b/apps/web-antdv-next/src/components/ktTable/config/constants.ts @@ -8,6 +8,10 @@ export const KT_TABLE_INDEX_COLUMN_KEY = '__kt_table_index__'; export const KT_TABLE_INDEX_COLUMN_WIDTH = 40; +export const KT_TABLE_DEFAULT_ROW_RESIZE_MAX_HEIGHT = 140; + +export const KT_TABLE_DEFAULT_ROW_RESIZE_MIN_HEIGHT = 40; + export const KT_TABLE_ROW_ACTION_OVERFLOW_LIMIT = 3; export const KT_TABLE_ROW_ACTION_VISIBLE_COUNT = 2; diff --git a/apps/web-antdv-next/src/components/ktTable/config/ktTableProps.ts b/apps/web-antdv-next/src/components/ktTable/config/ktTableProps.ts index 03c26f1..8798181 100644 --- a/apps/web-antdv-next/src/components/ktTable/config/ktTableProps.ts +++ b/apps/web-antdv-next/src/components/ktTable/config/ktTableProps.ts @@ -19,6 +19,8 @@ import type { import { KT_TABLE_DEFAULT_PAGE_SIZE, KT_TABLE_DEFAULT_PAGE_SIZE_OPTIONS, + KT_TABLE_DEFAULT_ROW_RESIZE_MAX_HEIGHT, + KT_TABLE_DEFAULT_ROW_RESIZE_MIN_HEIGHT, } from './constants'; export const DEFAULT_TABLE_SETTING: Required = { @@ -43,6 +45,9 @@ export const KT_TABLE_PROP_KEYS = [ 'pageSize', 'pageSizeOptions', 'rowActions', + 'rowResizeMaxHeight', + 'rowResizeMinHeight', + 'rowResizable', 'rowKey', 'showDefaultButtons', 'showFooter', @@ -78,6 +83,9 @@ export function createDefaultTableProps(): KtTableResolvedProps< pageSize: KT_TABLE_DEFAULT_PAGE_SIZE, pageSizeOptions: KT_TABLE_DEFAULT_PAGE_SIZE_OPTIONS, rowActions: [], + rowResizeMaxHeight: KT_TABLE_DEFAULT_ROW_RESIZE_MAX_HEIGHT, + rowResizeMinHeight: KT_TABLE_DEFAULT_ROW_RESIZE_MIN_HEIGHT, + rowResizable: false, rowKey: 'id', showDefaultButtons: true, showFooter: true, @@ -146,6 +154,18 @@ export const ktTableProps = { default: () => [], type: Array as PropType, }, + rowResizeMaxHeight: { + default: KT_TABLE_DEFAULT_ROW_RESIZE_MAX_HEIGHT, + type: Number, + }, + rowResizeMinHeight: { + default: KT_TABLE_DEFAULT_ROW_RESIZE_MIN_HEIGHT, + type: Number, + }, + rowResizable: { + default: false, + type: Boolean, + }, rowKey: { default: 'id', type: [String, Function] as PropType< diff --git a/apps/web-antdv-next/src/components/ktTable/styles/resizable.scss b/apps/web-antdv-next/src/components/ktTable/styles/resizable.scss index 8fd518c..6cf8fc2 100644 --- a/apps/web-antdv-next/src/components/ktTable/styles/resizable.scss +++ b/apps/web-antdv-next/src/components/ktTable/styles/resizable.scss @@ -19,6 +19,33 @@ &__resizable-handle:hover { background: hsl(var(--primary) / 18%); } + + &__row-resize-handle { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 4; + height: 8px; + cursor: row-resize; + user-select: none; + } + + &__row-resize-handle::after { + position: absolute; + right: 6px; + bottom: 0; + left: 6px; + height: 1px; + content: ''; + background: hsl(var(--primary)); + opacity: 0; + transition: opacity 0.15s ease; + } + + &__row-resize-handle:hover::after { + opacity: 1; + } } #{kt.$block}--column-resizing, @@ -27,6 +54,12 @@ user-select: none !important; } +#{kt.$block}--row-resizing, +#{kt.$block}--row-resizing * { + cursor: row-resize !important; + user-select: none !important; +} + #{kt.$block}__resize-guide { position: fixed; left: 0; @@ -37,3 +70,15 @@ box-shadow: 0 0 0 1px hsl(var(--primary) / 18%); will-change: transform; } + +#{kt.$block}__row-resize-guide { + position: fixed; + top: 0; + left: 0; + z-index: 3000; + height: 1px; + pointer-events: none; + background: hsl(var(--primary)); + box-shadow: 0 0 0 1px hsl(var(--primary) / 18%); + will-change: transform; +} diff --git a/apps/web-antdv-next/src/components/ktTable/styles/table.scss b/apps/web-antdv-next/src/components/ktTable/styles/table.scss index 1533000..1249145 100644 --- a/apps/web-antdv-next/src/components/ktTable/styles/table.scss +++ b/apps/web-antdv-next/src/components/ktTable/styles/table.scss @@ -131,6 +131,25 @@ white-space: nowrap; border-bottom: 0 !important; } + + .ant-table-tbody > tr#{kt.$block}__row--resizable > td { + height: var(--kt-table-row-height); + } + + .ant-table-tbody + > tr#{kt.$block}__row--resizable + > td#{kt.$block}__index-column { + position: sticky; + z-index: 16; + } + } + + &__index-cell { + position: static; + display: flex; + align-items: center; + justify-content: center; + min-height: 24px; } &__index-column { diff --git a/apps/web-antdv-next/src/components/ktTable/types.ts b/apps/web-antdv-next/src/components/ktTable/types.ts index b3621e0..7737b72 100644 --- a/apps/web-antdv-next/src/components/ktTable/types.ts +++ b/apps/web-antdv-next/src/components/ktTable/types.ts @@ -234,6 +234,9 @@ export interface KtTableProps< pageSize?: number; pageSizeOptions?: string[]; rowActions?: Array>; + rowResizeMaxHeight?: number; + rowResizeMinHeight?: number; + rowResizable?: boolean; rowKey?: ((row: Row) => string) | keyof Row | string; showDefaultButtons?: boolean; showFooter?: boolean; @@ -261,6 +264,9 @@ export type KtTableResolvedProps< pageSizeOptions: string[]; rowActions: Array>; rowKey: ((row: Row) => string) | keyof Row | string; + rowResizable: boolean; + rowResizeMaxHeight: number; + rowResizeMinHeight: number; showDefaultButtons: boolean; showFooter: boolean; showHeader: boolean; diff --git a/apps/web-antdv-next/src/views/system/ktTableDemo/list.tsx b/apps/web-antdv-next/src/views/system/ktTableDemo/list.tsx index f5eed96..cec6c93 100644 --- a/apps/web-antdv-next/src/views/system/ktTableDemo/list.tsx +++ b/apps/web-antdv-next/src/views/system/ktTableDemo/list.tsx @@ -551,6 +551,9 @@ export default defineComponent({ modules: [channelModule], pageSize: 20, rowActions, + rowResizable: true, + rowResizeMaxHeight: 300, + rowResizeMinHeight: 44, showSelection: true, statistics, tableTitle: 'KT 组件发布清单 Demo',