基于vue3.0和element-plus的组件库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

719 lines
22 KiB

<template>
<div class="list-table-v2" :style="containerStyle">
<!-- Mini hidden table for measuring row height and header height (only when using dynamic height mode) -->
<!-- This mirrors the real table's cell rendering for accurate height estimation -->
<div v-if="shouldUseProbeRow" ref="miniTableRef" class="mini-table" aria-hidden="true">
<div class="mini-table-inner">
<!-- Mini header row -->
<div ref="miniHeaderRef" class="mini-row mini-header">
<div
v-for="(column, columnIndex) in tableColumns || []"
:key="column.key"
class="mini-cell mini-header-cell"
:style="getMiniCellStyle(column)"
>
<MiniTableHeader :cellData="undefined as T" :columnIndex :columns="tableColumns" :column />
</div>
</div>
<!-- Mini data rows -->
<div v-if="miniTableData.length > 0" ref="miniTableRowsRef" class="mini-table-rows">
<div v-for="(rowData, rowIndex) in miniTableData" :key="rowIndex" class="mini-row">
<div
v-for="(column, columnIndex) in tableColumns || []"
:key="column.key"
class="mini-cell"
:style="getMiniCellStyle(column)"
>
<MiniTableCell
:cellData="lodash.get(rowData, String(column.dataKey || column.key))"
:columnIndex
:columns="tableColumns"
:column
:rowData
:rowIndex
/>
</div>
</div>
</div>
</div>
</div>
<div v-if="debug" class="debug-info">
<div>Estimated Row Height: {{ resolvedRowHeight }}</div>
<div>Estimated Header Height: {{ resolvedHeaderHeight }}</div>
</div>
<div ref="myTableRef" class="my-table">
<el-auto-resizer>
<template #default="{ height, width }">
<el-table-v2
ref="table"
:columns="tableColumns"
:data="pageData"
:width="width"
:height="resolvedHeight(height)"
:row-height="prop.rowHeight"
:header-height="resolvedHeaderHeight"
:estimated-row-height="resolvedRowHeight"
:border="border"
:row-key="rowKey"
:class="border ? 'has-border' : ''"
@scroll="onScroll"
@row-click="onRowClick"
@cell-click="onCellClick"
/>
</template>
</el-auto-resizer>
</div>
<div v-if="page" class="my-pagination">
<el-pagination
:small="state.size.size == 'small'"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="example.page"
:page-sizes="[10, 20, 50, 100, 200]"
:page-size="example.size"
layout="total, sizes, prev, pager, next, jumper"
:total="data.total"
/>
</div>
</div>
</template>
<script lang="tsx" setup generic="T">
import { useStore } from "vuex";
import {
ref,
computed,
watch,
h,
onMounted,
onUpdated,
onUnmounted,
useSlots,
renderSlot,
nextTick,
VNode,
StyleValue,
useTemplateRef,
} from "vue";
import { useI18n } from "vue3-i18n";
import { Column, ElAutoResizer, ElTableV2 } from "element-plus";
import * as lodash from "lodash-es";
import TzDateTime from "../item/tzDateTime.vue";
import {
CellRenderer,
CellRendererParams,
HeaderCellRenderer,
HeaderCellRendererParams,
} from "element-plus/es/components/table-v2/src/types.mjs";
import { match, P } from "ts-pattern";
import { FixedDir } from "element-plus/es/components/table-v2/src/constants.mjs";
const slots = useSlots();
const { t } = useI18n();
const { state } = useStore();
const miniTableRef = useTemplateRef("miniTableRef");
const miniHeaderRef = useTemplateRef("miniHeaderRef");
const miniTableRowsRef = useTemplateRef("miniTableRowsRef");
const myTableRef = useTemplateRef("myTableRef"); // Reference to .my-table for ResizeObserver
const miniTableData = ref<any[]>([]);
const estimatedRowHeight = ref<number | undefined>(undefined);
const estimatedHeaderHeight = ref<number | undefined>(undefined);
let miniTableResizeObserver: ResizeObserver | null = null;
let lastMiniTableHeight = 0;
// Header height constant
type TimestampValue =
| undefined
| boolean
| string
| {
valueFormat?: string;
valueTz?: string;
displayFormat?: string;
locale?: string;
type?: "iso8601" | "unix" | "unixMillis";
};
interface TzDateTimeConfig {
valueFormat?: string;
valueTz?: string;
displayFormat?: string;
locale?: string;
type?: "iso8601" | "unix" | "unixMillis";
}
interface ListTableColumn<T> {
key: string;
dataKey?: string;
name?: string;
i18n?: string;
type?: string;
width?: string | number;
minWidth?: string | number;
fixed?: boolean | "left" | "right";
align?: "left" | "center" | "right";
slot?: boolean;
dict?: string;
timestamp?: TimestampValue;
filesize?: boolean;
// Custom renderers - JSX-returning functions that override slot/default rendering
cellRenderer?: CellRenderer<T>;
headerCellRenderer?: HeaderCellRenderer<T>;
[others: string]: any;
}
interface Props {
data?: any;
columns?: ListTableColumn<T>[];
page?: boolean;
height?: number | string;
minHeight?: number | string;
maxHeight?: number | string;
example?: any;
rowKey?: string;
selectKey?: string;
treeProps?: any;
lazy?: boolean;
border?: boolean;
timestampFormat?: string;
rowHeight?: number;
estimatedRowHeight?: number;
headerHeight?: number;
debug?: boolean;
}
const prop = withDefaults(defineProps<Props>(), {
data: () => [],
columns: () => [],
page: false,
height: undefined,
minHeight: 300,
maxHeight: undefined,
example: () => ({}),
rowKey: "id",
selectKey: undefined,
treeProps: undefined,
lazy: false,
border: false,
timestampFormat: "YYYY-MM-DD HH:mm:ss",
rowHeight: undefined,
estimatedRowHeight: undefined,
headerHeight: undefined,
});
const emit = defineEmits(["query", "selection-change", "row-click", "cell-click"]);
// Pagination data - use data.data if page response, otherwise data array
const pageData = computed(() => {
if (!prop.data) return [];
if (prop.page && prop.data.data) {
return prop.data.data;
}
if (Array.isArray(prop.data)) {
return prop.data;
}
return [];
});
// Whether to use probe row for dynamic height measurement
// Only use probe row when rowHeight is NOT explicitly specified
const shouldUseProbeRow = computed(() => {
return prop.rowHeight === undefined && prop.estimatedRowHeight === undefined;
});
// The estimated row height to pass to el-table-v2
// Only used when rowHeight is NOT set (dynamic height mode)
const resolvedRowHeight = computed(() => {
if (prop.rowHeight !== undefined) return undefined; // fixed height mode, don't use estimated
if (prop.estimatedRowHeight !== undefined) return prop.estimatedRowHeight; // user provided estimate
return estimatedRowHeight.value; // use probe-measured value
});
// The header height to pass to el-table-v2
// Only use measured header height when prop.headerHeight is NOT explicitly set
const resolvedHeaderHeight = computed(() => {
if (prop.headerHeight !== undefined) return prop.headerHeight; // user provided, use it
return estimatedHeaderHeight.value; // use mini-table measured header height
});
// Measure mini table row and header heights when data changes (only when using probe row)
// We use TWO frames delay to let el-table-v2 fully settle before measuring
watch(
() => pageData.value,
async (data) => {
// Skip if rowHeight is set (fixed height mode) or no data
if (!shouldUseProbeRow.value || !data || data.length === 0) {
return;
}
// Use up to 3 rows for mini table
miniTableData.value = lodash.sampleSize(data, 3);
// Wait for mini table to render
await nextTick();
// Wait TWO frames for layout to settle
await new Promise((resolve) => requestAnimationFrame(resolve));
await new Promise((resolve) => requestAnimationFrame(resolve));
const { headerHeight, rowHeight } = estimateTableRowHeight();
if (headerHeight && headerHeight > 0) {
estimatedHeaderHeight.value = headerHeight;
}
if (rowHeight && rowHeight > 0) {
estimatedRowHeight.value = rowHeight;
}
// Update last known mini-table height for ResizeObserver comparison
lastMiniTableHeight = miniTableRef.value?.offsetHeight || 0;
},
{ immediate: true }
);
function estimateTableRowHeight() {
const headerHeight = miniHeaderRef.value?.offsetHeight;
const tableRowsHeight = miniTableRowsRef.value?.offsetHeight;
const rowHeight =
tableRowsHeight && tableRowsHeight > 0 && miniTableData.value.length > 0
? lodash.round(tableRowsHeight / miniTableData.value.length)
: undefined;
return { headerHeight, rowHeight };
}
// Setup ResizeObserver on mount
// Observe miniTableRef instead of myTableRef - only re-probe if mini-table's rendered height changes
// This avoids unnecessary re-probing during window resize when container width changes but row heights stay same
onMounted(() => {
// Resize handler - only re-probe if the mini-table's actual height changed
const handleResize = lodash.debounce(async () => {
if (!shouldUseProbeRow.value || miniTableData.value.length === 0) {
return;
}
// Check if mini-table height actually changed (e.g., due to text wrapping)
const currentHeight = miniTableRef.value?.offsetHeight || 0;
if (currentHeight === lastMiniTableHeight) {
return; // Height unchanged, no need to re-probe
}
lastMiniTableHeight = currentHeight;
await nextTick();
await new Promise((resolve) => requestAnimationFrame(resolve));
await new Promise((resolve) => requestAnimationFrame(resolve));
// Measure first while old values still set
const { headerHeight, rowHeight } = estimateTableRowHeight();
const newHeader = headerHeight && headerHeight > 0 ? headerHeight : estimatedHeaderHeight.value;
const newRow = rowHeight && rowHeight > 0 ? rowHeight : estimatedRowHeight.value;
// Clear then set in same microtask to minimize gap
estimatedRowHeight.value = undefined;
estimatedHeaderHeight.value = undefined;
queueMicrotask(() => {
estimatedRowHeight.value = newRow;
estimatedHeaderHeight.value = newHeader;
});
}, 200);
// Observe miniTableRef for height changes
// The mini-table's height only changes when column widths cause text to wrap
// During typical window resize with fixed-height content, height stays same → no re-probe
miniTableResizeObserver = new ResizeObserver(() => {
handleResize();
});
if (miniTableRef.value) {
miniTableResizeObserver.observe(miniTableRef.value);
}
});
// Cleanup on unmount
onUnmounted(() => {
if (miniTableResizeObserver) {
miniTableResizeObserver.disconnect();
miniTableResizeObserver = null;
}
});
const containerStyle = computed(() => {
const style: Record<string, string> = {};
if (prop.height !== undefined) {
style.height = typeof prop.height === "number" ? `${prop.height}px` : String(prop.height);
} else {
style.height = "100%";
}
if (prop.maxHeight !== undefined) {
style.maxHeight = typeof prop.maxHeight === "number" ? `${prop.maxHeight}px` : String(prop.maxHeight);
}
return style;
});
// Use explicit height if provided, otherwise use auto from auto-resizer
const resolvedHeight = (autoHeight: number) => {
if (prop.height !== undefined) {
return typeof prop.height === "number" ? prop.height : parseInt(String(prop.height));
}
return autoHeight;
};
const getValue = (value: any) => {
if ((typeof value === "undefined" || value === null || value === "") && value !== 0) {
return "--";
}
return value;
};
const formatStamp = (value: any) => {
const date = new Date(value * 1000);
const month = date.getMonth() < 9 ? "0" + (date.getMonth() + 1) : date.getMonth() + 1;
const day = date.getDate() < 10 ? "0" + date.getDate() : date.getDate();
const hour = date.getHours() < 10 ? "0" + date.getHours() : date.getHours();
const minute = date.getMinutes() < 10 ? "0" + date.getMinutes() : date.getMinutes();
const second = date.getSeconds() < 10 ? "0" + date.getSeconds() : date.getSeconds();
return date.getFullYear() + "-" + month + "-" + day + " " + hour + ":" + minute + ":" + second;
};
const formatFileSize = (value: any) => {
const k = value / 1024;
if (k < 1) {
return value + "B";
}
const m = k / 1024;
if (m < 1) {
return k.toFixed(2) + "K";
}
const g = m / 1024;
if (g < 1) {
return m.toFixed(2) + "M";
}
return g.toFixed(2) + "G";
};
// Helper to resolve timestamp column config to TzDateTime props
const resolveTimestampProps = (ts: TimestampValue): TzDateTimeConfig | null => {
if (!ts) return null;
if (ts === true) return { type: "unixMillis", displayFormat: prop.timestampFormat };
if (typeof ts === "string") {
// If it's 'unix', 'iso8601', or 'unixMillis' treat as type
if (["unix", "iso8601", "unixMillis"].includes(ts)) {
return { type: ts as TzDateTimeConfig["type"], displayFormat: prop.timestampFormat };
}
// Otherwise treat as valueFormat
return { valueFormat: ts, displayFormat: prop.timestampFormat };
}
// For object config, apply default displayFormat if not specified
const config = ts as TzDateTimeConfig;
if (!config.displayFormat) {
config.displayFormat = prop.timestampFormat;
}
return config;
};
const formatterByDist = (dictKey: string, cellData: any) => {
if (!dictKey) {
return getValue(cellData);
}
const mapping = state.dict[dictKey];
if (mapping == null) {
return getValue(cellData);
}
return mapping[cellData] == null ? cellData : mapping[cellData];
};
const formatCellValue = (cellData: T, column: ListTableColumn<T>, rowData: any) => {
if (column.dict) return formatterByDist(column.dict, cellData);
if (column.timestamp) return formatStamp(cellData);
if (column.filesize) return formatFileSize(cellData);
if (rowData.scheme) return formatterByDist(rowData.scheme + "_" + (column.dataKey || column.key), cellData);
return getValue(cellData);
};
const handleSizeChange = (val: number) => {
prop.example.size = val;
emit("query");
};
const handleCurrentChange = (val: number) => {
prop.example.page = val;
emit("query");
};
const onScroll = ({
scrollTop,
}: {
scrollTop: number;
scrollLeft?: number;
horizontal?: boolean;
vertical?: boolean;
}) => {
// Can be used for lazy loading or other scroll-based features
};
const onRowClick = (row: any) => {
emit("row-click", row);
};
const onCellClick = ({ row, column }: { row: any; column: any }) => {
emit("cell-click", row, column, null, null);
};
// Build columns for el-table-v2
const tableColumns = computed(() => {
return prop.columns.map((column): Column<T> => {
const col: Column<T> = {
key: column.key,
title: column.name || (column.i18n ? t(column.i18n) : column.key),
dataKey: column.dataKey || column.key,
align: column.align || "center",
fixed: match(column.fixed)
.with("left", () => FixedDir.LEFT)
.with("right", () => FixedDir.RIGHT)
.with(false, () => undefined)
.otherwise((value) => value),
minWidth: match(column.minWidth)
.with(P.number, (n) => n)
.with(
P.string,
(str) => !isNaN(parseInt(str)),
(str) => parseInt(str)
)
.otherwise(() => 120),
// If width is explicitly provided, use it; otherwise use flexGrow to auto-distribute
...match(column.width)
.with(P.number, (width) => ({ width }))
.with(
P.string,
(str) => !isNaN(parseInt(str)),
(str) => ({ width: parseInt(str) })
)
.otherwise(() => ({ width: 120, flexGrow: 1 })),
};
// Cell renderer - uses renderCellContent which handles slot, cellRenderer, and built-in types
col.cellRenderer = renderCellContent;
// Header cell renderer - use custom headerCellRenderer if provided
col.headerCellRenderer = renderHeaderCellContent;
col._listTableColumn = column;
return col;
});
});
const MiniTableCell = (params: CellRendererParams<T>) => renderCellContent(params);
const MiniTableHeader = (params: HeaderCellRendererParams<T>) => renderHeaderCellContent(params);
// Shared cell renderer - used by both el-table-v2 and mini table for consistent rendering
const renderCellContent = (params: CellRendererParams<T>) => {
const { cellData, rowData, column: elColumn } = params;
const column: ListTableColumn<T> = elColumn._listTableColumn;
const slotName = column.key;
// If custom cellRenderer is provided, use it (highest priority)
if (column.cellRenderer) {
return column.cellRenderer(params);
}
// If column has slot=true, render the parent's slot content
if (column.slot && slots[slotName]) {
return renderSlot(slots, slotName, { row: rowData });
}
// Handle timestamp display using TzDateTime component
if (column.timestamp && (typeof cellData === "string" || typeof cellData === "number")) {
const tzProps = resolveTimestampProps(column.timestamp);
if (tzProps) {
const { valueFormat, valueTz, displayFormat, locale, type } = tzProps;
return (
<TzDateTime
value={cellData}
valueFormat={valueFormat}
valueTz={valueTz}
displayFormat={displayFormat}
locale={locale}
type={type}
/>
);
}
}
// Handle dict display
if (column.dict) {
return <span class="mini-cell-text">{formatterByDist(column.dict, cellData)}</span>;
}
// Handle formatting
const formatted = formatCellValue(cellData, column, rowData);
return <span class="mini-cell-text">{formatted}</span>;
};
const renderHeaderCellContent = (params: HeaderCellRendererParams<T>) => {
const { column: elColumn } = params;
const column: ListTableColumn<T> = elColumn._listTableColumn;
if (column.headerCellRenderer) {
return column.headerCellRenderer(params);
}
const header = match(column)
.with({ name: P.select(P.string) }, (name) => name)
.with({ i18n: P.select(P.string) }, (i18n) => t(i18n))
.otherwise((c) => c.key);
return <span class="mini-header-cell-text">{header}</span>;
};
// Get mini cell style - mirrors the real table's column width/flex distribution
const getMiniCellStyle = ({ width, minWidth, maxWidth, flexGrow, flexShrink }: Column<T>): StyleValue => {
return { width: `${width}px`, minWidth: `${minWidth}px`, maxWidth: `${maxWidth}px`, flexGrow, flexShrink };
};
</script>
<style lang="scss" scoped>
.debug-info {
font-size: 9px;
}
.list-table-v2 {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
position: relative;
}
// Mini hidden table for measuring row height (flex boxes, not el-table-v2)
.mini-table {
position: absolute;
visibility: hidden;
pointer-events: none;
left: -9999px;
top: 0;
width: 100%;
font-size: var(--el-font-size-base);
}
.mini-table-inner {
display: flex;
flex-direction: column;
width: 100%;
}
.mini-table-rows {
display: flex;
flex-direction: column;
flex: 1;
}
.mini-row {
display: flex;
align-items: stretch;
padding: v-bind("state.size.tablePad");
border-right: var(--el-table-border);
border-bottom: var(--el-table-border);
background: v-bind("state.style.tableBg");
color: v-bind("state.style.tableColor");
font-size: var(--el-font-size-base);
box-sizing: border-box;
overflow: hidden;
width: 100%;
min-height: min-content;
}
.mini-header {
background: v-bind("state.style.tableHeadBg");
font-weight: bold;
}
.mini-header-cell {
justify-content: center;
}
.mini-header-cell-text {
display: block;
}
.mini-cell {
display: flex;
flex-direction: row;
align-items: center;
padding: 4px;
box-sizing: border-box;
white-space: normal;
word-break: normal;
overflow: hidden;
}
.mini-cell-text {
display: block;
}
.my-table {
flex: 1;
width: 100%;
min-height: 0; // Important for flex child to shrink properly
display: flex;
flex-direction: column;
overflow: hidden;
position: relative; // For mini-table's containing block
}
.my-table :deep(.el-table-v2) {
--el-table-bg-color: v-bind("state.style.tableBg") !important;
--el-table-tr-bg-color: v-bind("state.style.tableBg") !important;
--el-table-expanded-cell-bg-color: v-bind("state.style.tableBg") !important;
--el-table-border-color: v-bind("state.style.tableBorderColor");
--el-table-text-color: v-bind("state.style.tableColor");
--el-table-header-text-color: v-bind("state.style.tableColor");
--el-table-row-hover-bg-color: v-bind("state.style.tableCurBg");
--el-table-current-row-bg-color: v-bind("state.style.tableCurBg");
--el-table-header-bg-color: v-bind("state.style.tableHeadBg");
--el-bg-color: v-bind("state.style.tableBg") !important;
// Border - el-table-v2 uses --el-table-border
--el-table-border: 1px solid var(--el-table-border-color);
}
.my-table :deep(.el-table-v2__row) {
background: v-bind("state.style.tableBg") !important;
}
.my-table :deep(.el-table-v2__row:hover) {
background: v-bind("state.style.tableCurBg") !important;
}
.my-table :deep(.el-table-v2__header-cell) {
padding: v-bind("state.size.tablePad");
border-right: var(--el-table-border);
color: v-bind("state.style.tableColor");
}
.my-table :deep(.el-table-v2__row-cell) {
padding: v-bind("state.size.tablePad");
border-right: var(--el-table-border);
color: v-bind("state.style.tableColor");
}
// Child row background for tree data
.my-table :deep(.el-table-v2__row[data-level="1"]),
.my-table :deep(.el-table-v2__row[data-level="3"]) {
background: v-bind("state.style.tableChildBg") !important;
}
.my-pagination {
display: flex;
justify-content: center;
padding-top: 5px;
flex-shrink: 0;
}
.my-pagination * {
--el-pagination-bg-color: v-bind("state.style.bodyBg") !important;
--el-pagination-disabled-bg-color: v-bind("state.style.bodyBg") !important;
--el-pagination-text-color: v-bind("state.style.color") !important;
--el-pagination-button-color: v-bind("state.style.color") !important;
--el-pagination-button-disabled-bg-color: v-bind("state.style.bodyBg") !important;
}
</style>