vue3 + ts基于el-table和各种交互组件的二次封装,写出统一的列表页

2,061 阅读4分钟

1. 需求背景

在管理后台中,有很多的列表页,当多人开发时,可能会造成列表页的整体样式有些偏差,为了列表页的统一,以及效率的提升,将列表拆分,封装组件,开发者只需关心数据即可。

一般的列表页都长下边这样。按照绿色框进行划分,划分为【搜索区】和【列表区】,其中【列表区】一般包含【按钮区】、【表格区】、【分页区】

20230522153713.jpg

2.【搜索区】-> TYSearch.vue,【源码

集合了常见用户交互组件:

  • el-input
  • el-select
  • el-date-picker
  • el-checkbox-group
  • el-radio-group
  • el-cascader
<div class="ty-search-item" v-for="(item, idx) in data" :key="idx">
  <span class="ty-search-label">{{ item.label }}:</span>
  <!-- input -->
  <el-input
    v-if="item.type === 'input'"
    v-model="searchQuery[item.param]"
    clearable
    v-bind="item.config"
  />
  
  <!-- input number -->
  <el-input-number
    v-if="item.type === 'inputnumber'"
    v-model="searchQuery[item.param]"
    clearable
    v-bind="item.config"
  />
  
  <!-- select -->
  <el-select
    v-if="item.type === 'select'"
    v-model="searchQuery[item.param]"
    clearable
    v-bind="item.config"
  >
    <el-option
      v-for="option in item.options"
      :key="option.value"
      :label="option.label"
      :value="option.value"
    ></el-option>
  </select>
  
  <!-- cascader -->
  <el-cascader
    v-if="item.type === 'cascader'"
    v-model="searchQuery[item.param]"
    clearable
    v-bind="item.config"
  />
  
  <!-- date -->
  <el-date-picker
    v-if="dateTypes.includes(item.type)"
    v-model="searchQuery[item.param]"
    :type="item.type"
    v-bind="item.config"
  />
  
  <!-- radio -->
  <el-radio-group
    v-if="item.type === 'radio'"
    v-model="searchQuery[item.param]"
    v-bind="item.config"
  >
    <el-radio
      v-for="(option, idx) in item.options"
      :key="idx"
      :label="option.value"
    >
      {{ option.label }}
    </el-radio>
  </el-radio-group>
  
  <!-- checkbox -->
  <el-checkbox-group
    v-if="item.type === 'checkbox'"
    v-model="searchQuery[item.param]"
    v-bind="item.config"
  >
    <el-checkbox
      v-for="(option, idx) in item.options"
      :key="idx"
      :label="option.value"
    >
      {{ option.label }}
    </el-checkbox>
  </el-checkbox-group>
  
  <!-- 按钮区 -->
  <div class="ty-search-item">
    <el-button type="primary" @click="search">查询</el-button>
    <el-button type="primary" plain @click="reset">重置</el-button>
    <el-button v-if="showExport" @click="exportData">导出</el-button>
  </div>
</div>

自此,一个基本的【搜索区域】组件基本就完成了

2.1 TYSearch props

  1. 定义接口,限制props的类型
interface SearchData {
  data: SearchItem[] // 搜索项
  showExport: boolean // 导出
}
  1. 其中,SearchItem为每一个搜索项的参数,具体配置如下
interface SearchItem {
  label: string // 搜索项名称
  type: string // 搜索项类型,input,select, date, daterange...
  defaultValue?: any // 默认value值
  param: string // v-model绑定的名称
  config?: object // element-plus的attribute,默认所有的搜索都可以clearable,当不需要clearable时,传false
  options?: any[] // 下拉选项,type为select,checkbox,radio时必传
  // 事件
  events?: {
    [key: string]: any
  }
  // 下拉默认属性
  props?: {
    label: string
    value: string
  }
}
  1. 为props设置默认值
const props = withDefaults(defineProps<SearchData>(), {
  showExport: false
})

2.2 初始化搜索项绑定值

  1. 定义一个响应式变量,在onMounted时,设置默认值
const searchQuery = reactive({})

function getObjectOwnKey(targetObj: object, key: string) {
  return Object.prototype.hasOwnProperty.call(targetObj, key)
}

