若依前端Vue3表格组件改造

这段时间公司打算部署一个新项目,前后端采用若依的框架,但是前端的官方版本的vue项目是vue2版本的,放在当下肯定是有一点古董了,好在翻看官方文档有一个社区版本的vue3 + element-plus版本的前端项目。于是便下载该项目在这个项目上开发我们的公司项目。

翻看了业务代码,以及考虑到后期的开发效率上,觉得可以把表格部分再一步封装成一个组件,以便于后期的快速开发,于是就决定开干,参考了登录日志的代码,准备先拿一个页面改造看看在推广到后续的其他页面。

基本思路

原先的想法很简单,是想着把表格里常用的queryprams,page等写成一个useTable,然后到具体的业务代码里直接引用useTable就行,但是实际开发发现这样还是得写不少el-table-column,开发上也没有省多上事情,于是进一步想了一下干脆整个表格展示的数据写在一个columns里去然后直接数据生成。最后思考又发现,似乎每个页面的搜索栏部分也是标配的,那不如把搜索栏也用一个searchs数组生成出来。

这开发过程中碰到了各种坑,不再细说了,直接展示业务和组件代码好了。

业务代码

以登录日志为例,改造如下:

js 复制代码
<template>
  <div class="app-container">
    <!-- 登录日志表格 -->
    <BulleTable ref="bulleRef" tableRef="loginInfoRef" 
      :columns="columns" 
      :getList="list" 
      :showSelection="true" 
      @selection-change="handleSelectionChange"
      :multiple="multiple"
      :defaultSort="defaultSort" 
      :deletes="{ permis: ['monitor:logininfor:remove'], handle: handleDelete }"
      :exports="{ permis: ['monitor:logininfor:export'], handle: handleExport }"
      :hasColumnsSift="true"
      :searchs="searchs">
      <template #header-operations>
        <el-col :span="1.5">
          <el-button type="danger" plain icon="Delete" @click="handleClean" v-hasPermi="['monitor:logininfor:remove']">清空</el-button>
        </el-col>
        <el-col :span="1.5">
          <el-button type="primary" plain icon="Unlock" :disabled="single" @click="handleUnlock" v-hasPermi="['monitor:logininfor:unlock']">解锁</el-button>
        </el-col>
      </template>

      <!-- 自定义表格列 -->
      <template #status="scope">
        <dict-tag :options="sys_common_status" :value="scope.row.status" />
      </template>
      <template #loginTime="scope">
        <span>{{ parseTime(scope.row.loginTime) }}</span>
      </template>
    </BulleTable>
  </div>
</template>

<script setup name="Logininfor">
import { list, delLogininfor, cleanLogininfor, unlockLogininfor } from "@/api/monitor/logininfor";
import BulleTable from '@/components/BulleTable';

const { proxy } = getCurrentInstance();
const { sys_common_status } = proxy.useDict("sys_common_status");

const ids = ref([]);
const multiple = ref(true);
const defaultSort = ref({ prop: "loginTime", order: "descending" });
const single = ref(true);
const selectName = ref("");
const bulleRef = ref(null);

// 搜索项
const searchs = ref([
  { prop: "ipaddr", label: "登录地址", placeholder: "请输入登录地址", type: "input" },
  { prop: "userName", label: "用户名称", placeholder: "请输入用户名称", type: "input" },
  { prop: "status", label: "登录状态", placeholder: "请输入登录状态", type: "select",
    select: { options: sys_common_status, label: "label", value: "value" }
  },
  { prop: "dateRange", label: "登录时间", type: "daterange", dateRange: [] },
]);

// 列表项
const columns = [
  { prop: "infoId", label: "访问编号", visible: true },
  { prop: "userName", label: "用户名称", showOverflowTooltip: true, sortable: "custom", sortOrders: ["descending", "ascending"], visible: true },
  { prop: "ipaddr", label: "地址", showOverflowTooltip: true, visible: true },
  { prop: "loginLocation", label: "登录地点", showOverflowTooltip: true, visible: true },
  { prop: "os", label: "操作系统", showOverflowTooltip: true, visible: true },
  { prop: "browser", label: "浏览器", showOverflowTooltip: true, visible: true },
  { prop: "status", label: "登录状态", slot: "status", visible: true},
  { prop: "msg", label: "描述", showOverflowTooltip: true, visible: true },
  { prop: "loginTime", label: "访问时间", slot: "loginTime", sortable: "custom", sortOrders: ["descending", "ascending"], width: "180", visible: true},
];

