跳至主要內容

OJ 项目构建

holic-x...大约 28 分钟项目oj-platform

02-项目构建

前端通用模板构建

环境依赖

推荐环境:

nodejs:V18.16.0 或者 16(node -v);切换和管理 node 版本的工具:https://github.com/nvm-sh/nvm

npm 版本:9.5.1(npm -v)

本地环境:

node:v20.11.1

npm:10.2.4

vue:@vue/cli 5.0.8

初始化

# 安装脚手架工具
npm install -g @vue/cli

# 检查安装是否成功(如果找不到命令则重装npm配置环境变量)
vue -V

# 创建vue项目(选择自定义配置:构建)
vue create oj-platform-frontend
- Manually select features

image-20240422213157076

image-20240422213314559

​ 运行项目:npm run serve

image-20240422214213994

​ 脚手架已经配置了代码美化、自动校验、格式化插件等,无需再自行配置,但是需要在 webstorm 里开启代码美化插件(Settings=》搜索prettier=》修改插件生效文件(Run for files))

image-20240422214417573

image-20240422214600707

​ 在vue文件中执行格式化快捷键(ctrl+Alt+Shift+L),不报错则表示配置工程化成功(基于WebStorm)

​ 此外脚手架还自动整合了vue-router

image-20240421233023322

自行整合参考:

代码规范:https://eslint.org/docs/latest/use/getting-started

代码美化:https://prettier.io/docs/en/install.html

直接整合:https://github.com/prettier/eslint-plugin-prettier#recommended-configuration(包括了https://github.com/prettier/eslint-config-prettier#installation)

Vue Router 路由组件已自动引入,无需再引入:https://router.vuejs.org/zh/introduction.html

组件库:https://arco.design/vue

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

执行安装:

# 组件引入
npm install --save-dev @arco-design/web-vue

# 测试引入是否成功,在main.js文件中随便引入一个组件看是否成功
import ArcoVue from '@arco-design/web-vue';

查看官方文档:手动引入,此处选择完整引入Arco Design Vueopen in new window,在main.ts文件中修改配置

image-20240422214845813

import { createApp } from 'vue'
import ArcoVue from '@arco-design/web-vue';
import App from './App.vue';
import '@arco-design/web-vue/dist/arco.css';

const app = createApp(App);
app.use(ArcoVue);
app.mount('#app');

image-20240422215048592

1.项目通用布局构建

​ 先把上中下整体布局构件好,然后再在对应的文件中填充各个布局的组件定义

构建布局

新建布局BasicLayout.vue,并在App.vue中引入

<template>
  <router-view />
</template>
<style></style>
<template>
  <div id="app">
    <BasicLayout />
  </div>
</template>

<style>
#app {
}
</style>

<script>
import BasicLayout from "@/layouts/BasicLayout.vue";

export default {
  components: { BasicLayout },
};
</script>

​ 选用 arco design 的layout 组件(https://arco.design/vue/component/layout)先把上中下布局编排好,然后再填充内容:

image-20240422220100182

BasicLayout.vue

<template>
  <div id="basicLayout">
    <a-layout style="height: 400px">
      <a-layout-header class="header">导航栏</a-layout-header>
      <a-layout-content class="content">
        <!--        <router-view />-->
        <h1>页面定义</h1>
      </a-layout-content>
      <a-layout-footer class="footer">
        <a href="http://blog.holic-x.com"> 一人の境 </a>
      </a-layout-footer>
    </a-layout>
  </div>
</template>
<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: #42b983;
  padding: 16px;
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  text-align: center;
}
</style>
<script setup lang="ts"></script>

新建组件GlobalHeader.vue(初始内容参考如下),并在BasicLayout.vue中引入

<template>
  <div id="globalHeader">

  </div>
</template>

<!-- 设置setup vue3写法 -->
<script setup lang="ts">

</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>

</style>

引入菜单Menuopen in new window

image-20240422221837808

<template>
  <div id="globalHeader">
    <a-menu mode="horizontal" :default-selected-keys="['1']">
      <a-menu-item
        key="0"
        :style="{ padding: 0, marginRight: '38px' }"
        disabled
      >
        <div
          :style="{
            width: '80px',
            height: '30px',
            borderRadius: '2px',
            background: 'var(--color-fill-3)',
            cursor: 'text',
          }"
        />
      </a-menu-item>
      <a-menu-item key="1">Home</a-menu-item>
      <a-menu-item key="2">Solution</a-menu-item>
      <a-menu-item key="3">Cloud Service</a-menu-item>
      <a-menu-item key="4">Cooperation</a-menu-item>
    </a-menu>
  </div>
</template>

<!-- 设置setup vue3写法 -->
<script setup lang="ts"></script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.globalHeader {
  box-sizing: border-box;
  width: 100%;
  padding: 40px;
  background-color: var(--color-neutral-2);
}
</style>

image-20240422222140232

