YsTable 使用:字典管理主从表页面完整实现(列配置、查询、权限按钮、主从联动、增删改查)

上一篇拆解了 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);
        }),
    },
});

几个实战里容易忽略的点:

  1. type: 'seq' 是 vxe 自带的序号列 ,自动按当前页算行号,比手写 index+1 省心,翻页后序号也能连续(配合分页参数)。
  2. slots: { default: 'status' } 是自定义单元格渲染 。状态字段在库里是 0/1,界面要显示"禁用/启用"标签。模板里 <template #status="{ row }"> 接管这一格渲染。field 仍要写 status,否则 vxe 不知道这列绑哪个字段;slots.default 只是告诉它"这格用我给的插槽画"。
  3. 操作列也是插槽 。#action 里放编辑/删除链接,挂了 v-auth 指令做按钮级权限。没 system:dict:edit 权限的人连"编辑"链接都看不到。权限指令是项目自己封装的,但你要知道:列表里的操作按钮权限,是在列渲染这层做的,不是等点了才拦。
  4. 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,三个方法各管一摊,别混。

十三、列表页常见反模式

  1. 每个页面重写一遍分页逻辑 ------不信任封装,业务页自己 await api.list() 再 loadData(),十个页面十种 bug。正确:只传 requestFn。
  2. 高度写死 calc(100vh - 200px) ------布局一变就崩。用 autoHeight(默认开)。
  3. 在 requestFn 里写死分页参数 ------覆盖组件维护的分页状态,翻页失效。直接透传 params。
  4. 查询条件和表格拆两个组件各管各的 ------查询按钮要调表格 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,后端收到的过滤条件永远是空。联调时表格空白,先按这三个顺序排查,九成是契约对不齐,不是组件问题。

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 三样东西,让你把精力放在业务字段上。

开源地址:

在线体验:

官网地址: yscode.cn

相关推荐
白雾茫茫丶1 小时前
VibeCoding 一套 Admin 系统,五种技术栈实现
前端·ai编程·vibecoding
Yeyu1 小时前
不靠 libaums自己如何在 Android 上用 USB 协议把 U 盘读出来
前端
夏天要喝冰可乐1 小时前
Trae 每天自动签到:Serverless 定时任务完整复盘
前端·python
用户7783366132111 小时前
前端开发别拿生产 Key 刷数据:用 MSW 给搜索接口做 Mock
前端·api
光影少年1 小时前
RN启动流程(bundle加载→Bridge初始化→首屏渲染)
前端·react native·react.js
超人气王1 小时前
Agent 提示词工程:从「写 Prompt」到「设计行为控制系统」
前端·前端框架
恋猫de小郭1 小时前
Flutter Golden Tests:给 AI Agent 的 UI 测试系统
android·前端·flutter
mldong1 小时前
管理后台数据国际化:不建翻译表、一列 JSON、后端零改动
vue.js
特级业务专家1 小时前
X6 框选拖拽性能内幕:从 issue 4823 到开源内核(系列 3 篇)之二
前端
特级业务专家1 小时前
X6 框选拖拽性能内幕:从 issue 4823 到开源内核(系列 3 篇)之三
前端