/** 多选框选中数据 */
function handleSelectionChange(selection) {
  ids.value = selection.map((item) => item.infoId);
  multiple.value = !selection.length;
  single.value = selection.length != 1;
  selectName.value = selection.map((item) => item.userName);
}

/** 删除按钮操作 */
function handleDelete(row) {
  const infoIds = row.infoId || ids.value;
  proxy.$modal
    .confirm('是否确认删除访问编号为"' + infoIds + '"的数据项?')
    .then(function () {
      return delLogininfor(infoIds);
    })
    .then(() => {
      bulleRef.value.queryParams.pageNum = 1;
      bulleRef.value.$getList();
      proxy.$modal.msgSuccess("删除成功");
    })
    .catch(() => {});
}

/** 清空按钮操作 */
function handleClean() {
  proxy.$modal
    .confirm("是否确认清空所有登录日志数据项?")
    .then(function () {
      return cleanLogininfor();
    })
    .then(() => {
      bulleRef.value.$resetQuery();
      proxy.$modal.msgSuccess("清空成功");
    })
    .catch(() => {});
}

/** 解锁按钮操作 */
function handleUnlock() {
  const username = selectName.value;
  proxy.$modal
    .confirm('是否确认解锁用户"' + username + '"数据项?')
    .then(function () {
      return unlockLogininfor(username);
    })
    .then(() => {
      proxy.$modal.msgSuccess("用户" + username + "解锁成功");
    })
    .catch(() => {});
}

/** 导出按钮操作 */
function handleExport() {
  proxy.download(
    "monitor/logininfor/export",
    {
      ...bulleRef.value.queryParams,
    },
    `config_${new Date().getTime()}.xlsx`
  );
}
</script>

可以看到我在实际使用上做了一些扩展,首先是表格部分,我发现多选框勾选不太适合封装到组件里去,因为每个业务的勾选之后的取值不同,所以对外暴露了一个selection-change方法可以自行扩展。在搜索栏和表格之间有一些针对表格的操作按钮,查看整个项目代码后发现,删除导出可能比较常用,于是将这两个按钮封装到组件里,对外暴露一个permis用于添加不同的权限,暴露一个handle用于自行扩展具体的业务方法。

一个小坑

在实际开发中发现,若依的这个前端项目表格有一个表格列的筛选功能,代码实现在src/components/RightToolbar/index.vue中,这里面的显隐列信息的props也叫columns,直接和我的名字冲突了,思考再三决定修改RightToolbar的源码,其实也没改什么,就是在实际开发中,如果需要显隐列的功能则需要给columns的每一列添加visible属性。RightToolbar组件根据key值生成dropdown-menu,我这里认为key和prop同名,所以修改源码做了一个容错处理。

js 复制代码
// RightToolbar
<template>
...
<el-dropdown-menu>
  <template v-for="item in columns" :key="item.key || item.prop"> // 可以写key也可以不写
    <el-dropdown-item>
      <el-checkbox :checked="item.visible" @change="checkboxChange($event, item.label)" :label="item.label" />
    </el-dropdown-item>
  </template>
</el-dropdown-menu>
...
</template>

<script>
...
// 右侧列表元素变化
function dataChange(data) {
  for (let item in props.columns) {
    const key = props.columns[item].key || props.columns[item].prop;
    props.columns[item].visible = !data.includes(key);
  }
}
...
</script>

下面就来看一下具体的组件代码吧,代码写在了src/components下并命名为BulleTable。

表格组件(BulleTable)

表格组件代码如下:

js 复制代码
<template>
  <section>
    <!-- 搜索框 -->
    <SearchForms 
      ref="searchRef"
      v-show="showSearchSlot" 
      :searchs="searchs" 
      v-model:queryParams="queryParams" 
      @queryTable="$getList"
      @resetQuery="resetQuery" />

    <!-- 表格头部 -->
    <el-row :gutter="10" class="mb4">
      <slot name="header-operations"></slot>
      <el-col :span="1.5">
        <el-button type="danger" plain icon="Delete" :disabled="multiple" @click="deletes.handle" v-hasPermi="deletes.permis">删除</el-button>
      </el-col>
      <el-col v-if="exports.show" :span="1.5">
        <el-button type="warning" plain icon="Download" @click="exports.handle" v-hasPermi="exports.permis">导出</el-button>
      </el-col>
      <right-toolbar 
        v-model:showSearch="showSearchSlot" 
        @update:showSearch="toggleSearch"  
        @queryTable="$getList"
        :columns="hasColumnsSift ? columnsSlot : null"
        @update:columns="handleColumns"></right-toolbar> 
    </el-row>

    <el-table :ref="tableRef" v-loading="loading" :data="tableData" 
      @selection-change="handleSelectionChange"
      :default-sort="defaultSortSlot"
      @sort-change="handleSortChange">
      <el-table-column v-if="showSelection" type="selection" width="55" align="center" />
      <template v-for="(column, index) in columnsSlot" :key="column.prop">
        <el-table-column 
          v-if="hasColumnsSift ? column.visible : true"
          :prop="column.prop" 
          :label="column.label" 
          :width="column.prop==='operations' ? '180' : (column.width || 'auto')" align="center"
          :show-overflow-tooltip="column.showOverflowTooltip || false"
          :align="column.align || 'center'"
          :sortable="column.sortable || false"
          :sort-orders="column.sortOrders || ['ascending', 'descending', null]"
          :class-name="column.prop==='operations' ? 'small-padding fixed-width' : ''">
          <template v-if="column.slot" #default="{ row }">
            <slot :name="column.slot" :row="row"></slot>
          </template>
        </el-table-column>
      </template>
    </el-table>
    <pagination 
      v-show="total > 0" 
      :total="total" 
      v-model:page="queryParams.pageNum" 
      v-model:limit="queryParams.pageSize" 
      @pagination="$getList" />
  </section>
