소스 검색

feat(学生考试): 新增学生考试相关功能模块

实现学生考试系统的核心功能,包括:
- 新增试卷列表、答题、批改和查看页面
- 添加错题本和考试记录功能
- 实现试卷答题计时和自动提交
- 完善题目展示和批改组件
- 添加相关API接口和状态管理
- 新增考试工具函数和样式文件
tanshanming 7 달 전
부모
커밋
f6426c45f9

+ 8 - 0
src/api/student/examPaper.js

@@ -0,0 +1,8 @@
+import { baseRequest } from '@/utils/request'
+
+const request = (url, ...arg) => baseRequest(`/api/webapp/` + url, ...arg)
+
+export default {
+	select: (id) => request('api/student/exam/paper/select/' + id, '', 'post'),
+	pageList: (query) => request('api/student/exam/paper/pageList', query, 'post')
+}

+ 10 - 0
src/api/student/examPaperAnswer.js

@@ -0,0 +1,10 @@
+import { baseRequest } from '@/utils/request'
+
+const request = (url, ...arg) => baseRequest(`/api/webapp/` + url, ...arg)
+
+export default {
+	pageList: (query) => request('api/student/exampaper/answer/pageList', query, 'post'),
+	answerSubmit: (form) => request('api/student/exampaper/answer/answerSubmit', form, 'post'),
+	read: (id) => request('api/student/exampaper/answer/read/' + id, '', 'post'),
+	edit: (form) => request('api/student/exampaper/answer/edit', form, 'post')
+}

+ 8 - 0
src/api/student/questionAnswer.js

@@ -0,0 +1,8 @@
+import { baseRequest } from '@/utils/request'
+
+const request = (url, ...arg) => baseRequest(`/api/webapp/` + url, ...arg)
+
+export default {
+	pageList: (query) => request('/api/student/question/answer/page', query, 'post'),
+	select: (id) => request('/api/student/question/answer/select/' + id, '', 'post')
+}

+ 48 - 0
src/router/whiteList.js

@@ -86,6 +86,54 @@ const constRouters = [
 			}
 			}
 		},
 		},
 		props: true
 		props: true
