forked from mengyxu/noob-components
4 changed files with 97 additions and 833 deletions
@ -0,0 +1,95 @@ |
|||||||
|
# Code review |
||||||
|
|
||||||
|
Found 4 urgent issues need to be fixed: |
||||||
|
|
||||||
|
## 1 Dimension props regressed, so height/maxHeight no longer follow the old contract |
||||||
|
|
||||||
|
FilePath: packages/base/data/list-table-v2/list-table-v2.vue:291 line 291 |
||||||
|
|
||||||
|
if (props.height !== undefined) { |
||||||
|
const h = String(props.height); |
||||||
|
style.maxHeight = h.endsWith("px") ? h : `${h}px`; |
||||||
|
} |
||||||
|
// props.maxHeight handling is commented out |
||||||
|
|
||||||
|
height is being applied as maxHeight, maxHeight is ignored entirely, and minHeight is unused. The old component and the |
||||||
|
demo page both rely on these props behaving distinctly, so PR2 currently breaks the documented sizing behavior. |
||||||
|
|
||||||
|
### Suggested fix |
||||||
|
|
||||||
|
Restore the old container contract: set style.height from props.height, apply style.maxHeight from props.maxHeight, honor |
||||||
|
props.minHeight, and keep viewportHeight derived from the actual constrained container size. |
||||||
|
|
||||||
|
——— |
||||||
|
|
||||||
|
## 2 rowKey is exposed but never used, so virtual rows are keyed by visible index |
||||||
|
|
||||||
|
FilePath: packages/base/data/list-table-v2/list-table-v2.vue:32 line 32 |
||||||
|
|
||||||
|
<div |
||||||
|
v-for="row in visibleRows" |
||||||
|
:key="row.index" |
||||||
|
class="table-row" |
||||||
|
|
||||||
|
This throws away stable row identity. On page changes, filtering, or reordered data, Vue will reuse row subtrees by index |
||||||
|
instead of by record identity, which is exactly what rowKey is supposed to prevent. |
||||||
|
|
||||||
|
### Suggested fix |
||||||
|
|
||||||
|
Derive a stable key from the actual row, for example getRow(row.index)?.[props.rowKey] ?? row.index, and use that for the |
||||||
|
row wrapper. |
||||||
|
|
||||||
|
——— |
||||||
|
|
||||||
|
## 3 Pretext width calculation is measuring different text/fonts than the component actually renders |
||||||
|
|
||||||
|
FilePath: packages/base/data/list-table-v2/usePretextColumnWidths.ts:198 line 198 |
||||||
|
|
||||||
|
const font = options?.font ?? DEFAULT_FONT; |
||||||
|
const headerFont = options?.headerFont ?? DEFAULT_HEADER_FONT; |
||||||
|
... |
||||||
|
const headerText = col.name || col.i18n || colKey; |
||||||
|
|
||||||
|
The component renders body text with props.font and now defaults to 14px Microsoft YaHei, but list-table-v2.vue still |
||||||
|
calls this hook with hardcoded Inter fonts. On top of that, header sizing uses the raw i18n key instead of the translated |
||||||
|
label. That means the measured widths diverge from the rendered widths, which directly undermines PR2’s “column widths |
||||||
|
adapt to content” acceptance criterion. |
||||||
|
|
||||||
|
### Suggested fix |
||||||
|
|
||||||
|
Pass the real rendered font into usePretextColumnWidths from packages/base/data/list-table-v2/list-table-v2.vue:241, |
||||||
|
derive the header font from that same base font, and measure the translated header text instead of col.i18n. |
||||||
|
|
||||||
|
——— |
||||||
|
|
||||||
|
## 4 The rewrite dropped timestampFormat from the public API |
||||||
|
|
||||||
|
FilePath: packages/base/data/list-table-v2/list-table-v2.vue:110 line 110 |
||||||
|
|
||||||
|
interface Props { |
||||||
|
... |
||||||
|
border?: boolean; |
||||||
|
rowHeight?: number; |
||||||
|
estimatedRowHeight?: number; |
||||||
|
headerHeight?: number; |
||||||
|
font?: string; |
||||||
|
debug?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
The old component exposed timestampFormat, but the new runtime props and exported ListTableProps no longer include it, |
||||||
|
while timestamp rendering is still part of the component contract. That is a backward-compatibility break against the PRD |
||||||
|
goal of keeping the existing prop interface. |
||||||
|
|
||||||
|
### Suggested fix |
||||||
|
|
||||||
|
Re-add timestampFormat to both the runtime props and packages/base/data/list-table-v2/types.ts:87, then thread it into the |
||||||
|
timestamp formatting path so existing consumers keep their display override. |
||||||
|
|
||||||
|
——— |
||||||
|
|
||||||
|
Would you like me to use the Suggested fix section to address these issues? |
||||||
|
|
||||||
|
|
||||||
|
# Response |
||||||
|
› 4 is intentional, 1/2/3 should be fixed. However, there is an additional problem: when using custom slots like the table |
||||||
|
11 in the demo page, the inner elements consistently overflows the table cell even with overflow: hidden... |
||||||
@ -1,831 +0,0 @@ |
|||||||
<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 --> |
|
||||||
<!-- Hidden via display:none when off-screen to save GPU/CPU --> |
|
||||||
<div |
|
||||||
v-if="shouldUseProbeRow" |
|
||||||
ref="miniTableRef" |
|
||||||
class="mini-table" |
|
||||||
:class="{ 'is-hidden': !isInViewport }" |
|
||||||
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" |
|
||||||
:fixed="hasFixedColumns" |
|
||||||
: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; |
|
||||||
|
|
||||||
// Track if table is visible in viewport - only probe when visible |
|
||||||
const isInViewport = ref(false); |
|
||||||
let viewportObserver: IntersectionObserver | null = null; |
|
||||||
|
|
||||||
// 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; |
|
||||||
}); |
|
||||||
|
|
||||||
// Check if any column is fixed - used to set table's fixed prop |
|
||||||
const hasFixedColumns = computed(() => { |
|
||||||
return prop.columns.some((col) => col.fixed === "left" || col.fixed === "right" || col.fixed === true); |
|
||||||
}); |
|
||||||
|
|
||||||
// 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 AND table is visible |
|
||||||
const handleResize = lodash.debounce(async () => { |
|
||||||
// Skip if not using probe, no data, or not visible in viewport |
|
||||||
if (!shouldUseProbeRow.value || miniTableData.value.length === 0 || !isInViewport.value) { |
|
||||||
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; |
|
||||||
}); |
|
||||||
}, 50); |
|
||||||
|
|
||||||
// ResizeObserver for mini-table height changes |
|
||||||
miniTableResizeObserver = new ResizeObserver(() => { |
|
||||||
handleResize(); |
|
||||||
}); |
|
||||||
|
|
||||||
// IntersectionObserver to track viewport visibility |
|
||||||
// Only attach ResizeObserver when table is visible, detach when off-screen |
|
||||||
// This saves CPU for tables scrolled out of view |
|
||||||
viewportObserver = new IntersectionObserver( |
|
||||||
async (entries) => { |
|
||||||
const entry = entries[0]; |
|
||||||
const wasIntersecting = isInViewport.value; |
|
||||||
isInViewport.value = entry.isIntersecting; |
|
||||||
|
|
||||||
if (entry.isIntersecting) { |
|
||||||
// Table became visible - mini-table display:none is removed by class binding |
|
||||||
// Wait for Vue to update DOM + browser to render before reconnecting |
|
||||||
await nextTick(); |
|
||||||
await new Promise((resolve) => requestAnimationFrame(resolve)); |
|
||||||
|
|
||||||
if (miniTableRef.value && miniTableResizeObserver) { |
|
||||||
miniTableResizeObserver.observe(miniTableRef.value); |
|
||||||
} |
|
||||||
|
|
||||||
// If transitioning from off-screen to visible, trigger immediate remeasure |
|
||||||
// in case height changed while off-screen |
|
||||||
if (!wasIntersecting && !lodash.isNil(lastMiniTableHeight) && lastMiniTableHeight > 0) { |
|
||||||
handleResize(); |
|
||||||
} |
|
||||||
} else { |
|
||||||
// Table went off-screen - mini-table gets display:none via class binding |
|
||||||
// Disconnect ResizeObserver to save CPU |
|
||||||
if (miniTableResizeObserver) { |
|
||||||
miniTableResizeObserver.disconnect(); |
|
||||||
} |
|
||||||
} |
|
||||||
}, |
|
||||||
{ threshold: 0 } // Trigger as soon as any part is visible |
|
||||||
); |
|
||||||
|
|
||||||
if (myTableRef.value) { |
|
||||||
viewportObserver.observe(myTableRef.value); |
|
||||||
} |
|
||||||
|
|
||||||
// Initial observation of mini-table if already in viewport |
|
||||||
if (miniTableRef.value && isInViewport.value && miniTableResizeObserver) { |
|
||||||
miniTableResizeObserver.observe(miniTableRef.value); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
// Cleanup on unmount |
|
||||||
onUnmounted(() => { |
|
||||||
if (miniTableResizeObserver) { |
|
||||||
miniTableResizeObserver.disconnect(); |
|
||||||
miniTableResizeObserver = null; |
|
||||||
} |
|
||||||
if (viewportObserver) { |
|
||||||
viewportObserver.disconnect(); |
|
||||||
viewportObserver = 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> => { |
|
||||||
// Determine if column is fixed (left or right) |
|
||||||
const isFixed = column.fixed === "left" || column.fixed === "right" || column.fixed === true; |
|
||||||
|
|
||||||
// Resolve minWidth to a number |
|
||||||
const resolvedMinWidth = match(column.minWidth) |
|
||||||
.with(P.number, (n) => n) |
|
||||||
.with( |
|
||||||
P.string, |
|
||||||
(str) => !isNaN(parseInt(str)), |
|
||||||
(str) => parseInt(str) |
|
||||||
) |
|
||||||
.otherwise(() => 120); |
|
||||||
|
|
||||||
// Resolve explicit width if provided |
|
||||||
const explicitWidth = match(column.width) |
|
||||||
.with(P.number, (w) => w) |
|
||||||
.with( |
|
||||||
P.string, |
|
||||||
(str) => !isNaN(parseInt(str)), |
|
||||||
(str) => parseInt(str) |
|
||||||
) |
|
||||||
.otherwise(() => undefined); |
|
||||||
|
|
||||||
// Build column config |
|
||||||
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: resolvedMinWidth, |
|
||||||
}; |
|
||||||
|
|
||||||
// Width/flexGrow logic: |
|
||||||
// - Fixed columns: use explicit width or minWidth, NO flexGrow (el-table-v2 fixed sub-tables don't handle flexGrow) |
|
||||||
// - Non-fixed columns with explicit width: use that width |
|
||||||
// - Non-fixed columns without width: use width:120 with flexGrow:1 for auto-distribution |
|
||||||
if (isFixed) { |
|
||||||
// Fixed columns get explicit width or minWidth, no flexGrow |
|
||||||
col.width = explicitWidth !== undefined ? explicitWidth : resolvedMinWidth; |
|
||||||
// No flexGrow for fixed columns |
|
||||||
} else { |
|
||||||
// Non-fixed columns |
|
||||||
if (explicitWidth !== undefined) { |
|
||||||
col.width = explicitWidth; |
|
||||||
} else { |
|
||||||
// Auto-distribute with flexGrow |
|
||||||
col.width = 120; |
|
||||||
col.flexGrow = 1; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Cell renderer - uses renderCellContent which handles slot, cellRenderer, and built-in types |
|
||||||
col.cellRenderer = (params: CellRendererParams<T>) => renderCellContent(params, false); |
|
||||||
|
|
||||||
// Header cell renderer - use custom headerCellRenderer if provided |
|
||||||
col.headerCellRenderer = (params: HeaderCellRendererParams<T>) => renderHeaderCellContent(params, false); |
|
||||||
|
|
||||||
col._listTableColumn = column; |
|
||||||
return col; |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
const MiniTableCell = (params: CellRendererParams<T>) => renderCellContent(params, true); |
|
||||||
const MiniTableHeader = (params: HeaderCellRendererParams<T>) => renderHeaderCellContent(params, true); |
|
||||||
|
|
||||||
// Shared cell renderer - used by both el-table-v2 and mini table for consistent rendering |
|
||||||
const renderCellContent = (params: CellRendererParams<T>, isMiniTable = false) => { |
|
||||||
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={isMiniTable ? "mini-cell-text" : "table-cell-text"}>{formatterByDist(column.dict, cellData)}</span> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
// Handle formatting |
|
||||||
const formatted = formatCellValue(cellData, column, rowData); |
|
||||||
return <span class={isMiniTable ? "mini-cell-text" : "table-cell-text"}>{formatted}</span>; |
|
||||||
}; |
|
||||||
|
|
||||||
const renderHeaderCellContent = (params: HeaderCellRendererParams<T>, isMiniTable = false) => { |
|
||||||
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={isMiniTable ? "mini-header-cell-text" : "table-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); |
|
||||||
} |
|
||||||
|
|
||||||
// When off-screen, set display:none to remove from layout entirely (GPU optimization) |
|
||||||
.mini-table.is-hidden { |
|
||||||
display: none; |
|
||||||
} |
|
||||||
|
|
||||||
.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; |
|
||||||
} |
|
||||||
|
|
||||||
// CSS for real table cells (used by el-table-v2) |
|
||||||
.table-cell-text { |
|
||||||
display: block; |
|
||||||
width: 100%; |
|
||||||
overflow: hidden; |
|
||||||
text-overflow: ellipsis; |
|
||||||
white-space: nowrap; |
|
||||||
box-sizing: border-box; |
|
||||||
} |
|
||||||
|
|
||||||
.table-header-cell-text { |
|
||||||
display: block; |
|
||||||
width: 100%; |
|
||||||
overflow: hidden; |
|
||||||
text-overflow: ellipsis; |
|
||||||
white-space: nowrap; |
|
||||||
box-sizing: border-box; |
|
||||||
} |
|
||||||
|
|
||||||
.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> |
|
||||||
Loading…
Reference in new issue