onMounted(() => {
  props.data.forEach((item) => {
    searchQuery[item.param] = getObjectOwnKey(item, "defaultValue") ? item.defaultValue : ""
  })
})
  1. 当搜索项类型type为select,radio,checkbox时,则必须传入选择项,所以做个校验
props.data.forEach((item) => {
    searchQuery[item.param] = getObjectOwnKey(item, "defaultValue") ? item.defaultValue : ""
    try {
      if (
        item.type === "select" ||
        item.type === "radio" ||
        item.type === "checkbox"
      ) {
        // select、radio、checkbox时options为必传
        if (!getObjectOwnKey(item, "options")) {
          throw new Error(item.type + "必须要有options")
        }
      }
    } catch (e) {
      console.error(e)
    }
  })

2.3 select,radio,checkbox选择项的可配置性

  1. 默认使用的字段为:名称 - label,值 - value,也可自定义,通过SearchItem.props传入

  2. 修改相应代码TYSearch.vue

<!-- select -->
<el-select
  v-if="item.type === 'select'"
  v-model="searchQuery[item.param]"
  clearable
  v-bind="item.config"
>
  <el-option
    v-for="option in item.options"
    :key="item.props ? option[item.props.value] : option.value"
    :label="item.props ? option[item.props.label] : option.label"
    :value="item.props ? option[item.props.value] : option.value"
  ></el-option>
</el-select>

<!-- radio -->
<el-radio-group
  v-if="item.type === 'radio'"
  v-model="searchQuery[item.param]"
  v-bind="item.config"
>
  <el-radio
    v-for="(option, idx) in item.options"
    :key="idx"
    :label="item.props ? option[item.props.value] : option.value"
  >
    {{ item.props ? option[item.props.label] : option.label }}
  </el-radio>
</el-radio-group>

<!-- checkbox -->
<el-checkbox-group
  v-if="item.type === 'checkbox'"
  v-model="searchQuery[item.param]"
  v-bind="item.config"
>
  <el-checkbox
    v-for="(option, idx) in item.options"
    :key="idx"
    :label="item.props ? option[item.props.value] : option.value"
  >
    {{ item.props ? option[item.props.label] : option.label }}
  </el-checkbox>
</el-checkbox-group>
  1. 使用
<TYSearch
  :data="data"
/>

<script setup lang="ts">
const data = [
  {
    props: {
      label: 'key',
      value: 'val'
    }
  }
]
</script>

2.4 搜索项事件

  1. 当需要一些事件处理时,需要在SearchItem.events中进行配置
  2. 修改相应代码TYSearch.vue

因为el-input-numberchange事件有两个参数,所以单独写了一份

<el-input-number
    v-if="item.type === 'inputnumber'"
    v-model="searchQuery[item.param]"
    clearable
    v-bind="item.config"
    @blur="componentEvent(item, 'blur', $event)"
    @focus="componentEvent(item, 'focus', $event)"
    @change="(v1: number | undefined, v2: number | undefined) => inputNumberChange(v1, v2, item)"
  />
  
<script setup lang="ts">
// 组件事件 - 通用
function componentEvent(item: SearchItem, eventType: string, e?: any) {
  if (!item.events) return
  if (getObjectOwnKey(item.events, eventType)) {
    item.events[eventType](searchQuery[item.param], e)
  }
}

// el-input-number change事件
function inputNumberChange(
  newValue: number | undefined,
  oldValue: number | undefined,
  item: SearchItem
) {
  if (!item.events) return
  if (getObjectOwnKey(item.events, "change")) {
    item.events["change"](newValue, oldValue)
  }
}
</script>
  1. 使用
<TYSearch
  :data="data"
/>

<script setup lang="ts">
const data = [
  {
    events: {
      change: nameChange
    }
  }
]

function nameChange(val) {
  // TODO
}
</script>

2.5 TYSearch Emits

  1. 包含【查询】和【导出】
const emits = defineEmits(["search", "export"])

2.6 TYSearch Exposes

  1. 修改搜索项的默认值,多用于不同搜索项联动,修改了一个搜索项的值,改变另一个的初始值
defineExpose({
  changeParam
})

function changeParam(key: string, val: any) {
  if (getObjectOwnKey(searchQuery, key)) {
    searchQuery[key] = val
  }
}

