Browse Source

feat: add list-table-v2 component with el-table-v2 virtual scrolling

- 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
hechang27-sprt 3 months ago
parent
commit
2a2bba8539
  1. 157
      .trellis/spec/frontend/component-guidelines.md
  2. 51
      examples/App.vue
  3. 1
      examples/config/language/en.ts
  4. 1
      examples/config/language/zh.ts
  5. 6
      examples/config/router.ts
  6. 104
      examples/view/base/table-v2.vue
  7. 319
      packages/base/data/list-table-v2.vue
  8. 3
      packages/base/index.ts
  9. 2
      plugs/composables/useListTable.ts
  10. 2
      plugs/store/index.ts
  11. 3
      vite.config.ts

157
.trellis/spec/frontend/component-guidelines.md

@ -6,54 +6,161 @@ @@ -6,54 +6,161 @@
## Overview
<!--
Document your project's component conventions here.
This project uses Vue 3 with Element Plus components. Components follow a consistent pattern for props, slots, and composition.
Questions to answer:
- What component patterns do you use?
- How are props defined?
- How do you handle composition?
- What accessibility standards apply?
-->
---
(To be filled by the team)
## Element Plus el-table-v2 Usage
---
**IMPORTANT**: `el-table-v2` has a different API than `el-table`. Key differences:
## Component Structure
### Auto-resizing with ElAutoResizer
<!-- Standard structure of a component file -->
`el-table-v2` does NOT have an `autosize` prop. Use `ElAutoResizer` wrapper instead:
(To be filled by the team)
```vue
<el-auto-resizer>
<template #default="{ height, width }">
<el-table-v2
:columns="columns"
:data="data"
:width="width"
:height="height"
:row-height="50"
:header-height="50"
/>
</template>
</el-auto-resizer>
```
---
**Note**: Parent container of `ElAutoResizer` must have a fixed height (e.g., `height: 100%` or explicit pixel value).
## Props Conventions
### el-table-v2 Required Props
`el-table-v2` requires both `width` and `height` as mandatory props - they cannot be omitted.
### Slot-to-CellRenderer Conversion
`el-table-v2` uses `cellRenderer` functions instead of Vue slots. To render parent slots:
```typescript
import { useSlots, renderSlot } from 'vue';
<!-- How props should be defined and typed -->
const slots = useSlots();
(To be filled by the team)
// In column definition:
col.cellRenderer = ({ cellData, rowData }) => {
if (slots[slotName]) {
return renderSlot(slots, slotName, { row: rowData });
}
return h('span', {}, formattedValue);
};
```
### Column Flex Distribution
For auto-distributed column widths (no explicit width), use `flexGrow: 1`:
```typescript
const col = {
key: 'code',
title: 'Name',
dataKey: 'code',
width: 120, // minimum width required
flexGrow: 1, // expands to fill available space
align: 'center',
};
```
---
## Styling Patterns
## Import Path Conventions
### Correct Import Patterns
```typescript
// ✅ CORRECT: Use relative paths for internal modules
import { clearAndAssign, deepCopy } from "../util/objectUtil";
import { ElAutoResizer, ElTableV2 } from "element-plus";
<!-- How styles are applied (CSS modules, styled-components, Tailwind, etc.) -->
// ❌ WRONG: noob-mengyxu/utils does not exist
import { clearAndAssign } from "noob-mengyxu/utils";
```
(To be filled by the team)
**Rule**: For utility functions within the project, always use relative paths (`../util/`, `./`). The `noob-mengyxu` namespace is only for exporting packages.
---
## Accessibility
## Component Structure
```vue
<template>
<div class="component-name">
<!-- Markup -->
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
<!-- A11y requirements and patterns -->
// Props definition
interface Props {
data?: any;
columns?: TableColumn[];
}
(To be filled by the team)
const prop = withDefaults(defineProps<Props>(), {
data: () => [],
columns: () => [],
});
const emit = defineEmits(['query', 'change']);
</script>
<style lang="scss" scoped>
/* Scoped styles */
</style>
```
---
## Props Conventions
1. Use `withDefaults(defineProps<Props>())` for optional props with defaults
2. Always provide default values for array/object props
3. Use TypeScript interfaces for complex prop types
---
## Common Mistakes
<!-- Component-related mistakes your team has made -->
### 1. Timestamp Formatting Assumptions
The `formatStamp` function expects Unix timestamps in **seconds** (not milliseconds):
```typescript
const formatStamp = (value: any) => {
const date = new Date(value * 1000); // expects seconds
// ...
};
```
When generating test data, use `Math.floor(Date.now() / 1000)` or `Date.now() / 1000`.
### 2. Element Plus Version Mismatch
Element Plus `el-table` (v1) and `el-table-v2` (virtualized) have completely different APIs:
- v1: uses `prop` for columns, slots for cell rendering
- v2: uses `columns` prop, `cellRenderer` functions, separate `ElAutoResizer`
### 3. Forgetting Required Props
When using new Element Plus components, verify required props - they will cause runtime errors if omitted.
---
## Accessibility
(To be filled by the team)
- Use semantic HTML elements
- Ensure keyboard navigation works
- Add ARIA labels where necessary

