JB3-7-Vue(二)

Java道经第3卷 - 第7阶 - Vue(二)


传送门:JB3-7-Vue(一)
传送门:JB3-7-Vue(二)

S03. 封装ElementPlus组件

E01. 基本环境搭建

1. 封装通用工具JS文件

/util/index.js

/**
 * 判断非空值
 *
 * @param value 被判断的值
 * @return boolean 返回 true 表示不为 null 也不为 undefined
 */
export function isNotNull(value) {
    return value !== null && value !== undefined;
}

/**
 * 判断空值
 *
 * @param value 被判断的值
 * @return boolean 返回 true 表示为 null 或 undefined
 */
export function isNull(value) {
    return !isNotNull(value);
}

/**
 * 判断是否存在空值
 *
 * @param values 被判断的值,不定长列表
 * @return boolean 返回 true 表示包含 null 或 undefined
 */
export function hasNull(...values) {
    for (let value in values) {
        if (isNull(value)) {
            return true;
        }
    }
    return false;
}

/**
 * 判断空字符串
 *
 * @param value 被判断的值
 * @return boolean 返回 true 表示不为 null 或 undefined 或空字符串
 */
export function isNotEmpty(value) {
    return value !== null && value !== undefined && value !== '';
}

/**
 * 判断非空字符串
 *
 * @param value 被判断的值
 * @return boolean 返回 true 表示为 null 或 undefined 或空字符串
 */
export function isEmpty(value) {
    return !isNotEmpty(value);
}

/**
 * 判断是否存在空字符串
 *
 * @param values 被判断的值,不定长列表
 * @return boolean 返回 true 表示包含 null 或 undefined 或空字符串
 */
export function hasEmpty(...values) {
    for (let value in values) {
        if (isEmpty(value)) {
            return true;
        }
    }
    return false;
}

// 用于将六大元素处理为两位数格式(年除外)
function toDouble(e) {
    return e < 10 ? '0' + e : e;
}

/**
 * 日期字符串处理:1999-01-02T12:12:12 -> 1999年01月02日 12:12
 *
 * @param dateStr 日期字符串
 * @return string 返回格式化后的日期字符串
 */
export function datetimeFormat(dateStr) {
    if (isNull(dateStr)) return '';
    // 将日期字符串转为日期格式
    let date = new Date(dateStr);
    // 获取日期中的元素: 年,月,日,时,分
    let yy = toDouble(date.getFullYear());
    let mm = toDouble(date.getMonth() + 1);
    let dd = toDouble(date.getDate());
    let hh = toDouble(date.getHours());
    let mi = toDouble(date.getMinutes());
    // 返回美化后的日期字符串
    return `${yy}${mm}${dd}${hh}:${mi}`;
}

/**
 * 日期字符串处理:1999-01-02T12:12:12 -> 1999年01月02日
 *
 * @param dateStr 日期字符串
 * @return string 返回格式化后的日期字符串
 */
export function dateFormat(dateStr) {
    if (isNull(dateStr)) return '';
    // 将日期字符串转为日期格式
    let date = new Date(dateStr);
    // 获取日期中的元素: 年,月,日,时,分
    let yy = toDouble(date.getFullYear());
    let mm = toDouble(date.getMonth() + 1);
    let dd = toDouble(date.getDate());
    // 返回美化后的日期字符串
    return `${yy}${mm}${dd}`;
}

2. 封装通用请求JS文件

心法:该文件依赖 ElementPlus 框架,VueRouter 路由功能和 /util/index.js 文件。

/request/index.js

import {ElMessage, ElMessageBox} from "element-plus";
import {hasNull, isNotNull, isNull} from "../util/index.js";
import router from "../router/index.js";

/** 常用响应码 */
export const STATUS = {
    SUCCESS: 1000,
    TOKEN_EXPIRED: 8000
}

/**
 * 获取服务器响应中的data数据
 *
 * @param res axios响应结果对象
 * @return result 请求成功时返回data数据或true,响应失败时返回null
 * */
export function getResponseData(res) {
    let result = null;
    // 若响应结果为空,提示并返回null
    if (isNull(res)) {
        ElMessage.warning('服务器无响应!');
        return result;
    }
    // 若存在2层data,则直接拆除第1层data
    res = undefined !== res.data && undefined !== res.data.data ? res.data : res;
    // 请求成功,返回 data 数据
    if (res.code === STATUS.SUCCESS) {
        // DQL 操作成功时返回查询到的数据,DML 操作成功时返回 true
        result = isNotNull(res.data) ? res.data : true;
    }
    // 请求失败 - Token过期:提示并跳转到登录页面
    else if (res.code === STATUS.TOKEN_EXPIRED) {
        ElMessage.warning('Token过期,请重新登录!');
        setTimeout(() => router.push('/'), 1000);
    }
    // 请求失败 - 服务器异常
    else {
        ElMessage.warning(res['message']);
        console.error(res['coderMessage']); // TODO 生产环境下删除
    }
    return result;
}

/**
 * 新增一条数据(表单,异步)
 *
 * <p> form: 表单对象,必须是reactive变量,必传。
 * <p> api: API请求函数名(不带小括号),必传。
 * <p> params: API请求函数参数,JSON格式,作为请求参数,必传。
 * <p> args: API请求函数参数,JSON格式,不作为请求参数,而是有一些其它作用,可选。
 * <p> callback: 回调函数名(不带小括号),即当操作成功后调用的函数,可选。
 */
export async function myInsert(config) {
    // 必传参数
    const form = config['form'];
    const api = config['api'];
    const params = config['params'];
    // 空值保护
    if (hasNull(form, api, params)) return;
    // 可选参数
    const args = config['args'];
    const callback = config['callback'];
    // 验证表单:只有全部校验规则都通过,valid 参数才为 true
    form.value.validate(async (valid) => {
        if (valid) {
            // 发送请求
            let res = isNotNull(args) ? await api(params, args) : await api(params);
            if (isNotNull(getResponseData(res))) {
                // 存在回调函数时,异步调用回调函数
                if (callback) await callback();
            }
        }
    });
}

/**
 * 分页查询数据(异步)
 *
 * <p> api: API请求函数名(不带小括号),必传。
 * <p> params: API请求函数参数,JSON格式,作为请求参数,必传。
 * <p> args: API请求函数参数,JSON格式,不作为请求参数,而是有一些其它作用,可选。
 * <p> records: 分页结果中的数据列表,必传。
 * <p> pageInfo: 分页结果中的分页信息,包括 pageNum,pageSize 和 total 三项,必传。
 */
export async function myPage(config) {
    // 必传参数
    const api = config['api'];
    const records = config['records'];
    const pageInfo = config['pageInfo'];
    const params = config['params'];
    // 空值保护
    if (hasNull(api, params, records, pageInfo)) return;
    // 可选参数
    const args = config['args'];
    // 发送请求
    let res = isNotNull(args) ? await api(params, args) : await api(params);
    let data = getResponseData(res);
    if (isNotNull(data)) {
        records.value = data['list'] || data['records'];
        pageInfo['pageNum'] = data['pageNum'] || data['pageNumber'] || data['page'];
        pageInfo['pageSize'] = data['pageSize'] || data['size'];
        pageInfo['total'] = data['total'] || data['totalRow'];
    }
}

/**
 * 修改一条数据(表单,异步)
 *
 * <p> form: 表单对象,必须是reactive变量,必传。
 * <p> api: API请求函数名(不带小括号),必传。
 * <p> params: API请求函数参数,JSON格式,作为请求参数,必传。
 * <p> args: API请求函数参数,JSON格式,不作为请求参数,而是有一些其它作用,可选。
 * <p> callback: 回调函数名(不带小括号),即当操作成功后调用的函数,可选。
 * <p> successTip: 是否需要成功提示,默认true,可选。
 * <p> successTipContent: 操作成功后的提示文字,可选。
 */
export async function myUpdate(config) {
    await myInsert(config);
}

