圖書商城項目練習①管理後臺Vue2/ElementUI

image.png

本系列文章是爲學習Vue的項目練習筆記,儘量詳細記錄一下一個完整項目的開發過程。面向初學者,本人也是初學者,搬磚技術還不成熟。項目在技術上前端爲主,包含一些後端代碼,從基礎的數據庫(Sqlite)、到後端服務Node.js(Express),再到Web端的Vue,包含服務端、管理後臺、商城網站、小程序/App,分爲下面多個篇文檔。

🪧系列目錄


00、管理後臺Vue2+ElementUI

這是一個比較典型的管理後臺練習項目,包含登錄、框架頁、導航路由、導航標籤、數據管理、字典管理等基礎功能。管理後臺的業務大多是數據管理CRUD功能,該項目只是是簡單實現了幾個模塊。同時針對CRUD,整理了一個模板📁template。

🔸技術路線

  • Vue v2.*
  • ElementUI v2.*

🔸相關組件

  • vuex:狀態管理
  • vue-router:前端路由
  • axios:HTTP調用
  • echarts:圖表組件,按需定製
  • i18n:多語言國際化vue-i18nv8.*版本
  • @wangeditor:富文本編輯器
  • Less:CSS預處理器/語言

🔸源代碼地址Github / KWebNoteGitee / KWebNote,管理後臺代碼在目錄📁book_admin下。

🔸在線體驗地址🔥🔥http://kanding.gitee.io/kwebnote (任意用戶名、密碼。通過gitee靜態頁面Gitee Pages部署的,所以這裏部署的版本是寫了個mock模擬api,路由用的hash模式)。

image.png


01、創建項目/準備

創建圖書管理後臺項目“book_admin”,基於@vue/cli,通過其vue ui管理工具,可視化操作創建項目。

image.png

  • 選擇項目目錄,開始創建項目。
  • 可以選擇內置的多種預設模式,也可選擇“手動”模式,按需設置項目的詳細規則。

image.png

vue創建的項目已經包含了一個基礎的架子了,如下圖,主html頁面文件“index.html”,入口JS文件“main.js”,入口Vue文件“App.vue”。

image.png


02、主頁面/框架頁面

管理後端是SPA單頁應用,創建主框架頁面“Main.vue”,登錄後的所有內容都在這個主頁面內呈現和管理。頁面視圖關係如下圖:

2.1、Main.vue

因此,主頁面就比較重要,是搬磚的基座,實際效果和佈局結構圖如下:

  • Header:頭部區域,包含Logo、標籤欄(存放打開的頁面,類似瀏覽器的多頁籤)、系統按鈕(最右側區域)。
  • 路由菜單:系統導航菜單,數據來自路由配置信息。
  • 麪包屑:內容區域當前視圖的路由信息。
  • 內容區域:當前視圖內容呈現區域。
    • 緩存<keep-alive>,配合多標籤組件視圖緩存,切換標籤後視圖的狀態會被保持。
    • 切換動畫<transition>,切換內容時的動畫效果。
    • 內容滾動處理:內容區域的高度、寬度自適應鋪滿,如果內容超出該區域,則顯示滾動條,不會導致整個頁面出現滾動條。
  • Footer:底部區域,好像也沒啥用,先放這吧!
