forked from mengyxu/noob-components
3 changed files with 533 additions and 4 deletions
@ -0,0 +1,519 @@ |
|||||||
|
<template> |
||||||
|
<div class="list-table-v2"> |
||||||
|
<!-- Debug info --> |
||||||
|
<div v-if="debug" class="debug-info"> |
||||||
|
<div>Container Width: {{ containerWidth }}</div> |
||||||
|
<div>Total Height: {{ virtualTotalHeight }}</div> |
||||||
|
<div>Visible Rows: {{ visibleRows.length }}</div> |
||||||
|
<div>Total Rows: {{ pageData.length }}</div> |
||||||
|
<div> |
||||||
|
Column configs: |
||||||
|
{{ |
||||||
|
computedConfigs.map((c) => ({ |
||||||
|
key: c.key, |
||||||
|
flexBasis: Math.round(c.flexBasis), |
||||||
|
flexGrow: c.flexGrow.toFixed(1), |
||||||
|
})) |
||||||
|
}} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Main table container with ResizeObserver for width tracking --> |
||||||
|
<div ref="tableContainerRef" class="table-container" :style="tableContainerStyle"> |
||||||
|
<!-- Header row (fixed) --> |
||||||
|
<div class="table-header" :style="{ height: `${resolvedHeaderHeight}px` }"> |
||||||
|
<div v-for="col in visibleColumns" :key="col.key" class="header-cell" :style="getColumnStyle(col)"> |
||||||
|
<slot :name="`header-${col.key}`"> |
||||||
|
<span class="header-cell-text">{{ getHeaderText(col) }}</span> |
||||||
|
</slot> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Scrollable body --> |
||||||
|
<div ref="scrollBodyRef" class="table-body" :style="{ height: `${viewportHeight}px` }" @scroll="handleScroll"> |
||||||
|
<!-- Total height spacer --> |
||||||
|
<div class="table-spacer" :style="{ height: `${virtualTotalHeight}px` }"> |
||||||
|
<!-- Visible rows --> |
||||||
|
<div |
||||||
|
v-for="row in visibleRows" |
||||||
|
:key="row.index" |
||||||
|
class="table-row" |
||||||
|
:style="{ |
||||||
|
transform: `translateY(${row.offsetY}px)`, |
||||||
|
height: `${row.height}px`, |
||||||
|
}" |
||||||
|
@click="handleRowClick(row.index)" |
||||||
|
> |
||||||
|
<div |
||||||
|
v-for="col in visibleColumns" |
||||||
|
:key="col.key" |
||||||
|
class="table-cell" |
||||||
|
:style="getColumnStyle(col)" |
||||||
|
@click="handleCellClick(row.index, col)" |
||||||
|
> |
||||||
|
<slot :name="col.key" :row="getRow(row.index)"> |
||||||
|
<span class="cell-text">{{ getRow(row.index) ? formatCellValue(getRow(row.index)!, col) : '--' }}</span> |
||||||
|
</slot> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Pagination --> |
||||||
|
<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 { ref, computed, watch, onMounted, onUnmounted, toRef, useSlots, h } from "vue"; |
||||||
|
import { useStore } from "vuex"; |
||||||
|
import { useI18n } from "vue3-i18n"; |
||||||
|
import * as lodash from "lodash-es"; |
||||||
|
import type { ListTableColumn, ListTableProps, PageResponse } from "./types"; |
||||||
|
import { usePretextColumnWidths, type ColumnFlexConfig } from "./usePretextColumnWidths"; |
||||||
|
import { usePretextRowHeights, type RowHeightEntry } from "./usePretextRowHeights"; |
||||||
|
import { useVirtualRows } from "./useVirtualRows"; |
||||||
|
|
||||||
|
const slots = useSlots(); |
||||||
|
const { t } = useI18n(); |
||||||
|
const { state } = useStore(); |
||||||
|
|
||||||
|
// ============================================================================= |
||||||
|
// Container refs and dimensions |
||||||
|
// ============================================================================= |
||||||
|
const tableContainerRef = ref<HTMLElement | null>(null); |
||||||
|
const scrollBodyRef = ref<HTMLElement | null>(null); |
||||||
|
const containerWidth = ref(800); |
||||||
|
const viewportHeight = ref(400); |
||||||
|
let resizeObserver: ResizeObserver | null = null; |
||||||
|
|
||||||
|
// ============================================================================= |
||||||
|
// Props - same interface as original list-table-v2.vue |
||||||
|
// ============================================================================= |
||||||
|
interface Props { |
||||||
|
data?: T[] | PageResponse<T>; |
||||||
|
columns?: ListTableColumn<T>[]; |
||||||
|
page?: boolean; |
||||||
|
height?: number | string; |
||||||
|
minHeight?: number | string; |
||||||
|
maxHeight?: number | string; |
||||||
|
example?: { page?: number; size?: number }; |
||||||
|
rowKey?: string; |
||||||
|
selectKey?: string; |
||||||
|
treeProps?: any; |
||||||
|
lazy?: boolean; |
||||||
|
border?: boolean; |
||||||
|
timestampFormat?: string; |
||||||
|
rowHeight?: number; |
||||||
|
estimatedRowHeight?: number; |
||||||
|
headerHeight?: number; |
||||||
|
debug?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
const props = 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: 44, |
||||||
|
debug: false, |
||||||
|
}); |
||||||
|
|
||||||
|
const emit = defineEmits<{ |
||||||
|
(e: "query"): void; |
||||||
|
(e: "selection-change", selection: any[]): void; |
||||||
|
(e: "row-click", row: T): void; |
||||||
|
(e: "cell-click", row: T, column: ListTableColumn<T>, ...args: any[]): void; |
||||||
|
}>(); |
||||||
|
|
||||||
|
// ============================================================================= |
||||||
|
// Data and columns as computed refs for hooks |
||||||
|
// ============================================================================= |
||||||
|
const pageData = computed<T[]>(() => { |
||||||
|
if (!props.data) return []; |
||||||
|
if (props.page && (props.data as PageResponse<T>).data) { |
||||||
|
return (props.data as PageResponse<T>).data; |
||||||
|
} |
||||||
|
if (Array.isArray(props.data)) { |
||||||
|
return props.data; |
||||||
|
} |
||||||
|
return []; |
||||||
|
}); |
||||||
|
|
||||||
|
// Helper function for template to safely access row data |
||||||
|
function getRow(index: number): T | undefined { |
||||||
|
return pageData.value[index]; |
||||||
|
} |
||||||
|
|
||||||
|
const columnsRef = toRef(props, "columns"); |
||||||
|
|
||||||
|
// Pass computed pageData to hooks |
||||||
|
const pageDataRef = toRef(pageData); |
||||||
|
|
||||||
|
// ============================================================================= |
||||||
|
// Wire hooks |
||||||
|
// ============================================================================= |
||||||
|
|
||||||
|
// Column width computation via pretext |
||||||
|
const { computedConfigs, totalFlexBasis } = usePretextColumnWidths(pageDataRef, columnsRef, containerWidth, { |
||||||
|
font: "14px Inter, sans-serif", |
||||||
|
headerFont: "bold 14px Inter, sans-serif", |
||||||
|
}); |
||||||
|
|
||||||
|
// Column widths as array of numbers (for usePretextRowHeights) |
||||||
|
const columnWidths = computed<number[]>(() => { |
||||||
|
return computedConfigs.value.map((c) => c.flexBasis); |
||||||
|
}); |
||||||
|
|
||||||
|
// Row heights via pretext |
||||||
|
const { rowHeights, totalHeight } = usePretextRowHeights(pageDataRef, columnsRef, columnWidths, { |
||||||
|
font: "14px Inter, sans-serif", |
||||||
|
lineHeight: 20, |
||||||
|
rowPadding: 12, |
||||||
|
}); |
||||||
|
|
||||||
|
// Virtualizer (needs rowHeights as number[] and viewportHeight) |
||||||
|
const virtualizer = useVirtualRows( |
||||||
|
computed(() => rowHeights.value.map((r) => r.height)), |
||||||
|
viewportHeight, |
||||||
|
{ overscan: 5 } |
||||||
|
); |
||||||
|
|
||||||
|
// Destructure for template auto-unwrap (Vue 3 auto-unwraps top-level refs) |
||||||
|
const { visibleRows, totalHeight: virtualTotalHeight, onScroll } = virtualizer; |
||||||
|
|
||||||
|
// ============================================================================= |
||||||
|
// Visible columns (non-fixed columns only for now - fixed columns in PR4) |
||||||
|
// ============================================================================= |
||||||
|
const visibleColumns = computed(() => { |
||||||
|
return props.columns.filter((col) => !col.fixed); |
||||||
|
}); |
||||||
|
|
||||||
|
// ============================================================================= |
||||||
|
// Computed styles |
||||||
|
// ============================================================================= |
||||||
|
const tableContainerStyle = computed(() => { |
||||||
|
const style: Record<string, string> = { |
||||||
|
display: "flex", |
||||||
|
flexDirection: "column" as const, |
||||||
|
width: "100%", |
||||||
|
overflow: "hidden" as const, |
||||||
|
}; |
||||||
|
if (props.height !== undefined) { |
||||||
|
const h = String(props.height); |
||||||
|
style.maxHeight = h.endsWith("px") ? h : `${h}px`; |
||||||
|
} |
||||||
|
// if (props.maxHeight !== undefined) { |
||||||
|
// const mh = String(props.maxHeight); |
||||||
|
// style.maxHeight = mh.endsWith("px") ? mh : `${mh}px`; |
||||||
|
// } |
||||||
|
return style; |
||||||
|
}); |
||||||
|
|
||||||
|
// ============================================================================= |
||||||
|
// Column styling |
||||||
|
// ============================================================================= |
||||||
|
function getColumnStyle(col: ListTableColumn<T>): Record<string, string> { |
||||||
|
const config = computedConfigs.value.find((c) => c.key === col.key); |
||||||
|
if (!config) { |
||||||
|
return { flex: "1 1 100px", minWidth: "50px", maxWidth: "300px" }; |
||||||
|
} |
||||||
|
return { |
||||||
|
flex: `${config.flexGrow} ${config.flexShrink} ${config.flexBasis}px`, |
||||||
|
minWidth: `${config.minWidth}px`, |
||||||
|
maxWidth: `${config.maxWidth}px`, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================================================= |
||||||
|
// Header height resolution |
||||||
|
// ============================================================================= |
||||||
|
const resolvedHeaderHeight = computed(() => { |
||||||
|
return props.headerHeight ?? 44; |
||||||
|
}); |
||||||
|
|
||||||
|
// ============================================================================= |
||||||
|
// Cell formatting |
||||||
|
// ============================================================================= |
||||||
|
const getHeaderText = (col: ListTableColumn<T>): string => { |
||||||
|
if (col.name) return col.name; |
||||||
|
if (col.i18n) return t(col.i18n); |
||||||
|
return col.key; |
||||||
|
}; |
||||||
|
|
||||||
|
const formatCellValue = (row: T, col: ListTableColumn<T>): string => { |
||||||
|
const value = (row as any)[col.dataKey || col.key]; |
||||||
|
|
||||||
|
if (col.dict) { |
||||||
|
return formatterByDist(col.dict, value); |
||||||
|
} |
||||||
|
if (col.timestamp) { |
||||||
|
return formatStamp(value); |
||||||
|
} |
||||||
|
if (col.filesize) { |
||||||
|
return formatFileSize(value); |
||||||
|
} |
||||||
|
|
||||||
|
if (value === undefined || value === null || value === "") { |
||||||
|
return "--"; |
||||||
|
} |
||||||
|
return String(value); |
||||||
|
}; |
||||||
|
|
||||||
|
const getValue = (value: any): string => { |
||||||
|
if ((typeof value === "undefined" || value === null || value === "") && value !== 0) { |
||||||
|
return "--"; |
||||||
|
} |
||||||
|
return value; |
||||||
|
}; |
||||||
|
|
||||||
|
const formatStamp = (value: any): string => { |
||||||
|
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): string => { |
||||||
|
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"; |
||||||
|
}; |
||||||
|
|
||||||
|
const formatterByDist = (dictKey: string, cellData: any): string => { |
||||||
|
if (!dictKey) return getValue(cellData); |
||||||
|
const mapping = (state as any).dict[dictKey]; |
||||||
|
if (mapping == null) return getValue(cellData); |
||||||
|
return mapping[cellData] == null ? cellData : mapping[cellData]; |
||||||
|
}; |
||||||
|
|
||||||
|
// ============================================================================= |
||||||
|
// Scroll handling |
||||||
|
// ============================================================================= |
||||||
|
function handleScroll(event: Event) { |
||||||
|
const target = event.target as HTMLElement; |
||||||
|
onScroll(target.scrollTop); |
||||||
|
} |
||||||
|
|
||||||
|
// ============================================================================= |
||||||
|
// Event handlers |
||||||
|
// ============================================================================= |
||||||
|
function handleRowClick(index: number) { |
||||||
|
const row = pageData.value[index]; |
||||||
|
if (row) { |
||||||
|
emit("row-click", row); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleCellClick(index: number, col: ListTableColumn<T>) { |
||||||
|
const row = pageData.value[index]; |
||||||
|
if (row) { |
||||||
|
emit("cell-click", row, col, null, null); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const handleSizeChange = (val: number) => { |
||||||
|
props.example.size = val; |
||||||
|
emit("query"); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleCurrentChange = (val: number) => { |
||||||
|
props.example.page = val; |
||||||
|
emit("query"); |
||||||
|
}; |
||||||
|
|
||||||
|
// ============================================================================= |
||||||
|
// ResizeObserver for container dimensions |
||||||
|
// ============================================================================= |
||||||
|
onMounted(() => { |
||||||
|
// Initial measurement |
||||||
|
if (tableContainerRef.value) { |
||||||
|
const rect = tableContainerRef.value.getBoundingClientRect(); |
||||||
|
containerWidth.value = rect.width; |
||||||
|
viewportHeight.value = rect.height - resolvedHeaderHeight.value; |
||||||
|
} |
||||||
|
|
||||||
|
// ResizeObserver for container width changes |
||||||
|
resizeObserver = new ResizeObserver( |
||||||
|
lodash.debounce((entries) => { |
||||||
|
for (const entry of entries) { |
||||||
|
const { width, height } = entry.contentRect; |
||||||
|
containerWidth.value = width; |
||||||
|
viewportHeight.value = height - resolvedHeaderHeight.value; |
||||||
|
} |
||||||
|
}, 50) |
||||||
|
); |
||||||
|
|
||||||
|
if (tableContainerRef.value) { |
||||||
|
resizeObserver.observe(tableContainerRef.value); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
onUnmounted(() => { |
||||||
|
if (resizeObserver) { |
||||||
|
resizeObserver.disconnect(); |
||||||
|
resizeObserver = null; |
||||||
|
} |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<style lang="scss" scoped> |
||||||
|
.debug-info { |
||||||
|
font-size: 9px; |
||||||
|
padding: 4px 8px; |
||||||
|
background: #f5f5f5; |
||||||
|
border-bottom: 1px solid #ddd; |
||||||
|
font-family: monospace; |
||||||
|
white-space: pre; |
||||||
|
} |
||||||
|
|
||||||
|
.list-table-v2 { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
flex: 0 0 auto; |
||||||
|
overflow: hidden; |
||||||
|
position: relative; |
||||||
|
} |
||||||
|
|
||||||
|
.table-container { |
||||||
|
display: flex; |
||||||
|
flex: 1; |
||||||
|
min-height: 0; |
||||||
|
flex-direction: column; |
||||||
|
overflow: hidden; |
||||||
|
} |
||||||
|
|
||||||
|
.table-header { |
||||||
|
display: flex; |
||||||
|
flex-shrink: 0; |
||||||
|
background: v-bind("state.style.tableHeadBg"); |
||||||
|
border-bottom: 1px solid v-bind("state.style.tableBorderColor"); |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
|
||||||
|
.header-cell { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
padding: 8px 4px; |
||||||
|
box-sizing: border-box; |
||||||
|
border-right: 1px solid v-bind("state.style.tableBorderColor"); |
||||||
|
overflow: hidden; |
||||||
|
word-break: break-word; |
||||||
|
|
||||||
|
&:last-child { |
||||||
|
border-right: none; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.header-cell-text { |
||||||
|
display: block; |
||||||
|
width: 100%; |
||||||
|
overflow: hidden; |
||||||
|
text-overflow: ellipsis; |
||||||
|
white-space: nowrap; |
||||||
|
text-align: center; |
||||||
|
color: v-bind("state.style.tableColor"); |
||||||
|
font-weight: bold; |
||||||
|
font-size: var(--el-font-size-base); |
||||||
|
} |
||||||
|
|
||||||
|
.table-body { |
||||||
|
flex: 1; |
||||||
|
overflow-y: auto; |
||||||
|
overflow-x: hidden; |
||||||
|
background: v-bind("state.style.tableBg"); |
||||||
|
position: relative; |
||||||
|
} |
||||||
|
|
||||||
|
.table-spacer { |
||||||
|
position: relative; |
||||||
|
width: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
.table-row { |
||||||
|
position: absolute; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
display: flex; |
||||||
|
border-bottom: 1px solid v-bind("state.style.tableBorderColor"); |
||||||
|
box-sizing: border-box; |
||||||
|
background: v-bind("state.style.tableBg"); |
||||||
|
transition: background-color 0.15s; |
||||||
|
|
||||||
|
&:hover { |
||||||
|
background: v-bind("state.style.tableCurBg"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.table-cell { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
padding: 8px 4px; |
||||||
|
box-sizing: border-box; |
||||||
|
border-right: 1px solid v-bind("state.style.tableBorderColor"); |
||||||
|
overflow: hidden; |
||||||
|
word-break: break-word; |
||||||
|
min-height: 100%; |
||||||
|
|
||||||
|
&:last-child { |
||||||
|
border-right: none; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.cell-text { |
||||||
|
display: block; |
||||||
|
width: 100%; |
||||||
|
overflow: visible; |
||||||
|
text-overflow: clip; |
||||||
|
white-space: normal; |
||||||
|
word-break: break-word; |
||||||
|
color: v-bind("state.style.tableColor"); |
||||||
|
font-size: var(--el-font-size-base); |
||||||
|
} |
||||||
|
|
||||||
|
.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