/**
 * 删除一条数据(异步)
 *
 * <p> id: 数据主键,必传。
 * <p> api: API请求函数名(不带小括号),必传。
 * <p> args: API请求函数参数,JSON格式,不作为请求参数,而是有一些其它作用,可选。
 * <p> callback: 回调函数名(不带小括号),即当操作成功后调用的函数,可选。
 */
export async function myDelete(config) {
    // 必传参数
    const id = config['id'];
    const api = config['api'];
    // 空值保护
    if (hasNull(id, api)) return;
    // 可选参数
    const args = config['args'];
    const callback = config['callback'];
    // 危险操作保护
    await ElMessageBox.confirm('即将删除1条数据,确认吗?').then(async () => {
        // 发送请求
        let res = isNotNull(args) ? await api(id, args) : await api(id);
        if (isNotNull(getResponseData(res))) {
            // 存在回调函数时,异步调用回调函数
            if (callback) callback();
        }
    });
}

/**
 * 批量删除数据(异步)
 *
 * <p> ids: 主键数组,必传。
 * <p> api: API请求函数名(不带小括号),必传。
 * <p> args: API请求函数参数,JSON格式,不作为请求参数,而是有一些其它作用,可选。
 * <p> callback: 回调函数名(不带小括号),即当操作成功后调用的函数,可选。
 */
export async function myDeleteBatch(config) {
    // 必传参数
    const ids = config['ids'];
    const api = config['api'];
    // 空值保护
    if (hasNull(ids, api)) return;
    // 主键数组保护
    if (ids.length <= 0) {
        ElMessage.warning('至少选择1项!');
        return;
    }
    // 可选参数
    const args = config['args'];
    const callback = config['callback'];
    // 危险操作保护
    await ElMessageBox.confirm(`即将删除 ${ids.length} 条数据,确认吗?`).then(async () => {
        // 发送请求
        let res = isNotNull(args) ? await api(ids.join(','), args) : await api(ids.join(','));
        if (isNotNull(getResponseData(res))) {
            // 存在回调函数时,异步调用回调函数
            if (callback) callback();
        }
    })
}

E02. 封装基础组件

1. 封装组件 - 路径导航

心法:该组件依赖 VueRouter 路由功能。

在这里插入图片描述

  1. 封装路径导航通用组件:

components/MyNav.vue

<script setup>
import router from "../router";

let parent = defineProps({
  /* 导航项
   *
   * icon: 图标
   * label: 文案
   * url: 导航项点击跳转地址
   */
  items: {type: Array, required: true},
});
</script>

<template>
  <el-breadcrumb class="my-nav breadcrumb-nav">
    <el-button class="back-btn"
               size="small"
               link
               icon="Back"
               @click="router.back(-1);">
      返回
    </el-button>
    <el-divider class="divider" direction="vertical"/>
    <el-breadcrumb-item class="my-nav-item"
                        v-for="(item ,i) in items"
                        :key="item"
                        :to="item['url']">
      <!--type: 最后1个按钮为default,其余为info-->
      <el-button class="item-btn"
                 size="small"
                 link
                 :icon="item['icon']"
                 :type="i === items.length - 1 ? 'info' : 'default'">
        {{ item['label'] }}
      </el-button>
    </el-breadcrumb-item>
  </el-breadcrumb>
</template>

<style scoped lang="scss">
.my-nav {
  * {
    float: left; // 左浮动
  }

  .el-button {
    font-size: 1rem; // 字号
    padding-top: 0; // 上内边距
  }
}
</style>
  1. 测试路径导航通用组件:

views/my/MyNavTest.vue

<script setup>
import MyNav from "../../components/MyNav.vue";
import {useDark} from "@vueuse/core";
let items = [
  {icon: 'House', label: '首页', url: '/House'},
  {icon: 'Files', label: '内容管理', url: '/Files'},
  {icon: 'Picture', label: '横幅管理'},
];
useDark();
</script>

<template>
<MyNav :items="items"/>
</template>

2. 封装组件 - 图标文字

心法:该组件不依赖任何功能或文件。

在这里插入图片描述

  1. 封装图标文字通用组件:

components/MyIcon.vue

<script setup>
let parent = defineProps({
  // 图标名称,必填
  icon: {type: String, required: true},
  // 标题文案
  label: {type: String, required: false},
  // 图标和标题的共同尺寸,默认14px
  size: {type: Number, required: false, default: 14},
});
</script>

<template>
  <el-icon class="icon" :size="size">
    <component :is="icon"/>
  </el-icon>
  <span class="text" :style="{'font-size' : size + 'px'}">
    {{ label }}
  </span>
</template>
  1. 测试图标文字通用组件:

views/my/MyIconTest.vue

<script setup>
import MyIcon from "../../components/MyIcon.vue";
import {useDark} from "@vueuse/core";
useDark();
</script>

<template>
  <myIcon label="苹果" icon="Apple" size="50"/>
</template>

3. 封装组件 - 数据页头

心法:该组件依赖 /util/index.js 文件。

在这里插入图片描述

  1. 封装页头栅格通用组件:

components/MyHead.vue

<script setup>
import {ref} from "vue";
import {isNotNull} from "../util";

let parent = defineProps({
  /* 页头项
   *
   * span: 列占位,默认 1,全类型通用
   * offset: 列偏移,默认 0,全类型通用
   * type: 表头项类型,支持 ipt, opt, rdo, btn 4种,默认 btn:
   *     type = ipt: 单行文本框
   *         placeholder: 单行文本框背景字,默认 "请输入.."
   *         iptValue: 单行文本框默认值
   *         callback:点击时的回调函数,回传当前控件的 iptValue 值
   *     type = opt: 下拉菜单
   *         placeholder: 单行文本框背景字,默认 "请选择.."
   *         optValue: 下拉菜单默认值
   *         filterable: 下拉菜单是否开启过滤,默认 true
   *         options: 下拉菜单或单选按钮项,格式  [{ label: '', value: '' }]
   *         callback:选择时的回调函数,回传当前控件的 optValue 值
   *     type = rdo: 单选按钮
   *         options: 下拉菜单或单选按钮项,格式  [{ label: '', value: '' }]
   *         rdoValue: 单选按钮默认值,(type = rdo专属)
   *         callback:选择时的回调函数,回传当前控件的 rdoValue 值
   *     type = btn: 普通按钮
   *         btnTitle: 鼠标经过按钮时的文字提示,默认 "普通按钮"
   *         btnType: 按钮类型,默认 "Info"
   *         btnIcon: 按钮图标,默认 "Apple"
   */
  items: {type: Array, required: true}
});

// 存放父组件传递的全部文本框的值 + 全部单选按钮的值 + 全部下拉菜单的值
let iptValues = ref({}), rdoValues = ref({}), optValues = ref({});

// 分类存储 iptValues, rdoValues 和 optValues 的值
for (let item of parent['items']) {
  // 使用当前 item 的字符串形式作为 key 值,以保证唯一性
  let key = JSON.stringify(item);
  // 将全部单行文本的值存入 iptValues 对象
  if (item['iptValue'] != null) iptValues.value[key] = item['iptValue'];
  // 将全部单选按钮的值存入 rdoValues 对象
  if (item['rdoValue'] != null) rdoValues.value[key] = item['rdoValue'];
  // 将全部下拉菜单的值存入 optValues 对象
  if (item['optValue'] != null) optValues.value[key] = item['optValue'];
}
</script>