<template>
  <el-container style="height:100%">
    <el-container class="main-aside">
      <!-- 左側 :logo+導航菜單 -->
      <el-aside :width="config.menuCollapse?'auto':'200px'">
        <MenuSidebar />
      </el-aside>

      <!-- 右側 :頭部+主內容-->
      <el-container>
        <!-- 頭部 -->
        <el-header :style="config.thema" class="header">
          <!-- 標籤工具欄 -->
          <div style="flex:1;overflow:hidden">
            <TabsBar ref="tabsBar"></TabsBar>
          </div>
        	<!-- 右側的系統操作按鈕 -->
          <i class="el-icon-setting h-button" v-on:click="$refs.userConfig.show()" title="系統設置"></i>
          <el-dropdown class="header-userbox" @command="handleCommand">
            <span>
              <img :src="$api.URL.proxy+'/file/f1.jpg'" alt="頭像" />
              [ {{$store.state.user.name}} ]
              <i class="el-icon-arrow-down el-icon--right" style="font-size:12px"></i>
            </span>
            <el-dropdown-menu slot="dropdown">
              <el-dropdown-item command="pwd">修改密碼</el-dropdown-item>
              <el-dropdown-item command="about">
                <i class="el-icon-info"></i>關於
              </el-dropdown-item>
              <el-dropdown-item command="user">
                <i class="el-icon-user-solid"></i>個人中心
              </el-dropdown-item>
              <el-divider></el-divider>
              <el-dropdown-item command="logout" icon="el-icon-circle-close">退出登錄</el-dropdown-item>
            </el-dropdown-menu>
          </el-dropdown>
        </el-header>
        <!-- 主內容區域 -->
        <el-main class="main-wrapper">
          <!-- 麪包屑 -->
          <div class="breadcrumb-bar">
            <el-button type="text"
              :icon="config.menuCollapse?'el-icon-s-unfold':'el-icon-s-fold'"
              v-on:click="config.menuCollapse=!config.menuCollapse" ></el-button>
            <el-breadcrumb separator="/" style="display:inlne-block">
              <el-breadcrumb-item v-for="r in $route.matched" :key="r.name">{{r.meta?.lang ? $t('menu.' + r.meta.lang) : r.meta?.title}}</el-breadcrumb-item>
            </el-breadcrumb>
          </div>
          <!-- 頁面內容的容器 -->
          <div class="main view-scroll">
            <!-- 加了切換動畫、保存頁面狀態 -->
            <transition :name="config.routerAnimation?'fade-transform':''" mode="out-in">
              <keep-alive :include="cacheNames">
                <router-view></router-view>
              </keep-alive>
            </transition>
          </div>
        </el-main>
      </el-container>
    </el-container>
    <!-- footer -->
    <el-footer height="30px">{{$consts.footer}}</el-footer>
    <UserConfig ref="userConfig"></UserConfig>
  </el-container>
</template>

2.2、導航菜單&路由

每個後臺系統都會有導航菜單,支持多級展示、可收縮,效果如下圖:

1.gif

👷‍♂️實現過程

1、路由的配置:這裏用的是本地路由(vue-router中的路由配置信息),實際項目中路由可後臺管理,或者本地+後臺結合。本地路由配置“routes.js”詳見Github / KWebNote

import constants from '@/assets/constants'
import Vue from 'vue'
import VueRouter from 'vue-router'
//路由配置
import baseRoutes from './routes'
//註冊路由插件
Vue.use(VueRouter)
// 創建路由
const router = new VueRouter({
  mode: 'history',           //模式
  base: process.env.BASE_URL,
  routes: baseRoutes,        //路由配置
})

// 路由全局守衛-導航前,登錄token判斷
router.beforeEach((to, from, next) => {
  if (to.path === '/login')
    return next();
  // 除開登錄頁面,其他頁面都驗證token,如果沒有token則跳轉到登錄頁面
  const token = sessionStorage.getItem('admin_token');
  if (!token)
    return next('/login');
  else
    next();
})
router.afterEach((to, from) => {
  //更新網頁標題
  document.title = constants.sysName + '-' + to.meta.title;
})

📢404頁面的配置:路由的匹配是從上而下的,404頁面路由放到最後即可,然後路徑使用通配符匹配所有地址,{ path: '*', component: 404 }

2、導航菜單組件:創建“MenuSidebar.vue”,用<el-menu>組件顯示多級菜單,啓用路由導航router,菜單的數據就是來自前面的路由。

3、樹形菜單:導航菜單項-遞歸,菜單項用一個“MenuItem.vue”組件來實現遞歸路由樹,如果路由還有子節點children,則遞歸調用組件自身。

<template>
  <el-menu-item v-if="!hasChildren" :index="item.path">
    <i :class="item.meta.icon"></i>
    <!-- 名稱用title插槽 -->
    <span slot="title">{{title(item)}}</span>
  </el-menu-item>
  <el-submenu v-else :index="item.path">
    <template slot="title">
      <i :class="item.meta.icon"></i>
      <span slot="title">{{title(item)}}</span>
    </template>
    <MenuItem v-for="child in children" :item="child" :key="child.path"></MenuItem>
  </el-submenu>
</template>
<script>
export default {
  name: 'MenuItem',
  props: ['item'],
  computed: {
    children() {
      return this.item?.children?.filter(s => !s.hidden);
    },
    hasChildren() {
      return this.item?.children?.length > 0;
    },
  },
  methods: {
    title(item) {
      return item.meta?.lang ? this.$t('menu.' + item.meta.lang) : item.meta?.title;
    }
  }
}
</script>

2.3、配置路由動畫

就是在路由切換頁面視圖的時候,有一個過渡動畫效果,如下圖。

1.gif