2.7 完整使用示例

<TYSearch
  ref="tySearchRef"
  :data="searchQuery"
  show-export
  @search="search"
  @export="exportData"
/>

<script setup lang="ts">
import TYSearch from 'your/component/url'

// 因为下拉框的options有接口请求的内容,所以这里使用computed来返回搜索项
const searchQuery = computed<SearchItem[]>(() => {
  return [
    {
      type: "input",
      label: "预约单号",
      param: "orderNo",
      defaultValue: null,
      config: {
        clearable: false,
        placeholder: "请输入预约单号",
        maxlength: "20",
        "show-word-limit": true
      }
    },
    {
      type: "cascader",
      label: "区镇",
      param: "collectionPointIds",
      defaultValue: [],
      config: {
        options: regionList.value,
        filterable: true,
        props: { multiple: true },
        "collapse-tags": true,
        "collapse-tags-tooltip": true,
        "show-all-levels": false,
        placeholder: "请选择区镇"
      }
    },
    {
      type: "select",
      label: "状态",
      param: "orderStatus",
      defaultValue: null,
      options: appointmentOrderStatusEnum.getList(),
      config: {
        placeholder: "请选择状态"
      },
      events: {
        change: changeStatus
      },
      props: {
        label: "value",
        value: "key"
      }
    },
    {
      type: "daterange",
      label: "清运完成时间",
      param: "actualCleanTime",
      defaultValue: null,
      config: {
        "start-placeholder": "开始日期",
        "end-placeholder": "结束日期",
        "value-format": "YYYY-MM-DD"
      }
    }
  ]
})

const searchData = reactive({
  orderNo: null as any,
  collectionPointIds: [] as any[],
  actualCleanTime: null as any,
  orderStatus: null as any,
})

// 查询
function search(querys?: any) {
  // TODO
}

// 导出
function exportData() {
  // TODO
}

const tySearchRef = ref()
function changeStatus(val: string) {
  tySearchRef.value.changeParam("orderNo", val)
}
</script>

3.【列表】-> TYTable.vue,【源码

包含了列表顶部的操作按钮,表格数据,分页器内容

<main class="ty-list">
  <!-- 列表 -->
  <div class="ty-table-wrap">
      <el-table
        ref="elTableRef"
        :data="tableData"
        v-bind="tableConfig"
        height="100%"
        v-loading="loading"
      >
        <el-table-column
          v-for="(item, idx) in columns"
          :key="idx"
          v-bind="item"
          show-overflow-tooltip
        >
        </el-table-column>
      </el-table>
  </div>
  
  <!-- 分页 -->
  <div class="ty-page-wrap">
    <el-pagination
      v-model:current-page="page.pageBean.pageNum"
      v-model:page-size="page.pageBean.pageSize"
      :page-sizes="pageSizes"
      background
      layout="total, prev, pager, next, sizes, jumper"
      :total="page.total"
      @size-change="handleSizeChange"
      @current-change="getTableList"
    />
  </div>
</main>

3.1 TYTable props

  1. 定义接口,限制props的类型
interface ColumnItem {
  [key: string]: any
}

interface PropsType {
  action: any // 接口封装函数
  searchData?: object // 搜索项
  showCheckbox?: boolean // 是否表格多选
  showIndex?: boolean // 是否有序号列
  columns: ColumnItem[] // 表格列
  tableConfig?: object // el-table的attribute
  defalutPageSize?: number // 默认的每页数量
  pageSizes?: number[] // 页码Enum
  onSuccess?: (res: any) => void
  onError?: (err: any) => void
  responseProps?: {
    list: string
    total: string
  } // api请求默认赋值名称
  pageProps?: {
    pageNum: string
    pageSize: string
  } // 分页器参数
}
  1. 为props设置默认值
const props = withDefaults(defineProps<PropsType>(), {
  defalutPageSize: 10,
  pageSizes: () => [10, 20, 50, 100],
  showCheckbox: false,
  showIndex: false,
  responseProps: () => {
    return {
      list: "items",
      total: "total"
    }
  },
  pageProps: () => {
    return {
      pageNum: "pageNum",
      pageSize: "pageSize"
    }
  }
})