<template>
  <el-row class="my-head">
    <el-col v-for="item in items"
            :key="item"
            :span="isNotNull(item['span']) ? item['span'] : 1"
            :offset="isNotNull(item['offset']) ? item['offset'] : 0">

      <!--ipt文本框-->
      <el-input class="ipt"
                v-if="item['type'] === 'ipt'"
                size="small"
                clearable
                v-model="iptValues[JSON.stringify(item)]"
                :placeholder="isNotNull(item['placeholder']) ? item['placeholder'] : '请输入..'"
                @keyup.enter="item['callback'](iptValues[JSON.stringify(item)])"
                @clear="item['callback'](iptValues[JSON.stringify(item)])">
        <template #append>
          <el-button class="ipt-btn"
                     size="small"
                     title="根据输入的条件进行模糊搜索"
                     :icon="'Search'"
                     @click="item['callback'](iptValues[JSON.stringify(item)])"/>
        </template>
      </el-input>

      <!--opt下拉菜单-->
      <el-select class="opt"
                 v-else-if="item['type'] === 'opt'" size="small"
                 clearable
                 v-model="optValues[JSON.stringify(item)]"
                 :filterable="isNotNull(item['filterable']) ? item['filterable'] : true"
                 :placeholder="isNotNull(item['placeholder']) ? item['placeholder'] : '请选择..'"
                 @change="item['callback'](optValues[JSON.stringify(item)])">
        <el-option class="opt-item"
                   v-for="option in item['options']"
                   :key="option"
                   :label="option['label']"
                   :value="option['value']"/>
      </el-select>

      <!--rdo单选按钮-->
      <el-radio-group class="rdo"
                      v-else-if="item['type'] === 'rdo'"
                      size="small"
                      v-model="rdoValues[JSON.stringify(item)]"
                      @change="item['callback'](rdoValues[JSON.stringify(item)])">
        <el-radio-button class="rdo-item"
                         v-for="option in item['options']"
                         :key="option"
                         :label="option['value']">
          <span>{{ option['label'] }}</span>
        </el-radio-button>
      </el-radio-group>

      <!--普通按钮-->
      <el-button class="btn"
                 v-else
                 plain
                 size="small"
                 :title="isNotNull(item['btnTitle']) ? item['btnTitle'] : '普通按钮'"
                 :icon="isNotNull(item['btnIcon']) ? item['btnIcon'] : 'Apple'"
                 :type="isNotNull(item['btnType']) ? item['btnType'] : 'info'"
                 @click="item['callback']"/>
    </el-col>
  </el-row>
</template>

<style scoped lang="scss">
:deep(.el-input__inner) {
  padding-left: 5px; // 左内边距
  box-sizing: border-box; // 忽略内边距影响
}

:deep(.el-select__selection) {
  padding-left: 5px; // 左内边距
  box-sizing: border-box; // 忽略内边距影响
}

.my-head {
  display: flex; // flex布局
  align-items: center; // 垂直居中
  margin: 30px auto 10px; // 外边距

  .ipt, .opt {
    width: 95%; // 宽度
  }

  .rdo, .btn {
    float: right; // 右浮动
  }
}
</style>
  1. 测试页头栅格通用组件:

views/my/MyHeadTest.vue

<script setup>
import MyHead from "../../components/MyHead.vue";
import {useDark} from "@vueuse/core/index";

let items = [
  {
    type: 'ipt', iptValue: 0, span: 5, placeholder: '请输入用户昵称搜索',
    callback: res => console.log(res),
  },
  {
    type: 'opt', optValue: 0, span: 5, offset: 2, placeholder: '请选择你的性别',
    callback: res => console.log(res),
    options: [{label: '女', value: 0}, {label: '男', value: 1}]
  },
  {
    type: 'rdo', rdoValue: 0, span: 4,
    callback: res => console.log(res),
    options: [{label: '我是女孩', value: 0}, {label: '我是男孩', value: 1}]
  },
  {
    type: 'btn', btnTitle: '普通按钮', btnIcon: 'Plus', btnType: 'primary',
    callback: () => console.log('普通按钮')
  }
]
useDark();
</script>

<template>
  <myHead :items="items"/>
</template>

4. 组件封装 - 数据表格

心法:该组件依赖 ElementPlus 框架,/util/index.js 文件,/request/index.js 文件和 VueRouter 路由功能。

在这里插入图片描述

  1. 封装数据表格通用组件:

components/MyTable.vue

<script setup>
import {ref} from "vue";
import {ElMessageBox} from "element-plus";
import {myDelete, myDeleteBatch} from "../request";
import {isNotNull, datetimeFormat} from "../util";
import router from "../router";

let parent = defineProps({
  // 模块名称,必填,用于删除和批量删除业务
  module: {type: String, required: true},
  // 表格行数据,必填
  records: {type: Array, required: true},
  /*
   * 表格列数据,必填:
   *
   * label: 列名数组,全类型通用
   * prop: 属性名,全类型通用
   * width: 列宽,全类型通用
   * sortable: 是否支持排序,默认 true,全类型通用
   * tooltip: 是否支持溢出内容的鼠标经过提示,默认 false,全类型通用
   * type: 表格列类型,支持 tag, star, card, img, icon, text 6种,默认 text:
   *     type = tag: 标签
   *         prefix: 前缀文字
   *         suffix: 后缀文字
   *         format: 前置格式化函数
   *         tagType: 标签颜色,默认 "warning"
   *         tagTypeFn: 标签颜色函数
   *     type = card: 卡片
   *         prefix: 前缀文字
   *         suffix: 后缀文字
   *         format: 前置格式化函数
   *     type = img: 图片
   *         minio: MINIO地址前缀函数
   *         imgWidth: 图片宽度,默认 100px
   *         imgHeight: 图片高度,默认 100px
   *     type = icon: 图标
   *         iconType: 图标颜色,默认 "primary"
   *         iconSize: 图标尺寸,默认 "small"
   *     type = text: 文本
   *         prefix: 前缀文字
   *         suffix: 后缀文字
   *         format: 前置格式化函数
   */
  columns: {type: Array, required: true},
  /* 分页信息
   *
   * pageNum: 当前第几页,默认 1
   * pageSize: 每页多少条,默认 5
   * total: 一共多少条
   * callback: 分页回调函数
   */
  pageInfo: {type: Object, required: true},
  /* 表格尾按钮:
   *
   * label: 按钮文案,默认 "普通按钮"
   * type: 按钮颜色,默认 "success"
   * icon: 按钮图标,默认 "InfoFilled"
   * callback: 当点击按钮时触发的回调函数
   */
  buttons: {type: Array, required: false},
  // 当点击添加按钮时跳入的路由组件名称
  insertPage: {type: String, required: false},
  // 跳入添加页面之前,附加的参数,JSON格式,默认存储到 sessionStorage 中
  insertPageParam: {type: Object, required: false},
  // 当点击修改按钮时跳入的路由组件名称
  updatePage: {type: String, required: false},
  // 跳入修改页面之前,附加的参数,JSON格式,默认存储到 sessionStorage 中
  updatePageParam: {type: Object, required: false},
  // 单条删除API方法
  deleteApi: {type: Function, required: false},
  // 打印报表API方法
  excelApi: {type: Function, required: false},
  // 批量删除API方法
  deleteBatchApi: {type: Function, required: false},
  // 单条删除和批量删除结束后的回调方法
  deleteCallback: {type: Function, required: false},
  // 每页多少条可选数量,默认 [1, 5, 10, 18]
  pageSizes: {type: Array, required: false, default: [1, 5, 10, 18]},
});

/* ==================== 处理表格多级标题 ==================== */

// 表格列数据对象
let columnsObj = {};

/*
 * 原格式:
 *
 * [
 *   {label: ['a', 'b'], prop: 'id01'},
 *   {label: ['a', 'c'], prop: 'id02'},
 *   {label: ['b', 'c'], prop: 'id03'},
 * ]
 *
 * 新格式:
 *
 * {
 *   'a': [{label: 'b', prop: 'id01'}, {label: 'c', prop: 'id02'}],
 *   'b': [{label: 'c', prop: 'id03'}],
 * }
 *
 */
for (let column of parent['columns']) {
  // '主键' 或 ['主键'] -> ['基本信息', '主键']
  if (typeof column['label'] === 'string' || column['label'].length === 1) {
    column['label'] = ['基本信息', column['label']]
  }
  // 获取一级标题
  let parentName = column['label'][0];
  column['label'] = column['label'][1];
  // 若 columnsObj 中已经存在该一级标题对应的数组,则取出该数组,并加入该一级标题
  if (columnsObj.hasOwnProperty(parentName)) {
    columnsObj[parentName].push(column);
  }
  // 若 columnsObj 中不经存在该一级标题对应的数组,则创建新数组,再加入该一级标题
  else {
    columnsObj[parentName] = [column];
  }
}

