kt-template-admin/apps/web-antdv-next/src/components/ktTable/KtTable.tsx

986 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<KtTableRecord[]>([]);
const sortState = reactive<SortState>({});
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<KtTableSize>(props.size);
const mounted = ref(false);
const autoLoaded = ref(false);
const rowHeights = reactive<Record<string, number>>({});
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 (
<KtTableSearch
collapsed={searchCollapsed.value}
formGrid={formGrid.value}
onTransitionEnd={handleSearchTransitionEnd}
onTransitionStart={handleSearchTransitionStart}
visible
>
{{
actions: () =>
hasFormButtons || hasCollapse ? (
<div class="kt-table__search-action-stack">
{formButtons.value.map((button) => renderButton(button))}
{hasCollapse ? (
<AButton
class="kt-table__search-toggle"
onClick={() => {
searchCollapsed.value = !searchCollapsed.value;
}}
type="link"
>
<ChevronDown
class={[
'kt-table__search-toggle-icon',
searchCollapsed.value
? ''
: 'kt-table__search-toggle-icon--expanded',
]}
/>
{searchCollapsed.value ? '展开' : '收起'}
</AButton>
) : null}
</div>
) : null,
form: () => <SearchForm />,
}}
</KtTableSearch>
);
};
/**
* 渲染操作列里的行操作按钮。
*
* @param record 当前行数据。
*/
const renderActionCell = (record: KtTableRecord) => {
const { inlineActions, overflowActions } = splitRowActions(
rowActions.value,
);
return (
<ASpace class="kt-table__row-actions" size={0}>
{{
default: () => (
<>
{inlineActions.map((action) => renderRowAction(action, record))}
{overflowActions.length > 0 ? (
<APopover
overlayClassName="kt-table__row-action-popover"
placement="bottomRight"
trigger="click"
>
{{
content: () => (
<div class="kt-table__row-action-popover-content">
{overflowActions.map((action) =>
renderRowAction(action, record),
)}
</div>
),
default: () => (
<AButton
aria-label="更多操作"
class="kt-table__row-action-more"
type="link"
>
<EllipsisOutlined class="kt-table__row-action-more-icon" />
</AButton>
),
}}
</APopover>
) : null}
</>
),
}}
</ASpace>
);
};
/**
* 将行操作按内联展示和弹层展示拆分。
*
* @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 (
<ASpace wrap>
{{
default: () => (
<>
{buttons}
{toolbar}
</>
),
}}
</ASpace>
);
};
/**
* 渲染表格头部右侧设置按钮组。
*/
const renderHeaderSettings = () => {
if (!props.showTableSetting) return null;
return (
<KtTableSettings
columnOrderKeys={columnOrderKeys.value}
columns={sourceColumns.value}
fullscreen={fullscreen.value}
onColumnOrderKeysChange={(keys: string[]) => {
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 () => (
<div class={['kt-table', fullscreen.value ? 'kt-table--fullscreen' : '']}>
<div class="kt-table__main">
{renderSearchArea()}
<div class="kt-table__main-content">
{props.showHeader ? (
<KtTableHeader title={props.tableTitle}>
{{
controls: () => slots.headerControls?.(context),
settings: renderHeaderSettings,
title: () => slots.title?.(),
toolbar: renderHeaderButtons,
}}
</KtTableHeader>
) : null}
<div
class="kt-table__body"
ref={tableBodyRef}
style={{
'--kt-table-scroll-y': `${tableScrollY.value}px`,
}}
>
<ATable
class="kt-table__ant"
columns={columns.value}
components={tableComponents}
dataSource={rows.value}
loading={loading.value}
onChange={handleTableChange}
onRow={resolveRowProps}
pagination={false}
rowKey={props.rowKey}
rowSelection={rowSelection.value}
scroll={{ x: tableScrollX.value, y: tableScrollY.value }}
size={tableSize.value}
v-slots={{
bodyCell: ({ column, index, record }: any): VNodeChild => {
if (column.key === KT_TABLE_INDEX_COLUMN_KEY) {
const rowIndex = resolveRecordIndex(record, index);
if (!props.rowResizable) return rowIndex;
return (
<div class="kt-table__index-cell">
<span>{rowIndex}</span>
<span
aria-label="调整行高"
class="kt-table__row-resize-handle"
onMousedown={(event: MouseEvent) => {
startRowResize(event, record);
}}
role="separator"
/>
</div>
);
}
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,
}}
/>
</div>
{props.showFooter ? (
<KtTableFooter
current={pagination.current}
onPageChange={handlePageChange}
pageSize={pagination.pageSize}
pageSizeOptions={props.pageSizeOptions}
selectedCount={selectedRowKeys.value.length}
showPagination={props.showPagination}
showSelection={props.showSelection}
total={pagination.total}
>
{{
default: () =>
slots.footer?.({
context,
selectedRowKeys: selectedRowKeys.value,
selectedRows: selectedRows.value,
}),
}}
</KtTableFooter>
) : null}
</div>
</div>
</div>
);
},
});