主要是使用Vue的<transition>組件來實現過渡動畫,設置其name和對應CSS動畫即可,可參考另外一篇《Vue2快速上門(2)-模板語法 / Vue動畫》

<transition :name="config.routerAnimation?'fade-transform':''" mode="out-in">
  <keep-alive :include="cacheNames">
    <router-view></router-view>
  </keep-alive>
</transition>

  <style>
// 路由切換動畫
/* fade-transform */
.fade-transform-leave-active,
.fade-transform-enter-active {
  transition: all 0.5s;
}
.fade-transform-enter {
  opacity: 0;
  transform: translateX(30px);
}
.fade-transform-leave-to {
  opacity: 0;
  transform: translateX(-30px);
}
</style>

📢注意動畫模式mode="out-in",避免佈局變更引起的顯示異常。name值是用於動畫CSS樣式的類名,這裏的的name用了一個用戶配置屬性,目的是可以配置是否開啓路由轉場動畫。

2.4、多標籤工具欄

如下效果圖,類似Chrome瀏覽器的多標籤效果,打開的路由視圖標題顯示在標籤欄,可以刷新、關閉、切換。切換時會保留視圖狀態,這樣就可以很方便的多標籤操作了。

image.png

🔵方案設計:

  • 監測路由變化,記錄打開的頁面路由,保存在vuex的store中。同時記錄打開的頁面名稱,用於<keep-alive>include實現定向路由緩存,過濾不需要緩存的組件,也是實現視圖刷新的關鍵。
  • 顯示緩存的路由列表,就是看到的標籤欄。
  • 關閉按鈕,激活的路由視圖顯示關閉按鈕,關閉後自動路由到下一個頁面/標籤。
  • 固定的標籤:對於如“主頁”的路由固定在標籤欄(會在路由信息中配置),在初始化的時候就顯示在標籤欄,固定的標籤不支持關閉。
  • 右鍵菜單功能:刷新、關閉、關閉其他、關閉所有,更新緩存的路由列表。

👷‍♂️實現過程:

1、在vuex中添加一個子模塊“tabBars.js”,單獨管理標籤的狀態信息。提供緩存路由列表的操作方法:添加、刪除、刪除其他、刪除所有、清空。


export default {
  namespaced: true,
  state: {
    cacheRoutes: [], //緩存的路由,用於標籤欄使用
    cacheNames: [],  //緩存的打開的路由Name,用於Keep-alive的緩存白名單
  },
  mutations: {
    add(state, obj) {
      if (!state.cacheRoutes.some(s => s.path === obj.path)) {
        //添加打開的路由,只需要path、name、mata
        state.cacheRoutes.push({ path: obj.path, name: obj.name, meta: obj.meta });
        state.cacheNames = state.cacheRoutes.map(s => s.name);
      }
    },
    remove(state, obj) {
      const i = state.cacheRoutes.findIndex(s => s.path === obj.path);
      if (i < 0)
        return;
      state.cacheRoutes.splice(i, 1);
      state.cacheNames = state.cacheRoutes.map(s => s.name);
    },
    removeName(state, obj) {
      //只移除緩存名字
      const i = state.cacheNames.findIndex(s => s === obj.name);
      if (i < 0)
        return;
      state.cacheNames.splice(i, 1);
    },
  },
}

2、路由信息配置,來自vue-router的路由配置,添加了幾個自定義的屬性。

  • 注意name和組件內部的name定義應該一致,會在<keep-alive :include="cacheNames">中使用。
  • meta.title:標題
  • meta.icon:icon圖標
  • meta.affix:是否固定,固定在標籤欄
{
  path: '/home',
    name: 'Home',
    meta: { title: '首頁', lang: 'home', icon: 'el-icon-s-home', affix: true },
  	component: () => import('@/views/Home.vue'),
},
{
  path: '/books',
  name: 'Books',
  meta: { title: '圖書管理', lang: 'book', icon: 'el-icon-notebook-2' },
  component: () => import('@/views/book/Books.vue'),
},

3、創建標籤欄組件TabsBar.vue,核心功能、代碼都在這裏,標籤的顯示、功能操作,包括右鍵菜單。完整代碼:Github / KWebNote