​ 引入logo:在src下的assets文件夹放入自定义logo(例如oj-logo.png)

		<div class="title-bar">
          <img class="logo" src="../assets/oj-logo.png" />
          <div>Noob OJ</div>
        </div>

<style scoped>
.title-bar {
}
</style>

image-20240422222939685

前端调试样式小技巧,选择元素=》右键点击【检查】调出开发者工具栏

image-20240422223757133

然后调整title样式,最终效果参考

<template>
  <div id="globalHeader">
    <a-menu mode="horizontal" :default-selected-keys="['1']">
      <a-menu-item
        key="0"
        :style="{ padding: 0, marginRight: '38px' }"
        disabled
      >
        <div class="title-bar">
          <img class="logo" src="../assets/oj-logo.png" />
          <div class="title">Noob OJ</div>
        </div>
      </a-menu-item>
      <a-menu-item key="1">Home</a-menu-item>
      <a-menu-item key="2">Solution</a-menu-item>
      <a-menu-item key="3">Cloud Service</a-menu-item>
      <a-menu-item key="4">Cooperation</a-menu-item>
    </a-menu>
  </div>
</template>

<!-- 设置setup vue3写法 -->
<script setup lang="ts"></script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.globalHeader {
  box-sizing: border-box;
  width: 100%;
  padding: 40px;
  background-color: var(--color-neutral-2);
}

.title-bar {
  display: flex;
  align-items: center;
}

.title {
  color: black;
  margin-left: 16px;
}
</style>

image-20240422224059438

2.路由配置

​ 路由调整:将菜单改成根据文件路由动态整成

​ 菜单组件:https://arco.design/vue/component/menu目标:根据路由配置信息,自动生成菜单内容。实现更通用、更自动的菜单配置

构建步骤

1)提取通用路由文件(routes.ts)

2)菜单组件读取路由,动态渲染菜单项

3)绑定跳转事件

4)同步路由的更新到菜单项高亮

同步高亮原理:首先点击菜单项 =>触发点击事件,跳转更新路由 =>更新路由后,同步去更新菜单栏的高亮状态。 使用 Vue Router 的 afterEach 路由钩子实现:

image-20240422225114303

步骤1:

新建routes.ts文件:将routes内容导出来(通用routes内容)

image-20240422225457203

步骤2:

​ 菜单组件读取路由,动态渲染菜单项(将原有写死的静态a-menu-item配置借助v-for遍历渲染)

​ GlobalHeader.vue中配置:


<a-menu-item v-for="item in routes" :key="item.path">{{item.name}}</a-menu-item>

<!-- 设置setup vue3写法 -->
<script setup lang="ts">
import { routes } from "../router/routes";
</script>

​ 基于此步骤,可以看到菜单被正常加载

步骤3

绑定跳转事件

image-20240422232401584

image-20240422232429997

​ 调整完成,随后可看到菜单点击切换效果

步骤4

4)同步路由的更新到菜单项高亮

​ 即怎么通过路由=》激活相应的菜单(根据页面地址跳转到页面,激活菜单显示)

<a-menu
      mode="horizontal"
      :selectedKeys="selectedKeys"
      @menu-item-click="doMenuClick"
    >
      <a-menu-item v-for="item in routes" :key="item.path"
        >{{ item.name }}
      </a-menu-item>
    </a-menu>



<script setup lang="ts">
import { useRouter } from "vue-router";
import { ref } from "vue";
    
// 默认主页
const selectedKeys = ref(["/"]);
// 路由跳转后,更新选中的菜单项
router.afterEach((to, from, failure) => {
  selectedKeys.value = [to.path];
});
</script>

image-20240422233403195

3.全局状态管理

vuex:https://vuex.vuejs.org/zh/guide/(vue-cli 脚手架已自动引入)

什么是全局状态管理?

所有页面全局共享的变量,而不是局限在某一个页面中。

适合作为全局状态的数据:已登录用户信息(每个页面几乎都要用)Vuex 的本质:给你提供了一套增删改查全局变量的 API,只不过可能多了一些功能(比如时间旅行)

image-20240422233557715

可以直接参考购物车示例:https://github.com/vuejs/vuex/tree/main/examples/classic/shopping-cart

state:存储的状态信息,比如用户信息

mutation(尽量同步):定义了对变量进行增删改(更新)的方法

actions(支持异步):执行异步操作,并且触发mutation 的更改(actions 调用 mutation)

modules(模块):把一个大的 state(全局变量)划分为多个小模块,比如 user 专门存用户的状态信息

【1】在store文件中创建user模块

// initial state
import { StoreOptions } from "vuex";

export default {
  namespaced: true,
  state: () => ({
    loginUser: {
      userName: "未登录",
    },
  }),
  actions: {
    async getLoginUser({ commit, state }, payload) {
      // todo 调整为远程登录获取用户信息并设置全局参数
      commit("updateUser", { userName: "noob" });
    },
  },
  mutations: {
    updateUser(state, payload) {
      state.loginUser = payload;
    },
  },
} as StoreOptions<any>;

