配置 webstorm prettier 设置,ctrl+alt+L 格式化代码

安装 acro design 并引入

npm install --save-dev @arco-design/web-vue
import { createApp } from "vue";  
import App from "./App.vue";  
import router from "./router";  
import "@arco-design/web-vue/dist/arco.css";  
import ArcoVue from "@arco-design/web-vue";  
import store from "./store";  
  
createApp(App).use(store).use(router).use(ArcoVue).mount("#app");

实现通用布局

BasicLayout.vue

页面分三部分 header、content、footer

<template>
  <div id="basicLayout">
    <a-layout style="height: 400px">
      <a-layout-header class="header">
        <global-header />
      </a-layout-header>
      <a-layout-content class="content">
        Content
        <router-view />
      </a-layout-content>
      <a-layout-footer class="footer">Online Judge</a-layout-footer>
    </a-layout>
  </div>
</template>
 
<style>
#basicLayout {
}
 
#basicLayout .header {
  margin-bottom: 16px;
}
 
#basicLayout .content {
  margin-bottom: 16px;
}
 
#basicLayout .footer {
  background: #efefef;
  padding: 16px;
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  text-align: center;
}
</style>
<script setup lang="ts">
import GlobalHeader from "@/components/GlobalHeader.vue";
</script>
 

实现通用菜单

GlobalHeader.vue

使用 a-menu 组件 Arco Design 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 class="logo" src="../assets/logo.png" />  
          <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 setup lang="ts">  
import { routes } from "@/router/routes";  
import { useRoute, useRouter } from "vue-router";  
import { ref } from "vue";  
  
const router = useRouter();  
// 默认主页  
const selectedKeys = ref(["/"]);  
  
// 路由跳转,更新选中的菜单项  
router.afterEach((to, from, failure) => {  
  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;  
}  
  
.logo {  
  height: 48px;  
}  
  
.title {  
  margin-left: 16px;  
  color: #444;  
}  
</style>

全局状态管理

store\user.ts

先定义用户模块

import { StoreOptions } from "vuex";
 
export default {
  namespace: true,
  state: () => ({
    loginUser: {
      userName: "未登录",
    },
  }),
  actions: {
    getLoginUser({ commit, state }, payload) {
      commit("updateUser", payload);
    },
  },
  mutations: {
    updateUser(state, payload) {
      state.loginUser = payload;
    },
  },
} as StoreOptions<any>;

store\index.ts 导出用户模块

import { createStore } from "vuex";
import user from "@/store/user";
 
export default createStore({
  mutations: {},
  actions: {},
  modules: {
    user,
  },
});

获取/修改状态变量

<a-col flex="100px">  
  <div>{{ store.state.user?.loginUser?.userName ?? "未登录" }}</div>  
</a-col>
<script setup lang="ts">
import { useStore } from "vuex";
const store = useStore();  
 
setTimeout(() => {  
  store.dispatch("getLoginUser", {  
    userName: "鱼皮",  
  });  
}, 3000);
</script>

全局权限校验

  1. 定义权限 access\accessEnum.ts
const ACCESS_ENUM = {
  NOT_LOGIN: "notLogin",
  USER: "user",
  ADMIN: "admin",
};
export default ACCESS_ENUM;
  1. 权限校验函数 access\checkAccess.ts
import ACCESS_ENUM from "@/access/accessEnum";
 
/**
 * 检查权限
 * @param loginUser 当前登录用户
 * @param needAccess 需要有的权限
 * @return boolean 有无权限
 */
const checkAccess = (loginUser: any, needAccess = 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;
  1. 动态加载导航栏路由 GlobalHeader.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;
  });
});

全局项目入口

// 全局初始化
const doInit = () => {
  console.log("hello!");
};
    
onMounted(() => {
  doInit();
});

后端初始化

crtl+shfit+r 全局替换 spingbootinit 和 springboot-init

shift+f6 替换包名和项目名

前端请求

安装 axios、openapi

npm install axios@1.6.2
npm install openapi-typescript-codegen --save-dev

生成代码

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

开启 cookie :

WITH_CREDENTIALS: true

自动登录

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) => {  
  // 获取用户登陆状态  
  let loginUser = store.state.user.loginUser;  
  if (!loginUser || !loginUser.userRole) {  
    await store.dispatch("getLoginUser");  
    loginUser = store.state.user.loginUser;  
  }  
  // 需要的权限  
  const needAccess = (to.meta?.access as string) ?? ACCESS_ENUM.NOT_LOGIN;  
  // 如果需要登陆  
  if (needAccess !== ACCESS_ENUM.NOT_LOGIN) {  
    // 没登陆就跳转登录  
    if (  
      !loginUser ||  
      !loginUser.userRole ||  
      loginUser.userRole === ACCESS_ENUM.NOT_LOGIN  
    ) {  
      next(`/user/login?rediect=${to.fullPath}`);  
      return;  
    }  
    // 登录了但没权限就跳转无权限  
    if (!checkAccess(loginUser, needAccess)) {  
      next("/noAuth");  
      return;  
    }  
  }  
  // 登陆了或者不需要权限的页面直接可以放行  
  next();  
});

