OJ在线判题系统(一)

OJ在线判题系统(一)

项目设计

### 实现核心

  1. 权限校验:谁能提代码,谁不能提代码
  2. 代码沙箱(安全沙箱)
    1. 隔离藏毒的用户代码
    2. 资源分配-防止用户恶意疯狂占用资源占满内存
  3. 判题规则:判题用例的比对
  4. 任务调度:在服务器资源有限用户要排队的情况下如何按照顺序依次判题,而不是直接拒绝

### 核心业务流程

image.png

不编译不能运行代码

时序图:

按时间顺序、调用的顺序去画

image.png 判题服务:获取题目信息,预计的输入输出结果,返回给主业务后端:用户的答案是否正确

代码沙箱:只负责运行代码,给出结果,不管什么结果是正确的。

功能

  1. 题目模块
    1. 创建题目(管理员)
    2. 删除题目(管理员)
    3. 搜索题目(用户)
    4. 在线做题
    5. 提交题目代码
  2. 用户模块
    1. 注册
    2. 登录
  3. 判题模块
    1. 提交判题(结果是否正确)
    2. 错误处理(内存溢出、安全性、超时)
    3. 自主实现代码沙箱
    4. 开放接口

技术选型

前端:Vue3、Arco Design 组件库、手撸项目模板、在线代码编辑器、在线文档浏览

Java进程控制、Java安全管理器、部分JVM 知识点

虚拟机(云服务器)、Docker(代码沙箱实现)

Spring Cloud 微服务、消息队列、多种设计模式

架构设计

一、你要知道分哪些层;二、你要知道每一层有哪些模块

image.png

主流的OJ系统实现方案

开发原则:能用别人的轮子,就不要造轮子

这里鱼皮走的是自主开发,就不详写了,感兴趣自己看成熟项目Judge0的代码

代码:https://github.com/judge0/judge0

Judge0 APl 地址: https://rapidapi.com/judge0-official/api/judge0-ce

官方文档:https://cejudge0.com/#submissions-submission-post

前后端初始化

前端:

准备工具:nodejs + npm + vue-cli脚手架

image.png

其中vue-cli脚手架安装:

shell
复制代码
npm install -g @vue/cli

其他工具的安装问AI,nvm在这里只起到换nodejs版本的作用,nodejs版本在16/18的可不下nvm

创建项目

shell
复制代码
vue create demo-frontend-name

image.png

Manually select features

image.png

这里多选,用空格选上

其中TypeScript是语言,Router是路由,Vuex是状态管理

image.png

3.x

剩余设置看图片

image.png

先运行试试

  1. 打开package.json点击serve左边的绿色小箭头
  2. 终端输入,我的是yarn serve,具体看你初始化时的提示:

image.png

image.png

这里遇到了问题,vue-router找不到,刚初始化就报错也是难绷

试了两次都出现报错,应该是版本问题

这个是AI修复的回答:

你这个项目“其实已经启动了”,但被 TypeScript 类型检查错误拦住了热更新流程。

核心原因是版本不兼容:

  • 你的项目是老模板(vue@3.2.13 + typescript~4.5.5 + vue-cli 5)
  • 但现在装到的是新版本 vue-router@4.6.4
  • vue-router@4.6.4 的类型声明是 dist/vue-router.d.mts,对你这套旧 TS/解析配置不友好,导致报:
    • TS7016: Could not find a declaration file for module 'vue-router'

另外它还要求 peerDependencies: vue ^3.5.0,也和你当前 vue 3.2.13 不一致。

解决办法:

vue-router 降到和当前项目年代一致的版本:

yarn add vue-router@4.1.6

然后重新启动:

yarn serve

运行成功后继续

前端工程化配置

创建后在webStorm里开启代码美化插件

setting-Languages & Frameworks(语言和框架)-Javascript-Prettier

image.png

在vue文件中执行格式化快捷键Ctrl+Alt+L,不报错,表示配置工程化成功

引入组件

组件库:arco.design/vue

快速上手:arco.design/vue/docs/start

注意:vue版本≥3.2.0