</template>

<script setup name="BulleTable">
import { computed, onMounted, watch } from 'vue';
const { proxy } = getCurrentInstance();
import SearchForms from './SearchForms';
import useTable from './useTable';
const { tableData, loading, fetchData, total, queryParams } = useTable();

const props = defineProps({
  /* 搜索框相关 */
  searchs: {
    type: Array,
    default: () => {
      return []
    }
  },

  /* 表格头部可公共部分 */
  showSearch: {
    type: Boolean,
    default: true
  },
  // 是否需要列头搜索
  hasColumnsSift: {
    type: Boolean,
    default: false,
  },
  //导出配置 大部分表格都有故做成props
  exports: {
    show: {
      type: Boolean,
      default: true
    },
    permis: {
      type: Array,
      default: () => {
        return []
      }
    },
    handle: {
      type: Function,
      default: null
    }
  },
  //删除配置 大部分表格都有故做成props
  deletes: {
    show: {
      type: Boolean,
      default: true
    },
    permis: {
      type: Array,
      default: () => {
        return []
      }
    },
    handle: {
      type: Function,
      default: null
    }
  },

  /* 具体表格部分 */
  tableRef: {
    type: String,
    default: 'elTable'
  },
  /**
   * 表格列
   * {
   *   key: 'name',  // 如果需要显隐列
   *   visible: true // 如果需要显隐列
   *   prop: 'name',
   *   label: '名称',
   *   slot: 'name'
   * }
   */
  columns: {
    type: Array,
    default: () => {
      return []
    }
  },
  // 获取列表数据需要根据父层传入的具体的方法
  getList: {
    type: Function,
    default: null
  },

  // 是否显示多选框
  showSelection: {
    type: Boolean,
    default: false
  },
  // 是否多选了数据
  multiple: {
    type: Boolean,
    default: false
  },

  // 排序相关
  defaultSort: {
    type: Object,
    default: () => null
  },
});

// emits
const emits = defineEmits(['update:showSearch', 'selection-change']);

// SerachForms组件ref
const searchRef = ref(null);

/** 获取列表数据 */
const $getList = () => {
  return fetchData(props.getList);
}

/** 导出操作 */
const exports = computed(() => {
  return {
    ...props.exports,
    show: props.exports.hasOwnProperty('show') ? props.exports.show : true,
  }
});

/** 删除操作 */
const deletes = computed(() => {
  return {
    ...props.deletes,
    show: props.deletes.hasOwnProperty('show') ? props.deletes.show : true,
  }
});

/** 搜索相关 */
const showSearchSlot = ref(props.showSearch);
const toggleSearch = (event) => {
  emits('update:showSearch', showSearchSlot.value);
}

/* 显隐列相关 */
const columnsSlot = ref(props.columns);
const handleColumns = (columns) => {
  columnsSlot.value = columns;
}

// 表格多选
const selections = ref([]);
const handleSelectionChange = (selection) => {
  selections.value = selection;
  emits('selection-change', selection);
}

// 创建本地default-sort
const defaultSortSlot = ref(props.defaultSort);
/** 重置函数 */
const resetQuery = (params) => {
  queryParams.value = params;
  props.searchs.forEach((item) => {
    if (item.dateRange) {
      item.dateRange = [];
    }
    if (item.dateRangeName) {
      params['begin'+item.dateRangeName] = undefined;
      params['end'+item.dateRangeName] = undefined;
    } else {
      params.beginTime = undefined;
      params.endTime = undefined;
    }
  })
  props.defaultSort ? proxy.$refs[props.tableRef].sort(props.defaultSort.prop, props.defaultSort.order) : $getList();
};

