學一項技術最好的方法就是用這個技術作點什麼。javascript
學習node的時候,看完一遍以爲本身能打能抗,次日就作回了從前那個少年。惋惜不是張無忌,太極劍法看完忘了就吊打倚天劍。在下看完忘了,那即是忘了。故決定作個項目鞏固一下知識css
先看下部分效果圖html
整個項目是徹底先後端分離的項目,包含後臺接口,後臺頁面,前端頁面三個倉庫。前端
用戶經過註冊後臺管理員,對店鋪和店鋪食品進行增刪改查操做,相應的店鋪和食品會在前端進行展現。整個後臺項目以egg爲框架,mysql做爲數據庫,用typescript進行開發,涉及數據庫表十一張,接口四十個左右。後臺和前端頁面使用常規的vue+element-ui+vuex+vue-router進行開發。在部署方面,因爲這是我的項目,因此我決定用本身沒有用過的技術,自建了個Jenkins,經過jenkins自動拉取和執行腳本創建Docker鏡像對vue項目進行自動化部署。整個流程對於我的項目還算完整。vue
慕課網餓了嗎課程
基於 vue + element-ui 的後臺管理系統node
注:對於後臺管理系統,我這邊只參考了 基於 vue + element-ui 的後臺管理系統 的業務邏輯,代碼方面沒有深究,由於用的技術棧不太同樣。因爲這是我第一次用node作項目,日常在公司也沒有用到node,參考了一些零零碎碎的文章,但初學者確定是會有東施效顰的醜態,哪裏作的不合理的還請斧正,程序員最大的優勢的就是知錯就改,我不外乎如是。mysql
總體項目構建能夠參照egg官網提供的教程,裏面有詳細的教程和目錄詳解,這裏不講常規的增刪改查功能,咱們關注整個項目的通用性和比較麻煩的功能實現ios
/* * @Descripttion: controller基類 * @version: * @Author: 笑佛彌勒 * @Date: 2019-08-06 16:46:01 * @LastEditors: 笑佛彌勒 * @LastEditTime: 2020-03-09 10:43:37 */ import { Controller } from "egg" export class BaseController extends Controller { /** * @Descripttion: 請求成功 * @Author: 笑佛彌勒 * @param {status} 狀態 * @param {data} 響應數據 * @return: */ success(status: number, message: string, data?: any) { if (data) { this.ctx.body = { status: status, message: message, data: data } } else { this.ctx.body = { status: status, message: message } } } /** * @Descripttion: 失敗 * @Author: 笑佛彌勒 * @param {status} 狀態 * @param {data} 錯誤提示 * @return: */ fail(status: number, message: string) { this.ctx.body = { status: status || 500, message: message, }; }
/* * @Descripttion: 枚舉類 * @version: 1.0 * @Author: 笑佛彌勒 * @Date: 2020-03-14 10:07:36 * @LastEditors: 笑佛彌勒 * @LastEditTime: 2020-03-28 23:02:47 */ export enum Status { Success = 200, // 成功 SystemError = 500, // 系統錯誤 InvalidParams = 1001, // 參數錯誤 LoginOut = 1003, // 未登陸 LoginFail = 1004, // 登陸失效 CodeError = 1005, // 驗證碼錯誤 InvalidRequest = 1006, // 無效請求 TokenError = 1007 // token失效 }
因爲如今公司項目的歷史緣由,後臺返回的響應格式有多種,狀態碼也分散在各處,對前端不是很友好,在這裏我就把整個項目的響應作了封裝,全部的controller繼承於這個基類,這樣後臺開發也方便,前端也能更好的寫一些通用的代碼。nginx
對於不少通用的功能,好比這個項目裏的圖片上傳功能,建立文件夾功能,隨機生成商鋪評分和食品評分等等,這些和業務沒有太大關係又重複的代碼,都是須要作一個封裝以便維護,egg爲咱們提供了很好的helper拓展,在helper拓展中寫的功能,能在項目的全局範圍內經過this.ctx.helper調用,好比生成隨機商鋪銷售量
/** * @Descripttion: 生成範圍內隨機數,[lower, upper) * @Author: 笑佛彌勒 * @param {lower} 最小值 * @param {upper} 最大值 * @return: */ export function random(lower, upper) { return Math.floor(Math.random() * (upper - lower)) + lower; }
在一個請求過程當中就能夠經過egg提供的方法來調用
mon_sale: this.ctx.helper.random(1000, 20000)
對於前端傳參的校驗,若是參數不少,那咱們業務代碼裏面的校驗就會有一大坨關於校驗相關的檢測代碼,好比建立商鋪的時候,前端傳來的相關參數就有十幾個,這種看着仍是挺不爽的,我這邊本身開發的時候把參數校驗經過egg提供的validate作了統一管理,這裏的validate插件須要在啓動的時候本身加載。
/** * @Descripttion: 插件加載完成後加入校驗規則 * @Author: 笑佛彌勒 * @param {type} * @return: */ public async willReady() { const directory = path.join(this.app.config.baseDir, 'app/validate'); this.app.loader.loadToApp(directory, 'validate'); }
加載完以後就能在代碼裏使用自定義規則了,好比這段建立商鋪的代碼裏使用校驗規則,邏輯看起來就比較清晰,不會說看了好久沒看出重點。
public async createMerchants() { let params = this.ctx.request.body console.log(params) try { this.ctx.validate({ params: "addMerchants" }, { params: params }) } catch (error) { this.fail(Status.InvalidParams, error) return } try { await this.ctx.service.merchants.createMerchants(params) this.success(Status.Success, '建立商戶成功') } catch (error) { this.ctx.logger.error(`-----建立商戶錯誤------`, error) this.ctx.logger.error(`入參params:${params}`) this.fail(Status.SystemError, error) } }
登陸註冊功能是一個很常見的功能,邏輯實現上都差很少,首先拿到用戶帳號,查看數據庫裏是否有這條記錄,有則對比密碼是否正確,無則執行新增操做,將用戶密碼進行加密儲存。對於生成的登陸態cookie,這邊是經過egg-jwt插件生成加密串,而後經過redis把加密串存起來,用戶請求須要登陸的接口的時候,後臺會將egg中的cookie取出來和redis中的作對比,作一個登陸態的校驗,這裏有個不一樣的點,egg裏,cookie是以毫秒爲單位的,我沒認真看,致使開發的時候找不到bug的我捏碎了好幾個鼠標,下面是具體的實現邏輯
public async login() { const { ctx } = this let { mobile, password } = this.ctx.request.body try { ctx.validate({ mobile: "mobile" }) ctx.validate({ password: { type: "string", min: 1, max: 10 } }) } catch (error) { this.fail(Status.InvalidParams, error) return } let res = await ctx.service.admin.hasUser(mobile) // 加密密碼 password = utility.md5(password) let token = '' if (!res) { try { await ctx.service.admin.createUser(mobile, password) // 生成token await this.ctx.helper.loginToken({ mobile: mobile, password: password }).then((res) => token = res) // 取到生成token await this.app.redis.set(mobile, token, 'ex', 7200) // 保存到redis ctx.cookies.set('authorization', token, { httpOnly: true, // 默認就是 true maxAge: 1000 * 60 * 60, // egg中是以毫秒爲單位的 domain: this.app.config.env == 'prod' ? '120.79.131.113' : 'localhost' }) // 保存到cookie this.success(Status.Success, '註冊成功') } catch (error) { ctx.logger.error(`-----用戶註冊失敗------`, error) ctx.logger.error(`入參params:mobile:${mobile}、password:${password}`) this.fail(Status.SystemError, "用戶註冊失敗") } } else { if (res.password == password) { await this.ctx.helper.loginToken({ mobile: mobile, password: password }).then((res) => token = res) // 取到生成token await this.app.redis.set(mobile, token, 'ex', 7200) // 保存到redis ctx.cookies.set('authorization', token, { httpOnly: true, // 默認就是 true maxAge: 1000 * 60 * 60, // egg中是以毫秒爲單位的 domain: this.app.config.env == 'prod' ? '120.79.131.113' : 'localhost' }) // 保存到cookie ctx.body = { data: { token, expires: this.config.login_token_time }, code: 1, msg: '登陸成功' } // 返回 this.success(Status.Success, '登陸成功') } else { this.fail(Status.SystemError, "密碼錯誤") } } }
不過這種實現方式仍是有點問題的,用戶驗證主要有兩種方式
兩種方式實現的優劣就是session須要將sessionId保存在服務器,前端傳來的cookie和服務器上存儲的sessionId作對比來實現用戶驗證,而token令牌的驗證方式一般來講就是經過jwt生成加密串,前端請求的時候將加密串傳給後臺,後臺去驗證這個加密串的合法性,jwt方式就是後臺不須要去存儲加密串,而上面這種方式,用jwt生成加密串,再來驗證一遍,是有點奇怪的,我有時間會把他改過來。
開發過程當中不少接口是須要登陸才能訪問的,不可能說在全部須要登陸的接口裏給他加上登陸校驗,咱們能夠爲接口加個中間件,egg是基於洋蔥模型,中間件能在接口訪問前作一些攔截限制。
/* * @Descripttion: 登錄驗證 * @version: 1.0 * @Author: 笑佛彌勒 * @Date: 2019-12-31 23:59:22 * @LastEditors: 笑佛彌勒 * @LastEditTime: 2020-03-28 23:06:09 */ module.exports = (options, app) => { return async function userInterceptor(ctx, next) { let authToken = ctx.cookies.get('authorization') // 獲取header裏的authorization if (authToken) { const res = ctx.helper.verifyToken(authToken) // 解密獲取的Token if (res) { // 此處使用redis進行保存 let redis_token = '' res.email ? redis_token = await app.redis.get(res.email) : redis_token = await app.redis.get(res.mobile) // 獲取保存的token if (authToken === redis_token) { res.email ? app.redis.expire(res.email, 7200) : app.redis.expire(res.mobile, 7200) // 重置redis過時時間 await next() } else { ctx.body = { status: 1004, message: '登陸態失效' } } } else { ctx.body = { status: 1004, message: '登陸態失效' } } } else { ctx.body = { status: 1003, message: '請登錄後再進行操做' } } } }
然後就能夠在須要登陸的路由裏使用
export function admin(app) { const { router, controller } = app const jwt = app.middleware.jwt({}, app) router.post('/api/admin/login', controller.admin.login) router.post('/api/admin/logOut', jwt, controller.admin.logOut) router.post('/api/admin/updateAvatar', jwt, controller.admin.updateAvatar) router.post('/api/admin/getAdminCount', jwt, controller.admin.getAdminCount) router.get('/api/admin/findAdminByPage', jwt, controller.admin.findAdminByPage) router.get('/api/admin/totalData', jwt, controller.admin.totalData) router.get('/api/admin/getShopCategory', jwt, controller.admin.getShopCategory) router.get('/api/admin/getCurrentAdmin', jwt, controller.admin.getCurrentAdmin) router.get('/api/admin/isLogin', controller.admin.isLogin) }
實現方面首先是經過高德提供的api獲取全國全部的城市,而後再根據第三方庫pinyin,將城市首字母提取出來並分類,這邊爲了防止請求次數過多,致使個人服務器ip被高德封掉,將結果用redis儲存起來,redis沒有再去請求數據。
/** * @Descripttion: 獲取全國全部城市 * @Author: 笑佛彌勒 * @param {type} * @return: */ export async function getAllCity() { let url = `https://restapi.amap.com/v3/config/district?keywords=&subdistrict=2&key=44b1b802a3d72663f2cb9c3288e5311e`; var options = { method: "get", url: url, headers: { "Content-Type": "application/json", Accept: "application/json" // 需指定這個參數 不然 在特定的環境下 會引發406錯誤 } }; return await new Promise((resolve, reject) => { request(options, function(err, res, body) { if (err) { reject(err); } else { body = JSON.parse(body); if (body.status == 0) { reject(err); } else { let cityList: Array<Object> = []; getAllCityList(cityList, body.districts); cityList = orderByPinYin(cityList); resolve(cityList); } } }); }); } // 給全國城市根據拼音分組 function orderByPinYin(cityList) { const newCityList: Array<Object> = []; const title = [ "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z" ]; for (let i = 0; i < title.length; i++) { let items: Array<Object> = []; newCityList.push({ name: title[i], items: [] }); for (let j = 0; j < cityList.length; j++) { let indexLetter = pinyin(cityList[j].name.substring(0, 1), { style: pinyin.STYLE_FIRST_LETTER // 設置拼音風格 })[0][0].toUpperCase(); // 提取首字母 if (indexLetter === title[i]) { items.push(cityList[j]); } } newCityList[i]["items"] = items; } return newCityList; } // 遞歸獲取所有城市列表 function getAllCityList(cityList: Array<Object>, parent: any) { let exception: Array<string> = ["010", "021", "022", "023"]; // 四個直轄市另外處理 for (let i = 0; i < parent.length; i++) { if (parent[i].level === "province") { if (exception.includes(parent[i].citycode)) { parent[i].districts = []; parent[i].level = "city"; cityList.push(parent[i]); } else { cityList.push(...parent[i].districts); } } else { getAllCityList(cityList, parent[i].districts); } } }
還有一些功能,感興趣的能夠把項目clone下來本身瞅瞅。
項目使用amfe-flexible+px2rem-loader適配移動端。
package.json裏添加
"plugins": { "autoprefixer": {}, "postcss-px2rem": { "remUnit": 37.5 } }
這邊主要是對響應作了攔截,請求發生異常toast提醒,用戶態異常時跳轉到登陸頁,並添加redirect參數,確保登陸後能返回上一個頁面
// 添加響應攔截器 AJAX.interceptors.response.use( function(response) { const loginError = [10003, 10004] if (loginError.includes(response.data.status)) { router.push({ path: '/vue/login/index.html', query: { redirect: location.href.split('/vue')[1] } }) } else if (response.data.status != 200) { Toast.$create({ time: 2000, type: 'txt', txt: response.data.message }).show() } else { return response.data } }, function(error) { // 對響應錯誤作點什麼,好比400、40一、402等等 if (error && error.response) { console.log(error.response) } return Promise.reject(error) } )
像這種地址搜索都是經過調用高德地圖api返回的數據,這邊經過mixins作了封裝
/* * @Descripttion: 高德地圖mixins * @version: 1.0 * @Author: 笑佛彌勒 * @Date: 2020-01-20 20:41:57 * @LastEditors: 笑佛彌勒 * @LastEditTime: 2020-03-07 21:04:19 */ import { mapGetters } from 'vuex' // 高德地圖定位 export const AMapService = { data() { return { mapObj: '', positionFinallyFlag: false, currentPosition: '正在定位...', // 當前地址 locationFlag: false, // 定位結果 longitude: '', // 經度 latitude: '', // 緯度 searchRes: [] // 搜索結果 } }, computed: { // 當前城市 currentCity() { return this.getCurrentCity() } }, methods: { ...mapGetters('address', ['getCurrentCity']), initAMap() { this.mapObj = new AMap.Map('iCenter') }, // 定位 geoLocation() { const that = this this.initAMap() this.mapObj.plugin('AMap.Geolocation', function() { const geolocation = new AMap.Geolocation({ enableHighAccuracy: true, // 是否使用高精度定位,默認:true timeout: 5000, // 超過5秒後中止定位,默認:無窮大 noIpLocate: 0 }) geolocation.getCurrentPosition((status, result) => { if (status === 'complete') { that.longitude = result.position.lng that.latitude = result.position.lat that.currentPosition = result.formattedAddress that.locationFlag = true } else { that.locationFlag = false that.currentPosition = '定位失敗' const toast = that.$createToast({ time: 2000, type: 'txt', txt: '定位失敗' }) toast.show() } that.positionFinallyFlag = true }) }) }, // 高德地圖搜索服務 searchPosition(keyword) { const that = this AMap.plugin('AMap.Autocomplete', function() { // 實例化Autocomplete var autoOptions = { // city 限定城市,默認全國 city: that.currentCity || '全國', citylimit: false } var autoComplete = new AMap.Autocomplete(autoOptions) autoComplete.search(keyword, function(status, result) { // 搜索成功時,result便是對應的匹配數據 if (status === 'complete' && result.info === 'OK') { that.$nextTick(() => { that.searchRes = [] that.searchRes = result.tips }) } }) }) } } }
這邊還有一個小小的點,咱們將返回的結果根據咱們輸入數據進行高亮,好比上圖我輸入了寶安,結果列表裏寶安進行了高亮,這邊我是用正則匹配了下
filters: { format(text, stress, keyword) { if (stress) { const reg = new RegExp(keyword, 'ig') return text.replace(reg, item => { return `<span style="color:#666">${item}</span>` }) } else { return text } } },
有些頁面是須要登陸才能訪問的,這邊在路由守衛這邊也作了限制,只要在路由的 meat里加上needLogin就能加以控制
router.beforeEach(async(to, from, next) => { // 作些什麼,一般權限控制就在這裏作哦 // 必須寫next()哦,否則你的頁面就會白白的,並且不報錯,俗稱"代碼下毒" if (to.meta.needLogin) { const res = await api.isLogin() if (!res.data) { router.push({ path: '/vue/login/index.html', query: { redirect: to.path.split('/vue')[1] } }) } store.commit('common/SETUSERINFO', res.data || {}) } next() })
項目中的圖標都是引入的阿里矢量圖標,在阿里矢量圖標庫官網裏註冊完帳號後新建一個倉庫,將你須要的圖標都加到你的新建倉庫裏,而後在vue項目中引入在線連接就能直接使用了,沒有很麻煩,甚至都不用花錢。
@font-face { font-family: 'iconfont'; /* project id 1489393 */ src: url('//at.alicdn.com/t/font_1489393_8te3wqguyau.eot'); src: url('//at.alicdn.com/t/font_1489393_8te3wqguyau.eot?#iefix') format('embedded-opentype'), url('//at.alicdn.com/t/font_1489393_8te3wqguyau.woff2') format('woff2'), url('//at.alicdn.com/t/font_1489393_8te3wqguyau.woff') format('woff'), url('//at.alicdn.com/t/font_1489393_8te3wqguyau.ttf') format('truetype'), url('//at.alicdn.com/t/font_1489393_8te3wqguyau.svg#iconfont') format('svg'); } .iconfont{ font-family:"iconfont" !important; font-size:16px;font-style:normal; -webkit-font-smoothing: antialiased; -webkit-text-stroke-width: 0.2px; -moz-osx-font-smoothing: grayscale; }
下拉刷新是最多見的功能,幾乎每一個用到的頁面的邏輯都是同樣的,這邊也作了個封裝,避免重複開發
/* * @Descripttion: 加載更多Mixins * @version: 1.0 * @Author: 笑佛彌勒 * @Date: 2020-01-26 15:39:12 * @LastEditors : 笑佛彌勒 * @LastEditTime : 2020-02-10 23:15:57 */ export default { data() { return { page: 1, pageSize: 20, requireFinallyFlag: true, // 當次請求是否完成 totalPage: 1, allLoaded: false // 數據是否所有加載完成 } }, mounted() { document.addEventListener('scroll', this.handleScroll) }, destroyed() { document.removeEventListener('scroll', this.handleScroll) }, methods: { handleScroll() { const windowHeight = document.documentElement.clientHeight const scrollTop = document.documentElement.scrollTop const bodyHeight = document.body.scrollHeight const totalHeight = parseFloat(windowHeight + scrollTop, 10) // 考慮不一樣瀏覽器的交互,可能頂部條隱藏之類的,致使頁面高度變高 const browserOffset = 60 if (bodyHeight < totalHeight + browserOffset && this.page <= this.totalPage && this.requireFinallyFlag) { this.page++ if (this.page > this.totalPage) { this.allLoaded = true } else { this.requireFinallyFlag = false this.loadingMore() } } } } }
以頁面B爲中間頁面,A->B,B頁面應該是全新的頁面,B->C->B,B頁面應該保存以前的內容,這個項目爲例就是地址添加的時候,首次進入新建地址須要全新的頁面,選擇地址過程當中跳轉到地址搜索頁,跳回來以後新增頁面保存以前填寫的信息。這種需求以前我是先把B頁面keep-align下來,而後判斷下一個路由的name,看是否須要重置參數,固然這種仍是比較low的,這邊提供另外的思路,keep提供了一個include ,只有名稱匹配的組件會被緩存,咱們經過vuex去動態的去刪減這個變量,就能達到咱們想要的效果,若是下一個頁面是地址選擇頁,就把組件緩存,不然就刪除這個組件緩存。
beforeRouteLeave(to, from, next) { console.log('--------------beforeRouteLeave----------') if (to.name == 'searchAddress') { this.ADDCACHE('AddAddress') } else { this.DELCACHE('AddAddress') } next() },
準備工做:
具體步驟:
server{ listen 80; server_name www.smileele.net; rewrite ^/$ http://$host/vue/main/index.html$1 break; location / { proxy_pass http://120.79.131.113:9529/; } }
因爲是http,監聽80端口,訪問www.smileele.net 時改爲 www.smileele.net/vue/main/index.html,www.smileele.net和ip作對應
FROM node:12.14.0 WORKDIR /app COPY package*.json ./ RUN npm install -g cnpm --registry=https://registry.npm.taobao.org RUN cnpm install COPY ./ /app RUN npm run build FROM nginx RUN mkdir /app COPY --from=0 /app/dist /app COPY nginx.conf /etc/nginx/nginx.conf
指定node版本並下載,工做目錄設置爲/app目錄,安裝依賴並打包。下載nginx,將剛纔夠賤的dist裏的內容複製到app目錄下,替換nginx配置目錄。
nginx裏的配置文件以下,跨域也是在這裏解決的
server{ listen 8080; server_name 120.79.131.113; root /app; # 指向目錄 index index.html; location /api { proxy_pass http://120.79.131.113:7001; } location / { index index.html index.htm; try_files $uri $uri/ /index.html; } }
#!/usr/bin/env bash image_version=`date +%Y%m%d%H%M`; # 關閉ele_admin_ts容器 docker stop ele_admin_ts || true; # 刪除ele_admin_ts容器 docker rm ele_admin_ts || true; # 刪除ele/index/vue鏡像 docker rmi --force $(docker images | grep ele/admin/ts | awk '{print $3}') # 構建ele/index/vue:$image_version鏡像 docker build . -t ele/admin/ts:$image_version; # 查看鏡像列表 docker images; # 基於ele/index/vue 鏡像 構建一個容器 ele_admin_ts docker run -p 9528:7001 -d --name ele_admin_ts ele/admin/ts:$image_version; # 查看日誌 docker logs ele_admin_ts; #刪除build過程當中產生的鏡像 #docker image prune -a -f docker rmi $(docker images -f "dangling=true" -q) # 對空間進行自動清理 docker system prune -a -f
對容器內的端口和宿主機端口作了映射,宿主機訪問9529就能訪問到鏡像的內容。
以上就是項目的簡介,你們感興趣的能夠把項目download下來看一下,須要數據庫表設計的能夠加我一下,我能夠發你,微信:smile_code_0312
最後,最近有跳槽的打算,跪求各位大佬介紹,19屆菜雞前端,卑微求職