多套布局

添加路由

{
    path: "/user",
    name: "用户",
    component: UserLayout,
    meta: {
      hideInMenu: true,
    },
    children: [
      {
        path: "/user/login",
        name: "用户登录",
        component: UserLoginView,
      },
      {
        path: "/user/register",
        name: "用户注册",
        component: UserRegisterView,
      },
    ],
  },

新建 UserLayout.vue, UserLoginView, UserRegisterView

app.vue 中使用多套布局

  <template v-if="route.path.startsWith('/user')">
    <router-view />
  </template>
  <template v-else>
    <basic-layout />
  </template>

登录页面

UserLoginView.vue

<template>
  <div id="userLogin">
    <a-form :model="form" :style="{ width: '600px' }" @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">提交</a-button>
      </a-form-item>
    </a-form>
    {{ 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 store = useStore();
const router = useRouter();
const form = reactive({
  userAccount: "",
  userPassword: "",
} as UserLoginRequest);
const handleSubmit = async () => {
  const res = await UserControllerService.userLoginUsingPost(form);
  if (res.code === 0) {
    await store.dispatch("getLoginUser");
    router.push({
      path: "/",
      replace: true,
    });
  } else {
    message.error("登录失败" + res.message);
  }
};
</script>

Markdown 编辑器

pd4d10/bytemd: ByteMD v1 repository

安装组件库

npm i @bytemd/vue-next

安装插件

npm i @bytemd/plugin-highlight
npm i @bytemd/plugin-math
npm i @bytemd/plugin-gfm

组件属性

interface Props {  
  value: string;  
  handleChange: (v: string) => void;  
}

MdEditor.vue

<template>  
  <Editor :value="value" :plugins="plugins" @change="handleChange" />  
</template>  
  
<script setup lang="ts">  
import highlight from "@bytemd/plugin-highlight";  
import math from "@bytemd/plugin-math";  
import { Editor, Viewer } from "@bytemd/vue-next";  
import { ref, withDefaults, defineProps } from "vue";  
import "katex/dist/katex.css";  
  
interface Props {  
  value: string;  
  handleChange: (v: string) => void;  
}  
  
const plugins = [  
  highlight(),  
  math(),  
  // Add more plugins here  
];  
  
const props = withDefaults(defineProps<Props>(), {  
  value: () => "",  
  handleChange: (v: string) => {  
    console.log(v);  
  },  
});  
</script>  
  
<style scoped></style>

代码编辑器

Monaco Editor

安装组件(遇到编译问题安装第三个包):

npm install monaco-editor
npm install monaco-editor-webpack-plugin
npm install @babel/plugin-transform-class-static-block

加载配置 vue.config.js

const { defineConfig } = require("@vue/cli-service");
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");
module.exports = defineConfig({
  transpileDependencies: true,
  chainWebpack(config) {
    config.plugin("monaco").use(new MonacoWebpackPlugin());
  },
});

babel.config.js

module.exports = {
  presets: ["@vue/cli-plugin-babel/preset"],
  plugins: ["@babel/plugin-transform-class-static-block"],
};
 

codeEditor.vue

<template>  
  <div    id="code-editor"  
    style="min-height: 400px; height: 70vh"  
    ref="codeEditorRef"  
  />  
</template>  
<script setup lang="ts">  
import * as monaco from "monaco-editor";  
import { defineProps, onMounted, ref, toRaw, watch, withDefaults } from "vue";  
  
interface Props {  
  value: string;  
  language: string;  
  handleChange: (v: string) => void;  
}  
  
const props = withDefaults(defineProps<Props>(), {  
  value: () => "",  
  language: () => "java",  
  handleChange: (v: string) => {  
    console.log(v);  
  },  
});  
  
const codeEditorRef = ref();  
const codeEditor = ref();  
  
watch(  
  () => props.language,  
  () => {  
    if (codeEditor.value) {  
      monaco.editor.setModelLanguage(  
        toRaw(codeEditor.value.getModel()),  
        props.language  
      );  
    }  
  }  
);  
  
onMounted(() => {  
  if (!codeEditorRef.value) {  
    return;  
  }  
  codeEditor.value = monaco.editor.create(codeEditorRef.value, {  
    value: props.value,  
    language: props.language,  
    minimap: {  
      enabled: true,  
    },  
    readOnly: false,  
    theme: "vs-dark",  
  });  
  codeEditor.value.onDidChangeModelContent(() => {  
    props.handleChange(toRaw(codeEditor.value).getValue());  
  });  
});  
</script>  
  
<style scoped></style>

创建题目页面

嵌套表单:chttps://arco.design/vue/component/form#nest

动态增减表单:https://arco.design/vue/component/form#dynamic

分页问题:@page-change事件,改变searchParams 值,通过 watchEffect 监听 searchParmas 然后执行 loadData 重新加载

<template>
  <div id="addQuestionView">
    <h2>创建题目</h2>
    <a-form :model="form">
      <a-form-item field="title" label="标题">
        <a-input v-model="form.title" />
      </a-form-item>
      <a-form-item field="content" label="题目内容">
        <md-editor :value="form.content" :handle-change="onContentChange" />
      </a-form-item>
      <a-form-item field="tags" label="标签">
        <a-input-tag v-model="form.tags" allow-clear />
      </a-form-item>
      <a-form-item field="answer" label="答案">
        <md-editor :value="form.answer" :handle-change="onAnswerChange" />
      </a-form-item>
      <a-form-item label="判题配置" :content-flex="false" :merge-props="false">
        <a-space direction="vertical" style="min-width: 480px">
          <a-form-item field="judgeConfig.timeLimit" label="时间限制">
            <a-input-number
              v-model="form.judgeConfig.timeLimit"
              placeholder="请输入时间限制"
              mode="button"
              min="0"
              size="large"
            />
          </a-form-item>
          <a-form-item field="judgeConfig.memoryLimit" label="内存限制">
            <a-input-number
              v-model="form.judgeConfig.memoryLimit"
              placeholder="请输入内存限制"
              mode="button"
              min="0"
              size="large"
            />
          </a-form-item>
          <a-form-item field="judgeConfig.stackLimit" label="栈空间限制">
            <a-input-number
              v-model="form.judgeConfig.stackLimit"
              placeholder="请输入栈空间限制"
              mode="button"
              min="0"
              size="large"
            />
          </a-form-item>
        </a-space>
      </a-form-item>
      <a-form-item
        field="judgeCase"
        label="判题用例"
        :content-flex="false"
        :merge-props="false"
      >
        <a-form-item
          v-for="(judgeCaseItem, index) of form.judgeCase"
          :key="index"
          style="min-width: 800px"
          no-style
        >
          <a-space direction="vertical" style="min-width: 480px">
            <a-form-item
              :field="`form.judgeCase[${index}].input`"
              :label="`输入用例-${index}`"
              :key="index"
            >
              <a-input v-model="judgeCaseItem.input" placeholder="输入用例" />
            </a-form-item>
            <a-form-item
              :field="`form.judgeCase[${index}].output`"
              :label="`输出用例-${index}`"
              :key="index"
            >
              <a-input v-model="judgeCaseItem.output" placeholder="输出用例" />
            </a-form-item>
            <a-button @click="deleteJudgeCase(index)" status="danger"
              >删除
            </a-button>
          </a-space>
        </a-form-item>
        <div>
          <a-button
            @click="addJudgeCase"
            type="outline"
            status="success"
            style="margin-top: 16px"
            >添加测试用例
          </a-button>
        </div>
      </a-form-item>
      <a-form-item>
        <a-button html-type="submit" type="primary" @click="doSubmit"
          >提交
        </a-button>
      </a-form-item>
    </a-form>
  </div>
</template>
<script setup lang="ts">
import { reactive } from "vue";
import MdEditor from "@/components/MdEditor.vue";
import {
  QuestionAddRequest,
  QuestionControllerService,
} from "../../generated";
import message from "@arco-design/web-vue/es/message";
 
const form = reactive({
  answer: "print(a+b)",
  content: "题目内容",
  judgeCase: [
    {
      input: "1 2",
      output: "3",
    },
  ],
  judgeConfig: {
    timeLimit: 1000,
    memoryLimit: 1000,
    stackLimit: 1000,
  },
  tags: ["简单"],
  title: "A+B",
});
 
const onContentChange = (v: string) => {
  form.content = v;
};
const onAnswerChange = (v: string) => {
  form.answer = v;
};
const addJudgeCase = () => {
  form.judgeCase.push({
    input: "",
    output: "",
  });
};
const deleteJudgeCase = (index: number) => {
  form.judgeCase.splice(index, 1);
};
const doSubmit = async () => {
  console.log(form);
  const res = await QuestionControllerService.addQuestionUsingPost(form);
  if (res.code === 0) {
    message.success("创建成功");
  } else {
    message.error("创建失败" + res.message);
  }
};
</script>
 
<style scoped></style>

管理题目页面

表格渲染 https://arco.design/vue/component/table#custom

删除后需要手动调用 loadData 刷新数据

修改数据通过路由跳转到修改页

<template>
  <div id="manageQuestionView">
    <a-table
      :columns="columns"
      :data="dataList"
      :pagination="{
        showTotal: true,
        pageSize: searchParams.pageSize,
        current: searchParams.current,
        total,
      }"
      @page-change="onPageChange"
    >
      <template #optional="{ record }">
        <a-space>
          <a-button @click="doUpdate(record)" type="primary">修改</a-button>
          <a-button @click="doDelete(record)" status="danger">删除</a-button>
        </a-space>
      </template>
    </a-table>
  </div>
</template>
<script setup lang="ts">
import { onMounted, ref, watchEffect } from "vue";
import { Question, QuestionControllerService } from "../../generated";
import message from "@arco-design/web-vue/es/message";
import { useRouter } from "vue-router";
 
const router = useRouter();
 
const dataList = ref([]);
const total = ref(0);
const searchParams = ref({
  pageSize: 10,
  current: 1,
});
 
const loadData = async () => {
  const res = await QuestionControllerService.listQuestionByPageUsingPost(
    searchParams.value
  );
  if (res.code === 0) {
    dataList.value = res.data.records;
    total.value = res.data.total;
  } else {
    message.error("加载失败," + res.message);
  }
};
 
onMounted(() => {
  loadData();
});
 
const doDelete = async (question: Question) => {
  const res = await QuestionControllerService.deleteQuestionUsingPost(question);
  if (res.code === 0) {
    message.success("删除成功");
    loadData();
  } else {
    message.error("删除失败," + res.message);
  }
};
const doUpdate = (question: Question) => {
  router.push({
    path: "/update/question",
    query: {
      id: question.id,
    },
  });
};
watchEffect(() => {
  loadData();
});
const onPageChange = (page: number) => {
  searchParams.value = {
    ...searchParams.value,
    current: page,
  };
};
const columns = [
  {
    title: "id",
    dataIndex: "id",
  },
  {
    title: "标题",
    dataIndex: "title",
  },
  {
    title: "内容",
    dataIndex: "content",
  },
  {
    title: "标签",
    dataIndex: "tags",
  },
  {
    title: "答案",
    dataIndex: "answer",
  },
  {
    title: "判题样例",
    dataIndex: "judgeCase",
  },
  {
    title: "判题配置",
    dataIndex: "judgeConfig",
  },
  {
    title: "提交数",
    dataIndex: "submitNum",
  },
  {
    title: "通过数",
    dataIndex: "acceptedNum",
  },
  {
    title: "创建用户Id",
    dataIndex: "userId",
  },
  {
    title: "操作",
    slotName: "optional",
  },
];
</script>
 
<style scoped></style>

修改题目页面

<template>
...
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import MdEditor from "@/components/MdEditor.vue";
import { QuestionControllerService } from "../../generated";
import message from "@arco-design/web-vue/es/message";
import { useRoute } from "vue-router";
 
const route = useRoute();
const updatePage = route.path.includes("update");
 
const form = ref({
  answer: "题目答案",
  content: "题目内容",
  judgeCase: [
    {
      input: "1 2",
      output: "3",
    },
  ],
  judgeConfig: {
    timeLimit: 1000,
    memoryLimit: 1000,
    stackLimit: 1000,
  },
  tags: ["简单"],
  title: "A+B",
});
 
const loadData = async () => {
  const id = route.query.id;
  if (!id) {
    return;
  }
  const res = await QuestionControllerService.getQuestionVoByIdUsingGet(
    id as any
  );
  if (res.code === 0) {
    form.value = res.data as any;
  } else {
    message.error("加载失败," + res.message);
  }
};
 
onMounted(() => {
  loadData();
});
const onContentChange = (v: string) => {
  form.value.content = v;
};
const onAnswerChange = (v: string) => {
  form.value.answer = v;
};
const addJudgeCase = () => {
  form.value.judgeCase.push({
    input: "",
    output: "",
  });
};
const deleteJudgeCase = (index: number) => {
  form.value.judgeCase.splice(index, 1);
};
const doSubmit = async () => {
  if (updatePage) {
    const res = await QuestionControllerService.updateQuestionUsingPost(
      form.value
    );
    if (res.code === 0) {
      message.success("更新成功");
    } else {
      message.error("更新失败," + res.message);
    }
  } else {
    const res = await QuestionControllerService.addQuestionUsingPost(
      form.value
    );
    if (res.code === 0) {
      message.success("创建成功");
    } else {
      message.error("创建失败," + res.message);
    }
  }
};
</script>
 
<style scoped></style>

git 设置

git config --global core.atuocrlf false

浏览题目页面

  1. 使用表格布局和表单作为搜索
  2. 自定义渲染,使用插槽渲染标签、通过率、创建时间
  3. 时间字符串工具类 moment

在线做题页面

路由中开启 props : true

组件:a-tab-pane a-description a-tag a-select

题目提交列表页面

检索项:

  • status
  • userId
  • QuestionId
  • language