npm install --save-dev @arco-design/web-vue

这里使用全局引入

ts
复制代码
import { createApp } from "vue"; import App from "./App.vue"; import ArcoVue from "@arco-design/web-vue"; import "@arco-design/web-vue/dist/arco.css"; import router from "./router"; import store from "./store"; createApp(App).use(ArcoVue).use(store).use(router).mount("#app");

注:如果出现报错而,有可能是不符合Pretter/ESLint代码规范导致的,按ctrl+Alt+L即可

引入一个组件,如果显示出来,就表示引入成功

用日历组件进行实验(放那个vue都行只是看能不能引入)

vue
复制代码
<template> <a-calendar v-model="value" /> select: {{ value }} </template> <script> import { ref } from "vue"; export default { setup() { const value = ref(new Date("2023-01-01")); return { value, }; }, }; </script>

运行显示日历即可

image-20260502162000906.png

初始化的这些组件(HelloView等)先别删,后面还有用

搞布局

创建文件夹layouts,里面创建vue文件BasicLayout.vue

将这个布局文件引入到App.vue

vue
复制代码
<template> <div id="app"> <BasicLayout /> </div> </template>

引入完成,接下来是敲BasicLayout.vue来实现布局了,先把上中下布局编排好,然后再填充内容。

这里使用arco.design给的布局组件库

image-20260502162601026.png 这里鱼皮是不停调整css样式(比如修改颜色让布局更清晰)的,故只附上最终源码

BasicLayout.vue:

vue
复制代码
<template> <div id="basicLayout"> <a-layout style="height: 400px"> <a-layout-header class="header"> <GlobalHeader></GlobalHeader> </a-layout-header> <a-layout-content class="content"> <router-view /> </a-layout-content> <a-layout-footer class="footer"> <a href="http://yupi.icu.com" target="_blank" >OJ判题系统 by 桃木</a ></a-layout-footer > </a-layout> </div> </template> <script setup lang="ts"> import GlobalHeader from "@/components/GlobalHeader.vue"; </script> <style scoped> #basicLayout { } #basicLayout .header { background: red; margin-bottom: 16px; } #basicLayout .content { background: linear-gradient(to right, #bbb, #fff); margin-bottom: 16px; } #basicLayout .footer { background: #efefef; padding: 14px; position: fixed; bottom: 0; right: 0; left: 0; text-align: center; } </style>

GlobalHeader.vue:

vue
复制代码
<template> <div id="globalHeader"> <a-menu mode="horizontal" :selected-keys="selectedKeys" @menu-item-click="doMenuClick" > <a-menu-item key="0" :style="{ padding: 0, marginRight: '38px' }" disabled > <div class="title-bar"> <img src="@/assets/logo.png" alt="" class="logo" /> <div class="title">桃 OJ</div> </div> </a-menu-item> <a-menu-item v-for="item in routes" :key="item.path"> {{ item.name }} </a-menu-item> </a-menu> </div> </template> <script lang="ts" setup> import { routes } from "../router/routes"; import { useRouter } from "vue-router"; import { ref } from "vue"; const router = useRouter(); //默认主页 const selectedKeys = ref(["/"]); //路由跳转后,更新激活项 router.afterEach((to, from, next) => { selectedKeys.value = [to.path]; }); const doMenuClick = (key: string) => { router.push({ path: key, }); }; </script> <!-- Add "scoped" attribute to limit CSS to this component only --> <style scoped> .title-bar { display: flex; align-items: center; } .title { color: #444; margin-left: 16px; } .logo { height: 48px; } </style>

这时候你会发现报错,这是因为在router-index.ts文档下,鱼皮将里面具体的路由路径与配置做了分离

即:在router文件夹下创建routes.ts,将const routes的内容搬进去

routes.ts:

ts
复制代码
import { RouteRecordRaw } from "vue-router"; export const routes: Array<RouteRecordRaw> = [];

(因为我把初始化自带的组件都删了,所以我这很干净……)

然后在原先的index.ts文件中引入这个routes

text
复制代码
import { routes } from "@/router/routes";

