Vue3电影中后台开发纪实(五):组件封装

459 阅读1分钟

@引子

本系列组件基于Element-Plus开发,已发布为开源组件库,欢迎使用!

npm i @steveouyang/super-ep

码云仓库
Github仓库

@页头组件

实现效果

image.png

页面部署

App.vue

<template>
    <!-- 页头 -->
    <MyPageHeader
        title="欢迎光临卖座电影管理后台"
        @action-btn-click="onActionBtnClick">
        <!-- 覆盖action按钮插槽默认内容 -->
        <template #action>
            <el-icon
                size="24"
                class="content-elem pointer"
                v-change="{ color: 'cyan' }"
                ><SwitchButton
            /></el-icon>
        </template>
    </MyPageHeader>

    <main>
        ...
    </main>
</template>

源码

components/MyPageHeader.vue

<template>
  <div class="epHeader" v-if="!$route.meta.hideFrame">

    <!-- 左侧返回按钮:导航回退 -->
    <div class="left" @click="$router.back">
      <el-icon><ArrowLeftBold /></el-icon>
    </div>

    <!-- 中间显示父组件注入的标题 -->
    <div class="middle">{{ title }}</div>

    <div class="right">

      <!-- Element头像 -->
      <!-- src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" -->
      <el-avatar
        :size="24"
        class="mr-3"
        src="/user.jfif"
      />

      <!-- 欢迎回来 -->
      <span class="content-elem"
        >欢迎回来:
        <span class="pointer username" v-change="{ color: 'cyan' }">admin</span>
      </span>

      <!-- 右侧action按钮区:点击时通知父组件 -->
      <div
        class="action pointer centerbox"
        @click="emit(`actionBtnClick`, Date.now())"
      >

        <!-- action按钮默认使用X号按钮 父组件可以通过具名插槽覆盖action按钮 -->
        <slot name="action">
          <!-- 当用户点击action btn的时候 通知父组件 让父组件决定该干什么 -->
          <el-icon size="24"> <CloseBold /></el-icon>
        </slot>
      </div>

    </div>
  </div>
</template>

<script setup>
import { defineProps, defineEmits } from "vue";
import {
  ArrowLeftBold,
  SwitchButton,
  CloseBold,
} from "@element-plus/icons-vue";

/* 定义props */
const { title } = defineProps({
  title: String,
});

/* 定义向父组件发送的事件 */
const emit = defineEmits({
  actionBtnClick: null,
});
</script>

<style lang="scss" scoped>
@import "@assets/variable.scss";
@import "@assets/mixin.scss";

