之前一直忙于公司业务扩展后的填需求,现在终于有机会好好总结下在项目中一些优秀的实践,希望也会对你的开发有所启发。
Layout组件
对于一个控制台项目,他总有些登录后就不会再修改的部分,比如侧边菜单栏、顶部底部导航栏,在Vue中,我们可以通过嵌套路由来实现。这样做,在页面切换时,用户体验会更加平滑。
目录结构:
├── src ├── components └── common ├── Sidebar # 侧边菜单栏 │ ├── MenuItem.vue # 菜单子项 │ └── index.vue # 菜单栏 ├── Header.vue # 顶部导航 └── Layout.vue # Layout组件
Layout.vue:
<template> <div class="wrapper"> <v-sidebar></v-sidebar> <div class="content-box"> <v-head></v-head> <div class="content"> <transition name="move" mode="out-in"> <router-view></router-view> </transition> </div> </div> </div> </template> <script> import vHead from './Header.vue' import vSidebar from './Sidebar/Index.vue' export default { name: 'Layout', components: { vHead, vSidebar } } </script>
router/index.js:
export const constRoutes = [{ path: '/', name: 'home', component: Layout, redirect: '/home/index', children: [{ path: '/404', component: () => import(/* webpackChunkName: "404" */ '@page/error/404.vue'), meta: { title: '404' } }, { path: '/403', component: () => import(/* webpackChunkName: "403" */ '@page/error/403.vue'), meta: { title: '403' } }] }]
权限控制
权限控制是每个控制台都逃不掉的课题,最普遍简单的做法就是通过constRoutes
静态路由和asyncRoutes
动态路由来实现。
这里我们做个小小的升级,为了可以更灵活的配置权限,除了可配置的角色权限外,我们还额外引入一个全局可以展示的所有菜单列表,添加这个列表的好处是,当我们在版本迭代时,会存在删减需求的情况,这时候比起一个个角色修改可显示菜单,还是直接修改可展示列表更为高效便捷。
权限控制的流程:
- 未登录的情况跳转登录页面
- 用户登录获取token及权限可访问菜单
- 在浏览器地址栏输入访问地址时比较可展示菜单和用户可访问菜单,满足条件则跳转
- 菜单栏比较可展示菜单和用户可访问菜单显示符合条件的菜单
目录结构:
├── router │ ├── modules # 划分路由 │ │ ├── page.js # page菜单下所有路由配置 │ │ └── setting.js # setting菜单下所有路由配置 │ └── index.js # 路由主路径 ├── utils └── menulist.js # 所有可展示菜单
让我们直接来看看router/index.js文件:
import Vue from 'vue' import Router from 'vue-router' import { allPermissions } from '@/utils/menulist.js' import Layout from '@/components/common/Layout' import store from '../store' Vue.use(Router) export const constRoutes = [{ path: '/login', name: 'login', component: () => import(/* webpackChunkName: "login" */ '@page/Login.vue') }, { path: '/', name: 'home', component: Layout, redirect: '/overview/index', children: [{ path: '/404', component: () => import(/* webpackChunkName: "404" */ '@page/error/404.vue'), meta: { title: '404' } }, { path: '/403', component: () => import(/* webpackChunkName: "403" */ '@page/error/403.vue'), meta: { title: '403' } }] }] const routes = [] const files = require.context('./modules', false, /\w+.js$/) files.keys().forEach(fileName => { // 获取模块 const file = files(fileName) routes.push(file.default || file) }) export const asyncRoutes = [ ...routes, { path: '/log', name: 'log', meta: { title: '日志', icon: 'el-icon-s-management', roles: ['admin'] }, component: Layout, children: [{ path: 'index', name: 'log_index', meta: { title: '操作记录', icon: 'el-icon-s-custom', roles: ['admin'] }, component: () => import(/* webpackChunkName: "log_index" */ '@page/log/index') }] }, { path: '*', redirect: '/404', hidden: true } ] const router = new Router({ routes: constRoutes.concat(asyncRoutes) }) router.beforeEach((to, from, next) => { const hasToken = store.state.user.userId const permissions = store.getters.permissions if (hasToken) { if (to.path === '/login') { next({ path: '/' }) } else if (allPermissions.includes(to.name) && !permissions.includes(to.name)) { next({ path: '/404' }) } else { next() } } else { if (to.path !== '/login') { next('/login') } else { next() } } }) export default router
这里我们会发现,所谓的asyncRoutes
其实并不是从后台返回的,它包含了所有我们定义的路由,真正的控制其实是在用户信息的permissions
中实现的,为什么是这么做的呢,因为大多数时候后台保存的权限表并不是完整的路由信息,他可能只包含了路由的name或是path,为了达到真实的控制,我们只需要将asyncRoutes
和他比较就可以了。
分离对全局Vue的拓展
在项目中,我们经常会在全局Vue上做很多拓展,为了项目将来可以更方便的迁移拓展,我们可以做个小小的优化,将项目特有的拓展抽离成一个文件,也方便后期的维护。
目录结构:
├── main.js ├── app.js
main.js:
import Vue from './app.js' import router from './router' import store from './store' import App from './App.Vue' new Vue({ store, router, render: h => h(App) }).$mount('#app')
这个main.js里就是最纯粹原始的Vue实例创建创建,当我们需要迁移时,只需要修改Vue的来源。
app.js
import Vue from 'vue' import http from '@/utils/http' import ElementUI from 'element-ui' import contentmenu from 'v-contextmenu' import 'v-contextmenu/dist/index.css' import 'element-ui/lib/theme-chalk/index.css' // 默认主题 import './assets/css/icon.css' Vue.config.productionTip = false Vue.prototype.$http = http Vue.use(contentmenu) Vue.use(ElementUI, { size: 'small' }) export default Vue
这里举例的app.js就拓展引入了第三方的库。
axios的封装
通常项目中,为了做一些请求状态的拦截,我们会对axios再做一层封装,这其中也可以引入例如elemenet的加载组件,给所有的请求做一个过渡状态。
这里示例的例子主要在axios上拓展了三件事:
- 对所有的post请求添加loading动画
- 针对身份信息错误的情况,清空身份信息,跳转登录界面
- 针对请求返回错误状态的提示
目录结构:
├── src ├── utils └── http.js # 封装axios
http.js:
import axios from 'axios' import { MessageBox, Message, Loading } from 'element-ui' import router from '@/router' import store from '@/store' const http = axios.create({ baseURL: '/console', timeout: 10000 }) let loading = null let waiting = false http.interceptors.request.use( config => { if (config.method !== 'get') { loading = Loading.service({ fullscreen: true }) } return config }, error => { return Promise.reject(error) } ) http.interceptors.response.use( response => { loading && loading.close() return response.data }, error => { loading && loading.close() console.log('error', error.message) if (error.message && error.message.indexOf('timeout') > -1) { Message({ message: '请求超时', type: 'error', duration: 3 * 1000 }) return Promise.reject(error) } // 对错误状态码进行处理 const { status, data: { message } } = error.response if (status === 401) { if (!waiting) { waiting = true // 登录状态不正确 MessageBox.alert('登录状态异常,请重新登录', '确认登录信息', { confirmButtonText: '重新登录', type: 'warning', callback: () => { waiting = false store.commit('clearUserInfo') router.replace({ name: 'login' }) } }) } return Promise.reject(error) } if (status === 404) { return Promise.reject(error) } Message({ message, type: 'error', duration: 3 * 1000 }) return Promise.reject(error) } ) export default http
app.js:
import Vue from 'vue' import http from '@/utils/http' Vue.prototype.$http = http
这里直接把封装好的请求挂在了Vue上,可以方便之后再组件中使用。
组件中使用:
<template> <div>{{price}}</div> </template> <script> export default { data () { return { price: 0 } } method: { getData () { this.$http.get(`/getPrice`).then((data) => this.price = data }) } } } </script>
全局组件注册
项目中必然会存在一些全局公用的组件,但如果我们一个个去注册会很麻烦,所以这里我们把全局组件提到一个专门的目录下,通过一个registerComponent
的方法,批量注册,以后,我们就可以直接在页面里引用这些组件。
目录结构:
├── src ├── components └── global # 存放全局组件的目录 ├── TableData.vue # 全局组件 └── index.js # 用来批量处理组件组册的函数入口
index.js:
export default function registerComponent (Vue) { const modules = require.context('./', false, /\w+\.Vue$/) modules.keys().forEach(fileName => { const component = modules(fileName) const name = fileName.replace(/^\.\/(.*)\.\w+$/, '$1') Vue.component(name, component.default) }) }
app.js:
import Vue from 'vue' import registerComponent from './components/global' registerComponent(Vue)
页面中使用:
<template> <div> <TableData></TableData> </div> </template>
页面中无需引入组件,可以直接使用。
全局过滤器注册
在项目中,我们会频繁遇到对诸如时间、金额的格式化,将他们作为全局的过滤器,将更方便我们后续的使用。
目录结构:
├── src ├── utils └── filters.js # 存放全局过滤器函数入口
filters.js:
export const formatPrice = (value, fixed = 2) => { if (!value) { return Number(0).toFixed(fixed) } return Number(value / 10 ** fixed).toFixed(fixed) } export const formatDate = (date, split = '-') => { if (!date) return '' const _date = new Date(date) let year = _date.getFullYear() let month = _date.getMonth() + 1 let day = _date.getDate() return [year, month.toString().padStart(2, '0'), day.toString().padStart(2, '0')].join(split) } export const formatTime = (time) => { if (!time) return '' const _date = new Date(time) let year = _date.getFullYear() let month = _date.getMonth() + 1 let day = _date.getDate() let hour = _date.getHours() let minute = _date.getMinutes() return `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}` } export const formatTimeToSeconds = (time) => { if (!time) return '' const _date = new Date(time) let year = _date.getFullYear() let month = _date.getMonth() + 1 let day = _date.getDate() let hour = _date.getHours() let minute = _date.getMinutes() let seconds = _date.getSeconds() return `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } export default (Vue) => { Vue.filter('formatPrice', formatPrice) Vue.filter('formatDate', formatDate) Vue.filter('formatTime', formatTimeToSeconds) Vue.filter('formatTimeToSeconds', formatTimeToSeconds) }
app.js:
import Vue from 'vue' import registerFilter from './utils/filters' registerFilter(Vue)
组件中使用:
<template> <div>{{ price | formatPrice }}</div> </template>
表格过滤组件
控制台项目里,最常见的就是查询记录以表格的形式展现出来,表格的功能大多也都比较类似,所以我们可以封装一个通用的表格组件,帮助我们简化一下表格的操作。
这个表格组件将包含以下功能:
- 多种数据来源:表格的数据可以由用户传入,也可以通过请求api获得数据
- 数据查询:可以支持常见的输入框、下拉框、时间选择器的筛选
- 分页:根据传入参数,动态决定每页展示数据量
- 格式化数据:根据传入的规则对查询获取的数据格式化
- 自定义表格内容:允许用户自由编辑表格内容
<template> <div> <el-form :inline="true" :model="filter" class="demo-form-inline"> <el-form-item v-for="item in filterItems" :key="item.prop" :label="item.label"> <el-date-picker v-if="item.type === 'daterange'" v-model="filter[item.prop]" :default-time="['00:00:00', '23:59:59']" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期"> </el-date-picker> <el-date-picker v-else-if="item.type === 'date'" v-model="filter[item.prop]" type="date" placeholder="选择日期"> </el-date-picker> <el-select v-else-if="item.type === 'select'" v-model="filter[item.prop]" :placeholder="item.placeholder || item.label" clearable> <el-option v-for="option in item.options" :key="option.value" :label="option.label" :value="option.value"> </el-option> </el-select> <el-input v-else v-model="filter[item.prop]" :placeholder="item.placeholder || item.label" :type="item.type" clearable></el-input> </el-form-item> <el-form-item v-if="filterItems && filterItems.length > 0"> <el-button type="primary" @click="refresh">查询</el-button> </el-form-item> <el-form-item v-if="filterItems && filterItems.length > 0"> <el-button @click="reset">重置条件</el-button> </el-form-item> </el-form> <slot :data="list"></slot> <div class="pagination"> <el-pagination background layout="total, prev, pager, next" :current-page="page" :page-size="rows" :total="total" @current-change="changePage" ></el-pagination> </div> </div> </template> <script> export default { name: 'TableFilter', props: { // 可选,表格数据 tableData: Array, // 可选,请求api地址 url: String, // 表格筛选项 filterItems: { type: Array, default () { return [] } }, // 筛选数据 filter: { type: Object, default () { return {} } }, // 每页展示数据量 defaultRows: { type: Number, default: 10 }, // 格式化规则 formatTableData: Function }, data () { return { defaultFilter: { ...this.filter }, list: [], rows: this.defaultRows, total: 0, page: 1 } }, watch: { tableData: { handler (tableData) { this.calcTableData(tableData) }, immediate: true } }, methods: { reset () { for (const key in this.filter) { if (this.filter.hasOwnProperty(key)) { this.filter[key] = this.defaultFilter[key] } } }, changePage (page) { this.page = page this.search() }, // 针对用户传入表格数据的情况做前端分页 calcTableData (tableData = []) { const list = tableData.slice((this.page - 1) * this.rows, this.page * this.rows) this.list = this.formatTableData ? this.formatTableData(list) : list this.total = tableData.length }, search () { if (this.tableData) { this.calcTableData(this.tableData) } else { // 发送请求 const filter = {} Object.keys(this.filter).forEach(key => { if (this.filter[key]) { if (key === 'daterange') { filter['startTime'] = this.filter[key][0] filter['endTime'] = this.filter[key][1] } else { filter[key] = this.filter[key] } } }) this.$http.get(this.url, { params: { ...filter, page: this.page, rows: this.rows } }).then(({ total, list }) => { this.total = total this.list = this.formatTableData ? this.formatTableData(list) : list }) } }, refresh () { this.page = 1 this.search() } } } </script> <style lang="scss" scoped> </style>
下面我们来详细说下实现思路:
- 多种数据来源:
这个比较容易实现,通过可选传入url或者tableData进行判断
- 数据查询:
通过filterItems传入,filterItems的形如:
[ { prop: 'name', type: 'text', label: '名称' }, { prop: 'gender', type: 'select', label: '性别', options: [{ label: '男', value: 'male' }, { label: '女', value: 'female' }] } ]
- 分页:
通过传入的defaultRows控制。
- 格式化数据:
通过传入的formatTableData进行格式化,formatTableData的函数形如:
const formatItem = (item) => { // do something... } const formatTableData = (list) => { list.map(formatItem) }
- 自定义表格内容:
这里我们巧妙的运用了作用域插槽,让插槽内容可以访问子组件中的数据。
<!-- 子组件:--> <slot :data="list"></slot> <!-- 插槽内容 --> <template v-slot="{ data }"></template>
接下来看看在组件中使用的完整示例:
<template> <div> <TableFilter url="/getUser" :filterItem="filterItem" :filter="filter" :defaultRows="20" :formatTableData="formatTableData"> <template v-slot="{ data }"> <el-table :data="data"> <el-table-column prop="name" label="名称"></el-table-column> <el-table-column prop="gender" label="性别"></el-table-column> <el-table-column label="添加时间" sortable prop="createtime"> <template v-slot="{ row }"> <div >{{ row.createtime | formatTime }}</div> </template> </el-table-column> <el-table-column label="操作"> <template v-slot="{ row }"> <el-link :underline="false" @click="toDetail(row.id)">查看</el-link> </template> </el-table-column> </el-table> <template> </TableFilter> </div> </template> <script> const formatItem = (item) => { // do something } export default { data () { return { filterItem: [ { prop: 'name', type: 'text', label: '名称' }, { prop: 'gender', type: 'select', label: '性别', options: this.genderOptions } ], genderOptions: [{ label: '男', value: 'male' }, { label: '女', value: 'female' }], filter: { status: 'enable' } } }, methods: { formatTableData (list) { list.map(formatItem) } } } </script>
单例插件
项目中存在一类组件,这类组件可能是个在页面中会被频繁调用的弹出框,对于这类组件,显然在页面中引入多个是个不明智的做法,所以大多时候,我们会引入一个,让他根据不同的交互场景重新渲染内容,但是更好的做法是将他做成一个单例插件,通过函数调用的方法使用。
这里将介绍两种方法:
- 封装成vue插件,全局引入
- 单独引入,函数式调用
两种方法在使用上其实相差无几,在项目中,可以根据自己的喜好任选一种。
vue插件:
目录结构:
├── SingleComponent ├── component.vue # 组件内容 └── index.js # 组件创建
index.js:
import component from './component.vue' let SingleComponent = { install: function (Vue) { const Constructor = Vue.extend(component) const instance = new Constructor() instance.init = false Vue.prototype.$singleComponent = (options, callback) => { if (!instance.init) { instance.$mount() document.body.appendChild(instance.$el) instance.init = true } // 从options里获取参数,赋值给组件实例中的data // 传入的callback绑定给组件实例的某个方法,实例方法将会把组件的数据暴露给这个回调 instance.someOption = options.someValue instance.someMethods = callback } } } export default SingleComponent
app.js:
import Vue from 'vue' import SingleComponent from './global/SingleComponent' Vue.use(SingleComponent)
组件内使用:
export default { data () { return { studentsList: [] } } methods: { useComponent () { this.$singleComponent({ gender: 'male', age: 12 }, (list) => { this.studentsList = list }) } } }
函数式组件:
目录结构:
├── SingleComponent ├── component.vue # 组件内容 └── index.js # 组件创建
index.js:
import Vue from '@/app.js' import Component from './component.vue' let instance = null let SingleComponent = (options, callback) => { if (!instance) { const Consturctor = Vue.extend(Component) instance = new Consturctor() instance.$mount() document.body.appandchild(instance.$el) } // 从options里获取参数,赋值给组件实例中的data // 传入的callback绑定给组件实例的某个方法,实例方法将会把组件的数据暴露给这个回调 instance.someOption = options.someValue instance.someMethods = callback return instance }
组件中使用:
import SingleComponent from '@/global/SingleComponent' export default { data () { return { studentsList: [] } }, methods: { useComponent () { SingleComponent({ gender: 'male', age: 12 }, (list) => { this.studentsList = list }) } } }
CMS组件
随着一个项目的发展,我们必然会遇到一些特殊的页面,这些页面在不同的版本中,可能会频繁修改布局内容,如果按照传统的固定页面开发模式,将增加大量的工作量,所以CMS应运而生。
CMS(Content Management System)即内容管理系统,它的作用是可以让一个即使没有编码能力的用户,通过可视化的操作,就能编写出一个自定义的页面。
这里的示例,部分将会使用伪代码,主要提供一个CMS系统的设计思路。
目录结构:
├── CMS ├── components # 存放公用组件 ├── Modules # CMS组件库 │ ├── Text # 示例文本组件 │ │ ├── Module.vue # 预览模块 │ │ ├── options.js # 可修改配置属性及校验 │ │ └── Options.vue # 配置属性操作面板 │ └── index.js # 注册CMS组件 └── index.vue # 主面板
自选组件
从前面的目录结构,我们已经可以看出来,一个自选模块我们将用三个文件来实现:
- 预览模块:根据配置最终将展示的组件成品
- 属性及校验:定义组件将使用的属性结构及保存所需的校验
- 属性操作面板:包含所有可配置属性的表单
这里我们以一个文字组件作为示例,我们先来看看其中最关键的属性结构和校验的定义:
options.js:
import Validator from 'async-validator' export const name = '文本' // 自选组件名称 // 校验规则 const descriptor = { target: { type: 'object', fields: { link: { type: 'string', required: false }, name: { type: 'string', required: false } } }, text: { type: 'string', required: true, message: `${name}组件,内容不能为空` } } const validator = new Validator(descriptor) export const validate = (obj) => { return validator.validate(obj) } // 默认属性 export const defaultOptions = () => { return { target: { link: '', name: '' }, text: '', color: 'rgba(51, 51, 51, 1)', backgroundColor: 'rgba(255, 255, 255, 0)', fontSize: '16px', align: 'left', fontWeight: 'normal' } }
对属性的定义有了了解后,我们来看看对应的操作面板:
Options.vue:
<template> <el-form label-width="100px" :model="form"> <el-form-item label="文本:" prop="text"> <el-input type="textarea" :rows="3" v-model="form.text"></el-input> </el-form-item> <el-form-item label="字体大小:" class="inline"> <el-input v-model.number="fontSize"></el-input>px </el-form-item> <el-form-item label="字体颜色:"> <el-color-picker v-model="form.color" show-alpha></el-color-picker> </el-form-item> <el-form-item label="背景颜色:"> <el-color-picker v-model="form.backgroundColor" show-alpha></el-color-picker> </el-form-item> <el-form-item label="字体加粗:"> <el-checkbox v-model="checked"></el-checkbox> </el-form-item> <el-form-item label="对齐方式:"> <el-radio-group v-model="form.align"> <el-radio label="left">左对齐</el-radio> <el-radio label="center">居中对齐</el-radio> <el-radio label="right">右对齐</el-radio> </el-radio-group> </el-form-item> </el-form> </template> <script> import { defaultOptions } from './options' export default { name: 'options', props: { form: { type: Object, default () { return defaultOptions() } } }, watch: { fontSize (val) { this.fontSize = val.replace(/[^\d]/g, '') } }, computed: { // 字体大小 fontSize: { get () { return this.form.fontSize.slice(0, -2) }, set (val) { this.form.fontSize = val + 'px' } }, // 字体是否加粗 checked: { get () { return this.form.fontWeight === 'bold' }, set (val) { if (val) { this.form.fontWeight = 'bold' } else { this.form.fontWeight = 'normal' } } } }, data () { return { } } } </script>
实际上,每个自选组件的配置属性,都将保存在form属性中,之后他会作为prop属性传给Module以展示预览效果。
Module.vue:
<template> <div class="text" :style="style"> {{options.text}} </div> </template> <script> export default { name: 'module', props: { options: { type: Object, default () { return { text: '', align: 'left', color: 'rgba(19, 206, 102, 0.8)', backgroundColor: 'rgba(255, 255, 255, 0)', fontSize: '16px', fontWeight: 'normal' } } } }, computed: { style () { return { textAlign: this.options.align, color: this.options.color, backgroundColor: this.options.backgroundColor, fontSize: this.options.fontSize, fontWeight: this.options.fontWeight } } }, data () { return { } } } </script> <style lang="css" scoped> .text { word-break: break-all; } </style>
光看自选组件的三个文件,我们好像并没有将他们串在一起,别急,这些我们最终会在主面板里实现。
自选组件注册入口
index.js:
// 获取需要引入的自选组件 export const getComponents = () => { const modules = require.context('./', true, /.vue$/) const components = {} modules.keys().map(fileName => { const componentName = fileName.replace(/\.\/(\w+)\/(\w+).vue$/, '$1$2') components[componentName] = modules(fileName).default }) return components } // 获取自选组件的预览模块 export const getModules = () => { const modules = require.context('./', true, /.vue$/) const cells = modules.keys().map(fileName => { return fileName.replace(/\.\/(\w+)\/\w+.vue$/, '$1') }) return Array.from(new Set(cells)) } // 获取自选组件默认属性 export const getDefaultOptions = () => { const modules = require.context('./', true, /options.js$/) const ret = {} modules.keys().forEach(fileName => { ret[fileName.replace(/\.\/(\w+)\/\w+.js$/, '$1')] = modules(fileName).defaultOptions }) return ret } // 获取自选组件校验函数 export const getValidates = () => { const modules = require.context('./', true, /options.js$/) const ret = {} modules.keys().forEach(fileName => { ret[fileName.replace(/\.\/(\w+)\/\w+.js$/, '$1')] = modules(fileName).validate }) return ret } // 获取自选组件名称 export const getModuleName = () => { const modules = require.context('./', true, /options.js$/) const ret = {} modules.keys().forEach(fileName => { ret[fileName.replace(/\.\/(\w+)\/\w+.js$/, '$1')] = modules(fileName).name }) return ret }
在index.js中定义的几个函数,都将在主面板中使用。
主面板
页面主要分为这样几个区块:
- 自选组件列表
- 已添加组件列表操作面板
- 预览区域
- 详情操作面板
示例图:
现在我们来看看主面板的实现:
<template> <div class="manage-content"> <div class="designer"> <div class="designer-menus__left"> <div class="label">组件列表:</div> <span class="cell" v-for="cell in cells" :key="cell" @click="addModule(cell)">{{nameMap[cell]}}</span> <div class="label">页面导航:</div> <div v-if="modules.length === 0" class="map-wrapper"> <div class="map-module"> 未添加组件 </div> </div> <draggable v-else v-model="modules" class="map-wrapper" handle=".el-icon-rank"> <div v-for="module in modules" class="map-module" :class="{ select: module.id === curModule.id }" :key="module.id" @click="selModule(module)"> <i class="el-icon-rank"></i> <div class="name"> {{nameMap[module.type]}} </div> <i class="el-icon-close" @click.stop="delModule(module.id)"></i> </div> </draggable> </div> <div class="designer-content"> <!-- 预览区域 --> <div class="screen" ref="screen"> <div class="module" v-for="module in modules" :key="module.id" @click="selModule(module)" :class="{ select: module.id === curModule.id }" :id="module.id"> <component :is="module.type + 'Module'" :options="module.options"></component> </div> </div> <!-- 操作区域 --> <div class="operation-content"> <el-button @click="$router.back()">取消</el-button> <el-button @click="save">保存</el-button> </div> </div> <div class="designer-menus__right"> <!-- tab栏,配置组件和页面 --> <el-tabs v-model="activeName" type="card"> <el-tab-pane label="组件管理" name="module"> <component v-if="curModule.type" :is="curModule.type + 'Options'" :form="curModule.options"></component> </el-tab-pane> <el-tab-pane label="页面管理" name="page"> <!-- 页面全局信息配置,因为不是重点所以这里就不具体展示 --> </el-tab-pane> </el-tabs> </div> </div> </div> </template> <script> import { v1 } from 'uuid' import draggable from 'vuedraggable' import { getModules, getDefaultOptions, getComponents, getModuleName, getValidates } from './Modules' const validates = getValidates() const defaultOptions = getDefaultOptions() export default { name: 'Designer', components: { ...getComponents(), draggable }, props: { // 页面信息,用于回显 pageForm: { type: Object } }, data () { return { nameMap: getModuleName(), cells: getModules(), modules: [], curModule: { type: '' }, activeName: 'module' // tab激活页 } }, created () { this.resumePage() // 检测是否需要回填数据 }, methods: { // 回填数据 resumePage () { if (this.pageForm) { let page = JSON.parse(this.pageForm.page) this.modules = page.modules if (this.modules.length > 0) { this.curModule = this.modules[0] } } }, selModule (module) { this.curModule = module const elem = document.getElementById(module.id) this.$refs.screen.scrollTo(0, elem.offsetTop) }, delModule (id) { const index = this.modules.findIndex(({ id: _id }) => id === _id) this.modules.splice(index, 1) this.curModule = this.modules.length > 0 ? this.modules[index > 0 ? index - 1 : index] : { type: '' } }, addModule (module) { const id = v1() this.modules.push({ id, type: module, options: defaultOptions[module]() }) this.curModule = this.modules[this.modules.length - 1] this.$nextTick(() => { const elem = document.getElementById(id) this.$refs.screen.scrollTo(0, elem.offsetTop) }) }, // 保存 save () { let pageContent = { modules: this.modules } let form = { page: JSON.stringify(pageContent) } // 校验组件数据 const promises = this.modules.map(({ type, options }) => { return validates[type](options) }) Promise.all(promises).then(data => { // submit form }).catch(({ error, fields }) => { const [{ message }] = Object.values(fields)[0] this.$message.error(message) }) } } } </script>
这里比较关键的一点是,因为我们将会频繁对自选组件进行增删改,预览区域渲染的自选组件和详情操作面板中的内容将会经常变换,所以我们可以使用动态组件<component>
结合is
属性的绑定来实现。
写在最后
这里分享的实践只是一部分,也并不一定是最佳的,所以如果有更加好的解决方法也欢迎大家在评论里补充。