986 lines
29 KiB
TypeScript
986 lines
29 KiB
TypeScript
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>
|
||
);
|
||
},
|
||
});
|