前端
我们拥有的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 frontend
npm 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:页面入口的htmlsrc/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
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定义了几个路由
/会自动跳转到problemslogin是登录页/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
<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
<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的idproblem处使用了
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
之后请求自动带 Authorizationloginview.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
登录
没有账号?
去注册
这里登录页的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(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()
})
...
提交代码
{{ submitError }}
判题结果
状态:{{ submission.status }}
得分:{{ submission.score }}
耗时:{{ submission.time_used }} ms
错误信息:{{ submission.error_message }}
测试点结果
-
#{{ result.testcase }} - {{ result.status }} - {{ result.time_used }} ms
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
<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
前端跳转 /loginAPI
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文件下的:
filterset_fields筛选字段,可以通过类似
GET /api/problems/?difficulty=easy请求来筛选search_fields搜索,可以通过类似
GET /api/problems/?search=A+B请求来在给定的字段查询
这里本质是前端把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>('/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)
...
// 封装更新url的函数
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
// ide高亮
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 │
│ 题目 │ │
│ 状态 │ │
│ 耗时 │ │
│ 内存 │ │
└───────────────┴──────────────────────┘
┌──────────────────────────────────────┐
│ 测试点结果表格 │
└──────────────────────────────────────┘