51
examples/App.vue

@ -1,11 +1,13 @@ @@ -1,11 +1,13 @@
<template>
<Index
@updatePwd="updatePwd"
@logout="logout"
:langAble="false"
:styleAble="true"
center=""
:username="state.user.name"
@updatePwd="updatePwd"
@logout="logout"
:langAble="false"
:styleAble="true"
center=""
:username="state.user.name"
:checkUser="false"
:menus="menus"
>
</Index>
</template>
@ -13,7 +15,7 @@ @@ -13,7 +15,7 @@
<script lang="ts" setup>
import { reactive, onMounted, ref } from "vue";
import { useStore } from "vuex";
import { Index, Element, Http } from "noob-mengyxu";
import { Index, Element, Views } from "noob-mengyxu";
const { showMessage } = Element;
import md5 from "js-md5";
import { useI18n } from "vue3-i18n";
@ -21,6 +23,40 @@ const { t } = useI18n(); @@ -21,6 +23,40 @@ const { t } = useI18n();
const { commit, dispatch, state } = useStore();
// const logo = ref("/logo.png");
const { buff, dictionary, config, permission, role, user, status, log } = Views.menus;
const menus = [
{
i18n: "menu.home",
path: "home",
icon: "HomeFilled",
},
{
i18n: "menu.operator",
path: "operator",
icon: "Platform",
children: [buff, dictionary, config, permission, role, user, status, log],
},
{
i18n: "menu.base",
path: "base",
icon: "House",
children: [
{ i18n: "menu.table", path: "table", icon: "List" },
{ i18n: "menu.tableV2", path: "table-v2", icon: "List" },
{ i18n: "menu.form", path: "form", icon: "Postcard" },
],
},
{
i18n: "menu.tool",
path: "tool",
icon: "Tools",
children: [
{ i18n: "menu.terminal", path: "terminal", icon: "Platform" },
{ i18n: "menu.color", path: "color", icon: "MagicStick" },
],
},
];
const updatePwd = (pwd) => {
if (state.user.password != md5(pwd.old)) {
showMessage("error", "旧密码不正确");
@ -40,7 +76,6 @@ onMounted(() => { @@ -40,7 +76,6 @@ onMounted(() => {
</script>
<style lang="scss">
/* Force selection colors to work globally */
*::selection {
background-color: #3367d1 !important;

1
examples/config/language/en.ts

@ -36,6 +36,7 @@ export default class En extends Lang.En { @@ -36,6 +36,7 @@ export default class En extends Lang.En {
home: 'Home',
base: 'General',
table: 'Table',
tableV2: 'Table(V2)',
form: 'Form',
tool: 'Tool',
terminal: 'Terminal',

1
examples/config/language/zh.ts

@ -29,6 +29,7 @@ export default class Zh extends Lang.Zh { @@ -29,6 +29,7 @@ export default class Zh extends Lang.Zh {
home: '主页',
base: '通用',
table: '表格',
tableV2: '表格(V2)',
form: '表单',
tool: '工具',
terminal: '终端',

6
examples/config/router.ts

@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'; @@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router';
import { Views, Common } from 'noob-mengyxu';
import Home from '../view/home.vue';
import Table from '../view/base/table.vue';
import TableV2 from '../view/base/table-v2.vue';
import Form from '../view/base/form.vue';
import Terminal from '../view/tool/terminal.vue';
import Color from '../view/tool/color.vue';
@ -26,6 +27,11 @@ const routes: Array<RouteRecordRaw> = [ @@ -26,6 +27,11 @@ const routes: Array<RouteRecordRaw> = [
name: 'table',
component: Table,
},
{
path: '/table-v2',
name: 'table-v2',
component: TableV2,
},
{
path: '/form',
name: 'form',

104
examples/view/base/table-v2.vue

@ -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>

319
packages/base/data/list-table-v2.vue

@ -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>

3
packages/base/index.ts

@ -11,6 +11,7 @@ import TzDateTime from "./item/tzDateTime.vue"; @@ -11,6 +11,7 @@ import TzDateTime from "./item/tzDateTime.vue";
import WsMonitorToggle from "./item/ws-monitor-toggle.vue";
import SearchRow from "./data/search-row.vue";
import ListTable from "./data/list-table.vue";
import ListTableV2 from "./data/list-table-v2.vue";
import Infomation from "./data/infomation.vue";
import ModifyForm from "./data/modify-form.vue";
import Descriptions from "./data/descriptions.vue";
@ -31,4 +32,4 @@ export { @@ -31,4 +32,4 @@ export {
WsMonitorToggle,
};
export { SearchRow, ListTable, ListTableDialog, Infomation, ModifyForm, Descriptions, TableAction };
export { SearchRow, ListTable, ListTableV2, ListTableDialog, Infomation, ModifyForm, Descriptions, TableAction };

2
plugs/composables/useListTable.ts

@ -3,7 +3,7 @@ import { reactive, ref, shallowRef, toRaw, watchEffect } from "vue"; @@ -3,7 +3,7 @@ import { reactive, ref, shallowRef, toRaw, watchEffect } from "vue";
import * as Element from "../element";
import { useI18n } from "vue3-i18n";
import { PageResponse } from "../http";
import { clearAndAssign, deepCopy } from "noob-mengyxu/utils";
import { clearAndAssign, deepCopy } from "../util/objectUtil";
const { showMessage } = Element;

2
plugs/store/index.ts

@ -2,7 +2,7 @@ import { createStore as create } from "vuex"; @@ -2,7 +2,7 @@ import { createStore as create } from "vuex";
import { Styles, Size } from "../config";
import { getByCodes, getMenus, logout, getActions } from "../api/public";
import { mapping } from "../api/role";
import { clearAndAssign } from "noob-mengyxu/utils";
import { clearAndAssign } from "../util/objectUtil";
export class State {
dict = {

3
vite.config.ts

@ -2,6 +2,7 @@ import { defineConfig } from "vite"; @@ -2,6 +2,7 @@ import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import legacy from "@vitejs/plugin-legacy";
import dts from "vite-plugin-dts";
import VueDevTools from "vite-plugin-vue-devtools";
import { resolve } from "path";
export default defineConfig(({ command, mode }) => {
@ -14,6 +15,8 @@ export default defineConfig(({ command, mode }) => { @@ -14,6 +15,8 @@ export default defineConfig(({ command, mode }) => {
},
plugins: [
vue(),
// Vue DevTools for debugging (only in non-lib builds)
!isLibBuild && VueDevTools(),
// Only use legacy plugin for app builds, not library builds
!isLibBuild &&
legacy({

Loading…
Cancel
Save