/* ==================== 处理表格数据显示 ==================== */

/** 处理表格数据显示: row['user.username'] -> row['user']['username'] */
function toMustache(row, prop) {
  let propArray = prop.split('.');
  for (let i = 0, j = propArray.length; i < j; i++) {
    // i=0: row = row['user']
    // i=1: row = row['user']['username']
    if (row) row = row[propArray[i]];
  }
  return row;
}

/* ==================== 查看card内容详情 ==================== */

/**
 * 查看card内容详情
 *
 * @param row 当前行数据
 * @param prop 属性
 */
function cardDetail(row, prop) {
  ElMessageBox.alert(row[prop], '记录详情', {
    confirmButtonText: '了解',
    callback: () => {
    },
  });
}

/* ==================== 查看详情 ==================== */

// 菜单详情抽屉 + 菜单详情列表数据
let detailDrawer = ref(), detailItems = ref([]);

/** 查看记录详情 */
function detail(record) {

  // 每次清空,防止追加
  detailItems.value = [];
  // 填充表格数据到详情列表中
  for (let i in parent['columns']) {
    let item = parent['columns'][i];
    item['value'] = toMustache(record, item['prop']);
    detailItems.value.push(item);
  }
  // 填充 创建时间 和 修改时间 信息
  detailItems.value.push({label: '创建时间', value: record['created'], format: datetimeFormat});
  detailItems.value.push({label: '修改时间', value: record['updated'], format: datetimeFormat});
  // 打开抽屉
  detailDrawer.value = true;
}

/* ==================== 单条删除 ==================== */

/**
 * 调用 myDelete() 工具删除该行数据
 *
 * @param row 当前行数据
 */
function remove(row) {
  myDelete({
    id: row['id'],
    api: parent['deleteApi'],
    args: {module: parent['module']},
    callback: parent['deleteCallback']
  });
}

/* ==================== 批量删除 ==================== */

// 批量删除主键数组
let ids = [];

// 当表格首列的多选框被选中或取消选中时触发
function selectionChange(rows) {
  // 记录当前被选中的行
  ids = rows.map(e => e['id']);
}

/** 调用 myDeleteBatch() 工具批量删除数据*/
function removeBatch() {
  myDeleteBatch({
    ids: ids,
    api: parent['deleteBatchApi'],
    args: {module: parent['module']},
    callback: parent['deleteCallback']
  });
}

/* ==================== 单条添加 ==================== */

/** 路由到指定的添加页面 */
function insert() {
  if (isNotNull(parent['insertPage'])) {
    if (isNotNull(parent['insertPageParam'])) {
      sessionStorage.setItem('insertPageParam', JSON.stringify(parent['insertPageParam']));
    }
    router.push(parent['insertPage']);
  } else {
    ElMessageBox.alert('功能还未开发,敬请期待!', '提示', {type: 'warning'});
  }
}

/* ==================== 单条修改 ==================== */

/**
 * 存储当前行数据,并路由到指定的修改页面。
 *
 * @param row 当前行数据
 */
function update(row) {
  if (isNotNull(parent['updatePage'])) {
    if (isNotNull(parent['updatePageParam'])) {
      sessionStorage.setItem('updatePageParam', JSON.stringify(parent['updatePageParam']));
    }
    sessionStorage.setItem('row', JSON.stringify(row));
    router.push(parent['updatePage']);
  } else {
    ElMessageBox.alert('功能还未开发,敬请期待!', '提示', {type: 'warning'});
  }
}

/* ==================== 打印报表 ==================== */

/** 调用对应API,打印报表 */
function excel() {
  if (isNotNull(parent['excelApi'])) {
    parent['excelApi']();
  } else {
    ElMessageBox.alert('功能还未开发,敬请期待!', '提示', {type: 'warning'});
  }
}

/* ==================== 处理表格属性 ==================== */

/** 是否使用 tooltip 属性 */
function useTooltip(item) {
  // 配置优先
  if (isNotNull(item['tooltip'])) return item['tooltip'];
  // 评分,图片,图标和卡片都不使用 tooltip 属性,其余情况使用 tooltip 属性
  return item['type'] !== 'star' &&
      item['type'] !== 'img' &&
      item['type'] !== 'icon' &&
      item['type'] !== 'card';
}

/** 是否使用 sortable 属性 */
function useSortable(item) {
  // 配置优先
  if (isNotNull(item['sortable'])) return item['sortable'];
  // 评分,图片,图标和卡片都不使用 sortable 属性,其余情况使用 sortable 属性
  return item['type'] !== 'star' &&
      item['type'] !== 'img' &&
      item['type'] !== 'icon' &&
      item['card'] !== 'icon';
}

/** 是否使用 width 属性 */
function useWidth(item) {
  // 配置优先
  if (isNotNull(item['width'])) return item['width'];
  // 卡片默认宽度 300px
  if (item['type'] === 'card') return 300;
  // 标签默认宽度 110px
  if (item['type'] === 'tag') return 110;
  // 图片默认宽度 80px
  if (item['type'] === 'img') return 80;
  // 其他默认宽度 200px
  return 200;
}

</script>