const $resetQuery = () => {
  searchRef.value.resetQuery();
}

/** 排序触发事件 */
function handleSortChange(column, prop, order) {
  queryParams.value.orderByColumn = column.prop;
  queryParams.value.isAsc = column.order;
  $getList();
}

// 初始化数据
if (!defaultSortSlot.value) {
  defaultSortSlot.value = { prop: 'createTime', order: 'descending' };
}
$getList();

// 暴露给父组件的数据和方法
defineExpose({ queryParams, $getList, $resetQuery });
</script>

也没什么好说的,注释里基本都写清楚了,这里在开发完成以后碰到了的一个坑就是default-sort不能为空否则控制台直接报错,但是又不能给一个默认值,我查看了项目代码通常在页面初次进入以后获取后台数据表格不会采用排序规则,但是点击重置按钮则会有排序的加入,所以我这里定了一个defaultSortSlot获取props里的defaultSort,如果为空则附一个初始值,而初始化表格不会走排序规则,点击重置按钮的时候则判断props里的defaultSort是不是空的,是的话则说明用户并没有传排序规则直接调用列表函数,否则添加排序从后台再获取新的列表数据。

可以看到这里我将一些内容写到了useTable里,其实也可以就在这个组件里写,但奈何我是先写的useTable,所以将错就错吧(doge),具体代码如下

js 复制代码
// useTable
import { ref } from "vue";
import { ElMessage } from "element-plus";

export default function useTable() {
  const tableData = ref([]);
  const total = ref(0);
  const loading = ref(false);
  const queryParams = ref({
    pageNum: 1,
    pageSize: 10,
  });

  const fetchData = async (getList) => {
    loading.value = true;
    
    try {
      const response = await getList(queryParams.value);
      if (response.code === 200) {
        tableData.value = response.rows;
        total.value = response.total;
        loading.value = false;
      } else {
        ElMessage.error(response.msg);
        loading.value = false;
      }
    } catch (error) {
      ElMessage.error(error.msg || error);
    }
  };

  return {
    tableData,
    total,
    loading,
    fetchData,
    queryParams,
  }
}

同时,我将搜索栏部分封住成了一个SearchForms组件,下面介绍一下这个组件。

搜索栏组件(SearchForms)

具体代码如下:

js 复制代码
<template>
  <el-form :model="queryParamsSlot" ref="queryRef" :inline="true" :label-width="labelWidth">
    <el-form-item v-for="(item, index) in searchs" :key="item.prop" :label="item.label" :prop="item.prop">
      <el-input v-if="item.type==='input'" v-model="queryParamsSlot[item.prop]" :placeholder="item.placeholder" :clearable="item.clearable || true" :style="{width: item.width || '240px'}" @keyup.enter="handleQuery" />
      
      <el-select v-if="item.type==='select'" v-model="queryParamsSlot[item.prop]" :placeholder="item.placeholder" :clearable="item.clearable || true" :style="{width: item.width || '240px'}">
        <el-option v-for="dict in item.select.options" :key="dict[item.select.key ? item.select.key : item.select.value]" :label="dict[item.select.label]" :value="dict[item.select.value]" />
      </el-select>

      <el-date-picker v-if="['daterange', 'datetimerange'].includes(item.type)" 
        v-model="item.dateRange" 
        :value-format="item.valueFormat || 'YYYY-MM-DD HH:mm:ss'" 
        :type="item.type" 
        :range-separator="item.rangeSeparator || '-'" 
        :start-placeholder="item.startPlaceholder || '开始日期'" 
        :end-placeholder="item.endPlaceholder || '结束日期'" 
        @change="(event) => handleDateRangeChange(event, item)" 
        :style="{width: item.width || '308px'}"
        :default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"></el-date-picker>
    </el-form-item>

    <el-form-item>
      <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
      <el-button icon="Refresh" @click="resetQuery">重置</el-button>
    </el-form-item>
  </el-form>
</template> 

<script setup name="SearchForms">
const { proxy } = getCurrentInstance();

const props = defineProps({
  /** searchs项目大致结构
    {
      type: "input | select | daterange | datetimerange"
      prop: "",
      label: "",
      placeholder: "",
      clearable: true, //默认
      width: 240, //默认
      select: { // 当type为select时
        options: [],
        key: "",
        label: "",
        value: "",
      },
      dateRange: [],
      dateRangeName: "dateRange"
      rangeSeparator: "-"
      startPlaceholder: "开始日期",
      endPlaceholder: "结束日期",
      valueFormat: "YYYY-MM-DD HH:mm:ss", // 默认
    }
  */
  searchs: {
    type: Array,
    default: () => {
      return []
    }
  },
  queryParams: {
    type: Object,
    default: () => {
      return {}
    }
  },
  labelWidth: {
    type: String,
    default: '68px'
  }
});