<template>
  <div class="tabs-bar">
    <router-link
      class="item"
      v-for="r in cachedRoutes"
      :to="r"
      :key="r.path"
      :class="isActive(r)?'active':''"
      @contextmenu.prevent.native="showMenu(r,$event)"
      >
      <i :class="r.meta.icon"></i>
      {{r.meta?.lang ? $t('menu.' + r.meta.lang) : r.meta?.title}}
      <i class="el-icon-close close" v-if="!isAffix(r)" @click.prevent.stop="handleClose(r)"></i>
    </router-link>

    <!-- 頁籤按鈕的右鍵菜單 -->
    <el-card class="menu" v-show="tagMenu.visible" :style="{left:tagMenu.left+'px',top:tagMenu.top+'px'}">
      <ul>
        <li @click="refresh(selectedTag)" v-show="isActive(selectedTag)">
          <i class="el-icon-refresh"></i> 刷新
        </li>
        <li @click="handleClose()" v-show="!isAffix(selectedTag)">
          <i class="el-icon-close"></i> 關閉
        </li>
        <li @click="handleCloseOther()">
          <i class="el-icon-circle-close"></i> 關閉其他
        </li>
        <li @click="handleCloseAll">
          <i class="el-icon-error"></i> 關閉所有
        </li>
      </ul>
    </el-card>
  </div>
</template>

❗注意:這裏的標籤關閉按鈕,一定要加上修飾符“.prevent.stop”,阻止冒泡、及其他事件,因爲標籤本身也是有點擊事件的,開始沒加,莫名其妙沒有跳轉,被卡了好半天。

<i class="el-icon-close close" v-if="!isAffix(r)" @click.prevent.stop="handleClose(r)"></i>

複習一下:

修飾符 描述
.stop 調用 event.stopPropagation()停止向上冒泡(propagation /ˌprɒpəˈɡeɪʃn/ 傳播)
.prevent 調用 event.preventDefault()取消默認事件行爲,如checkbox、<a>的默認事件行爲,不影響冒泡

🔃刷新怎麼實現?

刷新的實現稍微複雜一點點,因爲這是本地路由,不能刷新整個頁面,而當前路由視圖是用了<keep-alive>緩存的。因此實現刷新的的基本過程:

  • 去除緩存並關閉路由:去除<keep-alive>的緩存,就是從其include白名單中移除。
  • 重新打開路由視圖

爲了視覺效果更佳,這裏用一箇中間頁面進行跳轉,效果如下:

1.gif

設計了一箇中級頁面Redirect.vue,作用只有一個,就是用於跳轉,跳轉目標用路由參數傳遞。

// 用於中轉跳轉的頁面
<script>
export default {
  created() {
    this.$router.replace({ path: '/' + this.$route.params.path, query: this.$route.query });
  },
  render: function (h) {
    return h() 
  }
}
</script>

需要注意中間頁面,不緩存、不顯示標籤欄。刷新時,移除緩存,然後重定向到當前頁面,重新加載當前頁面。

refresh(tag) {
  //移除去掉緩存,再重定向跳轉到當前頁面
  this.$store.commit('tabBars/removeName', this.$route);
  this.$nextTick(() => {
    this.$router.replace({
      path: '/redirect' + tag.path
    })
  })
},

2.5、用戶配置本地化保存

系統菜單“用戶設置”,實現用戶自定義的一些個性化配置,並本地存儲、加載。保存到localStorage中,這樣下次進入系統可以保持個性化配置了。

1.gif

🔵效果如上圖,需求分析

  • 主題樣式,只實現了標題欄顏色樣式,前景色color、背景色backgroundColor,應用在標題欄Header的樣式上。
  • 路由切換動畫是否開啓。
  • 多語言設置,實現了中文、英文語言,詳見下一章節。
  • 導航菜單的摺疊狀態。
  • 用戶配置保存到本地localStorage中,系統初始化時從localStorage加載用戶配置。

👷‍♂️實現過程

1、創建一個單獨的vue組件“UserConfig.vue”管理用戶的設置項,內部用抽屜<el-drawer>來實現從右側滑出的效果。
2、監聽數據的變化(深度監聽),如果變化則保存用戶配置數據到localStorage,同時更新多語言的配置項。

created() {
  //監聽配置變更,持久化存儲到本地
  this.$watch('config', () => {
    localStorage.setItem('admin-userconfig', JSON.stringify(this.config));
    //手動更新語言
    this.$i18n.locale = this.config.language;
  }, { deep: true })
},

3、在main.js中添加初始化代碼,從localStorage加載上次保存的用戶配置信息。