【2】在store/index.ts中引入user模块

import { createStore } from "vuex";
import user from "@/store/user";

export default createStore({
  state: {},
  getters: {},
  mutations: {},
  actions: {},
  modules: {
    user,
  },
});

【3】栅格布局改造,显示用户登录信息

<a-col flex="100px">
	<div>{{ store.state.user?.loginUser.userName }}</div>
     /* <div>{{ store.state.user?.loginUser?.userName ?? "尚未登录" }}</div> */
</a-col>

<script setup lang="ts">
import { useStore } from "vuex";
// 获取全局变量
const store = useStore();
</script>

image-20240422234714903

在vue页面中可以修改状态变量,使用dispatch来调用之前定义好的actions(例如此处设定一个定时器,模拟修改登录用户信息,其效果为访问页面3s后可以修改登录用户信息

setTimeout(() => {
  store.dispatch("user/getLoginUser", {
    userName: "哈哈",
  });
}, 3000);

4.权限管理

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

思路:

1.在路由配置文件,定义某个路由的访问权限

2.在全局页面组件 app.vue 中,绑定一个全局路由监听。每次访问页面时,根据用户要访问页面的路由信息先判断用户是否有对应的访问权限

3.如果有,跳转到原页面;如果没有,拦截或跳转到 401鉴权或登录页

构建参考

类似的,参考前面的路由定义,根据路由校验用户权限。因此需要定义一个全局参数,存储用户权限。

【1】创建多个页面用于校验不同权限页面访问

# 创建AdminView
<template>
  <div class="home">管理员才有的访问权限</div>
</template>


# 创建NoAuthView
<template>
  <div class="home">无权访问!!!!401</div>
</template>

【2】添加路由配置,并为路由配置指定meta属性(设定该路由可访问的权限)

# 此处为指定路由(/admin)配置了meta属性设置路由权限

import { RouteRecordRaw } from "vue-router";
import HomeView from "@/views/HomeView.vue";

export const routes: Array<RouteRecordRaw> = [
  {
    path: "/",
    name: "home",
    component: HomeView,
  },
  {
    path: "/about",
    name: "about",
    component: () =>
      import(/* webpackChunkName: "about" */ "../views/AboutView.vue"),
  },
  {
    path: "/admin",
    name: "管理员",
    component: () =>
      import("../views/AdminView.vue"),
    meta:{
      access: "canAdmin"
    }
  },
  {
    path: "/noAuth",
    name: "无权限访问",
    component: () =>
      import("../views/NoAuthView.vue"),
  },
];

【3】定义全局变量存储权限信息(用户的权限信息是在登录的时候保存的,因此此处在user.ts中模拟用户权限:设置loginUser中的role属性)

// initial state
import { StoreOptions } from "vuex";

export default {
  namespaced: true,
  state: () => ({
    loginUser: {
      userName: "未登录",
      role: "admin"
    },
  }),
  actions: {
    async getLoginUser({ commit, state }, payload) {
      // todo 调整为远程登录获取用户信息并设置全局参数
      commit("updateUser", { userName: "noob" });
    },
  },
  mutations: {
    updateUser(state, payload) {
      state.loginUser = payload;
    },
  },
} as StoreOptions<any>;

【4】App.vue中根据路由鉴权

<script setup lang="ts">
import BasicLayout from "@/layouts/BasicLayout.vue";
import { useRouter } from "vue-router";
import { useStore } from "vuex";

// 权限拦截校验
const router = useRouter();
const store = useStore();

router.beforeEach((to, from, next) => {
  console.log(to);
  // 仅限制管理员可见,判断当前用户是否有权限
  if (to.meta?.access === "canAdmin") {
    // 校验用户权限
    if (store.state.user.loginUser?.role !== "admin") {
      // 无权访问,跳转到无权访问页面
      next("/noAuth");
      return;
    }
  }
  next();
});
</script>

​ 如果出现下保存信息,需要调整script的写法:

image-20240423203405850

【5】测试

​ 测试过程中需要注意取消之前的模拟登录(定时3s模拟登录)

​ 设定loginUser的登录权限为user,然后访问测试可以看到当访问管理员页面的时候,无权访问自动跳转到无权访问页面

image-20240423204250773

5.通用导航栏优化

控制路由显隐

【1】给路由新增一个标志位,用于判断路由是否显隐

# 1.新建一个hide页面
<template>
  <div class="home">这是一个隐藏页面</div>
</template>
# 2.在routes.ts中配置页面路由,并指定meta参数配置(自定义一个hideInMenu参数)

import { RouteRecordRaw } from "vue-router";

export const routes: Array<RouteRecordRaw> = [
  {
    path: "/hideMenu",
    name: "隐藏页面",
    meta:{
      hideInMenu:true,
    },
    component: () => import("../views/HideView.vue"),
  },
];

【2】路由渲染的时候根据meta.hideMenu参数来决定是否要显示页面信息

# 3.修改GlobalHeader.vue,调整路由菜单生成规则
// 原有实现参考
<a-menu-item v-for="item in routes" :key="item.path">{{ item.name }}</a-menu-item>

不要用v-for + v-if去条件渲染元素(错误示例参考如下所示),这样会先循环所有的元素,导致性能的浪费(推荐:先去过滤要展示的元素)

​ 此处涉及到一个v-for、v-if的渲染优先级,v-for先渲染,例如要渲染1000个路由(里面只有1个要隐藏),这种写法会先把1000个路由渲染出来然后再去判断是否隐藏

image-20240423211629438

优化参考:如果要渲染1000个路由(里面只有1个要隐藏),则先把999过滤掉然后再执行渲染操作

// 1.定义显示在菜单的路由数组(过滤隐藏的路由)
const visibleRoutes = routes.filter((item, index) => {
  if (item.meta?.hideInMenu) {
    return false;
  }
  return true;
});

// 2.遍历渲染过滤后的路由数组[visibleRoutes]
<a-menu-item v-for="item in visibleRoutes" :key="item.path">{{item.name}}</a-menu-item>

根据权限隐藏菜单

需求:只有具有权限的菜单,才对用户可见

原理:类似上面的控制路由显示隐藏,只要判断用户没有这个权限,就直接过滤掉

const visibleRoutes = routes.filter((item, index) => {
  if (item.meta?.hideInMenu) {
    return false;
  }
  // todo:根据用户权限进一步过滤菜单
  return true;
});

6.全局权限管理(公共方法抽离)

​ 基于上述内容,单独将权限这块内容抽离出来(在src下创建一个access文件夹)

构建步骤

【1】定义权限(确认系统有哪些角色访问权限):创建accessEnum.ts文件

/**
 * 权限定义
 */
const ACCESS_ENUM = {
  NOT_LOGIN: "notlogin",
  USER: "user",
  ADMIN: "admin",
};

export default ACCESS_ENUM;

【2】定义公共的权限校验方法

​ 因为菜单组件中要判断权限、权限拦截也要用到权限判断功能,所以抽离成公共方法。创建checkAccess.ts文件,专门定义检测权限的函数:(此处注意校验的是userRole参数)

import ACCESS_ENUM from "@/access/accessEnum";

/**
 * 检查权限(判断当前登陆用户是否具有登陆权限)
 * @param loginUser 当前登陆用户
 * @param needAccess 需要有的权限
 * @return boolean 有无权限
 */
const checkAccess = (loginUser: any, needAccess = ACCESS_ENUM.NOT_LOGIN) => {
  //获取当前登陆用户具有的权限(如果没有 loginUser,则表示未登录)
  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;

【3】修改GlobalHeader 动态菜单组件,根据权限来过滤菜单(computed 动态计算,会联动变更visibleRoutes的值;类似watch监听效果)

​ 注意,这里使用计算属性,是为了当登陆用户信息发生变更时,触发菜单栏的重新渲染,展示新增权限的菜单项

import { computed } from "vue";
import checkAccess from "@/access/checkAccess";

// 定义显示在菜单的路由数组(过滤隐藏的路由)
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;
  });
});

