上一篇拆解了 YsTable 的内部设计与实现。这一篇落到地上:怎么把它用对。学组件最怕"看源码十分钟觉得会了,自己开页面还是对着空模板发呆"------列怎么配、按钮权限怎么挂、主从表怎么联动、查询表单和表格谁管。
我拿开源版里的字典管理页当样板,把 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。
三、列定义详解:columns 配置与自定义渲染
主表的列配置:
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(); // 顺手重置从表,避免停在上一主表数据
};
五、工具栏按钮事件:toolbarButtonClick 接新增
"新增"按钮点下去,要弹出新增字典的对话框。这不在 YsTable 内部,而是你通过 events.toolbarButtonClick 监听:
typescript
const onOpenAddMain = (type: string) => {
mainDialogRef.value.openDialog(type); // 调弹窗暴露的 openDialog 方法
};
MainDialog 是异步组件(defineAsyncComponent),defineExpose 了 openDialog 方法。YsTable 只负责把"新增按钮被点了,code 是 add"这个事件抛出来,弹窗怎么开、表单长啥样,是 dialog 组件的事。这种"组件只管发事件、业务自己接"的边界划得很干净,是 YsTable 好用的原因之一。
六、删除操作:二次确认与 refresh 刷新
列表里的删除走 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 配置
某列需要点表头排序、且排序在后端做(数据量大时前端排序不现实),只要给列加 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 想让你达到的状态------你只写业务逻辑,列表的脏活它包了。
九、page-header 插槽:查询表单与批量操作的最佳落点
很多新手把查询表单和"批量操作"按钮都往 toolbarConfig.buttons 里塞,结果按钮越堆越多、查询输入框和按钮挤在一起,窄屏下直接炸。YsTable 留了一个 #page-header 插槽,渲染在表格上方、工具栏左侧,是放查询输入框的天然位置------字典页例子里 #page-header 放名称输入框加"查询/重置","新增"这种业务动作才放 toolbarConfig.buttons。分工有讲究:查询条件是高频交互、要常驻可见,适合放头部;"新增/导出"这类动作按钮,放工具栏右侧更顺手。别把两件事混在一个区域,用户眼睛会累。
十、跨页批量选中:getAllSelectedData 实战
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 之上补的最实用能力之一,凡是涉及勾选的页面一定要用上。
十一、列配置进阶:formatter 与固定列
列多起来,几个细节让表格专业:
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插槽收在同一上下文。
十四、前后端分页数据契约:Page 返回结构必须对齐
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,后端收到的过滤条件永远是空。联调时表格空白,先按这三个顺序排查,九成是契约对不齐,不是组件问题。
十五、refresh / search / reset 三方法一张表分清
YsTable 暴露的三个刷新方法长得像,职责完全不同,选错了就出现"删完跳回第一页"这种怪现象:
| 方法 | 页码处理 | 查询条件处理 | 典型场景 |
|---|---|---|---|
refresh() |
保留当前页 | 保留 | 新增/编辑/删除成功后刷新,停在原地 |
search(params?) |
重置回第 1 页 | 合并新条件 | 用户主动查询、主从联动查右表 |
reset() |
重置回第 1 页 | 清空 | 用户点"重置"按钮 |
记忆口诀:删数据用 refresh、查数据用 search、清筛选用 reset 。新增成功后用 search 的问题不大(新数据大概率在第一页),但删除后用 search 就会把用户硬拽回第一页;反之主从联动里用 refresh 就是 bug------切了主表行,右表还停在旧字典翻到的第 3 页。另外 reset 只清"查询条件"不清"翻页状态到第一页以外的东西",它和 refresh 一样都会重新发请求,别误以为 reset 是纯前端清空。
十六、requestFn 里的异常处理:表格 loading 会自己收,错误要自己报
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