<template>
  <el-table class="my-table"
            tooltip-effect="light"
            size="small"
            stripe
            highlight-current-row
            :data="records"
            @selection-change="selectionChange">
    <el-table-column class="head-col"
                     label="序号"
                     align="center"
                     fixed="left">
      <el-table-column type="selection" width="40"/>
      <el-table-column type="index" width="40"/>
    </el-table-column>
    <el-table-column class="body-col"
                     align="center"
                     v-for="(childrenArray, parentLabel) in columnsObj"
                     :key="childrenArray"
                     :label="parentLabel">
      <el-table-column class="body-col-inner"
                       v-for="item in childrenArray"
                       :key="item"
                       :label="item['label']"
                       :property="item['prop']"
                       :sortable="useSortable(item)"
                       :show-overflow-tooltip="useTooltip(item)"
                       :width="useWidth(item)">
        <template #default="scope">
          <el-tag v-if="item['type'] === 'tag' && isNotNull(toMustache(scope.row, item['prop']))"
                  :type="isNotNull(item['tagTypeFn']) ? item['tagTypeFn'](scope.row[item['prop']]) :
                         isNotNull(item['tagType']) ? item['tagType'] : 'warning'">
            <span>{{ item['prefix'] }}</span>
            <span v-if="isNotNull(item['format'])">{{ item['format'](toMustache(scope.row, item['prop'])) }}</span>
            <span v-else>{{ toMustache(scope.row, item['prop']) }}</span>
            <span>{{ item['suffix'] }}</span>
          </el-tag>
          <el-rate v-else-if="item['type'] === 'star' && isNotNull(scope.row[item['prop']])"
                   disabled show-score
                   v-model="scope.row[item['prop']]"
                   text-color="#ff9900"
                   score-template="{value}"/>
          <el-card v-else-if="item['type'] === 'card' && isNotNull(toMustache(scope.row, item['prop']))"
                   @click="cardDetail(scope.row, item['prop'])">
            <span>{{ item['prefix'] }}</span>
            <span v-if="isNotNull(item['format'])">{{ item['format'](toMustache(scope.row, item['prop'])) }}</span>
            <span v-else>{{ toMustache(scope.row, item['prop']) }}</span>
            <span>{{ item['suffix'] }}</span>
          </el-card>
          <el-image v-else-if="item['type'] === 'img' && isNotNull(toMustache(scope.row, item['prop']))"
                    fit="fill"
                    preview-teleported
                    :src="item['minio'](toMustache(scope.row, item['prop']))"
                    :preview-src-list="[item['minio'](toMustache(scope.row, item['prop']))]"
                    :style="{width: item['imgWidth'], height: item['imgHeight']}"/>
          <el-button v-else-if="item['type'] === 'icon' && isNotNull(toMustache(scope.row, item['prop']))"
                     plain
                     :type="isNotNull(item['iconType']) ? item['iconType'] : 'primary'"
                     :size="isNotNull(item['iconSize']) ? item['iconSize'] : 'small'"
                     :icon="toMustache(scope.row, item['prop'])"/>
          <div class="text" v-else>
            <span>{{ item['prefix'] }}</span>
            <span v-if="isNotNull(item['format'])">{{ item['format'](toMustache(scope.row, item['prop'])) }}</span>
            <span v-else>{{ toMustache(scope.row, item['prop']) }}</span>
            <span>{{ item['suffix'] }}</span>
          </div>
        </template>
      </el-table-column>
    </el-table-column>
    <el-table-column class="time-col"
                     label="时间信息"
                     align="center">
      <el-table-column class="created-col"
                       label="记录首次创建时间"
                       property="created"
                       sortable
                       :width="150"
                       :formatter="row => datetimeFormat(row['created'])"/>
      <el-table-column class="updated-col"
                       label="记录最后修改时间"
                       property="updated"
                       sortable
                       :width="150"
                       :formatter="row => datetimeFormat(row['updated'])"/>
    </el-table-column>
    <el-table-column class="foot-col"
                     align="center"
                     fixed="right"
                     :width="170">
      <template #header>
        <el-button class="insert-btn"
                   plain
                   size="small"
                   icon="CirclePlusFilled"
                   type="primary"
                   title="单增一条记录"
                   @click="insert"/>
        <el-button class="delete-batch-btn"
                   plain
                   size="small"
                   icon="DeleteFilled"
                   type="danger"
                   title="批量删除记录"
                   @click="removeBatch"/>
        <el-button class="excel-btn"
                   plain
                   size="small"
                   icon="Grid"
                   type="warning"
                   title="打印数据报表"
                   @click="excel"/>
      </template>
      <template #default="scope">
        <el-row :gutter="0">
          <el-col class="info-btn-col" :span="12">
            <el-button class="info-btn"
                       link
                       size="small"
                       type="info"
                       icon="InfoFilled"
                       @click="detail(scope.row)">
              查看详情
            </el-button>
          </el-col>
          <el-col class="delete-btn-col" :span="12">
            <el-button class="delete-btn"
                       link
                       size="small"
                       type="danger"
                       icon="Delete"
                       @click="remove(scope.row)">
              删除记录
            </el-button>
          </el-col>
          <el-col class="update-btn-col" :span="12">
            <el-button class="update-btn"
                       link
                       size="small"
                       type="warning"
                       icon="Edit"
                       @click="update(scope.row)">
              修改记录
            </el-button>
          </el-col>
          <el-col class="custom-btn-col"
                  :span="12"
                  v-for="button in buttons"
                  :key="button">
            <el-button class="custom-btn"
                       link
                       size="small"
                       :type="isNotNull(button['type']) ? button['type'] : 'success'"
                       :icon="isNotNull(button['icon']) ? button['icon'] : 'InfoFilled'"
                       @click="button['callback'](scope.row)">
              {{ isNotNull(button['label']) ? button['label'] : '普通按钮' }}
            </el-button>
          </el-col>
        </el-row>
      </template>
    </el-table-column>
    <!--用于调控表格宽度,不设置宽度,则宽度自撑开到100%-->
    <el-table-column/>
  </el-table>
  <el-drawer class="detail-drawer"
             v-model="detailDrawer"
             title="记录详情"
             size="30%">
    <el-descriptions class="my-list"
                     border
                     label-align="center"
                     :column="1">
      <el-descriptions-item class="list-item"
                            v-for="item in detailItems"
                            :key="item"
                            :label="item['label']"
                            :span="isNotNull(item['colspan']) ? item['colspan'] : 1">
        <template #default>
          <el-tag v-if="item['type'] === 'tag' && isNotNull(item['value'])"
                  :type="isNotNull(item['tagTypeFn']) ? item['tagTypeFn'](item['value']) :
                       isNotNull(item['tagType']) ? item['tagType'] : 'warning'">
            <span>{{ item['prefix'] }}</span>
            <span v-if="isNotNull(item['format'])">{{ item['format'](item['value']) }}</span>
            <span v-else>{{ item['value'] }}</span>
            <span>{{ item['suffix'] }}</span>
          </el-tag>
          <el-rate v-else-if="item['type'] === 'star' && isNotNull(item['value'])"
                   disabled
                   show-score
                   v-model="item['value']"
                   text-color="#ff9900"
                   score-template="{value}"/>
          <el-card v-else-if="item['type'] === 'card' && isNotNull(item['value'])"
                   :style="{height: item['height'] + 'px'}">
            <span>{{ item['prefix'] }}</span>
            <span v-if="isNotNull(item['format'])">{{ item['format'](item['value']) }}</span>
            <span v-else>{{ item['value'] }}</span>
            <span>{{ item['suffix'] }}</span>
          </el-card>
          <el-image v-else-if="item['type'] === 'img' && isNotNull(item['value'])"
                    fit="fill"
                    preview-teleported
                    :src="item['minio'](item['value'])"
                    :preview-src-list="[item['minio'](item['value'])]"
                    :style="{width: item['imgWidth'] + 'px', height: item['imgHeight'] + 'px'}"/>
          <el-button v-else-if="item['type'] === 'icon' && isNotNull(item['value'])"
                     plain
                     :type="isNotNull(item['iconType']) ? item['iconType'] : 'primary'"
                     :size="isNotNull(item['iconSize']) ? item['iconSize'] : 'small'"
                     :icon="item['value']">
          </el-button>
          <div class="text" v-else>
            <span>{{ item['prefix'] }}</span>
            <span v-if="isNotNull(item['format'])">{{ item['format'](item['value']) }}</span>
            <span v-else>{{ item['value'] }}</span>
            <span>{{ item['suffix'] }}</span>
          </div>
        </template>
      </el-descriptions-item>
    </el-descriptions>
  </el-drawer>
  <el-pagination class="my-pager"
                 layout="total, sizes, prev, pager, next, jumper"
                 :background="true"
                 :page-sizes="pageSizes"
                 :current-page="isNotNull(pageInfo['pageNum']) ? pageInfo['pageNum'] : 1"
                 :page-size="isNotNull(pageInfo['pageSize']) ? pageInfo['pageSize'] : 5"
                 :total="pageInfo['total']"
                 @size-change="pageSize => pageInfo['callback'](pageInfo['pageNum'], pageSize)"
                 @current-change="pageNum => pageInfo['callback'](pageNum, pageInfo['pageSize'])"/>
</template>

<style scoped lang="scss">
.my-table {
  height: 480px; // 高度

  .el-image {
    width: 100%; // 宽度
    height: 50px; // 高度
    margin-bottom: -10px; // 下边距
  }

  .el-card {
    height: 62px; // 高度(大概2行文字)
    --el-card-padding: 10px !important; // 内边距
    white-space: normal !important; // 自动换行
    overflow-y: scroll !important; // 垂直溢出滚动
  }
}

.foot-col-inner {
  text-align: left; // 居左
}

:deep(.el-descriptions__label.el-descriptions__cell.is-bordered-label) {
  min-width: 107px !important; // 最小列宽107
  max-width: 137px !important; // 最大列宽137
}

.my-list {
  .el-image {
    width: 100px; // 宽度
    height: 100px; // 高度
  }

  .el-card {
    max-height: 420px; // 最大高度(不超过15行)
    --el-card-padding: 10px !important; // 内边距
    white-space: normal !important; // 自动换行
    overflow-y: scroll !important; // 垂直溢出滚动
  }
}

.my-pager {
  margin: 20px; // 外边距
  justify-content: center; // 自居中
}
</style>
  1. 测试数据表格通用组件:

views/my/MyTableTest.vue

<script setup>
import MyTable from "../../components/MyTable.vue";
import {useDark} from "@vueuse/core/index";
import {reactive} from "vue";