+	},
+	{
+		path: '/student/paper/',
+		name: 'studentPaper',
+		component: () => import('@/views/student/paper/index.vue'),
+		meta: {
+			title: '学生试卷'
+		}
+	},
+	{
+		path: '/student/edit/',
+		name: 'studentEdit',
+		component: () => import('@/views/student/exam/paper/edit.vue'),
+		meta: {
+			title: '试卷批改'
+		}
+	},
+	{
+		path: '/student/record/',
+		name: 'studentRecord',
+		component: () => import('@/views/student/record/index.vue'),
+		meta: {
+			title: '学生试卷'
+		}
+	},
+	{
+		path: '/student/questionError/',
+		name: 'QuestionErrorIndex',
+		component: () => import('@/views/student/question-error/index.vue'),
+		meta: {
+			title: '错题本'
+		}
+	},
+	{
+		path: '/student/do/',
+		name: 'studentDo',
+		component: () => import('@/views/student/exam/paper/do.vue'),
+		meta: {
+			title: '试卷答题'
+		}
+	},
+	{
+		path: '/student/read/',
+		name: 'studentRead',
+		component: () => import('@/views/student/exam/paper/read.vue'),
+		meta: {
+			title: '试卷查看'
+		}
 	}
 	}
 ]
 ]
 /**
 /**

+ 38 - 2
src/store/exam.js

@@ -50,7 +50,17 @@ export const useExamStore = defineStore('exam', {
 			statusBtn: [
 			statusBtn: [
 				{ key: 1, value: '禁用' },
 				{ key: 1, value: '禁用' },
 				{ key: 2, value: '启用' }
 				{ key: 2, value: '启用' }
-			]
+			],
+			message: {
+				readTag: [
+					{ key: true, value: 'success' },
+					{ key: false, value: 'warning' }
+				],
+				readText: [
+					{ key: true, value: '已读' },
+					{ key: false, value: '未读' }
+				]
+			}
 		},
 		},
 		exam: {
 		exam: {
 			examPaper: {
 			examPaper: {
@@ -60,6 +70,16 @@ export const useExamStore = defineStore('exam', {
 					{ key: 6, value: '任务试卷' }
 					{ key: 6, value: '任务试卷' }
 				]
 				]
 			},
 			},
+			examPaperAnswer: {
+				statusEnum: [
+					{ key: 1, value: '待批改' },
+					{ key: 2, value: '完成' }
+				],
+				statusTag: [
+					{ key: 1, value: 'warning' },
+					{ key: 2, value: 'success' }
+				]
+			},
 			question: {
 			question: {
 				typeEnum: [
 				typeEnum: [
 					{ key: 1, value: '单选题' },
 					{ key: 1, value: '单选题' },
@@ -74,7 +94,23 @@ export const useExamStore = defineStore('exam', {
 					{ key: 3, value: './edit/true-false.vue', name: '判断题' },
 					{ key: 3, value: './edit/true-false.vue', name: '判断题' },
 					{ key: 4, value: './edit/gap-filling.vue', name: '填空题' },
 					{ key: 4, value: './edit/gap-filling.vue', name: '填空题' },
 					{ key: 5, value: './edit/short-answer.vue', name: '简答题' }
 					{ key: 5, value: './edit/short-answer.vue', name: '简答题' }
-				]
+				],
+				answer: {
+					doRightTag: [
+						{ key: true, value: 'success' },
+						{ key: false, value: 'danger' },
+						{ key: null, value: 'warning' }
+					],
+					doRightEnum: [
+						{ key: true, value: '正确' },
+						{ key: false, value: '错误' },
+						{ key: null, value: '待批改' }
+					],
+					doCompletedTag: [
+						{ key: false, value: 'info' },
+						{ key: true, value: 'success' }
+					]
+				}
 			}
 			}
 		}
 		}
 	}),
 	}),

+ 359 - 0
src/utils/exam.js

@@ -0,0 +1,359 @@
+/**
+ * Created by PanJiaChen on 16/11/18.
+ */
+
+/**
+ * Parse the time to string
+ * @param {(Object|string|number)} time
+ * @param {string} cFormat
+ * @returns {string}
+ */
+export function parseTime(time, cFormat) {
+	if (arguments.length === 0) {
+		return null
+	}
+	const format = cFormat || '{y}-{m}-{d} {h}:{i}:{s}'
+	let date
+	if (typeof time === 'object') {
+		date = time
+	} else {
+		if (typeof time === 'string' && /^[0-9]+$/.test(time)) {
+			time = parseInt(time)
+		}
+		if (typeof time === 'number' && time.toString().length === 10) {
+			time = time * 1000
+		}
+		date = new Date(time)
+	}
+	const formatObj = {
+		y: date.getFullYear(),
+		m: date.getMonth() + 1,
+		d: date.getDate(),
+		h: date.getHours(),
+		i: date.getMinutes(),
+		s: date.getSeconds(),
+		a: date.getDay()
+	}
+	// eslint-disable-next-line camelcase
+	const time_str = format.replace(/{(y|m|d|h|i|s|a)+}/g, (result, key) => {
+		let value = formatObj[key]
+		// Note: getDay() returns 0 on Sunday
+		if (key === 'a') {
+			return ['日', '一', '二', '三', '四', '五', '六'][value]
+		}
+		if (result.length > 0 && value < 10) {
+			value = '0' + value
+		}
+		return value || 0
+	})
+	// eslint-disable-next-line camelcase
+	return time_str
+}
+
+/**
+ * @param {number} time
+ * @param {string} option
+ * @returns {string}
+ */
+export function formatTime(time, option) {
+	if (('' + time).length === 10) {
+		time = parseInt(time) * 1000
+	} else {
+		time = +time
+	}
+	const d = new Date(time)
+	const now = Date.now()
+
+	const diff = (now - d) / 1000
+
+	if (diff < 30) {
+		return '刚刚'
+	} else if (diff < 3600) {
+		// less 1 hour
+		return Math.ceil(diff / 60) + '分钟前'
+	} else if (diff < 3600 * 24) {
+		return Math.ceil(diff / 3600) + '小时前'
+	} else if (diff < 3600 * 24 * 2) {
+		return '1天前'
+	}
+	if (option) {
+		return parseTime(time, option)
+	} else {
+		return d.getMonth() + 1 + '月' + d.getDate() + '日' + d.getHours() + '时' + d.getMinutes() + '分'
+	}
+}
+
+/**
+ * @param {string} url
+ * @returns {Object}
+ */
+export function getQueryObject(url) {
+	url = url == null ? window.location.href : url
+	const search = url.substring(url.lastIndexOf('?') + 1)
+	const obj = {}
+	const reg = /([^?&=]+)=([^?&=]*)/g
+	search.replace(reg, (rs, $1, $2) => {
+		const name = decodeURIComponent($1)
+		let val = decodeURIComponent($2)
+		val = String(val)
+		obj[name] = val
+		return rs
+	})
+	return obj
+}
+
+/**
+ * @param {string} input value
+ * @returns {number} output value
+ */
+export function byteLength(str) {
+	// returns the byte length of an utf8 string
+	let s = str.length
+	for (var i = str.length - 1; i >= 0; i--) {
+		const code = str.charCodeAt(i)
+		if (code > 0x7f && code <= 0x7ff) s++
+		else if (code > 0x7ff && code <= 0xffff) s += 2
+		if (code >= 0xdc00 && code <= 0xdfff) i--
+	}
+	return s
+}
+
+/**
+ * @param {Array} actual
+ * @returns {Array}
+ */
+export function cleanArray(actual) {
+	const newArray = []
+	for (let i = 0; i < actual.length; i++) {
+		if (actual[i]) {
+			newArray.push(actual[i])
+		}
+	}
+	return newArray
+}
+
+/**
+ * @param {Object} json
+ * @returns {Array}
+ */
+export function param(json) {
+	if (!json) return ''
+	return cleanArray(
+		Object.keys(json).map((key) => {
+			if (json[key] === undefined) return ''
+			return encodeURIComponent(key) + '=' + encodeURIComponent(json[key])
+		})
+	).join('&')
+}
+
+/**
+ * @param {string} url
+ * @returns {Object}
+ */
+export function param2Obj(url) {
+	const search = url.split('?')[1]
+	if (!search) {
+		return {}
+	}
+	return JSON.parse(
+		'{"' +
+			decodeURIComponent(search).replace(/"/g, '\\"').replace(/&/g, '","').replace(/=/g, '":"').replace(/\+/g, ' ') +
+			'"}'
+	)
+}
+
+/**
+ * @param {string} val
+ * @returns {string}
+ */
+export function html2Text(val) {
+	const div = document.createElement('div')
+	div.innerHTML = val
+	return div.textContent || div.innerText
+}
+
+/**
+ * Merges two objects, giving the last one precedence
+ * @param {Object} target
+ * @param {(Object|Array)} source
+ * @returns {Object}
+ */
+export function objectMerge(target, source) {
+	if (typeof target !== 'object') {
+		target = {}
+	}
+	if (Array.isArray(source)) {
+		return source.slice()
+	}
+	Object.keys(source).forEach((property) => {
+		const sourceProperty = source[property]
+		if (typeof sourceProperty === 'object') {
+			target[property] = objectMerge(target[property], sourceProperty)
+		} else {
+			target[property] = sourceProperty
+		}
+	})
+	return target
+}
+
+/**
+ * @param {HTMLElement} element
+ * @param {string} className
+ */
+export function toggleClass(element, className) {
+	if (!element || !className) {
+		return
+	}
+	let classString = element.className
+	const nameIndex = classString.indexOf(className)
+	if (nameIndex === -1) {
+		classString += '' + className
+	} else {
+		classString = classString.substr(0, nameIndex) + classString.substr(nameIndex + className.length)
+	}
+	element.className = classString
+}
+
+/**
+ * @param {string} type
+ * @returns {Date}
+ */
+export function getTime(type) {
+	if (type === 'start') {
+		return new Date().getTime() - 3600 * 1000 * 24 * 90
+	} else {
+		return new Date(new Date().toDateString())
+	}
+}
+
+/**
+ * @param {Function} func
+ * @param {number} wait
+ * @param {boolean} immediate
+ * @return {*}
+ */
+export function debounce(func, wait, immediate) {
+	let timeout, args, context, timestamp, result
+
+	const later = function () {
+		// 据上一次触发时间间隔
+		const last = +new Date() - timestamp
+
+		// 上次被包装函数被调用时间间隔 last 小于设定时间间隔 wait
+		if (last < wait && last > 0) {
+			timeout = setTimeout(later, wait - last)
+		} else {
+			timeout = null
+			// 如果设定为immediate===true,因为开始边界已经调用过了此处无需调用
+			if (!immediate) {
+				result = func.apply(context, args)
+				if (!timeout) context = args = null
+			}
+		}
+	}
+
+	return function (...args) {
+		context = this
+		timestamp = +new Date()
+		const callNow = immediate && !timeout
+		// 如果延时不存在,重新设定延时
+		if (!timeout) timeout = setTimeout(later, wait)
+		if (callNow) {
+			result = func.apply(context, args)
+			context = args = null
+		}
+
+		return result
+	}
+}
+
+/**
+ * This is just a simple version of deep copy
+ * Has a lot of edge cases bug
+ * If you want to use a perfect deep copy, use lodash's _.cloneDeep
+ * @param {Object} source
+ * @returns {Object}
+ */
+export function deepClone(source) {
+	if (!source && typeof source !== 'object') {
+		throw new Error('error arguments', 'deepClone')
+	}
+	const targetObj = source.constructor === Array ? [] : {}
+	Object.keys(source).forEach((keys) => {
+		if (source[keys] && typeof source[keys] === 'object') {
+			targetObj[keys] = deepClone(source[keys])
+		} else {
+			targetObj[keys] = source[keys]
+		}
+	})
+	return targetObj
+}
+
+/**
+ * @param {Array} arr
+ * @returns {Array}
+ */
+export function uniqueArr(arr) {
+	return Array.from(new Set(arr))
+}
+
+/**
+ * @returns {string}
+ */
+export function createUniqueString() {
+	const timestamp = +new Date() + ''
+	const randomNum = parseInt((1 + Math.random()) * 65536) + ''
+	return (+(randomNum + timestamp)).toString(32)
+}
+
+/**
+ * Check if an element has a class
+ * @param {HTMLElement} elm
+ * @param {string} cls
+ * @returns {boolean}
+ */
+export function hasClass(ele, cls) {
+	return !!ele.className.match(new RegExp('(\\s|^)' + cls + '(\\s|$)'))
+}
+
+/**
+ * Add class to element
+ * @param {HTMLElement} elm
+ * @param {string} cls
+ */
+export function addClass(ele, cls) {
+	if (!hasClass(ele, cls)) ele.className += ' ' + cls
+}
+
+/**
+ * Remove class from element
+ * @param {HTMLElement} elm
+ * @param {string} cls
+ */
+export function removeClass(ele, cls) {
+	if (hasClass(ele, cls)) {
+		const reg = new RegExp('(\\s|^)' + cls + '(\\s|$)')
+		ele.className = ele.className.replace(reg, ' ')
+	}
+}
+
+export function formatSeconds(theTime) {
+	let theTime1 = 0
+	let theTime2 = 0
+	if (theTime > 60) {
+		theTime1 = parseInt(theTime / 60)
+		theTime = parseInt(theTime % 60)
+		if (theTime1 > 60) {
+			theTime2 = parseInt(theTime1 / 60)
+			theTime1 = parseInt(theTime1 % 60)
+		}
+	}
+	let result = '' + parseInt(theTime) + '秒'
+	if (theTime1 > 0) {
+		result = '' + parseInt(theTime1) + '分' + result
+	}
+	if (theTime2 > 0) {
+		result = '' + parseInt(theTime2) + '小时' + result
+	}
+	return result
+}

+ 173 - 0
src/views/student/exam/components/QuestionAnswerShow.vue

@@ -0,0 +1,173 @@
+<template>
+	<a-spin :spinning="qLoading" style="line-height: 1.8">
+		<div v-if="[1, 2, 3, 4, 5].includes(qType)">
+			<!-- 单选题 -->
+			<div v-if="qType === 1">
+				<!-- eslint-disable-next-line vue/no-v-html -->
+				<div class="q-title" v-html="question.title" />
+				<div class="q-content">
+					<a-radio-group v-model:value="answer.content">
+						<a-radio v-for="item in question.items" :key="item.prefix" :value="item.prefix">
+							<span class="question-prefix">{{ item.prefix }}.</span>
+							<!-- eslint-disable-next-line vue/no-v-html -->
+							<span v-html="item.content" class="q-item-span-content"></span>
+						</a-radio>
+					</a-radio-group>
+				</div>
+			</div>
+			<!-- 多选题 -->
+			<div v-else-if="qType === 2">
+				<!-- eslint-disable-next-line vue/no-v-html -->
+				<div class="q-title" v-html="question.title" />
+				<div class="q-content">
+					<a-checkbox-group v-model:value="answer.contentArray">
+						<a-checkbox v-for="item in question.items" :key="item.prefix" :value="item.prefix">
+							<span class="question-prefix">{{ item.prefix }}.</span>
+							<!-- eslint-disable-next-line vue/no-v-html -->
+							<span v-html="item.content" class="q-item-span-content"></span>
+						</a-checkbox>
+					</a-checkbox-group>
+				</div>
+			</div>
+			<!-- 判断题 -->
+			<div v-else-if="qType === 3">
+				<!-- eslint-disable-next-line vue/no-v-html -->
+				<div class="q-title" v-html="question.title" style="display: inline; margin-right: 10px" />
+				<span style="padding-right: 10px">(</span>
+				<a-radio-group v-model:value="answer.content">
+					<a-radio v-for="item in question.items" :key="item.prefix" :value="item.prefix">
+						<!-- eslint-disable-next-line vue/no-v-html -->
+						<span v-html="item.content" class="q-item-span-content"></span>
+					</a-radio>
+				</a-radio-group>
+				<span style="padding-left: 10px">)</span>
+			</div>
+			<!-- 填空题 -->
+			<div v-else-if="qType === 4">
+				<!-- eslint-disable-next-line vue/no-v-html -->
+				<div class="q-title" v-html="question.title" />
+				<div v-if="answer.contentArray !== null">
+					<a-form layout="vertical">
+						<a-form-item
+							v-for="item in question.items"
+							:label="item.prefix"
+							:key="item.prefix"
+							style="margin-top: 10px; margin-bottom: 10px"
+						>
+							<a-input v-model:value="answer.contentArray[item.prefix - 1]" />
+						</a-form-item>
+					</a-form>
+				</div>
+			</div>
+			<!-- 简答题 -->
+			<div v-else-if="qType === 5">
+				<!-- eslint-disable-next-line vue/no-v-html -->
+				<div class="q-title" v-html="question.title" />
+				<div>
+					<a-textarea v-model:value="answer.content" :rows="5" />
+				</div>
+			</div>
+			<!-- 结果、分数、难度、解析、正确答案 -->
+			<div class="question-answer-show-item" style="margin-top: 15px">
+				<span class="question-show-item">结果:</span>
+				<a-tag :color="doRightTagFormatter(answer.doRight)">
+					{{ doRightTextFormatter(answer.doRight) }}
+				</a-tag>
+			</div>
+			<div class="question-answer-show-item">
+				<span class="question-show-item">分数:</span>
+				<span>{{ question.score }}</span>
+			</div>
+			<div class="question-answer-show-item">
+				<span class="question-show-item">难度:</span>
+				<a-rate :value="question.difficult" disabled class="question-show-item" />
+			</div>
+			<br />
+			<div class="question-answer-show-item" style="line-height: 1.8">
+				<span class="question-show-item">解析:</span>
+				<span v-html="question.analyze" class="q-item-span-content" />
+			</div>
+			<div class="question-answer-show-item">
+				<span class="question-show-item">正确答案:</span>
+				<!-- eslint-disable-next-line vue/no-v-html -->
+				<span v-if="[1, 2, 5].includes(qType)" v-html="question.correct" class="q-item-span-content" />
+				<!-- eslint-disable-next-line vue/no-v-html -->
+				<span v-if="qType === 3" v-html="trueFalseFormatter(question)" class="q-item-span-content" />
+				<span v-if="qType === 4">{{ question.correctArray }}</span>
+			</div>
+		</div>
+		<div v-else></div>
+	</a-spin>
+</template>
+
+<script setup>
+	import { computed, toRefs } from 'vue'
+	import { useExamStore } from '@/store/exam'
+
+	const props = defineProps({
+		question: {
+			type: Object,
+			default: () => ({})
+		},
+		answer: {
+			type: Object,
+			default: () => ({ id: null, content: '', contentArray: [], doRight: false })
+		},
+		qLoading: {
+			type: Boolean,
+			default: false
+		},
+		qType: {
+			type: Number,
+			default: 0
+		}
+	})
+
+	const { question, answer, qLoading, qType } = toRefs(props)
+	const examStore = useExamStore()
+
+	const doRightEnum = computed(() => examStore.exam.question.answer.doRightEnum)
+	const doRightTag = computed(() => examStore.exam.question.answer.doRightTag)
+	const enumFormat = examStore.enumFormat
+
+	function trueFalseFormatter(question) {
+		const item = question.items?.find((d) => d.prefix === question.correct)
+		return item ? item.content : ''
+	}
+	function doRightTagFormatter(status) {
+		// ant-design-vue 的 a-tag 颜色映射
+		const tag = enumFormat(doRightTag.value, status)
+		if (tag === 'success') return 'green'
+		if (tag === 'danger') return 'red'
+		if (tag === 'warning') return 'orange'
+		return 'default'
+	}
+	function doRightTextFormatter(status) {
+		return enumFormat(doRightEnum.value, status)
+	}
+</script>
+
+<style lang="less" scoped>
+	.q-title {
+		font-weight: bold;
+		margin-bottom: 8px;
+	}
+	.q-content {
+		margin-bottom: 12px;
+	}
+	.question-prefix {
+		font-weight: bold;
+		margin-right: 4px;
+	}
+	.question-answer-show-item {
+		margin-bottom: 8px;
+	}
+	.question-show-item {
+		font-weight: 500;
+		margin-right: 8px;
+	}
+	.q-item-span-content {
+		display: inline-block;
+		vertical-align: middle;
+	}
+</style>

+ 129 - 0
src/views/student/exam/components/QuestionEdit.vue

@@ -0,0 +1,129 @@
+<template>
+	<div style="line-height: 1.8">
+		<a-spin :spinning="qLoading">
+			<!-- 单选题 -->
+			<div v-if="qType === 1">
+				<div class="q-title" v-html="question.title" />
+				<div class="q-content">
+					<a-radio-group :value="answer.content" @change="(e) => onSingleChange(e.target.value)">
+						<a-radio v-for="item in question.items" :key="item.prefix" :value="item.prefix">
+							<span class="question-prefix">{{ item.prefix }}.</span>
+							<span v-html="item.content" class="q-item-span-content"></span>
+						</a-radio>
+					</a-radio-group>
+				</div>
+			</div>
+			<!-- 多选题 -->
+			<div v-else-if="qType === 2">
+				<div class="q-title" v-html="question.title" />
+				<div class="q-content">
+					<a-checkbox-group :value="answer.contentArray" @change="(val) => onMultiChange(val)">
+						<a-checkbox v-for="item in question.items" :key="item.prefix" :value="item.prefix">
+							<span class="question-prefix">{{ item.prefix }}.</span>
+							<span v-html="item.content" class="q-item-span-content"></span>
+						</a-checkbox>
+					</a-checkbox-group>
+				</div>
+			</div>
+			<!-- 判断题 -->
+			<div v-else-if="qType === 3">
+				<div class="q-title" v-html="question.title" style="display: inline; margin-right: 10px" />
+				<span style="padding-right: 10px">(</span>
+				<a-radio-group :value="answer.content" @change="(e) => onSingleChange(e.target.value)">
+					<a-radio v-for="item in question.items" :key="item.prefix" :value="item.prefix">
+						<span v-html="item.content" class="q-item-span-content"></span>
+					</a-radio>
+				</a-radio-group>
+				<span style="padding-left: 10px">)</span>
+			</div>
+			<!-- 填空题 -->
+			<div v-else-if="qType === 4">
+				<div class="q-title" v-html="question.title" />
+				<div>
+					<a-form>
+						<a-form-item
+							v-for="item in question.items"
+							:key="item.prefix"
+							:label="item.prefix"
+							:style="{ margin: '10px 0' }"
+						>
+							<a-input
+								:value="answer.contentArray[item.prefix - 1]"
+								@change="(e) => onGapChange(item.prefix - 1, e.target.value)"
+							/>
+						</a-form-item>
+					</a-form>
+				</div>
+			</div>
+			<!-- 简答题 -->
+			<div v-else-if="qType === 5">
+				<div class="q-title" v-html="question.title" />
+				<div style="margin-top: 10px">
+					<a-textarea
+						:value="answer.content"
+						:rows="5"
+						:auto-size="{ minRows: 5, maxRows: 10 }"
+						@change="(e) => onSingleChange(e.target.value)"
+					/>
+				</div>
+			</div>
+		</a-spin>
+	</div>
+</template>
+
+<script setup>
+	import { defineEmits } from 'vue'
+
+	const props = defineProps({
+		question: {
+			type: Object,
+			default: () => ({})
+		},
+		answer: {
+			type: Object,
+			default: () => ({ id: null, content: '', contentArray: [] })
+		},
+		qLoading: {
+			type: Boolean,
+			default: false
+		},
+		qType: {
+			type: Number,
+			default: 0
+		}
+	})
+
+	const emit = defineEmits(['update:answerContent', 'update:answerContentArray', 'update:answerCompleted'])
+
+	function onSingleChange(val) {
+		emit('update:answerContent', val)
+		emit('update:answerCompleted', true)
+	}
+	function onMultiChange(val) {
+		emit('update:answerContentArray', val)
+		emit('update:answerCompleted', true)
+	}
+	function onGapChange(idx, val) {
+		const arr = [...(props.answer.contentArray || [])]
+		arr[idx] = val
+		emit('update:answerContentArray', arr)
+		emit('update:answerCompleted', true)
+	}
+</script>
+
+<style lang="less" scoped>
+	.q-title {
+		font-weight: bold;
+		margin-bottom: 8px;
+	}
+	.q-content {
+		margin-bottom: 12px;
+	}
+	.question-prefix {
+		font-weight: 500;
+		margin-right: 4px;
+	}
+	.q-item-span-content {
+		margin-left: 2px;
+	}
+</style>

+ 178 - 0
src/views/student/exam/paper/do.vue

@@ -0,0 +1,178 @@
+<template>
+	<div class="app-contain">
+		<a-row class="do-exam-title">
+			<a-col :span="24">
+				<span v-for="item in answer.answerItems" :key="item.itemOrder">
+					<a-tag
+						:color="questionCompleted(item.completed)"
+						class="do-exam-title-tag"
+						@click="goAnchor(`#question-${item.itemOrder}`)"
+					>
+						{{ item.itemOrder }}
+					</a-tag>
+				</span>
+				<span class="do-exam-time">
+					<label>剩余时间:</label>
+					<label>{{ formatSeconds(remainTime) }}</label>
+				</span>
+			</a-col>
+		</a-row>
+		<a-row class="do-exam-title-hidden">
+			<a-col :span="24">
+				<span v-for="item in answer.answerItems" :key="item.itemOrder">
+					<a-tag class="do-exam-title-tag">{{ item.itemOrder }}</a-tag>
+				</span>
+				<span class="do-exam-time">
+					<label>剩余时间:</label>
+				</span>
+			</a-col>
+		</a-row>
+		<a-layout class="app-item-contain">
+			<a-layout-header class="align-center">
+				<h1>{{ form.name }}</h1>
+				<div>
+					<span class="question-title-padding">试卷总分:{{ form.score }}</span>
+					<span class="question-title-padding">考试时间:{{ form.suggestTime }}分钟</span>
+				</div>
+			</a-layout-header>
+			<a-layout-content>
+				<a-form :model="form" ref="formRef" :label-col="{ span: 0 }" :wrapper-col="{ span: 24 }" :loading="formLoading">
+					<template v-for="(titleItem, index) in form.titleItems" :key="index">
+						<h3 class="question-title">{{ titleItem.name }}</h3>
+						<a-card class="exampaper-item-box" v-if="titleItem.questionItems && titleItem.questionItems.length">
+							<a-form-item
+								v-for="questionItem in titleItem.questionItems"
+								:key="questionItem.itemOrder"
+								:label="`${questionItem.itemOrder}.`"
+								class="exam-question-item"
+								:id="`question-${questionItem.itemOrder}`"
+								:label-col="{ span: 0 }"
+								:wrapper-col="{ span: 24 }"
+								:colon="false"
+							>
+								<QuestionEdit
+									:qType="questionItem.questionType"
+									:question="questionItem"
+									:answer="answer.answerItems[questionItem.itemOrder - 1]"
+									@update:answerCompleted="answer.answerItems[questionItem.itemOrder - 1].completed = true"
+									@update:answerContent="answer.answerItems[questionItem.itemOrder - 1].content = $event"
+									@update:answerContentArray="answer.answerItems[questionItem.itemOrder - 1].contentArray = $event"
+								/>
+							</a-form-item>
+						</a-card>
+					</template>
+					<a-row class="do-align-center">
+						<a-button type="primary" @click="submitForm">提交</a-button>
+						<a-button style="margin-left: 12px">取消</a-button>
+					</a-row>
+				</a-form>
+			</a-layout-content>
+		</a-layout>
+	</div>
+</template>
+
+<script setup>
+	import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
+	import { useRoute, useRouter } from 'vue-router'
+	import { useExamStore } from '@/store/exam'
+	import { formatSeconds } from '@/utils/exam'
+	import QuestionEdit from '../components/QuestionEdit.vue'
+	import examPaperApi from '@/api/student/examPaper'
+	import examPaperAnswerApi from '@/api/student/examPaperAnswer'
+	import { Modal } from 'ant-design-vue'
+	import '../../style.less'
+	const route = useRoute()
+	const router = useRouter()
+	const examStore = useExamStore()
+
+	const form = reactive({})
+	const formLoading = ref(false)
+	const answer = reactive({
+		questionId: null,
+		doTime: 0,
+		answerItems: []
+	})
+	const timer = ref(null)
+	const remainTime = ref(0)
+	const formRef = ref()
+
+	function questionCompleted(completed) {
+		// doCompletedTag: [{ key: false, value: 'info' }, { key: true, value: 'success' }]
+		return examStore.enumFormat(examStore.exam.question.answer.doCompletedTag, completed)
+	}
+
+	function goAnchor(selector) {
+		const el = document.querySelector(selector)
+		if (el) {
+			el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' })
+		}
+	}
+
+	function initAnswer() {
+		answer.id = form.id
+		answer.answerItems = []
+		const titleItemArray = form.titleItems || []
+		for (const tItem of titleItemArray) {
+			const questionArray = tItem.questionItems || []
+			for (const question of questionArray) {
+				answer.answerItems.push({
+					questionId: question.id,
+					content: null,
+					contentArray: [],
+					completed: false,
+					itemOrder: question.itemOrder
+				})
+			}
+		}
+	}
+
+	function timeReduce() {
+		timer.value = setInterval(() => {
+			if (remainTime.value <= 0) {
+				submitForm()
+			} else {
+				answer.doTime++
+				remainTime.value--
+			}
+		}, 1000)
+	}
+
+	function submitForm() {
+		clearInterval(timer.value)
+		formLoading.value = true
+		examPaperAnswerApi
+			.answerSubmit(answer)
+			.then((re) => {
+				Modal.success({
+					title: '考试结果',
+					content: `试卷得分:${re}分`,
+					okText: '返回考试记录',
+					onOk: () => {
+						router.push('/student/record/')
+					}
+				})
+				formLoading.value = false
+			})
+			.catch(() => {
+				formLoading.value = false
+			})
+	}
+
+	onMounted(() => {
+		const id = route.query.id
+		if (id && parseInt(id) !== 0) {
+			formLoading.value = true
+			examPaperApi.select(id).then((re) => {
+				Object.assign(form, re)
+				remainTime.value = re.suggestTime * 60
+				initAnswer()
+				timeReduce()
+				formLoading.value = false
+			})
+		}
+	})
+
+	onBeforeUnmount(() => {
+		clearInterval(timer.value)
+	})
+</script>

+ 177 - 0
src/views/student/exam/paper/edit.vue

@@ -0,0 +1,177 @@
+<template>
+	<div class="app-contain">
+		<a-row class="do-exam-title" style="background-color: #f5f5dc">
+			<a-col :span="24">
+				<span v-for="item in answer.answerItems" :key="item.itemOrder">
+					<a-tag
+						:color="questionDoRightTag(item.doRight)"
+						class="do-exam-title-tag"
+						@click="goAnchor('question-' + item.itemOrder)"
+					>
+						{{ item.itemOrder }}
+					</a-tag>
+				</span>
+			</a-col>
+		</a-row>
+		<a-row class="do-exam-title-hidden">
+			<a-col :span="24">
+				<span v-for="item in answer.answerItems" :key="item.itemOrder">
+					<a-tag class="do-exam-title-tag">{{ item.itemOrder }}</a-tag>
+				</span>
+			</a-col>
+		</a-row>
+		<a-layout class="app-item-contain">
+			<a-layout-header class="align-center">
+				<h1>{{ form.name }}</h1>
+				<div>
+					<span class="question-title-padding">试卷得分:{{ answer.score }}</span>
+					<span class="question-title-padding">试卷耗时:{{ formatSeconds(answer.doTime) }}</span>
+				</div>
+			</a-layout-header>
+			<a-layout-content>
+				<a-spin :spinning="formLoading">
+					<a-form :model="form">
+						<template v-for="(titleItem, index) in form.titleItems" :key="index">
+							<h3 class="question-title">{{ titleItem.name }}</h3>
+							<a-card class="exampaper-item-box" v-if="titleItem.questionItems.length !== 0">
+								<a-form-item
+									v-for="questionItem in titleItem.questionItems"
+									:key="questionItem.itemOrder"
+									:label="questionItem.itemOrder + '.'"
+									:id="'question-' + questionItem.itemOrder"
+									class="exam-question-item"
+									:label-col="{ span: 0 }"
+									:wrapper-col="{ span: 24 }"
+									:colon="false"
+								>
+									<QuestionAnswerShow
+										:qType="questionItem.questionType"
+										:question="questionItem"
+										:answer="answer.answerItems[questionItem.itemOrder - 1]"
+									/>
+									<div v-if="answer.answerItems[questionItem.itemOrder - 1].doRight === null" style="margin-top: 10px">
+										<label style="color: #e6a23c">批改:</label>
+										<a-radio-group v-model:value="answer.answerItems[questionItem.itemOrder - 1].score">
+											<a-radio v-for="item in scoreSelect(questionItem.score)" :key="item" :value="item">
+												{{ item }}
+											</a-radio>
+										</a-radio-group>
+									</div>
+								</a-form-item>
+							</a-card>
+						</template>
+						<a-row class="do-align-center">
+							<a-button type="primary" @click="submitForm">提交</a-button>
+							<a-button style="margin-left: 12px">取消</a-button>
+						</a-row>
+					</a-form>
+				</a-spin>
+			</a-layout-content>
+		</a-layout>
+	</div>
+</template>
+
+<script setup>
+	import { ref, onMounted, nextTick } from 'vue'
+	import { useRoute, useRouter } from 'vue-router'
+	import { useExamStore } from '@/store/exam'
+	import { message, Modal } from 'ant-design-vue'
+	import { formatSeconds } from '@/utils/exam'
+	import QuestionAnswerShow from '../components/QuestionAnswerShow.vue'
+	import questionAnswerApi from '@/api/student/examPaperAnswer'
+	import '../../style.less'
+
+	const route = useRoute()
+	const router = useRouter()
+	const examStore = useExamStore()
+	const { enumFormat } = examStore
+	const doRightTag = examStore.exam.question.answer.doRightTag
+
+	const form = ref({ titleItems: [] })
+	const formLoading = ref(false)
+	const answer = ref({
+		id: null,
+		score: 0,
+		doTime: 0,
+		answerItems: [],
+		doRight: false
+	})
+
+	function questionDoRightTag(status) {
+		// ant-design-vue的a-tag color支持字符串,直接用value即可
+		const tag = enumFormat(doRightTag, status)
+		if (tag === 'success') return 'green'
+		if (tag === 'danger') return 'red'
+		if (tag === 'warning') return 'orange'
+		return 'default'
+	}
+
+	function goAnchor(id) {
+		nextTick(() => {
+			const el = document.getElementById(id)
+			if (el) {
+				el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' })
+			}
+		})
+	}
+
+	function scoreSelect(score) {
+		let array = []
+		for (let i = 0; i <= parseInt(score); i++) {
+			array.push(i.toString())
+		}
+		if (String(score).indexOf('.') !== -1) {
+			array.push(score)
+		}
+		return array
+	}
+
+	function submitForm() {
+		formLoading.value = true
+		questionAnswerApi
+			.edit(answer.value)
+			.then((re) => {
+				Modal.success({
+					title: '考试结果',
+					content: `试卷得分:${re}分`,
+					okText: '返回考试记录',
+					onOk: () => {
+						router.push('/student/record/')
+					}
+				})
+				formLoading.value = false
+			})
+			.catch((e) => {
+				message.error(e.message || '提交失败')
+				formLoading.value = false
+			})
+	}
+
+	onMounted(() => {
+		const id = route.query.id
+		if (id && parseInt(id) !== 0) {
+			formLoading.value = true
+			questionAnswerApi.read(id).then((re) => {
+				form.value = re.paper
+				answer.value = re.answer
+				formLoading.value = false
+			})
+		}
+	})
+</script>
+
+<style lang="less" scoped>
+	.align-center {
+		text-align: center;
+	}
+	.exam-question-item {
+		padding: 10px;
+		:deep(.ant-form-item-label > label) {
+			font-size: 15px !important;
+		}
+	}
+	.question-title-padding {
+		padding-left: 25px;
+		padding-right: 25px;
+	}
+</style>

+ 110 - 0
src/views/student/exam/paper/read.vue

@@ -0,0 +1,110 @@
+<template>
+	<div class="app-contain">
+		<a-row class="do-exam-title">
+			<a-col :span="24">
+				<span v-for="item in answer.answerItems" :key="item.itemOrder">
+					<a-tag
+						:color="questionDoRightTag(item.doRight)"
+						class="do-exam-title-tag"
+						@click="goAnchor('question-' + item.itemOrder)"
+					>
+						{{ item.itemOrder }}
+					</a-tag>
+				</span>
+			</a-col>
+		</a-row>
+		<a-row class="do-exam-title-hidden">
+			<a-col :span="24">
+				<span v-for="item in answer.answerItems" :key="item.itemOrder">
+					<a-tag class="do-exam-title-tag">{{ item.itemOrder }}</a-tag>
+				</span>
+			</a-col>
+		</a-row>
+		<a-layout class="app-item-contain">
+			<a-layout-header class="align-center">
+				<h1>{{ form.name }}</h1>
+				<div>
+					<span class="question-title-padding">试卷得分:{{ answer.score }}</span>
+					<span class="question-title-padding">试卷耗时:{{ formatSeconds(answer.doTime) }}</span>
+				</div>
+			</a-layout-header>
+			<a-layout-content>
+				<a-spin :spinning="formLoading">
+					<a-form :model="form">
+						<template v-for="(titleItem, index) in form.titleItems" :key="index">
+							<h3 class="question-title">{{ titleItem.name }}</h3>
+							<a-card class="exampaper-item-box" v-if="titleItem.questionItems.length !== 0">
+								<a-form-item
+									v-for="questionItem in titleItem.questionItems"
+									:key="questionItem.itemOrder"
+									:label="questionItem.itemOrder + '.'"
+									:id="'question-' + questionItem.itemOrder"
+									class="exam-question-item"
+									:label-col="{ span: 2 }"
+									:wrapper-col="{ span: 24 }"
+									:colon="false"
+								>
+									<QuestionAnswerShow
+										:qType="questionItem.questionType"
+										:question="questionItem"
+										:answer="answer.answerItems[questionItem.itemOrder - 1]"
+									/>
+								</a-form-item>
+							</a-card>
+						</template>
+					</a-form>
+				</a-spin>
+			</a-layout-content>
+		</a-layout>
+	</div>
+</template>
+
+<script setup>
+	import { ref, onMounted, nextTick } from 'vue'
+	import { useRoute } from 'vue-router'
+	import { useExamStore } from '@/store/exam'
+	import { formatSeconds } from '@/utils/exam'
+	import examPaperAnswerApi from '@/api/student/examPaperAnswer'
+	import QuestionAnswerShow from '../components/QuestionAnswerShow.vue'
+	import '../../style.less'
+	const route = useRoute()
+	const examStore = useExamStore()
+	const { enumFormat } = examStore
+	const doRightTag = examStore.exam.question.answer.doRightTag
+
+	const form = ref({ titleItems: [] })
+	const formLoading = ref(false)
+	const answer = ref({
+		id: null,
+		score: 0,
+		doTime: 0,
+		answerItems: [],
+		doRight: false
+	})
+
+	function questionDoRightTag(status) {
+		// ant-design-vue的a-tag color支持字符串,直接用value即可
+		return enumFormat(doRightTag, status)
+	}
+
+	function goAnchor(id) {
+		nextTick(() => {
+			const el = document.getElementById(id)
+			if (el) {
+				el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' })
+			}
+		})
+	}
+
+	onMounted(() => {
+		const id = route.query.id
+		if (id && parseInt(id) !== 0) {
+			formLoading.value = true
+			examPaperAnswerApi.read(id).then((re) => {
+				form.value = re.paper
+				answer.value = re.answer
+				formLoading.value = false
+			})
+		}
+	})
+</script>

+ 139 - 0
src/views/student/paper/index.vue

@@ -0,0 +1,139 @@
+<template>
+	<div class="paper-list">
+		<a-spin :spinning="listLoading">
+			<a-tabs tab-position="left" v-model:activeKey="tabId" @change="subjectChange" class="subject-tabs">
+				<a-tab-pane v-for="item in subjectList" :key="item.id" :tab="item.name">
+					<div class="paper-type-radio">
+						<a-radio-group v-model:value="queryParam.paperType" @change="paperTypeChange" size="small">
+							<a-radio v-for="type in paperTypeEnum" :key="type.key" :value="type.key">
+								{{ type.value }}
+							</a-radio>
+						</a-radio-group>
+					</div>
+					<a-table :dataSource="tableData" :columns="columns" :pagination="false" rowKey="id" bordered size="middle">
+						<template #bodyCell="{ column, record }">
+							<template v-if="column.key === 'action'">
+								<router-link :to="{ path: '/student/do', query: { id: record.id } }" target="_blank">
+									<a-button type="link" size="small">开始答题</a-button>
+								</router-link>
+							</template>
+						</template>
+					</a-table>
+					<a-pagination
+						v-if="total > 0"
+						:total="total"
+						:current="queryParam.pageIndex"
+						:pageSize="queryParam.pageSize"
+						@change="onPageChange"
+						@showSizeChange="onPageSizeChange"
+						:showSizeChanger="true"
+						:pageSizeOptions="['10', '20', '50', '100']"
+						style="margin-top: 20px"
+					/>
+				</a-tab-pane>
+			</a-tabs>
+		</a-spin>
+	</div>
+</template>
+
+<script setup>
+	import { ref, reactive, onMounted, computed } from 'vue'
+	import { useExamStore } from '@/store/exam'
+	import examPaperApi from '@/api/student/examPaper'
+
+	// store
+	const examStore = useExamStore()
+	const paperTypeEnum = computed(() => examStore.paperTypeEnum)
+
+	// data
+	const queryParam = reactive({
+		paperType: 1,
+		subjectId: 0,
+		pageIndex: 1,
+		pageSize: 10
+	})
+	const tabId = ref('')
+	const listLoading = ref(true)
+	const subjectList = ref([])
+	const tableData = ref([])
+	const total = ref(0)
+	const columns = [
+		{ title: '序号', dataIndex: 'id', key: 'id', width: 90 },
+		{ title: '名称', dataIndex: 'name', key: 'name' },
+		{ title: '操作', key: 'action', align: 'right' }
+	]
+
+	// methods
+	const initSubject = async () => {
+		listLoading.value = true
+		await examStore.initSubject()
+		subjectList.value = examStore.subjects
+		if (subjectList.value.length > 0) {
+			const subjectId = subjectList.value[0].id
+			queryParam.subjectId = subjectId
+			tabId.value = subjectId.toString()
+			await search()
+		}
+		listLoading.value = false
+	}
+
+	const search = async () => {
+		listLoading.value = true
+		const params = {
+			...queryParam,
+			current: queryParam.pageIndex,
+			size: queryParam.pageSize
+		}
+		delete params.pageIndex
+		delete params.pageSize
+		const res = await examPaperApi.pageList(params)
+		const re = res
+		tableData.value = re.records
+		total.value = re.total
+		queryParam.pageIndex = re.current
+		listLoading.value = false
+	}
+
+	const paperTypeChange = () => {
+		queryParam.pageIndex = 1
+		search()
+	}
+
+	const subjectChange = (key) => {
+		queryParam.subjectId = Number(key)
+		queryParam.pageIndex = 1
+		search()
+	}
+
+	const onPageChange = (page, pageSize) => {
+		queryParam.pageIndex = page
+		queryParam.pageSize = pageSize
+		search()
+	}
+
+	const onPageSizeChange = (current, size) => {
+		queryParam.pageIndex = 1
+		queryParam.pageSize = size
+		search()
+	}
+
+	// lifecycle
+	onMounted(() => {
+		initSubject()
+	})
+</script>
+
+<style lang="less" scoped>
+	.paper-list {
+		margin-top: 10px;
+		.subject-tabs {
+			.ant-tabs-nav {
+				margin-right: 20px;
+			}
+		}
+		.paper-type-radio {
+			float: right;
+			margin-bottom: 10px;
+		}
+	}
+</style>

+ 156 - 0
src/views/student/question-error/index.vue

@@ -0,0 +1,156 @@
+<template>
+	<div style="margin-top: 10px" class="app-contain">
+		<a-row :gutter="50">
+			<a-col :span="14">
+				<a-table
+					:loading="listLoading"
+					:data-source="tableData"
+					:columns="columns"
+					row-key="id"
+					:custom-row="customRow"
+					:pagination="false"
+				>
+					<template #bodyCell="{ column, record }">
+						<template v-if="column.key === 'questionType'">
+							{{ questionTypeFormatter(record.questionType) }}
+						</template>
+						<template v-else-if="column.key === 'subjectName'">
+							{{ record.subjectName }}
+						</template>
+						<template v-else-if="column.key === 'createTime'">
+							{{ record.createTime }}
+						</template>
+						<template v-else-if="column.key === 'shortTitle'">
+							<span :title="record.shortTitle">{{ record.shortTitle }}</span>
+						</template>
+					</template>
+				</a-table>
+				<a-pagination
+					v-if="total > 0"
+					:total="total"
+					:current="queryParam.pageIndex"
+					:page-size="queryParam.pageSize"
+					@change="onPageChange"
+					@showSizeChange="onPageSizeChange"
+					:show-size-changer="true"
+					:page-size-options="['10', '20', '50', '100']"
+					style="margin-top: 20px"
+				/>
+			</a-col>
+			<a-col :span="10">
+				<a-card class="record-answer-info">
+					<a-form layout="vertical">
+						<a-form-item>
+							<QuestionAnswerShow
+								:qType="selectItem.questionType"
+								:qLoading="qAnswerLoading"
+								:question="selectItem.questionItem"
+								:answer="selectItem.answerItem"
+							/>
+						</a-form-item>
+					</a-form>
+				</a-card>
+			</a-col>
+		</a-row>
+	</div>
+</template>
+
+<script setup>
+	import { ref, reactive, onMounted } from 'vue'
+	import { useExamStore } from '@/store/exam'
+	import examPaperAnswerApi from '@/api/student/questionAnswer'
+	import QuestionAnswerShow from '../exam/components/QuestionAnswerShow.vue'
+
+	const examStore = useExamStore()
+
+	const queryParam = reactive({
+		pageIndex: 1,
+		pageSize: 10
+	})
+	const listLoading = ref(false)
+	const tableData = ref([])
+	const total = ref(0)
+	const qAnswerLoading = ref(false)
+	const selectItem = reactive({
+		questionType: 0,
+		questionItem: null,
+		answerItem: null
+	})
+
+	const columns = [
+		{ title: '题干', dataIndex: 'shortTitle', key: 'shortTitle', ellipsis: true },
+		{ title: '题型', dataIndex: 'questionType', key: 'questionType', width: 70 },
+		{ title: '学科', dataIndex: 'subjectName', key: 'subjectName', width: 50 },
+		{ title: '做题时间', dataIndex: 'createTime', key: 'createTime', width: 170 }
+	]
+
+	function search() {
+		listLoading.value = true
+		const params = { ...queryParam, current: queryParam.pageIndex, size: queryParam.pageSize }
+		delete params.pageIndex
+		delete params.pageSize
+		examPaperAnswerApi
+			.pageList(params)
+			.then((data) => {
+				const re = data
+				tableData.value = re.records
+				total.value = re.total
+				queryParam.pageIndex = re.current
+				listLoading.value = false
+				if (re.records && re.records.length !== 0) {
+					qAnswerShow(re.records[0].id)
+				}
+			})
+			.catch(() => {
+				listLoading.value = false
+			})
+	}
+
+	function customRow(record) {
+		return {
+			onClick: () => itemSelect(record)
+		}
+	}
+	function itemSelect(record) {
+		qAnswerShow(record.id)
+	}
+
+	function qAnswerShow(id) {
+		qAnswerLoading.value = true
+		examPaperAnswerApi.select(id).then((re) => {
+			let response = re
+			selectItem.questionType = response.questionVM.questionType
+			selectItem.questionItem = response.questionVM
+			selectItem.answerItem = response.questionAnswerVM
+			qAnswerLoading.value = false
+		})
+	}
+
+	function questionTypeFormatter(type) {
+		return examStore.enumFormat(examStore.exam.question.typeEnum, type)
+	}
+
+	function onPageChange(page, pageSize) {
+		queryParam.pageIndex = page
+		queryParam.pageSize = pageSize
+		search()
+	}
+	function onPageSizeChange(current, size) {
+		queryParam.pageIndex = 1
+		queryParam.pageSize = size
+		search()
+	}
+
+	onMounted(() => {
+		search()
+	})
+</script>
+
+<style lang="less" scoped>
+	.app-contain {
+		// 可根据需要自定义样式
+	}
+	.record-answer-info {
+		margin-top: 20px;
+	}
+</style>

+ 172 - 0
src/views/student/record/index.vue

@@ -0,0 +1,172 @@
+<template>
+	<div style="margin-top: 10px" class="app-contain">
+		<a-row :gutter="50">
+			<a-col :span="18">
+				<a-table
+					:loading="listLoading"
+					:data-source="tableData"
+					:columns="columns"
+					row-key="id"
+					:custom-row="customRow"
+					:pagination="false"
+				>
+					<template #bodyCell="{ column, record }">
+						<template v-if="column.key === 'status'">
+							<a-tag :color="statusTagFormatter(record.status)">
+								{{ statusTextFormatter(record.status) }}
+							</a-tag>
+						</template>
+						<template v-else-if="column.key === 'action'">
+							<template v-if="record.status === 1">
+								<router-link :to="{ path: '/student/edit', query: { id: record.id } }" target="_blank">
+									<a-button type="link" size="small">批改</a-button>
+								</router-link>
+							</template>
+							<template v-else-if="record.status === 2">
+								<router-link :to="{ path: '/student/read', query: { id: record.id } }" target="_blank">
+									<a-button type="link" size="small">查看试卷</a-button>
+								</router-link>
+							</template>
+						</template>
+					</template>
+				</a-table>
+				<a-pagination
+					v-if="total > 0"
+					:total="total"
+					:current="queryParam.pageIndex"
+					:page-size="queryParam.pageSize"
+					@change="onPageChange"
+					@showSizeChange="onPageSizeChange"
+					:show-size-changer="true"
+					:page-size-options="['10', '20', '50', '100']"
+					style="margin-top: 20px"
+				/>
+			</a-col>
+			<a-col :span="6">
+				<a-card class="record-answer-info">
+					<a-descriptions :column="1" bordered size="small">
+						<a-descriptions-item label="系统判分">{{ selectItem.systemScore }}</a-descriptions-item>
+						<a-descriptions-item label="最终得分">{{ selectItem.userScore }}</a-descriptions-item>
+						<a-descriptions-item label="试卷总分">{{ selectItem.paperScore }}</a-descriptions-item>
+						<a-descriptions-item label="正确题数">{{ selectItem.questionCorrect }}</a-descriptions-item>
+						<a-descriptions-item label="总题数">{{ selectItem.questionCount }}</a-descriptions-item>
+						<a-descriptions-item label="用时">{{ selectItem.doTime }}</a-descriptions-item>
+					</a-descriptions>
+				</a-card>
+			</a-col>
+		</a-row>
+	</div>
+</template>
+
+<script setup>
+	import { ref, reactive, onMounted } from 'vue'
+	import { useExamStore } from '@/store/exam'
+	import examPaperAnswerApi from '@/api/student/examPaperAnswer'
+	import { parseTime } from '@/utils/exam'
+	const examStore = useExamStore()
+
+	const queryParam = reactive({
+		pageIndex: 1,
+		pageSize: 10
+	})
+	const listLoading = ref(false)
+	const tableData = ref([])
+	const total = ref(0)
+	const selectItem = reactive({
+		systemScore: '0',
+		userScore: '0',
+		doTime: '0',
+		paperScore: '0',
+		questionCorrect: 0,
+		questionCount: 0
+	})
+
+	const columns = [
+		{ title: '序号', dataIndex: 'id', key: 'id', width: 90 },
+		{ title: '名称', dataIndex: 'paperName', key: 'paperName' },
+		{ title: '学科', dataIndex: 'subjectName', key: 'subjectName', width: 70 },
+		{
+			title: '状态',
+			dataIndex: 'status',
+			key: 'status',
+			width: 100
+		},
+		{
+			title: '做题时间',
+			dataIndex: 'createTime',
+			key: 'createTime',
+			width: 200,
+			customRender: ({ text }) => formatDateTime(text)
+		},
+		{
+			title: '',
+			key: 'action',
+			align: 'right',
+			width: 70
+		}
+	]
+
+	function formatDateTime(val) {
+		if (!val) return ''
+		return parseTime(val, '{y}-{m}-{d} {h}:{i}:{s}')
+	}
+
+	function search() {
+		listLoading.value = true
+		const params = { ...queryParam, current: queryParam.pageIndex, size: queryParam.pageSize }
+		delete params.pageIndex
+		delete params.pageSize
+		examPaperAnswerApi
+			.pageList(params)
+			.then((data) => {
+				const re = data
+				tableData.value = re.records
+				total.value = re.total
+				queryParam.pageIndex = re.current
+				listLoading.value = false
+			})
+			.catch(() => {
+				listLoading.value = false
+			})
+	}
+	function customRow(record) {
+		return {
+			onClick: () => itemSelect(record)
+		}
+	}
+
+	function itemSelect(record) {
+		Object.assign(selectItem, record)
+	}
+
+	function statusTagFormatter(status) {
+		return examStore.enumFormat(examStore.exam.examPaperAnswer.statusTag, status)
+	}
+	function statusTextFormatter(status) {
+		return examStore.enumFormat(examStore.exam.examPaperAnswer.statusEnum, status)
+	}
+
+	function onPageChange(page, pageSize) {
+		queryParam.pageIndex = page
+		queryParam.pageSize = pageSize
+		search()
+	}
+	function onPageSizeChange(current, size) {
+		queryParam.pageIndex = 1
+		queryParam.pageSize = size
+		search()
+	}
+
+	onMounted(() => {
+		search()
+	})
+</script>
+
+<style lang="less" scoped>
+	.app-contain {
+		// 可根据需要自定义样式
+	}
+	.record-answer-info {
+		margin-top: 20px;
+	}
+</style>

+ 115 - 0
src/views/student/style.less

@@ -0,0 +1,115 @@
+.do-exam-title {
+	position: fixed;
+	width: 100%;
+	background: #fff6f6;
+	z-index: 999;
+	padding: 5px 0px;
+}
+
+.do-exam-title-hidden {
+	width: 100%;
+	visibility: hidden;
+	padding: 5px 0px;
+}
+
+.do-exam-title-tag {
+	margin-left: 5px;
+	cursor: pointer;
+	color: #000;
+}
+
+.do-exam-time {
+	float: right;
+	line-height: 2;
+	font-size: 14px;
+	padding-right: 5px;
+}
+
+.do-align-center {
+	display: flex;
+	justify-content: center;
+	text-align: center;
+	margin-top: 40px;
+	margin-bottom: 20px;
+	.ant-btn {
+		padding: 0 25px;
+		height: 40px;
+	}
+	.el-form-item__content {
+		margin-left: 0px !important;
+	}
+}
+.exam-question-item {
+	padding: 10px;
+	display: flex;
+	align-items: flex-start;
+	.ant-form-item-label {
+		display: inline-block;
+		width: auto;
+		margin-right: 12px;
+		min-width: 30px;
+		padding-top: 4px;
+		text-align: right;
+	}
+	.ant-form-item-label > label {
+		font-size: 18px !important;
+		display: inline-block;
+		color: #333;
+		font-weight: 600;
+		line-height: 0.5;
+		position: relative;
+		z-index: 1;
+		&::before {
+			display: none !important;
+		}
+	}
+	.ant-form-item-control {
+		flex: 1;
+		width: 100%;
+		border: 1px soolid red;
+	}
+	@media (max-width: 768px) {
+		.ant-form-item-label {
+			min-width: 25px;
+			margin-right: 8px;
+		}
+		.ant-form-item-label > label {
+			font-size: 16px !important;
+		}
+	}
+}
+.question-title {
+	margin-left: 25px;
+}
+.question-title-padding {
+	padding-left: 25px;
+	padding-right: 25px;
+}
+
+.exampaper-item-box {
+	margin: 0 20px 20px;
+	box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+	border-radius: 6px;
+	:deep(.ant-card-body) {
+		padding: 15px 20px;
+	}
+}
+.app-contain {
+	height: 100vh;
+	overflow: auto;
+}
+.app-item-contain {
+	max-width: 1200px;
+	margin: 0 auto;
+	background-color: #fff;
+	border-radius: 8px;
+}
+.align-center {
+	text-align: center;
+	background: none;
+	padding: 20px 0;
+	height: 150px;
+}
+:deep(.ant-layout-content) {
+	padding: 0 15px 30px;
+}