created: function () {
  LoadUserConfig();
}
function LoadUserConfig() {
  let vstr = localStorage.getItem('admin-userconfig');
  if (vstr) {
    Object.assign(userConfig, JSON.parse(vstr));
    userConfig.thema = themas.filter(s => s.name == userConfig.thema.name)[0];
    //語言
    i18n.locale = userConfig.language;
  }
  

2.6、國際化多語言

實現多語言(國際化)的最主流、成熟的方案就是i18n(internationalization /ˌɪntəˌnæʃnəlaɪˈzeɪʃn/ 國際化,首字母i、尾字母n加中間的18個字母),官方文檔,Vue中使用vue-i18n插件。

1.gif

安裝i18n插件,Vue2.*不太兼容最新版的v9.*,安裝8.*版本:

vue add i18n
# 或者
cnpm i -S [email protected]

安裝完成後,“package.json”文件中就有了"vue-i18n": "^8.26.3"

vue add i18n方式安裝,除了安裝插件,還把基本的配置、語言文件都準備好了,屬於完成了簡裝可以擰包入住了。npm指令安裝只會安裝插件,需要自己完成配置和註冊。

🔸配置i18n

i18n的配置、使用還是比較簡單的,先配置語言信息,然後在代碼(JavaScript、Vue模板)中使用。

|- src
  |-lang
    |-index.js   # 配置i18n
    |-lang-en.js # 英文語言資源
    |-lang-cn.js # 中文語言資源

1、分別創建不同的語言包文件,語言信息爲一個鍵值結構的JSON對象,鍵爲語言項的key,值爲顯示的文本內容。結構可以按照項目情況自行定義,葉子節點屬性是一個語言項。

image.png

2、註冊插件,配置i18n實例。

import Vue from 'vue'
import VueI18n from 'vue-i18n'
import lang_zhcn from './lang-cn'
import lang_en from './lang-en'
//註冊
Vue.use(VueI18n);
//申明i18n
const i18n = new VueI18n({
  locale: 'en',     //選中的語言
  messages: {
    en: {
      ...lang_en,   //英文語言配置
    },
    zh: {
      ...lang_zhcn, //中文語言配置
    }
  }
})
  • locale屬性爲當前選中的語言,更改值實現語言切換。
  • Element的國際化,按照官方文檔配置即可:element-國際化

3、在main.js中引入,並注入到Vue根實例中。

import i18n from './lang'
new Vue({
  router,
  store,
  i18n,
}).$mount('#app')

🔸使用

使用就簡單了,使用i18n提供的方法$t('key')即可。

<p class="title">{{$t('home.user')}}</p>
//JS
title(item) {
  return item.meta?.lang ? this.$t('menu.' + item.meta.lang) : item.meta?.title;
}

03、登錄頁面/Login.vue

image.png

登錄頁面主要就是用戶名、密碼的表單,然後調用後端登錄api接口驗證、獲得token完成登錄。

{
  user: { name: '', pwd: '' },
  rules: {
    name: [{ required: true, message: '用戶名不能爲空' }, { min: 3, max: 8, message: "長度應爲3-8" }],
    pwd: [{ required: true, message: '密碼不能爲空' }, { min: 3, max: 8, message: "長度應爲3-8" }],
  },
}
  • 表單驗證:表單驗證的執行是在表單<el-form>組件上調用validate()方法,因此需要綁定model對象,在表單項<el-form-item>``prop上綁定model對象字段名。
this.$refs.userForm.validate((valid, mes) => {
  if (!valid) {
    this.$message.error('輸入有誤,請修改後重新提交!');
    return;
  }
  //提交...
  • 記住用戶名:保存到本地localStorage中,加載該頁面的時候讀取。
  • 登錄成功:獲取並保存token信息,保存在vuexstore中或本地sessionStorage,然後跳轉到主頁面 this.$router.push('/home')

04、首頁/Home.vue

管理類系統大概率都有一個首頁Home.vue,作爲默認頁面,展示系統的一些概況、用戶的一些統計信息、通知信息等。爲保持各個“豆腐塊”風格一致,推薦用<el-card>組件包裝內容。

image.png

用到了圖表組件 echarts,安裝最新版本:

$ cnpm install echarts -S

// 引入echarts
import * as echarts from 'echarts'
// 在Vue原型上掛載$echarts,在vue示例中this.$echarts
Vue.prototype.$echarts = echarts

這裏只用了2個圖表,卻引入了所有的echarts組件,打包後的JS文件6M多,實在太大了。通過官方提供的在線定製功能按需定製JS文件,然後引入該JS文件即可。


05、圖書管理模塊

圖書管理爲圖書的綜合管理,包含,是管理後臺的典型功能,如用戶管理、商品管理、活動管理、公告管理等等都類似。

1.gif

功能結構如下圖:

代碼結構如下圖,圖書管理模塊包含多個頁面,其中“Books.vue”爲入口頁面。

image.png

5.1、圖書列表

image.png

  • 圖書列表由表格<el-table>和分頁<el-pagination>組成。統一設計了查詢結構,分頁組件做了一個簡單的封裝。
  • 表格中的狀態用到了一個自己寫的枚舉組件,詳見《前端枚舉enum的應用(Element)封裝》
  • 圖書詳情用了抽屜組件<el-drawer>,點擊名稱從右側彈出。
  • 圖書的新增、修改用的彈框組件<el-dialog>,默認的BookDialog是彈出帶遮罩的模態框,額外實現了一個Plus版本BookDialogPlus(詳見下文)。

5.2、分頁組件

分頁是列表常用組件,Element-UI提供了分頁組件<el-pagination>,在此基礎上做一個簡單的封裝,統一規範、簡化使用。

image.png

✔️封裝了些什麼?

  • 統一風格樣式、佈局,也方便統一修改。
  • 統一分頁大小配置page-sizes="[5, 10, 20, 50]"
  • 統一分頁事件pagination,統一處理了頁碼、頁數的變更。
<template>
  <el-pagination
    style="text-align:right;margin:6px 2px" background
    :total="total" :current-page="currentPage" :page-size="pageSize" :page-sizes="[5, 10, 20, 50]"
    @current-change="pageChanged" @size-change="pageSizeChanged"
    layout="total, sizes, prev, pager, next, jumper"
    ></el-pagination>
</template>
<script>
  export default {
    props: {
      //總數
      total: { type: Number, default: 0, },
      //頁碼,外部綁定,加修飾符.sync
      size: { type: Number, default: 10, },
      // 當前頁碼,外部綁定,加修飾符.sync
      index: { type: Number, default: 1, }
    },
    computed: {
      // 用修飾符“.sync”來實現更新父組件的值
      currentPage: {
        get() { return this.index },
        set(val) { this.$emit('update:index', val) }
      },
      pageSize: {
        get() { return this.size },
        set(val) { this.$emit('update:size', val) }
      }
    },
    methods: {
      pageSizeChanged(v) {
        // 修改父組件值
        this.$emit('update:size', v);
        // 觸發分頁事件
        this.$emit('pagination');
      },
      pageChanged(v) {
        // 修改父組件值
        this.$emit('update:index', v);
        // 觸發分頁事件
        this.$emit('pagination');
      },
    }
  }
</script>

這裏的頁行數size、頁碼index,是外部傳入的的prop值,但內部也會修改。導致了“雙重綁定”更新,這就需要用到.sync修飾符了,其實就是基於事件通知實現的,可參考官網文檔

  • 子組件內部通過Vue的事件this.$emit('update:myPropName', v);觸發變更通知。
  • 外部綁定的時候用.sync修飾符,.sync實現了更新update事件的監聽和賦值,需注意不支持表達式,只能用property名。

🟢使用

<Pagination :total="total" :size.sync="search.size" :index.sync="search.index" @pagination="loadData"></Pagination>

5.3、圖書編輯Plus

如下效果圖,相比常規的模態框,只是遮住了當前視圖(圖書管理),不影響其他功能操作。

1.gif

用的依然是彈框組件<el-dialog>,在此基礎上做了一點點調整。完整代碼見 Github / KWebNote

image.png

  • 取消遮罩層:modal="false"
  • 操作按鈕放到了標題欄上。
  • 通過樣式讓彈層剛好覆蓋到當前視圖。
.dialogPlus {
  position: absolute;
  overflow: inherit;
  .el-dialog {
    min-height: 100% !important;
    max-height: 100%;
    display: flex;
    flex-flow: column;
    .el-dialog__header {
      padding: 4px 10px;
    }
    .el-dialog__body {
      overflow: auto;
      max-height: 100%;
    }
  }
}

5.4、圖片上傳upLoad

圖片上傳使用文件上傳組件<el-upload>,這裏涉及一些基礎通用操作,因此針對圖片上傳封裝爲一個組件“ImgUpload.vue”,效果如下:

image.png

  • 上傳接口action,後端文件接口實現詳見後端章節。
  • 文件格式accept="image/*",這裏的accept值爲文件的類型
  • 文件數量limit,配置最大支持的文件個數,鉤子on-exceed超過文件數量限制時觸發。當達到限制時,隱藏上傳按鈕。
  • 上傳前before-upload上傳前的鉤子,可用來驗證文件的合法性。
  • 上傳成功on-success文件上傳成功的鉤子,可用來同步上傳的文件資源。
  • 文件信息prop."value",組件中定義了一個value的props,接受父組件傳入的已有文件集合(字符串,多個逗號隔開),組件內文件變化通過this.$emit('input',nval)更新value值。
  • 預覽:用一個<el-dialog>來展示預覽圖。
<template>
  <div>
    <el-upload
      ref="upload" :action="$api.URL.upload" list-type="picture-card"
      :multiple="true" accept="image/*" name="file"
      :limit="limit" :class="{hide:uploadHide}" :file-list="fileList"
      :on-exceed="onOutOfLimit" :on-success="handleSuccess"
      :on-error="handleError" :before-upload="handeleBefore" >
      <!-- 上傳按鈕 -->
      <i slot="default" class="el-icon-plus"></i>
      <!-- 提示內容 -->
      <div slot="tip" style="font-size:0.8em">支持最多{{limit}}張圖片,每張圖片不超過{{maxSize}}Kb</div>
      <!-- file模板 -->
      <div slot="file" slot-scope="{file}" class="imgbox" :class="{success:file.status}">
        <!-- 縮略圖的路徑,如果相對路徑則添加代理前綴-->
        <img :src="proxyURL(file.url)" alt />
        <span class="el-upload-list__item-actions">
          <span class="el-upload-list__item-preview" @click="handlePictureCardPreview(file)">
            <i class="el-icon-zoom-in"></i>
          </span>
          <span class="el-upload-list__item-delete" @click="handleRemove(file)">
            <i class="el-icon-delete"></i>
          </span>
        </span>
      </div>
    </el-upload>
    <!-- 嵌套的dialog,需要設置append-to-body,嵌入自身到body元素 -->
    <el-dialog :visible.sync="dialogVisible" append-to-body custom-class="imgdialog">
      <img :src="proxyURL(dialogImageUrl)" alt style="max-width: 100%;max-heigt: 100%;object-fit:contain" />
    </el-dialog>
  </div>
</template>

再加上一點點JS和CSS就完成了,完整代碼見 Github / KWebNote。。使用:

import ImgUpload from '@/components/ImgUpload.vue'

<ImgUpload v-model="book.imgs"></ImgUpload>

5.5、富文本@wangeditor

wangEditor 是一個輕量級 web 富文本編輯器,配置方便,使用簡單。安裝vue版本的“@wangeditor/editor-for-vue”:$ cnpm i @wangeditor/editor-for-vue -S。然後使用參考官方vue使用文檔,Ctrl+CV即可。
要實現圖片、視頻上傳還需要自己配置,因此就基於@wangeditor封裝了一個富文本編輯器Editor.vue,效果如下。

  • 調整了工具欄,排除了一些不需要的。
  • 配置了圖片上傳服務,@wangeditor支持粘貼圖片。

image.png

🔸配置工具欄

默認提供的工具欄功能比較豐富,如果需要調整,則需要先獲取工具欄的toolbarKeys。引入@wangeditor/editor,在編輯器組件準備完成後獲取toolbarKeys,如updated()

import { DomEditor } from '@wangeditor/editor'

updated() {
  ////在這裏獲取工具欄的配置toolbarKeys,用於自定義配置工具欄    
  const toolbar = DomEditor.getToolbar(this.editor)
  console.log(toolbar.getConfig().toolbarKeys)
},

獲取到的keys如下(整理後):

[
  "headerSelect", "blockquote",
  "|",
  "bold", "underline", "italic",
  {
    "key": "group-more-style", "title": "更多", "iconSvg": "",
    "menuKeys": [ "through", "code", "sup", "sub", "clearStyle" ]
  },
  "color", "bgColor",
  "|",
  "fontSize", "fontFamily", "lineHeight",
  "|",
  "bulletedList", "numberedList", "todo",
  {
    "key": "group-justify", "title": "對齊", "iconSvg": "",
    "menuKeys": [ "justifyLeft", "justifyRight", "justifyCenter", "justifyJustify" ]
  },
  {
    "key": "group-indent", "title": "縮進", "iconSvg": "",
    "menuKeys": [ "indent", "delIndent" ]
  },
  "|", 
  "emotion", "insertLink",
  {
    "key": "group-image", "title": "圖片", "iconSvg": "",
    "menuKeys": [ "insertImage", "uploadImage" ]
  },
  {
    "key": "group-video",
    "title": "視頻", "iconSvg": "",
    "menuKeys": [ "insertVideo", "uploadVideo" ]
  },
  "insertTable", "codeBlock", "divider",
  "|",
  "undo", "redo", "|", "fullScreen"
]

通過toolbarConfig.excludeKeys配置不需要的工具欄按鈕:

data() {
  return {
    editor: null,
    toolbarConfig: { excludeKeys: ['group-video', 'emotion', 'lineHeight'] },
    editorConfig: { placeholder: '請輸入內容...', maxLength: 8000 },
    mode: 'default', // default simple
  }
},

完整代碼見 Github / KWebNote

🔸配置圖片上傳

在Editor的配置項editorConfig中配置圖片上傳參數,如下代碼:

editorConfig: {
  placeholder: '請輸入內容...', maxLength: 8000,
    MENU_CONF: {
     uploadImage: { //配置圖片上傳
      server: this.$api.URL.upload,  //後端文件上傳地址
        fieldName: 'file', //表單參數名,和後端一致
        maxFileSize: 2 * 2048 * 2048,  //最大文件大小
        maxNumberOfFiles: 1, //每次文件個數
        allowedFileTypes: ['image/*'], //文件類型:圖片
        timeout: 9 * 1000,  //超時時長
        // 自定義插入圖片,根據後端返回的結構,加上跨域代理
        customInsert: (res, insertFn) => {
        const url = this.$api.URL.proxy + res.url
        insertFn(url)
      },
    }
  }
},

5.6、樹形下拉框

圖書的類型是來自字典數據(詳見後續《字典管理》章節),樹形結構,ElementUI2版本中麼有樹形的下拉框組件,Element3(ElementPlus)有。結合下拉框組件<el-select>和樹形組件<el-tree>封裝實現了一個樹形下拉框組件TreeSelect,效果圖如下。

image.png

<el-tree>作爲<el-select>的一個選項值<el-option>,然後JS代碼實現選擇值的同步管理即可,邏輯比較簡單。

<el-select v-model="currentText" placeholder="請選擇" @clear="handelClear" clearable>
  <el-option class="option view-scroll" :value="currentItem[options.value]" :label="currentItem[options.label]">
    <!-- data:數據-->
    <!-- props:數據結構配置 -->
    <!-- node-key:唯一標識字段 -->
    <el-tree ref="tree" :data="data" :node-key="options.value" :props="options" class="tree" @current-change="handleCurrentChange"></el-tree>
  </el-option>
</el-select>

完整代碼見Github / KWebNote


06、字典管理模塊

字典管理爲一個比較通用的字典數據管理模塊Dictionary.vue,用來管理一些可變的分類數據,如圖書分類、商品促銷類型、品牌、國家、省市區地址等。包含兩部分數據:

  • 字典類別,定義有哪些字典類別,包含分類名稱、編碼、是否樹形結構等關鍵字段。
  • 字典數據,每一個字典類別的字典數據,統一存儲,用字典編碼區分,樹形結構。結構:id、名稱、類別編碼、排序號、父id。

image.png

樹形結構的數據編輯時,可以選擇父級,這裏用的是<el-cascader>級聯選擇器組件。

image.png

數據是在本地進行樹形組裝和排序的,根級節點的父idpid爲0,用buildDicTree方法遞歸構造一顆樹。

export function queryDicData(type, istree = false) {
  return api.dicdata({ code: type }).then(res => {
    if (!res.data || res.data.length <= 0) return [];
    //構造樹形結構
    if (!istree)
      return res.data.sort(sortDicData);
    let sortItems = buildDicTree(res.data, ROOT_PID);
    return sortItems;
  })
}
function buildDicTree(items, pid) {
  let sortItems = items.filter(s => s.pid == pid);
  if (!sortItems || sortItems.length <= 0) return [];
  sortItems.sort(sortDicData).forEach(item => {
    const res = buildDicTree(items, item.id);
    if (res && res.length > 0)
      item.children = res.sort(sortDicData);
  });
  return sortItems;
}
function sortDicData(item1, item2) {
  return item1.sort - item2.sort;
}

📢需要注意的是,這裏修改字典數據時,父級節點不能選擇自己及自己的子節點,否則會導致死循環。因此需要對上面構造的樹做一個處理,把不能選擇的節點設置disabled屬性。


參考資料


©️版權申明:版權所有@安木夕,本文內容僅供學習,歡迎指正、交流,轉載請註明出處!原文編輯地址-語雀

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章