Explorar el Código

学生参加考试界面原型

ChengYuXuan hace 5 años
padre
commit
5bc28cf4af

+ 3 - 0
package.json

@@ -23,6 +23,8 @@
   },
   "devDependencies": {
     "@koa/cors": "^3.1.0",
+    "@types/marked": "^1.2.1",
+    "@types/ramda": "^0.27.34",
     "@typescript-eslint/eslint-plugin": "^2.33.0",
     "@typescript-eslint/parser": "^2.33.0",
     "@vue/cli-plugin-babel": "~4.5.0",
@@ -41,6 +43,7 @@
     "koa-logger": "^3.2.1",
     "lint-staged": "^9.5.0",
     "mockjs": "^1.1.0",
+    "path-to-regexp": "^6.2.0",
     "prettier": "^1.19.1",
     "sass": "^1.19.0",
     "sass-loader": "^8.0.0",

+ 9 - 0
src/api/exam.ts

@@ -0,0 +1,9 @@
+import axios, { ResponseType } from 'axios'
+import { EXAM_MODULE } from './prefix'
+import { ExamSerializer, ReceivedData, Pageable, ExamStatus } from '@/api/types'
+
+export const getAllExamList = (params: Pageable<{ status: ExamStatus }>) => {
+  return axios.get<ReceivedData<Array<ExamSerializer>>>(`${EXAM_MODULE}`, {
+    params
+  })
+}

+ 3 - 0
src/api/prefix.ts

@@ -0,0 +1,3 @@
+const BASE_URL = '/api'
+
+export const EXAM_MODULE = BASE_URL + '/exam'

+ 70 - 0
src/api/types.d.ts

@@ -0,0 +1,70 @@
+import { AxiosRequestConfig } from 'axios'
+
+export interface AxiosResponseInfo {}
+
+export interface ReceivedData<R = {}, E = 0> extends AxiosResponseInfo {
+  err?: E | 0 | 401
+  res?: R
+  page?: PageSerializer
+}
+
+export interface ErrorData extends AxiosResponseInfo {
+  err: number
+  msg: string
+}
+
+export interface PageSerializer {
+  page?: number
+  limit?: number
+  size?: number
+  total?: number
+}
+
+export type Pageable<T = {}> = T & {
+  page?: number
+  limit?: number
+}
+
+export type LocalDateTime = string
+
+export type ExamStatus =
+  | 'READY' // 考试已准备好,考试尚未开始
+  | 'AVAILABLE' // 考试可用,表示考试正在进行
+  | 'FINISHED' // 考试已完成,在考试结束后使用
+  | 'CLOSED' // 考试已关闭,表示考试生命周期结束
+
+export interface ExamSerializer {
+  id?: number
+  title?: string
+  startAt?: LocalDateTime
+  endAt?: LocalDateTime
+  status?: ExamStatus
+  creator?: {
+    id?: number
+    username?: string
+  }
+  joined?: boolean
+  // result?: ResultSerializer
+  isReentryPermitted?: boolean
+  inviteCode?: string
+}
+
+export interface ResultSerializer {
+  id?: number
+  score?: number
+  lastCommitAt?: LocalDateTime
+  exam?: {
+    id?: number
+    title?: string
+  }
+  owner?: {
+    id?: number
+    username?: string
+  }
+  commits?: Array<{
+    id?: number
+    finished?: boolean
+    score?: number
+    createdAt?: LocalDateTime
+  }>
+}

+ 7 - 7
src/components/PageHeader.vue

@@ -1,14 +1,14 @@
 <template>
   <div class="pa-4">
-    <div v-if="title" class="d-flex">
-      <div class="flex-grow-1">
+    <v-row v-if="title" class="d-flex" align="end">
+      <v-col class="flex-grow-1">
         <h1 class="text-h3">{{ title }}</h1>
         <v-breadcrumbs :items="breadcrumb" class="pl-0"></v-breadcrumbs>
-      </div>
-      <div class="flex-shrink-0">
+      </v-col>
+      <v-col class="flex-shrink-0">
         <slot name="extra"></slot>
-      </div>
-    </div>
+      </v-col>
+    </v-row>
 
     <v-subheader v-if="subtitle">{{ subtitle }}</v-subheader>
     <v-tabs
@@ -25,7 +25,7 @@
         >{{ item.name }}</v-tab
       >
     </v-tabs>
-
+    <v-divider class="mt-2"></v-divider>
     <slot></slot>
   </div>
 </template>

+ 0 - 2
src/main.ts

@@ -3,9 +3,7 @@ import App from './App.vue'
 import router from './router'
 import store from './store'
 import vuetify from './plugins/vuetify'
-
 Vue.config.productionTip = false
-
 new Vue({
   router,
   store,

+ 5 - 0
src/mock/api/exam/get.json

@@ -0,0 +1,5 @@
+{
+  "err": 0,
+  "res|5": ["@EXAM_SERIALIZER"],
+  "page": "@PAGE_SERIALIZER"
+}

+ 69 - 0
src/mock/index.js

@@ -0,0 +1,69 @@
+const fs = require('fs').promises
+const path = require('path')
+const Koa = require('koa')
+const { pathToRegexp } = require('path-to-regexp')
+const cors = require('@koa/cors')
+const logger = require('koa-logger')
+const { Random, mock } = require('mockjs')
+// const randexp = require('randexp').randexp
+const installSerializers = require('./serializers')
+
+const PORT = 5000
+const API_ROOT = 'api'
+
+installSerializers(Random, mock)
+
+const app = new Koa()
+
+app.use(cors())
+app.use(logger())
+app.use(async (ctx) => {
+  const request = ctx.request
+  const method = request.method.toLocaleLowerCase()
+  const pathname = request.path
+  const routes = await traverseDir(API_ROOT)
+
+  const matchedRoute = routes.find((route) => {
+    const regexp = pathToRegexp(`/${route}`.replace(/\/_/g, '/:'))
+    return regexp.test(pathname)
+  })
+
+  if (matchedRoute) {
+    const filePath = path.join(__dirname, matchedRoute, `${method}.json`)
+    const rawData = await fs.readFile(filePath)
+    ctx.append('authorization', 'basic test-token')
+    ctx.body = mock(JSON.parse(rawData))
+  } else {
+    ctx.throw(404, `pathname or method not found: ${method} ${pathname}`)
+  }
+})
+
+app.listen(PORT)
+
+console.log(`mock start at http://localhost:${PORT}`)
+
+/**
+ * list all dir path
+ * @param {string} dir root path
+ */
+async function traverseDir(dir) {
+  const basePath = path.join(__dirname, `${dir}`)
+  const fileList = await fs.readdir(basePath)
+
+  const allFiles = []
+  let hasCurrentDir = false
+
+  for (const filename of fileList) {
+    const filepath = path.join(basePath, filename)
+    const stat = await fs.lstat(filepath)
+    const unixFilePath = `${dir}/${filename}`
+    if (stat.isDirectory()) {
+      const subFiles = await traverseDir(unixFilePath)
+      allFiles.push(...subFiles)
+    } else if (!hasCurrentDir) {
+      hasCurrentDir = true
+      allFiles.push(dir)
+    }
+  }
+  return allFiles
+}

+ 50 - 0
src/mock/serializers.js

@@ -0,0 +1,50 @@
+const { randomArrayValue } = require('./utils')
+
+const basicExtendsTypes = () => ({
+  // LOCAL_DATE_TIME: () => Math.floor(new Date().getTime() / 1000),
+  USER_ROLE: (role) => role || randomArrayValue(['STUDENT', 'HR']),
+  BUILD_STATUS: (status) =>
+    status ||
+    randomArrayValue([
+      'ACCEPTED',
+      'WRONG_ANSWER',
+      'FAILURE',
+      'TIMEOUT',
+      'WAITING',
+      'RUNNING'
+    ]),
+  CHOICE_PROBLEM_TYPE: (problemType) =>
+    problemType || randomArrayValue(['SINGLE', 'MULTI']),
+  EXAM_STATUS: (examStatus) =>
+    examStatus || randomArrayValue(['READY', 'AVAILABLE', 'FINISHED', 'CLOSED'])
+})
+
+const serializersPlugin = (mock) => ({
+  EXAM_SERIALIZER: () =>
+    mock({
+      'id|1-2000': 1,
+      title: '@CTITLE',
+      startAt: '@DATETIME',
+      endAt: '@DATETIME',
+      'lastTime|1-3': 1,
+      status: '@EXAM_STATUS',
+      creator: {
+        'id|1-1000': 1,
+        username: '@CNAME'
+      },
+      joined: '@BOOLEAN',
+      isReentryPermitted: '@BOOLEAN',
+      inviteCode: '@WORD(5)'
+    }),
+  PAGE_SERIALIZER: () => ({
+    page: 1,
+    limit: 20,
+    size: 20,
+    total: 20
+  })
+})
+
+module.exports = function(random, mock) {
+  random.extend(basicExtendsTypes())
+  random.extend(serializersPlugin(mock))
+}

+ 3 - 1
src/mock/utils.js

@@ -1,3 +1,5 @@
-export default function randomArrayValue(arr = []) {
+function randomArrayValue(arr = []) {
   return Array.isArray(arr) ? arr[Math.floor(Math.random() * arr.length)] : ''
 }
+
+exports.randomArrayValue = randomArrayValue

+ 37 - 0
src/router/exam.ts

@@ -0,0 +1,37 @@
+import { RouteConfig } from 'vue-router'
+
+const routes: Array<RouteConfig> = [
+  {
+    meta: { roles: ['student'] },
+    path: '/exam/:id',
+    name: 'exam',
+    redirect: 'student/exam-list',
+    component: () =>
+      import(/* webpackChunkName: "exam" */ '@/views/exam/ExamBasicLayout.vue'),
+    children: [
+      {
+        path: 'detail',
+        name: 'detail',
+        component: () =>
+          import(/* webpackChunkName: "exam" */ '@/views/exam/ExamDetails.vue')
+      },
+      {
+        path: 'exam-pane',
+        name: 'exam-pane',
+        component: () =>
+          import(/* webpackChunkName: "exam" */ '@/views/exam/ExamPane.vue'),
+        children: [
+          {
+            path: 'choice',
+            name: 'choice'
+          },
+          {
+            path: 'code',
+            name: 'code'
+          }
+        ]
+      }
+    ]
+  }
+]
+export default routes

+ 3 - 2
src/router/index.ts

@@ -1,7 +1,7 @@
 import Vue from 'vue'
 import VueRouter, { RouteConfig } from 'vue-router'
 import student from '@/router/student'
-
+import exam from '@/router/exam'
 Vue.use(VueRouter)
 
 const routes: Array<RouteConfig> = [
@@ -11,7 +11,8 @@ const routes: Array<RouteConfig> = [
     // redirect: "/student",
     component: () => import(/* webpackChunkName: "Home" */ '@/views/Home.vue')
   },
-  ...student
+  ...student,
+  ...exam
 ]
 
 const router = new VueRouter({

+ 3 - 3
src/router/student.ts

@@ -26,11 +26,11 @@ const routes: Array<RouteConfig> = [
           import(/* webpackChunkName: "student" */ '@/views/student/Info.vue')
       },
       {
-        path: 'evalList',
-        name: 'evalList',
+        path: 'exams',
+        name: 'exams',
         component: () =>
           import(
-            /* webpackChunkName: "student" */ '@/views/student/EvalList.vue'
+            /* webpackChunkName: "student" */ '@/views/student/ExamList.vue'
           )
       }
     ]

+ 3 - 1
src/store/index.ts

@@ -4,7 +4,9 @@ import Vuex from 'vuex'
 Vue.use(Vuex)
 
 export default new Vuex.Store({
-  state: {},
+  state: {
+    userId: ''
+  },
   mutations: {},
   actions: {},
   modules: {}

+ 9 - 0
src/utils/markdown.ts

@@ -0,0 +1,9 @@
+import marked from 'marked'
+
+export function markdownToHTML(markdownData = '') {
+  return marked(markdownData, {
+    pedantic: false,
+    breaks: false,
+    gfm: true
+  })
+}

+ 3 - 12
src/views/Home.vue

@@ -1,19 +1,17 @@
 <template>
-  <v-container>
+  <v-app>
     <Login></Login>
     <Register></Register>
-    <div>{{ mock }}</div>
     <router-link :to="{ name: 'student' }">
       <v-btn color="primary">student</v-btn>
     </router-link>
-  </v-container>
+  </v-app>
 </template>
 
 <script lang="ts">
 import Vue from 'vue'
 import Login from '@/views/portal/Login.vue'
 import Register from '@/views/portal/Register.vue'
-import { getEcho } from '@/api/exam.ts'
 export default Vue.extend({
   name: 'Home',
   components: {
@@ -21,14 +19,7 @@ export default Vue.extend({
     Register
   },
   data() {
-    return {
-      mock: ''
-    }
-  },
-  mounted() {
-    getEcho().then((res) => {
-      this.mock = res.data
-    })
+    return {}
   }
 })
 </script>

+ 20 - 0
src/views/exam/ExamBasicLayout.vue

@@ -0,0 +1,20 @@
+<template>
+  <v-app>
+    <v-main class="indigo lighten-5">
+      <router-view />
+    </v-main>
+  </v-app>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+
+export default Vue.extend({
+  name: 'ExamBasicLayout',
+  data() {
+    return {}
+  }
+})
+</script>
+
+<style scoped></style>

+ 73 - 0
src/views/exam/ExamDetails.vue

@@ -0,0 +1,73 @@
+<template>
+  <v-container>
+    <page-header title="考试详情" :breadcrumb="breadcrumb">
+      <template #extra>
+        <v-row justify="end">
+          <v-spacer></v-spacer>
+          <v-col cols="3">
+            <router-link :to="{ name: 'exam-pane' }">
+              <v-btn color="success">进入考试</v-btn>
+            </router-link>
+          </v-col>
+          <v-col cols="2">
+            <router-link to="/student/exams">
+              <v-btn color="error">返回</v-btn>
+            </router-link>
+          </v-col>
+        </v-row>
+      </template>
+    </page-header>
+    <v-card class="mb-5" hover style="cursor: default">
+      <v-card-title>考试须知 </v-card-title>
+      <v-card-subtitle>考试信息</v-card-subtitle>
+      <v-card-text>abc </v-card-text>
+      <v-card-subtitle>考试规则</v-card-subtitle>
+      <v-card-text>abc</v-card-text>
+      <v-card-subtitle>考试内容</v-card-subtitle>
+      <v-card-text>abc</v-card-text>
+    </v-card>
+  </v-container>
+</template>
+
+<script lang="ts">
+import Vue, { PropType } from 'vue'
+import PageHeader from '@/components/PageHeader.vue'
+import { ExamSerializer } from '@/api/types'
+
+export default Vue.extend({
+  name: 'ExamDetails',
+  components: {
+    PageHeader
+  },
+  props: {
+    examInfo: {
+      type: Object as PropType<ExamSerializer>,
+      required: true
+    }
+  },
+  data() {
+    return {
+      breadcrumb: [
+        {
+          text: '主页',
+          disabled: false,
+          href: '/student/home'
+        },
+        {
+          text: '评测列表',
+          disabled: false,
+          href: '/student/exams'
+        },
+        {
+          text: '评测详情',
+          disabled: true,
+          href: '/'
+        }
+      ]
+    }
+  },
+  mounted() {}
+})
+</script>
+
+<style scoped></style>

+ 192 - 0
src/views/exam/ExamPane.vue

@@ -0,0 +1,192 @@
+<template>
+  <div>
+    <v-app-bar dense app class="indigo lighten-5">
+      <v-row>
+        <v-col>
+          <h1>基础能力测试</h1>
+        </v-col>
+        <v-col class="d-inline-flex justify-end">
+          <h2 class="align-self-center">考试剩余时间 01:02:03</h2>
+        </v-col>
+      </v-row>
+    </v-app-bar>
+    <v-navigation-drawer app permanent right class="elevation-5">
+      <div class="flex-column d-flex" style="height: 100vh">
+        <div class="flex-shrink-0">
+          <div class="pa-4">
+            <div
+              style="height: 40px; width: 100%"
+              class="indigo lighten-3 rounded-lg justify-center d-flex "
+            >
+              <h1 class="align-self-center">答题卡</h1>
+            </div>
+          </div>
+        </div>
+        <div
+          class="overflow-auto flex-grow-1 pa-4 d-flex flex-column justify-space-around"
+          style="box-sizing: border-box;"
+        >
+          <div column class="justify-space-between">
+            <div class="d-flex justify-center">
+              <h2>单选题</h2>
+            </div>
+            <v-btn
+              v-for="i in questions"
+              :key="i.id"
+              class="ml-2 mt-2 pa-0 d-inline"
+              x-small
+              :disabled="i.answered"
+            >
+              {{ i.id }}
+            </v-btn>
+          </div>
+          <div column class="justify-space-between d-inline">
+            <div class="d-flex justify-center">
+              <h2>多选题</h2>
+            </div>
+            <v-btn
+              v-for="i in 12"
+              :key="i"
+              class="ml-2 mt-2 pa-0 d-inline"
+              x-small
+              :disabled="questions[i].answered"
+            >
+              {{ questions[i - 1].id }}
+            </v-btn>
+          </div>
+          <div column class="justify-space-between">
+            <div class="d-flex justify-center">
+              <h2>代码题</h2>
+            </div>
+            <v-btn
+              v-for="i in 4"
+              :key="i"
+              class="ml-2 mt-2 pa-0 d-inline"
+              x-small
+              :disabled="questions[i].answered"
+            >
+              {{ questions[i - 1].id }}
+            </v-btn>
+          </div>
+        </div>
+        <div class="flex-shrink-0">
+          <div class="pa-2 px-6 text--secondary justify-space-between d-flex">
+            <v-dialog v-model="exitDialog" persistent max-width="290">
+              <template v-slot:activator="{ on, attrs }">
+                <v-btn color="error" v-bind="attrs" v-on="on">退出考试</v-btn>
+              </template>
+              <v-card>
+                <v-card-title class="headline">
+                  确认离开考试?
+                </v-card-title>
+                <v-card-text>退出后将无法再次进入</v-card-text>
+                <v-card-actions>
+                  <v-spacer></v-spacer>
+                  <v-btn
+                    color="success darken-1"
+                    text
+                    @click="exitDialog = false"
+                  >
+                    否
+                  </v-btn>
+                  <v-btn color="error darken-1" text @click="leaveExam">
+                    是
+                  </v-btn>
+                </v-card-actions>
+              </v-card>
+            </v-dialog>
+            <v-dialog v-model="submitDialog" persistent max-width="290">
+              <template v-slot:activator="{ on, attrs }">
+                <v-btn color="success" v-bind="attrs" v-on="on">提交试卷</v-btn>
+              </template>
+              <v-card>
+                <v-card-title class="headline">
+                  确认提交试卷?
+                </v-card-title>
+                <v-card-text>提交后将无法再次进入</v-card-text>
+                <v-card-actions>
+                  <v-spacer></v-spacer>
+                  <v-btn
+                    color="success darken-1"
+                    text
+                    @click="submitDialog = false"
+                  >
+                    否
+                  </v-btn>
+                  <v-btn color="error darken-1" text @click="submitAnswer">
+                    是
+                  </v-btn>
+                </v-card-actions>
+              </v-card>
+            </v-dialog>
+          </div>
+          <div class="pa-2 pl-6 pb-5 text--secondary">All Right Reserve</div>
+        </div>
+      </div>
+    </v-navigation-drawer>
+    <v-main class="indigo lighten-5 pr-0 pt-0">
+      <v-container fluid>
+        <code-pane
+          v-if="!isChoiceProblem"
+          v-on:nextQuestion="nextQuestion"
+        ></code-pane>
+        <choice-pane
+          v-if="isChoiceProblem"
+          v-on:nextQuestion="nextQuestion"
+        ></choice-pane>
+      </v-container>
+    </v-main>
+  </div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+import CodePane from '@/views/exam/components/CodePane.vue'
+import ChoicePane from '@/views/exam/components/ChoicePane.vue'
+
+interface Question {
+  answered: boolean
+  id: number
+}
+
+export default Vue.extend({
+  name: 'ExamPane',
+  data() {
+    return {
+      isChoiceProblem: true,
+      exitDialog: false,
+      submitDialog: false,
+      questions: [] as Array<Question>
+    }
+  },
+  components: {
+    CodePane,
+    ChoicePane
+  },
+  methods: {
+    leaveExam() {
+      this.exitDialog = false
+      this.$router.go(-1)
+    },
+    submitAnswer() {
+      this.submitDialog = false
+      this.$router.go(-1)
+    },
+    nextQuestion() {
+      this.isChoiceProblem = !this.isChoiceProblem
+    }
+  },
+  beforeMount() {
+    for (let i = 1; i <= 20; i++) {
+      const r = Math.floor(Math.random() * 2)
+
+      this.questions.push({
+        answered: r === 0,
+        id: i
+      })
+    }
+  }
+})
+</script>
+
+<style scoped></style>

+ 102 - 0
src/views/exam/components/ChoicePane.vue

@@ -0,0 +1,102 @@
+<template>
+  <v-card
+    class="questionCard d-flex flex-column justify-space-between"
+    hover
+    style="cursor: default"
+  >
+    <div class="d-flex">
+      <v-card-title>多选题</v-card-title>
+      <v-chip class="align-self-center" style="background-color: darkgray"
+        >1/20</v-chip
+      >
+    </div>
+    <!-- <v-card-text class="flex pa-10">{{question.description}}</v-card-text>
+    <v-container style="height: max-content" class="mb-10 flex">
+      <v-img max-height="400" contain src="@/assets/logo.svg"></v-img>
+    </v-container> -->
+    <markdown-text :raw="question.description" class="pa-10"></markdown-text>
+    <v-list class="px-10 flex">
+      <v-list-item-group v-model="answer" multiple>
+        <v-divider></v-divider>
+        <template v-for="choice in choices">
+          <v-list-item :key="choice.id" :value="choice.id">
+            <template v-slot:default="{ active }">
+              <v-list-item-action>
+                <v-checkbox hide-details :input-value="active"> </v-checkbox>
+              </v-list-item-action>
+              <v-list-item-content>
+                <v-list-item-title class="text-wrap"
+                  >{{ choice.id }}: {{ choice.text }}</v-list-item-title
+                >
+              </v-list-item-content>
+            </template>
+          </v-list-item>
+          <v-divider :key="choice.id + 1"></v-divider>
+        </template>
+      </v-list-item-group>
+    </v-list>
+    <div class="d-flex justify-space-between px-10 py-10">
+      <v-btn color="info" @click="prevQuestion">上一题</v-btn>
+      <v-btn color="info" @click="nextQuestion">下一题</v-btn>
+    </div>
+  </v-card>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+import MarkdownText from '@/views/exam/components/MarkdownText.vue'
+export default Vue.extend({
+  name: 'ChoicePane',
+  components: {
+    MarkdownText
+  },
+  data() {
+    return {
+      isHovered: false,
+      question: {
+        description:
+          '床前明月光 疑是地上霜 举头望明月 低头思故乡\n床前明月光 疑是地上霜 举头望明月 低头思故乡\n床前明月光 疑是地上霜 举头望明月 低头思故乡\n\n ![picture](http://static.runoob.com/images/runoob-logo.png)'
+      },
+      answer: [],
+      choices: [
+        {
+          id: 'A',
+          text: '床前明月光',
+          choose: ''
+        },
+        {
+          id: 'B',
+          text: '床前明月光 疑是地上霜',
+          choose: ''
+        },
+        {
+          id: 'C',
+          text: '床前明月光 疑是地上霜 举头望明月',
+          choose: ''
+        },
+        {
+          id: 'D',
+          text:
+            '床前明月光 疑是地上霜 举头望明月 低头思故乡 床前明月光 疑是地上霜 举头望明月 低头思故乡 床前明月光 疑是地上霜 举头望明月 低头思故乡床前明月光 疑是地上霜 举头望明月 低头思故乡',
+          choose: ''
+        }
+      ]
+    }
+  },
+  methods: {
+    nextQuestion() {
+      this.$emit('nextQuestion')
+    },
+    prevQuestion() {}
+  }
+})
+</script>
+
+<style scoped>
+.questionCard {
+  margin: 1vh 10vw;
+  min-height: 90vh;
+  max-height: 90vh;
+  padding: 10px;
+}
+</style>

+ 96 - 0
src/views/exam/components/CodeEditor.vue

@@ -0,0 +1,96 @@
+<template>
+  <div style="height: 100%; width: 100%;"></div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+import * as Monaco from 'monaco-editor'
+import { editor } from 'monaco-editor'
+import ICodeEditor = editor.ICodeEditor
+import ITextModel = editor.ITextModel
+
+export default Vue.extend({
+  name: 'CodeEditor',
+
+  model: {
+    event: 'change'
+  },
+
+  data() {
+    return {
+      editor: (null as unknown) as ICodeEditor,
+      monaco: (null as unknown) as typeof Monaco
+    }
+  },
+  props: {
+    original: { default: '', type: String },
+    value: {
+      type: String,
+      required: true
+    },
+    theme: {
+      type: String,
+      default: 'vs'
+    },
+    language: { default: '', type: String },
+    options: {
+      default: () => ({
+        height: '100%'
+      }),
+      type: Object
+    }
+  },
+
+  watch: {
+    language: function(newVal) {
+      if (this.editor) {
+        const model = this.editor.getModel()
+        this.monaco.editor.setModelLanguage(model as ITextModel, newVal)
+        this.editor.setModel(model)
+      }
+    },
+
+    value: function(newVal) {
+      if (this.editor) {
+        if (newVal !== this.editor.getValue()) {
+          this.editor.setValue(newVal)
+        }
+      }
+    }
+  },
+
+  mounted() {
+    this.monaco = Monaco
+    this.initMonaco()
+  },
+
+  beforeDestroy() {
+    this.editor && this.editor.dispose()
+  },
+
+  methods: {
+    initMonaco() {
+      const options = {
+        value: this.value,
+        theme: this.theme,
+        language: this.language,
+        ...this.options
+      }
+
+      this.editor = this.monaco.editor.create(this.$el as HTMLElement, options)
+
+      this.editor.onDidChangeModelContent((event) => {
+        const value = this.editor.getValue()
+        if (this.value !== value) {
+          this.$emit('change', value)
+        }
+      })
+    },
+    setValue(val) {
+      this.editor.setValue(val)
+    }
+  }
+})
+</script>
+
+<style></style>

+ 302 - 0
src/views/exam/components/CodePane.vue

@@ -0,0 +1,302 @@
+<template>
+  <v-card class="d-flex flex-column questionCard pa-0">
+    <div class="my-1 d-flex flex-grow-1 pa-1">
+      <div class="flex-grow-1 d-flex flex-column justify-space-between">
+        <div>
+          <v-tabs v-model="tab" class="pa-0 ma-0 elevation-1" grow>
+            <v-tab>题目描述</v-tab>
+            <v-tab>提交记录</v-tab>
+          </v-tabs>
+          <v-tabs-items v-model="tab">
+            <v-tab-item>
+              <div class="overflow-y-auto" style="height: 80vh">
+                <markdown-text :raw="stem" style="height: 100%"></markdown-text>
+              </div>
+            </v-tab-item>
+            <v-tab-item class="pa-2">
+              <div class="d-flex flex-column justify-space-between pa-2">
+                <v-card
+                  flat
+                  class="d-flex flex-column justify-space-between mb-5"
+                >
+                  <div class="d-inline mb-2">
+                    执行结果:
+                    <v-chip
+                      :color="statusToColor(currentCommit.status)"
+                      outlined
+                      >{{ currentCommit.status }}</v-chip
+                    >
+                  </div>
+                  <v-card-text
+                    style="background-color: darkgray; height: 30vh"
+                    class="rounded ma-0 mb-5 overflow-y-auto pa-2"
+                    v-if="
+                      currentCommit && currentCommit.status === '编译或运行失败'
+                    "
+                  >
+                    TODO
+                  </v-card-text>
+                </v-card>
+                <!--                <v-simple-table fixed-header height="40vh" class="flex-grow-1">-->
+                <v-simple-table
+                  fixed-header
+                  :height="
+                    currentCommit && currentCommit.status === '编译或运行失败'
+                      ? 35 + 'vh'
+                      : 70 + 'vh'
+                  "
+                >
+                  <thead>
+                    <tr>
+                      <th>提交时间</th>
+                      <th>状态</th>
+                      <th>成绩</th>
+                      <th>操作</th>
+                    </tr>
+                  </thead>
+                  <tbody>
+                    <tr v-for="item in commits" :key="item.id">
+                      <td>{{ item.submitTime }}</td>
+                      <td>
+                        <v-chip
+                          :color="statusToColor(item.status)"
+                          small
+                          outlined
+                        >
+                          {{ item.status }}
+                        </v-chip>
+                      </td>
+                      <td>{{ item.score }}</td>
+                      <td>
+                        <v-btn small>详情</v-btn>
+                      </td>
+                    </tr>
+                  </tbody>
+                </v-simple-table>
+              </div>
+            </v-tab-item>
+          </v-tabs-items>
+        </div>
+
+        <div style="height: 32px" class="d-flex justify-space-between">
+          <v-btn color="info" small>上一题</v-btn>
+          <span> 代码题 1/4</span>
+          <v-btn color="info" small>下一题</v-btn>
+        </div>
+      </div>
+      <v-divider vertical class="mx-1"></v-divider>
+      <div
+        style="width: 50vw"
+        class="d-flex-grow-1 d-flex flex-column justify-space-between"
+      >
+        <div
+          style="height: 48px"
+          class="d-flex pr-5 justify-space-between elevation-1 mb-1"
+        >
+          <div class="d-flex ml-5">
+            <div class="d-flex">
+              <v-select
+                :items="languages"
+                v-model="currentLang"
+                dense
+                style="width: 120px"
+                class=" ma-0 pa-0"
+                solo
+              >
+              </v-select>
+            </div>
+            <v-tooltip top>
+              <template v-slot:activator="{ on, attrs }">
+                <v-btn
+                  fab
+                  x-small
+                  tile
+                  class="mx-2 mb-2 pa-0 elevation-1 align-self-center"
+                  v-bind="attrs"
+                  v-on="on"
+                >
+                  <v-icon>mdi-arrow-left</v-icon>
+                </v-btn>
+              </template>
+              <span>恢复到上次提交的代码</span>
+            </v-tooltip>
+            <v-tooltip top>
+              <template v-slot:activator="{ on, attrs }">
+                <v-btn
+                  fab
+                  x-small
+                  tile
+                  class="mx-2 mb-2 pa-0 elevation-1 align-self-center"
+                  v-bind="attrs"
+                  v-on="on"
+                >
+                  <v-icon>mdi-refresh</v-icon>
+                </v-btn>
+              </template>
+              <span>重置代码至初始状态</span>
+            </v-tooltip>
+          </div>
+          <v-btn
+            color="primary"
+            class="mx-2 mb-2 align-self-center"
+            small
+            v-on:click="submitCode"
+            >保存测试</v-btn
+          >
+        </div>
+        <code-editor
+          style="height: 100%"
+          :language="currentLang"
+          class="flex-grow-1"
+          v-model="currentCode"
+        ></code-editor>
+      </div>
+    </div>
+  </v-card>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+import CodeEditor from '@/views/exam/components/CodeEditor.vue'
+import MarkdownText from '@/views/exam/components/MarkdownText.vue'
+import { clone } from 'ramda'
+
+interface CommitInfo {
+  id?: string
+  submitTime?: string
+  status?: string
+  score?: number
+  codes?: CodeQuestionPair[]
+  msg?: string
+}
+
+interface CodeQuestionPair {
+  id?: string
+  stem?: string
+  originalCode?: LanguageCode[]
+  currentCode?: LanguageCode[]
+}
+
+interface LanguageCode {
+  type?: string
+  code?: string
+}
+
+export default Vue.extend({
+  components: {
+    CodeEditor,
+    MarkdownText
+  },
+  data() {
+    return {
+      content: '213123123',
+      tab: null,
+      commits: [] as CommitInfo[],
+      languages: ['cpp', 'java', 'python'],
+      currentLang: 'java',
+      currentCommit: (null as unknown) as CommitInfo,
+      statusTags: [
+        '所有用例通过',
+        '有用例未通过',
+        '编译或运行失败',
+        '运行超时',
+        '等待运行'
+      ],
+      statusColors: ['success', 'warning', 'error', 'accent', 'info'],
+      stem: '123145',
+      codeQuestions: [] as CodeQuestionPair[],
+      currentQuestion: {} as CodeQuestionPair,
+      currentCode: [] as CodeQuestionPair[]
+    }
+  },
+  computed: {
+    lastEdit: function() {
+      return clone(this.currentQuestion.currentCode)
+    },
+
+    codeByLanguage() {
+      for (const languageCode of this.currentQuestion.currentCode) {
+        if (this.currentLang === languageCode.type) {
+          return languageCode.code
+        }
+      }
+      return ''
+    }
+  },
+  watch: {
+    currentLang() {
+      this.currentCode = this.codeByLanguage
+    }
+  },
+  beforeMount() {
+    for (let i = 0; i < 10; i++) {
+      const r = Math.floor(Math.random() * 4 + 1)
+      this.commits.push({
+        id: i.toString(),
+        submitTime: '2020-12-11 20:02',
+        status: this.statusTags[r],
+        score: 6 * i
+      })
+    }
+    this.currentCommit = this.getCurrentCommit()
+
+    this.fetchData()
+  },
+  methods: {
+    submitCode(): void {
+      const r = Math.floor(Math.random() * 5)
+      const id = Math.floor(Math.random() * 1000).toString()
+      this.commits = [
+        {
+          id: id,
+          submitTime: '2020-12-11 20:03',
+          status: this.statusTags[r],
+          score: 100
+        },
+        ...this.commits
+      ]
+      this.currentCommit = this.getCurrentCommit()
+
+      console.log(this.currentCode)
+    },
+    getCurrentCommit(): CommitInfo {
+      return this.commits[0]
+    },
+    statusToColor(status: string): string {
+      return this.statusColors[this.statusTags.indexOf(status)]
+    },
+    fetchData() {
+      const cpp = { type: 'cpp', code: 'int main(){}' } as LanguageCode
+      const java = { type: 'java', code: 'int a=0;' } as LanguageCode
+      const python = { type: 'python', code: 'print a' } as LanguageCode
+
+      this.currentQuestion = {
+        id: '123',
+        stem: 'description',
+        originalCode: [cpp, java, python],
+        currentCode: [cpp, java, python]
+      }
+      this.codeQuestions.push(this.currentQuestion)
+      this.codeQuestions.push({
+        id: '234',
+        stem: 'description',
+        originalCode: [cpp, java, python],
+        currentCode: [cpp, java, python]
+      })
+
+      this.currentCode = this.codeByLanguage
+      console.log(this.currentCode)
+    },
+    resetCode() {
+      for (const codeQuestion of this.codeQuestions) {
+        if (codeQuestion.id === this.currentQuestion.id) {
+          this.currentQuestion.currentCode = clone(codeQuestion.originalCode)
+        }
+        // 添加完成后的提示
+      }
+    }
+  }
+})
+</script>
+
+<style scoped></style>

+ 25 - 0
src/views/exam/components/MarkdownText.vue

@@ -0,0 +1,25 @@
+<template>
+  <div v-html="markdownToHTML(raw)" class="pa-2 markdown-content"></div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+import { markdownToHTML } from '@/utils/markdown'
+
+export default Vue.extend({
+  name: 'MarkdownText',
+  props: {
+    raw: String
+  },
+  methods: {
+    markdownToHTML
+  }
+})
+</script>
+
+<style>
+.markdown-content {
+  word-break: break-all;
+  white-space: pre-wrap;
+}
+</style>

+ 12 - 0
src/views/portal/Login.vue

@@ -0,0 +1,12 @@
+<template>
+  <div>LOGIN</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+export default Vue.extend({
+  name: 'Login'
+})
+</script>
+
+<style scoped></style>

+ 12 - 0
src/views/portal/Register.vue

@@ -0,0 +1,12 @@
+<template>
+  <div>REGISTER</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+export default Vue.extend({
+  name: 'Register'
+})
+</script>
+
+<style scoped></style>

+ 0 - 121
src/views/student/EvalList.vue

@@ -1,121 +0,0 @@
-<template>
-  <div>
-    <page-header title="我的评测" :breadcrumb="breadcrumb"> </page-header>
-    <v-row class="mr-16 ml-5 pa-0" no-gutters style="height: 50px">
-      <v-col>
-        <v-btn color="info" @click="onJoinExam">
-          参加评测
-        </v-btn>
-      </v-col>
-      <v-col cols="2">
-        <v-select
-          :items="selectItems"
-          outlined
-          v-model="selected"
-          dense
-        ></v-select>
-      </v-col>
-    </v-row>
-
-    <v-card
-      hover
-      v-for="item in examList"
-      :key="item.examName"
-      class="ma-5 mr-16 px-3"
-      style="cursor: default"
-    >
-      <v-row>
-        <v-col class="d-flex">
-          <v-card-title class="ma-0 pa-0">{{ item.examName }}</v-card-title>
-          <v-chip :color="item.state" label class="ml-5" outlined
-            >已结束</v-chip
-          >
-        </v-col>
-        <v-col align="end" class="mr-3">
-          <v-btn color="primary" class="mx-2">查看详情</v-btn>
-          <v-btn color="info" class="mx-2">参加评测</v-btn>
-        </v-col>
-      </v-row>
-      <v-row no-gutters align-content="start">
-        <v-col>
-          <v-card-text class="pa-0 pb-5">ID: {{ item.id }}</v-card-text>
-        </v-col>
-        <v-col>
-          <v-card-text class="pa-0 pb-5"
-            >开始时间: {{ item.startTime }}</v-card-text
-          >
-        </v-col>
-        <v-col>
-          <v-card-text class="pa-0 pb-5"
-            >结束时间: {{ item.endTime }}</v-card-text
-          >
-        </v-col>
-        <v-col>
-          <v-card-text class="pa-0 pb-5"
-            >邀请码: {{ item.inviteCode }}</v-card-text
-          >
-        </v-col>
-        <v-col>
-          <v-card-text class="pa-0 pb-5"
-            >参与人数: {{ item.participantsNumber }}</v-card-text
-          >
-        </v-col>
-      </v-row>
-    </v-card>
-    <v-pagination v-model="page" :length="pages"> </v-pagination>
-  </div>
-</template>
-
-<script lang="ts">
-import Vue from 'vue'
-import PageHeader from '@/components/PageHeader.vue'
-import { getExamList } from '@/api/exam'
-
-export default Vue.extend({
-  name: 'ExamList',
-  components: {
-    PageHeader
-  },
-  data() {
-    return {
-      page: 1,
-      pages: 4,
-      selected: '正在进行',
-      selectItems: ['未开始', '正在进行', '已结束'],
-      dialog: false,
-      breadcrumb: [
-        {
-          text: '主页',
-          disabled: false,
-          href: '/'
-        },
-        {
-          text: '考试列表',
-          disabled: true,
-          href: '/'
-        }
-      ],
-      examList: []
-    }
-  },
-  methods: {
-    onJoinExam() {
-      console.log('参加评测')
-      this.$router.push('/exam/')
-    }
-  },
-  mounted() {
-    console.log('MOUNTED')
-    getExamList('myId')
-      .then((res) => {
-        console.log(res)
-        this.examList = res.data.data
-      })
-      .catch((err) => {
-        this.examList = []
-      })
-  }
-})
-</script>
-
-<style scoped></style>

+ 99 - 0
src/views/student/ExamList.vue

@@ -0,0 +1,99 @@
+<template>
+  <v-container>
+    <page-header title="我的评测" :breadcrumb="breadcrumb">
+      <template #extra>
+        <v-row class=" my-0 py-0" no-gutters justify="end">
+          <v-spacer></v-spacer>
+          <v-col cols="3">
+            <v-btn color="info" @click="onJoinExam">
+              参加评测
+            </v-btn>
+          </v-col>
+
+          <v-col cols="3">
+            <v-select
+              :items="selectItems"
+              outlined
+              v-model="selected"
+              dense
+            ></v-select>
+          </v-col>
+
+          <v-col cols="1" class="ml-5">
+            <v-btn color="secondary" fab small>
+              <v-icon dark>mdi-cached</v-icon>
+            </v-btn>
+          </v-col>
+        </v-row>
+      </template>
+    </page-header>
+    <exam-info-card
+      hover
+      v-for="item in examList"
+      :key="item.id"
+      :exam-info="item"
+    >
+    </exam-info-card>
+    <v-pagination v-model="page" :length="pageInfo.total" @input="changePage">
+    </v-pagination>
+  </v-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+import PageHeader from '@/components/PageHeader.vue'
+import ExamInfoCard from '@/views/student/components/ExamInfoCard.vue'
+import { getAllExamList } from '@/api/exam'
+import { ExamSerializer, PageSerializer } from '@/api/types'
+
+export default Vue.extend({
+  name: 'ExamList',
+  components: {
+    PageHeader,
+    ExamInfoCard
+  },
+  data: function() {
+    return {
+      page: 1,
+      selected: '正在进行',
+      selectItems: ['未开始', '正在进行', '已结束'],
+      dialog: false,
+      breadcrumb: [
+        {
+          text: '主页',
+          disabled: false,
+          href: '/student'
+        },
+        {
+          text: '考试列表',
+          disabled: true,
+          href: '/'
+        }
+      ],
+      examList: [] as Array<ExamSerializer>,
+      pageInfo: {} as PageSerializer
+    }
+  },
+  methods: {
+    changePage() {
+      this.fetchExams()
+    },
+    onJoinExam() {},
+    fetchExams(page: number = 1) {
+      getAllExamList({ page, limit: 10, status: 'READY' })
+        .then(({ data }) => {
+          this.examList = data.res ?? []
+          this.pageInfo = data.page ?? {}
+        })
+        .catch((err) => {
+          this.examList = []
+        })
+    }
+  },
+  mounted() {
+    this.fetchExams()
+  }
+})
+</script>
+
+<style scoped></style>

+ 4 - 7
src/views/student/StudentHome.vue

@@ -1,5 +1,5 @@
 <template>
-  <v-app id="inspire">
+  <v-app>
     <v-navigation-drawer app floating>
       <div class="flex-column d-flex" style="height: 100vh">
         <div class="flex-shrink-0">
@@ -12,7 +12,7 @@
         </div>
         <div class="overflow-auto flex-grow-1" style="box-sizing: border-box;">
           <v-list nav dense>
-            <v-list-item-group v-model="selectedItem" color="primary">
+            <v-list-item-group color="primary">
               <v-list-item
                 v-for="(item, i) in linkList"
                 :to="item.url"
@@ -79,7 +79,7 @@ export default Vue.extend({
       },
       {
         name: '评测',
-        url: '/student/evalList',
+        url: '/student/exams',
         icon: mdiViewList
       },
       {
@@ -88,10 +88,7 @@ export default Vue.extend({
         icon: mdiDetails
       }
     ]
-  }),
-  mounted() {
-    console.log('mount')
-  }
+  })
 })
 </script>
 

+ 82 - 0
src/views/student/components/ExamInfoCard.vue

@@ -0,0 +1,82 @@
+<template>
+  <v-card hover class="ma-5 px-3 my-8" style="cursor: default;height: 20%">
+    <v-row>
+      <v-col class="d-flex">
+        <v-card-title class="ma-0 pa-0">{{ examInfo.title }}</v-card-title>
+        <v-chip :color="examInfo.state" label class="ml-5" outlined
+          >已结束</v-chip
+        >
+      </v-col>
+      <v-col align="end" class="mr-3">
+        <v-btn color="primary" class="mx-2">查看详情</v-btn>
+        <v-btn
+          color="secondary"
+          class="mx-2"
+          v-if="examInfo.joined === true"
+          @click="onEnterExam"
+          >开始评测</v-btn
+        >
+        <v-btn color="info" class="mx-2" v-else @click="onJoinExam"
+          >参加评测</v-btn
+        >
+      </v-col>
+    </v-row>
+    <v-row justify="space-between" dense>
+      <v-col>
+        <v-card-text class="pa-0 pb-5">ID: {{ examInfo.id }}</v-card-text>
+      </v-col>
+      <v-col>
+        <v-card-text class="pa-0 pb-5"
+          >邀请码: {{ examInfo.inviteCode }}</v-card-text
+        >
+      </v-col>
+      <v-col>
+        <v-card-text class="pa-0 pb-5"
+          >评测时长: {{ examInfo.lastTime }} 小时</v-card-text
+        >
+      </v-col>
+    </v-row>
+    <v-row no-gutters>
+      <v-col>
+        <v-card-text class="pa-0 pb-5"
+          >开始时间: {{ examInfo.startAt }}</v-card-text
+        >
+      </v-col>
+      <v-col>
+        <v-card-text class="pa-0 pb-5"
+          >结束时间: {{ examInfo.endAt }}</v-card-text
+        >
+      </v-col>
+      <v-spacer></v-spacer>
+    </v-row>
+  </v-card>
+</template>
+
+<script lang="ts">
+import Vue, { PropType } from 'vue'
+import { ExamSerializer } from '@/api/types'
+
+export default Vue.extend({
+  name: 'ExamInfoCard',
+  data() {
+    return {}
+  },
+  props: {
+    examInfo: {
+      type: Object as PropType<ExamSerializer>,
+      required: true
+    }
+  },
+  methods: {
+    onJoinExam() {},
+    onEnterExam() {
+      const examId = this.examInfo.id
+      this.$router.push({
+        path: `/exam/${examId}/detail`
+      })
+    }
+  }
+})
+</script>
+
+<style scoped></style>

+ 1 - 1
vue.config.js

@@ -5,7 +5,7 @@ module.exports = {
   devServer: {
     proxy: {
       '^/api': {
-        target: 'http://localhost:4000/',
+        target: 'http://localhost:5000/',
         ws: true,
         changeOrigin: true
       }