红魔咖啡馆

头发越掉越多,头发越掉越少

0%

【KouOJ】前端部分

前端

我们拥有的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
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
<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的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

这里登录页的usernamepassword负责收集用户输入,点击登录后,页面调用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
前端跳转 /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>('/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    │
│ 题目           │                      │
│ 状态           │                      │
│ 耗时           │                      │
│ 内存           │                      │
└───────────────┴──────────────────────┘

┌──────────────────────────────────────┐
│ 测试点结果表格                         │
└──────────────────────────────────────┘