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

763 lines
21 KiB
Vue
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.

<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"
:customRow="customRow"
:row-class-name="rowClassName"
@change="handleTableChange"
>
<!-- 合计行插槽 -->
<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>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick, h, useSlots } from 'vue';
import { message, Tooltip } from 'ant-design-vue';
import { calcTableScrollY } from '@/utils/index';
import {
exportTable as exportTableUtil,
type ExportColumn
} from '@/utils/exportExcel';
// --- Types ---
interface Props {
columns: any[];
scrollY?: string | number; // 优先使用传入的固定值
scrollX?: string | number; // 横向滚动宽度
listUrl?: (params: any) => Promise<any>;
data?: any[];
enableRowSelection?: boolean;
enableRowHighlight?: boolean; // 是否启用行点击高亮
rowKey?: string | ((record: any) => string | number);
searchParams?: Record<string, any>;
defaultPageSize?: number;
isPage?: boolean;
defaultSelectedRowKeys?: string[];
getCheckboxProps?: (record: any) => any;
minSelectionCount?: number; // 最小选中数量0表示无限制
transformData?: (res: any) => { records: any[]; total: number };
emptyPlaceholder?: string; // 新增:空值占位符
processEmptyValues?: boolean; // 新增:是否开启空值处理
enableEllipsis?: boolean; // 新增:是否开启超出隐藏
enableSort?: boolean; // 新增:是否启用排序功能
}
const props = withDefaults(defineProps<Props>(), {
enableRowSelection: false,
enableRowHighlight: false,
rowKey: 'id',
isPage: true,
data: () => [],
searchParams: () => ({}),
defaultPageSize: 20,
defaultSelectedRowKeys: () => [],
getCheckboxProps: undefined,
minSelectionCount: 0,
transformData: undefined,
scrollY: undefined,
emptyPlaceholder: '-',
processEmptyValues: true, // 默认开启
enableEllipsis: true, // 默认开启
enableSort: false // 默认关闭,确保旧代码不受影响
});
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;
(e: 'row-click', record: any): void;
}>();
// --- State ---
const loading = ref(false);
const tableData = ref<any[]>([]);
const total = ref(0);
const page = ref(1);
const size = ref(props.defaultPageSize);
const selectedRowKeys = ref<string[]>([...props.defaultSelectedRowKeys]);
const selectedRows = ref<any[]>([]);
const lastFilter = ref<Record<string, any> | undefined>(undefined);
const tableContainerRef = ref<any>(null);
const tableScrollY = ref<number>(0); // ✅ 新增:存储容器高度
const clickedRowKey = ref<string | number | null>(null); // 新增:记录点击行 key
// --- 内部排序状态 ---
const internalSort = ref<{
field: string;
order: 'ascend' | 'descend' | null;
}>({ field: '', order: null });
// --- 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;
}
return config;
});
// --- Row Selection ---
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: string[], rows: any[]) => {
// 至少保留 minSelectionCount 条勾选,如果取消后低于最小值则恢复默认第一条
const currentData = props.data.length > 0 ? props.data : tableData.value;
if (
props.minSelectionCount > 0 &&
keys.length < props.minSelectionCount &&
currentData.length > 0
) {
const firstKey = currentData[0][props.rowKey];
keys = [firstKey];
rows = [currentData[0]];
}
selectedRowKeys.value = keys;
selectedRows.value = rows;
emit('selection-change', keys, rows);
},
getCheckboxProps: props.getCheckboxProps
? props.getCheckboxProps
: (record: any) => ({})
}));
// --- 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']
}));
const $slots = useSlots();
// --- 自定义插槽名称列表 ---
const customSlotNames = computed(() => {
return Object.keys($slots).filter(
name => name !== 'bodyCell' && name !== 'headerCell'
);
});
// --- Methods ---
const getList = async (filter?: Record<string, any>) => {
if (props.data.length > 0) return;
loading.value = true;
tableData.value = [];
total.value = 0;
if (filter !== undefined) {
lastFilter.value = filter;
page.value = 1;
}
try {
// 合并排序参数:优先使用用户手动排序,否则使用 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;
}
const params = {
...props.searchParams,
...(sort !== undefined ? { sort } : {}),
skip: page.value,
take: size.value,
filter: lastFilter.value
};
if (!props.isPage) {
delete params.take;
delete params.skip;
}
const res = await props.listUrl(params);
let finalRecords: any[] = [];
let finalTotal = 0;
if (props.transformData) {
const result: any = props.transformData(res);
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;
}
tableData.value = processData(finalRecords);
// tableData.value = finalRecords;
total.value = finalTotal;
// 数据加载完成后,默认高亮第一条数据(仅在启用行高亮时)
if (props.enableRowHighlight) {
if (tableData.value.length > 0) {
clickedRowKey.value = tableData.value[0][props.rowKey];
} else {
clickedRowKey.value = null;
}
}
emit('data-loaded', params, { records: finalRecords, total: finalTotal });
} catch (error) {
console.error('Fetch table data error:', error);
tableData.value = [];
total.value = 0;
} finally {
loading.value = false;
}
};
const handleTableChange = (pag: any, filters: any, sorter: any) => {
// 判断是否是排序操作sorter 有 field 且 order 发生了变化
const isSortChange = sorter && sorter.field && sorter.order !== undefined;
if (isSortChange) {
// 排序操作重置到第1页
internalSort.value = { field: sorter.field, order: sorter.order };
page.value = 1;
emit('sort-change', { field: sorter.field, order: sorter.order });
} else if (sorter && sorter.field && !sorter.order) {
// 取消排序也重置到第1页
internalSort.value = { field: '', order: null };
page.value = 1;
emit('sort-change', { field: '', order: null });
}
// 分页操作:直接使用传入的页码
page.value = pag.current;
size.value = pag.pageSize;
// 统一请求数据
getList();
};
const handleRowClick = (record: any) => {
clickedRowKey.value = record[props.rowKey];
emit('row-click', record);
};
const customRow = (record: any) => {
return {
onClick: () => {
handleRowClick(record);
}
};
};
const rowClassName = (record: any) => {
return record[props.rowKey] === clickedRowKey.value ? 'row-clicked' : '';
};
const reset = () => {
page.value = 1;
size.value = props.defaultPageSize;
selectedRowKeys.value = [];
selectedRows.value = [];
getList();
};
const refresh = () => {
getList();
};
const clearSelection = () => {
selectedRowKeys.value = [];
selectedRows.value = [];
};
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;
summary?: {
sheetName: string;
data: any[];
columns: ExportColumn[];
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, summary } = 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,
appendTables: summary ? [summary] : [],
callback: result => {
cb(result);
resolve();
},
tbtitle,
tbsub
});
});
};
defineExpose({
loading,
getList,
reset,
refresh,
tableData,
clearSelection,
clearSort: () => {
internalSort.value = { field: '', order: null };
},
setSelectedRowKeys,
setClickedRowKey,
exportTable,
getSelected: () => ({
keys: selectedRowKeys.value,
rows: selectedRows.value
})
});
const observer = new ResizeObserver(() => {
tableScrollY.value = calcTableScrollY(tableContainerRef.value);
});
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;
// 提取需要处理的字段columns 中的 dataIndex
const displayFields = new Set(
props.columns.map(col => col.dataIndex).filter(Boolean)
);
return records.map(record => {
const processedRecord: any = { ...record };
for (const key in processedRecord) {
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;
}
}
return processedRecord;
});
};
// --- 处理自定义单元格(用于行合并)---
const createMergeCellHandler = (col: any) => {
const dataIndex = col.dataIndex;
const mergeDeps = col.mergeDeps || []; // 依赖列,依赖列变化时重新开始合并
return (record: any, index: number) => {
const currentValue = record[dataIndex];
// 检查当前行是否是该值的起始行
let isStartRow = true;
if (index > 0) {
const previousValue = tableData.value[index - 1]?.[dataIndex];
if (previousValue === currentValue) {
// 当前值相同,但需要检查依赖列是否一致
const depsChanged = mergeDeps.some(
dep =>
tableData.value[index - 1]?.[dep] !== tableData.value[index]?.[dep]
);
if (depsChanged) {
isStartRow = true; // 依赖列变化,重新开始合并
} else {
isStartRow = false;
}
}
}
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) {
// 检查依赖列是否一致
const depsConsistent = mergeDeps.every(
dep => tableData.value[i][dep] === tableData.value[index][dep]
);
if (depsConsistent) {
rowSpan++;
} else {
break; // 依赖列不一致,停止合并
}
} else {
break;
}
}
return rowSpan > 1 ? { rowSpan } : {};
}
};
};
// --- 列配置增强(超出隐藏 + Tooltip + 插槽兼容 + 行合并 + 排序)---
const enhancedColumns = computed(() => {
return props.columns.map(col => {
const enhancedCol: any = { ...col };
// 处理 sort 或 sorter: true
const isSortable = enhancedCol.sort === true || enhancedCol.sorter === true;
if (isSortable) {
enhancedCol.sorter = true;
enhancedCol.sortOrder =
internalSort.value.field === enhancedCol.dataIndex
? internalSort.value.order
: false;
}
// 处理 merge 配置
if (col.merge && col.dataIndex) {
const baseCol = {
...col,
customCell: createMergeCellHandler(col)
};
if (enhancedCol.slots?.customRender) {
const { slots, ...restCol } = enhancedCol;
return {
...restCol,
customCell: createMergeCellHandler(col),
_slotName: slots.customRender,
...(props.enableEllipsis ? { ellipsis: true } : {})
};
}
// 没有 slots 时,返回包含 customCell 的列配置
return {
...enhancedCol,
customCell: createMergeCellHandler(col),
...(props.enableEllipsis ? { ellipsis: true } : {})
};
}
// 如果有 slots.customRender移除 slots
if (enhancedCol.slots?.customRender) {
const { slots, ...restCol } = enhancedCol;
return {
...restCol,
_slotName: slots.customRender,
...(props.enableEllipsis ? { ellipsis: true } : {})
};
}
// ✅ 修改:为需要 ellipsis 的列添加 customRender使用 Tooltip
if (props.enableEllipsis && !enhancedCol.customRender) {
return {
...enhancedCol,
ellipsis: true,
customRender: ({ text }: { text: any }) => {
// 如果 text 是空值,直接返回占位符
if (
text === null ||
text === undefined ||
text === '' ||
text === '-'
) {
return props.emptyPlaceholder;
}
// 使用 h 函数创建 Tooltip
return h(
'div',
{
style: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
},
h(
Tooltip,
{
title: String(text)
},
{
default: () =>
h(
'span',
{
style: {
display: 'inline-block',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
},
String(text)
)
}
)
);
}
};
}
// 如果已有 customRender 函数,但需要添加 ellipsis
if (enhancedCol.customRender) {
return {
...enhancedCol,
...(props.enableEllipsis && !enhancedCol.ellipsis
? { ellipsis: true }
: {})
};
}
// 普通列
if (!props.enableEllipsis) return enhancedCol;
return {
...enhancedCol,
ellipsis: true
};
});
});
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;
}
});
// 监听 data prop 变化
watch(
() => props.data,
newData => {
if (newData && newData.length > 0) {
tableData.value = processData(newData);
total.value = newData.length;
// 数据更新后,默认高亮第一条数据(仅在启用行高亮时)
if (props.enableRowHighlight) {
clickedRowKey.value = tableData.value[0][props.rowKey];
}
} else {
tableData.value = [];
total.value = 0;
clickedRowKey.value = null;
}
},
{ deep: true }
);
</script>
<style scoped lang="scss">
// 优化合并单元格的边框样式
::v-deep(.ant-table-cell) {
vertical-align: middle;
}
::v-deep(.ant-table-tbody > tr > td) {
border-bottom: 1px solid #f0f0f0;
}
// 行点击高亮样式(仅当 enableRowHighlight 为 true 时生效)
::v-deep(.ant-table-tbody > tr.row-clicked > td) {
background-color: v-bind(
'props.enableRowHighlight ? "#e6f7ff" : "#ffffff"'
) !important;
}
:deep(.ant-pagination) {
.ant-select {
width: 100px;
}
}
</style>