WholeProcessPlatform/frontend/src/components/BasicTable/index.vue

454 lines
12 KiB
Vue
Raw Normal View History

2026-04-22 17:53:20 +08:00
<template>
<div class="table-container" ref="tableContainerRef">
<a-table
size="small"
:loading="loading"
:row-selection="enableRowSelection ? rowSelection : undefined"
:data-source="tableData"
:columns="enhancedColumns"
:pagination="paginationConfig"
:scroll="scrollConfig"
:row-key="rowKey"
2026-06-17 11:22:03 +08:00
:customRow="customRow"
@change="handleTableChange"
>
2026-06-08 17:01:10 +08:00
<!-- 合计行插槽 -->
<template #summary>
<slot name="summary"></slot>
</template>
<!-- 使用 bodyCell 插槽透传自定义列内容 -->
<template #bodyCell="{ column, text, record, index }">
<template v-for="slotName in customSlotNames" :key="slotName">
<slot
v-if="
column._slotName === slotName || column.dataIndex === slotName
"
:name="slotName"
:column="column"
:text="text"
:record="record"
:index="index"
></slot>
</template>
</template>
</a-table>
</div>
2026-04-22 17:53:20 +08:00
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick, h, useSlots } from 'vue';
import { calcTableScrollY } from '@/utils/index';
2026-04-22 17:53:20 +08:00
// --- Types ---
interface Props {
columns: any[];
scrollY?: string | number; // 优先使用传入的固定值
scrollX?: string | number; // 横向滚动宽度
listUrl?: (params: any) => Promise<any>;
data?: any[];
2026-04-22 17:53:20 +08:00
enableRowSelection?: boolean;
rowKey?: string;
searchParams?: Record<string, any>;
defaultPageSize?: number;
isPage?: boolean;
2026-06-09 11:44:29 +08:00
defaultSelectedRowKeys?: string[];
getCheckboxProps?: (record: any) => any;
2026-06-09 11:44:29 +08:00
minSelectionCount?: number; // 最小选中数量0表示无限制
transformData?: (res: any) => { records: any[]; total: number };
emptyPlaceholder?: string; // 新增:空值占位符
processEmptyValues?: boolean; // 新增:是否开启空值处理
enableEllipsis?: boolean; // 新增:是否开启超出隐藏
enableSort?: boolean; // 新增:是否启用排序功能
2026-04-22 17:53:20 +08:00
}
const props = withDefaults(defineProps<Props>(), {
enableRowSelection: false,
rowKey: 'id',
isPage: true,
data: () => [],
2026-04-22 17:53:20 +08:00
searchParams: () => ({}),
defaultPageSize: 20,
2026-06-09 11:44:29 +08:00
defaultSelectedRowKeys: () => [],
getCheckboxProps: undefined,
2026-06-09 11:44:29 +08:00
minSelectionCount: 0,
transformData: undefined,
scrollY: undefined,
emptyPlaceholder: '-',
processEmptyValues: true, // 默认开启
enableEllipsis: true, // 默认开启
enableSort: false // 默认关闭,确保旧代码不受影响
2026-04-22 17:53:20 +08:00
});
const emit = defineEmits<{
(e: 'data-loaded', params: any, data: any): void;
(e: 'selection-change', selectedRowKeys: string[], selectedRows: any[]): void;
(
e: 'sort-change',
sortInfo: { field: string; order: 'ascend' | 'descend' | null }
): void;
2026-06-17 11:22:03 +08:00
(e: 'row-click', record: any): void;
2026-04-22 17:53:20 +08:00
}>();
// --- State ---
const loading = ref(false);
const tableData = ref<any[]>([]);
const total = ref(0);
const page = ref(1);
const size = ref(props.defaultPageSize);
2026-06-09 11:44:29 +08:00
const selectedRowKeys = ref<string[]>([...props.defaultSelectedRowKeys]);
2026-04-22 17:53:20 +08:00
const selectedRows = ref<any[]>([]);
2026-05-05 22:38:24 +08:00
const lastFilter = ref<Record<string, any> | undefined>(undefined);
const tableContainerRef = ref<any>(null);
const tableScrollY = ref<number>(0); // ✅ 新增:存储容器高度
// --- Computed Scroll Config ---
const scrollConfig = computed(() => {
const config: any = {
x: '100%',
y: 0
};
// 1. 如果父组件传入了固定的 scrollX优先使用
if (props.scrollX !== undefined && props.scrollX !== null) {
config.x = props.scrollX;
}
// 2. 如果父组件传入了固定的 scrollY优先使用
if (props.scrollY !== undefined && props.scrollY !== null) {
config.y = props.scrollY;
}
// 3. 否则,如果检测到容器有高度,则使用容器高度作为滚动高度
else if (tableScrollY.value > 0) {
config.y = tableScrollY.value;
}
2026-05-05 22:38:24 +08:00
return config;
});
2026-04-22 17:53:20 +08:00
// --- Row Selection ---
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: string[], rows: any[]) => {
2026-06-09 11:44:29 +08:00
// 至少保留 minSelectionCount 条勾选,如果取消后低于最小值则恢复默认第一条
if (
props.minSelectionCount > 0 &&
keys.length < props.minSelectionCount &&
props.data.length > 0
) {
const firstKey = props.data[0][props.rowKey];
keys = [firstKey];
rows = [props.data[0]];
}
2026-04-22 17:53:20 +08:00
selectedRowKeys.value = keys;
selectedRows.value = rows;
emit('selection-change', keys, rows);
2026-04-22 17:53:20 +08:00
},
getCheckboxProps: props.getCheckboxProps
? props.getCheckboxProps
: (record: any) => ({})
2026-04-22 17:53:20 +08:00
}));
// --- Pagination Config ---
const paginationConfig = computed(() => ({
total: total.value,
current: page.value,
pageSize: size.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number) => `${total}`,
pageSizeOptions: ['20', '50', '100']
2026-04-22 17:53:20 +08:00
}));
const $slots = useSlots();
// --- 自定义插槽名称列表 ---
const customSlotNames = computed(() => {
return Object.keys($slots).filter(
name => name !== 'bodyCell' && name !== 'headerCell'
);
});
2026-04-22 17:53:20 +08:00
// --- Methods ---
2026-04-24 15:31:32 +08:00
const getList = async (filter?: Record<string, any>) => {
if (props.data.length > 0) return;
2026-04-22 17:53:20 +08:00
loading.value = true;
2026-05-07 15:40:18 +08:00
tableData.value = [];
total.value = 0;
2026-05-05 22:38:24 +08:00
if (filter !== undefined) {
lastFilter.value = filter;
}
console.log(props.searchParams);
2026-04-22 17:53:20 +08:00
try {
const params = {
2026-04-30 17:09:07 +08:00
...props.searchParams,
2026-04-22 17:53:20 +08:00
skip: page.value,
take: size.value,
filter: lastFilter.value
2026-04-22 17:53:20 +08:00
};
if (!props.isPage) {
delete params.take;
delete params.skip;
}
2026-04-22 17:53:20 +08:00
const res = await props.listUrl(params);
2026-06-17 14:52:32 +08:00
console.log(res);
let finalRecords: any[] = [];
let finalTotal = 0;
if (props.transformData) {
const result = props.transformData(res);
2026-06-17 14:52:32 +08:00
finalRecords = result.records || result.data || [];
finalTotal = result.total || 0;
} else {
finalRecords = res?.data?.records || res?.data?.data || res?.data || [];
finalTotal = res.data?.data?.total || res?.data?.total || res?.total || 0;
}
2026-04-24 15:31:32 +08:00
tableData.value = processData(finalRecords);
total.value = finalTotal;
emit('data-loaded', params, { records: finalRecords, total: finalTotal });
2026-04-22 17:53:20 +08:00
} catch (error) {
console.error('Fetch table data error:', error);
2026-04-22 17:53:20 +08:00
tableData.value = [];
total.value = 0;
} finally {
loading.value = false;
}
};
const handleTableChange = (pag: any, filters: any, sorter: any) => {
2026-04-22 17:53:20 +08:00
page.value = pag.current;
size.value = pag.pageSize;
if (props.enableSort) {
if (sorter && sorter.field) {
emit('sort-change', { field: sorter.field, order: sorter.order });
} else {
emit('sort-change', { field: '', order: null });
}
} else {
getList();
}
2026-04-22 17:53:20 +08:00
};
2026-06-17 11:22:03 +08:00
const handleRowClick = (record: any) => {
emit('row-click', record);
};
const customRow = (record: any) => {
return {
onClick: () => {
handleRowClick(record);
}
};
};
2026-04-22 17:53:20 +08:00
const reset = () => {
page.value = 1;
size.value = props.defaultPageSize;
selectedRowKeys.value = [];
selectedRows.value = [];
getList();
};
const refresh = () => {
getList();
};
2026-05-07 15:40:18 +08:00
const clearSelection = () => {
selectedRowKeys.value = [];
selectedRows.value = [];
};
2026-04-22 17:53:20 +08:00
defineExpose({
loading,
2026-04-22 17:53:20 +08:00
getList,
reset,
refresh,
2026-05-07 15:40:18 +08:00
tableData,
clearSelection,
2026-04-22 17:53:20 +08:00
getSelected: () => ({
keys: selectedRowKeys.value,
rows: selectedRows.value
})
2026-04-22 17:53:20 +08:00
});
const observer = new ResizeObserver(() => {
tableScrollY.value = calcTableScrollY(tableContainerRef.value);
});
2026-06-09 11:44:29 +08:00
watch(
() => props.data,
newData => {
if (newData && newData.length > 0) {
// If defaultSelectedRowKeys is provided, reset selection
if (
props.defaultSelectedRowKeys &&
props.defaultSelectedRowKeys.length > 0
) {
selectedRowKeys.value = [...props.defaultSelectedRowKeys];
selectedRows.value = newData.filter(row =>
props.defaultSelectedRowKeys.includes(row[props.rowKey])
);
} else {
// Otherwise clear selection when data changes
selectedRowKeys.value = [];
selectedRows.value = [];
}
} else {
selectedRowKeys.value = [];
selectedRows.value = [];
}
},
{ deep: true }
);
// --- 空值处理工具函数 ---
const processData = (records: any[]) => {
if (!props.processEmptyValues || !Array.isArray(records)) return records;
return records.map(record => {
const processedRecord: any = { ...record };
for (const key in processedRecord) {
if (Object.prototype.hasOwnProperty.call(processedRecord, key)) {
const val = processedRecord[key];
2026-06-17 14:52:32 +08:00
// children 字段不处理,保持原值(包括 null
if (key === 'children') continue;
// 空数组不替换,只替换 null、undefined、空字符串
if (val === null || val === undefined || val === '') {
processedRecord[key] = props.emptyPlaceholder;
}
}
}
return processedRecord;
});
};
2026-06-10 18:54:40 +08:00
// --- 处理自定义单元格(用于行合并)---
const createMergeCellHandler = (dataIndex: string) => {
return (record: any, index: number) => {
const currentValue = record[dataIndex];
2026-06-17 14:52:32 +08:00
2026-06-10 18:54:40 +08:00
// 检查当前行是否是该值的起始行
let isStartRow = true;
if (index > 0) {
const previousValue = tableData.value[index - 1]?.[dataIndex];
if (previousValue === currentValue) {
isStartRow = false;
}
}
2026-06-17 14:52:32 +08:00
2026-06-10 18:54:40 +08:00
if (!isStartRow) {
// 不是起始行,设置 rowSpan 为 0隐藏该单元格
return { rowSpan: 0 };
} else {
// 是起始行,计算需要跨越的行数
let rowSpan = 1;
for (let i = index + 1; i < tableData.value.length; i++) {
if (tableData.value[i][dataIndex] === currentValue) {
rowSpan++;
} else {
break;
}
}
2026-06-17 14:52:32 +08:00
2026-06-10 18:54:40 +08:00
return rowSpan > 1 ? { rowSpan } : {};
}
};
};
// --- 列配置增强(超出隐藏 + Tooltip + 插槽兼容 + 行合并)---
const enhancedColumns = computed(() => {
return props.columns.map(col => {
2026-06-10 18:54:40 +08:00
// 处理 merge 配置
if (col.merge && col.dataIndex) {
const baseCol = {
...col,
customCell: createMergeCellHandler(col.dataIndex)
};
2026-06-17 14:52:32 +08:00
2026-06-10 18:54:40 +08:00
// 如果有 slots.customRender移除 slots
if (baseCol.slots?.customRender) {
const { slots, ...restCol } = baseCol;
return {
...restCol,
_slotName: slots.customRender,
...(props.enableEllipsis ? { ellipsis: true } : {})
};
}
2026-06-17 14:52:32 +08:00
2026-06-10 18:54:40 +08:00
return baseCol;
}
2026-06-17 14:52:32 +08:00
// 如果有 slots.customRender移除 slots由 bodyCell 处理),保留 dataIndex 用于匹配
if (col.slots?.customRender) {
const { slots, ...restCol } = col;
return {
...restCol,
// 保留 slotName 供 bodyCell 模板匹配使用
_slotName: slots.customRender,
...(props.enableEllipsis ? { ellipsis: true } : {})
};
}
// 如果已有 customRender 函数,直接返回
if (col.customRender) {
return {
...col,
...(props.enableEllipsis && !col.ellipsis ? { ellipsis: true } : {})
};
}
2026-06-12 16:59:35 +08:00
// 普通列,添加 ellipsis
if (!props.enableEllipsis) return col;
return {
...col,
2026-06-12 16:59:35 +08:00
ellipsis: true
};
});
});
2026-04-22 17:53:20 +08:00
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableContainerRef.value);
if (tableContainerRef.value) {
observer.observe(tableContainerRef.value);
}
});
if (props.data && props.data.length > 0) {
tableData.value = processData(props.data);
total.value = props.data.length;
}
2026-04-22 17:53:20 +08:00
});
// 监听 data prop 变化
watch(
() => props.data,
newData => {
if (newData && newData.length > 0) {
tableData.value = processData(newData);
total.value = newData.length;
} else {
tableData.value = [];
total.value = 0;
}
},
{ deep: true }
);
2026-04-22 17:53:20 +08:00
</script>
2026-06-10 18:54:40 +08:00
<style scoped lang="scss">
// 优化合并单元格的边框样式
::v-deep(.ant-table-cell) {
vertical-align: middle;
}
::v-deep(.ant-table-tbody > tr > td) {
border-bottom: 1px solid #f0f0f0;
}
2026-06-17 11:22:03 +08:00
:deep(.ant-pagination) {
.ant-select {
width: 100px;
}
}
2026-06-10 18:54:40 +08:00
</style>