【KouOJ】前端部分

  • ~45.58K 字
  1. 1. 前端
    1. 1.1. 初始化
    2. 1.2. 项目结构
    3. 1.3. 挂载与注册
      1. 1.3.1. 创建路由
      2. 1.3.2. 修改main.ts
      3. 1.3.3. 修改App.vue
      4. 1.3.4. axios API封装
        1. 1.3.4.1. http.ts
        2. 1.3.4.2. api.ts
        3. 1.3.4.3. Problems.ts
    4. 1.4. 题目列表页
    5. 1.5. 题目详情页
    6. 1.6. 登录模块
      1. 1.6.1. 整体链路
      2. 1.6.2. loginview.vue
      3. 1.6.3. store/auth.ts
      4. 1.6.4. api/auth.ts
      5. 1.6.5. http.ts
      6. 1.6.6. 路由登录保护
    7. 1.7. 交题与判题模块
      1. 1.7.1. 基本流程
      2. 1.7.2. code部分
      3. 1.7.3. submission部分
      4. 1.7.4. handleSubmit
      5. 1.7.5. pollSubmission
      6. 1.7.6. 前后端交互
    8. 1.8. 提交记录查看
      1. 1.8.1. SubmissionListView.vue
      2. 1.8.2. 题目详情页内查看提交记录
    9. 1.9. 提交详情
      1. 1.9.1. 整体链路
      2. 1.9.2. 具体实现
    10. 1.10. 导航栏
      1. 1.10.1. App.vue
    11. 1.11. 注册模块
      1. 1.11.1. 整体链路
      2. 1.11.2. API
      3. 1.11.3. register.vue
    12. 1.12. 搜索与筛选
      1. 1.12.1. 流程
      2. 1.12.2. 后端支持
      3. 1.12.3. 前端逻辑
    13. 1.13. 分页
      1. 1.13.1. 原理
      2. 1.13.2. 操作
    14. 1.14. 条件同步URL
      1. 1.14.1. 原理
      2. 1.14.2. 实现
    15. 1.15. 一些美化
      1. 1.15.1. 判题状态显示
      2. 1.15.2. 难度状态显示
  2. 2. 美化
    1. 2.1. 替换模板残留
    2. 2.2. 整理App.vue
    3. 2.3. 题目详情页
      1. 2.3.1. 整体修改
      2. 2.3.2. 更换右侧编辑代码区域为codemirror
    4. 2.4. 提交列表页
    5. 2.5. 提交详情页

前端

我们拥有的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:页面入口的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文件下的:

  • 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<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&&currentPage.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 │
│ 题目 │ │
│ 状态 │ │
│ 耗时 │ │
│ 内存 │ │
└───────────────┴──────────────────────┘

┌──────────────────────────────────────┐
│ 测试点结果表格 │
└──────────────────────────────────────┘
赞助喵
非常感谢您的喜欢!
赞助喵