【4】调整rotues.ts、user.ts、App.vue中的校验字段 (确保菜单路由配置和角色权限对照:使用枚举关联,避免硬编码)

user.ts:调整登录用户角色判断字段(loginUser.userRole),设置actions配置

// initial state
import { StoreOptions } from "vuex";
import accessEnum from "@/access/accessEnum";

export default {
  namespaced: true,
  state: () => ({
    loginUser: {
      userName: "未登录",
      userRole: accessEnum.NOT_LOGIN,
    },
  }),
  actions: {
    async getLoginUser({ commit, state }, payload) {
      // todo 调整为远程登录获取用户信息并设置全局参数
      // 根据传递的值设置登录用户信息({userName: "哈哈",userRole: accessEnum.ADMIN})
      commit("updateUser", payload);
    },
  },
  mutations: {
    updateUser(state, payload) {
      state.loginUser = payload;
    },
  },
} as StoreOptions<any>;

import { RouteRecordRaw } from "vue-router";
import HomeView from "@/views/HomeView.vue";
import accessEnum from "@/access/accessEnum";

export const routes: Array<RouteRecordRaw> = [
  {
    path: "/",
    name: "home",
    component: HomeView,
  },
  {
    path: "/about",
    name: "about",
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () =>
      import(/* webpackChunkName: "about" */ "../views/AboutView.vue"),
  },
  {
    path: "/admin",
    name: "管理员",
    component: () => import("../views/AdminView.vue"),
    meta: {
      access: accessEnum.ADMIN,
    },
  },
  {
    path: "/noAuth",
    name: "无权限访问",
    component: () => import("../views/NoAuthView.vue"),
  },
  {
    path: "/hideMenu",
    name: "隐藏页面",
    meta: {
      hideInMenu: true,
    },
    component: () => import("../views/HideView.vue"),
  },
];