3.2 多选表格或有序号列表格

  1. props传入showCheckboxshowIndex
  2. 修改TYTable.vue代码,添加el-table-column
<el-table-column
  v-if="showCheckbox"
  type="selection"
  fixed="left"
  width="50px"
></el-table-column>
<el-table-column
  v-if="showIndex"
  label="序号"
  type="index"
  width="55px"
></el-table-column>

3.3 TYTable Slots

  1. 修改TYTable.vue代码
<el-table>
  <template #empty>
    <slot name="empty"></slot>
  </template>
</el-table>
  1. 使用
<TYTable>
  <template #empty>
    <span>没有数据,请重试</span>
  </template>
</TYTable>

3.4 TYTable Column Slots

  1. 修改TYTable.vue代码
<el-table-column
  v-for="(item, idx) in columns"
  :key="idx"
  v-bind="item"
  show-overflow-tooltip
>
  <template v-if="item.slot" #default="scope">
    <slot :name="item.prop" :row="scope.row"></slot>
  </template>
</el-table-column>
  1. 使用时需要在props.columns中需要插槽的内容添加slot: true属性
<TYTable :columns="columns">
  <template #operate="{ row }">
    <el-button link @click="edit(row.id)">编辑</el-button>
  </template>
</TYTable>

<script setup lang="ts">
const columns = [
  {
    slot: true,
    label: "操作",
    prop: "operate",
    fixed: "right",
    width: "200px"
  }
]

</script>

3.5 TYTable Events

  1. 修改TYTable.vue代码,这里只实现了以下5个事件
<el-table
  @row-click="elRowClick"
  @select="elSelect"
  @select-all="elSelectAll"
  @selection-change="elSelectionChange"
  @cell-click="elCellClick"
>

<script setup lang="ts">
/**
 * emits
 */
const emits = defineEmits([
  "rowClick",
  "select",
  "selectAll",
  "selectionChange",
  "cellClick"
])

// element-plus的表格事件
function elRowClick(row: any, column: any, event: any) {
  emits("rowClick", row, column, event)
}
function elSelect(selection: any, row: any) {
  emits("select", selection, row)
}
function elSelectAll(selection: any) {
  emits("selectAll", selection)
}
function elSelectionChange(selection: any) {
  emits("selectionChange", selection)
}
function elCellClick(row: any, column: any, cell: any, event: any) {
  emits("cellClick", row, column, cell, event)
}
</script>

3.6 TYTable Exposes

  1. 修改TYTable.vue代码,这里只实现了以下5个方法
<script setup lang="ts">
/**
 * expose
 */
defineExpose({
  getList: handleSizeChange, // 获取列表数据方法
  clearSelection,
  getSelectionRows,
  toggleRowSelection,
  toggleAllSelection
})

// element-plus方法
function clearSelection() {
  elTableRef.value.clearSelection()
}
function getSelectionRows() {
  elTableRef.value.getSelectionRows()
}
function toggleRowSelection(row: any, selected?: boolean) {
  elTableRef.value.toggleRowSelection(row, selected)
}
function toggleAllSelection() {
  elTableRef.value.toggleAllSelection()
}
</script>

3.7 表格顶部操作按钮插槽

  1. 修改TYTable.vue代码,在el-table前添加一个插槽
<main class="ty-list">
  <!-- 按钮 -->
  <slot name="header"></slot>
  <!-- 列表 -->
  <div class="ty-table-wrap">
    <el-table></el-table>
  </div>
</main>
  1. 使用
<TYTable>
  <template #header>
    <el-button type="primary" @click="addUser">添加用户</el-button>
  </template>
</TYTable>

4. 全局的css样式

在引用TYSearch.vue和TYTable.vue的外层组件上添加该css类名,即可实现一个自动计算表格高度,撑满整个浏览器的列表页

.ty-list-container {
  height: 100%;
  display: flex;
  flex-direction: column;
}

完整使用如下:

<template>
  <main class="page-container ty-list-container">
    <TYSearch />
    <TYTable />
  </main>
</template>

5. api文档

6. 更新日志,代码见github

日期更新内容
2023-05-251. TYSearch新增el-radio-buttonel-checkbox-button
2. TYSearch搜索项type为selectcheckboxradio时,增加每一项属性的配置