let records = [
  {
  	f01: '刘能', f02: 0, f03: 3.5, f04: '亚洲舞王', f05: '刘能.jpg', f06: 'Apple', 
  	created: new Date(), updated: new Date(999999)
  },
  {
  	f01: '赵四', f02: 1, f03: 3, f04: '玉田老丈人', f05: '赵四.jpg', f06: 'House', 
  	created: new Date(), updated: new Date(999999)
  },
];
let columns = [
  {label: '文本', prop: 'f01', width: 90, sortable: true, tooltip: true},
  {label: '评分', prop: 'f03', type: 'star', width: 160, sortable: true, tooltip: false},
  {
  	label: '卡片', prop: 'f04', type: 'card', prefix: '->', suffix: '<-', width: 160, 
  	format: v => '~' + v + '~'},
  {
    label: '图片', prop: 'f05', type: 'img', imgWidth: 120, imgHeight: 120,
    minio: () => 'http://192.168.40.77:9001/mylesson/avatar/5641df5204-1736390233362.jpg',
  },
  {label: '图标', prop: 'f06', type: 'icon', width: 70, iconType: 'danger', iconSize: 'large'},
  {
    label: ['标签', '静态标签'], prop: 'f02', type: 'tag', prefix: '->', suffix: '<-', tagType: 'success', width: 90,
    format: v => v === 0 ? '零' : '非零',
  },
  {
    label: ['标签', '动态标签'], prop: 'f02', type: 'tag', prefix: '->', suffix: '<-', width: 90
    tagTypeFn: v => v === 0 ? 'success' : 'primary',
    format: v => v === 0 ? '零' : '非零',
  }
];
let buttons = [
  {type: 'upload', label: '上传图片', callback: () => console.log('上传图片')},
]
let pageInfo = reactive({
  pageNum: 1,
  pageSize: 5,
  total: 50,
  callback: (page, size) => {
    pageInfo['pageNum'] = page;
    pageInfo['pageSize'] = size;
  }
});

useDark();
</script>

<template>
  <MyTable module="user"
           :records="records"
           :columns="columns"
           :buttons="buttons"
           :insert-page="'user/UserInsert'"
           :insert-page-param="{a: 1}"
           :update-page="'user/UserUpdate'"
           :update-page-param="{b: 1}"
           :delete-api="()=>console.log('使用axios发送删除请求')"
           :delete-batch-api="()=>console.log('使用axios发送批量删除请求')"
           :delete-callback="()=>console.log('删除成功后的回调')"
           :excel-api="()=>console.log('使用axios发送导出Excel请求')"
           :pageInfo="pageInfo"
           :page-sizes="[1,5,10,12]"/>
</template>

5. 封装组件 - 数据表单

心法:该组件依赖 /util/index.js 文件和 /request/index.js 文件。

在这里插入图片描述

  1. 封装表单组件:

components/MyForm.vue

<script setup>
import {shallowRef} from "vue";
import {myInsert, myUpdate} from "../request";
import {isNotNull} from "../util";

let parent = defineProps({
  // 表单类型:支持 insert 和 update 两种
  type: {type: String, required: false, default: 'insert'},
  /* 表单项
   *
   * label: 文案,默认表单项属性名,全类型通用
   * prop: 属性名,全类型通用
   * required: 是否必填,默认 false,全类型通用
   * span: 每个表单项的宽度占 24 分之多少,默认 24,表示独占一行,全类型通用
   * hidden: 是否隐藏,默认 false,需要占位时改为 true,全类型通用
   * placeholder: 背景字,默认 "请输入..",icon 和 cascader 类型除外
   * disabled: 是否禁用,默认 false,icon 和 cascader 类型除外
   * readonly: 是否只读,默认 false,icon 和 cascader 类型除外
   * type: 表单项类型,支持 number, password, textarea, select, datetime, date, icon, cascader, text 9种,默认 text:
   *     type = number: 数字框
   *         min: 最小值,默认 0
   *         max: 最大值
   *         precision: 精度,默认 0
   *     type = textarea: 文本域
   *         rows: 行数,默认 8
   *         cols: 列数
   *     type = select: 下拉框
   *         options: 下拉菜单项,格式 [{ label: '', value: '' }]
   *         multiple: 是否支持多选,默认 false
   *     type = icon: ICON单选框
   *         iconSize: 按钮大小,默认 14
   *     type = cascader: 级联框
   *         options: 级联菜单项,格式 [{ label: '', value: '', children: [] }]
   *         cascaderChange: 当级联菜单发生改变时触发的函数(不带小括号)
   */
  items: {type: Array, required: true},
  // 表单规则
  rules: {type: Object, required: true},
  // API函数:表单提交时调用的API函数(不带小括号)
  api: {type: Function, required: true},
  // API参数:API请求函数参数,作为请求参数
  params: {type: Object, required: true},
  // API参数:API请求函数参数,不作为请求参数,而是有一些其它作用
  args: {type: Object, required: false, default: null},
  // 回调函数:表单提交后调用的函数(不带小括号)
  callback: {type: Function, required: false},
  // 表单宽度
  width: {type: String, required: false, default: '100%'},
});

// 表单对象
let form = shallowRef();

// 添加数据: 发送请求 + 重置表单
async function insert() {
  await myInsert({
    form: form,
    api: parent['api'],
    params: parent['params'],
    args: parent['args'],
    callback: parent['callback'],
  });
  form.value.resetFields;
}

// 修改数据: 发送请求 + 重置表单
async function update() {
  await myUpdate({
    form: form,
    api: parent['api'],
    params: parent['params'],
    args: parent['args'],
    callback: parent['callback'],
  });
  form.value.resetFields;
}