image-20240423222227194image-20240423222501047

【5】测试:GlobalHeader.vue开启定时器模拟登录,默认是未登录状态,定时器启动触发action修改登录状态随后再测试菜单

setTimeout(() => {
  store.dispatch("user/getLoginUser", {
    userName: "哈哈",
    userRole: accessEnum.ADMIN,
  });
}, 3000);

image-20240423222626193

7.全局路由入口

修改App.vue:app.vue 中预留一个可以编写全局初始化逻辑的代码

import { onMounted } from "vue";
const doInit = () => {
  console.log("hello 欢迎来到我的项目");
};

onMounted(() => {
  doInit();
});
  1. 完善前端通用项目的模板
  2. 后端项目初始化(万用模版)
  3. 前端接口调用代码的自动生成(通用的一个代码生成插件)
  4. 前后端联调
  5. 快速写一遍登陆注册页

后端通用模板构建

模板初始化&构建配置

(1)从代码库下载 springboot-react-init 万用模板中的noob-backend部分(已经在本地则直接复制)

(2)ctrl+shift+R 全局替换 项目名 为项目名(oj-platform-backend)

(3)全局替换noob包名为新的包名(可自定义选择:例如noj)

(4)修改 noob文件夹的名称为新的包名对应的名称(noj)

(5)本地新建数据库,直接执行 sql/sr_init_base_V2.sql 脚本,修改库名为 oj_platform,执行即可

(6)改 application.yml配置,修改 MySQL数据库的连接库名、账号密码,端口号

全局搜索noob-backend相关配置,然后替换为自己的项目配置

image-20240423082853991

启动测试

​ 配置构建完成,启动项目,访问接口文档:http://localhost:8101/api/doc.html#/home

​ 查看接口文档是否正常构建

image-20240423082411350

​ 注册用户、添加用户,然后访问接口进行调试确认功能

清理项目代码结构

​ module下有base、dataInfo、template版块,其中模块的内容是用作开发模板参考的,可以适当清理和项目无关的内容,简化项目结构

​ 例如此处提供的文章管理(文章信息管理、点赞/收藏管理等内容),第三方登陆服务接口(WX登陆相关)、ES操作(文章部分涉及,可以结合项目功能涉及选择是否保留)

  • 清理module/base:post、postfavour、postthumb相关内容
    • 如果清理了帖子相关内容,对应framework框架中涉及到的es同步任务也需要调整(此处仅保留定时认为基础设定,其内容暂不实现)
    • 且对应的test测试模块中相关的测试用例也要清理
    • resource/mapper:清理对应mapper映射文件配置
  • 数据管理、模板管理(可作为后端接口开发模板参考,结合自身需求考虑是否保留)

代码结构清理完成,最终只保留用户管理、文件管理、模板管理、数据管理这四个版块的内容,基于实例接口构建项目业务接口实现

前后端联调

1.axios联调

构建步骤说明

【1】前端引入axios、编写调用后端代码

【2】后端启动联调测试

1)前端引入axios

前端安装请求工具类:Axiosopen in new window

npm install axios

2)编写调用后端代码

传统情况下,每个请求都要单独编写代码。至少得写一个请求路径

可以引入自动生成组件进行构建(基于openapi规范,参考react中openapi的使用)

基于openapi的接口生成:openapi-typescript-codegenopen in new window

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

执行指令生成代码:

# 参考指令说明
openapi --input [API-URL] --output ./generated --client axios

3)前端直接使用生成的service代码,直接调用函数发送请求(此处的文档地址对应的是后端的文档生成地址)

image-20240423224644842image-20240423225025967

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

​ 构建完成,可以看到对应目录生成了接口服务,可能需要一键美化代码格式

image-20240423224843221image-20240423225440764

​ 测试:

(1)在store/user.ts中配置从远程请求获取登录用户信息

// 从远程请求获取登录信息
      const res = await UserControllerService.getLoginUserUsingGet();
      if (res.code === 0) {
        commit("updateUser", res.data);
      } else {
        commit("updateUser", {
          ...state.loginUser,
          userRole: ACCESS_ENUM.NOT_LOGIN,
        });
      }

image-20240424061737691

(2)解开GlobalHeader.vue中模拟登录触发的定时器(setTimeout),让它触发actions操作

image-20240423230048575

​ 多了个前缀,修改一下BASE即可:

image-20240423230215195

​ 修改完成,刷新页面正常调用接口(先忽略请求响应内容,关注是否正常调通后端接口即可)

image-20240423230323999

2.自定义请求处理

如果是自定义请求如何处理:

