前言
🤟 找工作,来万码优才:👉 #小程序://万码优才/r6rqmzDaXpYkJZF
一开始从实战中<el-table-v2>
了解到这个用法是从开源项目ruoyi-vue-pro中
其源码如下:
<template>
<doc-alert title="功能权限" url="https://doc.iocoder.cn/resource-permission" />
<doc-alert title="菜单路由" url="https://doc.iocoder.cn/vue3/route/" />
<!-- 搜索工作栏 -->
<ContentWrap>
<el-form
ref="queryFormRef"
:inline="true"
:model="queryParams"
class="-mb-15px"
label-width="68px"
>
<el-form-item label="菜单名称" prop="name">
<el-input
v-model="queryParams.name"
class="!w-240px"
clearable
placeholder="请输入菜单名称"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select
v-model="queryParams.status"
class="!w-240px"
clearable
placeholder="请选择菜单状态"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon class="mr-5px" icon="ep:search" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon class="mr-5px" icon="ep:refresh" />
重置
</el-button>
<el-button
v-hasPermi="['system:menu:create']"
plain
type="primary"
@click="openForm('create')"
>
<Icon class="mr-5px" icon="ep:plus" />
新增
</el-button>
<el-button plain type="danger" @click="toggleExpandAll">
<Icon class="mr-5px" icon="ep:sort" />
展开/折叠
</el-button>
<el-button plain @click="refreshMenu">
<Icon class="mr-5px" icon="ep:refresh" />
刷新菜单缓存
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-auto-resizer>
<template #default="{ width }">
<el-table-v2
v-model:expanded-row-keys="expandedRowKeys"
:columns="columns"
:data="list"
:expand-column-key="columns[0].key"
:height="1000"
:width="width"
fixed
row-key="id"
/>
</template>
</el-auto-resizer>
</ContentWrap>
<!-- 表单弹窗:添加/修改 -->
<MenuForm ref="formRef" @success="getList" />
</template>
<script lang="tsx" setup>
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { handleTree } from '@/utils/tree'
import * as MenuApi from '@/api/system/menu'
import { MenuVO } from '@/api/system/menu'
import MenuForm from './MenuForm.vue'
import DictTag from '@/components/DictTag/src/DictTag.vue'
import { Icon } from '@/components/Icon'
import { ElButton, TableV2FixedDir } from 'element-plus'
import { checkPermi } from '@/utils/permission'
import { CommonStatusEnum } from '@/utils/constants'
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
defineOptions({ name: 'SystemMenu' })
// 虚拟列表表格
const columns = [
{
key: 'name',
title: '菜单名称',
dataKey: 'name',
width: 250,
fixed: TableV2FixedDir.LEFT
},
{
key: 'icon',
title: '图标',
dataKey: 'icon',
width: 100,
align: 'center',
cellRenderer: ({ cellData: icon }) => <Icon icon={icon} />
},
{
key: 'sort',
title: '排序',
dataKey: 'sort',
width: 60
},
{
key: 'permission',
title: '权限标识',
dataKey: 'permission',
width: 300
},
{
key: 'component',
title: '组件路径',
dataKey: 'component',
width: 500
},
{
key: 'componentName',
title: '组件名称',
dataKey: 'componentName',
width: 200
},
{
key: 'status',
title: '状态',
dataKey: 'status',
width: 60,
fixed: TableV2FixedDir.RIGHT,
cellRenderer: ({ rowData }) => {
// 检查权限
if (!checkPermi(['system:menu:update'])) {
return <DictTag type={DICT_TYPE.COMMON_STATUS} value={rowData.status} />
}
// 如果有权限,渲染 ElSwitch
return (
<ElSwitch
v-model={rowData.status}
active-value={CommonStatusEnum.ENABLE}
inactive-value={CommonStatusEnum.DISABLE}
loading={menuStatusUpdating[rowData.id]}
class="ml-4px"
onChange={(val) => handleStatusChanged(rowData, val)}
/>
)
}
},
{
key: 'operations',
title: '操作',
align: 'center',
width: 160,
fixed: TableV2FixedDir.RIGHT,
cellRenderer: ({ rowData }) => {
// 定义按钮列表
const buttons = []
// 检查权限并添加按钮
if (checkPermi(['system:menu:update'])) {
buttons.push(
<ElButton key="edit" link type="primary" onClick={() => openForm('update', rowData.id)}>
修改
</ElButton>
)
}
if (checkPermi(['system:menu:create'])) {
buttons.push(
<ElButton
key="create"
link
type="primary"
onClick={() => openForm('create', undefined, rowData.id)}
>
新增
</ElButton>
)
}
if (checkPermi(['system:menu:delete'])) {
buttons.push(
<ElButton key="delete" link type="danger" onClick={() => handleDelete(rowData.id)}>
删除
</ElButton>
)
}
// 如果没有权限,返回 null
if (buttons.length === 0) {
return null
}
// 渲染按钮列表
return <>{buttons}</>
}
}
]
const { wsCache } = useCache()
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const loading = ref(true) // 列表的加载中
const list = ref<any[]>([]) // 列表的数据
const queryParams = reactive({
name: undefined,
status: undefined
})
const queryFormRef = ref() // 搜索的表单
const isExpandAll = ref(false) // 是否展开,默认全部折叠
const refreshTable = ref(true) // 重新渲染表格状态
// 添加展开行控制
const expandedRowKeys = ref<number[]>([])
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await MenuApi.getMenuList(queryParams)
list.value = handleTree(data)
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number, parentId?: number) => {
formRef.value.open(type, id, parentId)
}
/** 展开/折叠操作 */
const toggleExpandAll = () => {
if (!isExpandAll.value) {
// 展开所有
expandedRowKeys.value = list.value.map((item) => item.id)
} else {
// 折叠所有
expandedRowKeys.value = []
}
isExpandAll.value = !isExpandAll.value
}
/** 刷新菜单缓存按钮操作 */
const refreshMenu = async () => {
try {
await message.confirm('即将更新缓存刷新浏览器!', '刷新菜单缓存')
// 清空,从而触发刷新
wsCache.delete(CACHE_KEY.USER)
wsCache.delete(CACHE_KEY.ROLE_ROUTERS)
// 刷新浏览器
location.reload()
} catch {}
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
// 删除的二次确认
await message.delConfirm()
// 发起删除
await MenuApi.deleteMenu(id)
message.success(t('common.delSuccess'))
// 刷新列表
await getList()
} catch {}
}
/** 开启/关闭菜单的状态 */
const menuStatusUpdating = ref({}) // 菜单状态更新中的 menu 映射。key:菜单编号,value:是否更新中
const handleStatusChanged = async (menu: MenuVO, val: number) => {
// 1. 标记 menu.id 更新中
menuStatusUpdating.value[menu.id] = true
try {
// 2. 发起更新状态
menu.status = val
await MenuApi.updateMenu(menu)
} finally {
// 3. 标记 menu.id 更新完成
menuStatusUpdating.value[menu.id] = false
}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>
截图如下:
1. 实战
原先是传统模式,后续改为虚拟列表的方式
抽离核心部分进行对比:
<template>
<div>
<el-form :model="queryParams" ref="queryFormRef">
<el-form-item label="菜单名称">
<el-input v-model="queryParams.name" />
</el-form-item>
<el-button type="primary" @click="handleQuery">查询</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form>
<el-button @click="toggleExpandAll">展开/折叠</el-button>
<el-table
:data="list"
v-if="refreshTable"
:default-expand-all="isExpandAll"
style="width: 100%"
>
<el-table-column prop="name" label="菜单名称" />
<el-table-column prop="permission" label="权限标识" />
<el-table-column label="操作">
<template #default="scope">
<el-button type="primary" @click="openForm('update', scope.row.id)">修改</el-button>
<el-button type="danger" @click="handleDelete(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, nextTick } from 'vue'
import * as MenuApi from '@/api/system/menu'
import { handleTree } from '@/utils/tree'
const list = ref([])
const loading = ref(false)
const refreshTable = ref(true)
const isExpandAll = ref(false)
const queryParams = reactive({ name: '', status: undefined })
const queryFormRef = ref()
const getList = async () => {
loading.value = true
try {
const data = await MenuApi.getMenuList(queryParams)
list.value = handleTree(data)
} finally {
loading.value = false
}
}
const handleQuery = () => getList()
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
const toggleExpandAll = () => {
refreshTable.value = false
isExpandAll.value = !isExpandAll.value
nextTick(() => {
refreshTable.value = true
})
}
const openForm = (type, id) => {
console.log('打开表单', type, id)
}
const handleDelete = async (id) => {
await MenuApi.deleteMenu(id)
getList()
}
onMounted(getList)
</script>
另外一个:
<template>
<div>
<el-table-v2
:columns="columns"
:data="list"
:row-key="(row) => row.id"
:fixed-header="true"
:expand-column-key="'name'"
:expanded-row-keys="expandedRowKeys"
height="500"
width="100%"
/>
<el-button @click="toggleExpandAll">展开/折叠</el-button>
</div>
</template>
<script setup>
import { ref, onMounted, h } from 'vue'
import { ElSwitch, ElButton } from 'element-plus'
import * as MenuApi from '@/api/system/menu'
import { handleTree } from '@/utils/tree'
import { checkPermi } from '@/utils/permission'
import { Icon } from '@/components/Icon'
import DictTag from '@/components/DictTag/src/DictTag.vue'
import { CommonStatusEnum } from '@/utils/constants'
import { TableV2FixedDir } from 'element-plus' // 这里只需要 TableV2FixedDir 常量
const list = ref([])
const expandedRowKeys = ref([])
const getList = async () => {
const data = await MenuApi.getMenuList()
list.value = handleTree(data)
}
const toggleExpandAll = () => {
if (expandedRowKeys.value.length === 0) {
expandedRowKeys.value = list.value.map((item) => item.id)
} else {
expandedRowKeys.value = []
}
}
const menuStatusUpdating = ref({})
const handleStatusChanged = async (menu, val) => {
menuStatusUpdating.value[menu.id] = true
try {
menu.status = val
await MenuApi.updateMenu(menu)
} finally {
menuStatusUpdating.value[menu.id] = false
}
}
const openForm = (type, id) => {
console.log('打开表单', type, id)
}
const handleDelete = async (id) => {
await MenuApi.deleteMenu(id)
getList()
}
const columns = [
{
key: 'name',
title: '菜单名称',
dataKey: 'name',
width: 250,
fixed: TableV2FixedDir.LEFT
},
{
key: 'icon',
title: '图标',
dataKey: 'icon',
width: 80,
cellRenderer: ({ cellData }) => h(Icon, { icon: cellData })
},
{
key: 'status',
title: '状态',
dataKey: 'status',
width: 80,
cellRenderer: ({ rowData }) => {
if (!checkPermi(['system:menu:update'])) {
return h(DictTag, { type: 'status', value: rowData.status })
}
return h(ElSwitch, {
modelValue: rowData.status,
'onUpdate:modelValue': (val) => handleStatusChanged(rowData, val),
activeValue: CommonStatusEnum.ENABLE,
inactiveValue: CommonStatusEnum.DISABLE,
loading: menuStatusUpdating.value[rowData.id]
})
}
},
{
key: 'operations',
title: '操作',
dataKey: 'id',
width: 160,
cellRenderer: ({ rowData }) => {
const buttons = []
if (checkPermi(['system:menu:update'])) {
buttons.push(h(
ElButton,
{
link: true,
type: 'primary',
onClick: () => openForm('update', rowData.id)
},
() => '修改'
))
}
if (checkPermi(['system:menu:delete'])) {
buttons.push(h(
ElButton,
{
link: true,
type: 'danger',
onClick: () => handleDelete(rowData.id)
},
() => '删除'
))
}
return h('div', null, buttons)
}
}
]
onMounted(getList)
</script>
2. 拓展
2.1 ElSwitch
<ElSwitch />
是 Element Plus 中用于二元状态(开/关)切换的 UI 控件
常用于启用/禁用、显示/隐藏等场景
✅ 常用属性:
属性 | 说明 |
---|---|
v-model | 绑定的值,可以是布尔值或字符串/数字 |
active-value | 选中(开)时的值 |
inactive-value | 未选中(关)时的值 |
loading | 是否显示加载状态 |
disabled | 是否禁用 |
onChange | 状态变化时的事件处理器 |
简易的小Demo辅助理解:
<template>
<div>
<!-- 绑定布尔变量 isEnabled -->
<el-switch
v-model="isEnabled"
:active-value="true"
:inactive-value="false"
:loading="isLoading"
@change="handleChange"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
const isEnabled = ref(false) // 是否启用
const isLoading = ref(false) // 开关切换时加载中
const handleChange = async (val) => {
isLoading.value = true
try {
console.log('切换状态为:', val)
// 模拟请求
await new Promise(resolve => setTimeout(resolve, 1000))
} finally {
isLoading.value = false
}
}
</script>
整体注释如下:
v-model="isEnabled"
:绑定开关状态;active-value / inactive-value
:指定开启/关闭时的值(这里是布尔值);isLoading
:控制开关的加载效果;handleChange
:状态变化时的处理逻辑,模拟了异步请求
2.2 el-table-v2
Element Plus 中的 <el-table-v2>
确实是新版虚拟滚动表格的组件,它是为了大数据量表格渲染性能优化而引入的组件
<template>
<el-table-v2
:columns="columns"
:data="tableData"
:width="800"
:height="400"
fixed
/>
</template>
特性 | 说明 |
---|---|
📦 来自 | @element-plus/components/table-v2(Element Plus 官方子模块) |
⚡ 核心优势 | 支持虚拟滚动(Virtual Scroll),大幅提升渲染性能 |
🧩 和 <el-table> 区别 | 不支持复杂功能如树形表格、合并单元格、展开行,但支持大数据表格高性能滚动 |
🧱 使用方式 | 提供 columns 配置数组来定义列,而不是使用 el-table-column 插槽 |
核心 Props
Prop | 类型 | 说明 |
---|---|---|
columns | Array<Column> | 列定义数组(包含标题、字段、宽度等) |
data | Array<any> | 表格的数据源 |
width / height | number | 表格尺寸,必须指定(虚拟滚动需要) |
row-height | number | 每行高度,默认 50 |
header-height | number | 表头高度,默认 50 |
fixed | boolean | 是否开启固定列 |
class | string | 自定义类名 |
列定义结构(columns)
interface Column {
key: string // 唯一键
dataKey: string // 显示字段名
title: string // 表头标题
width?: number // 宽度
align?: 'left' | 'center' | 'right'
cellRenderer?: ({ cellData, rowData }) => VNode // 自定义渲染
}
简易Demo:
<template>
<el-table-v2
:columns="columns"
:data="tableData"
:width="800"
:height="400"
fixed
/>
</template>
<script setup>
import { ref } from 'vue'
const columns = [
{ key: 'id', dataKey: 'id', title: 'ID', width: 100 },
{ key: 'name', dataKey: 'name', title: 'Name', width: 200 },
{ key: 'age', dataKey: 'age', title: 'Age', width: 100 },
]
const tableData = Array.from({ length: 10000 }, (_, i) => ({
id: i + 1,
name: `Name ${i + 1}`,
age: Math.floor(Math.random() * 50 + 20),
}))
</script>
自定义的格式渲染:
{
key: 'status',
dataKey: 'status',
title: 'Status',
width: 120,
cellRenderer: ({ cellData }) => {
return h('span', { style: { color: cellData === 'OK' ? 'green' : 'red' } }, cellData)
}
}