@引子
本系列组件基于Element-Plus开发,已发布为开源组件库,欢迎使用!
npm i @steveouyang/super-ep
@页头组件
实现效果
页面部署
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>
@按钮组
实现效果
页面部署
<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>
{{ 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>
@数据表格
实现效果
页面部署
<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>
@ 确认弹窗
实现效果
页面部署
<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>
@递归菜单
实现效果
页面部署
<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>
“这不需要测试,肯定是好的,不必担心!”