1)使用代码生成器提供的全局参数修改对象,参考官方文档说明(generated/core/OpenAPI.ts)

export const OpenAPI: OpenAPIConfig = {
  BASE: "http://localhost:8101",
  VERSION: "1.0",
  WITH_CREDENTIALS: false,
  CREDENTIALS: "include",
  TOKEN: undefined,
  USERNAME: undefined,
  PASSWORD: undefined,
  HEADERS: undefined,
  ENCODE_PATH: undefined,
};

image-20240423231856204

2)直接定义axios请求库的全局参数(全局请求响应拦截器)

Axios-Interceptorsopen in new window

全局请求响应拦截器配置参考

构建步骤

1)新建plugins文件夹,新增一个axios.ts文件构建axios相关配置

​ 从官方文档open in new window中引入相关代码,然后基于这个代码自定义所需配置

image-20240423230642718

// Add a request interceptor
import axios from "axios";

axios.interceptors.request.use(
  function (config) {
    return config;
  },
  function (error) {
    return Promise.reject(error);
  }
);

// Add a response interceptor
axios.interceptors.response.use(
  function (response) {
    // 自定义响应拦截处理
    console.log("响应拦截" + response);
    return response;
  },
  function (error) {
    return Promise.reject(error);
  }
);

2)在main.ts中引入自定义的axios

import "./plugins/axios";

3)测试

​ 再次访问页面,查看控制台日志是否正常输出信息

image-20240423231252670

用户登录

1.自动登录

构建步骤

1)在store\user.ts 编写获取远程登陆用户信息的代码(前面已经实现:参考user.ts中的actions配置)

actions: {
    async getLoginUser({ commit, state }, payload) {
      // 从远程请求获取登录信息
      const res = await UserControllerService.getLoginUserUsingGet();
      if (res.code === 0) {
        commit("updateUser", res.data);
      } else {
        commit("updateUser", {
          ...state.loginUser,
          userRole: accessEnum.NOT_LOGIN,
        });
      }
      // 根据传递的值设置登录用户信息({userName: "哈哈",userRole: accessEnum.ADMIN})
      // commit("updateUser", payload);
    },

2)在哪里去触发 getLoginUser函数的执行?应当在一个全局的位置,这个位置可以有很多选择:

  • 路由拦截
  • 全局页面入口app.vue
  • 全局通用布局(所有页面都共享的组件)

2.全局权限管理优化

构建步骤

1)新建access\index.ts文件,把原有的路由拦截、权限校验逻辑放在独立的文件中

  • 优势:只要不引入,就不会开启,不会对项目有影响

2)编写权限管理和自动登陆逻辑

  • 如果没登陆过,自动登陆
// 如果之前没登陆过,自动登陆
  if (!loginUser || !loginUser.userRole) {
    // 加await 是为了等用户登陆成功之后,再执行后续的代码
    await store.dispatch("user/getLoginUser");
  }

如果用户访问的页面不需要登陆,是否需要强制跳转到登录页?(不需要)

image-20240423235420934

access\index.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;
  // 如果之前没登陆过,自动登陆
  if (!loginUser || !loginUser.userRole) {
    // 加await 是为了等用户登陆成功之后,再执行后续的代码
    await store.dispatch("user/getLoginUser");
  }
  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)) {
      next("/noAuth");
      return;
    }
  }
  next();
});

3)在main.ts中引入index.ts

# 写法1
import "@/access";

# 写法2
import "./access/index";

4)启动访问测试:http://localhost:8080/admin可以看到url自动跳转:http://localhost:8080/user/login?redirect=/admin

登录界面设计

1.前端支持多套布局(通用模板构建)

构建步骤

1)修改router/routes.ts文件:在routes路由文件中新建一套用户路由,使用vue-router 自带的子路由机制,实现布局和嵌套路由

import UserLayout from "@/layouts/UserLayout.vue";
import UserLoginView from "@/views/user/UserLoginView.vue";
import UserRegisterView from "@/views/user/UserRegisterView.vue";

export const routes: Array<RouteRecordRaw> = [
  // 定义路由组件(用户相关)
  {
    path: "/user",
    name: "用户",
    component: UserLayout,
    children: [
      {
        path: "/user/login",
        name: "用户登录",
        component: UserLoginView,
      },
      {
        path: "/user/register",
        name: "用户注册",
        component: UserRegisterView,
      },
    ],
  },
  
  ---  其他路由组件定义 ----
 }
]

2)新建 layouts/UserLayout、views/user/UserLoginView、views/user/UserRegisterView 页面,并且在routes中引入

  • BasicLayout.vue =》UserLayout(修改为用户布局相关内容)
  • HideView.vue=》UserLoginView.vue(用户登录页面实现)
  • HideView.vue=》UserRegisterView.vue(用户注册页面实现)

image-20240423235855593

# 登录页面定义
<template>
  <div class="home">用户登录页面</div>
