|
@@ -1,237 +1,198 @@
|
|
|
-# Vue 3 + TypeScript + Vite
|
|
|
|
|
|
|
+# OJ 在线判题系统 · 前端
|
|
|
|
|
|
|
|
-This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
|
|
|
|
|
|
+基于 Vue 3 + TypeScript + Vite 6 的在线判题(Online Judge)前端,提供题目浏览与提交、比赛与排名、后台管理等功能。
|
|
|
|
|
+
|
|
|
|
|
+## 技术栈
|
|
|
|
|
+
|
|
|
|
|
+| 分类 | 选型 |
|
|
|
|
|
+| --- | --- |
|
|
|
|
|
+| 框架 | Vue 3.5(`<script setup>` SFC) |
|
|
|
|
|
+| 语言 | TypeScript 5.8 |
|
|
|
|
|
+| 构建 | Vite 6 |
|
|
|
|
|
+| 路由 | Vue Router 4 |
|
|
|
|
|
+| 状态管理 | Pinia 3 + pinia-plugin-persistedstate |
|
|
|
|
|
+| UI 组件库 | Element Plus 2(中文语言环境) |
|
|
|
|
|
+| HTTP | Axios(统一拦截器) |
|
|
|
|
|
+| 接口生成 | Orval 7(基于 OpenAPI 生成 TypeScript 客户端与类型) |
|
|
|
|
|
+| 代码编辑器 | Monaco Editor(当前仅 Java) |
|
|
|
|
|
+| Markdown | ByteMD(GFM + 代码高亮) |
|
|
|
|
|
+| 时间处理 | Day.js |
|
|
|
|
|
+
|
|
|
|
|
+## 功能特性
|
|
|
|
|
+
|
|
|
|
|
+### 用户端
|
|
|
|
|
+
|
|
|
|
|
+- **题目**:列表支持按标题、难度、分类搜索与分页;详情页展示描述、限制信息、样例、统计与分类标签,并可在页内使用 Monaco 提交代码、查看判题结果。
|
|
|
|
|
+- **比赛**:列表按状态/类型筛选;详情页展示赛题列表与分值,支持报名(密码型比赛先校验口令);独立排名页展示名次、用户、总分与 AC 最大耗时。
|
|
|
|
|
+- **我的提交**:分页表格展示提交记录,展开行可查看提交的代码与判题详情。
|
|
|
|
|
+- **账号**:登录/退出,用户信息与登录态持久化。
|
|
|
|
|
+
|
|
|
|
|
+### 管理后台
|
|
|
|
|
|
|
|
-Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
|
|
|
|
|
|
+- **题目管理**:题目 CRUD,维护分类勾选,详情弹窗管理样例与评测用例(含隐藏标记)。
|
|
|
|
|
+- **分类管理**:题目分类 CRUD。
|
|
|
|
|
+- **比赛管理**:比赛 CRUD,配置赛题(多选题目 + 显示顺序 + 分值),查看报名名单。
|
|
|
|
|
+- **用户管理**:用户分页查询、状态与角色管理、改密与头像。
|
|
|
|
|
+
|
|
|
|
|
+## 快速开始
|
|
|
|
|
+
|
|
|
|
|
+### 环境要求
|
|
|
|
|
+
|
|
|
|
|
+- Node.js 18+
|
|
|
|
|
+- 后端服务运行在 `http://localhost:8080`(提供 `/v3/api-docs` OpenAPI 文档)
|
|
|
|
|
+
|
|
|
|
|
+### 安装与运行
|
|
|
|
|
+
|
|
|
|
|
+```shell
|
|
|
|
|
+npm install
|
|
|
|
|
+npm run dev # 本地开发,默认 http://localhost:5173
|
|
|
|
|
+npm run build # vue-tsc 类型检查 + 生产构建,产物在 dist/
|
|
|
|
|
+npm run preview # 本地预览构建产物
|
|
|
|
|
+npm run api:gen # 依据后端 OpenAPI 文档重新生成接口代码
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 后端联调
|
|
|
|
|
+
|
|
|
|
|
+开发环境通过 Vite 代理转发请求,`/api` 前缀会被重写后转发到 `http://localhost:8080`:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+// vite.config.ts
|
|
|
|
|
+server: {
|
|
|
|
|
+ proxy: {
|
|
|
|
|
+ '/api': {
|
|
|
|
|
+ target: 'http://localhost:8080',
|
|
|
|
|
+ changeOrigin: true,
|
|
|
|
|
+ rewrite: (path) => path.replace(/^\/api/, '')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Axios 实例的 `baseURL` 取 `import.meta.env.VITE_API_BASE_URL`,未配置时回退为 `/api`(即走上面的代理)。如需直连后端或部署到其他地址,创建 `.env.local` 覆盖即可:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+VITE_API_BASE_URL=http://localhost:8080
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 请求与响应约定
|
|
|
|
|
+
|
|
|
|
|
+`src/util/axios-instance.ts` 集中处理鉴权与提示:
|
|
|
|
|
+
|
|
|
|
|
+- 请求拦截器从 `localStorage.token` 读取令牌并注入 `Authorization: Bearer <token>`。
|
|
|
|
|
+- 响应拦截器按业务状态码判断:`code` 为 `0` 或 `200` 视为成功;成功且带 `message` 时弹出 `ElMessage.success`,失败时弹出后端返回的 `message` 并 reject。
|
|
|
|
|
+- HTTP `401` 统一跳转登录页。
|
|
|
|
|
+
|
|
|
|
|
+视图层因此不需要各自处理错误提示,只需 `try/catch` 处理业务失败分支。
|
|
|
|
|
+
|
|
|
|
|
+## 接口代码生成
|
|
|
|
|
+
|
|
|
|
|
+Orval 读取后端 OpenAPI 文档,按标签拆分为控制器文件,类型输出到 `src/api/models`,并使用自定义 mutator 走统一的 Axios 实例:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+// orval.config.ts 关键配置
|
|
|
|
|
+input: { target: 'http://localhost:8080/v3/api-docs' },
|
|
|
|
|
+output: {
|
|
|
|
|
+ mode: 'tags',
|
|
|
|
|
+ client: 'axios',
|
|
|
|
|
+ target: './src/api/client.ts',
|
|
|
|
|
+ schemas: './src/api/models',
|
|
|
|
|
+ clean: true,
|
|
|
|
|
+ override: {
|
|
|
|
|
+ mutator: { path: './src/util/axios-instance.ts', name: 'customAxiosInstance' },
|
|
|
|
|
+ query: { useQuery: true, useMutation: true }
|
|
|
|
|
+ }
|
|
|
|
|
+},
|
|
|
|
|
+hooks: { afterAllFilesWrite: 'prettier --write' }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+后端每完成一批接口后执行 `npm run api:gen`,产物示例:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+src/api/
|
|
|
|
|
+├── auth-controller.ts # 登录认证
|
|
|
|
|
+├── category-controller.ts # 题目分类
|
|
|
|
|
+├── contest-controller.ts # 比赛
|
|
|
|
|
+├── problem-controller.ts # 题目
|
|
|
|
|
+├── submission-controller.ts # 代码提交与判题
|
|
|
|
|
+├── user-controller.ts # 用户管理
|
|
|
|
|
+└── models/ # 请求/响应 DTO、VO 与枚举
|
|
|
|
|
+```
|
|
|
|
|
|
|
|
-# 前端模板项目
|
|
|
|
|
-
|
|
|
|
|
-> node18
|
|
|
|
|
->
|
|
|
|
|
-> Vue 3 + TypeScript + Vite6
|
|
|
|
|
-
|
|
|
|
|
-## 构建过程
|
|
|
|
|
-
|
|
|
|
|
-1. 项目初始化
|
|
|
|
|
-
|
|
|
|
|
- ```shell
|
|
|
|
|
- npm create vite@6
|
|
|
|
|
- ```
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-2. 添加vue-router和elementPlus依赖
|
|
|
|
|
-
|
|
|
|
|
- - 安装
|
|
|
|
|
-
|
|
|
|
|
- ```shell
|
|
|
|
|
- npm install vue-router@4
|
|
|
|
|
- npm install element-plus --save
|
|
|
|
|
- ```
|
|
|
|
|
-
|
|
|
|
|
- - 配置
|
|
|
|
|
-
|
|
|
|
|
- ```typescript
|
|
|
|
|
- const routes:RouteRecordRaw[] = [
|
|
|
|
|
- {
|
|
|
|
|
- path: '/',
|
|
|
|
|
- name: 'commonLayout',
|
|
|
|
|
- component: CommonLayout,
|
|
|
|
|
- children: [
|
|
|
|
|
- {
|
|
|
|
|
- path: '/',
|
|
|
|
|
- name: 'home',
|
|
|
|
|
- component: HomeView
|
|
|
|
|
- },
|
|
|
|
|
- {
|
|
|
|
|
- path: '/about',
|
|
|
|
|
- name: 'about',
|
|
|
|
|
- component: AboutView
|
|
|
|
|
- },
|
|
|
|
|
- ],
|
|
|
|
|
- },
|
|
|
|
|
- ]
|
|
|
|
|
-
|
|
|
|
|
- const router : Router = createRouter({
|
|
|
|
|
- history: createWebHistory(),
|
|
|
|
|
- routes: routes,
|
|
|
|
|
- });
|
|
|
|
|
- export default router
|
|
|
|
|
- ```
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
- - 使用
|
|
|
|
|
-
|
|
|
|
|
- ```typescript
|
|
|
|
|
- app.use(router)
|
|
|
|
|
- app.use(ElementPlus)
|
|
|
|
|
- ```
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-3. 添加状态管理依赖
|
|
|
|
|
-
|
|
|
|
|
- - 安装
|
|
|
|
|
-
|
|
|
|
|
- ```shell
|
|
|
|
|
- npm install pinia
|
|
|
|
|
- ```
|
|
|
|
|
-
|
|
|
|
|
- - 配置
|
|
|
|
|
-
|
|
|
|
|
- ```
|
|
|
|
|
-
|
|
|
|
|
- ```
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
- - 使用
|
|
|
|
|
-
|
|
|
|
|
- ```typescript
|
|
|
|
|
- import { createPinia } from 'pinia'
|
|
|
|
|
- const pinia = createPinia()
|
|
|
|
|
- app.use(pinia)
|
|
|
|
|
- ```
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-4. 添加axios依赖
|
|
|
|
|
-
|
|
|
|
|
- ```shell
|
|
|
|
|
- npm install axios
|
|
|
|
|
- ```
|
|
|
|
|
-
|
|
|
|
|
- 配置axios-instance.ts
|
|
|
|
|
-
|
|
|
|
|
- ```typescript
|
|
|
|
|
- import axios, {type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios';
|
|
|
|
|
-
|
|
|
|
|
- // 自定义配置接口
|
|
|
|
|
- export interface CustomInstanceConfig extends AxiosRequestConfig {
|
|
|
|
|
- // 可以添加自定义配置
|
|
|
|
|
- skipAuth?: boolean; // 是否跳过鉴权
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 创建 Axios 实例
|
|
|
|
|
- const axiosInstance: AxiosInstance = axios.create({
|
|
|
|
|
- baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
|
|
|
|
- timeout: 30000,
|
|
|
|
|
- headers: {
|
|
|
|
|
- 'Content-Type': 'application/json',
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- // 请求拦截器
|
|
|
|
|
- axiosInstance.interceptors.request.use(
|
|
|
|
|
- (config) => {
|
|
|
|
|
- // 添加 token
|
|
|
|
|
- const token = localStorage.getItem('token');
|
|
|
|
|
- if (token) {
|
|
|
|
|
- config.headers.Authorization = `Bearer ${token}`;
|
|
|
|
|
- }
|
|
|
|
|
- return config;
|
|
|
|
|
- },
|
|
|
|
|
- (error) => {
|
|
|
|
|
- return Promise.reject(error);
|
|
|
|
|
- }
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- // 响应拦截器
|
|
|
|
|
- axiosInstance.interceptors.response.use(
|
|
|
|
|
- (response: AxiosResponse) => {
|
|
|
|
|
- // 根据后端返回格式调整
|
|
|
|
|
- if (response.data.code !== 0) {
|
|
|
|
|
- return Promise.reject(new Error(response.data.message || '请求失败'));
|
|
|
|
|
- }
|
|
|
|
|
- return response.data;
|
|
|
|
|
- },
|
|
|
|
|
- (error) => {
|
|
|
|
|
- // 统一错误处理
|
|
|
|
|
- if (error.response?.status === 401) {
|
|
|
|
|
- // 未授权,跳转登录
|
|
|
|
|
- window.location.href = '/login';
|
|
|
|
|
- }
|
|
|
|
|
- return Promise.reject(error);
|
|
|
|
|
- }
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- // 自定义实例函数(供 Orval 使用)
|
|
|
|
|
- export const customAxiosInstance = <T>(config: CustomInstanceConfig): Promise<T> => {
|
|
|
|
|
- return axiosInstance(config);
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- // 也可以导出第二参数形式(如需处理完整响应)
|
|
|
|
|
- export const customAxiosInstanceWithFullResponse = <T>(
|
|
|
|
|
- config: CustomInstanceConfig
|
|
|
|
|
- ): Promise<AxiosResponse<T>> => {
|
|
|
|
|
- return axiosInstance(config);
|
|
|
|
|
- };
|
|
|
|
|
- ```
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-5. openAPI自动生成接口调用
|
|
|
|
|
-
|
|
|
|
|
- 使用orval进行API生成
|
|
|
|
|
-
|
|
|
|
|
- ```shell
|
|
|
|
|
- npm install orval
|
|
|
|
|
- ```
|
|
|
|
|
-
|
|
|
|
|
- 编写orval.config.ts配置文件
|
|
|
|
|
-
|
|
|
|
|
- ```typescript
|
|
|
|
|
- import { defineConfig } from 'orval';
|
|
|
|
|
-
|
|
|
|
|
- export default defineConfig({
|
|
|
|
|
- 'your-api': {
|
|
|
|
|
- // OpenAPI 规范文件路径(支持本地文件或 URL)
|
|
|
|
|
- input: {
|
|
|
|
|
- target: 'http://localhost:8080/v3/api-docs', // 可改为你的 Swagger JSON 地址,如:http://localhost:8000/openapi.json
|
|
|
|
|
- },
|
|
|
|
|
- output: {
|
|
|
|
|
- // 生成代码的模式:tags-按接口标签拆分文件
|
|
|
|
|
- mode: 'tags',
|
|
|
|
|
- // 客户端类型:使用 axios
|
|
|
|
|
- client: 'axios',
|
|
|
|
|
- // 生成 vue-query hooks
|
|
|
|
|
- target: './src/api/client.ts',
|
|
|
|
|
- // 生成的 TypeScript 类型存放目录
|
|
|
|
|
- schemas: './src/api/models',
|
|
|
|
|
- // 每次生成前自动清理旧文件
|
|
|
|
|
- clean: true,
|
|
|
|
|
- // 覆盖默认配置
|
|
|
|
|
- override: {
|
|
|
|
|
- // 自定义 axios 实例
|
|
|
|
|
- mutator: {
|
|
|
|
|
- path: './src/util/axios-instance.ts',
|
|
|
|
|
- name: 'customAxiosInstance',
|
|
|
|
|
- },
|
|
|
|
|
- // 为每个接口生成 vue-query hooks
|
|
|
|
|
- query: {
|
|
|
|
|
- useQuery: true, // 生成 useGetXXX hooks
|
|
|
|
|
- useMutation: true, // 生成 usePostXXX hooks
|
|
|
|
|
- },
|
|
|
|
|
- },
|
|
|
|
|
- },
|
|
|
|
|
- // 生成后自动格式化代码
|
|
|
|
|
- hooks: {
|
|
|
|
|
- afterAllFilesWrite: 'prettier --write',
|
|
|
|
|
- },
|
|
|
|
|
- },
|
|
|
|
|
- });
|
|
|
|
|
- ```
|
|
|
|
|
-
|
|
|
|
|
- 配置package.json文件中
|
|
|
|
|
-
|
|
|
|
|
- ```json
|
|
|
|
|
- {
|
|
|
|
|
- "scripts": {
|
|
|
|
|
- ...,
|
|
|
|
|
- "api:gen": "orval"
|
|
|
|
|
- },
|
|
|
|
|
- }
|
|
|
|
|
- ```
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
|
|
+视图层按控制器调用接口,类型统一从 `@/api/models` 引入:
|
|
|
|
|
+
|
|
|
|
|
+```ts
|
|
|
|
|
+import { getProblemController } from '@/api/problem-controller'
|
|
|
|
|
+import type { ProblemVo, QueryByPage1Params } from '@/api/models'
|
|
|
|
|
+
|
|
|
|
|
+const page = await getProblemController().queryByPage1({ pageNum, pageSize } as QueryByPage1Params)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+> `src/api` 目录由 Orval 生成(`clean: true`,每次生成会清空重建),不要手动修改。
|
|
|
|
|
+
|
|
|
|
|
+## 目录结构
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+src/
|
|
|
|
|
+├── api/ # Orval 生成的接口客户端与数据模型
|
|
|
|
|
+├── components/ # 公共组件(MonacoEditor)
|
|
|
|
|
+├── layout/ # 布局:UserLayout(顶部导航)、AdminLayout(侧边菜单)
|
|
|
|
|
+├── router/ # 路由定义与权限守卫
|
|
|
|
|
+├── store/ # Pinia:meta(站点配置)、user(登录态)
|
|
|
|
|
+├── type/ # 手工维护的公共类型
|
|
|
|
|
+├── util/ # axios-instance、markdown、format-time、submission 状态映射
|
|
|
|
|
+├── view/
|
|
|
|
|
+│ ├── admin/ # 后台:题目/分类/比赛/用户管理
|
|
|
|
|
+│ ├── contest/ # 比赛列表、详情、排名
|
|
|
|
|
+│ ├── problem/ # 题目列表、详情
|
|
|
|
|
+│ └── submission/ # 我的提交
|
|
|
|
|
+├── App.vue
|
|
|
|
|
+└── main.ts # 应用入口:注册 Router、Pinia、Element Plus(zh-cn)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+路径别名 `@` 指向 `src`(见 `vite.config.ts`)。
|
|
|
|
|
+
|
|
|
|
|
+## 路由与权限
|
|
|
|
|
+
|
|
|
|
|
+| 路径 | 页面 | 访问权限 |
|
|
|
|
|
+| --- | --- | --- |
|
|
|
|
|
+| `/login` | 登录 | 公开 |
|
|
|
|
|
+| `/` | 首页 | 公开 |
|
|
|
|
|
+| `/about` | 关于 | 公开 |
|
|
|
|
|
+| `/problems` | 题目列表 | 公开 |
|
|
|
|
|
+| `/problems/:id` | 题目详情 | 公开(提交需登录) |
|
|
|
|
|
+| `/contests` | 比赛列表 | 公开 |
|
|
|
|
|
+| `/contests/:id` | 比赛详情 | 公开(报名需登录) |
|
|
|
|
|
+| `/contests/:id/ranking` | 比赛排名 | 公开 |
|
|
|
|
|
+| `/submissions` | 我的提交 | 公开(仅展示本人记录) |
|
|
|
|
|
+| `/admin/problems` | 题目管理 | `admin` / `coach` |
|
|
|
|
|
+| `/admin/categories` | 分类管理 | `admin` / `coach` |
|
|
|
|
|
+| `/admin/contests` | 比赛管理 | `admin` / `coach` |
|
|
|
|
|
+| `/admin/users` | 用户管理 | `admin` |
|
|
|
|
|
+
|
|
|
|
|
+权限通过路由 `meta` 声明,由 `router.beforeEach` 统一守卫:
|
|
|
|
|
+
|
|
|
|
|
+- `meta.requiresAdmin`:仅 `admin` 可访问。
|
|
|
|
|
+- `meta.requiresCoach`:`admin` 与 `coach` 均可访问。
|
|
|
|
|
+- 未登录时携带 `redirect` 参数跳转登录页,登录后返回原页面。
|
|
|
|
|
+
|
|
|
|
|
+布局菜单同样按角色过滤:顶部导航的“后台管理”入口只对 `admin`/`coach` 展示,后台侧边菜单按每条路由的 `meta` 过滤,用户管理仅对 `admin` 可见。
|
|
|
|
|
+
|
|
|
|
|
+角色取值:`admin`(管理员)、`coach`(教练)、`user`(普通用户)、`guest`(未登录)。
|
|
|
|
|
+
|
|
|
|
|
+## 状态管理
|
|
|
|
|
+
|
|
|
|
|
+Pinia 采用 setup 语法 store,并开启 `persist` 持久化,刷新后登录态与站点配置保留在 localStorage:
|
|
|
|
|
+
|
|
|
|
|
+- `useLoginUserStore`(`store/user.ts`):`loginUser` 状态、`userId` getter、`loginSuccess` / `logoutUser` 等 action。
|
|
|
|
|
+- `useMetaStore`(`store/meta.ts`):站点标题、Logo、后端地址。
|
|
|
|
|
+
|
|
|
|
|
+## 判题状态
|
|
|
|
|
+
|
|
|
|
|
+`util/submission.ts` 维护提交状态到展示文案的映射(`getStatusInfo`),覆盖 `SUBMITTED`、`JUDGING`、`AC`、`WA`、`TLE`、`MLE`、`RE`、`CE`、`SE`,并给出对应的 Element Plus 标签类型。
|
|
|
|
|
+
|
|
|
|
|
+## 开发约定
|
|
|
|
|
+
|
|
|
|
|
+- 时间展示统一使用 `util/format-time.ts` 的 `formatTime()`(`YYYY-MM-DD HH:mm`),无效值回退为 `-`。
|
|
|
|
|
+- Markdown 渲染统一使用 `util/markdown.ts` 导出的异步组件 `MdEditor` / `MdViewer`(按需加载,避免首屏引入编辑器体积)。
|
|
|
|
|
+- 组件使用 `<script setup lang="ts">`,路径引用使用 `@/` 别名。
|
|
|
|
|
+- 提交代码前先跑 `npm run build`(含 `vue-tsc` 严格类型检查)。
|