演示一下样式,如果你的routes不为空,组件还没删,能看到顶部的导航栏=。=

image-20260502170920252.png

全局状态管理

工具:vuex

状态管理:所有页面全局共享的变量,而不是仅存在于一个页面中

比如:同步已登录的用户信息

本质上:给你提供了一套增删改查全局变量的API,只不过可能多了一些功能(比如时间旅行)

存放vuex全局管理信息的位置:store-index.ts

state:存储的状态信息,比如用户信息 mutation(尽量同步):定义了对变量进行增删改(更新)的方法 actions(支持异步):执行异步操作,并且触发mutation 的更改(actions调用mutation) modules(模块):把一个大的state(全局变量)划分为多个小模块,比如user专门存用户的状态信息

这里创建user.ts,并在index.ts的modules里定义了user

ts
复制代码
import { createStore } from "vuex"; import user from "@/store/user"; export default createStore({ state: {}, getters: {}, mutations: {}, actions: {}, modules: { user }, });

如果把store比作一个数据库,那么user此刻就是里面的一张表

user.ts:

ts
复制代码
import { StoreOptions } from "vuex"; export default { namespaced: true, state: { loginUser: { userName: "未登录" }, }, getters: {}, actions: { getLoginUser({ commit, state }, payload) { commit("updateUser", { userName: "鱼皮" }); }, }, mutations: { updateUser(state, payload) { state.loginUser = payload; }, }, } as StoreOptions<any>;

然后修改GlobalHeader测试全局管理的效果

在已有的导航栏外添加栅格布局(template)

vue
复制代码
<a-row align="center" style="margin-bottom: 16px" id="globalHeader"> <a-col flex="auto"> <a-menu mode="horizontal" :selected-keys="selectedKeys" @menu-item-click="doMenuClick" > <a-menu-item key="0" :style="{ padding: 0, marginRight: '38px' }" disabled > <div class="title-bar"> <img src="@/assets/logo.jpg" alt="" class="logo" /> <div class="title">派 OJ</div> </div> </a-menu-item> <a-menu-item v-for="item in routes" :key="item.path"> {{ item.name }} </a-menu-item> </a-menu> </a-col> <a-col flex="100px"> <div>{{ store.state.user?.loginUser?.userName ?? "未登录" }}</div> </a-col> </a-row>

在script中使用vuex进行变量的全局管理

vue
复制代码
//使用vuex const store = useStore(); //添加一个3s后启动的方法 setTimeout(() => { store.dispatch("user/getLoginUser",{userName:"鱼皮"}); }, 3000);

用store.dispatch调用action

store.dispatch(模块名.对应的方法,{传入的参数})

运行

image-20260502175047923.png

全局权限管理

目标:能够直接以一套通用的机制,去定义哪个页面需要那些权限。而不用每个页面独立去判断权限,提高效率。 思路:

  1. 在路由配置文件,定义某个路由的访问权限
  2. 在全局页面组件 app.vue 中,绑定一个全局路由监听。每次访问页面时,根据用户要访问页面的路由信息先判断用户是否有对应的访问权限。
  3. 如果有,跳转到原页面;如果没有,拦截或跳转到 401鉴权或登录页

在视图(view)下添加两个页面,分别为AdminView.vueNoAuthView.vue,并配置好路由显示在导航栏上

在路由中用meta定义每个页面需要什么权限

