node實戰系列:幫黃老師完善餓了嗎專案
學一項技術最好的方法就是用這個技術做點什麼。
學習node的時候,看完一遍覺得自己能打能抗,第二天就做回了從前那個少年。可惜不是張無忌,太極劍法看完忘了就吊打倚天劍。在下看完忘了,那便是忘了。故決定做個專案鞏固一下知識
先看下部分效果圖
整個專案是完全前後端分離的專案,包含後臺介面,後臺頁面,前端頁面三個倉庫。
使用者通過註冊後臺管理員,對店鋪和店鋪食品進行增刪改查操作,相應的店鋪和食品會在前端進行展示。整個後臺專案以egg為框架,mysql作為資料庫,用typescript進行開發,涉及資料庫表十一張,介面四十個左右。後臺和前端頁面使用常規的vue+element-ui+vuex+vue-router
線上地址:
專案參考:
注:對於後臺管理系統,我這邊只參考了 基於 vue + element-ui 的後臺管理系統 的業務邏輯,程式碼方面沒有深究,因為用的技術棧不太一樣。由於這是我第一次用node做專案,平常在公司也沒有用到node,參考了一些零零碎碎的文章,但初學者肯定是會有東施效顰的醜態,哪裡做的不合理的還請斧正
後臺
所用技術
- Node.js
- Egg
- MySql
- Redis
- TypeScript
實現功能
- 管理員註冊登入
- 新增和修改商鋪
- 新增和修改商鋪食品
- 檢視食品列表
- 檢視商家列表
- 檢視當天資料和整體資料
- 管理員資訊設定
- ...
整體專案構建可以參照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,};
}複製程式碼
- 列舉類
/*
* @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',{
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,// 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+cookie
- token令牌
兩種方式實現的優劣就是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',controller.admin.updateAvatar)
router.post('/api/admin/getAdminCount',controller.admin.getAdminCount)
router.get('/api/admin/findAdminByPage',controller.admin.findAdminByPage)
router.get('/api/admin/totalData',controller.admin.totalData)
router.get('/api/admin/getShopCategory',controller.admin.getShopCategory)
router.get('/api/admin/getCurrentAdmin',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,比較常見,這邊就不深入講,主要講講使用者端的開發思考和遇到的問題
所用技術
- vue
- vuex
- vue-router
- cube-ui
- Axios
- ....
實現功能
- 註冊登入功能
- 使用者地址增刪改查功能
- 商戶列表展示
- 商戶詳情頁展示
- 食品列表
- 食品詳情頁
- 商戶搜尋
- ....
- 移動端佈局方案
專案使用amfe-flexible+px2rem-loader適配移動端。
package.json裡新增
"plugins": {
"autoprefixer": {},"postcss-px2rem": {
"remUnit": 37.5
}
}複製程式碼
- axios做統一請求和攔截
這邊主要是對響應做了攔截,請求發生異常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、401、402等等
if (error && error.response) {
console.log(error.response)
}
return Promise.reject(error)
}
)複製程式碼
- 整合高德地圖api
像這種地址搜尋都是通過呼叫高德地圖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,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
}
}
},複製程式碼
- api、router、vuex統一管理
這邊我是沿用了我司專案的管理方式,通過功能將介面路由和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',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()
}
}
}
}
}
複製程式碼
- 頁面A,B,C之間切換,資料儲存問題
以頁面B為中間頁面,A->B,B頁面應該是全新的頁面,B->C->B,B頁面應該儲存之前的內容,這個專案為例就是地址新增的時候,首次進入新建地址需要全新的頁面,選擇地址過程中跳轉到地址搜尋頁,跳回來之後新增頁面儲存之前填寫的資訊。這種需求之前我是先把B頁面keep-align下來,然後判斷下一個路由的name,看是否需要重置引數,當然這種還是比較low的,這邊提供另外的思路,keep提供了一個include
,只有名稱匹配的元件會被快取,我們通過vuex去動態的去刪減這個變數,就能達到我們想要的效果,如果下一個頁面是地址選擇頁,就把元件快取,否則就刪除這個元件快取。
beforeRouteLeave(to,next) {
console.log('--------------beforeRouteLeave----------')
if (to.name == 'searchAddress') {
this.ADDCACHE('AddAddress')
} else {
this.DELCACHE('AddAddress')
}
next()
},複製程式碼
專案部署
準備工作:
- 申請域名
- 購買個伺服器
- 裝好必備軟體(git、node、mysql、nginx、docker...)
- 做好踩坑的打算...
- 域名和伺服器我這邊都是在阿里雲上買的,比較麻煩的是域名需要備份,要等一陣子,本來我不打算買域名的,但是這樣就會有一個問題,後臺管理系統和前端共用一個ip,這樣cookie會互串,最後還是被迫買了個域名。
- 域名配置,這個需要在阿里雲後臺對你伺服器ip和你的域名進行配置,接下來是nginx配置,有兩個點,首先是訪問域名時將域名指向你的伺服器地址,其次是直接訪問域名時需要將域名改成你的首頁地址
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做對應
- Dockerfile檔案編寫,我只把vue專案做了docker容器化,所以docker容器中需要下載的軟體只有node和nginx,檔案內容如下
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構建,為了方便Jenkins的自動部署,提供了個指令碼檔案
docker run -p 9529:8080 -d --name ele_index_vue ele/index/vue:$image_version;複製程式碼
對容器內的埠和宿主機埠做了對映,宿主機訪問9529就能訪問到映象的內容。
- 安裝Jenkins並和自己的GitHub建立連線。這邊Jenkins也是通過docker來安裝的。
以上就是專案的簡介,大家感興趣的可以把專案download下來看一下,需要資料庫表設計的可以加我一下,我可以發你,微信:smile_code_0312
github地址:
最後,最近有跳槽的打算,跪求各位大佬介紹,19屆菜雞前端,卑微求職