上一篇拆解了 YsTable 的内部设计与实现。这一篇落到地上:怎么把它用对。学组件最怕"看源码十分钟觉得会了,自己开页面还是对着空模板发呆"——列怎么配、按钮权限怎么挂、主从表怎么联动、查询表单和表格谁管。
我拿开源版里的字典管理页当样板,把 YsTable 在业务里被用出来的一行一行拆给你看。字典管理页是个典型的"主从表"结构:左边字典主表(编码、名称、状态),右边是选中字典下的明细项,点左边某行右边自动加载它的明细。这种布局在后台系统遍地都是(角色-权限、分类-商品、部门-人员),吃透这一个页面,八成的列表需求你都会写了。
我们要实现的页面长这样:左表列"名称/编码/状态/操作",右表列"明细编码/名称/排序/状态/操作",中间靠"点击左表行"联动。这是一个中后台最常见的列表形态,涵盖了 YsTable 的大部分实战要点:列定义、自定义渲染、权限按钮、主从联动、查询、删除、分页。
字典页模板(节选自 ys-vue-open/src/views/system/dict/index.vue):
xml代码解读复制代码<template> <div class="page-container"> <el-card>
<el-row style="height: 100%"> <!-- 左:字典主表 -->
<el-col :span="12"> <YsTable
ref="mainTableRef"
<!-- 主表实例引用 -->
:request-fn="useDictApi().list" <!-- 数据接口:直接传 API 方法 --> :options="mainTableOptions" <!-- 列等配置 --> :query-params="mainSearchForm" <!-- 响应式查询条件 --> :show-pagination="true" :events="mainTableEvents" <!-- 事件回调 --> > <!-- 查询表单插槽:名称输入框 + 查询/重置按钮 --> <template #page-header> <el-input v-model="mainSearchForm.name" placeholder="请输入名称" clearable /> <el-button type="primary" @click="searchMainTable(true)">查询</el-button> <el-button @click="resetMainTable">重置</el-button> </template> <!-- 操作列:编辑/删除链接,挂 v-auth 权限指令 --> <template #action="scope"> <el-link v-auth="'system:dict:edit'" @click="onOpenEditMain('edit', scope.row)">编辑</el-link> <el-link v-auth="'system:dict:delete'" @click="onRowDelMain(scope.row)">删除</el-link> </template> <!-- 状态列:用 el-tag 渲染 0/1 --> <template #status="{ row }"> <el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? '启用' : '禁用' }}</el-tag> </template> </YsTable> </el-col> <!-- 右:字典明细表(结构同上,略) --> <el-col :span="12"> <YsTable ref="detailTableRef" :request-fn="useDictApi().getDetailList" :options="detailTableOptions" :query-params="detailSearchForm" :show-pagination="true" :events="detailTableEvents"> </YsTable> </el-col> </el-row> </el-card> <!-- 新增/编辑弹窗:成功后 @refresh 刷新对应表 --> <MainDialog ref="mainDialogRef" @refresh="refreshMainTable()" /> <DetailDialog ref="detailDialogRef" @refresh="refreshDetailTable()" /> </div> </template>
三个第一眼要记住的约定:
主表和明细表是两个独立的 YsTable 实例,各自有 ref、options、requestFn、events,互不干扰,只在事件里联动。
数据接口直接把 API 方法当 requestFn 传:useDictApi().list。YsTable 内部拿它当 vxe 代理的 query 函数,你不用自己 await 再 loadData。
查询条件走 :query-params,这是个响应式对象,YsTable 会合并进每次请求的 params。mainSearchForm 里只有 name 一个字段,所以每次查询都带 name。
主表的列配置:
arduino代码解读复制代码const mainTableOptions = reactive<any>({ columns: [
{ type: 'seq', width: 80, align: 'center' }, // ① 序号列(vxe 自带,自动按页算行号)
{ title: '名称', field: 'name' }, { title: '编码', field: 'code' },
{ title: '状态', field: 'status', width: 80, slots: { default: 'status' } }, //
② 自定义渲染 { title: '操作', field: 'action', width: 120, slots:
{ default: 'action' }, align: 'center' }, ], rowConfig: { isCurrent:
true, isHover: true }, // isCurrent 开启"当前行"事件,用于主从联动
toolbarConfig: { size: 'small', refresh: true, //
显示刷新按钮 buttons: computed(() => {
const allButtons = [ { name: '新增', code: 'add',
status: 'success', icon: 'ri-add-line', auth: 'system:dict:add' },
]; // 按权限过滤按钮:无权限则不显示(前端隐藏)
return allButtons.filter(btn => !btn.auth ||
hasAuth(btn.auth)).map(({ auth, ...rest }) => rest);
}), }, });
几个实战里容易忽略的点:
type: 'seq' 是 vxe 自带的序号列,自动按当前页算行号,比手写 index+1 省心,翻页后序号也能连续(配合分页参数)。
slots: { default: 'status' } 是自定义单元格渲染。状态字段在库里是 0/1,界面要显示"禁用/启用"标签。模板里 <template #status="{ row }"> 接管这一格渲染。field 仍要写 status,否则 vxe 不知道这列绑哪个字段;slots.default 只是告诉它"这格用我给的插槽画"。
操作列也是插槽。#action 里放编辑/删除链接,挂了 v-auth 指令做按钮级权限。没 system:dict:edit 权限的人连"编辑"链接都看不到。权限指令是项目自己封装的,但你要知道:列表里的操作按钮权限,是在列渲染这层做的,不是等点了才拦。
toolbarConfig.buttons 支持按权限过滤。它用 computed 包着,返回前先 filter(btn => !btn.auth || hasAuth(btn.auth)),hasAuth 读 Pinia 里用户的 authBtnList。"新增"按钮也会按权限消失。更讲究的做法:按钮 auth 字段和 v-auth 共用同一套权限码,前端隐藏 + 后端 @SaCheckPermission 双重保险。
这是字典页最精髓的交互,也是"主从表"的标准写法:
typescript代码解读复制代码const mainTableEvents = { // 工具栏"新增"按钮点击
toolbarButtonClick(params: any) { switch (params.code) {
case 'add': onOpenAddMain('add'); break; } }, // 当前行变化
(因为 rowConfig.isCurrent: true) currentRowChange(rowIndex: any) {
state.currentMainRow = rowIndex.row; detailSearchForm.dictId = rowIndex.row?.id || '';
// 把主表 id 塞进右表查询条件 isDetailAddVisible.value = !!state.currentMainRow;
if (state.currentMainRow) {
detailTableRef.value?.search(detailSearchForm); // 右表重新查
} }, };
currentRowChange 是 vxe 的行选中事件。用户点主表某行,组件拿到这一行,把它的 id 塞进明细表查询条件 detailSearchForm.dictId,再调 detailTableRef.search(...) 让右表重新查。
顺序讲究:search 会把 pageNo 重置回 1 再查。因为用户切到另一个字典,理应看它的第一页明细,不是带着上一个字典翻到的页码。YsTable 的 search 内部已帮你 pageVo.currentPage = 1,不用手动重置。
反过来,重置主表时也要把明细表清空:
ini代码解读复制代码const resetMainTable = () => { mainTableRef.value?.reset();
detailTableRef.value?.reset(); // 顺手重置从表,避免停在上一主表数据 };
"新增"按钮点下去,要弹出新增字典的对话框。这不在 YsTable 内部,而是你通过 events.toolbarButtonClick 监听:
typescript代码解读复制代码const onOpenAddMain = (type: string) => {
mainDialogRef.value.openDialog(type); // 调弹窗暴露的 openDialog 方法 };
MainDialog 是异步组件(defineAsyncComponent),defineExpose 了 openDialog 方法。YsTable 只负责把"新增按钮被点了,code 是 add"这个事件抛出来,弹窗怎么开、表单长啥样,是 dialog 组件的事。这种"组件只管发事件、业务自己接"的边界划得很干净,是 YsTable 好用的原因之一。
列表里的删除走 ElMessageBox.confirm 二次确认,成功后调 refresh 而不是 search:
typescript代码解读复制代码const onRowDelMain = (row: any) => { ElMessageBox.confirm
(`此操作将永久删除字典:${row.name}, 是否继续?`, '提示', { confirmButtonText: '确定',
cancelButtonText: '取消', type: 'warning', }).then(() => { useDictApi().
delete(row.id).then(() => { ElMessage.success('删除成功');
mainTableRef.value?.refresh(); // 用当前页码重查,停在原地看剩余 }); }).catch(()
=> {}); };
为什么用 refresh 不用 search? 删一条数据,你希望停在当前页看剩下的,不是跳回第一页。refresh 就是"用当前页码和参数重查一次",最贴合"删完看原地"的体验。这个小细节很多新手会搞混,导致删完莫名其妙跳页。
某列需要点表头排序、且排序在后端做(数据量大时前端排序不现实),只要给列加 sortable: true:
less代码解读复制代码columns: [ { title: '序号', field: 'seq', width: 80, sortable: true },
// ... ]
加了之后,用户点表头,vxe 把排序信息通过代理的 sorts 参数传进 query 函数,YsTable 拼成 orderBy: "seq|asc" 带去后端。你什么都不用写,排序的查询、刷新、回到第一页全由组件管。注意远程排序意味着每次排序列都会重新发请求,表头会有 loading 态,这是正常的。
下面把"列表 + 查询 + 新增弹窗 + 删除"串成完整闭环,基于 YsTable,不依赖真实后端(用 mock)。直接贴进项目就能跑:
typescript代码解读复制代码<template> <el-card> <YsTable ref="tableRef"
:request-fn="req" :options="options" :query-params="searchForm"
:events="events" > <!-- 查询表单:放在 page-header 插槽 -->
<template #page-header> <el-input v-model="searchForm.name" placeholder="名称"
clearable style="width:180px" /> <el-button type="primary" @click="tableRef?.search()"
>查询</el-button> <el-button @click="tableRef?.reset()">重置</el-button>
</template> <!-- 状态列自定义渲染 --> <template #status="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'"> {{ row.status === 1 ? '启用'
: '禁用' }} </el-tag> </template> <!-- 操作列 --> <template
#action="{ row }"> <el-link type="primary" @click="openEdit(row)">编辑</el-link>
<el-link type="danger" @click="onDelete(row)">删除</el-link> </template>
</YsTable> <!-- 新增/编辑弹窗(用 YsDialog) --> <YsDialog :title="dialogTitle"
v-model="visible" width="40%" @close="visible = false">
<el-form :model="form" label-width="80px"> <el-form-item label="名称"><el-input
v-model="form.name" /></el-form-item> <el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
</el-form-item> </el-form> <template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="submit">保存
</el-button> </template> </YsDialog>
</el-card> </template> <script setup lang="ts">
import { ref, reactive } from 'vue'; import
{ ElMessage, ElMessageBox } from 'element-plus';
const tableRef = ref(); const visible = ref(false); const dialogTitle = ref(''); const editing =
ref<any>(null); const searchForm = reactive({ name: '' }); // 模拟接口:返回 { current, size, total,
records } 约定结构 const store = Array.from({ length: 43 }, (_, i) => ({ id: i + 1, name: `项${i +
1}`, status: i % 2, })); const req = async (params: any) => { const filtered = store.filter((x) =>
!params.name || x.name.includes(params.name)); const start = (params.pageNo - 1) * params.
pageSize; return { code: 200, data: { current: params.pageNo,
size: params.pageSize, total: filtered.length,
records: filtered.slice(start, start + params.pageSize), }, }; };
const options = reactive({ columns: [ { type: 'seq', width: 60, align: 'center' },
{ title: '名称', field: 'name' }, { title: '状态', field: 'status', width: 90,
slots: { default: 'status' } }, { title: '操作', field: 'action', width: 140,
slots: { default: 'action' }, align: 'center' }, ], rowConfig: { keyField: 'id',
isHover: true }, toolbarConfig: { size: 'small', refresh: true,
buttons: [{ name: '新增', code: 'add', status: 'success', icon: 'ri-add-line' }],
}, }); const events = { toolbarButtonClick: (p: any) => { if (p.code === 'add')
openAdd(); }, }; const form = reactive({ id: '', name: '', status: 1 });
const openAdd = () => { Object.assign(form, { id: '', name: '', status: 1 });
editing.value = null; dialogTitle.value = '新增'; visible.value = true; };
const openEdit = (row: any) => { Object.assign(form, row); editing.value
= row; dialogTitle.value = '编辑'; visible.value = true; }; const submit =
() => { if (editing.value) Object.assign(editing.value, { ...form });
else store.unshift({ id: Date.now(), name: form.name, status: form.status });
visible.value = false; ElMessage.success('保存成功'); tableRef.value?.refresh();
// 提交后刷新列表 }; const onDelete = (row: any) => { ElMessageBox.confirm(`删除 ${row.name}?
`, '提示', { type: 'warning' }).then(() => { const i = store.findIndex((x) => x.id ===
row.id); if (i >= 0) store.splice(i, 1); ElMessage.success('删除成功');
tableRef.value?.refresh(); }); }; </script>
跑起来你会发现:查询、重置、翻页、新增、编辑、删除全部闭环,且新增/编辑/删除后表格都正确刷新。这就是 YsTable 想让你达到的状态——你只写业务逻辑,列表的脏活它包了。
很多新手把查询表单和"批量操作"按钮都往 toolbarConfig.buttons 里塞,结果按钮越堆越多、查询输入框和按钮挤在一起,窄屏下直接炸。YsTable 留了一个 #page-header 插槽,渲染在表格上方、工具栏左侧,是放查询输入框的天然位置——字典页例子里 #page-header 放名称输入框加"查询/重置","新增"这种业务动作才放 toolbarConfig.buttons。分工有讲究:查询条件是高频交互、要常驻可见,适合放头部;"新增/导出"这类动作按钮,放工具栏右侧更顺手。别把两件事混在一个区域,用户眼睛会累。
vxe-table 原生勾选默认只记当前页。YsTable 用 selectedDataMap 把每页选中行都收着,对外暴露 getAllSelectedData() 合并返回。批量删除这么写:
typescript代码解读复制代码const batchDelete = async () => { const all = tableRef.value?.
getAllSelectedData() || []; // 跨页合并后的全部选中行 if (all.length === 0) { ElMessage.warning
('请先勾选要删除的数据'); return; } await ElMessageBox.confirm(`确认删除选中的 ${all.length} 条数据?
`, '提示', { type: 'warning' }); const ids = all.map((r: any) => r.id).join(','); // 拼成逗号分隔的
id 串 await useDictApi().delete(ids); ElMessage.success('删除成功');
tableRef.value?.clearSelection(); // 清空勾选态 tableRef.value?.refresh(); };
注意最后调 clearSelection() 把勾选态清掉——删完表格还停"选中态"视觉上很怪。getAllSelectedData 和 clearSelection 是 YsTable 在原生 vxe 之上补的最实用能力之一,凡是涉及勾选的页面一定要用上。
列多起来,几个细节让表格专业:
1. formatter 做展示转换。 时间字段存 2026-09-10 13:07:22 只想显示日期;金额要加千分位、加"¥"前缀。vxe 列支持 formatter 函数:
typescript代码解读复制代码columns: [ { title: '创建时间', field: 'createTime', formatter:
({ cellValue }: any) => (cellValue ? cellValue.slice(0, 10) : '-') }, { title: '金额', field: '
amount', formatter: ({ cellValue }: any) => `¥${Number(cellValue || 0).toLocaleString()}` }, ]
formatter 和自定义插槽 #status 都能做渲染转换,区别:纯文本/值简单变形用 formatter 更轻;要放标签、按钮、链接这种有交互或复杂结构的,才上插槽。别一上来就写插槽,杀鸡用牛刀。
2. 固定列。 列太多出现横向滚动时,把关键列固定住:
less代码解读复制代码columns: [ { title: '操作', field: 'action', fixed: 'right', width: 140,
slots: { default: 'action' } }, ]
fixed: 'right' 让操作列永远贴右侧可视区,用户横向滚也不丢操作入口。主表一般列少用不到,明细字段多时很救命。
有个坑几乎人人踩一次:页面里改了 mainSearchForm.name,满心以为表格会自动重新查,结果它纹丝不动。根源在 YsTable 机制——queryParams 是响应式的,组件内部 watch 它变化、把新值合并进 searchParams,但它不会因为你改了条件就自动发请求。自动发请求意味着"用户每敲一个字就查一次库",对大部分后台列表是灾难。所以设计是:你改条件,它记住;你调 search(),它才查。这就是为什么字典页"查询"按钮显式调 searchMainTable(true)。想做"回车即查"或"选择框 change 即查",自己事件里调 tableRef.search() 即可。
search 保留当前页码、reset 回到第一页——删数据用 refresh、查数据用 search、清空筛选用 reset,三个方法各管一摊,别混。
每个页面重写一遍分页逻辑——不信任封装,业务页自己 await api.list() 再 loadData(),十个页面十种 bug。正确:只传 requestFn。
高度写死 calc(100vh - 200px) ——布局一变就崩。用 autoHeight(默认开)。
在 requestFn 里写死分页参数——覆盖组件维护的分页状态,翻页失效。直接透传 params。
查询条件和表格拆两个组件各管各的——查询按钮要调表格 search,耦合更隐晦。用 page-header 插槽收在同一上下文。
YsTable 能"传个 requestFn 就自动分页"的前提,是前后端遵守同一份数据契约。后端接口必须返回这个结构(Spring Boot 侧是 ResponseResult<Page<T>>,MyBatis-Plus 的 Page 恰好长这样):
json代码解读复制代码{ "code": 200, "msg": "操作成功", "data": { "current": 1,
// 当前页码 "size": 10, // 每页条数 "total": 43, // 总记录数
(决定分页器总页数) "records": [ ... ] // 当页数据行 } }
前端请求参数约定:pageNo(当前页)、pageSize(每页条数),再加上查询条件对象。YsTable 内部维护 pageVo 存翻页状态,每次请求把 pageNo/pageSize 和 queryParams 合并后一起交给 requestFn。三个最容易断链的地方:一是后端返回字段名和约定不一致(比如返回 list 而不是 records),表格永远空;二是 total 忘了给,分页器永远只有一页;三是查询条件没合并进 params,后端收到的过滤条件永远是空。联调时表格空白,先按这三个顺序排查,九成是契约对不齐,不是组件问题。
YsTable 暴露的三个刷新方法长得像,职责完全不同,选错了就出现"删完跳回第一页"这种怪现象:
refresh() | 保留当前页 | 保留 | 新增/编辑/删除成功后刷新,停在原地 |
search(params?) | 重置回第 1 页 | 合并新条件 | 用户主动查询、主从联动查右表 |
reset() | 重置回第 1 页 | 清空 | 用户点"重置"按钮 |
记忆口诀:删数据用 refresh、查数据用 search、清筛选用 reset。新增成功后用 search 的问题不大(新数据大概率在第一页),但删除后用 search 就会把用户硬拽回第一页;反之主从联动里用 refresh 就是 bug——切了主表行,右表还停在旧字典翻到的第 3 页。另外 reset 只清"查询条件"不清"翻页状态到第一页以外的东西",它和 refresh 一样都会重新发请求,别误以为 reset 是纯前端清空。
YsTable 发起请求时会自动展示表格 loading,请求返回(无论成功失败)loading 自动收起——这部分组件包了。但错误提示组件不管:如果 requestFn 抛异常或返回非 200,vxe 代理会捕获,表格恢复空态,用户却不知道发生了什么。项目的 request 工具层通常有全局拦截器统一弹 ElMessage.error,所以多数页面不用管;但如果你在 requestFn 里做了额外数据处理(比如 res.data.records.map(...)),要防 res.data 为空的边界:
ini代码解读复制代码const req = async (params: any) => { const res = await useDictApi().list
(params); // 防御:data 或 records 缺失时给空数组,避免 .map 直接抛错 const records = res.data?.
records ?? []; return { ...res, data: { ...res.data, records: records.map((r: any) =>
({ ...r, statusName: r.status === 1 ? '启用' : '禁用' })) } }; };
原则:数据加工放 requestFn 可以,但要做空值防御;错误提示交给全局拦截器,别在业务页重复弹。接口挂了弹三次错(拦截器一次、catch 一次、表格空态一次)比不弹更糟。
这一篇把 YsTable 从"组件"落到了"页面"。你会发现,一个真实的后台列表页,难的从来不是表格本身,而是列表和查询、弹窗、权限、主从联动之间那一堆 glue code。YsTable 的价值,就是把这些 glue 收敛成 requestFn / options / events 三样东西,让你把精力放在业务字段上。
开源地址:
Gitee: gitee.com/lqclf/ys-lo…
GitHub: github.com/lqclf/ys-co…
在线体验:
地址: admin.yscode.cn/
账号: ysadmin
密码: Ysadmin123456
官网地址: yscode.cn
作者:Eric的技术杂货铺
链接:https://juejin.cn/post/7688304091224440882
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。