學一項技術最好的方法就是用這個技術作點什麼。css
學習node的時候,看完一遍以爲本身能打能抗,次日就作回了從前那個少年。惋惜不是張無忌,太極劍法看完忘了就吊打倚天劍。在下看完忘了,那即是忘了。故決定作個項目鞏固一下知識html
先看下部分效果圖前端
整個項目是徹底先後端分離的項目,包含後臺接口,後臺頁面,前端頁面三個倉庫。vue
用戶經過註冊後臺管理員,對店鋪和店鋪食品進行增刪改查操做,相應的店鋪和食品會在前端進行展現。整個後臺項目以egg爲框架,mysql做爲數據庫,用typescript進行開發,涉及數據庫表十一張,接口四十個左右。後臺和前端頁面使用常規的vue+element-ui+vuex+vue-router進行開發。在部署方面,因爲這是我的項目,因此我決定用本身沒有用過的技術,自建了個Jenkins,經過jenkins自動拉取和執行腳本創建Docker鏡像對vue項目進行自動化部署。整個流程對於我的項目還算完整。node
在線地址:mysql
前端地址ios
後端地址nginx
項目參考:git
慕課網餓了嗎課程
程序員
注:對於後臺管理系統,我這邊只參考了 基於 vue + element-ui 的後臺管理系統 的業務邏輯,代碼方面沒有深究,由於用的技術棧不太同樣。因爲這是我第一次用node作項目,日常在公司也沒有用到node,參考了一些零零碎碎的文章,但初學者確定是會有東施效顰的醜態,哪裏作的不合理的還請斧正,程序員最大的優勢的就是知錯就改,我不外乎如是。
總體項目構建能夠參照egg官網提供的教程,裏面有詳細的教程和目錄詳解,這裏不講常規的增刪改查功能,咱們關注整個項目的通用性和比較麻煩的功能實現
/*
* @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繼承於這個基類,這樣後臺開發也方便,前端也能更好的寫一些通用的代碼。
對於不少通用的功能,好比這個項目裏的圖片上傳功能,建立文件夾功能,隨機生成商鋪評分和食品評分等等,這些和業務沒有太大關係又重複的代碼,都是須要作一個封裝以便維護,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下來本身瞅瞅。
後臺管理系統是常規的vue+element-ui,比較常見,這邊就不深刻講,主要講講用戶端的開發思考和遇到的問題
項目使用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
}
}
},複製代碼
這邊我是沿用了我司項目的管理方式,經過功能將接口路由和vuex數據進行了劃分,而後經過一個index.js來向外暴露
有些頁面是須要登陸才能訪問的,這邊在路由守衛這邊也作了限制,只要在路由的 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;
}
}複製代碼
docker run -p 9529:8080 -d --name ele_index_vue ele/index/vue:$image_version;複製代碼
對容器內的端口和宿主機端口作了映射,宿主機訪問9529就能訪問到鏡像的內容。
以上就是項目的簡介,你們感興趣的能夠把項目download下來看一下,須要數據庫表設計的能夠加我一下,我能夠發你,微信:smile_code_0312
github地址:
最後,最近有跳槽的打算,跪求各位大佬介紹,19屆菜雞前端,卑微求職