</template>

# 注册页面定义
<template>
  <div class="home">用户注册页面</div>
</template>

3)在App.vue根页面文件,根据路由去区分多套布局

// 原有实现
<template>
  <div id="app">
    <BasicLayout />
  </div>
</template>


// 调整内容:根据路由去区分多套布局
<template>
  <div id="app">
    <template v-if="route.path.startsWith('/user')">
      <router-view />
    </template>
    <template v-else>
      <BasicLayout />
    </template>
  </div>
</template>

​ 当前这种方式app.vue 中通过if else区分布局的方式,不是最优雅的,理想情况下是直接读取routes.ts,在这个文件中定义多套布局,然后自动使用页面布局。

4)测试

访问:http://localhost:8080/user/login、http://localhost:8080/user/register。可以看到对应新的针对用户模块的布局

image-20240424000012557

2.登录注册页面开发

登录页面

<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 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>

​ 因为之前将获取登陆用户信息的函数放到了store的actions中,所以我们需要在登陆的时候用store.dispatch("user/getLoginUser")来获取登陆的用户信息

注册页面(可以基于上面的UserLogin.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
        :rules="[{ required: true, message: '昵称不能为空' }]"
        field="userName"
        label="昵称"
      >
        <a-input v-model="form.userName" placeholder="请输入昵称" />
      </a-form-item>
      <a-form-item
        :rules="[
          { required: true, message: '账号不能为空' },
          { minLength: 4, message: '账号长度不能低于4位' },
        ]"
        field="userAccount"
        label="账号"
      >
        <a-input v-model="form.userAccount" placeholder="请输入账号" />
      </a-form-item>
      <a-form-item
        :rules="[
          { required: true, message: '密码不能为空' },
          { minLength: 8, message: '密码不能低于8位' },
        ]"
        field="userPassword"
        tooltip="密码不少于8位"
        label="密码"
      >
        <a-input-password
          v-model="form.userPassword"
          placeholder="请输入密码"
        />
      </a-form-item>
      <a-form-item
        :rules="[
          { required: true, message: '密码不能为空' },
          { minLength: 8, message: '密码不能低于8位' },
        ]"
        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";
import { useStore } from "vuex";

/**
 * 表单信息
 */
const form = reactive({
  userAccount: "",
  userPassword: "",
  checkPassword: "",
  userName: "",
} as UserRegisterRequest);

const router = useRouter();
const store = useStore();

/**
 * 提交表单
 * @param data
 */
const handleSubmit = async () => {
  if (
    form.userPassword?.length !== form.checkPassword?.length ||
    form.userPassword !== form.checkPassword
  ) {
    message.error("两次输入的密码不一致");
    return;
  }
  const res = await UserControllerService.userRegisterUsingPost(form);
  // 登陆成功,跳转到主页
  if (res.code === 0) {
    message.success("注册成功!!!");
    await router.push({
      path: "/user/login",
      replace: true,
    });
  } else {
    message.error("登陆失败" + res.message);
  }
};
</script>

如果用户模块还是被渲染到导航栏,此处可以设置隐藏即可

image-20240424000925769

image-20240424000954538

访问测试

​ 登录测试:发现用户状态还是没有登录(右上角名称没有显示),排查前后端代码,查看前端请求参数请求头是否携带cookies(下方请求头并没有携带cookies)

image-20240424001614081

调整:需要修改请求配置generated/OpenAPI.ts文件中的WITH_CREDENTIALS: false将其设置为true,修改完成再次登录访问测试

image-20240424001441968

image-20240424001749650

vue登录成功后导航栏获取不到用户信息,参考解决方案:https://blog.csdn.net/qq_45886144/article/details/128992653

确认下是页面哪里修改了内容

image-20240424003926824

​ 排查思路 :

1)排查接口响应的值,看有没有正常拿到用户信息、用户名等(部分新注册用户登录进去显示未登录状态是因为没有拿到用户名,需额外提供接口让他其配置这个参数,或者再加一层校验)

image-20240424062636728

​ 注册的时候多补充一个用户名的表单项,然后前后端对接的部分要调整一下

前端:

  • UserRegisterView.vue补充一个userName表单项,并在form表单的信息中添加属性
  • generated/models/UserRegisterRequest 添加userName属性

image-20240424064129290

后端:

  • 在com/xxx/service/UserService.java的UserRegisterRequest中添加userName
  • 在com/xxx/service/impl/UserServiceImpl.java的UserRegisterRequest要传入的参数中添加userName,并编写插入数据库的代码(注意插入的时候要手动设置进去)

image-20240424064812722

2)是否哪里修改了这个值(检查一下调用到获取用户信息的接口的调用情况),例如之前测试在GlobalHeader.vue中设置的setTimeout定时器(取消掉)

image-20240424061245120