// emits
const emits = defineEmits(["update:queryParams", "queryTable", "resetQuery"]);

// 初始化查询参数
const queryParamsSlot = ref(props.queryParams);
if (Object.keys(queryParamsSlot.value).length <= 2 
  && queryParamsSlot.value.hasOwnProperty('pageNum')
  && queryParamsSlot.value.hasOwnProperty('pageSize')) {
  for (let i = 0; i < props.searchs.length; i++) {
    if (!Object.keys(queryParamsSlot.value).includes(props.searchs[i].prop)) {
      queryParamsSlot.value[props.searchs[i].prop] = undefined;
    }
  }
  queryParamsSlot.value.orderByColumn = undefined;
}
emits("update:queryParams", queryParamsSlot.value);

/** 起止时间函数 */
function handleDateRangeChange(event, item) {
  if (item.dateRangeName) {
    queryParamsSlot.value['begin'+item.dateRangeName] = event ? event[0] : undefined;
    queryParamsSlot.value['end'+item.dateRangeName] = event ? event[1] : undefined;
  } else {
    queryParamsSlot.value.beginTime = event ? event[0] : undefined;
    queryParamsSlot.value.endTime =  event ? event[1] : undefined;
  }
  emits("update:queryParams", queryParamsSlot.value);
}

/** 搜索按钮操作 */
function handleQuery() {
  queryParamsSlot.value.pageNum = 1;
  emits("queryTable", queryParamsSlot.value);
}

/** 重置按钮操作 */
function resetQuery() {
  proxy.resetForm("queryRef");
  queryParamsSlot.value.pageNum = 1;
  emits("resetQuery", queryParamsSlot.value);
}

// 暴露给父组件的数据和方法
defineExpose({ resetQuery });
</script>

常用的搜索栏里面应该是有输入框下拉框和时间插件等。这里我根据自己目前的业务需要只考虑了四种情况,分别是InputSelect, date-range, datetime-range。其他有需要的话可以再扩展,需要注意的是这里的起止时间函数,这个代码是参考原项目中的起名规则,众所周知起止时间的element-plus组件会返回一个数组,这里则进行一个简要处理,如果searchs中的具体数据提供了dateRangeName则在该名称基础上拼接beginend作为传给后端的字段否则就直接使用beginTimeendTime作为字段名,这里是因为若依的前端默认就是这个名字后端不太可能修改,有的项目可能前后端约定了其他前缀只要自行修改就好。

这里另一个难点是重置按钮,重置按钮是写在SearchForms组件里的,但实际开发使用的是BulleTable,所以在SerchForms组件里暴露resetQuery方法给BulleTable,BulleTable在这个基础上封装一个$resetQuery方法再暴露给最外层,最外层的业务代码则实际调用的是这个名称的重置方法。

结尾

综上,这就是我目前对于这个社区版本的若依Vue3前端的一点二次开发,后期业务开发都可以使用这个组件,这样可以省不少用来复制粘贴el-table-column时间了,哈哈。如果后期整个项目的表格想调整增加或减少功能也只用改一下组件就好。感谢各位阅读,希望对你有些帮助。

相关推荐
余生H28 分钟前
前端Python应用指南(二)深入Flask:理解Flask的应用结构与模块化设计
前端·后端·python·flask·全栈
outstanding木槿32 分钟前
JS中for循环里的ajax请求不数据
前端·javascript·react.js·ajax
酥饼~39 分钟前
html固定头和第一列简单例子
前端·javascript·html
一只不会编程的猫43 分钟前
高德地图自定义折线矢量图形
前端·vue.js·vue
m0_748250931 小时前
html 通用错误页面
前端·html
来吧~1 小时前
vue3使用video-player实现视频播放(可拖动视频窗口、调整大小)
前端·vue.js·音视频
魔术师卡颂1 小时前
最近看到太多 cursor 带来的焦虑,有些话想说
前端·aigc·openai
鎈卟誃筅甡1 小时前
Vuex 的使用和原理详解
前端·javascript
火山方舟1 小时前
解密!企业级智能客服高效运营的秘密武器 | 大模型流程设计与Prompt模版
前端·人工智能·稀土
呆呆小雅1 小时前
二、创建第一个VUE项目
前端·javascript·vue.js