const ICONS = [
  'Plus', 'Minus', 'CirclePlus', 'Search', 'Female', 'Male', 'Aim', 'House', 'FullScreen', 'Loading',
  'Link', 'Service', 'Pointer', 'Star', 'Notification', 'Connection', 'ChatDotRound', 'Setting',
  'Clock', 'Position', 'Discount', 'Odometer', 'ChatSquare', 'ChatRound', 'ChatLineRound', 'ChatLineSquare',
  'ChatDotSquare', 'View', 'Hide', 'Unlock', 'Lock', 'RefreshRight', 'RefreshLeft', 'Refresh', 'Bell',
  'MuteNotification', 'User', 'Check', 'CircleCheck', 'Warning', 'CircleClose', 'Close', 'PieChart', 'More',
  'Compass', 'Filter', 'Switch', 'Select', 'SemiSelect', 'CloseBold', 'EditPen', 'Edit', 'Message',
  'MessageBox', 'TurnOff', 'Finished', 'Delete', 'Crop', 'SwitchButton', 'Operation', 'Open', 'Remove',
  'ZoomOut', 'ZoomIn', 'InfoFilled', 'CircleCheckFilled', 'SuccessFilled', 'WarningFilled', 'CircleCloseFilled',
  'QuestionFilled', 'WarnTriangleFilled', 'UserFilled', 'MoreFilled', 'Tools', 'HomeFilled', 'Menu',
  'UploadFilled', 'Avatar', 'HelpFilled', 'Share', 'StarFilled', 'Comment', 'Histogram', 'Grid', 'Promotion',
  'DeleteFilled', 'RemoveFilled', 'CirclePlusFilled', 'ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown',
  'ArrowLeftBold', 'ArrowUpBold', 'ArrowRightBold', 'ArrowDownBold', 'DArrowRight', 'DArrowLeft', 'Download',
  'Upload', 'Top', 'Bottom', 'Back', 'Right', 'TopRight', 'TopLeft', 'BottomRight', 'BottomLeft', 'Sort',
  'SortUp', 'SortDown', 'Rank', 'CaretLeft', 'CaretTop', 'CaretRight', 'CaretBottom', 'DCaret', 'Expand',
  'Fold', 'DocumentAdd', 'Document', 'Notebook', 'Tickets', 'Memo', 'Collection', 'Postcard', 'ScaleToOriginal',
  'SetUp', 'DocumentDelete', 'DocumentChecked', 'DataBoard', 'DataAnalysis', 'CopyDocument', 'FolderChecked',
  'Files', 'Folder', 'FolderDelete', 'FolderRemove', 'FolderOpened', 'DocumentCopy', 'DocumentRemove',
  'FolderAdd', 'FirstAidKit', 'Reading', 'DataLine', 'Management', 'Checked', 'Ticket', 'Failed', 'TrendCharts',
  'List', 'Microphone', 'Mute', 'Mic', 'VideoPause', 'VideoCamera', 'VideoPlay', 'Headset', 'Monitor', 'Film',
  'Camera', 'Picture', 'PictureRounded', 'Iphone', 'Cellphone', 'VideoCameraFilled', 'PictureFilled',
  'Platform', 'CameraFilled', 'BellFilled', 'Location', 'LocationInformation', 'DeleteLocation', 'Coordinate',
  'Bicycle', 'OfficeBuilding', 'School', 'Guide', 'AddLocation', 'MapLocation', 'Place', 'LocationFilled',
  'Van', 'Watermelon', 'Pear', 'NoSmoking', 'Smoking', 'Mug', 'GobletSquareFull', 'GobletFull', 'KnifeFork',
  'Sugar', 'Bowl', 'MilkTea', 'Lollipop', 'Coffee', 'Chicken', 'Dish', 'IceTea', 'ColdDrink', 'CoffeeCup',
  'DishDot', 'IceDrink', 'IceCream', 'Dessert', 'IceCreamSquare', 'ForkSpoon', 'IceCreamRound', 'Food',
  'HotWater', 'Grape', 'Fries', 'Apple', 'Burger', 'Goblet', 'GobletSquare', 'Orange', 'Cherry', 'Printer',
  'Calendar', 'CreditCard', 'Box', 'Money', 'Refrigerator', 'Cpu', 'Football', 'Brush', 'Suitcase',
  'SuitcaseLine', 'Umbrella', 'AlarmClock', 'Medal', 'GoldMedal', 'Present', 'Mouse', 'Watch', 'QuartzWatch',
  'Magnet', 'Help', 'Soccer', 'ToiletPaper', 'ReadingLamp', 'Paperclip', 'MagicStick', 'Basketball',
  'Baseball', 'Coin', 'Goods', 'Sell', 'SoldOut', 'Key', 'ShoppingCart', 'ShoppingCartFull', 'ShoppingTrolley',
  'Phone', 'Scissor', 'Handbag', 'ShoppingBag', 'Trophy', 'TrophyBase', 'Stopwatch', 'Timer', 'CollectionTag',
  'TakeawayBox', 'PriceTag', 'Wallet', 'Opportunity', 'PhoneFilled', 'WalletFilled', 'GoodsFilled', 'Flag',
  'BrushFilled', 'Briefcase', 'Stamp', 'Sunrise', 'Sunny', 'Ship', 'MostlyCloudy', 'PartlyCloudy', 'Sunset',
  'Drizzling', 'Pouring', 'Cloudy', 'Moon', 'MoonNight', 'Lightning', 'ChromeFilled', 'Eleme', 'ElemeFilled',
  'ElementPlus', 'Shop', 'SwitchFilled', 'WindPower'
];
</script>

<template>
  <el-form class="my-form"
           ref="form"
           status-icon
           label-width="auto"
           size="small"
           :model="params"
           :rules="rules"
           :style="{'width': width}">
    <el-row :gutter="20">
      <el-col class="layout-col"
              v-for="item in items"
              :key="item"
              :span="isNotNull(item['span']) ? item['span'] : 24">
        <el-form-item class="form-item"
                      :label="isNotNull(item['label']) ? item['label'] : item['prop']"
                      :prop="item['prop']"
                      :required="item['required']"
                      v-show="!item['hidden']">
          <!--数字框-->
          <el-input-number v-if="item['type'] === 'number'"
                           clearable
                           v-model="params[item['prop']]"
                           :min="isNotNull(item['min']) ? item['min'] : 0"
                           :max="item['max']"
                           :precision="isNotNull(item['precision']) ? item['precision'] : 0"
                           :placeholder="isNotNull(item['placeholder']) ? item['placeholder'] : '请输入..'"
                           :disabled="item['disabled']"
                           :readonly="item['readonly']"/>
          <!--密码框-->
          <el-input v-else-if="item['type'] === 'password'"
                    clearable
                    show-password
                    v-model="params[item['prop']]"
                    :placeholder="isNotNull(item['placeholder']) ? item['placeholder'] : '请输入..'"
                    :disabled="item['disabled']"
                    :readonly="item['readonly']"/>
          <!--文本域-->
          <el-input v-else-if="item['type'] === 'textarea'"
                    type="textarea"
                    clearable
                    resize="none"
                    v-model="params[item['prop']]"
                    :rows="isNotNull(item['rows']) ? item['rows'] : 8"
                    :cols="item['cols']"
                    :placeholder="isNotNull(item['placeholder']) ? item['placeholder'] : '请输入..'"
                    :disabled="item['disabled']"
                    :readonly="item['readonly']"/>
          <!--下拉框-->
          <el-select v-else-if="item['type'] === 'select'"
                     clearable
                     filterable
                     :multiple="item['multiple']"
                     v-model="params[item['prop']]"
                     :placeholder="isNotNull(item['placeholder']) ? item['placeholder'] : '请输入..'"
                     :disabled="item['disabled']"
                     :readonly="item['readonly']">
            <el-option class="select-item"
                       v-for="option in item['options']"
                       :key="option"
                       :label="option['label']"
                       :value="option['value']"/>
          </el-select>
          <!--时间框-->
          <el-date-picker v-else-if="item['type'] === 'datetime'"
                          type="datetime"
                          clearable
                          v-model="params[item['prop']]"
                          format="YYYY-MM-DD HH:mm"
                          value-format="YYYY-MM-DDTHH:mm:ss"
                          :placeholder="isNotNull(item['placeholder']) ? item['placeholder'] : '请输入..'"
                          :disabled="item['disabled']"
                          :readonly="item['readonly']"/>
          <!--日期框-->
          <el-date-picker v-else-if="item['type'] === 'date'"
                          type="date"
                          clearable
                          v-model="params[item['prop']]"
                          format="YYYY-MM-DD"
                          value-format="YYYY-MM-DD"
                          :placeholder="isNotNull(item['placeholder']) ? item['placeholder'] : '请输入..'"
                          :disabled="item['disabled']"
                          :readonly="item['readonly']"/>
          <!--ICON单选框-->
          <el-radio-group v-else-if="item['type'] === 'icon'"
                          class="icon-list"
                          size="small"
                          v-model="params[item['prop']]">
            <el-radio-button class="icon-item"
                             v-for="icon in ICONS"
                             :key="icon"
                             :label="icon">
              <template #default>
                <el-icon :size="isNotNull(item['iconSize']) ? item['iconSize'] : 14">
                  <component :is="icon"/>
                </el-icon>
              </template>
            </el-radio-button>
          </el-radio-group>
          <!--级联框-->
          <el-cascader v-else-if="item['type'] === 'cascader'"
                       v-model="params[item['prop']]"
                       :options="item['options']"
                       :props="{expandTrigger: 'hover'}"
                       @change="params[item['cascaderChange']]"
                       style="width: 100%;"/>
          <!--文本框-->
          <el-input v-else
                    clearable
                    v-model="params[item['prop']]"
                    :placeholder="isNotNull(item['placeholder']) ? item['placeholder'] : '请输入..'"
                    :disabled="item['disabled']"
                    :readonly="item['readonly']"/>
        </el-form-item>
      </el-col>
    </el-row>
    <el-form-item class="btn-item">
      <el-button class="insert-btn"
                 v-if="type === 'insert'"
                 type="primary"
                 @click="insert">
        确认添加
      </el-button>
      <el-button class="update-btn"
                 v-else-if="type === 'update'"
                 type="primary"
                 @click="update">
        确认修改
      </el-button>
    </el-form-item>
  </el-form>
</template>

<style scoped lang="scss">
.my-form {
  .el-input-number, .el-button {
    width: 100%; // 宽度
  }
}

:deep(.el-date-editor) {
  --el-date-editor-width: 100%; // 宽度
}

