最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 技术提炼|盘点那些Vue项目中的优秀实践-PC控制台篇

    正文概述 掘金(Minorjone)   2020-11-24   612

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

    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>
    

    下面我们来详细说下实现思路:

    • 多种数据来源:
    • 数据查询:
    [
      { prop: 'name', type: 'text', label: '名称' },
      { prop: 'gender', type: 'select', label: '性别', options: [{ label: '男', value: 'male' }, { label: '女', value: 'female' }] }
    ]
    
    • 分页:
    • 格式化数据:
    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. 详情操作面板

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

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

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

    写在最后

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


    起源地下载网 » 技术提炼|盘点那些Vue项目中的优秀实践-PC控制台篇

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元