ts
复制代码
import { RouteRecordRaw } from "vue-router"; export const routes: Array<RouteRecordRaw> = [ { path: "/noAuth", name: "无权限", component: () => import("../views/NoAuthView.vue"), }, { path: "/admin", name: "admin", component: () => import("../views/AdminView.vue"), meta: {//注意这个meta access: "canAdmin", }, }, ];

在App.vue中加入跳转逻辑

ts
复制代码
router.beforeEach((to, from, next) => { if (to.meta.access === "canAdmin") { if (store.state.loginUser?.role !== "admin") { next("/noAuth"); return; } } next(); });

效果:点击admin后会跳转无权限页

修改页面布局

  1. 为了修改footer不在最底部的问题
vue
复制代码
<template> <div id="basicLayout"> //该行修改 <a-layout style="min-height: 100vh"> <a-layout-header class="header"> <GlobalHeader></GlobalHeader> </a-layout-header> <a-layout-content class="content"> <router-view /> </a-layout-content> <a-layout-footer class="footer"> <a href="http://yupi.icu.com" target="_blank" >OJ判题系统 by 桃木</a ></a-layout-footer > </a-layout> </div> </template> #basicLayout .footer { background: #efefef; padding: 16px; //该行修改 position: sticky; bottom: 0; right: 0; left: 0; text-align: center; }
  1. 过窄时未登录被挤压到第二行
vue
复制代码
//GlobalHeader.vue //添加wrap //为了下一步的优化布局,删掉style <a-row align="center" id="globalHeader" :wrap="false">
  1. 纯优化布局
css
复制代码
#basicLayout .content { background: linear-gradient(to right, #bbb, #fff); margin-bottom: 16px; //加这行 padding: 20px; } //红色背景早就可以删了,这里改成鱼皮调的阴影 #basicLayout .header { box-shadow: #eee 1px 1px 5px; margin-bottom: 16px; }

通过meta控制页面可见

阶段一 用属性来实现vue页面的展示

逻辑:在meta中添加hideInMenu属性(boolean),然后在vue展示中只展示没有hideInMenu的页面

注意,不要用v-for+v-if去条件渲染元素,这样会先循环所有的元素,导致性能的浪费

方法:先过滤再展示

ts
复制代码
//也是不得已临时创建一个HelloView组件了,引入路径不用模仿我,你们看懂了就行 { path: "/hide", name: "隐藏页面", component: () => import("../views/HelloView.vue"), meta: { hideInMenu: true, }, },

这时候你看到导航栏有更新,这里的任务是只让隐藏页面消失(不要在意那个主页)

image-20260503105905888.png 需要在GlobalHeader引入的路由中加入过滤条件

js
复制代码
const visibleRouter = routes.filter((item, index) => { if (item.meta?.hideInMenu) { return false; } return true; }); const router = useRouter();

在渲染中将渲染范围中routes改为visibleRoutes

vue
复制代码
<a-menu-item v-for="item in visibleRoutes" :key="item.path"> {{ item.name }} </a-menu-item>

完成

image-20260503110435966.png

阶段二 让这个属性的值能按照使用逻辑动态改变
定义权限

创建access文件夹,添加accessEnum和checkAccess

在accessEnum.ts中定义不同权限(类似枚举类的形式)

ts
复制代码
/** * 权限定义 */ const ACCESS_ENUM = { NOT_LOGIN: "notLogin", USER: "user", ADMIN: "admin", }; export default ACCESS_ENUM;
抽离权限检查功能

以后的很多功能需要验证当前用户的权限,故将检验权限功能从App.vue中抽离出来

checkAccess.ts

ts
复制代码
import ACCESS_ENUM from "@/access/accessEnum"; /** * 检查权限(判断当前登录用户是否具有某个权限) * @param loginUser 当前登录用户 * @param needAccess 需要有的权限 * @return boolean 有无权限 */ const checkAccess = (loginUser: any, needAccess = ACCESS_ENUM.NOT_LOGIN) => { // 获取当前登录用户具有的权限(如果没有 loginUser,则表示未登录) //如果loginUser.userRole不存在,则loginUserAccess=ACCESS_ENUM.NOT_LOGIN const loginUserAccess = loginUser?.userRole ?? ACCESS_ENUM.NOT_LOGIN; if (needAccess === ACCESS_ENUM.NOT_LOGIN) { return true; } // 如果用户登录才能访问 if (needAccess === ACCESS_ENUM.USER) { // 如果用户没登录,那么表示无权限 if (loginUserAccess === ACCESS_ENUM.NOT_LOGIN) { return false; } } // 如果需要管理员权限 if (needAccess === ACCESS_ENUM.ADMIN) { // 如果不为管理员,表示无权限 if (loginUserAccess !== ACCESS_ENUM.ADMIN) { return false; } } return true; }; export default checkAccess;
调整路由携带的参数

将meta里的参数修改成ACCESS_ENUM中定义的常量

routes.ts

ts
复制代码
{ path: "/admin", name: "admin", component: () => import("../views/AdminView.vue"), meta: { access: AccessEnum.ADMIN, }, },
修改store存储的参数名称

前面有的写成role了,这里统一一下,注意getLoginUser里payload部分不要写死

user.ts

ts
复制代码
export default { namespaced: true, state: { loginUser: { userName: "未登录", userRole: AccessEnum.NOT_LOGIN }, }, getters: {}, actions: { getLoginUser({ commit, state }, payload) { commit("updateUser", payload); }, }, mutations: { updateUser(state, payload) { state.loginUser = payload; }, }, } as StoreOptions<any>;
修改导航栏组件动态更新
vue
复制代码
const visibleRoutes = computed(() => { return routes.filter((item, index) => { if (item.meta?.hideInMenu) { return false; } //根据权限过滤菜单 if (!checkAccess(store.state.user.loginUser, item.meta?.access as string)) { return false; } return true; }); });

setTimeout这部分注意将role修改成userRole

vue
复制代码
setTimeout(() => { store.dispatch("user/getLoginUser", { userName: "管理员", userRole: "admin", }); }, 3000);

若3s后用户从未登录变成管理员,且出现admin导航栏,说明此功能实现

后端项目初始化

在知识星球中下载后端源码

https://pan.baidu.com/s/1VkrwKe63VF7KLtM_Jd4kdQ?pwd=lcpu

  1. ctrl shift R spring-init修改为yuoj-backend
  2. ctrl shift R springinit 修改为 yuoj
  3. 包名 springinit 修改为yuoj
  4. 模块名 修改为yuoj-backend
  5. 在sql文件夹中运行create_table文件,自己定义数据库
  6. application.yml中配置MySQL
  7. 将server底下的port从8101改为8121(也可以不改但你要记住你和鱼皮后端用的端口号不一样)

运行如果显示找不到springinit就在编辑配置中将爆红的springinit修改为yuoj

image-20260504182302609.png

初始化模板讲解

1)先阅读README.md 2)sql/create_table.sql定义了数据库的初始化建库建表语句

3)sq/post_es_mappingjson 帖子表在 ES 中的建表语句

4)aop:用于全局权限校验、全局日志记录 5)common:万用的类,比如通用响应类 6)config:用于接收application.yml 中的参数,初始化一些客户端的配置类(比如对象存储客户端)

7)constant:定义常量 8)controller:接受请求 9)esdao:类似 mybatis 的mapper,用于操作ES

