import type { VNodeChild } from 'vue'; import type { KtTableContext, KtTableProps, KtTableRecord, KtTableRegisterApi, KtTableRowAction, KtTableSize, } from './types'; import { computed, defineComponent, onBeforeUnmount, onMounted, reactive, ref, watch, } from 'vue'; import { ChevronDown } from '@vben/icons'; import { EllipsisOutlined } from '@antdv-next/icons'; import { Button, Popover, Space, Table } from 'antdv-next'; import KtTableFooter from './components/KtTableFooter'; import KtTableHeader from './components/KtTableHeader'; import KtTableResizableTitle from './components/KtTableResizableTitle'; import KtTableSearch from './components/KtTableSearch'; import KtTableSettings from './components/KtTableSettings'; import { renderKtTableSummary } from './components/KtTableSummary'; import { KT_TABLE_ACTION_COLUMN_KEY, KT_TABLE_INDEX_COLUMN_KEY, KT_TABLE_ROW_ACTION_VISIBLE_COUNT, } from './config/constants'; import { DEFAULT_TABLE_SETTING, ktTableProps } from './config/ktTableProps'; import { useKtTableActions } from './hooks/useKtTableActions'; import { useKtTableColumns } from './hooks/useKtTableColumns'; import { useKtTableForm } from './hooks/useKtTableForm'; import { useKtTableRuntimeHooks } from './hooks/useKtTableHooks'; import { useKtTableLayout } from './hooks/useKtTableLayout'; import { useKtTablePermission } from './hooks/useKtTablePermission'; import { useKtTableResolvedProps } from './hooks/useKtTableResolvedProps'; import { useKtTableSelection } from './hooks/useKtTableSelection'; import { normalizePageResult } from './utils/index'; import './style.scss'; const AButton = Button as any; const APopover = Popover as any; const ASpace = Space as any; const ATable = Table as any; const tableComponents = { header: { cell: KtTableResizableTitle, }, }; type SortState = { field?: string; order?: string; }; 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, emits: ['register'], /** * 初始化 KtTable 主组件,组装表单、按钮、列、分页、选择和注册式 API。 * * @param rawProps 组件显式传入的 props,后续会和 register 配置合并。 * @param emit Vue setup context。 * @param emit.emit Vue 事件发送器,用于向业务侧暴露 register API。 * @param emit.expose Vue 暴露实例方法的函数,用于模板 ref 直接访问表格 API。 * @param emit.slots 业务侧传入的 title、toolbar、bodyCell、summary、footer 等插槽。 */ setup(rawProps, { emit, expose, slots }) { const { props, setProps } = useKtTableResolvedProps( rawProps as KtTableProps, ); const loading = ref(false); const rows = ref([]); const sortState = reactive({}); const pagination = reactive({ current: 1, pageSize: props.pageSize, total: 0, }); const fullscreen = ref(false); const searchCollapsed = ref(false); const searchVisible = ref(true); 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, formGrid, formOptions, getSearchValues, resetForm, SearchForm, setSearchValues, } = useKtTableForm(props); const { registerHook, runHook, unregisterHook } = useKtTableRuntimeHooks(props); const { clearSelection, rowSelection, selectedRowKeys, selectedRows } = useKtTableSelection(props); const api = computed( () => props.api || props.modules.find((module) => !!module.api)?.api || null, ); const tableSetting = computed(() => ({ ...DEFAULT_TABLE_SETTING, ...props.tableSettings, })); const statistics = computed(() => [ ...props.statistics, ...props.modules.flatMap((module) => module.statistics || []), ]); const hasSummary = computed( () => statistics.value.length > 0 || !!slots.summary, ); const { handleSearchTransitionEnd, handleSearchTransitionStart, scheduleTableLayout, tableBodyRef, tableScrollY, tableViewportWidth, } = useKtTableLayout({ hasSummary }); const context: KtTableContext = { formApi, getRows: () => rows.value, getSearchValues, registerHook, reload, reset, search, selectedRowKeys: () => selectedRowKeys.value, selectedRows: () => selectedRows.value, setSearchValues, unregisterHook, }; const registerApi: KtTableRegisterApi = { ...context, getProps: () => ({ ...props }), setProps, }; emit('register', registerApi); const permissions = useKtTablePermission(context); const { formButtons, headerButtons, renderButton, renderRowAction, rowActions, } = useKtTableActions({ context, permissions, props, reload, reset, runHook, search, }); const { columnOrderKeys, columns, reorderColumns, resetColumns, sourceColumns, tableScrollX, visibleColumnKeys, } = useKtTableColumns({ props, rowActions, scheduleTableLayout, tableViewportWidth, }); watch( () => props.size, (size) => { tableSize.value = size; }, { immediate: true, }, ); watch( () => props.pageSize, (pageSize) => { pagination.pageSize = pageSize; }, { immediate: true, }, ); watch( () => props.dataSource, (dataSource) => { if (!api.value?.list && Array.isArray(dataSource)) { rows.value = dataSource; pagination.total = dataSource.length; } }, { immediate: true, }, ); watch( [searchCollapsed, formOptions], ([collapsed]) => { formApi.setState({ collapsed, showCollapseButton: true }); }, { immediate: true, }, ); /** * 根据当前分页状态和当前页行下标计算序号列展示值。 * * @param index Antdv Table 当前页内的行下标,从 0 开始。 */ function resolveRowIndex(index: number) { if (!props.showPagination) return index + 1; return (pagination.current - 1) * pagination.pageSize + index + 1; } /** * 从 Antdv bodyCell 参数中解析当前行序号,兼容 slot 未透出 index 的情况。 * * @param record 当前行数据。 * @param index Antdv Table 当前页内的行下标;部分版本可能不传。 */ function resolveRecordIndex(record: KtTableRecord, index?: number) { const rowIndex = typeof index === 'number' ? index : rows.value.indexOf(record); 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, }; } /** * 读取查询参数,并按需触发表单校验。 * * @param options 加载参数选项,控制是否在读取参数前校验表单。 */ async function getFetchParams(options: LoadOptions = {}) { if (options.validateForm) { const { valid } = await formApi.validate(); if (!valid) return null; } return { ...(await getSearchValues()), pageNo: props.showPagination ? pagination.current : undefined, pageSize: props.showPagination ? pagination.pageSize : undefined, sortField: sortState.field, sortOrder: sortState.order, }; } /** * 加载表格数据,兼容接口数据源和静态 dataSource。 * * @param options 加载参数选项,透传给查询参数构建逻辑。 */ async function loadData(options: LoadOptions = {}) { if (!api.value?.list) { const list = props.dataSource || []; rows.value = list; pagination.total = list.length; return; } const rawParams = await getFetchParams(options); if (!rawParams) return; const params = ((await props.beforeFetch?.(rawParams, context)) as KtTableRecord) || rawParams; loading.value = true; try { await runHook('onBeforeFetch', params, context); const result = await api.value.list(params, context); const afterResult = (await props.afterFetch?.(result, context)) || result; const normalized = normalizePageResult(afterResult); rows.value = normalized.list; pagination.total = normalized.total; clearSelection(); await runHook('onAfterFetch', afterResult, context); } catch (error) { await runHook('onFetchError', error, context); throw error; } finally { loading.value = false; } } /** * 执行首次自动加载,register 模式下会等待 api 准备完成。 */ async function autoLoadData() { if (!props.immediate || autoLoaded.value || !api.value?.list) return; // register 模式下 api 可能晚于 mounted 合并,首次自动加载要等 api 真正可用。 autoLoaded.value = true; await loadData(); } /** * 执行查询操作,重置到第一页并要求表单校验通过。 */ async function search() { pagination.current = 1; await loadData({ validateForm: true }); } /** * 重置查询表单并重新加载第一页数据。 */ async function reset() { await resetForm(); pagination.current = 1; await loadData(); } /** * 按当前分页、排序和搜索条件重新加载数据。 */ async function reload() { await loadData(); } /** * 从 Antdv Table 排序参数中读取当前排序字段和排序方向。 * * @param sorter Antdv Table 传入的排序对象或多排序对象数组。 */ function readSorter(sorter: KtTableRecord | KtTableRecord[]) { const currentSorter = Array.isArray(sorter) ? sorter[0] : sorter; sortState.field = currentSorter?.field || currentSorter?.columnKey; sortState.order = currentSorter?.order; } /** * 响应 Antdv Table 的排序/过滤/分页变化。 * * @param _tablePagination Antdv Table 内部分页参数,KtTable 使用自定义分页所以当前不消费。 * @param _filters Antdv Table 过滤参数,当前预留给未来列过滤扩展。 * @param sorter Antdv Table 排序参数,用于更新 KtTable 排序状态。 */ function handleTableChange( _tablePagination: KtTableRecord, _filters: KtTableRecord, sorter: KtTableRecord | KtTableRecord[], ) { pagination.current = 1; readSorter(sorter); loadData(); } /** * 响应底部分页变化并重新加载数据。 * * @param pageInfo 分页组件传回的页码和每页条数。 */ function handlePageChange(pageInfo: KtTableRecord) { pagination.current = pageInfo.current || 1; pagination.pageSize = pageInfo.pageSize || props.pageSize; loadData(); } /** * 渲染搜索表单区域和表单按钮。 */ const renderSearchArea = () => { const hasSearch = (formOptions.value.schema?.length || 0) > 0; const hasFormButtons = formButtons.value.length > 0; const hasCollapse = hasSearch && (formOptions.value.schema?.length || 0) > 4; const visible = hasSearch && searchVisible.value; if (!visible) return null; return ( {{ actions: () => hasFormButtons || hasCollapse ? (
{formButtons.value.map((button) => renderButton(button))} {hasCollapse ? ( { searchCollapsed.value = !searchCollapsed.value; }} type="link" > {searchCollapsed.value ? '展开' : '收起'} ) : null}
) : null, form: () => , }}
); }; /** * 渲染操作列里的行操作按钮。 * * @param record 当前行数据。 */ const renderActionCell = (record: KtTableRecord) => { const { inlineActions, overflowActions } = splitRowActions( rowActions.value, ); return ( {{ default: () => ( <> {inlineActions.map((action) => renderRowAction(action, record))} {overflowActions.length > 0 ? ( {{ content: () => (
{overflowActions.map((action) => renderRowAction(action, record), )}
), default: () => ( ), }}
) : null} ), }}
); }; /** * 将行操作按内联展示和弹层展示拆分。 * * @param actions 当前行可见操作按钮列表。 */ function splitRowActions(actions: KtTableRowAction[]) { const visibleCount = resolveRowActionVisibleCount(); if (actions.length <= visibleCount) { return { inlineActions: actions, overflowActions: [], }; } return { inlineActions: actions.slice(0, visibleCount), overflowActions: actions.slice(visibleCount), }; } /** * 解析行操作内联按钮数量,异常配置回退到默认两个。 */ function resolveRowActionVisibleCount() { const visibleCount = Number(props.rowActionVisibleCount); if (!Number.isFinite(visibleCount)) { return KT_TABLE_ROW_ACTION_VISIBLE_COUNT; } return Math.max(0, Math.floor(visibleCount)); } /** * 渲染表格头部左侧业务按钮和 toolbar 插槽。 */ const renderHeaderButtons = () => { const toolbar = slots.toolbar?.(context); const buttons = headerButtons.value.map((button) => renderButton(button)); if (!toolbar && buttons.length === 0) return null; return ( {{ default: () => ( <> {buttons} {toolbar} ), }} ); }; /** * 渲染表格头部右侧设置按钮组。 */ const renderHeaderSettings = () => { if (!props.showTableSetting) return null; return ( { reorderColumns(keys); }} onFullscreenChange={(value: boolean) => { fullscreen.value = value; }} onReload={reload} onResetColumns={resetColumns} onSearchVisibleChange={(value: boolean) => { searchVisible.value = value; }} onSizeChange={(value: KtTableSize) => { tableSize.value = value; }} onVisibleColumnKeysChange={(keys: string[]) => { visibleColumnKeys.value = keys; }} searchVisible={searchVisible.value} setting={tableSetting.value} size={tableSize.value} visibleColumnKeys={visibleColumnKeys.value} /> ); }; /** * 生成表格布局监听签名,只收集会影响容器高度、横向滚动和列宽的轻量信号。 * 行内字段变化仍由 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); onMounted(() => { mounted.value = true; autoLoadData(); }); onBeforeUnmount(() => { stopRowResize(); }); watch(api, () => { if (mounted.value) { autoLoadData(); } }); watch( () => [ createLayoutWatchKey(), rows.value.length, searchVisible.value, fullscreen.value, tableSize.value, hasSummary.value, ], () => { scheduleTableLayout(); }, ); return () => (
{renderSearchArea()}
{props.showHeader ? ( {{ controls: () => slots.headerControls?.(context), settings: renderHeaderSettings, title: () => slots.title?.(), toolbar: renderHeaderButtons, }} ) : null}
{ if (column.key === KT_TABLE_INDEX_COLUMN_KEY) { 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) { return renderActionCell(record); } return slots.bodyCell?.({ column, record }); }, summary: (): VNodeChild => hasSummary.value ? renderKtTableSummary({ columns: columns.value, context, customSummary: slots.summary?.({ columns: columns.value, context, rows: rows.value, }), showSelection: props.showSelection, statistics: statistics.value, }) : null, }} />
{props.showFooter ? ( {{ default: () => slots.footer?.({ context, selectedRowKeys: selectedRowKeys.value, selectedRows: selectedRows.value, }), }} ) : null}
); }, });