.icon-list {
  margin-left: -2px; // 左外边距
  max-height: 160px; // 最大高度
  overflow-y: scroll; // Y滚动

  .el-radio-button {
    margin-bottom: 5px; // 下外边距
  }
}

/* 取消首尾按钮的圆角 */
:deep(.el-radio-button:first-child .el-radio-button__inner),
:deep(.el-radio-button:last-child .el-radio-button__inner) {
  border-radius: 0; // 圆角
}

/* 修改单选按钮样式 */
:deep(.el-radio-button--small .el-radio-button__inner) {
  border-left: 1px solid #7e7e7e; // 左边框
  margin: 0 2px; // 左右外边距
}
</style>
  1. 测试表单组件:

views/my/MyFormTest.vue

<script setup>
import MyForm from '../../components/MyForm.vue';
import {useDark} from "@vueuse/core/index";
import {ref, shallowReactive} from "vue";

let insertFormItems = ref([
  {
    label: '数字', prop: 'f01', type: 'number', min: 0, max: 100, precision: 2, span: 12, hidden: false,
    placeholder: 'f01', required: true, disabled: false, readonly: false
  },
  {
    label: '文本', prop: 'f02', type: 'text', span: 12, hidden: false,
    placeholder: 'f02', required: true, disabled: false, readonly: false
  },
  {
    label: '文本域', prop: 'f03', type: 'textarea', rows: 8, cols: 10, span: 24, hidden: false,
    placeholder: 'f03', required: true, disabled: false, readonly: false
  },
  {
    label: '下拉菜单', prop: 'f04', type: 'select', span: 12, hidden: false,
    placeholder: 'f04', required: true, disabled: false, readonly: false,
    options: [
      {label: '男', value: '男'},
      {label: '女', value: '女'}
    ]
  },
  {
    label: '密码框', prop: 'f05', type: 'password', span: 12, hidden: false,
    placeholder: 'f05', required: true, disabled: false, readonly: false
  }
]);

let insertParams = shallowReactive({f01: 0, f02: '', f03: '', f04: '', f05: ''});

let updateFormItems = ref([
  {
    label: '日期时间', prop: 'f06', type: 'datetime', span: 12, hidden: false,
    placeholder: 'f06', required: true, disabled: false, readonly: false
  },
  {
    label: '日期', prop: 'f07', type: 'date', span: 12, hidden: false,
    placeholder: 'f07', required: true, disabled: false, readonly: false
  },
  {
    label: '图标', prop: 'f08', type: 'icon', iconSize: 10, span: 24, hidden: false,
    placeholder: 'f08', required: true, disabled: false, readonly: false
  },
  {
    label: '级联', prop: 'f09', type: 'cascader', span: 24, hidden: false,
    options: [
      {
        label: '性别', value: 'gender', children: [
          {label: '男', value: 'male'},
          {label: '女', value: 'female'}
        ]
      },
      {
        label: '年龄', value: 'age', children: [
          {label: '老年人', value: 'old'},
          {label: '年轻人', value: 'young'}
        ]
      }
    ],
    placeholder: 'f09', required: true, disabled: false, readonly: false
  }
]);

let updateParams = shallowReactive({f06: '', f07: '', f08: '', f09: []});

useDark();
</script>

<template>
  <el-row style="padding: 50px;" :gutter="50">
    <el-col :span="12">
      <MyForm type="insert"
              :items="insertFormItems"
              :params="insertParams"
              :rules="{'f01': ''}"
              :api="() => 'axios.post(insert_xxx)'"
              :callback="() => '添加完成回调'"/>
    </el-col>
    <el-col :span="12">
      <MyForm type="update"
              :items="updateFormItems"
              :params="updateParams"
              :rules="{'f06': ''}"
              :api="() => 'axios.post(update_xxx)'"
              :callback="() => '修改完成回调'"/>
    </el-col>
  </el-row>
</template>

6. 封装组件 - 上传文件

心法:该组件依赖 ElementPlus 框架,/util/index.js 文件和 /request/index.js 文件。

在这里插入图片描述

  1. 开发上传文件通用组件:

components/MyUpload.vue

<script setup>
import {shallowRef} from "vue";
import {ElMessage} from "element-plus";
import {getResponseData} from "../request";
import {hasNull, isNotNull} from "../util";

// 上传控件对象
let uploader = shallowRef();

// 当前token值
let currentToken = sessionStorage.getItem('token');

let parent = defineProps({
  // 控件名称:对应后台API接口中的文件参数名,如 avatarFile 等
  name: {type: String, required: true},
  // 上传地址:对应后台API接口地址
  url: {type: String, required: true},
  // 回调函数:上传成功后的回调函数
  callback: {type: Function, required: false},
  // 单次文件上传的最大数量限制,默认 1
  limit: {type: Number, required: false, default: 1},
  // 是否自动上传,默认 false
  autoUpload: {type: Boolean, required: false, default: false},
  // 上传提交按钮主题颜色,默认 "warning"
  btnType: {type: String, required: false, default: 'warning'},
  // 支持上传的MIME格式数组 默认 ['image/jpeg', 'image/png']
  allowTypes: {type: Array, required: false, default: ['image/jpeg', 'image/png']},
  // 支持上传的文件最大限制,单位MB,默认10M
  maxSize: {type: Number, required: false, default: 10},
});

/**
 * 文件上传前的校验过程
 *
 * @param file 文件对象,必传
 * @return true 校验成功,false 校验失败
 */
function beforeUpload(file) {
  let allowTypes = parent['allowTypes'];
  let maxSize = parent['maxSize'];
  // 空值保护
  if(hasNull(file, allowTypes)) return false;
  // 校验文件MIME类型
  if (!allowTypes.includes(file.type)) {
    ElMessage.warning(`仅支持 ${allowTypes.toString()} 格式!`);
    return false;
  }
  // 校验文件大小
  if (file.size / 1024 / 1024 > maxSize) {
    ElMessage.error(`文件大小不能超过 ${maxSize} MB`)
    return false;
  }
  return true;
}

/**
 * 上传文件成功后执行的函数
 *
 * @param res 响应对象
 */
async function onSuccess(res) {
  let data = getResponseData(res);
  if (isNotNull(data)) {
    // 清空上传控件的文件列表
    uploader.value.clearFiles();
    // 存在回调函数时,异步调用回调函数,并将响应数据作为参数传入
    if (parent['callback']) await parent['callback'](data);
  }
}

</script>

<template>
  <el-upload class="my-uploader"
             ref="uploader"
             list-type="picture"
             :name="name"
             :action="url"
             :headers="{'token': currentToken}"
             :drag="true"
             :limit="limit"
             :show-file-list="false"
             :auto-upload="autoUpload"
             :before-upload="beforeUpload"
             :on-success="onSuccess">
    <el-link class="el-icon--upload"
             icon="UploadFilled"
             :underline="false"/>
    <div class="el-upload__text">拖拽文件 或 <em>点击上传</em></div>
    <template #tip>
      <div class="el-upload__tip">
        仅支持 {{ allowTypes.toString().replaceAll('image/', '').replaceAll(',', ', ') }}
        格式文件,且单个文件不超过{{ maxSize }}MB
      </div>
    </template>
  </el-upload>

  <!--上传按钮-->
  <el-button class="upload-btn"
             v-if="!autoUpload"
             :type="btnType"
             @click="uploader.submit()">
    确认上传
  </el-button>
</template>

<style scoped lang="scss">
.upload-btn {
  margin-top: 10px; // 上外边距
  width: 100%; // 宽度
}
</style>
  1. 测试上传文件通用组件:

views/my/MyUploadTestr.vue

<script setup>
import MyUpload from "../../components/MyUpload.vue";
import {useDark} from "@vueuse/core";

useDark();
</script>

<template>
  <MyUpload name="avatarFile"
            url="http://localhost:5266/api/v1/user/avatar/upload/1"
            :callback="() => console.log('上传成功')"/>
</template>

Java道经第3卷 - 第7阶 - Vue(二)


传送门:JB3-7-Vue(一)
传送门:JB3-7-Vue(二)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值