perf: 优化KtTable布局与滚动性能

This commit is contained in:
sunlei 2026-05-21 13:49:33 +08:00
parent dcb32e6037
commit 1c342d3ab3
17 changed files with 903 additions and 647 deletions

View File

@ -136,6 +136,7 @@ export default defineComponent({
scheduleTableLayout, scheduleTableLayout,
tableBodyRef, tableBodyRef,
tableScrollY, tableScrollY,
tableViewportWidth,
} = useKtTableLayout({ hasSummary }); } = useKtTableLayout({ hasSummary });
const context: KtTableContext = { const context: KtTableContext = {
@ -187,6 +188,7 @@ export default defineComponent({
props, props,
rowActions, rowActions,
scheduleTableLayout, scheduleTableLayout,
tableViewportWidth,
}); });
watch( watch(
@ -451,7 +453,7 @@ export default defineComponent({
); );
return ( return (
<ASpace class="kt-table__row-actions" size={1}> <ASpace class="kt-table__row-actions" size={0}>
{inlineActions.map((action) => renderRowAction(action, record))} {inlineActions.map((action) => renderRowAction(action, record))}
{overflowActions.length > 0 ? ( {overflowActions.length > 0 ? (
<APopover <APopover
@ -555,6 +557,27 @@ export default defineComponent({
); );
} }
/**
*
* Vue/Antdv deep watch
*/
function createLayoutWatchKey() {
return columns.value
.map((column) =>
[
column.key,
Array.isArray(column.dataIndex)
? column.dataIndex.join('.')
: column.dataIndex,
column.width,
column.fixed,
]
.map((value) => String(value ?? ''))
.join(':'),
)
.join('|');
}
expose(registerApi); expose(registerApi);
onMounted(() => { onMounted(() => {
@ -569,13 +592,17 @@ export default defineComponent({
}); });
watch( watch(
[columns, rows, searchVisible, fullscreen, tableSize], () => [
createLayoutWatchKey(),
rows.value.length,
searchVisible.value,
fullscreen.value,
tableSize.value,
hasSummary.value,
],
() => { () => {
scheduleTableLayout(); scheduleTableLayout();
}, },
{
deep: true,
},
); );
return () => ( return () => (

View File

@ -186,6 +186,20 @@ export default defineComponent({
return h('th', attrs, slots.default?.()); return h('th', attrs, slots.default?.());
} }
if (!props.onResize) {
return h(
'th',
{
...attrs,
style: {
...(attrs.style as Record<string, unknown> | undefined),
width: `${props.width}px`,
},
},
slots.default?.(),
);
}
return h( return h(
'th', 'th',
{ {

View File

@ -2,8 +2,12 @@ import type { KtTableFormGridOptions } from '../types';
export const KT_TABLE_ACTION_COLUMN_KEY = '__kt_table_actions__'; export const KT_TABLE_ACTION_COLUMN_KEY = '__kt_table_actions__';
export const KT_TABLE_ACTION_COLUMN_WIDTH = 112;
export const KT_TABLE_INDEX_COLUMN_KEY = '__kt_table_index__'; export const KT_TABLE_INDEX_COLUMN_KEY = '__kt_table_index__';
export const KT_TABLE_INDEX_COLUMN_WIDTH = 40;
export const KT_TABLE_ROW_ACTION_OVERFLOW_LIMIT = 3; export const KT_TABLE_ROW_ACTION_OVERFLOW_LIMIT = 3;
export const KT_TABLE_ROW_ACTION_VISIBLE_COUNT = 2; export const KT_TABLE_ROW_ACTION_VISIBLE_COUNT = 2;

View File

@ -1,6 +1,6 @@
import type { TableColumnType } from 'antdv-next'; import type { TableColumnType } from 'antdv-next';
import type { ComputedRef } from 'vue'; import type { ComputedRef, Ref } from 'vue';
import type { import type {
KtTableRecord, KtTableRecord,
@ -12,8 +12,9 @@ import { computed, reactive, ref, watch } from 'vue';
import { import {
KT_TABLE_ACTION_COLUMN_KEY, KT_TABLE_ACTION_COLUMN_KEY,
KT_TABLE_ACTION_COLUMN_WIDTH,
KT_TABLE_INDEX_COLUMN_KEY, KT_TABLE_INDEX_COLUMN_KEY,
KT_TABLE_ROW_ACTION_OVERFLOW_LIMIT, KT_TABLE_INDEX_COLUMN_WIDTH,
} from '../config/constants'; } from '../config/constants';
import { getColumnKey } from '../utils/index'; import { getColumnKey } from '../utils/index';
@ -21,8 +22,18 @@ interface UseKtTableColumnsOptions {
props: KtTableResolvedProps; props: KtTableResolvedProps;
rowActions: ComputedRef<KtTableRowAction[]>; rowActions: ComputedRef<KtTableRowAction[]>;
scheduleTableLayout: () => void; scheduleTableLayout: () => void;
tableViewportWidth: Ref<number>;
} }
type ColumnResizeHandler = (
event: MouseEvent,
info: {
size: {
width: number;
};
},
) => void;
/** /**
* KtTable * KtTable
* *
@ -30,10 +41,12 @@ interface UseKtTableColumnsOptions {
* @param options.props * @param options.props
* @param options.rowActions * @param options.rowActions
* @param options.scheduleTableLayout * @param options.scheduleTableLayout
* @param options.tableViewportWidth
*/ */
export function useKtTableColumns(options: UseKtTableColumnsOptions) { export function useKtTableColumns(options: UseKtTableColumnsOptions) {
const { props, rowActions, scheduleTableLayout } = options; const { props, rowActions, scheduleTableLayout, tableViewportWidth } =
// 列系统集中处理可见列、拖拽宽度和横向滚动宽度,避免主组件继续堆列计算细节。 options;
// 列系统集中处理可见列、拖拽宽度和横向滚动启停,避免主组件继续堆列计算细节。
const columnWidths = reactive<Record<string, number>>({}); const columnWidths = reactive<Record<string, number>>({});
const columnOrderKeys = ref<string[]>([]); const columnOrderKeys = ref<string[]>([]);
const visibleColumnKeys = ref<string[]>([]); const visibleColumnKeys = ref<string[]>([]);
@ -60,12 +73,54 @@ export function useKtTableColumns(options: UseKtTableColumnsOptions) {
.map((key) => columnMap.get(key)) .map((key) => columnMap.get(key))
.filter(Boolean) as Array<TableColumnType<KtTableRecord>>; .filter(Boolean) as Array<TableColumnType<KtTableRecord>>;
}); });
const visibleColumns = computed(() => const visibleSourceColumns = computed(() =>
orderedSourceColumns.value orderedSourceColumns.value.filter((column) =>
.filter((column) =>
visibleColumnKeys.value.includes(getColumnKey(column)), visibleColumnKeys.value.includes(getColumnKey(column)),
) ),
.map((column) => normalizeColumnWidth(column)), );
const rawTableWidth = computed(() => {
const selectionWidth = props.showSelection ? 48 : 0;
const indexWidth = props.showIndex ? KT_TABLE_INDEX_COLUMN_WIDTH : 0;
const actionWidth =
rowActions.value.length > 0 ? KT_TABLE_ACTION_COLUMN_WIDTH : 0;
const businessWidth = visibleSourceColumns.value.reduce(
(total, column) => total + getColumnRenderWidth(column),
0,
);
return selectionWidth + indexWidth + businessWidth + actionWidth;
});
const tableRenderWidth = computed(() => {
const hasFlexibleColumns = visibleSourceColumns.value.length > 0;
if (!hasFlexibleColumns) {
return rawTableWidth.value;
}
return Math.max(rawTableWidth.value, tableViewportWidth.value, 720);
});
const tableScrollX = computed(() => {
const viewportWidth = tableViewportWidth.value;
if (viewportWidth <= 0) return rawTableWidth.value;
return rawTableWidth.value > viewportWidth + 1
? rawTableWidth.value
: undefined;
});
const surplusWidthMap = computed(() =>
createFlexibleSurplusMap(
visibleSourceColumns.value,
Math.max(0, tableRenderWidth.value - rawTableWidth.value),
),
);
const visibleColumns = computed(() =>
visibleSourceColumns.value.map((column) =>
normalizeColumnWidth(
column,
surplusWidthMap.value.get(getColumnKey(column)) || 0,
),
),
); );
const indexColumn = computed<null | TableColumnType<KtTableRecord>>(() => { const indexColumn = computed<null | TableColumnType<KtTableRecord>>(() => {
if (!props.showIndex) return null; if (!props.showIndex) return null;
@ -75,22 +130,21 @@ export function useKtTableColumns(options: UseKtTableColumnsOptions) {
align: 'center', align: 'center',
fixed: 'left', fixed: 'left',
key: KT_TABLE_INDEX_COLUMN_KEY, key: KT_TABLE_INDEX_COLUMN_KEY,
minWidth: 40, minWidth: KT_TABLE_INDEX_COLUMN_WIDTH,
title: '序号', title: '序号',
width: 48, width: KT_TABLE_INDEX_COLUMN_WIDTH,
} as TableColumnType<KtTableRecord>); } as TableColumnType<KtTableRecord>);
}); });
const actionColumn = computed<null | TableColumnType<KtTableRecord>>(() => { const actionColumn = computed<null | TableColumnType<KtTableRecord>>(() => {
if (rowActions.value.length === 0) return null; if (rowActions.value.length === 0) return null;
const actionColumnWidth = resolveActionColumnWidth(rowActions.value.length);
return normalizeColumnWidth({ return normalizeColumnWidth({
className: 'kt-table__action-column', className: 'kt-table__action-column',
fixed: 'right', fixed: 'right',
key: KT_TABLE_ACTION_COLUMN_KEY, key: KT_TABLE_ACTION_COLUMN_KEY,
minWidth: actionColumnWidth, minWidth: KT_TABLE_ACTION_COLUMN_WIDTH,
title: '操作', title: '操作',
width: actionColumnWidth, width: KT_TABLE_ACTION_COLUMN_WIDTH,
} as TableColumnType<KtTableRecord>); } as TableColumnType<KtTableRecord>);
}); });
const columns = computed( const columns = computed(
@ -99,16 +153,6 @@ export function useKtTableColumns(options: UseKtTableColumnsOptions) {
Boolean, Boolean,
) as Array<TableColumnType<KtTableRecord>>, ) as Array<TableColumnType<KtTableRecord>>,
); );
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(total, column) => total + readColumnWidth(column.width, 140),
props.showSelection ? 48 : 0,
),
720,
),
);
watch( watch(
sourceColumns, sourceColumns,
(nextColumns) => { (nextColumns) => {
@ -127,17 +171,6 @@ export function useKtTableColumns(options: UseKtTableColumnsOptions) {
}, },
); );
/**
*
*/
function resetColumns() {
const sourceKeys = sourceColumns.value
.map((column) => getColumnKey(column))
.filter(Boolean);
columnOrderKeys.value = [...sourceKeys];
visibleColumnKeys.value = [...sourceKeys];
}
/** /**
* key * key
* *
@ -168,6 +201,28 @@ export function useKtTableColumns(options: UseKtTableColumnsOptions) {
]; ];
} }
/**
*
*/
function resetColumnWidths() {
Object.keys(columnWidths).forEach((key) => {
Reflect.deleteProperty(columnWidths, key);
});
}
/**
*
*/
function resetColumns() {
const sourceKeys = sourceColumns.value
.map((column) => getColumnKey(column))
.filter(Boolean);
columnOrderKeys.value = [...sourceKeys];
visibleColumnKeys.value = [...sourceKeys];
resetColumnWidths();
scheduleTableLayout();
}
/** /**
* *
* *
@ -195,43 +250,124 @@ export function useKtTableColumns(options: UseKtTableColumnsOptions) {
function getColumnMinWidth(column: TableColumnType<KtTableRecord>) { function getColumnMinWidth(column: TableColumnType<KtTableRecord>) {
const minWidth = Number((column as any).minWidth || 96); const minWidth = Number((column as any).minWidth || 96);
if (getColumnKey(column) === KT_TABLE_INDEX_COLUMN_KEY) { if (getColumnKey(column) === KT_TABLE_INDEX_COLUMN_KEY) {
return Number.isFinite(minWidth) ? Math.max(minWidth, 40) : 40; return Number.isFinite(minWidth)
? Math.max(minWidth, KT_TABLE_INDEX_COLUMN_WIDTH)
: KT_TABLE_INDEX_COLUMN_WIDTH;
} }
if (getColumnKey(column) === KT_TABLE_ACTION_COLUMN_KEY) { if (getColumnKey(column) === KT_TABLE_ACTION_COLUMN_KEY) {
return Number.isFinite(minWidth) ? Math.max(minWidth, 96) : 112; return Number.isFinite(minWidth)
? Math.max(minWidth, KT_TABLE_ACTION_COLUMN_WIDTH)
: KT_TABLE_ACTION_COLUMN_WIDTH;
} }
return Number.isFinite(minWidth) ? Math.max(minWidth, 80) : 96; return Number.isFinite(minWidth) ? Math.max(minWidth, 80) : 96;
} }
/** /**
* *
* *
* @param actionCount * @param column
* @param extraWidth
*/ */
function resolveActionColumnWidth(actionCount: number) { function getColumnRenderWidth(
if (actionCount > KT_TABLE_ROW_ACTION_OVERFLOW_LIMIT) return 112; column: TableColumnType<KtTableRecord>,
if (actionCount === KT_TABLE_ROW_ACTION_OVERFLOW_LIMIT) return 112; extraWidth = 0,
if (actionCount === 2) return 96; ) {
const key = getColumnKey(column);
const width =
key && columnWidths[key]
? columnWidths[key]
: readColumnWidth(column.width, 160);
const minWidth = getColumnMinWidth(column);
return 80; return Math.max(width + extraWidth, minWidth);
}
/**
* Antdv
* scroll.x
*
* @param sourceColumns
* @param surplusWidth
*/
function createFlexibleSurplusMap(
sourceColumns: Array<TableColumnType<KtTableRecord>>,
surplusWidth: number,
) {
const map = new Map<string, number>();
if (surplusWidth <= 0) return map;
const entries = sourceColumns
.map((column) => ({
key: getColumnKey(column),
width: getColumnRenderWidth(column),
}))
.filter(
(entry): entry is { key: string; width: number } =>
!!entry.key && !isFixedSystemColumn(entry.key),
);
const totalWidth = entries.reduce((total, entry) => total + entry.width, 0);
if (totalWidth <= 0) return map;
entries.forEach((entry) => {
map.set(entry.key, (surplusWidth * entry.width) / totalWidth);
});
return map;
}
/**
* KtTable
*
* @param key key
*/
function isFixedSystemColumn(key?: string) {
return (
key === KT_TABLE_INDEX_COLUMN_KEY || key === KT_TABLE_ACTION_COLUMN_KEY
);
}
/**
*
*
* @param column
* @param originalResize resize
*/
function createColumnResizeHandler(
column: TableColumnType<KtTableRecord>,
originalResize?: ColumnResizeHandler,
) {
if (isFixedSystemColumn(getColumnKey(column))) return undefined;
return (event: MouseEvent, info: Parameters<ColumnResizeHandler>[1]) => {
originalResize?.(event, info);
resizeColumnWidth(column, info.size.width);
};
} }
/** /**
* ellipsis * ellipsis
* *
* @param column * @param column
* @param extraWidth
*/ */
function normalizeColumnWidth(column: TableColumnType<KtTableRecord>) { function normalizeColumnWidth(
column: TableColumnType<KtTableRecord>,
extraWidth = 0,
) {
const key = getColumnKey(column); const key = getColumnKey(column);
const width =
key && columnWidths[key]
? columnWidths[key]
: readColumnWidth(column.width, 160);
const originalHeaderCell = column.onHeaderCell; const originalHeaderCell = column.onHeaderCell;
const originalCell = column.onCell; const originalCell = column.onCell;
const minWidth = getColumnMinWidth(column); const minWidth = getColumnMinWidth(column);
const nextWidth = Math.max(width, minWidth); const nextWidth = getColumnRenderWidth(column, extraWidth);
const isSystemColumn = isFixedSystemColumn(key);
const fixedWidthStyle = isSystemColumn
? {
maxWidth: `${nextWidth}px`,
minWidth: `${nextWidth}px`,
width: `${nextWidth}px`,
}
: undefined;
return { return {
...column, ...column,
@ -244,24 +380,18 @@ export function useKtTableColumns(options: UseKtTableColumnsOptions) {
onHeaderCell: (targetColumn: TableColumnType<KtTableRecord>) => { onHeaderCell: (targetColumn: TableColumnType<KtTableRecord>) => {
const originalProps = (originalHeaderCell?.(targetColumn) || const originalProps = (originalHeaderCell?.(targetColumn) ||
{}) as Record<string, any>; {}) as Record<string, any>;
const resizeHandler = createColumnResizeHandler(
column,
originalProps.onResize,
);
return { return {
...originalProps, ...originalProps,
/** onResize: resizeHandler,
*
*
* @param event
* @param info
* @param info.size
* @param info.size.width
*/
onResize: (event: MouseEvent, info: { size: { width: number } }) => {
originalProps.onResize?.(event, info);
resizeColumnWidth(column, info.size.width);
},
style: { style: {
...(originalProps.style as Record<string, unknown>), ...(originalProps.style as Record<string, unknown>),
minWidth: `${minWidth}px`, minWidth: `${minWidth}px`,
...fixedWidthStyle,
}, },
width: nextWidth, width: nextWidth,
}; };
@ -283,6 +413,7 @@ export function useKtTableColumns(options: UseKtTableColumnsOptions) {
style: { style: {
...(originalProps.style as Record<string, unknown>), ...(originalProps.style as Record<string, unknown>),
minWidth: `${minWidth}px`, minWidth: `${minWidth}px`,
...fixedWidthStyle,
}, },
}; };
}, },

View File

@ -17,6 +17,7 @@ export function useKtTableLayout(options: UseKtTableLayoutOptions) {
// 搜索区动画期间冻结表格高度重算,等过渡结束后再同步一次,避免频繁重算导致动画卡顿。 // 搜索区动画期间冻结表格高度重算,等过渡结束后再同步一次,避免频繁重算导致动画卡顿。
const tableBodyRef = ref<HTMLElement | null>(null); const tableBodyRef = ref<HTMLElement | null>(null);
const tableScrollY = ref(260); const tableScrollY = ref(260);
const tableViewportWidth = ref(0);
const searchTransitioning = ref(false); const searchTransitioning = ref(false);
let layoutFrame: number | undefined; let layoutFrame: number | undefined;
let resizeObserver: ResizeObserver | undefined; let resizeObserver: ResizeObserver | undefined;
@ -51,6 +52,8 @@ export function useKtTableLayout(options: UseKtTableLayoutOptions) {
const wrapper = tableBodyRef.value; const wrapper = tableBodyRef.value;
if (!wrapper) return; if (!wrapper) return;
tableViewportWidth.value = wrapper.clientWidth;
const header = wrapper.querySelector( const header = wrapper.querySelector(
'.ant-table-header', '.ant-table-header',
) as HTMLElement | null; ) as HTMLElement | null;
@ -148,5 +151,6 @@ export function useKtTableLayout(options: UseKtTableLayoutOptions) {
scheduleTableLayout, scheduleTableLayout,
tableBodyRef, tableBodyRef,
tableScrollY, tableScrollY,
tableViewportWidth,
}; };
} }

View File

@ -1,580 +1,9 @@
.kt-table { @use './styles/layout';
display: flex; @use './styles/search';
flex-direction: column; @use './styles/header';
height: 100%; @use './styles/table';
min-height: 0; @use './styles/footer';
overflow: hidden; @use './styles/actions';
@use './styles/settings';
&--fullscreen { @use './styles/resizable';
position: fixed; @use './styles/responsive';
inset: 0;
z-index: 1000;
padding: 16px;
background: hsl(var(--background));
}
&__main {
display: flex;
flex: 1 1 0;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: hsl(var(--card));
}
&__main-content {
display: flex;
flex: 1 1 0;
flex-direction: column;
min-height: 0;
padding: 16px;
}
&__search {
--kt-table-form-action-fr: 6fr;
--kt-table-form-action-min-width: 180px;
--kt-table-form-content-fr: 18fr;
--kt-table-form-content-span: 18;
--kt-table-form-item-span: 4;
--kt-table-form-tablet-columns: 2;
flex-shrink: 0;
padding: 12px 16px 0;
background: hsl(var(--card));
}
&__search-content-shell {
overflow: hidden;
}
&__search-content-shell--transitioning {
transition: height 0.22s cubic-bezier(0.22, 1, 0.36, 1);
will-change: height;
}
&__search-content-motion {
min-height: 0;
overflow: hidden;
}
&__search-content {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
&__search-form {
min-width: 0;
}
&__search-actions {
display: flex;
align-items: flex-start;
justify-content: flex-start;
}
&__search-action-stack {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-start;
width: 100%;
}
&__search-toggle {
padding-inline: 8px;
}
&__search-toggle-icon {
width: 16px;
height: 16px;
transition: transform 0.2s ease;
}
&__search-toggle-icon--expanded {
transform: rotate(180deg);
}
&__search-split {
height: 1px;
margin-top: 12px;
background: hsl(var(--border));
}
&__form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
&__form-item {
min-width: 0;
padding-bottom: 0;
}
&__form-control {
width: 100%;
}
&__header {
display: flex;
flex-shrink: 0;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
&__header-align {
display: flex;
flex: 1 1 0;
gap: 12px;
align-items: center;
justify-content: flex-start;
min-width: 0;
}
&__header-title {
min-width: 0;
font-size: 16px;
font-weight: 500;
}
&__header-button {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-start;
}
&__header-toolbar {
display: flex;
flex-shrink: 0;
align-items: center;
}
&__header-divider {
margin-inline: 8px;
}
&__body {
--kt-table-scroll-y: 260px;
position: relative;
flex: 1 1 0;
min-height: 0;
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: 6px;
}
&__ant,
&__ant .ant-spin-nested-loading,
&__ant .ant-spin-container {
height: 100%;
min-height: 0;
}
&__ant {
.ant-table,
.ant-table-container,
.ant-table-content,
.ant-table-header,
.ant-table-body {
overscroll-behavior: contain;
}
.ant-table table {
table-layout: fixed !important;
}
.ant-spin-container,
.ant-table {
display: flex;
flex: 1 1 0;
flex-direction: column;
min-height: 0;
}
.ant-table-container {
display: flex;
flex: 1 1 0;
flex-direction: column;
min-height: 0;
}
.ant-table-header {
position: relative;
z-index: 5;
flex: 0 0 auto;
contain: paint;
background: hsl(var(--card));
}
.ant-table-body {
position: relative;
z-index: 1;
min-height: 0;
max-height: var(--kt-table-scroll-y) !important;
contain: paint;
overflow: auto !important;
scrollbar-gutter: stable;
will-change: scroll-position;
}
.ant-table-summary {
flex-shrink: 0;
margin-top: auto;
}
.ant-table-cell {
border-bottom-color: hsl(var(--border)) !important;
}
.ant-table-cell-fix-left,
.ant-table-cell-fix-right,
.ant-table-cell-fix-start,
.ant-table-cell-fix-end {
background: hsl(var(--card)) !important;
}
.ant-table-thead > tr > th.ant-table-cell-fix-left,
.ant-table-thead > tr > th.ant-table-cell-fix-right,
.ant-table-thead > tr > th.ant-table-cell-fix-start,
.ant-table-thead > tr > th.ant-table-cell-fix-end {
z-index: 6;
background: hsl(var(--card)) !important;
}
.ant-table-tbody > tr:hover > td.ant-table-cell-fix-left,
.ant-table-tbody > tr:hover > td.ant-table-cell-fix-right,
.ant-table-tbody > tr:hover > td.ant-table-cell-fix-start,
.ant-table-tbody > tr:hover > td.ant-table-cell-fix-end {
background: hsl(var(--accent)) !important;
}
.ant-table-summary .ant-table-cell-fix-left,
.ant-table-summary .ant-table-cell-fix-right,
.ant-table-summary .ant-table-cell-fix-start,
.ant-table-summary .ant-table-cell-fix-end {
background: hsl(var(--card)) !important;
}
// 固定列阴影会在横向滚动时随 Antdv shadow class 反复重绘KT 表格用边框区分固定列即可
.ant-table-cell-fix-start-shadow::after,
.ant-table-cell-fix-end-shadow::after,
.ant-table-cell-fix-left-last::after,
.ant-table-cell-fix-right-first::after {
box-shadow: none !important;
}
.ant-table-thead > tr > th {
position: relative;
top: 0;
z-index: 5;
background: hsl(var(--card)) !important;
}
tfoot.ant-table-summary .ant-table-cell,
tfoot.ant-table-summary > tr > td {
height: 44px;
padding: 0 16px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
line-height: 20px;
color: hsl(var(--foreground));
white-space: nowrap;
border-bottom: 0 !important;
}
}
&__body + &__footer {
margin-top: 10px;
}
&__footer {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 12px;
align-items: center;
min-height: 32px;
}
&__footer-settings {
display: flex;
flex: 1 1 0;
gap: 12px;
align-items: center;
font-size: 14px;
}
&__footer-selection {
color: hsl(var(--muted-foreground));
}
&__footer-selection-count {
margin-inline: 4px;
color: hsl(var(--foreground));
}
&__summary-value {
display: inline-flex;
gap: 4px;
align-items: center;
}
&__summary-label {
color: hsl(var(--muted-foreground));
}
&__summary-title {
font-weight: 500;
}
&__row-actions {
white-space: nowrap;
.ant-btn {
min-width: auto;
padding-inline: 0;
}
}
&__action-column {
min-width: 112px;
}
&__row-action-more {
min-width: 18px;
padding-inline: 2px;
}
&__row-actions &__row-action-more {
min-width: 18px;
padding-inline: 2px;
}
&__row-action-more-icon {
font-size: 14px;
}
&__button-icon,
&__toolbar-icon {
width: 16px;
height: 16px;
}
&__toolbar-button {
width: 32px;
height: 32px;
}
&__settings-popover {
width: 240px;
}
&__settings-popover-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
&__settings-popover-title {
font-weight: 500;
}
&__settings-popover-list {
display: flex;
flex-direction: column;
max-height: 260px;
overflow-y: auto;
}
&__settings-column-item {
position: relative;
display: flex;
gap: 6px;
align-items: center;
min-height: 32px;
padding: 4px 6px;
border-radius: 4px;
transition:
background 0.16s ease,
opacity 0.16s ease;
}
&__settings-column-item:hover {
background: hsl(var(--accent));
}
&__settings-column-item::before,
&__settings-column-item::after {
position: absolute;
right: 6px;
left: 6px;
height: 2px;
content: '';
background: transparent;
border-radius: 999px;
}
&__settings-column-item::before {
top: 0;
}
&__settings-column-item::after {
bottom: 0;
}
&__settings-column-item--dragging {
opacity: 0.45;
}
&__settings-column-item--drop-before::before,
&__settings-column-item--drop-after::after {
background: hsl(var(--primary));
}
&__settings-column-drag {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 20px;
height: 24px;
padding: 0;
color: hsl(var(--muted-foreground));
cursor: grab;
background: transparent;
border: 0;
}
&__settings-column-drag:active {
cursor: grabbing;
}
&__settings-column-drag-icon {
width: 16px;
height: 16px;
}
&__settings-column-checkbox {
flex: 1 1 0;
min-width: 0;
}
&__resizable-title {
position: relative;
}
&__resizable-handle {
position: absolute;
top: 0;
right: -4px;
bottom: 0;
z-index: 4;
width: 8px;
cursor: col-resize;
user-select: none;
}
&__resizable-handle:hover {
background: hsl(var(--primary) / 18%);
}
}
@media (min-width: 1280px) {
.kt-table {
&__search-content {
grid-template-columns:
minmax(0, var(--kt-table-form-content-fr))
minmax(
var(--kt-table-form-action-min-width),
var(--kt-table-form-action-fr)
);
}
&__search-actions,
&__search-action-stack {
justify-content: flex-end;
}
&__form-grid {
grid-template-columns: repeat(
var(--kt-table-form-content-span),
minmax(0, 1fr)
);
}
&__form-item {
grid-column: span var(--kt-table-form-item-span) / span
var(--kt-table-form-item-span);
}
&__form-item--collapsed-visible {
display: flex !important;
}
}
}
@media (min-width: 768px) and (max-width: 1279px) {
.kt-table {
&__form-grid {
grid-template-columns: repeat(
var(--kt-table-form-tablet-columns),
minmax(0, 1fr)
);
}
&__form-item {
grid-column: span 1 / span 1;
}
&__form-item--range {
grid-column: 1 / -1;
}
}
}
.kt-table--column-resizing,
.kt-table--column-resizing * {
cursor: col-resize !important;
user-select: none !important;
}
.kt-table__resize-guide {
position: fixed;
left: 0;
z-index: 3000;
width: 1px;
pointer-events: none;
background: hsl(var(--primary));
box-shadow: 0 0 0 1px hsl(var(--primary) / 18%);
will-change: transform;
}
.kt-table__row-action-popover-content {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 72px;
.ant-btn {
justify-content: flex-start;
padding-inline: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.kt-table {
&__search-content-shell--transitioning {
transition: none;
}
}
}

View File

@ -0,0 +1,52 @@
@use './tokens' as kt;
@include kt.block {
&__row-actions {
white-space: nowrap;
.ant-space-item {
display: inline-flex;
align-items: center;
}
.ant-space-item + .ant-space-item::before {
display: inline-block;
width: 1px;
height: 14px;
margin: 0 4px;
content: '';
background: hsl(var(--border));
}
.ant-btn {
min-width: auto;
padding-inline: 0;
}
}
&__row-action-more {
min-width: 18px;
padding-inline: 2px;
}
&__row-actions &__row-action-more {
min-width: 18px;
padding-inline: 2px;
}
&__row-action-more-icon {
font-size: 14px;
}
}
#{kt.$block}__row-action-popover-content {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 72px;
.ant-btn {
justify-content: flex-start;
padding-inline: 8px;
}
}

View File

@ -0,0 +1,47 @@
@use './tokens' as kt;
@include kt.block {
&__body + &__footer {
margin-top: 10px;
}
&__footer {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 12px;
align-items: center;
min-height: 32px;
}
&__footer-settings {
display: flex;
flex: 1 1 0;
gap: 12px;
align-items: center;
font-size: 14px;
}
&__footer-selection {
color: hsl(var(--muted-foreground));
}
&__footer-selection-count {
margin-inline: 4px;
color: hsl(var(--foreground));
}
&__summary-value {
display: inline-flex;
gap: 4px;
align-items: center;
}
&__summary-label {
color: hsl(var(--muted-foreground));
}
&__summary-title {
font-weight: 500;
}
}

View File

@ -0,0 +1,57 @@
@use './tokens' as kt;
@include kt.block {
&__header {
display: flex;
flex-shrink: 0;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
&__header-align {
display: flex;
flex: 1 1 0;
gap: 12px;
align-items: center;
justify-content: flex-start;
min-width: 0;
}
&__header-title {
min-width: 0;
font-size: 16px;
font-weight: 500;
}
&__header-button {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-start;
}
&__header-toolbar {
display: flex;
flex-shrink: 0;
align-items: center;
}
&__header-divider {
margin-inline: 8px;
}
&__button-icon,
&__toolbar-icon {
width: 16px;
height: 16px;
}
&__toolbar-button {
width: 32px;
height: 32px;
}
}

View File

@ -0,0 +1,34 @@
@use './tokens' as kt;
@include kt.block {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
&--fullscreen {
position: fixed;
inset: 0;
z-index: 1000;
padding: 16px;
background: hsl(var(--background));
}
&__main {
display: flex;
flex: 1 1 0;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: hsl(var(--card));
}
&__main-content {
display: flex;
flex: 1 1 0;
flex-direction: column;
min-height: 0;
padding: 16px;
}
}

View File

@ -0,0 +1,39 @@
@use './tokens' as kt;
@include kt.block {
&__resizable-title {
position: relative;
}
&__resizable-handle {
position: absolute;
top: 0;
right: -4px;
bottom: 0;
z-index: 4;
width: 8px;
cursor: col-resize;
user-select: none;
}
&__resizable-handle:hover {
background: hsl(var(--primary) / 18%);
}
}
#{kt.$block}--column-resizing,
#{kt.$block}--column-resizing * {
cursor: col-resize !important;
user-select: none !important;
}
#{kt.$block}__resize-guide {
position: fixed;
left: 0;
z-index: 3000;
width: 1px;
pointer-events: none;
background: hsl(var(--primary));
box-shadow: 0 0 0 1px hsl(var(--primary) / 18%);
will-change: transform;
}

View File

@ -0,0 +1,62 @@
@use './tokens' as kt;
@media (min-width: 1280px) {
@include kt.block {
&__search-content {
grid-template-columns:
minmax(0, var(--kt-table-form-content-fr))
minmax(
var(--kt-table-form-action-min-width),
var(--kt-table-form-action-fr)
);
}
&__search-actions,
&__search-action-stack {
justify-content: flex-end;
}
&__form-grid {
grid-template-columns: repeat(
var(--kt-table-form-content-span),
minmax(0, 1fr)
);
}
&__form-item {
grid-column: span var(--kt-table-form-item-span) / span
var(--kt-table-form-item-span);
}
&__form-item--collapsed-visible {
display: flex !important;
}
}
}
@media (min-width: 768px) and (max-width: 1279px) {
@include kt.block {
&__form-grid {
grid-template-columns: repeat(
var(--kt-table-form-tablet-columns),
minmax(0, 1fr)
);
}
&__form-item {
grid-column: span 1 / span 1;
}
&__form-item--range {
grid-column: 1 / -1;
}
}
}
@media (prefers-reduced-motion: reduce) {
@include kt.block {
&__search-content-shell--transitioning {
transition: none;
}
}
}

View File

@ -0,0 +1,90 @@
@use './tokens' as kt;
@include kt.block {
&__search {
--kt-table-form-action-fr: 6fr;
--kt-table-form-action-min-width: 180px;
--kt-table-form-content-fr: 18fr;
--kt-table-form-content-span: 18;
--kt-table-form-item-span: 4;
--kt-table-form-tablet-columns: 2;
flex-shrink: 0;
padding: 12px 16px 0;
background: hsl(var(--card));
}
&__search-content-shell {
overflow: hidden;
}
&__search-content-shell--transitioning {
transition: height 0.22s cubic-bezier(0.22, 1, 0.36, 1);
will-change: height;
}
&__search-content-motion {
min-height: 0;
overflow: hidden;
}
&__search-content {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
&__search-form {
min-width: 0;
}
&__search-actions {
display: flex;
align-items: flex-start;
justify-content: flex-start;
}
&__search-action-stack {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-start;
width: 100%;
}
&__search-toggle {
padding-inline: 8px;
}
&__search-toggle-icon {
width: 16px;
height: 16px;
transition: transform 0.2s ease;
}
&__search-toggle-icon--expanded {
transform: rotate(180deg);
}
&__search-split {
height: 1px;
margin-top: 12px;
background: hsl(var(--border));
}
&__form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
&__form-item {
min-width: 0;
padding-bottom: 0;
}
&__form-control {
width: 100%;
}
}

View File

@ -0,0 +1,98 @@
@use './tokens' as kt;
@include kt.block {
&__settings-popover {
width: 240px;
}
&__settings-popover-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
&__settings-popover-title {
font-weight: 500;
}
&__settings-popover-list {
display: flex;
flex-direction: column;
max-height: 260px;
overflow-y: auto;
}
&__settings-column-item {
position: relative;
display: flex;
gap: 6px;
align-items: center;
min-height: 32px;
padding: 4px 6px;
border-radius: 4px;
transition:
background 0.16s ease,
opacity 0.16s ease;
}
&__settings-column-item:hover {
background: hsl(var(--accent));
}
&__settings-column-item::before,
&__settings-column-item::after {
position: absolute;
right: 6px;
left: 6px;
height: 2px;
content: '';
background: transparent;
border-radius: 999px;
}
&__settings-column-item::before {
top: 0;
}
&__settings-column-item::after {
bottom: 0;
}
&__settings-column-item--dragging {
opacity: 0.45;
}
&__settings-column-item--drop-before::before,
&__settings-column-item--drop-after::after {
background: hsl(var(--primary));
}
&__settings-column-drag {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 20px;
height: 24px;
padding: 0;
color: hsl(var(--muted-foreground));
cursor: grab;
background: transparent;
border: 0;
}
&__settings-column-drag:active {
cursor: grabbing;
}
&__settings-column-drag-icon {
width: 16px;
height: 16px;
}
&__settings-column-checkbox {
flex: 1 1 0;
min-width: 0;
}
}

View File

@ -0,0 +1,159 @@
@use './tokens' as kt;
@include kt.block {
&__body {
--kt-table-scroll-y: 260px;
position: relative;
flex: 1 1 0;
min-height: 0;
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: 6px;
}
&__ant,
&__ant .ant-spin-nested-loading,
&__ant .ant-spin-container {
height: 100%;
min-height: 0;
}
&__ant {
.ant-table,
.ant-table-container,
.ant-table-content,
.ant-table-header,
.ant-table-body {
overscroll-behavior: contain;
}
.ant-table table {
table-layout: fixed !important;
}
.ant-spin-container,
.ant-table {
display: flex;
flex: 1 1 0;
flex-direction: column;
min-height: 0;
}
.ant-table-container {
display: flex;
flex: 1 1 0;
flex-direction: column;
min-height: 0;
}
.ant-table-header {
position: relative;
z-index: 5;
flex: 0 0 auto;
contain: paint;
background: hsl(var(--card));
}
.ant-table-body {
position: relative;
z-index: 1;
min-height: 0;
max-height: var(--kt-table-scroll-y) !important;
contain: paint;
overflow: auto !important;
scrollbar-gutter: stable;
will-change: scroll-position;
}
.ant-table-summary {
flex-shrink: 0;
margin-top: auto;
}
.ant-table-cell {
border-bottom-color: hsl(var(--border)) !important;
}
.ant-table-cell-fix-left,
.ant-table-cell-fix-right,
.ant-table-cell-fix-start,
.ant-table-cell-fix-end {
background: hsl(var(--card)) !important;
}
.ant-table-thead > tr > th.ant-table-cell-fix-left,
.ant-table-thead > tr > th.ant-table-cell-fix-right,
.ant-table-thead > tr > th.ant-table-cell-fix-start,
.ant-table-thead > tr > th.ant-table-cell-fix-end {
z-index: 6;
background: hsl(var(--card)) !important;
}
.ant-table-tbody > tr:hover > td.ant-table-cell-fix-left,
.ant-table-tbody > tr:hover > td.ant-table-cell-fix-right,
.ant-table-tbody > tr:hover > td.ant-table-cell-fix-start,
.ant-table-tbody > tr:hover > td.ant-table-cell-fix-end {
background: hsl(var(--accent)) !important;
}
.ant-table-summary .ant-table-cell-fix-left,
.ant-table-summary .ant-table-cell-fix-right,
.ant-table-summary .ant-table-cell-fix-start,
.ant-table-summary .ant-table-cell-fix-end {
background: hsl(var(--card)) !important;
}
// Fixed column shadows repaint while Antdv toggles shadow classes during horizontal scroll.
.ant-table-cell-fix-start-shadow::after,
.ant-table-cell-fix-end-shadow::after,
.ant-table-cell-fix-left-last::after,
.ant-table-cell-fix-right-first::after {
box-shadow: none !important;
}
.ant-table-thead > tr > th {
position: relative;
top: 0;
z-index: 5;
background: hsl(var(--card)) !important;
}
tfoot.ant-table-summary .ant-table-cell,
tfoot.ant-table-summary > tr > td {
height: 44px;
padding: 0 16px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
line-height: 20px;
color: hsl(var(--foreground));
white-space: nowrap;
border-bottom: 0 !important;
}
}
&__index-column {
width: 40px;
min-width: 40px;
max-width: 40px;
padding-inline: 4px !important;
overflow: visible !important;
text-overflow: clip !important;
text-align: center;
white-space: nowrap;
}
&__index-column .ant-table-cell-content,
&__index-column .ant-table-column-title {
min-width: 0;
overflow: visible;
text-overflow: clip;
}
&__action-column {
width: 112px;
min-width: 112px;
max-width: 112px;
}
}

View File

@ -0,0 +1,8 @@
$block: '.kt-table';
// Keep the block selector in one namespace so every partial shares the same BEM root.
@mixin block {
#{$block} {
@content;
}
}

View File

@ -9,6 +9,7 @@
flex-direction: column; flex-direction: column;
gap: 3px; gap: 3px;
min-width: 0; min-width: 0;
contain: paint;
} }
&__component-title { &__component-title {