提示:文章寫完后,目錄可以自動生成,如何生成可參考右邊的幫助文檔
文章目錄
- 前言
- 1. 管理員登錄前端
- 1.1 測試
- 1.2 同源策略
- 1.3 修改前端端口號
- 1.4 跨域問題
- 1.5 接收響應數據
- 1.6 js-cookie
- 1.7 錯誤消息提示
- 1.8 優化
- 1.9 響應攔截器
- 1.10 @用法
- 2. 后臺管理-布局
- 2.1 點擊跳轉不同頁面
- 3. 獲取當前用戶信息
- 3.1 數據庫修改
- 3.2 設計
- 3.3 開發后端
- 3.4 開發前端
- 4. 退出登錄
- 4.1 業務分析
- 4.2 后端開發
- 4.3 前端開發
- 5. 前端路由優化
- 5.1 重定向
- 5.2 全局前置守衛
- 5.3 token過期處理
- 總結
前言
1. 管理員登錄前端
1.1 測試
測試一下發現報了這個錯
這個主要是因為我們配置的前置url沒有含有http協議,所以瀏覽器就會自動加上靜態資源的url
const service = axios.create({baseURL: "http://127.0.0.1:19090/system",timeout: 1000,
})
這樣就Ok了
但是又出了一個新的問題
這個就是跨域問題
1.2 同源策略
1.3 修改前端端口號
在vite.config.js里面添加
server: {port: 5555,}
這樣就可以了
1.4 跨域問題
我們可以用一個代理服務器來處理
瀏覽器前端先請求同源的代理服務器,然后代理服務器把請求轉發到后端
因為瀏覽器有同源策略的約束
但是代理服務器是沒有同源策略的約束的
server: {proxy: {"/dev-api": {target: "http://127.0.0.1:19090/system",rewrite: (p) => p.replace(/^\/dev-api/, ""),},},},
還是在vite.config.js里面,這樣設置就可以了
這個就是對代理規則的配置
然后修改request.js里面的前置url
const service = axios.create({baseURL: "/dev-api",timeout: 1000,
})
如果沒有加協議的話,瀏覽器會把前端的url拼接到baseURL上
所以請求地址為
http://localhost:5173/dev-api/sysUser/login
這個不會發生跨域問題
這個是同源的
會報404,找不到嗎
不會
因為配置了代理規則
代理規則就是前綴包含/dev-api的時候,就會把請求轉發到http://127.0.0.1:19090/system
rewrite: (p) => p.replace(/^\/dev-api/, ""),
這個就是把/dev-api變為空的字符串
所以http://localhost:5173/dev-api/sysUser/login就會變為
http://127.0.0.1:19090/system/sysUser/login
先把http://localhost:5173替換為http://127.0.0.1:19090/system,然后去掉/dev-api,就OK了
補充一下,axios可以自動轉換JSON數據,所以那樣寫沒有問題
測試一下也是沒有問題的
1.5 接收響應數據
但是axios這個調用接口的過程是一個異步的過程
往往不那么好拿到返回結果
所以要用await去獲取異步操作結果
但是對應的調用他的函數也要為async
async function loginFun() {const res = await loginService(userAccount.value, password.value);if(res.data.code === 1000){console.log("登錄成功:" , res.data);}else{console.log(res.data.msg)}
}
async 表示這個函數要使用await,await表示調用這個方法接口的時候用異步的方式
console.log(“登錄成功:” , res.data);這里,如果是 console.log(“登錄成功:” +res.data);
那么res.data打印出的內容是object
如果要打印出類的詳細數據的話,還是得用逗號隔開
登錄成功要跳轉頁面
我們用router的push方法就可以了
{path: '/oj/system',name: 'system',component: () => import('../views/System.vue')}
記得還要配置路由
import router from '@/router'
router.push("/oj/system")
這樣就可以跳轉了
1.6 js-cookie
登錄成功以后要存儲token
怎么存儲呢
存儲方式有很多種
比如cookie和local-storige
我們這里使用cookie存儲
js-cookie就是來操作cookie的
npm install js-cookie
在utils下創建cookie.js
import Cookies from "js-cookie";
const TokenKey = "Admin-Oj-b-Token";
export function getToken() {return Cookies.get(TokenKey);
}
export function setToken(token) {return Cookies.set(TokenKey, token);
}
export function removeToken() {return Cookies.remove(TokenKey);
}
這樣就可以了
import { setToken } from '@/utils/cookie'
setToken(res.data.data)
在appication.cookies那里就可以看到我們設置的cookie了
1.7 錯誤消息提示
import { ElMessage } from 'element-plus'
ElMessage.error(res.data.msg)
1.8 優化
<el-input v-model="password" type="password" show-password placeholder="請輸入密碼" />
給按鈕加上 type=“password”
就可以把密碼隱藏起來了
show-password就是顯示是不是顯示小眼睛
1.9 響應攔截器
我們在request.js里面設置
service.interceptors.response.use((res) => {// 未設置狀態碼則默認成功狀態const code = res.data.code;const msg = res.data.msg;if (code !== 1000) {ElMessage.error(msg);return Promise.reject(new Error(msg));} else {return Promise.resolve(res.data);}},(error) => {return Promise.reject(error);}
);
為什么要用響應攔截器呢
因為我們可以用響應攔截器對響應進行攔截,,可以直接返回后端返回的result數據
現在我們就在登錄成功和失敗的情況下分別測試一下
const res = await loginService(userAccount.value, password.value);console.log("登錄成功:" , res.data);
我們可以看出登錄成功返回的數據就是后端返回的result,沒有進行分裝了
這個就是Promise.resolve(res.data)的作用
而Promise.reject(new Error(msg))就是相當于返回一個異常了
直接報錯了
(error) => {return Promise.reject(error);}
這里是屬于錯誤返回,其他的都是正常返回
我們控制臺肯定不能這樣打印的,因為這樣打印就是相當于出錯了
所以我們還要捕獲異常
就和后端捕獲異常是一樣的寫法
async function loginFun() {try{const res = await loginService(userAccount.value, password.value);setToken(res.data.data)router.push("/oj/system")console.log("登錄成功:" , res.data);}catch(err){console.log("登錄失敗:" , err);}
}
這樣就可以了
1.10 @用法
import { setToken } from '@/utils/cookie'
這里的@是什么意思呢
resolve: {alias: {'@': fileURLToPath(new URL('./src', import.meta.url))},},
其實在vite.config.js里面就配置過了
@就是./src,所以很方便使用
相應的router這里的兩點我們也可以改為@
2. 后臺管理-布局
創建一個布局的文件
Layout.vue
<template><el-container class="layout-container"><el-header class="el-header"><el-dropdown><span class="el-dropdown__box"><div><strong>當前用戶:</strong>超級管理員</div><el-icon><ArrowDownBold /></el-icon><!-- <el-icon><Lock /></el-icon> --></span><template #dropdown><el-dropdown-menu><el-dropdown-item @click="logout" :icon="SwitchButton">退出登錄</el-dropdown-item></el-dropdown-menu></template></el-dropdown></el-header><el-main class="layout-bottom-box"><div class="left"><el-aside width="200px" class="el-aside"><el-menu class="el-menu" router><el-menu-item index="/oj/layout/cuser"><el-icon><Management /></el-icon><span>用戶管理</span></el-menu-item><el-menu-item index="/oj/layout/question"><el-icon><Management /></el-icon><span>題目管理</span></el-menu-item><el-menu-item index="/oj/layout/exam"><el-icon><Management /></el-icon><span>競賽管理</span></el-menu-item></el-menu></el-aside></div><div class="right"><RouterView /></div></el-main></el-container>
</template><script setup>
import {Management,ArrowDownBold,Lock,SwitchButton
} from '@element-plus/icons-vue'
</script><style lang="scss" scoped>
.layout-container {height: 100vh;background: #f7f7f7;.layout-bottom-box {display: flex;justify-content: space-between;height: calc(100vh - 100px);overflow: hidden;.left {margin-right: 20px;background: #fff;display: flex;:deep(.el-menu) {flex: 1;.el-menu-item.is-active {color: #32c5ff;}.el-menu-item:hover {background: #fff;color: #32c5ff;}}}.right {flex: 1;overflow-y: auto;background: #fff;padding: 20px;}}.el-aside {background-color: #fff;&__logo {height: 120px;// background: url('@/assets/logo.png') no-repeat center / 120px auto;}.el-menu {border-right: none;}}.el-header {background-color: #fff;display: flex;align-items: center;justify-content: flex-end;height: 40px;.el-dropdown__box {display: flex;align-items: center;.el-icon {color: #4c4141;margin-left: 20px;}&:active,&:focus {outline: none;}}}.el-footer {display: flex;align-items: center;justify-content: center;font-size: 14px;color: #666;}
}
</style>
然后配置路由
{path: '/oj/layout',name: 'layout',component: () => import('@/views/Layout.vue')}
這樣就可以了
現在分析一下
分成了三個部分
我們用的是是這個container布局容器
<el-container class="layout-container"><el-header class="el-header"></el-header><el-main class="layout-bottom-box"><div class="left"><el-aside width="200px" class="el-aside"></el-aside></div><div class="right"><RouterView /></div></el-main></el-container>
這個就是布局
整體結構
dropdown是一個下拉菜單
ArrowDownBold是圖標
import {Management,ArrowDownBold,Lock,SwitchButton
} from '@element-plus/icons-vue'
但是使用圖標要在js里面import
SwitchButton也是圖標
el-aside我們用的是el-menu來寫的
2.1 點擊跳轉不同頁面
<div class="right"><RouterView /></div>
這里就是根據不同url,渲染不同頁面,就是主要的頁面內容
先創建對應的vue文件
然后是配置router
怎么實現點擊切換不url呢
官網的menu組件有一個router屬性
這樣就可以實現點擊切換url了
首先先加上屬性router
因為默認為false,不啟用,加上就啟動了
<el-menu class="el-menu" router>
在el-menu這里加上router屬性,表示啟動router
然后還不行,因為點擊要跳轉到哪里呢,
這下就要設置index了
所以index設置為路徑就可以了
<el-menu-item index="/oj/question">
這樣設置就可以了
但是點擊跳轉直接跳轉到新的頁面了,而不是在那個主頁面展示
為什么會這樣呢
因為我們配置的路徑是/oj/question,與/oj/layout是同一級的
這個路由發生改變之后
會觸發routerview,但是這個觸發的routerview是app.vue那里的
{path: '/oj/layout',name: 'layout',component: () => import('@/views/Layout.vue')},{path: '/oj/cuser',name: 'cuser',component: () => import('@/views/Cuser.vue')},
這里的配置路徑就是同一級的,所以改變路徑的時候就是觸發的同一個routerview,因為這兩個的路徑的配置是類似的,所以layout能觸發那個routerview,為什么cuser不行呢
所以cuser也是用的app.vue的routerview
所以我們需要把cuser的路由配置到layout里面去
因為cuser是在layout內部進行的頁面渲染
因為渲染的順序就是先渲染layout,然后是點擊在layout里面渲染cuser
所以路徑的配置,就必須在layout里面進行配置
所以cuser的路由就是layout下的路由
路由提供了一個child的屬性就可以配置了,這個是數組
{path: '/oj/layout',name: 'layout',component: () => import('@/views/Layout.vue'),children: [{path: '/cuser',name: 'cuser',component: () => import('@/views/Cuser.vue')},{path: '/exam',name: 'exam',component: () => import('@/views/Exam.vue')},{path: '/question',name: 'question',component: () => import('@/views/Question.vue')},]},
這樣就可以了
其中cuser的路徑就是/oj/layout/cuser
會自動加上父組件的路徑的
router.push("/oj/layout")
然后登錄成功的跳轉也要改了
<el-menu-item index="/oj/layout/cuser">
然后這里也要改
但是還是不行
<div class="right"><RouterView /></div>
這里的routerview是二級目錄
這里跳轉顯示的是二級路由,而app.vue里面顯示跳轉的是一級路由
這里的情況是在一級小的頁面里面有二級頁面,所以對應也要在app.vuede的routerview里面在嵌套一個routerview
因為頁面有嵌套關系
所以routerview也要有嵌套關系,路由配置也要有嵌套關系
{path: '/oj/layout',name: 'layout',component: () => import('@/views/Layout.vue'),children: [{path: 'cuser',name: 'cuser',component: () => import('@/views/Cuser.vue')},{path: 'exam',name: 'exam',component: () => import('@/views/Exam.vue')},{path: 'question',name: 'question',component: () => import('@/views/Question.vue')},]},
注意這里的cuser的路徑前面就不要加上/了,因為這樣可能表示是以/cser開頭的,是絕對路徑,如果是二級路徑,就最前面不要加/了
子路由 path 不加 /:路徑會自動拼接父路由路徑,保持嵌套關系(正確用法)。
子路由 path 加 /:路徑被視為絕對路徑,脫離父路由,成為獨立的一級路由(不符合二級路由的設計意圖)。
3. 獲取當前用戶信息
3.1 數據庫修改
給數據庫添加用戶昵稱字段
nick_name varchar(20) not null comment '昵稱',
要么重新創建數據庫
要么用alter
alter table tb_sys_user add nick_name varchar(20) null after user_account ;
update tb_sys_user set nick_name = '超級管理員' where user_account = 'aaa'
[HY000][1366] Incorrect string value: '\xE8\xB6\x85\xE7\xBA\xA7...' for column 'nick_name' at row 1
在update的時候報錯了,這個是因為不支持中文的原因
就是編碼出問題
改一下配置文件就可以了
找到etc/my.cnf
加上配置
character-set-server=utf8mb4
collation-server = utf8mb4_general_ci
保存一下,然后重啟容器生效
但是就算這樣修改了執行還是不行,因為這個表提前就創建好了,編碼已經確定了
所以不行
所以我們要重新創建一個表
但是就算創建一個新的表還是有編碼問題
怎么回事呢
因為數據庫的編碼沒有變
得創建一個新的庫才可以
我們先用root用戶創建新的庫
所以說改了配置文件以后,刪除以前的庫才可以,或者創建新的庫才可以生效
右鍵表的數據,然后生成sql,生成insertSQL就可以保存數據了
這樣就成功了
3.2 設計
3.3 開發后端
@GetMapping("/info")public R<LoginUserVO> info(@RequestHeader(HttpConstants.AUTHENTICATIO) String token){return sysUserService.info(token);}
我們的token直接從header里面獲取就可以了,用的是RequestHeader注解
@Data
public class LoginUserVO {private String nickName;
}
@Data
public class LoginUser {//存儲在redis中的用戶信息private Integer identity;private String nickName;
}
這里也要完善一下,存儲到redis中的基本數據,還有數據庫對應的類也要增加字段,記得還要修改對應的代碼,登錄存儲基本用戶數據的時候記得修改代碼,存儲昵稱
tokenService里面分裝方法
public LoginUser getLoginUser(String token, String secret ) {String userKey = getUserKey(token, secret);if(userKey == null){return null;}String tokenKey = getTokenKey(userKey);return redisService.getCacheObject(tokenKey, LoginUser.class);}private String getTokenKey(String userKey) {return CacheConstants.LOGIN_TOKEN_KEY + userKey;}private String getUserKey(String token, String secret) {Claims claims;try {claims = JwtUtils.parseToken(token, secret); //獲取令牌中信息 解析payload中信息if (claims == null) {log.error("令牌已過期或驗證不正確!");return null;}} catch (Exception e) {log.error("令牌已過期或驗證不正確!e:",e);return null;}return JwtUtils.getUserKey(claims); //獲取jwt中的key}
@Overridepublic R<LoginUserVO> info(String token) {if (StrUtil.isNotEmpty(token) && token.startsWith(HttpConstants.PREFIX)) {token = token.replaceFirst(HttpConstants.PREFIX, StrUtil.EMPTY);}LoginUser loginUser = tokenService.getLoginUser(token,secret);if(loginUser == null){return R.fail();}LoginUserVO loginUserVO = new LoginUserVO();loginUserVO.setNickName(loginUser.getNickName());return R.ok(loginUserVO);}
然后測試一下
del+key可以刪除redis數據
這樣就成功了
這個是根據登錄設置的header自動進行查詢的,不用傳json
然后測試一下延長redis時間的接口也是沒有問題的
3.4 開發前端
export function getUserInfoService(){return service({url: '/sysUser/info',method: 'get'})
}
import { reactive } from 'vue';
import { getUserInfoService } from '@/apis/suser';const loginUser = reactive({nickName: ''
})async function getUserInfo(){const userInfo = await getUserInfoService();loginUser.nickName = userInfo.data.nickName;
}await getUserInfo();
然后再request.js里面定義請求攔截器
//請求攔截器
service.interceptors.request.use((config) => {if (getToken()) {config.headers["Authorization"] = "Bearer " + getToken();}return config;},(error) => {console.log(error)Promise.reject(error);}
);
這個請求攔截器就是攔截每個給后端發起的請求,然后判斷是否有token,有的話,就在請求頭中加上token
<div><strong>當前用戶:</strong>{{loginUser.nickName}}</div>
注意登錄的前端接口寫錯了
改一下為這個樣子
async function loginFun() {try{const res = await loginService(userAccount.value, password.value);setToken(res.data)router.push("/oj/layout")console.log("登錄成功:" , res.data);}catch(err){console.log("登錄失敗:" , err);}
}
這樣就可以了
4. 退出登錄
4.1 業務分析
就是讓token不為空,然后解析一下,解析出來不能執行正常業務了。是可以進行解析的
所以點擊退出登錄,讓redis中的數據不存在就可以了
這樣就可以避免多次登錄,redis中的數據增多了
后端返回請求以后,如果是成功的,前端就清楚存儲的token
所以就是后端清楚redis,前端清楚token
4.2 后端開發
@DeleteMapping("/logout")@Operation(summary = "退出登錄", description = "退出登錄")public R<Void> logout(@RequestHeader(HttpConstants.AUTHENTICATION) String token){log.info("退出登錄...,token:{}", token);return toR(sysUserService.logout(token));}
因為退出登錄后端會刪除redis所以是DeleteMapping
@Overridepublic boolean logout(String token) {if (StrUtil.isNotEmpty(token) && token.startsWith(HttpConstants.PREFIX)) {token = token.replaceFirst(HttpConstants.PREFIX, StrUtil.EMPTY);}return tokenService.deleteLoginUser(token,secret);}
public boolean deleteLoginUser(String token, String secret) {String userKey = getUserKey(token, secret);if(userKey == null){return false;}String tokenKey = getTokenKey(userKey);return redisService.deleteObject(tokenKey);}
然后測試一下
4.3 前端開發
點擊退出登錄的時候會有一個消息彈窗
我們用的就是elementplus的消息彈窗
觀察一下我們可以發現
ElMessageBox.confirm 方法返回一個 Promise 對象。當用戶點擊確認按鈕時,Promise 會進入 resolved 狀態,此時會執行 .then() 中的回調函數;而當用戶點擊取消按鈕或者關閉對話框時,Promise 會進入 rejected 狀態,這時就會執行 .catch() 中的回調函數。
所以說用戶點擊取消按鈕或者關閉對話框時,就是相當于拋出了一個異常,所以彈窗如果后面還有代碼就不會執行了
而點擊了確定按鈕的話(其實點擊什么都是返回Promise ),會返回一個 Promise 對象,返回這個對象是一個異步的過程,所以要await,不然異步的話,就去判斷Promise 對象,可能會判斷失誤
所以說點擊了確定按鈕的話,就會執行彈窗后面的代碼了
export function logoutService(){return service({url: '/sysUser/logout',method: 'delete'})
}
如果是函數拋出異常,也會結束后面代碼執行
async function logout(){await ElMessageBox.confirm('退出登錄','溫馨提示',{confirmButtonText: '確認',cancelButtonText: '取消',type: 'warning',})await logoutService();removeToken();router.push('/oj/login');
}
因為logoutService拋出的異常我們可以直接輸出錯誤,對于異常的情況沒有什么好處理的,就什么都不干就可以了,所以我們不用try和catch
5. 前端路由優化
5.1 重定向
這個的問題是什么呢
就是我們點擊這個地址不用直接到login,而是要手動輸入地址才可以了
什么做到點擊這個http://localhost:5173/,就可以自動跳轉到login呢,這個就要使用重定向了
{path: '/',redirect: '/oj/login'},
這樣配置就可以了
5.2 全局前置守衛
我們要求
未登錄要求不管點擊哪個頁面都要跳回登錄頁面
登錄過后,未過期,點回login自動跳轉功能頁面
登錄過后,點擊login,就直接不用登錄就可以使用功能了
就是要在路由跳轉之前進行判斷處理
誰來進行頁面跳轉呢,就是router,router在哪里呢,就是在index.js里面配置的,所以對router進行配置即可,就是對頁面跳轉之前進行的配置
router.beforeEach((to, from, next) => {if (getToken()) { //已經登陸過/* has token*/if (to.path === '/oj/login') {next({ path: '/oj/layout/question' })} else {next()}} else {if (to.path !== '/oj/login') {next({path:'/oj/login'})} else {next()}}
})
這樣就可以了
to是目的路由
from是源路由
next是真正的目的路由是去哪里
next()就是和to一樣的
這樣我們在沒有登錄的情況下輸入http://localhost:5173/oj/layout
就會自動跳轉為http://localhost:5173/oj/login
登錄下輸入http://localhost:5173/oj/login
就會自動變為http://localhost:5173/oj/layout/question
但是如果登錄狀態過期呢
這個也是和沒有登錄是一樣的,怎么判斷呢,前端是無法判斷token是否過期的
5.3 token過期處理
boolean isLogin = redisService.hasKey(getTokenKey(userKey));if (!isLogin) {return unauthorizedResponse(exchange, "登錄狀態已過期");}
private Mono<Void> unauthorizedResponse(ServerWebExchange exchange, Stringmsg) {log.error("[鑒權異常處理]請求路徑:{}", exchange.getRequest().getPath());return webFluxResponseWriter(exchange.getResponse(), msg,ResultCode.FAILED_UNAUTHORIZED.getCode());}
FAILED_UNAUTHORIZED (3001, "未授權"),
我們可以這樣處理
因為后端對于token過期會報錯的,就在網關中,會報登錄狀態已過期,會報3001的錯誤,,,而且我們還有響應攔截器,所以就可以在響應攔截器中進行處理了,如果過期了就自動跳轉到login
所以說登錄狀態由瀏覽器的token和token是否過期一起決定
我們可以去redis中刪除數據,手動弄為過期
因為過期了,redis就會自動刪除數據,所以我們刪除它,就是模仿的過期
service.interceptors.response.use((res) => {// 未設置狀態碼則默認成功狀態const code = res.data.code;const msg = res.data.msg;if(code === 3001){ElMessage.error(msg);router.push('/oj/login')removeToken();return Promise.reject(new Error(msg));}else if (code !== 1000) {ElMessage.error(msg);return Promise.reject(new Error(msg));} else {return Promise.resolve(res.data);}},(error) => {return Promise.reject(error);}
);
為什么要removeToken呢,因為已經過期了,沒用了,但是不刪掉的話,就可以一直去layout頁面
注意如果是刷新的話,在全局前置守衛中,to就是自身url,而from則是根路徑
這樣就OK了
調試也沒有錯誤