2026-04-22 17:53:20 +08:00
|
|
|
|
<template>
|
2026-05-15 17:38:03 +08:00
|
|
|
|
<div class="table-container" ref="tableContainerRef">
|
2026-06-05 16:10:14 +08:00
|
|
|
|
<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"
|
2026-06-24 14:18:39 +08:00
|
|
|
|
:row-class-name="rowClassName"
|
2026-06-05 16:10:14 +08:00
|
|
|
|
@change="handleTableChange"
|
|
|
|
|
|
>
|
2026-06-08 17:01:10 +08:00
|
|
|
|
<!-- 合计行插槽 -->
|
|
|
|
|
|
<template #summary>
|
|
|
|
|
|
<slot name="summary"></slot>
|
|
|
|
|
|
</template>
|
2026-06-08 09:33:06 +08:00
|
|
|
|
<!-- 使用 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>
|
2026-05-15 17:38:03 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</a-table>
|
|
|
|
|
|
</div>
|
2026-04-22 17:53:20 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-06-08 09:33:06 +08:00
|
|
|
|
import { ref, computed, onMounted, watch, nextTick, h, useSlots } from 'vue';
|
2026-06-24 14:18:39 +08:00
|
|
|
|
import { message } from 'ant-design-vue';
|
2026-06-05 16:10:14 +08:00
|
|
|
|
import { calcTableScrollY } from '@/utils/index';
|
2026-06-24 14:18:39 +08:00
|
|
|
|
import { exportTable as exportTableUtil } from '@/utils/exportExcel';
|
2026-04-22 17:53:20 +08:00
|
|
|
|
|
|
|
|
|
|
// --- Types ---
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
columns: any[];
|
2026-05-15 17:38:03 +08:00
|
|
|
|
scrollY?: string | number; // 优先使用传入的固定值
|
2026-06-05 16:10:14 +08:00
|
|
|
|
scrollX?: string | number; // 横向滚动宽度
|
|
|
|
|
|
listUrl?: (params: any) => Promise<any>;
|
2026-05-08 19:11:10 +08:00
|
|
|
|
data?: any[];
|
2026-04-22 17:53:20 +08:00
|
|
|
|
enableRowSelection?: boolean;
|
2026-06-24 14:18:39 +08:00
|
|
|
|
enableRowHighlight?: boolean; // 是否启用行点击高亮
|
2026-04-22 17:53:20 +08:00
|
|
|
|
rowKey?: string;
|
|
|
|
|
|
searchParams?: Record<string, any>;
|
|
|
|
|
|
defaultPageSize?: number;
|
2026-05-20 17:55:48 +08:00
|
|
|
|
isPage?: boolean;
|
2026-06-09 11:44:29 +08:00
|
|
|
|
defaultSelectedRowKeys?: string[];
|
2026-04-27 10:35:06 +08:00
|
|
|
|
getCheckboxProps?: (record: any) => any;
|
2026-06-09 11:44:29 +08:00
|
|
|
|
minSelectionCount?: number; // 最小选中数量,0表示无限制
|
2026-04-27 19:11:22 +08:00
|
|
|
|
transformData?: (res: any) => { records: any[]; total: number };
|
2026-06-04 11:42:07 +08:00
|
|
|
|
emptyPlaceholder?: string; // 新增:空值占位符
|
|
|
|
|
|
processEmptyValues?: boolean; // 新增:是否开启空值处理
|
|
|
|
|
|
enableEllipsis?: boolean; // 新增:是否开启超出隐藏
|
|
|
|
|
|
enableSort?: boolean; // 新增:是否启用排序功能
|
2026-04-22 17:53:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
|
|
|
|
enableRowSelection: false,
|
2026-06-24 14:18:39 +08:00
|
|
|
|
enableRowHighlight: false,
|
2026-06-05 16:10:14 +08:00
|
|
|
|
rowKey: 'id',
|
2026-05-20 17:55:48 +08:00
|
|
|
|
isPage: true,
|
2026-05-15 17:38:03 +08:00
|
|
|
|
data: () => [],
|
2026-04-22 17:53:20 +08:00
|
|
|
|
searchParams: () => ({}),
|
|
|
|
|
|
defaultPageSize: 20,
|
2026-06-09 11:44:29 +08:00
|
|
|
|
defaultSelectedRowKeys: () => [],
|
2026-04-27 19:11:22 +08:00
|
|
|
|
getCheckboxProps: undefined,
|
2026-06-09 11:44:29 +08:00
|
|
|
|
minSelectionCount: 0,
|
2026-04-27 19:11:22 +08:00
|
|
|
|
transformData: undefined,
|
2026-05-20 17:55:48 +08:00
|
|
|
|
scrollY: undefined,
|
2026-06-05 16:10:14 +08:00
|
|
|
|
emptyPlaceholder: '-',
|
2026-06-04 11:42:07 +08:00
|
|
|
|
processEmptyValues: true, // 默认开启
|
|
|
|
|
|
enableEllipsis: true, // 默认开启
|
2026-06-05 16:10:14 +08:00
|
|
|
|
enableSort: false // 默认关闭,确保旧代码不受影响
|
2026-04-22 17:53:20 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
2026-06-05 16:10:14 +08:00
|
|
|
|
(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);
|
2026-05-15 17:38:03 +08:00
|
|
|
|
const tableContainerRef = ref<any>(null);
|
|
|
|
|
|
const tableScrollY = ref<number>(0); // ✅ 新增:存储容器高度
|
2026-06-24 14:18:39 +08:00
|
|
|
|
const clickedRowKey = ref<string | number | null>(null); // 新增:记录点击行 key
|
2026-05-15 17:38:03 +08:00
|
|
|
|
|
2026-06-22 10:00:26 +08:00
|
|
|
|
// --- 内部排序状态 ---
|
|
|
|
|
|
const internalSort = ref<{
|
|
|
|
|
|
field: string;
|
|
|
|
|
|
order: 'ascend' | 'descend' | null;
|
|
|
|
|
|
}>({ field: '', order: null });
|
2026-05-15 17:38:03 +08:00
|
|
|
|
|
|
|
|
|
|
// --- Computed Scroll Config ---
|
|
|
|
|
|
const scrollConfig = computed(() => {
|
|
|
|
|
|
const config: any = {
|
2026-06-05 16:10:14 +08:00
|
|
|
|
x: '100%',
|
|
|
|
|
|
y: 0
|
2026-05-15 17:38:03 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-05 16:10:14 +08:00
|
|
|
|
// 1. 如果父组件传入了固定的 scrollX,优先使用
|
|
|
|
|
|
if (props.scrollX !== undefined && props.scrollX !== null) {
|
|
|
|
|
|
config.x = props.scrollX;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 如果父组件传入了固定的 scrollY,优先使用
|
2026-05-15 17:38:03 +08:00
|
|
|
|
if (props.scrollY !== undefined && props.scrollY !== null) {
|
|
|
|
|
|
config.y = props.scrollY;
|
|
|
|
|
|
}
|
2026-06-05 16:10:14 +08:00
|
|
|
|
// 3. 否则,如果检测到容器有高度,则使用容器高度作为滚动高度
|
2026-05-15 17:38:03 +08:00
|
|
|
|
else if (tableScrollY.value > 0) {
|
|
|
|
|
|
config.y = tableScrollY.value;
|
|
|
|
|
|
}
|
2026-05-05 22:38:24 +08:00
|
|
|
|
|
2026-05-15 17:38:03 +08:00
|
|
|
|
return config;
|
|
|
|
|
|
});
|
2026-04-29 16:26:15 +08:00
|
|
|
|
|
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 条勾选,如果取消后低于最小值则恢复默认第一条
|
2026-06-24 14:18:39 +08:00
|
|
|
|
const currentData = props.data.length > 0 ? props.data : tableData.value;
|
2026-06-09 11:44:29 +08:00
|
|
|
|
if (
|
|
|
|
|
|
props.minSelectionCount > 0 &&
|
|
|
|
|
|
keys.length < props.minSelectionCount &&
|
2026-06-24 14:18:39 +08:00
|
|
|
|
currentData.length > 0
|
2026-06-09 11:44:29 +08:00
|
|
|
|
) {
|
2026-06-24 14:18:39 +08:00
|
|
|
|
const firstKey = currentData[0][props.rowKey];
|
2026-06-09 11:44:29 +08:00
|
|
|
|
keys = [firstKey];
|
2026-06-24 14:18:39 +08:00
|
|
|
|
rows = [currentData[0]];
|
2026-06-09 11:44:29 +08:00
|
|
|
|
}
|
2026-04-22 17:53:20 +08:00
|
|
|
|
selectedRowKeys.value = keys;
|
|
|
|
|
|
selectedRows.value = rows;
|
2026-06-05 16:10:14 +08:00
|
|
|
|
emit('selection-change', keys, rows);
|
2026-04-22 17:53:20 +08:00
|
|
|
|
},
|
2026-05-15 17:38:03 +08:00
|
|
|
|
getCheckboxProps: props.getCheckboxProps
|
|
|
|
|
|
? props.getCheckboxProps
|
2026-06-05 16:10:14 +08:00
|
|
|
|
: (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} 条`,
|
2026-06-05 16:10:14 +08:00
|
|
|
|
pageSizeOptions: ['20', '50', '100']
|
2026-04-22 17:53:20 +08:00
|
|
|
|
}));
|
|
|
|
|
|
|
2026-06-08 09:33:06 +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>) => {
|
2026-05-15 17:38:03 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-04-22 17:53:20 +08:00
|
|
|
|
try {
|
2026-06-22 10:00:26 +08:00
|
|
|
|
// 合并排序参数:优先使用用户手动排序,否则使用 searchParams 中的默认排序
|
|
|
|
|
|
let sort = undefined;
|
|
|
|
|
|
if (internalSort.value.field && internalSort.value.order) {
|
|
|
|
|
|
sort = [
|
|
|
|
|
|
{
|
|
|
|
|
|
field: internalSort.value.field,
|
|
|
|
|
|
dir: internalSort.value.order === 'ascend' ? 'asc' : 'desc'
|
|
|
|
|
|
}
|
|
|
|
|
|
];
|
|
|
|
|
|
} else if (props.searchParams?.sort) {
|
|
|
|
|
|
sort = props.searchParams.sort;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 17:53:20 +08:00
|
|
|
|
const params = {
|
2026-04-30 17:09:07 +08:00
|
|
|
|
...props.searchParams,
|
2026-06-22 10:00:26 +08:00
|
|
|
|
...(sort !== undefined ? { sort } : {}),
|
2026-04-22 17:53:20 +08:00
|
|
|
|
skip: page.value,
|
|
|
|
|
|
take: size.value,
|
2026-06-05 16:10:14 +08:00
|
|
|
|
filter: lastFilter.value
|
2026-04-22 17:53:20 +08:00
|
|
|
|
};
|
2026-05-20 17:55:48 +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-04-27 19:11:22 +08:00
|
|
|
|
|
2026-05-15 17:38:03 +08:00
|
|
|
|
let finalRecords: any[] = [];
|
2026-06-01 08:42:06 +08:00
|
|
|
|
let finalTotal = 0;
|
2026-05-15 17:38:03 +08:00
|
|
|
|
|
2026-04-27 19:11:22 +08:00
|
|
|
|
if (props.transformData) {
|
2026-06-24 11:26:19 +08:00
|
|
|
|
const result:any = props.transformData(res);
|
2026-06-17 14:52:32 +08:00
|
|
|
|
finalRecords = result.records || result.data || [];
|
2026-05-15 17:38:03 +08:00
|
|
|
|
finalTotal = result.total || 0;
|
2026-04-27 19:11:22 +08:00
|
|
|
|
} else {
|
2026-05-20 17:55:48 +08:00
|
|
|
|
finalRecords = res?.data?.records || res?.data?.data || res?.data || [];
|
|
|
|
|
|
finalTotal = res.data?.data?.total || res?.data?.total || res?.total || 0;
|
2026-04-27 19:11:22 +08:00
|
|
|
|
}
|
2026-04-24 15:31:32 +08:00
|
|
|
|
|
2026-06-04 11:42:07 +08:00
|
|
|
|
tableData.value = processData(finalRecords);
|
2026-06-24 14:18:39 +08:00
|
|
|
|
// tableData.value = finalRecords;
|
2026-05-15 17:38:03 +08:00
|
|
|
|
total.value = finalTotal;
|
2026-06-24 14:18:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 数据加载完成后,默认高亮第一条数据(仅在启用行高亮时)
|
|
|
|
|
|
if (props.enableRowHighlight) {
|
|
|
|
|
|
if (tableData.value.length > 0) {
|
|
|
|
|
|
clickedRowKey.value = tableData.value[0][props.rowKey];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
clickedRowKey.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 16:10:14 +08:00
|
|
|
|
emit('data-loaded', params, { records: finalRecords, total: finalTotal });
|
2026-04-22 17:53:20 +08:00
|
|
|
|
} catch (error) {
|
2026-06-05 16:10:14 +08:00
|
|
|
|
console.error('Fetch table data error:', error);
|
2026-04-22 17:53:20 +08:00
|
|
|
|
tableData.value = [];
|
|
|
|
|
|
total.value = 0;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 11:42:07 +08:00
|
|
|
|
const handleTableChange = (pag: any, filters: any, sorter: any) => {
|
2026-06-25 10:23:25 +08:00
|
|
|
|
// 判断是否是排序操作:sorter 有 field 且 order 发生了变化
|
|
|
|
|
|
const isSortChange = sorter && sorter.field && sorter.order !== undefined;
|
|
|
|
|
|
|
|
|
|
|
|
if (isSortChange) {
|
|
|
|
|
|
// 排序操作:重置到第1页
|
2026-06-22 10:00:26 +08:00
|
|
|
|
internalSort.value = { field: sorter.field, order: sorter.order };
|
2026-06-25 10:23:25 +08:00
|
|
|
|
page.value = 1;
|
2026-06-22 10:00:26 +08:00
|
|
|
|
emit('sort-change', { field: sorter.field, order: sorter.order });
|
2026-06-25 10:23:25 +08:00
|
|
|
|
} else if (sorter && sorter.field && !sorter.order) {
|
|
|
|
|
|
// 取消排序:也重置到第1页
|
2026-06-22 10:00:26 +08:00
|
|
|
|
internalSort.value = { field: '', order: null };
|
|
|
|
|
|
page.value = 1;
|
|
|
|
|
|
emit('sort-change', { field: '', order: null });
|
2026-06-04 11:42:07 +08:00
|
|
|
|
}
|
2026-06-25 10:23:25 +08:00
|
|
|
|
|
|
|
|
|
|
// 分页操作:直接使用传入的页码
|
|
|
|
|
|
page.value = pag.current;
|
|
|
|
|
|
size.value = pag.pageSize;
|
|
|
|
|
|
|
|
|
|
|
|
// 统一请求数据
|
|
|
|
|
|
getList();
|
2026-04-22 17:53:20 +08:00
|
|
|
|
};
|
2026-06-17 11:22:03 +08:00
|
|
|
|
const handleRowClick = (record: any) => {
|
2026-06-24 14:18:39 +08:00
|
|
|
|
clickedRowKey.value = record[props.rowKey];
|
2026-06-17 11:22:03 +08:00
|
|
|
|
emit('row-click', record);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const customRow = (record: any) => {
|
|
|
|
|
|
return {
|
|
|
|
|
|
onClick: () => {
|
|
|
|
|
|
handleRowClick(record);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
2026-04-22 17:53:20 +08:00
|
|
|
|
|
2026-06-24 14:18:39 +08:00
|
|
|
|
const rowClassName = (record: any) => {
|
|
|
|
|
|
return record[props.rowKey] === clickedRowKey.value ? 'row-clicked' : '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
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-05-15 17:38:03 +08:00
|
|
|
|
};
|
2026-06-24 14:18:39 +08:00
|
|
|
|
|
|
|
|
|
|
const setSelectedRowKeys = (keys: string[]) => {
|
|
|
|
|
|
selectedRowKeys.value = keys;
|
|
|
|
|
|
selectedRows.value = tableData.value.filter(row =>
|
|
|
|
|
|
keys.includes(row[props.rowKey])
|
|
|
|
|
|
);
|
|
|
|
|
|
emit('selection-change', selectedRowKeys.value, selectedRows.value);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const setClickedRowKey = (key: string | number | null) => {
|
|
|
|
|
|
clickedRowKey.value = key;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// --- 导出方法 ---
|
|
|
|
|
|
interface ExportTableParams {
|
|
|
|
|
|
fileName: string;
|
|
|
|
|
|
callback?: (message: string | boolean) => void;
|
|
|
|
|
|
tbtitle?: string;
|
|
|
|
|
|
tbsub?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const defaultExportCallback = (result: string | boolean) => {
|
|
|
|
|
|
if (result === true) {
|
|
|
|
|
|
message.success('导出成功');
|
|
|
|
|
|
} else if (typeof result === 'string') {
|
|
|
|
|
|
message.warning(result);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
message.error('导出失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const exportTable = async (params: ExportTableParams): Promise<void> => {
|
|
|
|
|
|
const { fileName, callback, tbtitle, tbsub } = params;
|
|
|
|
|
|
const cb = callback || defaultExportCallback;
|
|
|
|
|
|
|
|
|
|
|
|
// 如果没有 listUrl 且没有内部数据,则无法导出
|
|
|
|
|
|
if (!props.listUrl && (!props.data || props.data.length === 0)) {
|
|
|
|
|
|
cb('没有可导出的数据');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let records: any[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
if (props.data && props.data.length > 0) {
|
|
|
|
|
|
// 使用传入的数据
|
|
|
|
|
|
records = props.data;
|
|
|
|
|
|
} else if (props.listUrl) {
|
|
|
|
|
|
// 通过 listUrl 分页获取所有数据
|
|
|
|
|
|
try {
|
|
|
|
|
|
const allRecords: any[] = [];
|
|
|
|
|
|
const pageSize = 3000;
|
|
|
|
|
|
let skip = 1;
|
|
|
|
|
|
let total = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 显示 loading
|
|
|
|
|
|
const loadingMsg = message.loading({
|
|
|
|
|
|
content: '正在获取导出数据...',
|
|
|
|
|
|
duration: 0
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
|
const requestParams = {
|
|
|
|
|
|
...props.searchParams,
|
|
|
|
|
|
skip,
|
|
|
|
|
|
take: pageSize,
|
|
|
|
|
|
groupResultFlat: false,
|
|
|
|
|
|
filter: lastFilter.value
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const res = await props.listUrl(requestParams);
|
|
|
|
|
|
|
|
|
|
|
|
let pageRecords: any[] = [];
|
|
|
|
|
|
let pageTotal = 0;
|
|
|
|
|
|
|
|
|
|
|
|
if (props.transformData) {
|
|
|
|
|
|
const result = props.transformData(res);
|
|
|
|
|
|
pageRecords = result.records || result.data || [];
|
|
|
|
|
|
pageTotal = result.total || 0;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
pageRecords =
|
|
|
|
|
|
res?.data?.records || res?.data?.data || res?.data || [];
|
|
|
|
|
|
pageTotal =
|
|
|
|
|
|
res?.data?.data?.total || res?.data?.total || res?.total || 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (skip === 1) {
|
|
|
|
|
|
total = pageTotal;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
allRecords.push(...pageRecords);
|
|
|
|
|
|
|
|
|
|
|
|
// 更新 loading 提示
|
|
|
|
|
|
loadingMsg();
|
|
|
|
|
|
message.loading({
|
|
|
|
|
|
content: `正在获取导出数据... (${allRecords.length}/${total})`,
|
|
|
|
|
|
duration: 0
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
skip++;
|
|
|
|
|
|
} while (allRecords.length < total);
|
|
|
|
|
|
|
|
|
|
|
|
// 关闭 loading
|
|
|
|
|
|
message.destroy();
|
|
|
|
|
|
|
|
|
|
|
|
records = allRecords;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.destroy();
|
|
|
|
|
|
console.error('导出数据获取失败:', error);
|
|
|
|
|
|
cb('导出数据获取失败');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!records || records.length === 0) {
|
|
|
|
|
|
cb('没有可导出的数据');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 返回 Promise,等待导出完成(直接使用原始数据,不使用 customRender)
|
|
|
|
|
|
return new Promise<void>(resolve => {
|
|
|
|
|
|
exportTableUtil({
|
|
|
|
|
|
data: records,
|
|
|
|
|
|
columns: props.columns,
|
|
|
|
|
|
fileName,
|
|
|
|
|
|
callback: result => {
|
|
|
|
|
|
cb(result);
|
|
|
|
|
|
resolve();
|
|
|
|
|
|
},
|
|
|
|
|
|
tbtitle,
|
|
|
|
|
|
tbsub
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-22 17:53:20 +08:00
|
|
|
|
defineExpose({
|
2026-05-15 17:38:03 +08:00
|
|
|
|
loading,
|
2026-04-22 17:53:20 +08:00
|
|
|
|
getList,
|
|
|
|
|
|
reset,
|
|
|
|
|
|
refresh,
|
2026-05-07 15:40:18 +08:00
|
|
|
|
tableData,
|
|
|
|
|
|
clearSelection,
|
2026-06-22 10:00:26 +08:00
|
|
|
|
clearSort: () => {
|
|
|
|
|
|
internalSort.value = { field: '', order: null };
|
|
|
|
|
|
},
|
2026-06-24 14:18:39 +08:00
|
|
|
|
setSelectedRowKeys,
|
|
|
|
|
|
setClickedRowKey,
|
|
|
|
|
|
exportTable,
|
2026-04-22 17:53:20 +08:00
|
|
|
|
getSelected: () => ({
|
|
|
|
|
|
keys: selectedRowKeys.value,
|
2026-06-05 16:10:14 +08:00
|
|
|
|
rows: selectedRows.value
|
|
|
|
|
|
})
|
2026-04-22 17:53:20 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-15 17:38:03 +08:00
|
|
|
|
const observer = new ResizeObserver(() => {
|
|
|
|
|
|
tableScrollY.value = calcTableScrollY(tableContainerRef.value);
|
|
|
|
|
|
});
|
2026-06-04 11:42:07 +08:00
|
|
|
|
|
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 }
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-04 11:42:07 +08:00
|
|
|
|
// --- 空值处理工具函数 ---
|
|
|
|
|
|
const processData = (records: any[]) => {
|
|
|
|
|
|
if (!props.processEmptyValues || !Array.isArray(records)) return records;
|
|
|
|
|
|
|
2026-06-24 14:18:39 +08:00
|
|
|
|
// 提取需要处理的字段(columns 中的 dataIndex)
|
|
|
|
|
|
const displayFields = new Set(
|
|
|
|
|
|
props.columns.map(col => col.dataIndex).filter(Boolean)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-04 11:42:07 +08:00
|
|
|
|
return records.map(record => {
|
|
|
|
|
|
const processedRecord: any = { ...record };
|
|
|
|
|
|
for (const key in processedRecord) {
|
2026-06-24 14:18:39 +08:00
|
|
|
|
if (!displayFields.has(key)) continue; // 不在 columns 中的字段不处理
|
|
|
|
|
|
if (key === 'children') continue; // children 永远不处理
|
|
|
|
|
|
|
|
|
|
|
|
const val = processedRecord[key];
|
|
|
|
|
|
// 只处理 columns 中定义的字段,替换 null/undefined/空字符串
|
|
|
|
|
|
if (val === null || val === undefined || val === '') {
|
|
|
|
|
|
processedRecord[key] = props.emptyPlaceholder;
|
2026-06-04 11:42:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return processedRecord;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-10 18:54:40 +08:00
|
|
|
|
// --- 处理自定义单元格(用于行合并)---
|
2026-06-24 14:18:39 +08:00
|
|
|
|
const createMergeCellHandler = (col: any) => {
|
|
|
|
|
|
const dataIndex = col.dataIndex;
|
|
|
|
|
|
const mergeDeps = col.mergeDeps || []; // 依赖列,依赖列变化时重新开始合并
|
|
|
|
|
|
|
2026-06-10 18:54:40 +08:00
|
|
|
|
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) {
|
2026-06-24 14:18:39 +08:00
|
|
|
|
// 当前值相同,但需要检查依赖列是否一致
|
|
|
|
|
|
const depsChanged = mergeDeps.some(
|
|
|
|
|
|
dep =>
|
|
|
|
|
|
tableData.value[index - 1]?.[dep] !== tableData.value[index]?.[dep]
|
|
|
|
|
|
);
|
|
|
|
|
|
if (depsChanged) {
|
|
|
|
|
|
isStartRow = true; // 依赖列变化,重新开始合并
|
|
|
|
|
|
} else {
|
|
|
|
|
|
isStartRow = false;
|
|
|
|
|
|
}
|
2026-06-10 18:54:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
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) {
|
2026-06-24 14:18:39 +08:00
|
|
|
|
// 检查依赖列是否一致
|
|
|
|
|
|
const depsConsistent = mergeDeps.every(
|
|
|
|
|
|
dep => tableData.value[i][dep] === tableData.value[index][dep]
|
|
|
|
|
|
);
|
|
|
|
|
|
if (depsConsistent) {
|
|
|
|
|
|
rowSpan++;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
break; // 依赖列不一致,停止合并
|
|
|
|
|
|
}
|
2026-06-10 18:54:40 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-17 14:52:32 +08:00
|
|
|
|
|
2026-06-10 18:54:40 +08:00
|
|
|
|
return rowSpan > 1 ? { rowSpan } : {};
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-22 10:00:26 +08:00
|
|
|
|
// --- 列配置增强(超出隐藏 + Tooltip + 插槽兼容 + 行合并 + 排序)---
|
2026-06-04 11:42:07 +08:00
|
|
|
|
const enhancedColumns = computed(() => {
|
|
|
|
|
|
return props.columns.map(col => {
|
2026-06-22 10:00:26 +08:00
|
|
|
|
const enhancedCol: any = { ...col };
|
|
|
|
|
|
|
|
|
|
|
|
// 处理 sort 或 sorter: true,统一转为 sorter 并绑定 sortOrder
|
|
|
|
|
|
const isSortable = enhancedCol.sort === true || enhancedCol.sorter === true;
|
|
|
|
|
|
if (isSortable) {
|
|
|
|
|
|
enhancedCol.sorter = true;
|
|
|
|
|
|
enhancedCol.sortOrder =
|
|
|
|
|
|
internalSort.value.field === enhancedCol.dataIndex
|
|
|
|
|
|
? internalSort.value.order
|
|
|
|
|
|
: false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 18:54:40 +08:00
|
|
|
|
// 处理 merge 配置
|
|
|
|
|
|
if (col.merge && col.dataIndex) {
|
|
|
|
|
|
const baseCol = {
|
|
|
|
|
|
...col,
|
2026-06-24 14:18:39 +08:00
|
|
|
|
customCell: createMergeCellHandler(col)
|
2026-06-10 18:54:40 +08:00
|
|
|
|
};
|
2026-06-17 14:52:32 +08:00
|
|
|
|
|
2026-06-10 18:54:40 +08:00
|
|
|
|
// 如果有 slots.customRender,移除 slots
|
2026-06-22 10:00:26 +08:00
|
|
|
|
if (enhancedCol.slots?.customRender) {
|
|
|
|
|
|
const { slots, ...restCol } = enhancedCol;
|
2026-06-10 18:54:40 +08:00
|
|
|
|
return {
|
|
|
|
|
|
...restCol,
|
|
|
|
|
|
_slotName: slots.customRender,
|
|
|
|
|
|
...(props.enableEllipsis ? { ellipsis: true } : {})
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-06-17 14:52:32 +08:00
|
|
|
|
|
2026-06-22 10:00:26 +08:00
|
|
|
|
return enhancedCol;
|
2026-06-10 18:54:40 +08:00
|
|
|
|
}
|
2026-06-17 14:52:32 +08:00
|
|
|
|
|
2026-06-08 09:33:06 +08:00
|
|
|
|
// 如果有 slots.customRender,移除 slots(由 bodyCell 处理),保留 dataIndex 用于匹配
|
2026-06-22 10:00:26 +08:00
|
|
|
|
if (enhancedCol.slots?.customRender) {
|
|
|
|
|
|
const { slots, ...restCol } = enhancedCol;
|
2026-06-08 09:33:06 +08:00
|
|
|
|
return {
|
|
|
|
|
|
...restCol,
|
|
|
|
|
|
// 保留 slotName 供 bodyCell 模板匹配使用
|
|
|
|
|
|
_slotName: slots.customRender,
|
|
|
|
|
|
...(props.enableEllipsis ? { ellipsis: true } : {})
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果已有 customRender 函数,直接返回
|
2026-06-22 10:00:26 +08:00
|
|
|
|
if (enhancedCol.customRender) {
|
2026-06-08 09:33:06 +08:00
|
|
|
|
return {
|
2026-06-22 10:00:26 +08:00
|
|
|
|
...enhancedCol,
|
|
|
|
|
|
...(props.enableEllipsis && !enhancedCol.ellipsis ? { ellipsis: true } : {})
|
2026-06-08 09:33:06 +08:00
|
|
|
|
};
|
2026-06-04 11:42:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 16:59:35 +08:00
|
|
|
|
// 普通列,添加 ellipsis
|
2026-06-22 10:00:26 +08:00
|
|
|
|
if (!props.enableEllipsis) return enhancedCol;
|
2026-06-08 09:33:06 +08:00
|
|
|
|
|
2026-06-04 11:42:07 +08:00
|
|
|
|
return {
|
2026-06-22 10:00:26 +08:00
|
|
|
|
...enhancedCol,
|
2026-06-12 16:59:35 +08:00
|
|
|
|
ellipsis: true
|
2026-06-04 11:42:07 +08:00
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-22 17:53:20 +08:00
|
|
|
|
onMounted(() => {
|
2026-05-15 17:38:03 +08:00
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
tableScrollY.value = calcTableScrollY(tableContainerRef.value);
|
|
|
|
|
|
if (tableContainerRef.value) {
|
|
|
|
|
|
observer.observe(tableContainerRef.value);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
if (props.data && props.data.length > 0) {
|
2026-06-04 11:42:07 +08:00
|
|
|
|
tableData.value = processData(props.data);
|
2026-06-05 16:10:14 +08:00
|
|
|
|
total.value = props.data.length;
|
2026-05-15 17:38:03 +08:00
|
|
|
|
}
|
2026-04-22 17:53:20 +08:00
|
|
|
|
});
|
2026-06-05 16:10:14 +08:00
|
|
|
|
|
|
|
|
|
|
// 监听 data prop 变化
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => props.data,
|
|
|
|
|
|
newData => {
|
|
|
|
|
|
if (newData && newData.length > 0) {
|
|
|
|
|
|
tableData.value = processData(newData);
|
|
|
|
|
|
total.value = newData.length;
|
2026-06-24 14:18:39 +08:00
|
|
|
|
// 数据更新后,默认高亮第一条数据(仅在启用行高亮时)
|
|
|
|
|
|
if (props.enableRowHighlight) {
|
|
|
|
|
|
clickedRowKey.value = tableData.value[0][props.rowKey];
|
|
|
|
|
|
}
|
2026-06-05 16:10:14 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
tableData.value = [];
|
|
|
|
|
|
total.value = 0;
|
2026-06-24 14:18:39 +08:00
|
|
|
|
clickedRowKey.value = null;
|
2026-06-05 16:10:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{ 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-24 14:18:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 行点击高亮样式(仅当 enableRowHighlight 为 true 时生效)
|
|
|
|
|
|
::v-deep(.ant-table-tbody > tr.row-clicked > td) {
|
|
|
|
|
|
background-color: v-bind(
|
|
|
|
|
|
'props.enableRowHighlight ? "#e6f7ff" : "transparent"'
|
|
|
|
|
|
) !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 11:22:03 +08:00
|
|
|
|
:deep(.ant-pagination) {
|
|
|
|
|
|
.ant-select {
|
|
|
|
|
|
width: 100px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-10 18:54:40 +08:00
|
|
|
|
</style>
|