前端
我们拥有的API清单:
1 2 3 4 5 6 7 8 9 10 11 12 POST /api/auth/register/ POST /api/auth/login/ GET /api/auth/me/ GET /api/problems/ GET /api/problems/{id}/ POST /api/submissions/ GET /api/submissions/ GET /api/submissions/{id}/ GET /api/tags/
初始化
执行npm create vite@latest frontend,framework选择Vue,variant选择TypeScript
创建后进入目录下载依赖,启动本地服务器
1 2 3 cd frontendnpm install npm run dev
若可以看到vite的默认界面,说明创建成功
接下来需要安装一些依赖:
vue-router:负责页面路由
pinia:负责登录与token管理
axios:请求后端的API
element-plus:UI组件库
1 npm install vue-router pinia axios element-plus
项目结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 frontend/ index.html package.json vite.config.ts tsconfig.json src/ main.ts App.vue style.css assets/ components/ router/ views/ stores/ api/ types/
index.html:页面入口的html
src/main.ts:vue的启动入口
src/App.vue:根组件
vite.config.ts:vite配置
package.json:用到的依赖和脚本
启动流程:index-> main -> app -> others
挂载与注册
首先在src下创建若干目录
1 2 3 4 5 6 cd frontend mkdir src\router mkdir src\views mkdir src\stores mkdir src\api mkdir src\types
并预先在views下创建几个空页面
创建路由
接下来在router内创建路由文件index.ts,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 import { createRouter, createWebHistory } from "vue-router" ;import LoginView from "../views/LoginView.vue" ;import ProblemListView from "../views/ProblemListView.vue" ;import ProblemDetailView from "../views/ProblemDetailView.vue" ;const router = createRouter ({ history : createWebHistory (), routes : [ { path : '/' , redirect : '/problems' , }, { path : '/login' , name : 'login' , component : LoginView , }, { path : '/problems' , name : 'problem_list' , component : ProblemListView , }, { path : '/problems/:id' , name : 'problem-detail' , component : ProblemDetailView , }, ], }) export default router
定义了几个路由
/会自动跳转到problems
login是登录页
/problems是题目列表
/problems/id是对应id的题目的详情页
修改main.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import { createApp } from 'vue' import {createPinia} from 'pinia' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import './style.css' import App from './App.vue' import router from './router' const app = createApp (App )app.use (createPinia ()) app.use (router) app.use (ElementPlus ) app.mount ('#app' )
app.use(createPinia())注册状态管理
app.use(router)注册路由
app.use(ElementPlus)注册UI组件库
修改App.vue
1 2 3 <template> <router-view /> </template>
设定router-view,当前url对应哪个页面,就把哪个页面显示出来
axios API封装
我们统一在src/api/路径下请求后端
http.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import axios from "axios" ;const http = axios.create ({ baseURL : 'http://127.0.0.1:8000/api' , timeout : 10000 , }) http.interceptors .request .use ((config )=> { const token = localStorage .getItem ('access_token' ) if (token){ config.headers .Authorization = `bearer ${token} ` } return config; }) export default http
默认所有请求发到http://127.0.0.1:8000/api
若localstorage内有access_token,就自动带上请求头,如http.get('/problems/')会自动在链接后加上请求头
api.ts
类型文件,对应后台返回的JSON
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 export interface Tag { id : number name : string } export interface SampleCase { id : number input_data : string output_data : string order : number } export interface ProblemListItem { id : number title : string difficulty : 'easy' |'medium' |'hard' time_limit : number memory_limit : number tags : Tag [] } export interface ProblemDetail extends ProblemListItem { description : string input_description : string output_description : string is_public : boolean sample_cases : SampleCase [] created_at : string updated_at : string } export interface PaginatedResponse <T>{ count : number next : string |null previous : string |null results : T[] }
该文件存放了后端API返回数据的ts类型定义。即我们告诉ts后端返回的JSON的格式与数据类型是什么样的,它并不请求数据
其中最后的PaginatedResponse<T>是对应的DRF分页格式,对应接受的是后端传来的分页对象,通用类型之后会传来不同的类。如题目列表与提交列表等
Problems.ts
1 2 3 4 5 6 7 8 9 10 import http from './http' import type { PaginatedResponse , ProblemDetail , ProblemListItem } from '../types/api' export function getProblems ( ){ return http.get <PaginatedResponse <ProblemListItem >> ('/problems/' ) } export function getProblem (id : string |number ){ return http.get <ProblemDetail > (`/problems/${id} /` ) }
创建题目的api文件,后面的页面不用完整写url,可以直接调用对应方法
list页面接口返回分页文件,而detail不是分页
题目列表页
开始修改vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 <script setup lang="ts"> import { onMounted, ref } from 'vue'; import { RouterLink } from 'vue-router'; import { getProblems } from '../api/problems'; import type { ProblemListItem } from '../types/api'; const problems = ref<ProblemListItem[]>([]) const loading = ref(false) const errorMessage = ref('') async function loadProblems(){ loading.value = true; errorMessage.value = '' try{ const response = await getProblems() problems.value = response.data.results }catch(error){ errorMessage.value = '题目列表加载失败' } finally{ loading.value = true } } onMounted(()=>{ loadProblems() }) </script> <template> <main> <h1>ProblemList</h1> <p v-if="loading">加载中...</p> <p v-else-if="errorMessage">{{errorMessage}}</p> <ul v-else> <li v-for="problem in problems" :key="problem.id"> <router-link :to="`/problems/${problem.id}`"> {{problem.id}}. {{ problem.title }} </router-link> <span>- {{ problem.difficulty }}</span> </li> </ul> </main> </template>
该界面负责请求题目列表API,显示加载、错误状态,渲染题目列表,提供题目详情入口
对应后端接口GET /api/problems
数据按照如下流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ProblemListView.vue | v getProblems() | v http.get('/problems/') | v Django ProblemViewSet | v ProblemListSerializer | v 返回分页 JSON | v Vue 渲染题目列表
开头使用setup语法,为每一个组件实例执行,语言是ts
2-5行引入依赖
ref是响应式数据,数据变化后页面会自动更新
onMounted是生命周期函数,当组件挂载到页面后,执行某个操作
RouterLink用于页面跳转
getProblems是我们封装的api函数,用于发送请求头GET http://127.0.0.1:8000/api/problems/
problemlistitem是列表项的ts类型,import type表示只导入类型
7-9行是页面状态变量,它们控制页面状态
problems是题目列表,初始是空数组
loading表示是否在加载,请求开始时是true,结束时是false
errorMessage是错误提示,如果请求失败,就会显示错误信息
loadProblems函数是核心逻辑
请求后端作为异步操作,需要使用async/await
处理请求常用格式:try...catch...finally,try内请求题目列表数据,若失败则显示错误,无论成功与否把加载状态设为false
onMounted意为页面一打开就加载题目列表,会自动请求后端API
下面是vue的模板,使用条件渲染,如果loading是True则显示加载中,若有错误信息则显示,否则显示题目列表,vfor用于遍历problems数组,每个题目渲染一个li,:key="problem.id"用来更新列表
RouterLink用于生成一个可点击链接,可以进入详情页
题目详情页
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 <script setup lang="ts"> import { onMounted, ref } from 'vue' import { useRoute } from 'vue-router' import { getProblem } from '../api/problems' import type { ProblemDetail } from '../types/api' const route = useRoute(); const problem = ref<ProblemDetail | null>(null) const loading = ref(false) const errorMessage = ref('') async function loadProblem() { loading.value = true; errorMessage.value = '' try { const response = await getProblem(route.params.id as string) problem.value = response.data } catch(error) { errorMessage.value = '加载失败' } finally { loading.value = false } } onMounted(()=>{ loadProblem() }) </script> <template> <main> <p v-if="loading">加载中...</p> <p v-else-if="errorMessage"> {{ errorMessage }}</p> <article v-else-if="problem"> <h1>{{ problem.id }}. {{ problem.title }}</h1> <p> 难度:{{ problem.difficulty }} 时间限制:{{ problem.time_limit }} 内存限制:{{ problem.memory_limit }} </p> <section> <h2>Tags:</h2> <span v-for="tag in problem.tags"> {{ tag.name }} </span> </section> <section> <h2>题目描述</h2> <p>{{ problem.description }}</p> </section> <section> <h2>输入描述</h2> <p>{{ problem.input_description }}</p> </section> <section> <h2>输出描述</h2> <p>{{ problem.output_description }}</p> </section> <section> <h2>样例</h2> <div v-if="problem.sample_cases.length===0"> 暂无样例 </div> <div v-for="sample in problem.sample_cases" :key="sample.id"> <p>输入</p> <pre>{{ sample.input_data }}</pre> <p>输出</p> <pre>{{ sample.output_data }}</pre> </div> </section> </article> </main> </template>
与上一块的区别是这里面要先从路由里获取id,使用const route = useRoute()拿到当前路由对象,则route.params.id就是当前url的id
problem处使用了ref<ProblemDetail | null>(null),因为页面刚打开的时候,请求没有回来,一开始没有题目,所以要默认填充null,请求成功后才会有data
样例来自后端接口sample_cases,前端用v-for渲染
登录模块
整体链路
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 LoginView.vue | v authStore.login(username, password) | v api/auth.ts 的 login() | v http.ts 封装的 axios | v POST http://127.0.0.1:8000/api/auth/login/ | v Django SimpleJWT | v 返回 access / refresh token | v Pinia store 保存 token | v localStorage 保存 token | v 之后请求自动带 Authorization
loginview.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 <script setup lang="ts"> import { ref } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useAuthStore } from '../stores/auth' const router = useRouter() const route = useRoute() const authStore = useAuthStore() const username = ref('') const password = ref('') const loading = ref(false) const errorMessage = ref('') // 处理登录逻辑 async function handleLogin() { loading.value = true errorMessage.value = '' try { await authStore.login(username.value, password.value) // 跳转到problems或原页面 const redirect = route.query.redirect as string router.push(redirect||'/problems') } catch(error) { errorMessage.value = '用户名或密码错误' } finally { loading.value = false; } } </script> <template> <main> <h1>登录</h1> <form @submit.prevent=""handleLogin> <div> <label>用户名</label> <input v-model="username"> </div> <div> <label>密码</label> <input type="password" v-model="password"> </div> <p v-if="errorMessage">{{ errorMessage }}</p> <button type="submit" :disabled="loading" v-on:click="handleLogin()"> {{ loading?'登录中...': '登录' }} </button> </form> <p> 没有账号? <router-link to="/register">去注册</router-link> </p> </main> </template>
这里登录页的username和password负责收集用户输入,点击登录后,页面调用store里的登录操作,并传入用户输入的参数
store/auth.ts
这里是pinia store,负责保存登录状态
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 import { defineStore } from "pinia" ;import { login, getMe } from "../api/auth" ;import type { User } from "../types/api" ;export const useAuthStore = defineStore ('auth' , { state : ()=> ({ accessToken : localStorage .getItem ('access_token' ) || '' , refreshToken : localStorage .getItem ('refresh_token' ) || '' , user : null as User | null , }), getters : { isLoggedIn : (state ) => Boolean (state.accessToken ), }, actions : { async login (username : string , password : string ) { const response = await login ({username, password}) this .accessToken = response.data .access this .refreshToken = response.data .refresh localStorage .setItem ('access_token' , response.data .access ) localStorage .setItem ('refresh_token' , response.data .refresh ) await this .loadMe () }, async loadMe ( ) { const response = await getMe () this .user = response.data }, logout ( ) { this .accessToken = '' this .refreshToken = '' this .user = null localStorage .removeItem ('access_token' ) localStorage .removeItem ('refresh_token' ) }, }, })
const response = await login({ username, password })负责调用API层的login函数,后端返回access和refresh
store保存获得的数据并写入浏览器localStorage,防止刷新页面后状态丢失
api/auth.ts
负责认证相关API的封装
1 2 3 4 5 6 7 8 9 10 11 12 import http from "./http" ;import type { LoginRequest , LoginResponse , User } from "../types/api" ;export function login (data : LoginRequest ){ return http.post <LoginResponse >('/auth/login/' , data); } export function getMe ( ){ return http.get <User >('/auth/me/' ) }
将axios封装为两个函数,login函数用于请求,并返回两种token,getMe请求auth/me/获取当前用户详细信息,让前端知道当前登录用户是谁
这里api路由结尾要带/,如果少写了会让Django的post路由重定向出现问题
http.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 const http = axios.create({ baseURL: 'http://127.0.0.1:8000/api' , timeout: 10000 , }) http.interceptors.request.use((config) => { const token = localStorage.getItem('access_token' ) if (token) { config.headers.Authorization = `Bearer ${token}` } return config })
上面post头请求的就是这里的url/api/auth/login/
另外增加了一个请求拦截器,每次发送请求前,先看localStorage里有没有access_token,如果有就加到请求头中
这样只要登录一次,后面访问其他接口可以自动携带用户信息
其中,accesstoken用于访问接口,它的有效期较短,风险较小。当accesstoken过期后,可以使用refreshtoken更新,它的有效期长
路由登录保护
当前有一个问题,即后端实现了submission部分不允许未登录用户查看,我们进一步改为未登录用户点入submission部分后自动跳转login
另外,已登录用户也不能访问登录和注册页面
将相关路由改为如下,自定义路由信息,这里requiresAuth表示页面需要登录,guestOnly表示仅非登录游客
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 { path : '/submissions' , name : 'submission-list' , component : SubmissionListView , meta : { requiresAuth : true , }, }, { path : '/submissions/:id' , name : 'submission-detail' , component : SubmissionDetailView , meta : { requiresAuth : true , }, }, { path : '/login' , name : 'login' , component : LoginView , meta : { guestOnly : true , } }, { path : '/register' , name : 'register' , component : RegisterView , meta : { guestOnly : true , }, }
下面添加一个beforeEach函数,即在路由跳转前执行的回调函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 router.beforeEach ((to )=> { const token = localStorage .getItem ('access_token' ) if (to.meta .requiresAuth && !token) { return { path :'/login' , query : { redirect : to.fullPath , }, } } if (to.meta .guestOnly && token) { return '/problems' } return true })
to指的是目标路由对象
首先从本地存储中读取access_token的值
to.meta.requiresAuth判断对应路由是否需要存在需要登录的meta值
当需要登录且没有token时,重定向返回一个路由对象
path指定跳转到login页面
query.redirect保存原本想去的完整路径
当需要非登录但有token时,重定向返回至problems页面
交题与判题模块
基本流程
1 2 3 4 5 6 7 8 9 1. 输入代码 2. 点击提交 3. 前端 POST /api/submissions/ 4. 后端创建一条 Submission,状态为 PENDING 5. 前端拿到 submission id 6. 前端每隔 1 秒 GET /api/submissions/{id}/ 7. 后端返回当前状态 8. 如果还是 PENDING/JUDGING,继续轮询 9. 如果变成 AC/WA/TLE/RE,停止轮询并展示结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 ... const code = ref('') const submitting = ref(false) const submission = ref<Submission|null>(null) const submitError = ref('') let pollingTimer: number|null = null ... // 清除轮询 function clearPolling() { if (pollingTimer) { window.clearInterval(pollingTimer) pollingTimer = null } } // 轮询函数 async function pollSubmission(id: number) { clearPolling() async function fetchSubmission() { const response = await getSubmission(id) submission.value = response.data if (!['PENDING', 'JUDGING'].includes(response.data.status)) { clearPolling() } } await fetchSubmission() pollingTimer = window.setInterval(()=> { fetchSubmission() }, 1000) } // 提交函数 async function handleSubmit() { if (!problem.value) { return ; } submitting.value = true submitError.value = '' submission.value = null try { const response = await createSubmission( { problem: problem.value.id, language: 'python3', code: code.value, } ) await pollSubmission(response.data.id) } catch(error) { submitError.value = '提交失败' } finally { submitting.value = false } } ... onUnmounted(()=> { clearPolling() }) ... <section> <h2>提交代码</h2> <textarea v-model="code" rows="10" style="width: 100%; font-family: monospace;"></textarea> <div> <button type="button" :disabled="submitting" @click="handleSubmit"> {{ submitting?'提交中...':'提交' }} </button> </div> <p v-if="submitError">{{ submitError }}</p> <div v-if="submission"> <h3>判题结果</h3> <p>状态:{{ submission.status }}</p> <p>得分:{{ submission.score }}</p> <p>耗时:{{ submission.time_used }} ms</p> <p v-if="submission.error_message"> 错误信息:{{ submission.error_message }} </p> <div v-if="submission.results.length > 0"> <h4>测试点结果</h4> <ul> <li v-for="result in submission.results" :key="result.id"> #{{ result.testcase }} - {{ result.status }} - {{ result.time_used }} ms </li> </ul> </div> </div> </section>
code部分
用const code = ref('')保存输入的代码,对应vue里用v-model与code双向绑定,这样用户输入的同时code.value自动变化
const submitting = ref(false)表示正在提交请求,请求结束后设为false,防止用户点多次按钮同时发多个请求,这里不代表正在判题,对应vue里在submitting为true的时候,将button组件设为disabled可以实现
submission部分
const submission = ref<Submission | null>(null)用来保存当前提交的完整结果,缺省为null,提交后轮询获得结果,对应vue里当有提交结果时才展示提交状态
const submitError = ref('')保存提交失败提示,对应vue里显示提交错误与错误信息
handleSubmit
首先判断题目是否为空,非空才继续
提交前首先重置状态,清除上一次的结果
createSubmission调用了POST /api/submission/,向后端发送一个请求体
提交成功后,后端返回response,我们提取id用来查询
pollSubmission
在这之前设置了一个清除轮询的函数,方便后面调用
由于判题是通过后端的判题机进行,所以前端要轮询结果
let pollingTimer: number | null = null保存轮询定时器ID
首先要清除旧轮询,否则结果可能覆盖
setInterval用于设定查询间隔,每隔1000ms执行一次函数,返回一个定时器ID传给pollingTimer
提交后立刻查询一次,之后一秒内查询一次请求const response = await getSubmission(id),并更新页面submission.value = response.data
判断如果状态不是pending或judging,表示判题结束,停止轮询,并clearInterval,设置定时器为空
最后页面卸载时执行清理轮询,防止用户离开详情页后定时器还在处理请求
前后端交互
前端通过createSubmission()创建POST请求,通过后端的serializers转换并传入submission,状态设为pending
前端通过getSubmission(id)创建GET请求,从后端的serializers读取对应ID的JSON,并返回到status/results
提交记录查看
SubmissionListView.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 <script setup lang="ts"> import { ref, onMounted } from 'vue'; import { RouterLink } from 'vue-router'; import { getSubmissions } from '../api/submission'; import type { Submission } from '../types/api'; const submissions = ref<Submission[]> ([]); const loading = ref(false) const errorMessage = ref('') async function loadSubmissions() { loading.value = true; errorMessage.value = '' try { const response = await getSubmissions() submissions.value = response.data.results } catch(error) { errorMessage.value = ("提交记录页出现问题") } finally { loading.value = false; } } onMounted(()=> { loadSubmissions() }) </script> <template> <main> <h1>提交记录</h1> <p v-if="loading">加载中...</p> <p v-else-if="errorMessage"></p> <table v-else border="1" cellpadding="8"> <thead> <tr> <th>ID</th> <th>题目</th> <th>语言</th> <th>状态</th> <th>得分</th> <th>耗时</th> <th>消耗内存</th> <th>提交时间</th> </tr> </thead> <tbody> <tr v-for="submission in submissions" :key="submission.id"> <td> <RouterLink :to="`/submissions/${submission.id}`"> # {{ submission.id }} </RouterLink> </td> <td> <RouterLink :to="`/problems/${submission.problem}`"> {{ submission.problem }} </RouterLink> </td> <td>{{ submission.language }}</td> <td>{{ submission.status }}</td> <td>{{ submission.score }}</td> <td>{{ submission.time_used }}</td> <td>{{ submission.memory_used }}</td> <td>{{ submission.created_at }}</td> </tr> </tbody> </table> </main> </template>
和题目列表页差异不大,区别在于submission后端必须要求登录
题目详情页内查看提交记录
实现在/problems/:id中添加查看提交页面,点击进入相应的/submissions?problem=1
首先在题目详情页添加链接:
1 2 3 <router-link :to="`/submissions?problem=${problem.id}`"> 提交记录 </router-link>
提交详情
整体链路
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 /submissions | | 点击 #12 v /submissions/12 | v SubmissionDetailView.vue | v getSubmission(12) | v GET /api/submissions/12/ | v Django SubmissionViewSet | v SubmissionReadSerializer | v 返回提交详情 JSON | v Vue 渲染状态、代码、测试点结果
具体实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 <script setup lang="ts"> import { ref, onMounted } from 'vue'; import { useRoute } from 'vue-router'; import { getSubmission } from '../api/submission'; import type { Submission } from '../types/api'; const route = useRoute() const submission = ref<Submission|null>(null) const loading = ref(false) const errorMessage = ref('') async function loadSubmission() { loading.value = true errorMessage.value = '' try { const response = await getSubmission(route.params.id as string) submission.value = response.data } catch(error) { errorMessage.value = '提交详情加载错误' } finally { loading.value = false } } onMounted(()=> { loadSubmission() }) </script> <template> <main> <p v-if="loading">加载中...</p> <p v-else-if="errorMessage"> {{ errorMessage }}</p> <article v-else-if="submission"> <h1>提交 #{{ submission.id }}</h1> <p>用户:{{ submission.username }}</p> <p>题目:{{ submission.problem_title }}</p> <p>语言:{{ submission.language }}</p> <p>状态:{{ submission.status }}</p> <p>得分:{{ submission.score }}</p> <p>耗时:{{ submission.time_used }}</p> <p>消耗内存:{{ submission.memory_used }}</p> <p>提交时间:{{ submission.created_at }}</p> <p>判题时间:{{ submission.judged_at }}</p> <section> <h2>测试点结果</h2> <p v-if="submission.results.length===0"> 暂无测试点信息 </p> <table v-else border="1" cellpadding="8"> <thead> <tr> <th>测试点</th> <th>状态</th> <th>耗时</th> <th>消耗内存</th> <th>输出</th> <th>错误信息</th> </tr> </thead> <tbody> <tr v-for="result in submission.results" :key="result.id""> <td>{{ result.testcase }}</td> <td>{{ result.status }}</td> <td>{{ result.time_used }}</td> <td>{{ result.memory_used }}</td> <td>{{ result.output }}</td> <td>{{ result.error_message }}</td> </tr> </tbody> </table> </section> </article> </main> </template>
提交详情部分对应着接口GET /api/submission/{id}
其中路由部分定义了路径:id,是动态路由参数,用于访问指定的id对应的页面
页面中使用useRouter获取当前id,用该id请求后端的提交详情
API函数也应该包括具体submission的部分:
1 2 3 4 export function getSubmission (id : string |number ){ return http.get <Submission >(`/submissions/${id} /` ) }
导航栏
App.vue
流程图:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 App.vue 加载 | v 检查 localStorage 有没有 token | v 有 token 且 user 为空 | v 调用 /api/auth/me/ 恢复用户信息 | v 导航栏显示用户名和退出按钮 没有 token | v 导航栏显示登录链接
该vue页面是整个vue的根组件,所有页面都会实现该vue中的元素,所以适合放一些公共布局
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 <script setup lang="ts"> import { onMounted } from 'vue'; import { RouterLink, RouterView, useRouter } from 'vue-router'; import { useAuthStore } from './stores/auth'; // 获取pinia的登陆状态仓库 const authStore = useAuthStore() // 拿到路由控制器,用于后面主动跳转页面 const router = useRouter() // 控制登出 function handleLogout() { authStore.logout() router.push('/login') } /** * 页面刷新后恢复用户信息 * 刷新以后pinia中的user会丢掉,所以要从localStorage中拿取token * 当App启动时,如果发现有token,就请求GET api/auth/me */ onMounted(()=> { if (authStore.isLoggedIn && !authStore.user) { authStore.loadMe().catch(()=>{authStore.logout()}) } }) </script> <!-- 导航栏 --> <template> <div> <header> <nav> <div class="logo"><router-link to="/problems">KouOJ</router-link></div> <router-link to="/problems">题目</router-link> <router-link to="/submissions">提交记录</router-link> <span v-if="authStore.user"> {{ authStore.user.username }} </span> <button v-if="authStore.isLoggedIn" type="button" @click="handleLogout"> 退出 </button> <span v-else> <router-link to="/login">登录 </router-link> <router-link to="/register">注册</router-link> </span> </nav> </header> </div> <router-view /> </template>
router-view用于vue界面的出口,当前URL匹配到哪个路由,就渲染哪个页面组件
useAuthStore拿到pinia的登陆状态仓库,可以根据登录状态来决定显示什么
isLoggedIn返回布尔值,若当前accessToken不为空,就认为当前是登陆状态
显示用户名等地方,需要用authStore检测是否是user对象,若是则返回从GET /api/auth/me内获取data,即loadMe()
由于pinia默认是内存状态,因此刷新后authStore都会清空,但是token还在,因此我们写一个onMouted来判断若有token但是没有用户信息,就请求对应头恢复用户信息
若token过期或失效,就执行logout,把所有相关内容清空
handleLogout函数内有两种操作:清理登录状态与跳转登录页
useRouter这里作为路由控制器,可以让代码主动跳转到登录页,而不是routerLink那种点击跳转
因此这里的前端登录状态分两层,pinia负责当前页面运行时的状态,刷新后就会丢失,而localStore持久保存登录token,刷新后可以重新获取user
注册模块
注册模块对应的接口是POST /api/auth/register,注册成功后数据库内多一个用户
整体链路
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 用户打开 /register | v 输入 username/email/password | v 点击注册 | v RegisterView.vue handleRegister() | v api/auth.ts register() | v POST /api/auth/register/ | v Django RegisterView | v RegisterSerializer 校验数据 | v create_user() 加密密码并保存用户 | v MySQL accounts_user 新增记录 | v 返回 id/username/email | v 前端跳转 /login
API
1 2 3 4 export function register (data : RegisterRequest ){ return http.post <RegisterResponse >('/auth/register/' , data) }
请求POST http://127.0.0.1:8000/api/auth/register/,请求体类型如下:
1 2 3 4 5 export interface RegisterRequest { username : string email : string password : string }
请求后后端返回:
1 2 3 4 5 export interface RegisterResponse { id : number username : string email : string }
注意不返回密码
register.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 <script setup lang="ts"> import { ref } from 'vue'; import { RouterLink, useRouter } from 'vue-router'; import { register } from '../api/auth'; const router = useRouter() const username = ref('') const email = ref('') const password = ref('') const loading = ref(false) const errorMessage = ref('') async function handleRegister() { loading.value = true; errorMessage.value = '' try { await register( { username: username.value, email: email.value, password: password.value, } ) router.push('/login') } catch(error) { errorMessage.value = '注册失败' } finally { loading.value = false } } </script> <template> <main> <h1>注册</h1> <form @submit.prevent="handleRegister"> <div> <label>用户名</label> <input v-model="username"> </div> <div> <label>邮箱</label> <input type="email" v-model="email"> </div> <div> <label>密码</label> <input type="password" v-model="password"> </div> <p v-if="errorMessage">{{ errorMessage }}</p> <button type="submit" :disabled="loading">{{ loading?'注册中...':'注册' }}</button> </form> <p> 已有账号? <router-link to="/login">去登录</router-link> </p> </main> <
注册页面负责收集表单
username, email,
password对应三个input框,使用v-model用于随时变动ref
点击注册后,执行注册逻辑,这里加了一个阻止浏览器默认刷新页面
handleRegister函数用于处理注册逻辑,主要逻辑就是调用注册API
搜索与筛选
之前我们在后端已经写了有关搜索和筛选的请求,现在可以接到前端上来
具体是在/apps/problems/view.py文件下的:
这里本质是前端把query参数传给后端,后端根据参数过滤查询集,再返回符合条件的题目列表
流程
用户在题目列表页输入关键词和难度筛选
点击搜索后,前端自动拼接请求GET /api/problems/?search=搜索内容&difficulty=难度
后端收到后在标签和标题中搜索带有搜索内容的数据,并只保留difficulty是easy的题,最后返回分页结果
后端支持
后端在/apps/problems/views.vue中支持了搜索和筛选
1 2 3 filterset_fields = ("difficulty" , "tags" ) search_fields = ("title" , "description" ) ordering_fields = ("id" , "created_at" , "difficulty" )
这些定义的字段可以直接
用于settings.py里的DRF配置,可以直接调用DRF里的筛选和搜索逻辑
前端逻辑
首先我们添加了ProblemQuery的API
1 2 3 4 export interface ProblemQuery { search ?: string difficulty ?: 'easy' | 'medium' | 'hard' | '' }
表示请求列表时,可以传哪些查询参数,其中?表示可选
后端difficulty包含三个难度,前端有一个全部难度,让他对应空字符串,但是发送请求时,我们会把空字符串转为undefined,不发给后端
然后修改getProblems
1 2 3 4 5 export function getProblems (params ?: ProblemQuery ) { return http.get <PaginatedResponse <ProblemListItem >>('/problems/' , { params, }) }
这里axios的params会自动拼接querystring
ProblemListView.vue修改实现搜索和筛选功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ... const search = ref('') const difficulty = ref<'easy' | 'medium' | 'hard' | ''>('') ... const response = await getProblems({ search: search.value || undefined, difficulty: difficulty.value || undefined, }) ... <form @submit.prevent="loadProblems"> <input v-model="search" placeholder="搜索题目" /> <select v-model="difficulty"> <option value="">全部难度</option> <option value="easy">简单</option> <option value="medium">中等</option> <option value="hard">困难</option> </select> <button type="submit">搜索</button> <button type="button" @click="search=''; difficulty=''; loadProblems()">重置</button> </form>
前面两个变量存搜索输入内容和难度下拉框
getProblems中修改如上,意思是如果search内有内容,就传后端,如果是空字符串,就传undefined,difficulty同理
这里不传字符串更清晰
模板部分新建一个表单,提交时调用loadProblems,并阻止浏览器刷新,否则提交时就会导致刷新
重置按钮实现三件事,清空关键词,难度,重新加载题目
提交列表部分同样操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <form @submit.prevent="loadSubmissions(1)"> <select v-model="selectedStatus" @change="loadSubmissions(1)"> <option value="">全部状态</option> <option value="PENDING">Pending</option> <option value="JUDGING">Judging</option> <option value="ACCEPTED">Accepted</option> <option value="WRONG_ANSWER">Wrong Anwser</option> <option value="TIME_LIMIT_EXCEEDED">Time Limit Exceeded</option> <option value="RUNTIME_ERROR">Runtime Error</option> <option value="SYSTEM_ERROR">System</option> </select> <button type="button" @click="selectedStatus='';loadSubmissions(1)"> 重置 </button> </form>
分页
原理
分页本质是接受前端的page参数,后端返回数据与next/previous
前端根据得到的next/previous判断按钮是否可用
操作
后端DRF返回的是分页结构,但是前端目前没有设置分页
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // 分页相关 const currentPage = ref(1) const totalCount = ref(0) const hasNext = ref(false) const hasPrevious = ref(false) async function loadProblems(page = currentPage.value){ loading.value = true; errorMessage.value = '' try{ const response = await getProblems( { search: search.value || undefined, difficulty: difficulty.value || undefined, tags: selectedTag.value || undefined, page, } ) problems.value = response.data.results // 分页相关 totalCount.value = response.data.count hasNext.value = Boolean(response.data.next) hasPrevious.value = Boolean(response.data.previous) currentPage.value = page }catch(error){ errorMessage.value = '题目列表加载失败' } finally{ loading.value = false } }
首先在problems.ts中的problemQuery加一个page,用于请求对应页面
对应Vue文件中修改loadproblem多接受一个页码,并将函数体修改为接收分页相关变量值
接下来添加跳转页面函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function goPreviousPage ( ) { if (hasPrevious.value &¤tPage.value >1 ) { loadProblems (currentPage.value -1 ) } } function goNextPage ( ) { if (hasNext.value ) { loadProblems (currentPage.value +1 ) } }
模板里加入相应组件:
1 2 3 4 5 6 7 8 9 10 <div class="pagination"> <button type="button" :disabled="!hasPrevious" @click="goPreviousPage"> 上一页 </button> <span>第{{ currentPage }}页</span> <span>共{{ totalCount }}页</span> <button type="button" :disabled="!hasNext" @click="goNextPage"> 下一页 </button> </div>
submissionList处加入同样的组件,api如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 export function getSubmissions (params ?: SubmissionQuery ){ return http.get <PaginatedResponse <Submission >>('/submissions/' , {params,}) } export interface SubmissionQuery { page ?: number status ?: string problem ?: number }
条件同步URL
原理
这里我们将query同步到URL后面,具体的说,URL中?后面的称为query,表示筛选条件
与query对应的是params参数
类型
示例
Vue 读取
params
/submissions/12
route.params.id
query
/submissions?problem=1
route.query.problem
query适用于只看某些条件的内容
对于类似于在题目详情页内查看当前题目提交记录,你需要有办法让提交记录页知道你要看哪道题,因此需要将problem的id放在url后面,直接query对应的内容
附加的,将page和status加入query,也可以防止刷新后筛选条件丢失等,可以优化页面
实现
useRoute可以读取到路由的query,读取出来一般是string或string数组
1 2 3 const problemQuery = route.query .problem const statusQuery = route.query .status const pageQuery = route.query .page
前端通过router的replace方法写入query
1 2 3 4 5 6 7 8 9 10 router.replace ( { path :'/submissions' , query : { page : String (page), problem : selectedProblem.value ?String (selectedProblem.value ):undefined , status : selectedStatus.value ||undefined , }, })
最终的请求类似于GET /api/submissions/?problem=1&status=ACCEPTED&page=2
后端看到后会自动通过filter处理
修改SubmissionListView,添加url查询参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 const selectedProblem = ref<number |undefined >(undefined )... function syncQuery (page : number ){ router.replace ( { path :'/submissions' , query : { page : String (page), problem : selectedProblem.value ?String (selectedProblem.value ):undefined , status : selectedStatus.value ||undefined , }, }) } syncQuery (page)const response = await getSubmissions ({ page, status : selectedStatus.value ||undefined , problem : selectedProblem.value , }) ... onMounted (()=> { const problemQuery = route.query .problem if (typeof problemQuery==='string' ) { selectedProblem.value = Number (problemQuery) } loadSubmissions (1 ) })
通过useRoute的.query.problem得到query,注意要转成数字
使用query适合于在连接中保存筛选条件
一些美化
判题状态显示
让判题状态结果上色并显示指定的别名
在/utils文件夹内创建status.ts,存储状态相关的工具,防止多次调用要重新写
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 import type { SubmissionStatus } from "../types/api" ;export function getStatusText (status : SubmissionStatus ){ const map : Record <SubmissionStatus , string > = { PENDING : 'Pending' , JUDGING : 'Judging' , ACCEPTED : 'Accepted' , WRONG_ANSWER : 'Wrong Answer' , TIME_LIMIT_EXCEEDED : 'Time Limit Exceeded' , RUNTIME_ERROR : 'Runtime Error' , SYSTEM_ERROR : 'System Error' , } return map[status] } export function getStatusClass (status : SubmissionStatus ){ const map : Record <SubmissionStatus , string > = { PENDING : 'status-pd' , JUDGING : 'status-jg' , ACCEPTED : 'status-ac' , WRONG_ANSWER : 'status-wa' , TIME_LIMIT_EXCEEDED : 'status-tle' , RUNTIME_ERROR : 'status-re' , SYSTEM_ERROR : 'status-se' , } return map[status] }
getStatusText用于将对应状态改为想要的名字
getStatusClass用于给每个状态一个css类,方便在vue里修改颜色等样式
难度状态显示
让难度上色并指定别名,和上面一样的逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import type { ProblemListItem } from "../types/api" ;export function getDifficultyText (difficulty : ProblemListItem ['difficulty' ] ){ const map : Record <ProblemListItem ['difficulty' ], string > = { easy : '简单' , medium : '中等' , hard : '困难' , } return map[difficulty] } export function getDifficultyClass (difficulty : ProblemListItem ['difficulty' ] ){ const map : Record <ProblemListItem ['difficulty' ], string > = { easy : 'difficulty-easy' , medium : 'difficulty-medium' , hard : 'difficulty-hard' , } return map[difficulty] }
美化
替换模板残留
在style.css内修改,目的是统一
背景色
页面宽度
section 卡片样式
表格样式
按钮、输入框、textarea
pre 代码块样式
整理App.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 <header class="app-header"> <nav class="app-nav"> <RouterLink class="brand" to="/">KouOJ</RouterLink> <div class="nav-links"> <RouterLink to="/">首页</RouterLink> <RouterLink to="/problems">题目</RouterLink> <RouterLink to="/submissions">提交记录</RouterLink> </div> <div class="nav-user"> <template v-if="authStore.user"> <RouterLink to="/profile"> {{ authStore.user.nickname || authStore.user.username }} </RouterLink> <button type="button" @click="handleLogout">退出</button> </template> <template v-else> <RouterLink to="/login">登录</RouterLink> <RouterLink to="/register">注册</RouterLink> </template> </div> </nav> </header> <RouterView />
题目详情页
1 2 3 4 5 6 7 8 9 ProblemDetailView ┌───────────────────────────────┬──────────────────────────────┐ │ 左侧:题目详情 │ 右侧:代码编辑/判题结果 │ │ - 标题、难度、限制 │ - 语言选择 │ │ - 题面 │ - 代码编辑器 │ │ - 输入/输出/样例 │ - 提交按钮 │ │ - 查看题解 │ - 判题结果 │ │ - 提交记录 │ │ └────────────── 可拖动分隔条 ────┴──────────────────────────────┘
整体修改
vue里添加对应方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 const leftWidth = ref (55 )const isResizing = ref (false )... function startResize ( ){ isResizing.value = true window .addEventListener ('mousemove' , handleResize) window .addEventListener ('mouseup' , stopResize) } function handleResize (event : MouseEvent ){ if (!isResizing.value ) { return } const windowWidth = window .innerWidth const nextWidth = (event.clientX / windowWidth) * 100 if (nextWidth < 35 || nextWidth > 70 ) { return } leftWidth.value = nextWidth } function stopResize ( ){ isResizing.value = false window .removeEventListener ('mousemove' , handleResize) window .removeEventListener ('mouseup' , stopResize) }
template里优化为两部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 <article v-else-if="problem" class="ide-layout" :style="{ gridTemplateColumns: `${leftWidth}% 6px 1fr` }" > <div class="problem-pane"> <section class="problem-hero"> <div> <h1>#{{ problem.id }} {{ problem.title }}</h1> <div class="problem-meta"> <span :class="getDifficultyClass(problem.difficulty)"> {{ getDifficultyText(problem.difficulty) }} </span> <span>{{ problem.time_limit }} ms</span> <span>{{ problem.memory_limit }} MB</span> </div> </div> <div class="problem-actions"> <RouterLink class="secondary-button" :to="`/submissions?problem=${problem.id}`"> 提交记录 </RouterLink> <RouterLink class="secondary-button" :to="`/problems/${problem.id}/solutions`"> 查看题解 </RouterLink> </div> </section> <section> <h2>标签</h2> <div class="tag-list"> <span v-for="tag in problem.tags" :key="tag.id" class="tag"> {{ tag.name }} </span> </div> </section> <section> <h2>题目描述</h2> <p>{{ problem.description }}</p> </section> <section> <h2>输入描述</h2> <p>{{ problem.input_description }}</p> </section> <section> <h2>输出描述</h2> <p>{{ problem.output_description }}</p> </section> <section> <h2>样例</h2> <div v-if="problem.sample_cases.length === 0">暂无样例</div> <div v-for="sample in problem.sample_cases" :key="sample.id" class="sample-case"> <p>输入</p> <pre>{{ sample.input_data }}</pre> <p>输出</p> <pre>{{ sample.output_data }}</pre> </div> </section> </div> <div class="resize-handle" @mousedown="startResize"></div> <div class="editor-pane"> <div class="editor-toolbar"> <h2>提交代码</h2> <select v-model="submitLanguage"> <option value="python3">Python3</option> <option value="c">C</option> <option value="cpp">C++</option> </select> <button class="primary-button" type="button" :disabled="submitting" @click="handleSubmit"> {{ submitting ? '提交中...' : '提交' }} </button> </div> <textarea v-model="code" class="ide-editor" rows="12"></textarea> <p v-if="submitError">{{ submitError }}</p> <div v-if="submission" class="judge-result"> <h3>判题结果</h3> <p> 状态: <span :class="getStatusClass(submission.status)"> {{ getStatusText(submission.status) }} </span> </p> <p>得分:{{ submission.score }}</p> <p>耗时:{{ submission.time_used }} ms</p> <p v-if="submission.error_message">错误信息:{{ submission.error_message }}</p> <div v-if="submission.results.length > 0"> <h4>测试点结果</h4> <ul> <li v-for="result in submission.results" :key="result.id"> #{{ result.testcase }} - <span :class="getStatusClass(result.status)"> {{ getStatusText(result.status) }} </span> - {{ result.time_used }} ms </li> </ul> </div> </div> </div> </article>
更换右侧编辑代码区域为codemirror
npm下载包codemirror6
1 2 npm install vue-codemirror6 codemirror npm install @codemirror/lang-python @codemirror/lang-cpp @codemirror/theme-one-dark
导包
1 2 3 4 5 6 import { computed } from 'vue' import Codemirror from 'vue-codemirror6' import { python } from '@codemirror/lang-python' import { cpp } from '@codemirror/lang-cpp' import { oneDark } from '@codemirror/theme-one-dark'
根据语言切换高亮
1 2 3 4 5 6 7 8 9 10 11 12 const editorExtensions = computed (()=> { if (submitLanguage.value ==='python3' ) { return [python (), oneDark] } else if (submitLanguage.value ==='c' || submitLanguage.value ==='cpp' ) { return [cpp (), oneDark] } })
提交列表页
添加了一列显示用户信息的地方
1 2 3 4 5 6 7 8 9 <td> <RouterLink :to="`/problems/${submission.problem}`"> {{ submission.problem_title || submission.problem }} </RouterLink> </td> <td>{{ submission.username }}</td> <td>{{ submission.language }}</td>
提交详情页
布局如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 SubmissionDetail ┌──────────────────────────────────────┐ │ #123 Accepted │ │ A+B Problem / Python3 / 100 分 │ └──────────────────────────────────────┘ ┌───────────────┬──────────────────────┐ │ 提交信息 │ 代码 │ │ 用户 │ code block/editor │ │ 题目 │ │ │ 状态 │ │ │ 耗时 │ │ │ 内存 │ │ └───────────────┴──────────────────────┘ ┌──────────────────────────────────────┐ │ 测试点结果表格 │ └──────────────────────────────────────┘