forked from mengyxu/noob-components
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.
547 lines
16 KiB
547 lines
16 KiB
<template> |
|
<div class="list-table-v2" :style="containerStyle"> |
|
<!-- Probe row for measuring actual row height (only when using dynamic height mode) --> |
|
<div v-if="shouldUseProbeRow" ref="probeRowRef" class="probe-row" aria-hidden="true"> |
|
<div v-if="probeRowData" class="probe-cell"> |
|
<template v-for="item in prop.columns || []" :key="item.key"> |
|
<span class="probe-content"> |
|
{{ getProbeCellText(probeRowData, item) }} |
|
</span> |
|
</template> |
|
</div> |
|
</div> |
|
|
|
<div 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="prop.headerHeight" |
|
:estimated-row-height="estimatedRowHeightToUse" |
|
: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> |
|
import { useStore } from "vuex"; |
|
import { ref, computed, watch, h, onMounted, onUpdated, onUnmounted, useSlots, renderSlot, nextTick } from "vue"; |
|
import { useI18n } from "vue3-i18n"; |
|
import { ElAutoResizer, ElTableV2 } from "element-plus"; |
|
import * as lodash from "lodash-es"; |
|
import TzDateTime from "../item/tzDateTime.vue"; |
|
|
|
const slots = useSlots(); |
|
|
|
const { t } = useI18n(); |
|
const { state } = useStore(); |
|
const table = ref(); |
|
const probeRowRef = ref(); |
|
const probeRowData = ref(); |
|
const estimatedRowHeight = ref<number | undefined>(undefined); |
|
|
|
// Debounced resize handler for re-measuring probe row |
|
// Use LodashDebounce for proper cancel() method typing |
|
const debouncedResizeHandler = ref<lodash.DebouncedFunc<() => void> | undefined>(undefined); |
|
|
|
// Header height constant |
|
|
|
type TimestampValue = |
|
| undefined |
|
| boolean |
|
| string |
|
| { |
|
valueFormat?: string; |
|
valueTz?: string; |
|
displayFormat?: string; |
|
locale?: string; |
|
type?: "iso8601" | "unix" | "unixMillis"; |
|
}; |
|
|
|
interface TzDateTimeProps { |
|
value: string | number; |
|
valueFormat?: string; |
|
valueTz?: string; |
|
displayFormat?: string; |
|
locale?: string; |
|
type?: "iso8601" | "unix" | "unixMillis"; |
|
slot?: boolean; |
|
} |
|
|
|
interface TzDateTimeConfig { |
|
valueFormat?: string; |
|
valueTz?: string; |
|
displayFormat?: string; |
|
locale?: string; |
|
type?: "iso8601" | "unix" | "unixMillis"; |
|
} |
|
|
|
interface TableColumn { |
|
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; |
|
|
|
[others: string]: any; |
|
} |
|
|
|
interface Props { |
|
data?: any; |
|
columns?: TableColumn[]; |
|
page?: boolean; |
|
height?: 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; |
|
} |
|
|
|
const prop = withDefaults(defineProps<Props>(), { |
|
data: () => [], |
|
columns: () => [], |
|
page: false, |
|
height: undefined, |
|
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 []; |
|
}); |
|
|
|
// Probe row text extraction (same logic as cellRenderer but returns text only) |
|
const getProbeCellText = (rowData: any, item: TableColumn): string => { |
|
const value = lodash.get(rowData, item.dataKey || item.key); |
|
|
|
if (item.dict) return formatterByDist(item.dict, value); |
|
if (item.timestamp) return formatStamp(value); |
|
if (item.filesize) return formatFileSize(value); |
|
if (rowData.scheme) return formatterByDist(rowData.scheme + "_" + (item.dataKey || item.key), value); |
|
return getValue(value); |
|
}; |
|
|
|
// 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; |
|
}); |
|
|
|
// The estimated row height to pass to el-table-v2 |
|
// Only used when rowHeight is NOT set (dynamic height mode) |
|
const estimatedRowHeightToUse = 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 |
|
}); |
|
|
|
// Measure probe row height 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?.[0], |
|
async (firstRow) => { |
|
// Skip if rowHeight is set (fixed height mode) or already have a value |
|
if (!shouldUseProbeRow.value || !firstRow) { |
|
return; |
|
} |
|
probeRowData.value = firstRow; |
|
// Wait for probe to render |
|
await nextTick(); |
|
// Wait TWO frames for el-table-v2 to fully settle its layout |
|
await new Promise((resolve) => requestAnimationFrame(resolve)); |
|
await new Promise((resolve) => requestAnimationFrame(resolve)); |
|
const height = probeRowRef.value?.offsetHeight; |
|
if (height && height > 0) { |
|
estimatedRowHeight.value = height; |
|
} |
|
}, |
|
{ immediate: true } |
|
); |
|
|
|
// Setup resize listener on mount |
|
onMounted(() => { |
|
// Create debounced handler |
|
debouncedResizeHandler.value = lodash.debounce(() => { |
|
// Only re-measure in dynamic height mode |
|
if (shouldUseProbeRow.value) { |
|
measureProbeRow(); |
|
} |
|
}, 250); // 250ms debounce delay |
|
|
|
// Attach window resize listener |
|
window.addEventListener("resize", debouncedResizeHandler.value); |
|
}); |
|
|
|
// Cleanup on unmount |
|
onUnmounted(() => { |
|
if (debouncedResizeHandler.value) { |
|
window.removeEventListener("resize", debouncedResizeHandler.value); |
|
debouncedResizeHandler.value.cancel(); // Cancel any pending debounce calls |
|
} |
|
}); |
|
|
|
// Function to re-measure probe row height (for resize handling) |
|
const measureProbeRow = async () => { |
|
if (!shouldUseProbeRow.value || !probeRowData.value) { |
|
return; |
|
} |
|
// Clear previous measurement so el-table-v2 recalculates |
|
estimatedRowHeight.value = undefined; |
|
await nextTick(); |
|
// Wait TWO frames for el-table-v2 to fully settle its layout |
|
await new Promise((resolve) => requestAnimationFrame(resolve)); |
|
await new Promise((resolve) => requestAnimationFrame(resolve)); |
|
const height = probeRowRef.value?.offsetHeight; |
|
if (height && height > 0) { |
|
estimatedRowHeight.value = height; |
|
} |
|
}; |
|
|
|
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, value: any) => { |
|
if (!dictKey) { |
|
return getValue(value); |
|
} |
|
const mapping = state.dict[dictKey]; |
|
if (mapping == null) { |
|
return getValue(value); |
|
} |
|
return mapping[value] == null ? value : mapping[value]; |
|
}; |
|
|
|
const formatCellValue = (value: any, item: TableColumn, row: any) => { |
|
if (item.dict) return formatterByDist(item.dict, value); |
|
if (item.timestamp) return formatStamp(value); |
|
if (item.filesize) return formatFileSize(value); |
|
if (row.scheme) return formatterByDist(row.scheme + "_" + (item.dataKey || item.key), value); |
|
return getValue(value); |
|
}; |
|
|
|
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((item: TableColumn) => { |
|
const col: any = { |
|
key: item.key, |
|
title: item.name || (item.i18n ? t(item.i18n) : item.key), |
|
dataKey: item.dataKey || item.key, |
|
align: item.align || "center", |
|
fixed: item.fixed, |
|
}; |
|
|
|
// If width is explicitly provided, use it; otherwise use flexGrow to auto-distribute |
|
if (item.width !== undefined) { |
|
col.width = typeof item.width === "number" ? item.width : parseInt(String(item.width)) || 120; |
|
} else { |
|
// Use flexGrow: 1 to auto-expand columns to fill available width |
|
col.flexGrow = 1; |
|
col.width = 120; // minimum width |
|
} |
|
|
|
if (item.minWidth !== undefined) { |
|
col.minWidth = typeof item.minWidth === "number" ? item.minWidth : parseInt(String(item.minWidth)) || 120; |
|
} |
|
|
|
// Cell renderer - el-table-v2 uses cellRenderer function |
|
col.cellRenderer = ({ cellData, rowData }: { cellData: any; rowData: any }) => { |
|
const slotName = item.key; |
|
|
|
// If column has slot=true, render the parent's slot content |
|
if (item.slot && slots[slotName]) { |
|
return renderSlot(slots, slotName, { row: rowData }); |
|
} |
|
|
|
const value = lodash.get(rowData, item.dataKey || item.key); |
|
|
|
// Handle timestamp display using TzDateTime component |
|
if (item.timestamp) { |
|
const tzProps = resolveTimestampProps(item.timestamp); |
|
if (tzProps) { |
|
const { valueFormat, valueTz, displayFormat, locale, type } = tzProps; |
|
return ( |
|
<TzDateTime |
|
value={value} |
|
valueFormat={valueFormat} |
|
valueTz={valueTz} |
|
displayFormat={displayFormat} |
|
locale={locale} |
|
type={type} |
|
/> |
|
); |
|
} |
|
} |
|
|
|
// Handle dict display |
|
if (item.dict) { |
|
return <span>{formatterByDist(item.dict, value)}</span>; |
|
} |
|
|
|
// Handle formatting |
|
const formatted = formatCellValue(value, item, rowData); |
|
return <span>{formatted}</span>; |
|
}; |
|
|
|
return col; |
|
}); |
|
}); |
|
</script> |
|
|
|
<style lang="scss" scoped> |
|
.list-table-v2 { |
|
display: flex; |
|
flex-direction: column; |
|
flex: 1; |
|
overflow: hidden; |
|
position: relative; |
|
} |
|
|
|
// Probe row for measuring actual row height |
|
.probe-row { |
|
position: absolute; |
|
visibility: hidden; |
|
pointer-events: none; |
|
left: -9999px; |
|
top: 0; |
|
} |
|
|
|
.probe-cell { |
|
display: flex; |
|
align-items: center; |
|
padding: v-bind("state.size.tablePad"); |
|
border-right: var(--el-table-border); |
|
background: v-bind("state.style.tableBg"); |
|
color: v-bind("state.style.tableColor"); |
|
white-space: nowrap; |
|
} |
|
|
|
.probe-content { |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
} |
|
|
|
.my-table { |
|
flex: 1; |
|
width: 100%; |
|
min-height: 0; // Important for flex child to shrink properly |
|
display: flex; |
|
flex-direction: column; |
|
overflow: hidden; |
|
} |
|
|
|
.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>
|
|
|