3)在access/index.ts中校验用户权限状态,如果不存在则调用接口获取登录用户信息(所以每次刷新页面都会调用这个getLoginUser)

image-20240424061222953

4)设置全局状态失败(检查一下user.ts直接中的actions定义,看其设置的是什么内容)

image-20240424061318421

经过上述思路排查,发现res返回的是一个json字符串,而判断直接用res===0是错误的,应该是res.code=0;

除非axios响应拦截器做了配置,将res结果转化为对应的内容

3.页面美化

美化右上方个人信息展示

1)替换原有用户名展示效果,根据不同的登录状态显示信息(未登录=》登录、注册;已登录=》个人信息、登录注销)

<a-col flex="100px">
      <!--      <div>{{ store.state.user?.loginUser?.userName }}</div>-->
      <!--      <div>{{ store.state.user?.loginUser?.userName ?? "尚未登录" }}</div>-->

      <a-dropdown trigger="hover">
        <template
          v-if="loginUserInfo.userRole as string !== ACCESS_ENUM.NOT_LOGIN"
        >
          <template v-if="loginUserInfo.userAvatar">
            <a-avatar>
              <img
                alt="avatar"
                :src="loginUserInfo.userAvatar"
                width="24px"
                height="24px"
              />
            </a-avatar>
            {{ loginUserInfo.userName }}
          </template>
          <template v-else>
            <a-avatar>
              <IconUser />
            </a-avatar>
            {{ loginUserInfo.userName }}
          </template>
        </template>
        <template v-else>
          <a-avatar :style="{ backgroundColor: '#168CFF' }"> 未登录</a-avatar>
        </template>
        <template #content>
          <template v-if="loginUserInfo.userRole !== accessEnum.NOT_LOGIN">
            <a-doption>
              <template #icon>
                <icon-idcard />
              </template>
              <template #default>
                <a-anchor-link href="/user/info">个人信息</a-anchor-link>
              </template>
            </a-doption>
            <a-doption>
              <template #icon>
                <icon-poweroff />
              </template>
              <template #default>
                <a-anchor-link @click="logout">退出登陆</a-anchor-link>
              </template>
            </a-doption>
          </template>
          <template v-else>
            <a-doption>
              <template #icon>
                <icon-user />
              </template>
              <a-anchor-link href="/user/login">用户登录</a-anchor-link>
            </a-doption>
            <a-doption>
              <template #icon>
                <icon-user-add />
              </template>
              <a-anchor-link href="/user/register">用户注册</a-anchor-link>
            </a-doption>
          </template>
        </template>
      </a-dropdown>
    </a-col>

2)对应ts实现(定义一个登录注销方法实现,调用登陆注销接口完成注销操作)

const logout = async () => {
  // 调用登录注销方法
  alert("用户即将注销登录");
  const res = await UserControllerService.userLogoutUsingPost();
  if (res.code === 0) {
    // 注销成功,跳转登录页面
    router.push({
      path: "/user/login",
      replace: true,
    });
  } else {
    alert("登录注销失败,请联系管理员处理");
  }
};

4.常见问题处理

在不同电脑上引入同一个vue项目,启动报格式错误

问题分析:(本质是是git clone换行符不一致导致的问题)

1)可能是 eslintopen in new window 配置后运行项目有如下警告(换行格式问题) ,在window系统中,clone代码下来,会自动把换行符LF(linefeed character) 转换成回车符CRLF(carriage-return character)。这时候本地的代码都是回车符。

2)使用了eslint并有进行规则配置或者prettier的.prettierrc有进行配置结尾换行符,那么就会直接在开发环境中进行验证。可能就会提示上述错误(警告)。

3) 该问题详情可以参见:

["error Delete prettier/prettier" in .vue files · Issue #114 · prettier/eslint-plugin-prettier · GitHub]( prettier/prettier" in .vue files · Issue #114 · prettier/eslint-plugin-prettier · GitHub)

image-20240424081634132

解决方案:执行指令(npm run lint --fix

npm run lint --fix

eslint强校验会提示这些文件中部分属性定义没有被引用需要清理

image-20240424082204452

image-20240424082934186

参考解决方案:注释掉eslint语法检测open in new window

​ 如果使用上述这种方式则变成两边配置不同步又会导致这边更新那边又要调整,或者在webstorm中一键美化代码换行符格式自动修复虽然文件内容没有变,但是改动需要提交上去,不然又会出现同样的问题),参考其他方案open in new window

image-20240424084936392

image-20240424084709391

​ 终极解决方案:为了避免这种问题卡新手,可以选择关闭eslint、preitter

image-20240424085759145

此处需注意:在img标签中的 src 和 :src是有区别的。如果使用src来接受图片地址,会导致图片直接裂开,:src则不会。

//表示:src后面悪字符串当做变量解析使用
<img :src="loginUserInfo.userAvatar" />
//这种就是当字符串使用
<img src="loginUserInfo.userAvatar" />
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3