下載查看vue-elemen-admin源碼css
git clone https://github.com/PanJiaChen/vue-element-admin cd vue-element-admin npm i npm run dev
刪除src/views下的源碼,保留:html
WARNING若是是線上項目,建議將 components 的內容也進行清理,以避免影響訪問速度,或者直接使用查看 vue-admin-template 構建項目,課程選擇 vue-element-admin 初始化項目,是由於 vue-element-admin 實現了登陸模塊,包括 token 校驗、網絡請求等,能夠簡化咱們的開發工做前端
經過src/settings.js進行局配置vue
頁面標題-站點標題
node
如需進行源碼調試,修改vue.config.jsmysql
config // https://webpack.js.org/configuration/devtool/#development .when(process.env.NODE_ENV === 'development', config => config.devtool('cheap-source-map') )
utils:通用工具方法linux
Node 是一個基於 V8 引擎的 Javascript 運行環境,它使得 Javascript 能夠運行在服務端,直接與操做系統進行交互,與文件控制、網絡交互、進程控制等
express 是一個輕量級的 Node Web 服務端框架,一樣是一我的氣超高的項目,它能夠幫助咱們快速搭建基於 Node 的 Web 應用
建立項目webpack
mkdir admin-imooc-node cd admin-imooc-node npm init -y
安裝依賴npm i -S express
建立app.jsios
const express = require('express') // 建立 express 應用 const app = express() // 監聽 / 路徑的 get 請求 app.get('/', function(req, res) { res.send('hello node') }) // 使 express 監聽 5000 端口號發起的 http 請求 const server = app.listen(5000, function() { const { address, port } = server.address() console.log('Http Server is running on http://%s:%s', address, port) })
中間件是一個函數,在請求和響應週期中被順序調用nginx
const myLogger = function(req, res, next) { console.log('myLogger') next() } app.use(myLogger)
規則主要分爲兩部分
經過自定義中間件進行異常處理
app.get('/', function(req, res) { throw new Error('something has error...') }) const errorHandler = function (err, req, res, next) { console.log('errorHandler...') res.status(500) res.send('down...') } app.use(errorHandler)
參數不能少 中間件要在請求以後引用
錯誤處理 安裝boom依賴
`npm i -S boom
`
建立router文件夾 建立router/index.js
const express = require('express') const boom = require('boom') const userRouter = require('./user') const { CODE_ERROR } = require('../utils/constant') // 註冊路由 const router = express.Router() router.get('/', function(req, res) { res.send('歡迎學習小慕讀書管理後臺') }) // 經過 userRouter 來處理 /user 路由,對路由處理進行解耦 router.use('/user', userRouter) /** * 集中處理404請求的中間件 * 注意:該中間件必須放在正常處理流程以後 * 不然,會攔截正常請求 */ router.use((req, res, next) => { next(boom.notFound('接口不存在')) }) /** * 自定義路由異常處理中間件 * 注意兩點: * 第一,方法的參數不能減小 * 第二,方法的必須放在路由最後 */ router.use((err, req, res, next) => { const msg = (err && err.message) || '系統錯誤' const statusCode = (err.output && err.output.statusCode) || 500; const errorMsg = (err.output && err.output.payload && err.output.payload.error) || err.message res.status(statusCode).json({ code: CODE_ERROR, msg, error: statusCode, errorMsg }) }) module.exports = router
建立router/use.js
const express = require('express') const router = express.Router() router.get('/info', function(req, res, next) { res.json('user info...') }) module.exports = router
建立 utils/constant
module.exports = { CODE_ERROR: -1 }
驗證/user/info:"user info..."
驗證/user/login
{"code":-1,"msg":"接口不存在","error":404,"errorMsg":"Not Found"}
項目需求分析
項目技術架構
項目目標
本質是壓縮zip文件
安裝nginx
打開配置文件nginx.conf
修改一:添加當前登陸用戶爲owneruser mac owner
修改二:在結尾大括號以前添加include /Users/mac/upload/upload.conf
額外配置文件 用來添加https
修改/Users/mac/upload/upload.conf文件配置
server { charset utf-8; listen 8089; server_name http_host; root /Users/mac/upload/; autoindex on; add_header Cache-Control "no-cache, must-revalidate"; location / { add_header Access-Control-Allow-Origin *; } } server { listen 443 default ssl; server_name https_host; root /Users/mac/upload/; autoindex on; add_header Cache-Control "no-cache, must-revalidate"; location / { add_header Access-Control-Allow-Origin *; } ssl_certificate /Users/mac/Documents/https/budai.store.pem; ssl_certificate_key /Users/mac/Documents/https/budai.store.key; ssl_session_timeout 5m; ssl_protocols SSLv3 TLSv1; ssl_ciphers ALL:ADH:!EXPORT56:RC4+RSA:+HIGH:+MEDIUM:+LOW:+SSLv2:+EXP; ssl_prefer_server_ciphers on; }
配置證書
sudo nginx
sudo nginx -s reload
sudo nginx -s stop
sudo nginx -t
訪問地址
http://localhost:8089
https://localhost
地址:[https://dev.mysql.com/downloa...
](https://dev.mysql.com/downloa...
[https://www.navicat.com.cn/pr...
](https://www.navicat.com.cn/pr...
windows參考:https://blog.csdn.net/ycxzuoxin/article/details/80908447
mac參考https://blog.csdn.net/ycxzuoxin/article/details/80908447
cd /usr/local/mysql-8.0.13-macos10.14-x86_64/bin ./mysql
初始化數據庫
建立數據庫book,選擇utf-8,下載book.sql https://www.youbaobao.xyz/resource/admin/book.sql
執行 source book.sql
導入數據
將login中的template改成
<template> <div class="login-container"> <el-form ref="loginForm" :model="loginForm" :rules="loginRules" class="login-form" autocomplete="on" label-position="left" > <div class="title-container"> <h3 class="title">小慕讀書</h3> </div> <el-form-item prop="username"> <span class="svg-container"> <svg-icon icon-class="user" /> </span> <el-input ref="username" v-model="loginForm.username" placeholder="請輸入用戶名" name="username" type="text" tabindex="1" autocomplete="on" /> </el-form-item> <el-tooltip v-model="capsTooltip" content="Caps lock is On" placement="right" manual> <el-form-item prop="password"> <span class="svg-container"> <svg-icon icon-class="password" /> </span> <el-input :key="passwordType" ref="password" v-model="loginForm.password" :type="passwordType" placeholder="密碼" name="password" tabindex="2" autocomplete="on" @keyup.native="checkCapslock" @blur="capsTooltip = false" @keyup.enter.native="handleLogin" /> <span class="show-pwd" @click="showPwd"> <svg-icon :icon-class="passwordType === 'password' ? 'eye' : 'eye-open'" /> </span> </el-form-item> </el-tooltip> <el-button :loading="loading" type="primary" style="width:100%;margin-bottom:30px;" @click.native.prevent="handleLogin" >登陸 </el-button> </el-form> </div> </template>
建立組件 src/views/book/create.vue
修改 src/router/index.js 的asyncRoutes 新加入頁面路由
{ path: '/book', component: Layout, redirect: '/book/create', meta: { title: '圖書管理', icon: 'documentation', rules: ['admin'] }, children: [ { path: '/book/create', component: () => import('@/views/book/create'), meta: { title: '上傳圖書', icon: 'edit', roles: ['admin'] } } ] },
路由處理邏輯圖以下
已獲取Token
未獲取Token
第一步、main.js 加載了全局路由守衛import './permission' /
第二步、permission 定義全局路由守衛
router.beforeEach(async(to, from, next) => { // 啓動進度條 NProgress.start() // 修改頁面標題 document.title = getPageTitle(to.meta.title) // 從 Cookie 獲取 Token const hasToken = getToken() // 判斷 Token 是否存在 if (hasToken) { // 若是當前路徑爲 login 則直接重定向至首頁 if (to.path === '/login') { next({ path: '/' }) NProgress.done() } else { // 判斷用戶的角色是否存在 const hasRoles = store.getters.roles && store.getters.roles.length > 0 // 若是用戶角色存在,則直接訪問 if (hasRoles) { next() } else { try { // 異步獲取用戶的角色 const { roles } = await store.dispatch('user/getInfo') // 根據用戶角色,動態生成路由 const accessRoutes = await store.dispatch('permission/generateRoutes', roles) // 調用 router.addRoutes 動態添加路由 router.addRoutes(accessRoutes) // 使用 replace 訪問路由,不會在 history 中留下記錄 next({ ...to, replace: true }) } catch (error) { // 移除 Token 數據 await store.dispatch('user/resetToken') // 顯示錯誤提示 Message.error(error || 'Has Error') // 重定向至登陸頁面 next(`/login?redirect=${to.path}`) NProgress.done() } } } } else { // 若是訪問的 URL 在白名單中,則直接訪問 if (whiteList.indexOf(to.path) !== -1) { next() } else { // 若是訪問的 URL 不在白名單中,則直接重定向到登陸頁面,並將訪問的 URL 添加到 redirect 參數中 next(`/login?redirect=${to.path}`) NProgress.done() } } }) router.afterEach(() => { // 中止進度條 NProgress.done() })
獲取角色信息動態路由生成
生成動態路由的源碼位於 src/store/modules/permission.js中的 generateRoutes方法
import { asyncRoutes, constantRoutes } from '@/router' generateRoutes({ commit }, roles) { // 返回 Promise 對象 return new Promise(resolve => { let accessedRoutes if (roles.includes('admin')) { // 若是角色中包含 admin,則直接跳過判斷,直接將 asyncRoutes 所有返回 accessedRoutes = asyncRoutes || [] } else { // 若是角色中沒有包含 admin,則調用 filterAsyncRoutes 過濾路由 accessedRoutes = filterAsyncRoutes(asyncRoutes, roles) } // 將路由保存到 vuex 中 commit('SET_ROUTES', accessedRoutes) resolve(accessedRoutes) }) }
SET_ROUTES
方法源碼以下
SET_ROUTES: (state, routes) => { // 將 routes 保存到 state 中的 addRoutes state.addRoutes = routes // 將 routes 集成到 src/router/index.js 的 constantRoutes 中 state.routes = constantRoutes.concat(routes) }
路由過濾的方法 filterAsyncRoutes
源碼以下
/** * @params routes - 異步加載的路由 * @params roles - 用戶的角色,數組形式 */ export function filterAsyncRoutes(routes, roles) { const res = [] // 遍歷所有路由 routes.forEach(route => { // 對路由進行淺拷貝,注意 children 不會拷貝,由於不須要對 children 進行判斷,因此可使用淺拷貝 const tmp = { ...route } // 檢查用戶角色是否具有訪問路由的權限 if (hasPermission(roles, tmp)) { // 當路由具備訪問權限時,判斷路由是否具有 children 屬性 if (tmp.children) { // 當路由包含 children 時,對 children 迭代調用 filterAsyncRoutes 方法 tmp.children = filterAsyncRoutes(tmp.children, roles) } // 當路由具備訪問權限時,將 tmp 保存到 res 中 res.push(tmp) } }) return res }
檢查權限方法 hasPermission
源碼以下
function hasPermission(roles, route) { // 檢查路由是否包含 meta 和 meta.roles 屬性 if (route.meta && route.meta.roles) { // 判斷 route.meta.roles 中是否包含用戶角色 roles 中的任何一個權限,若是包含則返回 true,不然爲 false return roles.some(role => route.meta.roles.includes(role)) } else { // 若是路由沒有 meta 或 meta.roles 屬性,則視爲該路由不須要進行權限控制,全部用戶對該路由都具備訪問權限 return true } }
NProgress.start(); NProgress.done(); NProgress.configure({ showSpinner: false }) //showSpinner 能夠控制右側的環形進度條是否顯示
訪問路由時會從Cookies中獲取Token,判斷Token是否存在
若是讓你實現一個側邊欄,你會如何設計
側邊欄的核心是將根據權限過濾後的 router和 el-menu 組件進行映射,因此熟悉el-menu是理解sidebar的起點
<el-row class="tac"> <el-col :span="12"> <el-menu default-active="1-1" background-color="#545c64" text-color="#fff" active-text-color="#ffd04b" mode="vertical" unique-opened :collapse="isCollapse" :collapse-transition="false" class="el-menu-vertical-demo" @open="handleOpen" @close="handleClose" @select="handleSelect" > <el-submenu index="1"> <template slot="title"> <i class="el-icon-location"></i> <span>導航一</span> </template> <el-menu-item-group> <template slot="title">分組一</template> <el-menu-item index="1-1">選項1</el-menu-item> <el-menu-item index="1-2">選項2</el-menu-item> </el-menu-item-group> <el-menu-item-group title="分組2"> <el-menu-item index="1-3">選項3</el-menu-item> </el-menu-item-group> <el-submenu index="1-4"> <template slot="title">選項4</template> <el-menu-item index="1-4-1">選項1</el-menu-item> </el-submenu> </el-submenu> <el-submenu index="2"> <template slot="title"> <i class="el-icon-menu"></i> <span slot="title">導航二</span> </template> <el-menu-item index="2-1">選項2-1</el-menu-item> </el-submenu> <el-menu-item index="3" disabled> <i class="el-icon-document"></i> <span slot="title">導航三</span> </el-menu-item> <el-menu-item index="4"> <i class="el-icon-setting"></i> <span slot="title">導航四</span> </el-menu-item> </el-menu> </el-col> <el-col> <el-button @click="isCollapse = !isCollapse">摺疊</el-button> </el-col> </el-row> </template> <script> export default { data() { return { isCollapse: false } }, methods: { handleSelect(key, keyPath) { console.log('handleSelect', key, keyPath) }, handleOpen(key, keyPath) { console.log('handleOpen', key, keyPath) }, handleClose(key, keyPath) { console.log('handleClose', key, keyPath) } } } </script>
el-menu 表示菜單容器組件
handleSelect 1-4-1 (3) ["1", "1-4", "1-4-1"]
獲取keypath 咱們能夠獲取1-4-1菜單的全部父級菜單的ID
子菜單容器,el-submenu與el-menu不一樣,el-menu表示整個菜單,而el-submenu表示一個具體菜單,只是該菜單保含了子菜單
el-submenu 能夠經過定製solt的title來自定義菜單樣式
<el-submenu index="1"> <template slot="title"> <i class="el-icon-location"></i> <span>導航一</span> </template> </el-submenu>
el-submenu 容器內的default的solt用來存放子菜單,能夠包含三種子菜單組件
sidebar-item 組件源碼以下:
<template> <div v-if="!item.hidden" class="menu-wrapper"> <template v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow"> <app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)"> <el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}"> <item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="onlyOneChild.meta.title" /> </el-menu-item> </app-link> </template> <el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body> <template slot="title"> <item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" /> </template> <sidebar-item v-for="child in item.children" :key="child.path" :is-nest="true" :item="child" :base-path="resolvePath(child.path)" class="nest-menu" /> </el-submenu> </div> </template> <script> import path from 'path' import { isExternal } from '@/utils/validate' import Item from './Item' import AppLink from './Link' import FixiOSBug from './FixiOSBug' export default { name: 'SidebarItem', components: { Item, AppLink }, mixins: [FixiOSBug], props: { // route object item: { type: Object, required: true }, isNest: { type: Boolean, default: false }, basePath: { type: String, default: '' } }, data() { // To fix https://github.com/PanJiaChen/vue-admin-template/issues/237 // TODO: refactor with render function this.onlyOneChild = null return {} }, methods: { hasOneShowingChild(children = [], parent) { const showingChildren = children.filter(item => { if (item.hidden) { return false } else { // Temp set(will be used if only has one showing child) this.onlyOneChild = item return true } }) // When there is only one child router, the child router is displayed by default if (showingChildren.length === 1) { return true } // Show parent if there are no child router to display if (showingChildren.length === 0) { this.onlyOneChild = { ... parent, path: '', noShowingChildren: true } return true } return false }, resolvePath(routePath) { if (isExternal(routePath)) { return routePath } if (isExternal(this.basePath)) { return this.basePath } return path.resolve(this.basePath, routePath) } } } </script>
sidebar-item的props以下:
sidebar-item 最重要的是展現邏輯,主要分爲如下幾步:
經過 hasoneShowingChild(item.children,item)&&(!onlyOneCHild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow 邏輯判斷template菜單是否展現,template表明單一菜單
入參:
hasOneShowingChild(children = [], parent) { const showingChildren = children.filter(item => { // 若是 children 中的路由包含 hidden 屬性,則返回 false if (item.hidden) { return false } else { // 將子路由賦值給 onlyOneChild,用於只包含一個路由時展現 this.onlyOneChild = item return true } }) // 若是過濾後,只包含展現一個路由,則返回 true if (showingChildren.length === 1) { return true } // 若是沒有子路由須要展現,則將 onlyOneChild 的 path 設置空路由,並添加 noShowingChildren 屬性,表示雖然有子路由,可是不須要展現子路由 if (showingChildren.length === 0) { this.onlyOneChild = { ...parent, path: '', noShowingChildren: true } return true } // 返回 false,表示不須要展現子路由,或者超過一個須要展現的子路由 return false }
item組件須要路由meta中包含 title和icon屬性,不然將渲染內容爲空的vnode對象
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)"> <el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}"> <item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="onlyOneChild.meta.title" /> </el-menu-item> </app-link>
<el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body> <template slot="title"> <item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" /> </template> <sidebar-item v-for="child in item.children" :key="child.path" :is-nest="true" :item="child" :base-path="resolvePath(child.path)" class="nest-menu" /> </el-submenu>
el-submenu 中的 sidebar-item有兩點區別:
app-link 是一個動態組件,經過解析to參數,若是包含http前綴則變成a標籤,不然變成一個router-link組件
<template> <!-- eslint-disable vue/require-component-is --> <component v-bind="linkProps(to)"> <slot /> </component> </template> <script> import { isExternal } from '@/utils/validate' export default { props: { to: { type: String, required: true } }, methods: { linkProps(url) { if (isExternal(url)) { return { is: 'a', href: url, target: '_blank', rel: 'noopener' } } return { is: 'router-link', to: url } } } } </script>
isExternal
函數經過一個正則表達式匹配http連接
export function isExternal(path) { return /^(https?:|mailto:|tel:)/.test(path) }
item組件經過定義render函數完成組件渲染
<script> export default { name: 'MenuItem', functional: true, props: { icon: { type: String, default: '' }, title: { type: String, default: '' } }, render(h, context) { const { icon, title } = context.props const vnodes = [] if (icon) { vnodes.push(<svg-icon icon-class={icon}/>) } if (title) { vnodes.push(<span slot='title'>{(title)}</span>) } return vnodes } } </script>
sidebar:sidebar主要包含el-menu容器組件,el-menu中遍歷vuex中的routes,生成sidebar-item 組件,sidebar主要配置項以下:
sidebar-item:sidebar-item 分爲兩部分:
第一部分是當只須要展現一個children或者沒有children時進行展現,展現的組件包括:
第二部分是當children超過兩項時進行展現,展現的組件包括:
sidebar-item:el-submenu迭代嵌套了sidebar-item組件,在sidebar-item組件中有兩點變化
* 設置is-nest屬性爲true * 根據child.path 生成了 base-path屬性傳入sidebar-item 組件
如何實現重定向功能
login.vue 中對 $route 進行監聽
watch: { $route: { handler: function(route) { const query = route.query if (query) { this.redirect = query.redirect this.otherQuery = this.getOtherQuery(query) } }, immediate: true } }
this.getOtherQuery(query)的用途是獲取除redirect外的其餘查詢條件,登陸成功後:
this.$store.dispatch('user/login', this.loginForm) .then(() => { this.$router.push({ path: this.redirect || '/', query: this.otherQuery }) this.loading = false }) .catch(() => { this.loading = false })
完成重定向的代碼爲:this.$router.push({ path: this.redirect || '/', query: this.otherQuery })
vue-element-admin 提供了專門的重定向組件,源碼以下:
<script> export default { created() { const { params, query } = this.$route const { path } = params this.$router.replace({ path: '/' + path, query }) }, render: function(h) { return h() // avoid warning message } } </script>
重定向組件配置了動態路由
{ path: '/redirect', component: Layout, hidden: true, children: [ { path: '/redirect/:path*', component: () => import('@/views/redirect/index') } ] }
還有一個細節path: '/redirect/:path*'
表示匹配零個或多個路由,好比路由爲 /redirect 時,仍然能匹配到redirect組件,若是將路由改成path: '/redirect/:path'
此時路由 /redirect 將只能匹配到layout組件,而沒法匹配redirect組件
如何實現麪包屑導航
<el-breadcrumb separator="/"> <el-breadcrumb-item :to="{ path: '/' }">首頁</el-breadcrumb-item> <el-breadcrumb-item><a href="/">活動管理</a></el-breadcrumb-item> <el-breadcrumb-item>活動列表</el-breadcrumb-item> <el-breadcrumb-item>活動詳情</el-breadcrumb-item> </el-breadcrumb>
使用to屬性和a標籤切換路由的區別是:to屬性切換路由是動態替換app.vue中的路由內容,而a標籤切換路由會刷新界面
麪包屑導航的最大難度在於如何將路由與麪包屑導航進行映射
生成麪包屑導航
getBreadcrumb() { let matched = this.$route.matched.filter(item => item.meta && item.meta.title) const first = matched[0] if (!this.isDashboard(first)) { matched = [{ path: '/dashboard', meta: { title: 'Dashboard' }}].concat(matched) } this.levelList = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false) }
麪包屑導航實現的邏輯以下
這裏的關鍵是this.$route.matched 屬性,它是一個數組,記錄了路由匹配的過程,是麪包屑導航實現的基礎
isDashboard實現以下:
isDashboard(route) { const name = route && route.name if (!name) { return false } return name.trim().toLocaleLowerCase() === 'Dashboard'.toLocaleLowerCase() }
麪包屑導航模板源碼
<el-breadcrumb class="app-breadcrumb" separator="/"> <transition-group name="breadcrumb"> <el-breadcrumb-item v-for="(item,index) in levelList" :key="item.path"> <span v-if="item.redirect==='noRedirect'||index==levelList.length-1" class="no-redirect">{{ item.meta.title }}</span> <a v-else @click.prevent="handleLink(item)">{{ item.meta.title }}</a> </el-breadcrumb-item> </transition-group> </el-breadcrumb>
el-breadcrumb-item 內作了一個判斷,若是最後一個元素或者路由的redirect屬性指定爲noRedirect 則不會生成連接,不然將使用 a標籤生成連接,可是這裏使用了 @click.prevent 阻止了默認a標籤的事件觸發,而使用自定義的 handlink 方法處理路由跳轉,handlink方法源碼以下:
handleLink(item) { const { redirect, path } = item if (redirect) { this.$router.push(redirect) return } this.$router.push(this.pathCompile(path)) }
這裏的 pathCompile 用於解決動態路由匹配問題
登陸組件login.vue 佈局要點以下:
el-form容器,包含username和password兩個 el-form-item,el-form主要屬性:
password使用了el-tooltip提示,當用戶打開大小寫時,會進行提示,主要屬性:
password對應el-input主要屬性:
> 這裏綁定@keyup 事件須要添加 .native 修飾符,這是由於咱們的事件綁定在el-input組件上,因此若是不添加.native修飾符,事件將沒法綁定到原生的input標籤上
checkCapsLock 方法的主要用途是監聽用戶鍵盤輸入,顯示提示文字的判斷邏輯以下
當按下CapsLock按鍵時,若是按下後是小寫模式,則會當即消除提示文字
checkCapslock({ shiftKey, key } = {}) { if (key && key.length === 1) { if (shiftKey && (key >= 'a' && key <= 'z') || !shiftKey && (key >= 'A' && key <= 'Z')) { this.capsTooltip = true } else { this.capsTooltip = false } } if (key === 'CapsLock' && this.capsTooltip === true) { this.capsTooltip = false } }
handleLogin 方法處理流程以下:
這裏須要注意:因爲vuex中的user制定了namespaced爲true,因此dispatch時須要加上namespace,不然將沒法調用vuex中的action
handleLogin() { this.$refs.loginForm.validate(valid => { if (valid) { this.loading = true this.$store.dispatch('user/login', this.loginForm) .then(() => { this.$router.push({ path: this.redirect || '/', query: this.otherQuery }) this.loading = false }) .catch(() => { this.loading = false }) } else { console.log('error submit!!') return false } }) }
user/login 方法調用了login API,傳入username和password參數,請求成功後會從response中獲取token,而後將token保存到cookies中,以後返回,若是請求失敗,將調用reject方法,交由咱們自定義的request模塊來處理異常
login({ commit }, userInfo) { const { username, password } = userInfo return new Promise((resolve, reject) => { login({ username: username.trim(), password: password }).then(response => { const { data } = response commit('SET_TOKEN', data.token) setToken(data.token) resolve() }).catch(error => { reject(error) }) }) }
login API的方法以下:
import request from '@/utils/request' export function login(data) { return request({ url: '/user/login', method: 'post', data }) }
這裏使用request方法,它是一個基於axios封裝的庫,目前咱們的/user/login 接口是經過mock實現的,用戶的數據位於 /mock/user.js
request 庫使用了 axios的手動實例化方法create來封裝請求,要理解其中的用法,咱們首先須要學習axios庫的用法
咱們先從一個普通的axios示例開始
import axios from 'axios' const url = 'https://test.youbaobao.xyz:18081/book/home/v2?openId=1234' axios.get(url).then(response => { console.log(response) })
上述代碼能夠改成
import axios from 'axios' const url = 'https://test.youbaobao.xyz:18081/book/home/v2?openId=1234' axios.get(url).then(response => { console.log(response) })
若是咱們在請求是須要在headr中添加一個token,須要將代碼修改成:
const url = 'https://test.youbaobao.xyz:18081/book/home/v2' axios.get(url, { params: { openId: '1234' }, headers: { token: 'abcd' } }).then(response => { console.log(response) }).catch(err => { console.log(err) })
這樣改動能夠實現咱們的需求,可是有兩個問題
下面咱們使用axios.create 對整個請求進行重構
const url = '/book/home/v2' const request = axios.create({ baseURL: 'https://test.youbaobao.xyz:18081', timeout: 5000 }) request({ url, method: 'get', params: { openId: '1234' } })
首先咱們經過 axios.create 生成一個函數,該函數是axios示例,經過執行該方法完成請求,它與直接調用axios.get 區別以下
上述代碼完成了基本請求的功能,下面咱們須要爲http請求的headers中添加token,同時進行白名單校驗,如 /login 不須要添加token,並實現異步捕獲和自定義處理:
const whiteUrl = [ '/login', '/book/home/v2' ] const url = '/book/home/v2' const request = axios.create({ baseURL: 'https://test.youbaobao.xyz:18081', timeout: 5000 }) request.interceptors.request.use( config => { // throw new Error('error...') const url = config.url.replace(config.baseURL, '') if (whiteUrl.some(wl => url === wl)) { return config } config.headers['token'] = 'abcd' return config }, error => { return Promise.reject(error) } ) request({ url, method: 'get', params: { openId: '1234' } }).catch(err => { console.log(err) })
這裏核心是調用了 request.interceptors.request.use 方法,即axios的請求攔截器,該方法須要傳入兩個參數,第一個參數爲攔截器方法,包含config參數,咱們能夠在這個方法中修改config而且進行回傳,第二個參數是異常處理方法,咱們可使用 Promise.reject(error)將異常返回給用戶進行處理,因此咱們在request請求後能夠經過catch捕獲異常進行自定義處理
下面咱們進一步加強axios功能 咱們在實際開發中除了須要保障http statusCode爲 200 ,還須要保障業務代碼正確,上述案例中 我定義了 error_code 爲0時,表示業務正常,若是返回值不爲0 則說明業務處理出錯 此時咱們經過 request.interceptors.response.use 方法定義響應攔截器,它仍然須要2個參數,與請求攔截器相似,注意第二個參數主要處理 statusCode 非200的異常請求,源碼以下:
const whiteUrl = [ '/login', '/book/home/v2' ] const url = '/book/home/v2' const request = axios.create({ baseURL: 'https://test.youbaobao.xyz:18081', timeout: 5000 }) request.interceptors.request.use( config => { const url = config.url.replace(config.baseURL, '') if (whiteUrl.some(wl => url === wl)) { return config } config.headers['token'] = 'abcd' return config }, error => { return Promise.reject(error) } ) request.interceptors.response.use( response => { const res = response.data if (res.error_code != 0) { alert(res.msg) return Promise.reject(new Error(res.msg)) } else { return res } }, error => { return Promise.reject(error) } ) request({ url, method: 'get', params: { openId: '1234' } }).then(response => { console.log(response) }).catch(err => { console.log(err) })
有了上述基礎後,咱們在看request庫源碼就很是容易了
const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 5000 }) service.interceptors.request.use( config => { // 若是存在 token 則附帶在 http header 中 if (store.getters.token) { config.headers['X-Token'] = getToken() } return config }, error => { return Promise.reject(error) } ) service.interceptors.response.use( response => { const res = response.data if (res.code !== 20000) { Message({ message: res.message || 'Error', type: 'error', duration: 5 * 1000 }) // 判斷 token 失效的場景 if (res.code === 50008 || res.code === 50012 || res.code === 50014) { // 若是 token 失效,則彈出確認對話框,用戶點擊後,清空 token 並返回登陸頁面 MessageBox.confirm('You have been logged out, you can cancel to stay on this page, or log in again', 'Confirm logout', { confirmButtonText: 'Re-Login', cancelButtonText: 'Cancel', type: 'warning' }).then(() => { store.dispatch('user/resetToken').then(() => { location.reload() }) }) } return Promise.reject(new Error(res.message || 'Error')) } else { return res } }, error => { Message({ message: error.message, type: 'error', duration: 5 * 1000 }) return Promise.reject(error) } ) export default service
檢查用戶名或密碼是否爲空,若是發現爲空,則自動彙集:
mounted() { if (this.loginForm.username === '') { this.$refs.username.focus() } else if (this.loginForm.password === '') { this.$refs.password.focus() } }
切換密碼顯示狀態後,自動彙集password輸入框:
showPwd() { if (this.passwordType === 'password') { this.passwordType = '' } else { this.passwordType = 'password' } this.$nextTick(() => { this.$refs.password.focus() }) }
const query = { redirect: '/book/list', name: 'sam', id: '1234' } // 直接刪除 query.redirect,會直接改動 query // delete query.redirect // 經過淺拷貝實現屬性過濾 // const _query = Object.assign({}, query) // delete _query.redirect const _query = Object.keys(query).reduce((acc, cur) => { if (cur !== 'redirect') { acc[cur] = query[cur] } return acc }, {}) console.log(query, _query)
去掉main.js 中mock相關代碼
import { mockXHR } from '../mock' if (process.env.NODE_ENV === 'production') { mockXHR() }
刪除 src/api目錄下2個api文件
article.js qiniu.js
刪除 vue.config.js 中的相關配置
proxy: { // change xxx-api/login => mock/login // detail: https://cli.vuejs.org/config/#devserver-proxy [process.env.VUE_APP_BASE_API]: { target: `http://127.0.0.1:${port}/mock`, changeOrigin: true, pathRewrite: { ['^' + process.env.VUE_APP_BASE_API]: '' } } }, after: require('./mock/mock-server.js')
修改後咱們的項目就不能使用mock接口,會直接請求到http接口,咱們須要打開SwitchHosts配置host映射,讓域名映射到本地node項目127.0.0.1 budai.store
咱們將發佈到開發環境和生產環境,因此須要修改 .env.development 和 .env.production兩個配置文件;
`VUE_APP_BASE_API = 'https://budai.store:18082'
`
有兩點須要注意:
從新啓動項目後,發現已能夠指向指定的接口https://budai.store:18082/user/login
首先須要將https證書拷貝到node項目中,而後添加下列代碼
const fs = require('fs') const https = require('https') const privateKey = fs.readFileSync('https/budai.store.key', 'utf8') const certificate = fs.readFileSync('https/budai.store.pem', 'utf8') const credentials = { key: privateKey, cert: certificate } const httpsServer = https.createServer(credentials, app) const SSLPORT = 18082 httpsServer.listen(SSLPORT, function() { console.log('HTTPS Server is running on: https://localhost:%s', SSLPORT) })
啓動https服務須要證書對象credentials,包含了私鑰和證書,從新啓動node服務
node app.js
在瀏覽器輸入 https://budai.store:18082
能夠看到
`歡迎學習小慕讀書管理後臺
`
說明https服務啓動成功
在 router/user.js 中填入如下代碼:
router.post('/login', function(req, res, next) { console.log('/user/login', req.body) res.json({ code: 0, msg: '登陸成功' }) })
$ curl https://budai.store:18082/user/login -X POST -d "username=sam&password=123456" {"code":0,"msg":"登陸成功"}
上面的命令能夠簡寫爲
`curl https://budai.store:18082/user/login -d "username=sam&password=123456"
`
這裏咱們能夠經過req.body 獲取POST請求中的參數,可是沒有獲取成功,咱們須要經過 body-parser 中間件來解決這個問題:
`npm i -S body-parser
`
在 app.js 中加入
const bodyParser = require('body-parser') // 建立 express 應用 const app = express() app.use(bodyParser.urlencoded({ extended: true })) app.use(bodyParser.json())
返回前端按鈕請求登陸接口,發現控制檯報錯:
`Access to XMLHttpRequest at 'https://budai.store:18082/user/login' from origin 'http://localhost:9527' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
`
這是因爲前端部署在 http://localhost:9527 然後端部署在 https://budai.store:18082,因此致使了跨域錯誤,咱們須要在 node 服務中添加跨域中間件 cors:
`npm i -S cors
`
而後修改app.js:
const cors = require('cors') // ... app.use(cors())
再次請求便可成功,這裏咱們在Network中會發現發起了兩次https請求,這是因爲由於觸發了跨域請求,因此會首先進行OPTIONS請求,判斷服務器是否容許跨域請求,若是容許才能實際進行請求
在 /user/login 咱們看到的返回值是
res.json({ code: 0, msg: '登陸成功' })
以後咱們還要定義錯誤返回值,但若是每一個接口都編寫以上代碼就顯得很是冗餘,並且不易維護,因此咱們建立一個Result類來解決這個問題
建立 /models/Result.js 文件
const { CODE_ERROR, CODE_SUCCESS } = require('../utils/constant') class Result { constructor(data, msg = '操做成功', options) { this.data = null if (arguments.length === 0) { this.msg = '操做成功' } else if (arguments.length === 1) { this.msg = data } else { this.data = data this.msg = msg if (options) { this.options = options } } } createResult() { if (!this.code) { this.code = CODE_SUCCESS } let base = { code: this.code, msg: this.msg } if (this.data) { base.data = this.data } if (this.options) { base = { ...base, ...this.options } } console.log(base) return base } json(res) { res.json(this.createResult()) } success(res) { this.code = CODE_SUCCESS this.json(res) } fail(res) { this.code = CODE_ERROR this.json(res) } } module.exports = Result
咱們還須要建立 /utils/constant.js
module.exports = { CODE_ERROR: -1, CODE_SUCCESS: 0 }
Result 使用了 ES6 的Class,使用方法以下
// 調用成功時 new Result().success(res) new Result('登陸成功').success(res) // 調用成功時,包含參數 new Result({ token }, '登陸成功').success(res) // 調用失敗時 new Result('用戶名或密碼不存在').fail(res)
有了Result類之後咱們能夠將登陸API改成:
router.post('/login', function(req, res, next) { const username = req.body.username const password = req.body.password if (username === 'admin' && password === '123456') { new Result('登陸成功').success(res) } else { new Result('登陸失敗').fail(res) } })
若是在響應前跑出error,此時的Error將被咱們自定義的異常處理捕獲,並返回500錯誤
安裝mysql庫
` npm i -S mysql
`
建立db目錄,新建兩個文件
index.js config.js
config.js源碼以下
module.exports = { host: 'localhost', user: 'root', password: '12345678', database: 'book' }
鏈接數據庫須要提供使用mysql的
const {host,user,password,database} = require('./config') function connect() { return mysql.createConnection({ host, user, password, database, multipleStatements: true }) }
查詢須要調用connection對象的query方法:
function querySql(sql) { const conn = connect() debug && console.log(sql) return new Promise((resolve, reject) => { try { conn.query(sql, (err, results) => { if (err) { debug && console.log('查詢失敗,緣由:' + JSON.stringify(err)) reject(err) } else { debug && console.log('查詢成功', JSON.stringify(results)) resolve(results) } }) } catch (e) { reject(e) } finally { conn.end() } }) }
咱們在 constant.js 建立一個debug參數控制打印日誌:const debug = require('../utils/constant').debug
這裏須要注意conn對象使用完畢後須要調用end進行關閉,不然會致使內存泄漏
調用方法以下:
db.querySql('select * from book').then(result => { console.log(result) })
這裏咱們須要基於mysql 查詢庫封裝一層service ,用來協調業務邏輯和數據庫邏輯,咱們不但願直接把業務邏輯放在router中,建立 /service/user.js
const { querySql } = require('../db') function login(username, password) { const sql = `select * from admin_user where username='${username}' and password='${password}'` return querySql(sql) } module.exports = { login }
改造 /user/login API:
router.post('/login', function(req, res, next) { const username = req.body.username const password = req.body.password login(username, password).then(user => { if (!user || user.length === 0) { new Result('登陸失敗').fail(res) } else { new Result('登陸成功').success(res) } }) })
此時即便咱們輸入正確的用戶名和密碼仍然沒法登陸,這是由於密碼採用了MD5+SALT加密,因此咱們須要對密碼進行對等加密,才能查詢成功。在/utils/constant.js 中加入SALT:
module.exports = { // ... PWD_SALT: 'admin_imooc_node', }
安裝crypto庫:
`npm i -S crypto
`
而後在 /utils/index.js 中建立md5方法
const crypto = require('crypto') function md5(s) { // 注意參數須要爲 String 類型,不然會出錯 return crypto.createHash('md5') .update(String(s)).digest('hex'); }
const password = md5(`${req.body.password}${PWD_SALT}`)
再次輸入正確的用戶名和密碼,查詢成功:
select * from admin_user where username='admin' and password='91fe0e80d07390750d46ab6ed3a99316' 查詢成功 [{"id":1,"username":"admin","password":"91fe0e80d07390750d46ab6ed3a99316","role":"admin","nicknamedmin","avatar":"https://www.youbaobao.xyz/mpvue-res/logo.jpg"}] { code: 0, msg: '登陸成功' }
express-validator 是一個功能強大的表單驗證器,它是validator.js的中間件
使用express-validator 能夠簡化POST請求的參數驗證,使用方法以下:
安裝
`npm i -S express-validator
`
驗證
const { body, validationResult } = require('express-validator') const boom = require('boom') router.post( '/login', [ body('username').isString().withMessage('username類型不正確'), body('password').isString().withMessage('password類型不正確') ], function(req, res, next) { const err = validationResult(req) if (!err.isEmpty()) { const [{ msg }] = err.errors next(boom.badRequest(msg)) } else { const username = req.body.username const password = md5(`${req.body.password}${PWD_SALT}`) login(username, password).then(user => { if (!user || user.length === 0) { new Result('登陸失敗').fail(res) } else { new Result('登陸成功').success(res) } }) } })
express-validator 使用技巧:
安裝 jsonwebtoken
`npm i -S jsonwebtoken
`
使用
const jwt = require('jsonwebtoken') const { PRIVATE_KEY, JWT_EXPIRED } = require('../utils/constant') login(username, password).then(user => { if (!user || user.length === 0) { new Result('登陸失敗').fail(res) } else { const token = jwt.sign( { username }, PRIVATE_KEY, { expiresIn: JWT_EXPIRED } ) new Result({ token }, '登陸成功').success(res) } })
這裏須要定義jwt的私鑰和過時時間,過時時間不宜太短,也不宜過長,課程裏設置爲1小時,實際業務中可根據場景來判斷,一般建議不超過24小時,保密性要求高的業務能夠設置爲1-2小時
module.exports = { // ... PRIVATE_KEY: 'admin_imooc_node_test_youbaobao_xyz', JWT_EXPIRED: 60 * 60, // token失效時間 }
前端再次請求,結果以下
{ "code":0, "msg":"登陸成功", "data":{ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNTc0NDk1NzA0LCJleHAiOjE1NzQ0OTkzMDR9.9lnxdTn1MmMbKsPvhvRHDRIufbMcUD437CWjnoJsmfo" } }
咱們能夠將該token在jwt.io網站上進行驗證,能夠獲得以下結果:
{ "username": "admin", "iat": 1574495704, "exp": 1574499304 }
能夠看到username 被正確解析,說明token生成成功
修改 src/utils/request.js
service.interceptors.response.use( response => { const res = response.data if (res.code !== 0) { Message({ message: res.msg || 'Error', type: 'error', duration: 5 * 1000 }) // 判斷 token 失效的場景 if (res.code === -2) { // 若是 token 失效,則彈出確認對話框,用戶點擊後,清空 token 並返回登陸頁面 MessageBox.confirm('Token 失效,請從新登陸', '確認退出登陸', { confirmButtonText: '從新登陸', cancelButtonText: '取消', type: 'warning' }).then(() => { store.dispatch('user/resetToken').then(() => { location.reload() }) }) } return Promise.reject(new Error(res.msg || '請求失敗')) } else { return res } }, error => { let message = error.message || '請求失敗' if (error.response && error.response.data) { const { data } = error.response message = data.msg } Message({ message, type: 'error', duration: 5 * 1000 }) return Promise.reject(error) } )
安裝express-jwt
`npm i -S express-jwt
`
建立 /router/jwt.js
const expressJwt = require('express-jwt'); const { PRIVATE_KEY } = require('../utils/constant'); const jwtAuth = expressJwt({ secret: PRIVATE_KEY, credentialsRequired: true,// 設置爲false就不進行校驗了,遊客也能夠訪問 ,algorithms: ['HS256'] }).unless({ path: [ '/', '/user/login' ], // 設置 jwt 認證白名單 }); module.exports = jwtAuth;
在 /router/index.js中使用中間件
const jwtAuth = require('./jwt') // 註冊路由 const router = express.Router() // 對全部路由進行 jwt 認證 router.use(jwtAuth)
在 /utils/constans.js 中添加
module.exports = { // ... CODE_TOKEN_EXPIRED: -2 }
修改 /model/Result.js:
expired(res) { this.code = CODE_TOKEN_EXPIRED this.json(res) }
修改自定義異常:
router.use((err, req, res, next) => { if (err.name === 'UnauthorizedError') { new Result(null, 'token失效', { error: err.status, errorMsg: err.name }).expired(res.status(err.status)) } else { const msg = (err && err.message) || '系統錯誤' const statusCode = (err.output && err.output.statusCode) || 500; const errorMsg = (err.output && err.output.payload && err.output.payload.error) || err.message new Result(null, msg, { error: statusCode, errorMsg }).fail(res.status(statusCode)) } })
後端添加路由的jwt認證後,再次請求 /user/info 將拋出401 錯誤,這是因爲前端未傳遞合理的Token致使的,下面咱們就修改 /utils/request.js ,使得前端請求時能夠傳遞Token:
service.interceptors.request.use( config => { // 若是存在 token 則附帶在 http header 中 if (store.getters.token) { config.headers['Authorization'] = `Bearer ${getToken()}` } return config }, error => { return Promise.reject(error) } )
前端去掉 /user/info 請求時傳入的 token ,由於咱們已經從header中傳入,修改 src/api/user.js
export function getInfo() { return request({ url: '/user/info', method: 'get' }) }
在 db/index.js 中添加:
function queryOne(sql) { return new Promise((resolve, reject) => { querySql(sql) .then(results => { if (results && results.length > 0) { resolve(results[0]) } else { resolve(null) } }) .catch(error => { reject(error) }) }) }
在 /services/user.js 中添加
function findUser(username) { const sql = `select * from admin_user where username='${username}'` return queryOne(sql) }
此時有個問題,前端僅在http Header中傳入了Token若是經過Token獲取username呢?這裏就須要經過對 JWTToken 進行解析,在 /utils/index.js 中添加 decode方法:
const jwt = require('jsonwebtoken') const { PRIVATE_KEY } = require('./constant') function decode(req) { const authorization = req.get('Authorization') let token = '' if (authorization.indexOf('Bearer') >= 0) { token = authorization.replace('Bearer ', '') } else { token = authorization } return jwt.verify(token, PRIVATE_KEY) }
修改 /router/user.js
router.get('/info', function(req, res) { const decoded = decode(req) if (decoded && decoded.username) { findUser(decoded.username).then(user => { if (user) { user.roles = [user.role] new Result(user, '獲取用戶信息成功').success(res) } else { new Result('獲取用戶信息失敗').fail(res) } }) } else { new Result('用戶信息解析失敗').fail(res) } })
此時在前端從新登陸,終於登陸成功
修改 src/store/modules/user.js:
logout({ commit, state, dispatch }) { return new Promise((resolve, reject) => { try { commit('SET_TOKEN', '') commit('SET_ROLES', []) removeToken() resetRouter() // reset visited views and cached views // to fixed https://github.com/PanJiaChen/vue-element-admin/issues/2485 dispatch('tagsView/delAllViews', null, { root: true }) resolve() } catch (e) { reject(e) } }) }
若是你的場景須要受權給第三方app,呢麼咱們一般須要在增長一個RefreshToken的API,該API的用途是根據現有的Token獲取用戶名,而後生成一個新的Token,這樣作的目的是爲了防止Token失效後退出登陸,因此app通常會在打開時刷新一次Token,該api的實現方法比較簡單,所需的技術以前都已經介紹過,你們能夠參考以前的文檔進行實現
電子書上傳過程分爲新增電子書和編輯電子書,新增:
<template> <detail :is-edit="false" /> </template> <script> import Detail from './components/Detail' export default { name: 'CreateBook', components: { Detail } } </script>
編輯:
<template> <article-detail :is-edit /> </template> <script> import Detail from './components/Detail' export default { name: 'EditBook', components: { Detail } } </script>
Detail組件比較複雜,咱們逐步實現,首先實現Detail的大致佈局,包括一個el-form和sticky導航欄 sticky導航欄在內容較多時會產生吸頂效果:
<div class="detail"> <el-form ref="postForm" :model="postForm" :rules="rules" class="form-container"> <sticky :z-index="10" :class-name="'sub-navbar ' + postForm.status"> <el-button v-if="!isEdit" @click.prevent.stop="showGuide">顯示幫助</el-button> <el-button v-loading="loading" style="margin-left: 10px;" type="success" @click="submitForm"> {{ isEdit ? '編輯電子書' : '新增電子書' }} </el-button> </sticky> <div class="detail-container"> <el-row> <Warning /> <el-col :span="24"> <!-- 編寫具體表單控件 --> </el-col> <el-col :span="24"> <!-- 編寫具體表單控件 --> </el-col> </el-row> </div> </el-form> </div> <style lang="scss" scoped> @import "~@/styles/mixin.scss"; .detail { position: relative; .detail-container { padding: 40px 45px 20px 50px; .preview-img { width: 200px; height: 270px; } .contents-wrapper { padding: 5px 0; } } } </style>
這裏咱們基於 el-upload封裝了上傳組件EbookUpload,基於EbookUpload咱們再實現上傳組件就很是容易了:
<div class="upload-container"> <el-upload :action="action" :headers="headers" :multuple="false" :limit="1" :before-upload="beforeUpload" :on-success="onSuccess" :on-error="onError" :on-remove="onRemove" :file-list="fileList" :on-exceed="onExceed" :disabled="disabled" drag show-file-list accept="application/epub+zip" class="image-upload" > <i class="el-icon-upload"></i> <div class="el-upload__text" v-if="fileList.length === 0">請將電子書拖入或 <em>點擊上傳</em></div> <div v-else class="el-upload__text">圖書已上傳</div> </el-upload> </div>
上傳失敗事件
onError(err) { const errMsg = err.message && JSON.parse(err.message) this.$message({ message: (errMsg && errMsg.msg && `上傳失敗,失敗緣由:${errMsg.msg}`) || '上傳失敗', type: 'error' }) this.$emit('onError', err) }, onExceed() { this.$message({ message: '每次只能上傳一本電子書', type: 'warning' }) }
指定目的nginx上傳路徑,這樣作的好處是一旦電子書拷貝到指定目錄下後,,就能夠經過nginx生成下載連接:
新建 /utils/env.js
module.exports = { env:'dev' } const { env } = require('./env') const UPLOAD_PATH = env === 'dev' ? '/Users/sam/upload/admin-upload-ebook' : '/root/upload/admin-upload-ebook'
安裝multer:上傳
`const multer = require('multer')
`
上傳API
router.post( '/upload', multer({ dest: `${UPLOAD_PATH}/book` }).single('file'), function(req, res, next) { if (!req.file || req.file.length === 0) { new Result('上傳電子書失敗').fail(res) } else { const book = new Book(req.file) book.parse() .then(book => { new Result(book.toJson(), '上傳成功').success(res) }) .catch((err) => { console.log('/book/upload', err) next(boom.badImplementation(err)) book.reset() }) } })
圖書表單包括如下信息:
<el-col :span="24"> <el-form-item prop="title"> <MdInput v-model="postFrom.title" :maxlength="100" name="name" required>書名</MdInput> </el-form-item> <el-row> <el-col :span="12"> <el-form-item :label-width="labelWidth" label="做者:"> <el-input v-model="postFrom.author" placeholder="做者" style="width: 100%" /> </el-form-item> </el-col> <el-col :span="12"> <el-form-item :label-width="labelWidth" label="出版社:"> <el-input v-model="postFrom.publisher" placeholder="出版社" style="width: 100%"/> </el-form-item> </el-col> </el-row> <el-row> <el-col :span="12"> <el-form-item label="語言:" :label-width="labelWidth"> <el-input v-model="postFrom.language" placeholder="語言" /> </el-form-item> </el-col> <el-col :span="12"> <el-form-item label="根文件:" :label-width="labelWidth"> <el-input v-model="postFrom.rootFile" placeholder="根文件" disabled /> </el-form-item> </el-col> </el-row> <el-row> <el-col :span="12"> <el-form-item label="文件路徑:" :label-width="labelWidth"> <el-input v-model="postFrom.filePath" placeholder="文件路徑" disabled /> </el-form-item> </el-col> <el-col :span="12"> <el-form-item label="解壓路徑:" :label-width="labelWidth"> <el-input v-model="postFrom.unzipPath" placeholder="解壓路徑" disabled /> </el-form-item> </el-col> </el-row> <el-row> <el-col :span="12"> <el-form-item label="封面路徑:" :label-width="labelWidth"> <el-input v-model="postFrom.language" placeholder="封面路徑" /> </el-form-item> </el-col> <el-col :span="12"> <el-form-item label="文件名稱:" :label-width="labelWidth"> <el-input v-model="postFrom.rootFile" placeholder="文件名稱" /> </el-form-item> </el-col> </el-row> <el-row> <el-col :span="24"> <el-form-item label="封面:" :label-width="labelWidth"> <a v-if="postFrom.cover" :href="postFrom.cover" target="_blank">  </a> <span v-else>無</span> </el-form-item> </el-col> </el-row> <el-row> <el-col :span="24"> <el-form-item label="目錄:" :label-width="labelWidth"> <div v-if="postFrom.contents && postFrom.contents.length > 0" class="contents_warpper"> <el-tree class="" /> </div> <span v-else>無</span> </el-form-item> </el-col> </el-row> </el-col>
構造函數
Book分爲兩種場景,第一種是直接從電子書文件中解析出Book對象,第二種是從data對象生成Book對象
constructor(file, data) { if (file) { this.createBookFromFile(file) } else if (data) { this.createBookFromData(data) } }
從文件讀取電子書後,初始化Book對象
createBookFromFile(file) {
const { destination: des, // 文件本地存儲目錄 filename, // 文件名稱 mimetype = MIME_TYPE_EPUB // 文件資源類型 } = file const suffix = mimetype === MIME_TYPE_EPUB ? '.epub' : '' const oldBookPath = `${des}/${filename}` const bookPath = `${des}/${filename}${suffix}` const url = `${UPLOAD_URL}/book/${filename}${suffix}` const unzipPath = `${UPLOAD_PATH}/unzip/${filename}` const unzipUrl = `${UPLOAD_URL}/unzip/${filename}` if (!fs.existsSync(unzipPath)) { fs.mkdirSync(unzipPath, { recursive: true }) // 建立電子書解壓後的目錄 } if (fs.existsSync(oldBookPath) && !fs.existsSync(bookPath)) { fs.renameSync(oldBookPath, bookPath) // 重命名文件 } this.fileName = filename // 文件名 this.path = `/book/${filename}${suffix}` // epub文件路徑 this.filePath = this.path // epub文件路徑 this.url = url // epub文件url this.title = '' // 標題 this.author = '' // 做者 this.publisher = '' // 出版社 this.contents = [] // 目錄 this.cover = '' // 封面圖片URL this.category = -1 // 分類ID this.categoryText = '' // 分類名稱 this.language = '' // 語種 this.unzipPath = `/unzip/${filename}` // 解壓後的電子書目錄 this.unzipUrl = unzipUrl // 解壓後的電子書連接 this.originalName = file.originalname
}
從表單對象中建立Book對象
createBookFromData(data) { this.fileName = data.fileName this.cover = data.coverPath this.title = data.title this.author = data.author this.publisher = data.publisher this.bookId = data.fileName this.language = data.language this.rootFile = data.rootFile this.originalName = data.originalName this.path = data.path || data.filePath this.filePath = data.path || data.filePath this.unzipPath = data.unzipPath this.coverPath = data.coverPath this.createUser = data.username this.createDt = new Date().getTime() this.updateDt = new Date().getTime() this.updateType = data.updateType === 0 ? data.updateType : UPDATE_TYPE_FROM_WEB this.contents = data.contents }
初始化後,能夠調用Book實例的parse 方法解析電子書,這裏咱們使用了epub庫,咱們直接將epub源碼集成到項目中:
epub庫源碼 https://github.com/julien-c/epub
咱們直接將 epub.js 拷貝到 /utils/epub.js
getImage(id, callback) { if (this.manifest[id]) { if ((this.manifest[id]['media-type'] || '').toLowerCase().trim().substr(0, 6) != 'image/') { return callback(new Error('Invalid mime type for image')) } this.getFile(id, callback) } else { const coverId = Object.keys(this.manifest).find(key => ( this.manifest[key].properties === 'cover-image')) if (coverId) { this.getFile(coverId, callback) } else { callback(new Error('File not found')) } } };
parse() {
return new Promise((resolve, reject) => { const bookPath = `${UPLOAD_PATH}${this.path}` if (!this.path || !fs.existsSync(bookPath)) { reject(new Error('電子書路徑不存在')) } const epub = new Epub(bookPath) epub.on('error', err => { reject(err) }) epub.on('end', err => { if (err) { reject(err) } else { let { title, language, creator, creatorFileAs, publisher, cover } = epub.metadata // title = '' if (!title) { reject(new Error('圖書標題爲空')) } else { this.title = title this.language = language || 'en' this.author = creator || creatorFileAs || 'unknown' this.publisher = publisher || 'unknown' this.rootFile = epub.rootFile const handleGetImage = (error, imgBuffer, mimeType) => { if (error) { reject(error) } else { const suffix = mimeType.split('/')[1] const coverPath = `${UPLOAD_PATH}/img/${this.fileName}.${suffix}` const coverUrl = `${UPLOAD_URL}/img/${this.fileName}.${suffix}` fs.writeFileSync(coverPath, imgBuffer, 'binary') this.coverPath = `/img/${this.fileName}.${suffix}` this.cover = coverUrl resolve(this) } } try { this.unzip() // 解壓電子書 this.parseContents(epub) .then(({ chapters, chapterTree }) => { this.contents = chapters this.contentsTree = chapterTree epub.getImage(cover, handleGetImage) // 獲取封面圖片 }) .catch(err => reject(err)) // 解析目錄 } catch (e) { reject(e) } } } }) epub.parse() this.epub = epub })
}
電子書解析過程當中咱們須要自定義電子書目錄,第一步須要解壓電子書:
unzip() { const AdmZip = require('adm-zip') const zip = new AdmZip(Book.genPath(this.path)) // 解析文件路徑 zip.extractAllTo( /*target path*/Book.genPath(this.unzipPath), /*overwrite*/true ) }
genPath 是 Book 的一個屬性方法,咱們可使用 es6 的 static 屬性來實現:
static genPath(path) { if (path.startsWith('/')) { return `${UPLOAD_PATH}${path}` } else { return `${UPLOAD_PATH}/${path}` } }
電子書目錄解析算法:
parseContents(epub) { function getNcxFilePath() { const manifest = epub && epub.manifest const spine = epub && epub.spine const ncx = manifest && manifest.ncx const toc = spine && spine.toc return (ncx && ncx.href) || (toc && toc.href) } /** * flatten方法,將目錄轉爲一維數組 * * @param array * @returns {*[]} */ function flatten(array) { return [].concat(...array.map(item => { if (item.navPoint && item.navPoint.length) { return [].concat(item, ...flatten(item.navPoint)) } else if (item.navPoint) { return [].concat(item, item.navPoint) } else { return item } })) } /** * 查詢當前目錄的父級目錄及規定層次 * * @param array * @param level * @param pid */ function findParent(array, level = 0, pid = '') { return array.map(item => { item.level = level item.pid = pid if (item.navPoint && item.navPoint.length) { item.navPoint = findParent(item.navPoint, level + 1, item['$'].id) } else if (item.navPoint) { item.navPoint.level = level + 1 item.navPoint.pid = item['$'].id } return item }) } if (!this.rootFile) { throw new Error('目錄解析失敗') } else { const fileName = this.fileName return new Promise((resolve, reject) => { const ncxFilePath = Book.genPath(`${this.unzipPath}/${getNcxFilePath()}`) // 獲取ncx文件路徑 const xml = fs.readFileSync(ncxFilePath, 'utf-8') // 讀取ncx文件 // 將ncx文件從xml轉爲json xml2js(xml, { explicitArray: false, // 設置爲false時,解析結果不會包裹array ignoreAttrs: false // 解析屬性 }, function(err, json) { if (!err) { const navMap = json.ncx.navMap // 獲取ncx的navMap屬性 if (navMap.navPoint) { // 若是navMap屬性存在navPoint屬性,則說明目錄存在 navMap.navPoint = findParent(navMap.navPoint) const newNavMap = flatten(navMap.navPoint) // 將目錄拆分爲扁平結構 const chapters = [] epub.flow.forEach((chapter, index) => { // 遍歷epub解析出來的目錄 // 若是目錄大於從ncx解析出來的數量,則直接跳過 if (index + 1 > newNavMap.length) { return } const nav = newNavMap[index] // 根據index找到對應的navMap chapter.text = `${UPLOAD_URL}/unzip/${fileName}/${chapter.href}` // 生成章節的URL // console.log(`${JSON.stringify(navMap)}`) if (nav && nav.navLabel) { // 從ncx文件中解析出目錄的標題 chapter.label = nav.navLabel.text || '' } else { chapter.label = '' } chapter.level = nav.level chapter.pid = nav.pid chapter.navId = nav['$'].id chapter.fileName = fileName chapter.order = index + 1 chapters.push(chapter) }) const chapterTree = [] chapters.forEach(c => { c.children = [] if (c.pid === '') { chapterTree.push(c) } else { const parent = chapters.find(_ => _.navId === c.pid) parent.children.push(c) } }) // 將目錄轉化爲樹狀結構 resolve({ chapters, chapterTree }) } else { reject(new Error('目錄解析失敗,navMap.navPoint error')) } } else { reject(err) } }) }) } }
function getNcxFilePath() { const spine = epub && epub.spine const ncx = spine.toc && spine.toc.href const id = spine.toc && spine.toc.id if (ncx) { return ncx } else { return manifest[id].href } } function findParent(array, level = 0, pid = '') { return array.map(item => { item.level = level item.pid = pid if (item.navPoint && item.navPoint.length > 0) { item.navPoint = findParent(item.navPoint, level + 1, item['$'].id) } else if (item.navPoint) { item.navPoint.level = level + 1 item.navPoint.pid = item['$'].id } return item }) } function flatten(array) { return [].concat(...array.map(item => { if (item.navPoint && item.navPoint.length > 0) { return [].concat(item, ...flatten(item.navPoint)) } else if (item.navPoint) { return [].concat(item, item.navPoint) } return item })) } const ncxFilePath = Book.genPath(`${this.unzipPath}/${getNcxFilePath()}`) console.log(ncxFilePath) if (fs.existsSync(ncxFilePath)) { return new Promise((resolve, reject) => { const fileName = this.fileName const xml = fs.readFileSync(ncxFilePath, 'utf-8') const dir = path.dirname(ncxFilePath).replace(UPLOAD_PATH,'') parseString(xml, {explicitArray: false, ignoreAttrs: false}, function (err, json) { if (err) { reject(err) } else { const navMap = json.ncx.navMap if (navMap.navPoint && navMap.navPoint.length > 0) { navMap.navPoint = findParent(navMap.navPoint) const newNavMap = flatten(navMap.navPoint) const chapters = [] newNavMap.forEach((chapter, index) => { const src = chapter.content['$'].src console.log(src) chapter.text = `${UPLOAD_URL}${dir}/${src}` chapter.label = chapter.navLabel.text || '' chapter.navId = chapter['$'].id chapter.fileName = fileName chapter.order = index + 1 chapters.push(chapter) }) const chapterTree = [] chapters.forEach(c => { c.children = [] if (c.pid === '') { chapterTree.push(c) } else { const parent = chapters.find(_ => _.navId === c.pid) parent.children.push(c) } }) resolve({chapters, chapterTree}) } else { reject(new Error('目錄解析失敗,目錄數爲0')) } } }) }); } else { } }
電子書樹狀結構解析
<el-tree :data="contentsTree" @node-click="onContentClick" />
點擊打開章節信息
onContentClick(data) { console.log(data) if (data.text) { window.open(data.text) } },
電子書表單驗證
onContentClick(data) { console.log(data) if (data.text) { window.open(data.text) } } onContentClick(data) { console.log(data) if (data.text) { window.open(data.text) } },
submitForm() { if (!this.loading) { this.$refs.postForm.validate((valid, fields) => { console.log(valid) if (valid) { this.loading = true const book = Object.assign({}, this.postForm) delete book.contentsTree if (!this.isEdit) { createBook(book).then(response => { const { msg } = response this.$notify({ title: '添加成功', message: msg, type: 'success', duration: 2000 }) this.loading = false this.setDefault() }).catch(() => { this.loading = false }) } else { // updateBook() } } else { const message = fields[Object.keys(fields)[0]][0].message this.$message({ message, type: 'error' }) } // this.loading = false }) } }
在路由中增長/book/create 接口
router.post('/create',function (req,res,next) { const decode = decoded(req) if (decode && decode.username){ req.body.username = decode.username } const book = new Book(null,req.body) bookService.insertBook(book).then(()=>{ console.log('添加電子書成功') new Result('添加電子書成功').success(res) }).catch(err=>{ next(boom.badImplementation(err)) }) })
解析電子書內容 使用db數據庫插入電子書內容
function insertBook(book) { return new Promise(async (resolve, reject) => { try { if (book instanceof Book) { const result = await exists(book) console.log('電子書----',result) if (result) { await removeBook(book) reject(new Error('電子書已存在')) } else { await db.insert(book.toDb(), 'book') await insertContents(book) resolve() } } else { reject(new Error('添加的圖書對象不合法')) } } catch (e) { reject(e) } }) }
若是電子書存在就刪除這次上傳的 同時查詢是否已插入數據庫 若是已插入則把數據刪除
async function removeBook(book) { if (book){ book.reset() if (book.fileName){ const removeBookSql = `delete from book where filename='${book.fileName}'` const removeContentsSql = `delete from book where filename='${book.fileName}'` await db.querySql(removeBookSql) await db.querySql(removeContentsSql) } } }
修改前端 /router/index中的edit路由 增長fileName路由參數
{ name: 'bookEdit', path: '/book/edit/:fileName', component: () => import('@/views/book/edit'), hidden: true, meta: { title: '編輯圖書', icon: 'edit', roles: ['admin'], activeMenu: '/book/list' } },
經過vue生命週期 created 請求book信息
created() { if (this.isEdit) { const fileName = this.$route.params.fileName this.getBookData(fileName) } } export function getBook(fileName) { return request({ url: '/book/get', method: 'get', params: { fileName: fileName } }) }
在後臺添加路由 /book/get
router.get('/get',function (req,res,next) { const {fileName} = req.query if (!fileName){ next(boom.badRequest(new Error('參數fileName不能爲空'))) }else { bookService.getBook(fileName).then(book=>{ new Result(book,'獲取圖書信息成功').success(res) }).catch(err=>{ next(boom.badImplementation(err)) }) } }) function getBook(fileName){ return new Promise(async (resolve, reject) => { const bookSql = `select * from book where filename='${fileName}'` const contentsSql = `select * from contents where filename='${fileName}' order by \`order\`` const book = await db.queryOne(bookSql) const contents = await db.querySql(contentsSql) if (book){ book.cover = Book.getCoverUrl(book) } console.log('book',book) resolve({book}) }) }
生成ssh公鑰 ssh-keygen -t rsa
拷貝到服務器 ssh-copy-id -i ~/.ssh/id_rsa.pub root@123.56.163.191
修改 ssh配置防止斷鏈 vim /etc/ssh/sshd_config 添加 ClientAliveInterval 30
重啓ssh配置項 restart sshd.service
`curl -o- https://raw.githubusercontent... | bash
`
配置
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion source ~/.bash_profile
安裝node nodejs環境搭建
nvm install node npm install -g cnpm --registry=https://registry.npm.taobao.org
安裝依賴
yum -y install pcre* yum -y install openssl
建立nginx默認配置
cd /usr/share/nginx/ touch nginx.conf
修改nginx 將user改成root 並添加配置文件include /usr/share/nginx/*.conf;
修改主配置文件端口號9000
nginx個性化配置
server { listen 80: server_name localhost; root /usr/share/nginx/upload; autoindex on; add_header Cache-Control "no-cache,must-revalidate"; location / { add_header Access-Control-Allow-Origin *; } }
使用fileZilla 上傳文件到服務器
安裝git
yum install -y git
建立imooc-ebook目錄
`mkdir imooc-ebook
cd imooc-ebook
`
更新git版本
安裝依賴
yum install curl-devel expat-devel gettext-devel openssl-devel zlib-devel asciidoc yum install gcc perl-ExtUtils-MakeMaker wget https://mirrors.edge.kernel.org/pub/software/scm/git/git-2.9.4.tar.xz tar -xvf git-2.9.4.tar.xz cd git-2.9.4 編譯並連接源碼git make prefix=/usr/local/git all make prefix=/usr/local/git install cd /usr/bin ln -s /usr/local/git/bin/git git
git免密配置
ssh-keygen -t rsa -C "243100134_gg"
添加祕鑰到雲服務期
安裝mysql
mysql卸載
mysql安裝
新手必備
wget http://repo.mysql.com/mysql-community-release-el7-5.noarch.rpm # rpm -ivh mysql-community-release-el7-5.noarch.rpm
yum -y install mysql-server
service nysqld restartcat /var/log/mysqld.log |grep password
修改默認密碼
USE mysql ; UPDATE user SET Password = password ( 'new-password' ) WHERE User = 'root' ;
添加3306端口
linux 查找文件夾
find / -name 'admin-vue-imooc-book' -type d
linux 移動文件夾