forked from mengyxu/noob-components
Browse Source
- Create ListTableV2 component using Element Plus el-table-v2 for performance - Use ElAutoResizer for automatic container height sizing - Support auto-distributed column widths via flexGrow - Convert parent slots to cellRenderer functions using renderSlot - Fix incorrect import path in useListTable.ts and plugs/store/index.ts - Add Vue DevTools plugin for debugging - Add demo page with 100 rows of test data - Add menu entry and i18n translations - Update component guidelines with el-table-v2 usage notes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>dev
11 changed files with 613 additions and 36 deletions
@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
|
||||
<template> |
||||
<SearchRow :title="t('table.title') + ' (V2)'"> |
||||
<template #default> |
||||
<NoobInput v-model="example.aaa" :width="180"></NoobInput> |
||||
<NoobSelect v-model="example.bbb" dict="test"></NoobSelect> |
||||
<NoobSelect v-model="example.ccc" stateProp="test" :width="120"></NoobSelect> |
||||
</template> |
||||
</SearchRow> |
||||
<ListTableV2 |
||||
:data="data" |
||||
:columns="columns" |
||||
:page="true" |
||||
:border="true" |
||||
:example="example" |
||||
:row-key="'id'" |
||||
@query="handleQuery" |
||||
></ListTableV2> |
||||
</template> |
||||
|
||||
<script lang="ts" setup> |
||||
import { useStore } from "vuex"; |
||||
import { reactive, onMounted, ref } from "vue"; |
||||
import { ListTableV2, SearchRow, NoobInput, NoobSelect, Infomation } from "noob-mengyxu"; |
||||
import { useI18n } from "vue3-i18n"; |
||||
|
||||
const { state } = useStore(); |
||||
const { t } = useI18n(); |
||||
|
||||
const example = reactive({ |
||||
page: 1, |
||||
size: 10, |
||||
aaa: "", |
||||
bbb: "b", |
||||
ccc: "c", |
||||
}); |
||||
|
||||
// Generate 100 rows of data across multiple pages |
||||
const generateData = () => { |
||||
const rows = []; |
||||
const now = Math.floor(Date.now() / 1000); // Unix timestamp in seconds |
||||
for (let i = 1; i <= 100; i++) { |
||||
rows.push({ |
||||
id: i, |
||||
caseName: `案件${i}`, |
||||
taskName: `任务${i}`, |
||||
userId: `user${i}`, |
||||
content: `内容${i}`, |
||||
createTime: now - i * 60, // timestamps in seconds |
||||
}); |
||||
} |
||||
return rows; |
||||
}; |
||||
|
||||
const allData = generateData(); |
||||
|
||||
const data = reactive({ |
||||
data: allData.slice(0, 10), |
||||
total: allData.length, |
||||
}); |
||||
|
||||
const columns = [ |
||||
{ |
||||
code: "id", |
||||
name: "ID", |
||||
}, |
||||
{ |
||||
code: "caseName", |
||||
i18n: "table.props.0", |
||||
}, |
||||
{ |
||||
code: "taskName", |
||||
i18n: "table.props.1", |
||||
}, |
||||
{ |
||||
code: "userId", |
||||
i18n: "table.props.2", |
||||
}, |
||||
{ |
||||
code: "content", |
||||
i18n: "table.props.3", |
||||
}, |
||||
{ |
||||
code: "createTime", |
||||
i18n: "table.props.4", |
||||
timestamp: true, |
||||
}, |
||||
]; |
||||
|
||||
const handleQuery = () => { |
||||
// Simulate pagination - slice data based on page and size |
||||
const start = (example.page - 1) * example.size; |
||||
const end = start + example.size; |
||||
data.data = allData.slice(start, end); |
||||
console.log("Query:", example.page, example.size, "showing", start, "-", end); |
||||
}; |
||||
|
||||
onMounted(() => { |
||||
console.log("Table V2 mounted"); |
||||
}); |
||||
</script> |
||||
|
||||
<style lang="scss" scoped> |
||||
//@import url(); 引入公共css类 |
||||
</style> |
||||
@ -0,0 +1,319 @@
@@ -0,0 +1,319 @@
|
||||
<template> |
||||
<div class="list-table-v2" :style="containerStyle"> |
||||
<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="rowHeight" |
||||
:header-height="headerHeight" |
||||
: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="ts" setup> |
||||
import { useStore } from "vuex"; |
||||
import { ref, computed, watch, h, onMounted, onUpdated, useSlots, renderSlot } from "vue"; |
||||
import { useI18n } from "vue3-i18n"; |
||||
import { ElAutoResizer, ElTableV2 } from "element-plus"; |
||||
import * as lodash from "lodash-es"; |
||||
|
||||
const slots = useSlots(); |
||||
|
||||
const { t } = useI18n(); |
||||
const { state } = useStore(); |
||||
const table = ref(); |
||||
|
||||
// el-table-v2 uses fixed row heights for virtual scrolling by default |
||||
const ROW_HEIGHT = 50; |
||||
const HEADER_HEIGHT = 50; |
||||
|
||||
interface TableColumn { |
||||
code: 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?: boolean; |
||||
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; |
||||
} |
||||
|
||||
const prop = withDefaults(defineProps<Props>(), { |
||||
data: () => [], |
||||
columns: () => [], |
||||
page: false, |
||||
height: undefined, |
||||
maxHeight: undefined, |
||||
example: () => ({}), |
||||
rowKey: "id", |
||||
selectKey: undefined, |
||||
treeProps: undefined, |
||||
lazy: false, |
||||
border: false, |
||||
}); |
||||
|
||||
const emit = defineEmits(["query", "selection-change", "row-click", "cell-click"]); |
||||
|
||||
const rowHeight = ROW_HEIGHT; |
||||
const headerHeight = HEADER_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; |
||||
}; |
||||
|
||||
// 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 []; |
||||
}); |
||||
|
||||
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"; |
||||
}; |
||||
|
||||
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.code, 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.code, |
||||
title: item.name || (item.i18n ? t(item.i18n) : item.code), |
||||
dataKey: item.code, |
||||
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, converts parent slots |
||||
col.cellRenderer = ({ cellData, rowData }: { cellData: any; rowData: any }) => { |
||||
const slotName = item.code; |
||||
|
||||
// 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.code); |
||||
|
||||
// Handle dict display |
||||
if (item.dict) { |
||||
return h("span", {}, formatterByDist(item.dict, value)); |
||||
} |
||||
|
||||
// Handle formatting |
||||
const formatted = formatCellValue(value, item, rowData); |
||||
return h("span", {}, formatted); |
||||
}; |
||||
|
||||
return col; |
||||
}); |
||||
}); |
||||
</script> |
||||
|
||||
<style lang="scss" scoped> |
||||
.list-table-v2 { |
||||
display: flex; |
||||
flex-direction: column; |
||||
flex: 1; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
.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; |
||||
} |
||||
|
||||
.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__cell) { |
||||
padding: v-bind("state.size.tablePad"); |
||||
} |
||||
|
||||
.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