.epHeader {
  height: 60px;
  background-color: rgb(50, 68, 129);
  color: white;
  padding: 0 20px;
  display: flex;
  align-items: center;

  .left {
    background-color: rgba($color: #fff, $alpha: 0.1);
    border-radius: 50%;
    width: 30px;
    height: 30px;
    @include centeredFlexBox;
    color: white;
    cursor: pointer;
  }

  .middle {
    margin-left: 20px;
    font-size: 16px;
    // color: rgb(146, 251, 255);
    font-style: italic;
  }

  .right {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    flex-grow: 1;

    .content-elem {
      margin-left: 10px;
      margin-right: 10px;
      color: white;
      font-size: 14px;
    }

    .username {
      color: $myYellow;
    }
  }
}
</style>

@按钮组

实现效果

image.png

页面部署

<template>
   <div class="wrapper">
      <!-- 批量操作按钮组 -->
      <BtnGroup
         ref="bgRef"
         :groupBtns="groupBtns"
         @group-btn-click="onGroupBtnClick">
         <template #delete>
            <el-icon><Delete /></el-icon>
         </template>

         <template #export>
            <el-icon><DocumentCopy /></el-icon>
         </template>

         <template #add>
            <el-icon><Plus /></el-icon>
         </template>
      </BtnGroup>

      <!-- 表格+分页器 -->
      ...

      <!-- 默认隐藏的对话框 -->
      ...
   </div>
</template>
/* 定义BtnGroup需要的数据 */
const groupBtns = [
	{
		name: "删除",
		type: "danger",
		slotName: "delete",
		callback: "patchDelete",
	},
	{
		name: "导出",
		type: "success",
		slotName: "export",
		callback: "patchExport",
	},
	{
		name: "添加",
		type: "primary",
		slotName: "add",
		callback: "addComing",
	},
];
/* 定义按钮组回调功能 */
let groupBtnCallbacks = {
	patchDelete,
	patchExport,
	addComing,
};

/* 定义事件处理逻辑 */
const onGroupBtnClick = callback => {
    console.log("onGroupBtnClick", callback);
    groupBtnCallbacks[callback]();
};

源码

componnets/BtnGroup.vue

<template>
   <div class="card top">
      <el-button
         v-for="(btn, index) in groupBtns"
         class="opBtn"
         :type="btn.type"
         @click="emit(`groupBtnClick`, btn.callback)"
         :disabled="disabledArr[index]">
         <slot :name="btn.slotName">
            <el-icon><Menu /></el-icon>
         </slot>

         &nbsp;{{ btn.name }}
      </el-button>
   </div>
</template>

<script setup>
import { Menu, Delete, Edit } from "@element-plus/icons-vue";
import { defineProps, defineEmits, ref, defineExpose } from "vue";

const { groupBtns } = defineProps({
   groupBtns: Array,
});

const emit = defineEmits({
   groupBtnClick: null,
});

const disabledArr = ref([]);
const disableBtn = index => {
   console.log("disableBtn called", index);
   disabledArr.value[index] = true;
};

/* 父组件调用子组件暴露出来的API:xxRef.value.disableBtn(0) */
defineExpose({
   disableBtn,
});
</script>

<style lang="scss" scoped>
.top {
   display: flex;
   justify-content: flex-end;
   .opBtn {
      width: 100px;
   }
}
</style>

@数据表格

实现效果

image.png

页面部署

<template>
   <div class="wrapper">
      <!-- <p>currentPage: {{ currentPage }}</p> -->

      <!-- 批量操作按钮组 -->
      ...

      <!-- 表格+分页器 -->
      <!-- @page-change="onEpPageChange" -->
      <EpTable
         ref="refEpTable"
         :tableData="tableData"
         :page-size="10"
         :cols="cols"
         :avgColWidth="100"
         @delete-item="deleteItem">
         
         <!-- 这里的东东覆盖名为poster的插槽 row为poster插槽暴露出来的数据 -->
         <template #poster="{ row }">
            <div style="display: flex; align-items: center">
               <el-image :src="row.poster" />
            </div>
         </template>

         <!-- 这里的东东覆盖名为poster的插槽 row为poster插槽暴露出来的数据 -->
         <template #actors="{ row }">
            <div style="display: flex; align-items: center">
               <el-image :src="row.actors[0].avatarAddress" />
            </div>
         </template>
      </EpTable>

      <!-- 默认隐藏的对话框 -->
      ...
   </div>
</template>
/* 表格列定义 */
const cols = [
	{
		prop: "filmId",
		label: "id",
		fixed: true,
		width: 100,
	},
	{
		prop: "poster",
		label: "海报",
		width: 60,
		nosort: true,
		hasSlot: true,
	},
	{
		prop: "actors",
		label: "导演",
		width: 60,
		hasSlot: true,
		nosort: true,
	},
	{
		prop: "name",
		label: "片名",
		width: 300,
	},
	{
		prop: "actors",
		label: "主演",
		formatter: row =>
			row.actors
				.map(a => a.name)
				.slice(0, 3)
				.join(",") + "等",
		width: 300,
	},
	{
		prop: "category",
		label: "影片类型",
		width: 200,
	},
	{
		prop: "filmType.name",
		label: "视觉",
	},
	{
		prop: "premiereAt",
		label: "首映",
		width: 150,
		formatter: (row, column, cellValue) => new Date(cellValue * 1000).toLocaleDateString().replaceAll("/", "-"),
	},
	{
		prop: "nation",
		label: "国家",
	},
	{
		prop: "grade",
		label: "评分",
	},
	{
		prop: "runtime",
		label: "时长",
	},
];

源码

<template>
   <div
      id="table-wrapper"
      ref="refWrapper">
      <el-table
         ref="refTable"
         :data="getPageData(tableData)"
         stripe
         class="middle wrapper"
         style="width: 100%"
         :default-sort="{ prop: 'date', order: 'ascending' }"
         @selection-change="handleSelectionChange">
         <!-- 多选显示栏 -->
         <el-table-column
            type="selection"
            width="40" />

         <!-- 递归列配置 -->
         <el-table-column
            v-for="{ prop, label, fixed, width, formatter, hasSlot, nosort } in cols"
            :sortable="nosort ? false : true"
            :fixed="fixed"
            :prop="prop"
            :label="label"
            :width="width ? width : avgColWidth ? avgColWidth : defColWidth"
            :formatter="formatter">
            <template
               v-if="hasSlot"
               #default="{ row }">
               <slot
                  :name="prop"
                  :row="row"></slot>
            </template>
         </el-table-column>

         <!-- 右侧固定的操作按钮区 -->
         <el-table-column
            fixed="right"
            label="操作"
            width="90">
            <!-- action按钮区作用域插槽提供的数据中含有当前行信息row -->
            <!-- <template #default="scope"> -->

            <!-- 可以通过简单的测试查看一下作用域插槽中携带的数据 -->
            <!-- <el-button @click="showScope(scrope)"/> -->

            <!-- 从作用域插槽数据中解构出当前行id -->
            <template #default="{ row: { _id } }">
               <!-- 点击Edit按钮 携带id跳转详情页 -->
               <el-button
                  @click="$router.push(`/film/${_id}`)"
                  type="primary"
                  :icon="Edit"
                  circle
                  size="small" />

               <!-- 触发单个影片删除 -->
               <el-button
                  @click="deleteItem(_id)"
                  type="danger"
                  :icon="Delete"
                  circle
                  size="small" />
            </template>
         </el-table-column>
      </el-table>
   </div>

   <div class="card bottom">
      <el-pagination
         background
         layout="prev, pager, next"
         :page-size="pageSize"
         :total="tableData.length"
         v-model:current-page="currentPage" />
   </div>
</template>

<script setup>
import { ref, onMounted, defineExpose } from "vue";
import { Delete, Edit } from "@element-plus/icons-vue";
import { ElTable, ElMessage } from "element-plus";

const { tableData, pageSize, fixedCol, cols, colWidth, avgColWidth } = defineProps({
   tableData: Array,
   pageSize: Number,
   fixedCol: Object,
   cols: Array,
   avgColWidth: Number,
});

const emit = defineEmits({
   deleteItem: id => /^[a-z\d]{24}$/.test(id),
   patchDelete: null,
});

/* 多选时此处能拿到选中的子数组 */
const selectedItems = ref([]);
const getSelectedItems = () => selectedItems.value;

const handleSelectionChange = val => {
   console.log("handleSelectionChange", val);
   selectedItems.value = val;
};

/* 获取分页数据 */
const currentPage = ref(1);
const getPageData = arr => {
   console.log("getPageData,arr=", arr);
   console.log("getPageData,tableData=", tableData);
   return arr.slice((currentPage.value - 1) * pageSize, currentPage.value * pageSize);
};

/* 默认列宽 */
const refWrapper = ref(null);
const defColWidth = ref(100);
onMounted(() => {
   console.log("refTable.value=", refWrapper.value.clientWidth);
   defColWidth.value = Math.round(refWrapper.value.clientWidth / 4);
});

/* 删除单个 */
const deleteItem = id => {
   emit("deleteItem", id);
};

defineExpose({
   getSelectedItems,
});
</script>

<style lang="scss" scoped>
.card {
   padding: 10px;
   background-color: white;
   border-radius: 5px;
}

.middle {
   margin: 10px 0;
}

.bottom {
   padding: 5px;
}

.wrapper {
   width: 100%;
}
</style>

@ 确认弹窗

实现效果

image.png

页面部署

<template>
   <div class="wrapper">
      <!-- <p>currentPage: {{ currentPage }}</p> -->

      <!-- 批量操作 -->
      <BtnGroup...
      </BtnGroup>

      <!-- 表格+分页器 -->
      <!-- @page-change="onEpPageChange" -->
      <EpTable...
      </EpTable>

      <!-- 默认隐藏的对话框 -->
      <EpDialog
         ref="refEpDialog"
         :dialogMode="dialogMode"></EpDialog>
   </div>
</template>
/* 对话框模式 */
const dialogModes = {
    // 单个删除模式
    deleteItem: {
        msg: "确认删除影片吗?",
        callback: doDeleteItem,
    },

    // 批量删除模式
    patchDelete: {
        msg: "确认执行批量删除吗?",
        callback: doPatchDelete,
    },
};

// 默认使用单个删除模式
let dialogMode = ref(dialogModes.deleteItem);

源码

<template>
   <el-dialog
      v-model="dialogVisible"
      title="操作确认"
      width="30%">
      <!-- 显示【当前模式的提示信息】 -->
      <span>{{ dialogMode.msg }}</span>

      <template #footer>
         <span class="dialog-footer">
            <el-button @click="dialogVisible = false">取消</el-button>

            <!-- 用户点击确认时执行【当前模式对应的回调】 -->
            <el-button
               type="primary"
               @click="dialogMode.callback">
               确认
            </el-button>
         </span>
      </template>
   </el-dialog>
</template>

<script setup>
import { ref } from "vue";

const { dialogMode } = defineProps({
   dialogMode: Object,
});

// 对话框显隐控制
const dialogVisible = ref(false);

const setDialogVisible = value => (dialogVisible.value = value);

defineExpose({
   setDialogVisible,
});
</script>

<style lang="scss" scoped></style>

@递归菜单

实现效果

image.png

页面部署

<template>
   <!-- 页头 -->
   <MyPageHeader...</MyPageHeader>

   <main>
      <div class="left">
         <!-- 递归菜单 -->
         <EpMenu
            :menu="adminMenu"
            :activeIndex="currentMenuIndex"></EpMenu>
      </div>

      <!-- 右侧内容区 -->
      <div class="right">
         <router-view...</router-view>
      </div>
   </main>
</template>
[
    {
        "name":"数据看板",
        "path":"/data",
        "iconName":"PieChart"
    },
    {
        "name":"影片管理",
        "iconName":"Film",
        "submenu":[
            {
                "name":"正在热映",
                "iconName":"VideoCamera",
                "path":"/film/playing"
            },
            {
                "name":"即将上映",
                "iconName":"Loading",
                "path":"/film/coming"
            }
        ]
    },
    {
        "name":"影院管理",
        "iconName":"PictureFilled",
        "submenu":[
            {
                "name":"热门城市",
                "iconName":"Star",
                "submenu":[
                    {
                        "name":"北京",
                        "path":"/cinema/hot"
                    },
                    {
                        "name":"上海",
                        "path":"/cinema/hot"
                    },
                    {
                        "name":"广州",
                        "path":"/cinema/hot"
                    }
                ]
            },
            {
                "name":"所有城市",
                "iconName":"Location",
                "path":"/cinema/all"
            }
        ]
    },
    {
        "name":"用户管理",
        "iconName":"User",
        "path":"/user"
    },
    {
        "name":"案例管理",
        "iconName":"Grid",
        "path":"/demos"
    }
]

源码实现

components/menu/EpMenu.vue

<template>
   <el-menu
      :default-active="activeIndex"
      class="el-menu-vertical-demo"
      :collapse="isCollapse"
      @open="handleOpen"
      @close="handleClose"
      @select="onSelect"
      background-color="#3a4149"
      text-color="#fff"
      active-text-color="#99ffff">
      <EpMenuUnit
         :menu="menu"
         parentIndex="" />
   </el-menu>
</template>

<script setup>
import EpMenuUnit from "./EpMenuUnit.vue";
const { activeIndex } = defineProps({
   activeIndex: String,
   menu: Array,
});
</script>

<style lang="scss" scoped></style>

components/menu/EpMenuUnit.vue

<template>
   <template
      v-for="(item, idx) in menu"
      :key="item.name">
      <!-- 渲染菜单项 -->
      <RouterLink
         v-if="!item.submenu"
         :to="item.path">
         <el-menu-item :index="parentIndex + idx">
            <el-icon v-if="item.iconName">
               <EpIcon :icon="icons[item.iconName]" />
            </el-icon>

            <template #title>
               {{ item.name }}
            </template>
         </el-menu-item>
      </RouterLink>

      <!-- 渲染子菜单 -->
      <el-sub-menu
         v-else
         :index="parentIndex + idx">
         <template #title>
            <el-icon v-if="item.iconName">
               <EpIcon :icon="icons[item.iconName]" />
            </el-icon>

            <span>{{ item.name }}</span>
         </template>

         <!-- 对子菜单实施递归 -->
         <EpMenuUnit
            :menu="item.submenu"
            :parentIndex="`${parentIndex}${idx}-`" />
      </el-sub-menu>
   </template>
</template>

<script setup>
import EpIcon from "./EpIcon.vue";

import {
   Document,
   Menu,
   Location,
   User,
   Film,
   VideoCamera,
   Odometer,
   Loading,
   Star,
   PictureFilled,
   PieChart,
   Grid,
} from "@element-plus/icons-vue";

const icons = {
   Document,
   Menu,
   Location,
   User,
   Film,
   VideoCamera,
   Odometer,
   Loading,
   Star,
   PictureFilled,
   PieChart,
   Grid,
};

const { activeIndex, menu, parentIndex } = defineProps({
   activeIndex: String,
   menu: Array,
   parentIndex: String,
});
</script>

<style lang="scss" scoped></style>

components/menu/EpIcon.vue

<script>
import { h } from "vue";

export default {
   props: ["icon"],
   render() {
      return h(this.icon);
   },
};
</script>


“这不需要测试,肯定是好的,不必担心!”

image.png