10)exception:异常处理相关 11)job:任务相关(定时任务、单次任务) 12)manager:服务层(一般是定义一些公用的服务、对接第三方APl等)

13)mapper:mybatis 的数据访问层,用于操作数据库 14)model:数据模型、实体类、包装类、枚举值

15)service:服务层,用于编写业务逻辑

16)utils:工具类,各种各样公用的方法 17)wxmp:公众号相关的包

18)test:单元测试

19)MainApplication:项目启动入口

20)Dockerfile:用于构建Docker镜像

前后端联调

意思是将前端和后端的数据同步起来

  1. 安装请求工具类axios(里面封装的是ajax)npm install axios(axios.get()...axios.post()...)
  2. 编写调用后端的代码

1)

参考这个链接直接生成代码:https://github.com/ferdikoomen/openapi-typescript-codegen

npm install openapi-typescript-codegen --save-dev

后执行

openapi --input http://localhost:8121/api/v2/api-docs --output ./generated --client axios

我在这里访问不到http://localhost:8121/api/v2/api-docs,于是在本地创建json文件转存进去,让openapi依据这个json文件生成代码

这里先修改一下插件的全局配置,在generated-core-OpenAPI.ts中修改:

ts
复制代码
export const OpenAPI: OpenAPIConfig = { //删掉api BASE: "http://localhost:8121", VERSION: "1.0", WITH_CREDENTIALS: false, CREDENTIALS: "include", TOKEN: undefined, USERNAME: undefined, PASSWORD: undefined, HEADERS: undefined, ENCODE_PATH: undefined, };

在user.ts中修改action:

ts
复制代码
actions: { //async:声明这是一个异步函数,内部可以使用 await 等待接口返回。 async getLoginUser({ commit, state }, payload) { //这里调用了刚才通过工具生成的 API 服务(UserControllerService) //它会发起一个 HTTP GET 请求到后端的 /user/get/login(或其他对应路径)接口。 const res = await UserControllerService.getLoginUserUsingGet(); //如果后端返回 0(通常代表操作成功),则调用名为 updateUser 的 Mutation。 if (res.code === 0) { commit("updateUser", res.data); } else { //...state.loginUser:保留原有用户信息(如果有的话)。 //强制将 userRole 字段设置为 AccessEnum.NOT_LOGIN(未登录枚举)。 //配合之前的 checkAccess 权限校验函数,确保页面能正确识别出当前是“游客”身份,从而限制某些敏感操作。 commit("updateUser", { ...state.loginUser, userRole: AccessEnum.NOT_LOGIN, }); } }, },

效果:打开浏览器,在网络请求中看到对后端的请求

image-20260504212242533.png

image-20260504212300532.png

到这里前后端最基本的对接就已经完成了

让我们理一理逻辑:后端提供API接口,我们用一个github插件将api接口快捷生成前端访问后端的业务代码(services文件夹下),然后我们在vuex的user.ts文件中将数据的更新从完全的前端业务转为依据后端返回的结果而更新

因为你是访问的后端,所以考虑到网络波动的影响,它后端服务器未必立刻为你服务,所以你要加async异步访问要加await,毕竟你不太可能前端的访问刚发出,后端的服务器就立马为你返回结果

2)

直接定义axios请求库的全局参数,比如全局请求响应拦截器

创建plugins目录,添加axios.ts文件

ts
复制代码
// Add a request interceptor import axios from "axios"; axios.interceptors.request.use( function (config) { // Do something before request is sent return config; }, function (error) { // Do something with request error return Promise.reject(error); } ); // Add a response interceptor axios.interceptors.response.use( function (response) { console.log("响应", response); // Any status code that lie within the range of 2xx cause this function to trigger // Do something with response data return response; }, function (error) { // Any status codes that falls outside the range of 2xx cause this function to trigger // Do something with response error return Promise.reject(error); } );

在main.ts中引入,添加一行:

ts
复制代码
import "@/plugins/axios";

打开浏览器发现打印:

image-20260504213959170-1777902127895-1-1777902131515-3.png

为什么我看上去没有明写get/post请求,这里却自动对准了http://localhost:8121/api/user/get/login?

因为你写的是拦截器,它捕获到了应用启动时自动发起的“获取当前登录用户”请求

无论是你手动写的axios.get还是插件生成的UserControllerService.getLoginUserUsingGet(),只要底层使用的是同一个Axios包,都会经过定义的拦截器

而根据之前的代码逻辑,getLoginUser会在页面加载时自动执行

于是触发了拦截器

用户登录功能

自动登录

业务逻辑:用户在登进你的网站后你要第一时间访问服务器获取数据并更新页面

  1. 在store-user.ts中编写获取远程登录用户信息的代码(vuex专门管理前端的全局变量)
  2. 在哪里去触发 getLoginUser 函数的执行?应当在一个全局的位置
    1. 可以用路由拦截、全局页面入口app.vue、全局通用布局等多种方式实现
    2. 此处选择第一种方案,可以直接在全局权限管理的路由拦截中判断用户是否已经登录

全局权限管理优化

在access下新建index.ts

将App.vue里的关于用户验证的代码移到index.ts中

App.vue:

ts
复制代码
<template> <div id="app"> <BasicLayout></BasicLayout> </div> </template> <style></style> <script setup lang="ts"> import BasicLayout from "@/layouts/BasicLayout.vue"; import { onMounted } from "vue"; /** * 全局初始化函数,有全局单次调用的代码。都可以写到这里 */ const doInit = () => { console.log("hello 欢迎来到我的项目"); }; onMounted(() => { doInit(); }); </script>

在main.ts中引入access

ts
复制代码
import "@/access";

创建access/index.ts(好吧看样子是重新写了逻辑)

ts
复制代码
import router from "@/router"; import store from "@/store"; import ACCESS_ENUM from "@/access/accessEnum"; import checkAccess from "@/access/checkAccess"; router.beforeEach(async (to, from, next) => { console.log("登录用户信息" + store.state.user.loginUser); const loginUser = store.state.user.loginUser; //如果没登陆,自动登录 //守卫会检查内存中是否有用户信息。如果没有,它会阻塞(await)后续操作,先去调用你之前写的 getLoginUser 接口 //防止用户刷新浏览器时,vuex的状态会被清空 if (!loginUser || !loginUser.userRole) { await store.dispatch("user/getLoginUser"); } //如果没有配置 access,则默认该页面是公共的(NOT_LOGIN) const needAccess = (to.meta?.access as string) ?? ACCESS_ENUM.NOT_LOGIN; //要跳转的页面需要登录 if (needAccess !== ACCESS_ENUM.NOT_LOGIN) { //如果没登录,跳转登录页 if (!loginUser || !loginUser.userRole) { next(`/user/login?redirect=${to.fullPath}`); return; } //已登录但没有权限 if (!checkAccess(loginUser, needAccess as string)) { next("/noAuth"); return; } } // if (to.meta.access === "canAdmin") { // if (store.state.loginUser?.role !== "admin") { // next("/noAuth"); // return; // } // } //如果跳转的页面是公共页面,则直接放行 next(); });

访问http://localhost:8080/admin,重定位到http://localhost:8080/user/login?redirect=/admin即可

image-20260505164631179.png

笔者遇到的问题是无法重定位,依然是/noAuth

这里排查了原因:路由守卫里 await store.dispatch("user/getLoginUser") 之后,代码仍然用的是dispatch 之前的旧 loginUser 常量,导致它被当成“已登录但无权限”(或拿到空用户去做权限判断),于是走了 next("/noAuth")

src/access/index.ts 改成:dispatch 之后重新从 store.state.user.loginUser 取一次最新值,再做登录/权限判断,这样访问 http://localhost:8080/admin 未登录时会正确跳转到:/user/login?redirect=/admin

也就是access/index.ts的代码修改成:

ts
复制代码
import router from "@/router"; import store from "@/store"; import ACCESS_ENUM from "@/access/accessEnum"; import checkAccess from "@/access/checkAccess"; router.beforeEach(async (to, _from, next) => { let loginUser = store.state.user.loginUser; // 如果没登陆,自动拉取登录态(避免刷新后 vuex 清空) if (!loginUser || !loginUser.userRole) { await store.dispatch("user/getLoginUser"); loginUser = store.state.user.loginUser; } // 如果没有配置 access,则默认该页面是公共的(NOT_LOGIN) const needAccess = (to.meta?.access as string) ?? ACCESS_ENUM.NOT_LOGIN; // 要跳转的页面需要登录 if (needAccess !== ACCESS_ENUM.NOT_LOGIN) { // 如果没登录,跳转登录页 if (!loginUser || !loginUser.userRole) { next(`/user/login?redirect=${to.fullPath}`); return; } // 已登录但没有权限 if (!checkAccess(loginUser, needAccess as string)) { next("/noAuth"); return; } } next(); });

在vuex中user.ts文件中的state删去userRole这一项

支持多套布局

  1. 在routes路由文件中添加一套用户路由
ts
复制代码
{ path: "/user", name: "用户", component: () => import("../layouts/UserLayout.vue"), children: [ { path: "/user/login", name: "登录", component: () => import("../views/user/UserLoginView.vue"), }, { path: "/user/register", name: "注册", component: () => import("../views/user/UserRegisterView.vue"), }, ], meta: { hideInMenu: true, },
  1. 新建UserLayout、UserLoginView、UserRegisterView页面,并在routes中引入

  2. 在app.vue根页面文件,根据路由区分多套布局

ts
复制代码
<template v-if="route.path.startsWith('/user')"> <router-view /> </template> <template v-else> <BasicLayout /> </template>

登录页面开发

登陆页面的核心是表单,只需要从组件库中找到表单组件,修改表单字段名称和后端完全匹配就足够了。

看个人审美吧

html
复制代码
<template> <div id="userLoginView"> <h2 style="margin-bottom: 16px">用户登录</h2> <a-form style="max-width: 480px; margin: 0 auto" label-align="left" auto-label-width :model="form" @submit="handleSubmit" > <a-form-item field="userAccount" label="账号"> <a-input v-model="form.userAccount" placeholder="请输入账号" /> </a-form-item> <a-form-item field="userPassword" tooltip="密码不少于 8 位" label="密码"> <a-input-password v-model="form.userPassword" placeholder="请输入密码" /> </a-form-item> <a-form-item> <a-button type="primary" html-type="submit" style="width: 120px"> 登录 </a-button> </a-form-item> </a-form> </div> </template> <script setup lang="ts"> import { reactive } from "vue"; import { UserControllerService, UserLoginRequest } from "../../../generated"; import message from "@arco-design/web-vue/es/message"; import { useRouter } from "vue-router"; import { useStore } from "vuex"; /** * 表单信息 */ const form = reactive({ userAccount: "", userPassword: "", } as UserLoginRequest); const router = useRouter(); const store = useStore(); /** * 提交表单 * @param data */ const handleSubmit = async () => { const res = await UserControllerService.userLoginUsingPost(form); // 登录成功,跳转到主页 if (res.code === 0) { await store.dispatch("user/getLoginUser"); router.push({ path: "/", replace: true, }); } else { message.error("登陆失败," + res.message); } }; </script>

在OpenApi.ts中修改配置

ts
复制代码
export const OpenAPI: OpenAPIConfig = { BASE: "http://localhost:8121", VERSION: "1.0", //改为true WITH_CREDENTIALS: true, CREDENTIALS: "include", TOKEN: undefined, USERNAME: undefined, PASSWORD: undefined, HEADERS: undefined, ENCODE_PATH: undefined, };

不然请求头不带cookie

修改库里的userName,再次登录,会发现用户名同步改变

image-20260505165351286.png

注册页面开发

UserRegisterView.vue

vue
复制代码
<template> <div id="userRegisterView"> <h2 style="margin-bottom: 16px">用户注册</h2> <a-form style="max-width: 480px; margin: 0 auto" label-align="left" auto-label-width :model="form" @submit="handleSubmit" > <a-form-item field="userAccount" label="账号"> <a-input v-model="form.userAccount" placeholder="请输入账号" /> </a-form-item> <a-form-item field="userPassword" tooltip="密码不少于 8 位" label="密码"> <a-input-password v-model="form.userPassword" placeholder="请输入密码" /> </a-form-item> <a-form-item field="checkPassword" tooltip="密码不少于 8 位" label="确认密码" > <a-input-password v-model="form.checkPassword" placeholder="请与密码一致" /> </a-form-item> <a-form-item> <a-button type="primary" html-type="submit" style="width: 120px"> 登录 </a-button> </a-form-item> </a-form> </div> </template> <script setup lang="ts"> import { reactive } from "vue"; import { UserControllerService, UserRegisterRequest } from "../../../generated"; import message from "@arco-design/web-vue/es/message"; import { useRouter } from "vue-router"; /** * 表单信息 */ const form = reactive({ userAccount: "", userPassword: "", checkPassword: "", } as UserRegisterRequest); const router = useRouter(); /** * 提交表单 * @param data */ const handleSubmit = async () => { if (form.userPassword !== form.checkPassword) { message.error("两次输入的密码不一致"); return; } const res = await UserControllerService.userRegisterUsingPost(form); // 注册成功,跳转到主页 if (res.code === 0) { router.push({ path: "/user/login", replace: true, }); } else { message.error("注册失败," + res.message); } }; </script>
0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
桃木
下载 APP