技术提炼|盘点那些Vue项目中的优秀实践-PC控制台篇

1,161 阅读6分钟

之前一直忙于公司业务扩展后的填需求,现在终于有机会好好总结下在项目中一些优秀的实践,希望也会对你的开发有所启发。

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动态路由来实现。

这里我们做个小小的升级,为了可以更灵活的配置权限,除了可配置的角色权限外,我们还额外引入一个全局可以展示的所有菜单列表,添加这个列表的好处是,当我们在版本迭代时,会存在删减需求的情况,这时候比起一个个角色修改可显示菜单,还是直接修改可展示列表更为高效便捷。

权限控制的流程:

  1. 未登录的情况跳转登录页面
  2. 用户登录获取token及权限可访问菜单
  3. 在浏览器地址栏输入访问地址时比较可展示菜单和用户可访问菜单,满足条件则跳转
  4. 菜单栏比较可展示菜单和用户可访问菜单显示符合条件的菜单

目录结构:

├── 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上拓展了三件事:

  1. 对所有的post请求添加loading动画
  2. 针对身份信息错误的情况,清空身份信息,跳转登录界面
  3. 针对请求返回错误状态的提示

目录结构:

├── 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中定义的几个函数,都将在主面板中使用。

主面板

页面主要分为这样几个区块:

  1. 自选组件列表
  2. 已添加组件列表操作面板
  3. 预览区域
  4. 详情操作面板

示例图:

现在我们来看看主面板的实现:

<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属性的绑定来实现。

写在最后

这里分享的实践只是一部分,也并不一定是最佳的,所以如果有更加好的解决方法也欢迎大家在评论里补充。