Merge branch 'dev' of http://110.42.96.65:19890/GoCo/OL-LearnPlatform-Frontend into dev
This commit is contained in:
commit
597b9a0d3f
@ -4,3 +4,4 @@ type: "manual"
|
||||
|
||||
1、在接下来的每一个步骤当中,请帮我实现对页面的响应式设计
|
||||
2、必须严格执行我给你的指令,一步一步执行,不得有缩减
|
||||
3、我们用的是naive UI组件 ,TS,vue3
|
||||
|
BIN
public/images/loginImage/backImage.png
Normal file
BIN
public/images/loginImage/backImage.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.7 MiB |
BIN
public/images/loginImage/logo.png
Normal file
BIN
public/images/loginImage/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 68 KiB |
25
src/App.vue
25
src/App.vue
@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import { NConfigProvider } from 'naive-ui'
|
||||
import { NConfigProvider, NMessageProvider } from 'naive-ui'
|
||||
import type { GlobalThemeOverrides } from 'naive-ui';
|
||||
|
||||
|
||||
@ -58,6 +58,10 @@ const themeOverrides: GlobalThemeOverrides = {
|
||||
};
|
||||
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
|
||||
// 检查是否为登录页面
|
||||
const isLoginPage = computed(() => route.name === 'Login')
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化用户认证状态
|
||||
@ -68,9 +72,18 @@ onMounted(() => {
|
||||
<template>
|
||||
<div id="app">
|
||||
<n-config-provider :theme-overrides="themeOverrides">
|
||||
<AppLayout>
|
||||
<RouterView />
|
||||
</AppLayout>
|
||||
<!-- 登录页面不使用 AppLayout,但需要 message provider -->
|
||||
<template v-if="isLoginPage">
|
||||
<n-message-provider>
|
||||
<RouterView />
|
||||
</n-message-provider>
|
||||
</template>
|
||||
<!-- 其他页面使用 AppLayout -->
|
||||
<template v-else>
|
||||
<AppLayout>
|
||||
<RouterView />
|
||||
</AppLayout>
|
||||
</template>
|
||||
</n-config-provider>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -67,6 +67,8 @@ import ExamLibrary from '@/views/teacher/ExamPages/ExamLibrary.vue'
|
||||
import MarkingCenter from '@/views/teacher/ExamPages/MarkingCenter.vue'
|
||||
import AddExam from '@/views/teacher/ExamPages/AddExam.vue'
|
||||
import AddQuestion from '@/views/teacher/ExamPages/AddQuestion.vue'
|
||||
import StudentList from '@/views/teacher/ExamPages/StudentList.vue'
|
||||
import GradingPage from '@/views/teacher/ExamPages/GradingPage.vue'
|
||||
|
||||
import ChapterEditor from '@/views/teacher/course/ChapterEditor.vue'
|
||||
|
||||
@ -289,8 +291,29 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'marking-center',
|
||||
name: 'MarkingCenter',
|
||||
component: MarkingCenter,
|
||||
meta: { title: '阅卷中心' }
|
||||
component: ExamManagement,
|
||||
meta: { title: '阅卷中心' },
|
||||
redirect: '/teacher/exam-management/marking-center/list',
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
name: 'MarkingList',
|
||||
component: MarkingCenter,
|
||||
meta: { title: '试卷列表' }
|
||||
},
|
||||
{
|
||||
path: 'student-list/:paperId',
|
||||
name: 'StudentList',
|
||||
component: StudentList,
|
||||
meta: { title: '阅卷页面' }
|
||||
},
|
||||
{
|
||||
path: 'grading/:examId/:studentId',
|
||||
name: 'GradingPage',
|
||||
component: GradingPage,
|
||||
meta: { title: '批阅试卷' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'add',
|
||||
@ -298,6 +321,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: AddExam,
|
||||
meta: { title: '添加试卷' }
|
||||
},
|
||||
{
|
||||
path: 'preview',
|
||||
name: 'ExamPreview',
|
||||
component: () => import('../views/teacher/ExamPages/ExamPreview.vue'),
|
||||
meta: { title: '试卷预览' }
|
||||
},
|
||||
{
|
||||
path: 'add-question/:id?',
|
||||
name: 'AddQuestionPage',
|
||||
@ -367,6 +396,14 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { title: 'AI伴学' }
|
||||
},
|
||||
|
||||
// 登录页面
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { title: '登录' }
|
||||
},
|
||||
|
||||
// 首页与课程
|
||||
{
|
||||
path: '/service-agreement',
|
||||
|
@ -1,10 +1,43 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-container">
|
||||
<!-- 背景图片 -->
|
||||
<div class="background-image">
|
||||
<img src="/images/loginImage/backImage.png" alt="登录背景" />
|
||||
</div>
|
||||
|
||||
<!-- 左上角logo -->
|
||||
<div class="top-logo">
|
||||
<img src="/images/loginImage/logo.png" alt="云岭智教" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧登录区域 -->
|
||||
<div class="login-area">
|
||||
<!-- 学员端/教师端切换 -->
|
||||
<div class="user-type-tabs">
|
||||
<n-button
|
||||
:type="activeTab === 'student' ? 'primary' : 'default'"
|
||||
text
|
||||
@click="activeTab = 'student'"
|
||||
class="type-tab"
|
||||
:class="{ active: activeTab === 'student' }"
|
||||
>
|
||||
学员端
|
||||
</n-button>
|
||||
<n-button
|
||||
:type="activeTab === 'teacher' ? 'primary' : 'default'"
|
||||
text
|
||||
@click="activeTab = 'teacher'"
|
||||
class="type-tab"
|
||||
:class="{ active: activeTab === 'teacher' }"
|
||||
>
|
||||
教师端
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<div class="login-form">
|
||||
<div class="form-header">
|
||||
<h1>登录</h1>
|
||||
<p>欢迎回到在线学习平台</p>
|
||||
<h2>账号密码登录</h2>
|
||||
</div>
|
||||
|
||||
<n-form
|
||||
@ -14,18 +47,15 @@
|
||||
size="large"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<n-form-item path="email" label="邮箱">
|
||||
<n-form-item path="studentId" label="学号">
|
||||
<n-input
|
||||
v-model:value="formData.email"
|
||||
placeholder="请输入邮箱地址"
|
||||
type="email"
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon>
|
||||
<MailOutline />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-input>
|
||||
v-model:value="formData.studentId"
|
||||
placeholder="2014195268"
|
||||
class="form-input"
|
||||
/>
|
||||
<div class="input-hint">
|
||||
没有账号?<n-button text type="primary" size="small">立即注册</n-button>
|
||||
</div>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item path="password" label="密码">
|
||||
@ -33,22 +63,17 @@
|
||||
v-model:value="formData.password"
|
||||
placeholder="请输入密码"
|
||||
type="password"
|
||||
show-password-on="mousedown"
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon>
|
||||
<LockClosedOutline />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-input>
|
||||
show-password-on="click"
|
||||
class="form-input"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item>
|
||||
<div class="form-options">
|
||||
<n-checkbox v-model:checked="rememberMe">
|
||||
记住我
|
||||
<n-checkbox v-model:checked="rememberMe" size="small">
|
||||
下次自动登录
|
||||
</n-checkbox>
|
||||
<n-button text type="primary">
|
||||
<n-button text type="primary" size="small">
|
||||
忘记密码?
|
||||
</n-button>
|
||||
</div>
|
||||
@ -61,6 +86,7 @@
|
||||
block
|
||||
:loading="userStore.isLoading"
|
||||
attr-type="submit"
|
||||
class="login-btn"
|
||||
>
|
||||
登录
|
||||
</n-button>
|
||||
@ -68,45 +94,11 @@
|
||||
</n-form>
|
||||
|
||||
<div class="form-footer">
|
||||
<p>
|
||||
还没有账号?
|
||||
<n-button text type="primary" @click="$router.push('/register')">
|
||||
立即注册
|
||||
</n-button>
|
||||
<p class="agreement-text">
|
||||
登录即同意我们的用户协议
|
||||
<n-button text type="primary" size="small">《服务协议和隐私政策》</n-button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 社交登录 -->
|
||||
<div class="social-login">
|
||||
<n-divider>或使用以下方式登录</n-divider>
|
||||
<n-space justify="center">
|
||||
<n-button circle size="large">
|
||||
<n-icon size="20">
|
||||
<LogoGithub />
|
||||
</n-icon>
|
||||
</n-button>
|
||||
<n-button circle size="large">
|
||||
<n-icon size="20">
|
||||
<LogoGoogle />
|
||||
</n-icon>
|
||||
</n-button>
|
||||
<n-button circle size="large">
|
||||
<n-icon size="20">
|
||||
<LogoWechat />
|
||||
</n-icon>
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 侧边图片 -->
|
||||
<div class="login-image">
|
||||
<PlaceholderImage
|
||||
:width="600"
|
||||
:height="800"
|
||||
text="登录背景图"
|
||||
icon="🎨"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -117,14 +109,6 @@ import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage, type FormInst, type FormRules } from 'naive-ui'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import PlaceholderImage from '@/components/common/PlaceholderImage.vue'
|
||||
import {
|
||||
MailOutline,
|
||||
LockClosedOutline,
|
||||
LogoGithub,
|
||||
LogoGoogle,
|
||||
LogoWechat
|
||||
} from '@vicons/ionicons5'
|
||||
import { AuthApi } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
@ -133,24 +117,20 @@ const userStore = useUserStore()
|
||||
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
const rememberMe = ref(false)
|
||||
const activeTab = ref('student') // 当前选中的标签页
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
email: '',
|
||||
studentId: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules: FormRules = {
|
||||
email: [
|
||||
studentId: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入邮箱地址',
|
||||
trigger: ['input', 'blur']
|
||||
},
|
||||
{
|
||||
type: 'email',
|
||||
message: '请输入有效的邮箱地址',
|
||||
message: '请输入学号',
|
||||
trigger: ['input', 'blur']
|
||||
}
|
||||
],
|
||||
@ -178,9 +158,9 @@ const handleSubmit = async () => {
|
||||
// 显示加载状态
|
||||
userStore.isLoading = true
|
||||
|
||||
// 调用登录API
|
||||
// 调用登录API - 使用学号登录
|
||||
const response = await AuthApi.login({
|
||||
email: formData.email,
|
||||
username: formData.studentId, // 使用username字段传递学号
|
||||
password: formData.password
|
||||
})
|
||||
|
||||
@ -231,9 +211,13 @@ const handleSubmit = async () => {
|
||||
|
||||
message.success('登录成功!')
|
||||
|
||||
// 登录成功后跳转到首页或之前的页面
|
||||
// 根据用户类型跳转到不同页面
|
||||
const redirect = router.currentRoute.value.query.redirect as string
|
||||
router.push(redirect || '/')
|
||||
if (activeTab.value === 'teacher') {
|
||||
router.push(redirect || '/teacher')
|
||||
} else {
|
||||
router.push(redirect || '/')
|
||||
}
|
||||
} else {
|
||||
message.error(response.message || '登录失败')
|
||||
}
|
||||
@ -242,7 +226,7 @@ const handleSubmit = async () => {
|
||||
|
||||
// 处理不同类型的错误
|
||||
if (error.response?.status === 401) {
|
||||
message.error('邮箱或密码错误')
|
||||
message.error('学号或密码错误')
|
||||
} else if (error.response?.status === 429) {
|
||||
message.error('登录尝试过于频繁,请稍后再试')
|
||||
} else if (error.response?.data?.message) {
|
||||
@ -259,88 +243,337 @@ const handleSubmit = async () => {
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 250px; /* 增加右侧边距 */
|
||||
}
|
||||
|
||||
/* 背景图片 - 居中显示,覆盖整个页面 */
|
||||
.background-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
max-width: 1000px;
|
||||
.background-image img {
|
||||
width: 100%;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 60px 40px;
|
||||
/* 左上角logo */
|
||||
.top-logo {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: 48px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.top-logo img {
|
||||
height: 48px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* 右侧登录区域 */
|
||||
.login-area {
|
||||
position: relative;
|
||||
width: 516px;
|
||||
min-height: 520px; /* 降低高度 */
|
||||
z-index: 10;
|
||||
background: transparent; /* 透明背景 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
/* 用户类型切换标签 */
|
||||
.user-type-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 40px; /* 增加按钮间距 */
|
||||
margin-bottom: 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-header h1 {
|
||||
font-size: 2rem;
|
||||
.type-tab {
|
||||
width: 84px;
|
||||
height: 40px;
|
||||
font-family: PingFangSC, PingFang SC;
|
||||
font-weight: 400; /* 未点击状态字重 */
|
||||
font-size: 24px;
|
||||
color: #000000; /* 未点击状态颜色 */
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
font-style: normal;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 激活状态样式 */
|
||||
.type-tab.active {
|
||||
font-weight: 500; /* 点击状态字重 */
|
||||
color: #0288D1; /* 点击状态颜色 */
|
||||
}
|
||||
|
||||
/* 激活状态底部横线 */
|
||||
.type-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
background: #0288D1;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 悬停效果 */
|
||||
.type-tab:hover {
|
||||
color: #0288D1;
|
||||
}
|
||||
|
||||
/* 登录表单容器 */
|
||||
.login-form {
|
||||
background: rgba(255,255,255,0.5);
|
||||
padding: 40px 35px; /* 减少内边距 */
|
||||
border-radius: 12px; /* 添加适度的圆角 */
|
||||
border: 2px solid #FFFFFF;
|
||||
max-height: 630px; /* 限制最大高度 */
|
||||
}
|
||||
|
||||
/* 表单样式 */
|
||||
.form-header {
|
||||
margin-bottom: 32px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.n-form-item-label) {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.n-input) {
|
||||
border-radius: 6px; /* 添加轻微圆角 */
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
:deep(.n-input:hover) {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
:deep(.n-input.n-input--focus) {
|
||||
border-color: #1890ff;
|
||||
box-shadow: none; /* 移除聚焦阴影 */
|
||||
}
|
||||
|
||||
:deep(.n-input__input-el) {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
.input-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
:deep(.n-checkbox .n-checkbox__label) {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
background: #1890ff;
|
||||
border: none;
|
||||
border-radius: 6px; /* 添加轻微圆角 */
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-top: 20px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.login-btn:hover) {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.social-login {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.login-image {
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.agreement-text {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.login-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1400px) {
|
||||
.login-page {
|
||||
padding-right: 180px; /* 调整右边距 */
|
||||
}
|
||||
|
||||
.login-area {
|
||||
width: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.login-page {
|
||||
padding-right: 120px; /* 调整右边距 */
|
||||
}
|
||||
|
||||
.login-area {
|
||||
width: 450px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.login-page {
|
||||
justify-content: center;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.login-area {
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.background-image {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.top-logo {
|
||||
top: 20px;
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.user-type-tabs {
|
||||
justify-content: center;
|
||||
gap: 32px; /* 中等屏幕间距 */
|
||||
}
|
||||
|
||||
.type-tab {
|
||||
font-size: 24px;
|
||||
width: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.login-container {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 400px;
|
||||
.login-page {
|
||||
justify-content: center;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.login-image {
|
||||
display: none;
|
||||
.login-area {
|
||||
width: 350px;
|
||||
max-width: 85vw;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 40px 24px;
|
||||
padding: 36px 24px;
|
||||
}
|
||||
|
||||
.top-logo {
|
||||
top: 16px;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.top-logo img {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.user-type-tabs {
|
||||
margin-bottom: 30px;
|
||||
gap: 28px; /* 小屏幕间距 */
|
||||
}
|
||||
|
||||
.type-tab {
|
||||
font-size: 20px;
|
||||
width: 60px;
|
||||
height: 35px;
|
||||
line-height: 35px;
|
||||
}
|
||||
|
||||
.type-tab.active::after {
|
||||
width: 30px;
|
||||
height: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-page {
|
||||
justify-content: center;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.login-area {
|
||||
width: 300px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 32px 20px;
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.top-logo {
|
||||
top: 12px;
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.type-tab {
|
||||
font-size: 18px;
|
||||
width: 55px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.type-tab.active::after {
|
||||
width: 25px;
|
||||
height: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -45,7 +45,7 @@
|
||||
:class="{ active: activeSubNavItem === 'exam-library' }" @click="setActiveSubNavItem('exam-library')">
|
||||
<span>试卷管理</span>
|
||||
</router-link>
|
||||
<router-link to="/teacher/exam-management/marking-center" class="submenu-item"
|
||||
<router-link to="/teacher/exam-management/marking-center/list" class="submenu-item"
|
||||
:class="{ active: activeSubNavItem === 'marking-center' }" @click="setActiveSubNavItem('marking-center')">
|
||||
<span>阅卷中心</span>
|
||||
</router-link>
|
||||
@ -430,10 +430,9 @@ const updateActiveNavItem = () => {
|
||||
activeNavItem.value = 4; // 考试管理
|
||||
examMenuExpanded.value = true;
|
||||
|
||||
// 获取路由的最后一层路径(不包含/)
|
||||
const pathSegments = path.split('/');
|
||||
const lastSegment = pathSegments[pathSegments.length - 1];
|
||||
activeSubNavItem.value = lastSegment || '';
|
||||
const arr = ['question-management', 'exam-library', 'marking-center'];
|
||||
const found = arr.find(item => path.includes(item));
|
||||
activeSubNavItem.value = found || '';
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -17,6 +17,14 @@
|
||||
</template>
|
||||
</n-button>
|
||||
<h1>添加试卷</h1>
|
||||
<span v-if="isAutoSaved" class="auto-save-indicator">
|
||||
<n-icon size="14" color="#52c41a">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</n-icon>
|
||||
已自动保存
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<n-card size="small">
|
||||
@ -55,7 +63,7 @@
|
||||
<template v-for="(item, index) in examForm.questions" :key="index">
|
||||
<n-card size="small">
|
||||
<div class="group">
|
||||
<n-row>第{{ index + 1 }}题:</n-row>
|
||||
<n-row>第{{ index + 1 }}大题:</n-row>
|
||||
<div class="questionRow">
|
||||
<n-input class="input-title" v-model:value="item.title" placeholder="请输入题目名称" />
|
||||
<n-button strong quaternary @click="deleteBigQuestion(index)">
|
||||
@ -119,7 +127,7 @@
|
||||
class="sub-question-item">
|
||||
<!-- 小题标题栏 -->
|
||||
<div class="sub-question-header">
|
||||
<span class="sub-question-number">*{{ index + 1 }}.{{ subIndex + 1 }} {{
|
||||
<span class="sub-question-number">*{{ subIndex + 1 }} {{
|
||||
getQuestionTypeName(subQuestion.type) }}</span>
|
||||
</div>
|
||||
|
||||
@ -422,7 +430,7 @@
|
||||
</template>
|
||||
试卷设置
|
||||
</n-button>
|
||||
<n-button type="primary" ghost size="large">
|
||||
<n-button type="primary" ghost size="large" @click="previewExam">
|
||||
预览试卷
|
||||
</n-button>
|
||||
</n-space>
|
||||
@ -467,7 +475,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, reactive, ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { createDiscreteApi } from 'naive-ui';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { AddCircle, SettingsOutline, TrashOutline, ChevronUpSharp, BookSharp, ArrowBackOutline } from '@vicons/ionicons5'
|
||||
@ -483,7 +491,32 @@ const router = useRouter()
|
||||
|
||||
// 返回上一个页面
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
// 检查是否有未保存的内容
|
||||
const hasContent = examForm.title.trim() ||
|
||||
examForm.questions.some(q => q.subQuestions.length > 0) ||
|
||||
examForm.description.trim()
|
||||
|
||||
if (hasContent) {
|
||||
dialog.warning({
|
||||
title: '确认返回',
|
||||
content: '返回将清空当前编辑的试卷内容,确定要返回吗?',
|
||||
positiveText: '确定返回',
|
||||
negativeText: '继续编辑',
|
||||
onPositiveClick: () => {
|
||||
// 清空 sessionStorage 中的试卷数据
|
||||
sessionStorage.removeItem('examPreviewData')
|
||||
// 返回上一个页面
|
||||
router.back()
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
// 用户选择继续编辑,不做任何操作
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 没有编辑内容,直接返回
|
||||
sessionStorage.removeItem('examPreviewData')
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
|
||||
// 题型枚举
|
||||
@ -642,6 +675,7 @@ const addQuestion = (index: number) => {
|
||||
{ id: '4', content: '选项D', isCorrect: false }
|
||||
];
|
||||
newSubQuestion.correctAnswer = '';
|
||||
newSubQuestion.explanation = '';
|
||||
break;
|
||||
|
||||
case QuestionType.MULTIPLE_CHOICE:
|
||||
@ -652,24 +686,30 @@ const addQuestion = (index: number) => {
|
||||
{ id: '4', content: '选项D', isCorrect: false }
|
||||
];
|
||||
newSubQuestion.correctAnswer = [];
|
||||
newSubQuestion.explanation = '';
|
||||
break;
|
||||
|
||||
case QuestionType.TRUE_FALSE:
|
||||
newSubQuestion.trueFalseAnswer = undefined; // 初始状态不选择任何选项
|
||||
newSubQuestion.explanation = '';
|
||||
break;
|
||||
|
||||
case QuestionType.FILL_BLANK:
|
||||
newSubQuestion.fillBlanks = [
|
||||
{ id: '1', content: '', position: 1 }
|
||||
];
|
||||
newSubQuestion.explanation = '';
|
||||
break;
|
||||
|
||||
case QuestionType.SHORT_ANSWER:
|
||||
newSubQuestion.textAnswer = '';
|
||||
newSubQuestion.explanation = '';
|
||||
break;
|
||||
|
||||
case QuestionType.COMPOSITE:
|
||||
newSubQuestion.subQuestions = [];
|
||||
newSubQuestion.explanation = '';
|
||||
break;
|
||||
break;
|
||||
}
|
||||
|
||||
@ -1135,6 +1175,46 @@ const saveExam = () => {
|
||||
});
|
||||
}
|
||||
|
||||
// 预览试卷
|
||||
const previewExam = () => {
|
||||
// 先保存当前编辑状态到 sessionStorage,无论验证是否通过
|
||||
const examData = {
|
||||
...examForm,
|
||||
previewTime: new Date().toISOString()
|
||||
};
|
||||
sessionStorage.setItem('examPreviewData', JSON.stringify(examData));
|
||||
|
||||
// 验证数据
|
||||
if (!examForm.title.trim()) {
|
||||
dialog.warning({
|
||||
title: '输入提示',
|
||||
content: '请输入试卷标题',
|
||||
positiveText: '确定'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let hasQuestions = false;
|
||||
for (const bigQuestion of examForm.questions) {
|
||||
if (bigQuestion.subQuestions.length > 0) {
|
||||
hasQuestions = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasQuestions) {
|
||||
dialog.warning({
|
||||
title: '输入提示',
|
||||
content: '请至少添加一道题目再进行预览',
|
||||
positiveText: '确定'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 跳转到预览页面
|
||||
router.push('/teacher/exam-management/preview');
|
||||
}
|
||||
|
||||
// 复合题相关方法
|
||||
const addCompositeSubQuestion = (bigQuestionIndex: number, subQuestionIndex: number) => {
|
||||
const subQuestion = examForm.questions[bigQuestionIndex].subQuestions[subQuestionIndex];
|
||||
@ -1156,6 +1236,7 @@ const addCompositeSubQuestion = (bigQuestionIndex: number, subQuestionIndex: num
|
||||
{ id: '4', content: '选项D', isCorrect: false }
|
||||
],
|
||||
correctAnswer: '',
|
||||
explanation: '这是复合题子题的答案解析示例。',
|
||||
createTime: new Date().toISOString()
|
||||
};
|
||||
|
||||
@ -1357,6 +1438,77 @@ const previewSubQuestion = (bigQuestionIndex: number, subQuestionIndex: number)
|
||||
console.log('预览题目:', subQuestion);
|
||||
// 这里可以实现题目预览功能,比如打开一个模态框显示题目
|
||||
}
|
||||
|
||||
// 恢复试卷数据
|
||||
const restoreExamData = () => {
|
||||
const storedData = sessionStorage.getItem('examPreviewData');
|
||||
if (storedData) {
|
||||
try {
|
||||
const parsedData = JSON.parse(storedData);
|
||||
|
||||
// 恢复试卷基本信息
|
||||
examForm.title = parsedData.title || '';
|
||||
examForm.type = parsedData.type || 1;
|
||||
examForm.description = parsedData.description || '';
|
||||
examForm.totalScore = parsedData.totalScore || 0;
|
||||
examForm.duration = parsedData.duration || 60;
|
||||
examForm.passScore = parsedData.passScore || 60;
|
||||
examForm.instructions = parsedData.instructions || '';
|
||||
examForm.useAIGrading = parsedData.useAIGrading || false;
|
||||
|
||||
// 恢复试卷题目
|
||||
if (parsedData.questions && Array.isArray(parsedData.questions)) {
|
||||
examForm.questions = parsedData.questions;
|
||||
}
|
||||
|
||||
console.log('试卷数据已恢复:', examForm);
|
||||
} catch (error) {
|
||||
console.error('恢复试卷数据失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页面挂载时尝试恢复数据
|
||||
onMounted(() => {
|
||||
restoreExamData();
|
||||
});
|
||||
|
||||
// 页面卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听试卷数据变化,自动保存到 sessionStorage
|
||||
watch(
|
||||
() => examForm,
|
||||
(newValue) => {
|
||||
// 防抖处理,避免频繁保存
|
||||
isAutoSaved.value = false; // 数据变化时标记为未保存
|
||||
clearTimeout(autoSaveTimer);
|
||||
autoSaveTimer = setTimeout(() => {
|
||||
const examData = {
|
||||
...newValue,
|
||||
lastModified: new Date().toISOString()
|
||||
};
|
||||
sessionStorage.setItem('examPreviewData', JSON.stringify(examData));
|
||||
isAutoSaved.value = true; // 保存完成后标记为已保存
|
||||
|
||||
// 3秒后隐藏保存提示
|
||||
setTimeout(() => {
|
||||
isAutoSaved.value = false;
|
||||
}, 3000);
|
||||
}, 1000); // 1秒后保存
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 自动保存定时器
|
||||
let autoSaveTimer: NodeJS.Timeout;
|
||||
|
||||
// 自动保存状态指示
|
||||
const isAutoSaved = ref(false);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -1386,6 +1538,29 @@ const previewSubQuestion = (bigQuestionIndex: number, subQuestionIndex: number)
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.auto-save-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #52c41a;
|
||||
padding: 2px 8px;
|
||||
background: rgba(82, 196, 26, 0.1);
|
||||
border-radius: 12px;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.back-button {
|
||||
color: #666;
|
||||
transition: all 0.3s ease;
|
||||
|
@ -168,11 +168,12 @@ const paginationConfig = computed(() => ({
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
showSizePicker: true,
|
||||
showQuickJumper: true,
|
||||
prefix: (info: { startIndex: number; endIndex: number; page: number; pageSize: number; pageCount: number; itemCount?: number }) => {
|
||||
goto: ()=>{
|
||||
return '跳转'
|
||||
},
|
||||
prefix: (info: { itemCount?: number }) => {
|
||||
const itemCount = info.itemCount || 0;
|
||||
const start = (currentPage.value - 1) * pageSize.value + 1;
|
||||
const end = Math.min(currentPage.value * pageSize.value, itemCount);
|
||||
return `显示 ${start}-${end} 条,共 ${itemCount} 条`;
|
||||
return `共 ${itemCount} 条`;
|
||||
},
|
||||
onUpdatePage: (page: number) => {
|
||||
currentPage.value = page;
|
||||
|
704
src/views/teacher/ExamPages/ExamPreview.vue
Normal file
704
src/views/teacher/ExamPages/ExamPreview.vue
Normal file
@ -0,0 +1,704 @@
|
||||
<template>
|
||||
<div class="exam-preview-container">
|
||||
<!-- 左侧时间和题目导航 -->
|
||||
<div class="exam-sidebar">
|
||||
<div class="exam-title">
|
||||
<n-button quaternary circle size="large" @click="goBack" class="back-button">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowBackOutline />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
<h1 class="exam-title">{{ examData?.examName || '试卷预览' }}</h1>
|
||||
</div>
|
||||
<n-divider />
|
||||
<div class="time-section">
|
||||
<div class="time-label">剩余时间</div>
|
||||
<div class="time-display">{{ formatTime(examData?.duration * 60 || 3600) }}</div>
|
||||
<div class="time-units">
|
||||
<span>时</span>
|
||||
<span>分</span>
|
||||
<span>秒</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="question-nav">
|
||||
<div class="nav-title">答题卡</div>
|
||||
<div class="nav-grid">
|
||||
<div v-for="(_, index) in allQuestions" :key="index" class="nav-item"
|
||||
:class="{ 'answered': false }">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧主要内容区域 -->
|
||||
<div class="exam-main">
|
||||
<div class="header-content">
|
||||
<div class="exam-info">
|
||||
<div class="exam-meta">
|
||||
<span>总分:{{ examData?.totalScore || 0 }}分</span>
|
||||
<span>题目数量:{{ totalQuestions }}题</span>
|
||||
<span>考试时长:{{ examData?.duration || 0 }}分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exam-content" v-if="examData">
|
||||
<!-- 大题循环 -->
|
||||
<div v-for="(bigQuestion, bigIndex) in examData.questions" :key="bigQuestion.id" class="big-question">
|
||||
<div class="big-question-header">
|
||||
<span class="question-desc">{{ bigQuestion.title }}</span>
|
||||
<span class="question-count">共{{ bigQuestion.subQuestions.length }}题</span>
|
||||
</div>
|
||||
|
||||
<!-- 小题循环 -->
|
||||
<div v-for="(subQuestion, subIndex) in bigQuestion.subQuestions" :key="subQuestion.id"
|
||||
class="sub-question">
|
||||
<div class="question-header">
|
||||
<span class="question-number">第{{ getQuestionNumber(bigIndex, subIndex) }}题 {{
|
||||
getQuestionTypeName(subQuestion.type) }}</span>
|
||||
<span class="question-score">{{ subQuestion.score }}分</span>
|
||||
</div>
|
||||
|
||||
<div class="question-title">{{ subQuestion.title }}</div>
|
||||
|
||||
<!-- 单选题 -->
|
||||
<div v-if="subQuestion.type === 'single_choice'" class="question-content">
|
||||
<div class="options">
|
||||
<div v-for="(option, optIndex) in subQuestion.options" :key="option.id"
|
||||
class="option-item" :class="{ 'correct-option': option.isCorrect }">
|
||||
<span class="option-label">{{ String.fromCharCode(65 + optIndex) }}</span>
|
||||
<span class="option-content">{{ option.content }}</span>
|
||||
<span v-if="option.isCorrect" class="correct-mark">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 答案解析 -->
|
||||
<div class="answer-analysis">
|
||||
<div class="correct-answer">
|
||||
<span class="label">正确答案:</span>
|
||||
<span class="answer">{{ getCorrectAnswerText(subQuestion) }}</span>
|
||||
</div>
|
||||
<div v-if="subQuestion.explanation" class="explanation">
|
||||
<span class="label">答案解析:</span>
|
||||
<p>{{ subQuestion.explanation }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 多选题 -->
|
||||
<div v-if="subQuestion.type === 'multiple_choice'" class="question-content">
|
||||
<div class="options">
|
||||
<div v-for="(option, optIndex) in subQuestion.options" :key="option.id"
|
||||
class="option-item" :class="{ 'correct-option': option.isCorrect }">
|
||||
<span class="option-label">{{ String.fromCharCode(65 + optIndex) }}</span>
|
||||
<span class="option-content">{{ option.content }}</span>
|
||||
<span v-if="option.isCorrect" class="correct-mark">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 答案解析 -->
|
||||
<div class="answer-analysis">
|
||||
<div class="correct-answer">
|
||||
<span class="label">正确答案:</span>
|
||||
<span class="answer">{{ getCorrectAnswerText(subQuestion) }}</span>
|
||||
</div>
|
||||
<div v-if="subQuestion.explanation" class="explanation">
|
||||
<span class="label">答案解析:</span>
|
||||
<p>{{ subQuestion.explanation }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 判断题 -->
|
||||
<div v-if="subQuestion.type === 'true_false'" class="question-content">
|
||||
<div class="true-false-options">
|
||||
<div class="option-item"
|
||||
:class="{ 'correct-option': subQuestion.trueFalseAnswer === true }">
|
||||
<span class="option-label">A</span>
|
||||
<span class="option-content">正确</span>
|
||||
<span v-if="subQuestion.trueFalseAnswer === true" class="correct-mark">✓</span>
|
||||
</div>
|
||||
<div class="option-item"
|
||||
:class="{ 'correct-option': subQuestion.trueFalseAnswer === false }">
|
||||
<span class="option-label">B</span>
|
||||
<span class="option-content">错误</span>
|
||||
<span v-if="subQuestion.trueFalseAnswer === false" class="correct-mark">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 答案解析 -->
|
||||
<div class="answer-analysis">
|
||||
<div class="correct-answer">
|
||||
<span class="label">正确答案:</span>
|
||||
<span class="answer">{{ subQuestion.trueFalseAnswer ? 'A 正确' : 'B 错误' }}</span>
|
||||
</div>
|
||||
<div v-if="subQuestion.explanation" class="explanation">
|
||||
<span class="label">答案解析:</span>
|
||||
<p>{{ subQuestion.explanation }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 填空题 -->
|
||||
<div v-if="subQuestion.type === 'fill_blank'" class="question-content">
|
||||
<div class="fill-blanks">
|
||||
<div v-for="blank in subQuestion.fillBlanks" :key="blank.id" class="blank-item">
|
||||
<span>第{{ blank.position }}空:</span>
|
||||
<div class="blank-answer">{{ blank.content || '未设置' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 答案解析 -->
|
||||
<div class="answer-analysis">
|
||||
<div class="correct-answer">
|
||||
<span class="label">正确答案:</span>
|
||||
<div class="answer">
|
||||
<div v-for="blank in subQuestion.fillBlanks" :key="blank.id">
|
||||
第{{ blank.position }}空:{{ blank.content || '未设置' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="subQuestion.explanation" class="explanation">
|
||||
<span class="label">答案解析:</span>
|
||||
<p>{{ subQuestion.explanation }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 简答题 -->
|
||||
<div v-if="subQuestion.type === 'short_answer'" class="question-content">
|
||||
<div class="answer-area">
|
||||
<div class="answer-text">{{ subQuestion.textAnswer || '未设置参考答案' }}</div>
|
||||
</div>
|
||||
<!-- 答案解析 -->
|
||||
<div class="answer-analysis">
|
||||
<div class="correct-answer">
|
||||
<span class="label">参考答案:</span>
|
||||
<p class="answer">{{ subQuestion.textAnswer || '未设置参考答案' }}</p>
|
||||
</div>
|
||||
<div v-if="subQuestion.explanation" class="explanation">
|
||||
<span class="label">答案解析:</span>
|
||||
<p>{{ subQuestion.explanation }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-data">
|
||||
<n-empty description="没有找到试卷数据">
|
||||
<template #extra>
|
||||
<n-button @click="goBack">返回</n-button>
|
||||
</template>
|
||||
</n-empty>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NButton, NEmpty } from 'naive-ui'
|
||||
import { ArrowBackOutline } from '@vicons/ionicons5'
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
const examData = ref<any>(null)
|
||||
|
||||
// 计算所有题目数量和列表
|
||||
const allQuestions = computed(() => {
|
||||
if (!examData.value) return []
|
||||
const questions: any[] = []
|
||||
examData.value.questions.forEach((bigQ: any) => {
|
||||
bigQ.subQuestions.forEach((subQ: any) => {
|
||||
if (subQ.type === 'composite') {
|
||||
subQ.subQuestions?.forEach((compSubQ: any) => {
|
||||
questions.push(compSubQ)
|
||||
})
|
||||
} else {
|
||||
questions.push(subQ)
|
||||
}
|
||||
})
|
||||
})
|
||||
return questions
|
||||
})
|
||||
|
||||
const totalQuestions = computed(() => {
|
||||
return allQuestions.value.length
|
||||
})
|
||||
|
||||
// 时间格式化函数
|
||||
const formatTime = (seconds: number) => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 获取题型名称
|
||||
const getQuestionTypeName = (type: string) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
'single_choice': '单选题',
|
||||
'multiple_choice': '多选题',
|
||||
'true_false': '判断题',
|
||||
'fill_blank': '填空题',
|
||||
'short_answer': '简答题',
|
||||
'composite': '复合题'
|
||||
}
|
||||
return typeMap[type] || '未知题型'
|
||||
}
|
||||
|
||||
// 获取题目编号
|
||||
const getQuestionNumber = (bigIndex: number, subIndex: number) => {
|
||||
let number = 1
|
||||
for (let i = 0; i < bigIndex; i++) {
|
||||
number += examData.value.questions[i].subQuestions.length
|
||||
}
|
||||
return number + subIndex
|
||||
}
|
||||
|
||||
// 获取正确答案文本
|
||||
const getCorrectAnswerText = (question: any) => {
|
||||
if (question.type === 'single_choice') {
|
||||
const correctOption = question.options?.find((opt: any) => opt.isCorrect)
|
||||
if (correctOption) {
|
||||
const index = question.options.findIndex((opt: any) => opt.isCorrect)
|
||||
return `${String.fromCharCode(65 + index)}`
|
||||
}
|
||||
return '未设置正确答案'
|
||||
} else if (question.type === 'multiple_choice') {
|
||||
const correctOptions = question.options?.filter((opt: any) => opt.isCorrect)
|
||||
if (correctOptions?.length > 0) {
|
||||
return correctOptions.map((opt: any) => {
|
||||
const optIndex = question.options.findIndex((o: any) => o.id === opt.id)
|
||||
return `${String.fromCharCode(65 + optIndex)}`
|
||||
}).join('、')
|
||||
}
|
||||
return '未设置正确答案'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 从 sessionStorage 获取试卷数据
|
||||
const savedData = sessionStorage.getItem('examPreviewData')
|
||||
if (savedData) {
|
||||
try {
|
||||
examData.value = JSON.parse(savedData)
|
||||
} catch (error) {
|
||||
console.error('解析试卷数据失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.exam-preview-container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* 左侧边栏样式 */
|
||||
.exam-sidebar {
|
||||
width: 280px;
|
||||
background: white;
|
||||
padding: 10px;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.exam-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.time-section {
|
||||
background: #f0f9ff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
font-family: 'Courier New', monospace;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.time-units {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.question-nav {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.nav-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-item.answered {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.nav-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.legend-color.answered {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
/* 右侧主要内容区域 */
|
||||
.exam-main {
|
||||
flex: 1;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.exam-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.exam-meta {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
justify-content: flex-end
|
||||
}
|
||||
|
||||
.preview-note {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.exam-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.big-question {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.big-question-header {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.question-desc {
|
||||
color: #1890ff;
|
||||
font-weight: bold;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.question-count {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sub-question {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.sub-question:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.question-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.question-number {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.question-score {
|
||||
background: #f0f9ff;
|
||||
color: #1890ff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.question-title {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.question-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background-color: #F5F8FB;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.option-item.correct-option {
|
||||
background: #f6ffed;
|
||||
border-color: #52c41a;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.correct-option .option-label {
|
||||
background: #52c41a;
|
||||
color: white;
|
||||
border-color: #52c41a;
|
||||
}
|
||||
|
||||
.option-content {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.correct-mark {
|
||||
color: #52c41a;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.true-false-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.fill-blanks {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.blank-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.blank-answer {
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #1890ff;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
min-width: 100px;
|
||||
color: #1890ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.answer-area {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.answer-text {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e8e8e8;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
min-height: 80px;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 答案解析样式 */
|
||||
.answer-analysis {
|
||||
background: #fafbfc;
|
||||
border: 1px solid #e1e4e8;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.correct-answer {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.correct-answer .label {
|
||||
font-weight: bold;
|
||||
color: #52c41a;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.correct-answer .answer {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.explanation .label {
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.explanation p {
|
||||
margin: 4px 0 0 0;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 60px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.exam-sidebar {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.exam-main {
|
||||
margin-left: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.exam-preview-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.exam-sidebar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.exam-main {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.nav-grid {
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
1070
src/views/teacher/ExamPages/GradingPage.vue
Normal file
1070
src/views/teacher/ExamPages/GradingPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -125,7 +125,6 @@
|
||||
:page-size="pageSize"
|
||||
show-size-picker
|
||||
:page-sizes="[10, 20, 50]"
|
||||
show-quick-jumper
|
||||
:item-count="totalItems"
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
@ -136,6 +135,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { PersonOutline, CalendarOutline } from '@vicons/ionicons5'
|
||||
|
||||
// 接口定义
|
||||
@ -151,6 +151,9 @@ interface ExamItem {
|
||||
gradedCount: number
|
||||
}
|
||||
|
||||
// 路由
|
||||
const router = useRouter()
|
||||
|
||||
// 响应式数据
|
||||
const activeTab = ref('all')
|
||||
const examFilter = ref('')
|
||||
@ -275,7 +278,11 @@ const handleDelete = (exam: ExamItem) => {
|
||||
}
|
||||
|
||||
const handleAction = (exam: ExamItem) => {
|
||||
console.log('执行操作:', exam)
|
||||
// 跳转到学生列表页面,传递考试ID
|
||||
router.push({
|
||||
name: 'StudentList',
|
||||
params: { paperId: exam.id }
|
||||
})
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
|
@ -241,11 +241,12 @@ const paginationConfig = computed(() => ({
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
showSizePicker: true,
|
||||
showQuickJumper: true,
|
||||
prefix: (info: { startIndex: number; endIndex: number; page: number; pageSize: number; pageCount: number; itemCount?: number }) => {
|
||||
goto: ()=>{
|
||||
return '跳转'
|
||||
},
|
||||
prefix: (info: { itemCount?: number }) => {
|
||||
const itemCount = info.itemCount || 0;
|
||||
const start = (pagination.page - 1) * pagination.pageSize + 1;
|
||||
const end = Math.min(pagination.page * pagination.pageSize, itemCount);
|
||||
return `显示 ${start}-${end} 条,共 ${itemCount} 条`;
|
||||
return `共 ${itemCount} 条`;
|
||||
},
|
||||
onUpdatePage: (page: number) => {
|
||||
pagination.page = page;
|
||||
|
765
src/views/teacher/ExamPages/StudentList.vue
Normal file
765
src/views/teacher/ExamPages/StudentList.vue
Normal file
@ -0,0 +1,765 @@
|
||||
<template>
|
||||
<div class="student-list-container">
|
||||
<!-- 页面头部 -->
|
||||
<div class="header-section">
|
||||
<div class="header-left">
|
||||
<n-button quaternary circle size="large" @click="goBack" class="back-button">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowBackOutline />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
<div class="header-info">
|
||||
<h1 class="page-title">{{ examInfo.title || '试卷名' }}</h1>
|
||||
<div class="exam-meta">
|
||||
<n-tag :type="getStatusType(examInfo.status)" :bordered="false" size="small">
|
||||
{{ getStatusText(examInfo.status) }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions-group">
|
||||
<n-button type="primary" @click="publishExam">
|
||||
发布补考
|
||||
</n-button>
|
||||
<n-button ghost @click="importStudents">
|
||||
导入
|
||||
</n-button>
|
||||
<n-button ghost @click="exportResults">
|
||||
导出
|
||||
</n-button>
|
||||
<n-select v-model:value="classFilter" :options="classFilterOptions" placeholder="班级名称"
|
||||
style="width: 120px;" clearable />
|
||||
<n-input v-model:value="searchKeyword" placeholder="请输入学生姓名"
|
||||
style="width: 200px;" clearable>
|
||||
</n-input>
|
||||
<n-button type="primary" @click="handleSearch">搜索</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab切换 -->
|
||||
<div class="tab-container">
|
||||
<n-tabs v-model:value="activeTab" type="line" animated @update:value="handleTabChange">
|
||||
<n-tab-pane name="all" tab="全部">
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="submitted" tab="已交">
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="not-submitted" tab="未交">
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 学生表格 -->
|
||||
<n-data-table :columns="columns" :data="filteredStudents" :pagination="paginationReactive"
|
||||
:loading="loading" size="medium" striped :scroll-x="1400" :single-line="false"
|
||||
class="student-table" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, h } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ArrowBackOutline } from '@vicons/ionicons5'
|
||||
import { NButton, NTag, NIcon, useMessage } from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
|
||||
// 路由
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
|
||||
// 接口定义
|
||||
interface StudentExamInfo {
|
||||
id: string
|
||||
studentId: string
|
||||
studentName: string
|
||||
className: string
|
||||
startTime: string | null
|
||||
submitTime: string | null
|
||||
examDuration: string | null
|
||||
score: number | null
|
||||
status: 'not-started' | 'in-progress' | 'submitted' | 'graded'
|
||||
gradingStatus: 'pending' | 'graded'
|
||||
totalQuestions: number
|
||||
answeredQuestions: number
|
||||
}
|
||||
|
||||
interface ExamInfo {
|
||||
id: string
|
||||
title: string
|
||||
duration: string
|
||||
status: 'not-started' | 'in-progress' | 'completed'
|
||||
totalStudents: number
|
||||
submittedCount: number
|
||||
gradedCount: number
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const statusFilter = ref('')
|
||||
const gradeFilter = ref('')
|
||||
|
||||
// Tab相关
|
||||
const activeTab = ref('all')
|
||||
|
||||
// 新增筛选相关
|
||||
const classFilter = ref('')
|
||||
|
||||
// 考试信息
|
||||
const examInfo = ref<ExamInfo>({
|
||||
id: '',
|
||||
title: '',
|
||||
duration: '',
|
||||
status: 'in-progress',
|
||||
totalStudents: 0,
|
||||
submittedCount: 0,
|
||||
gradedCount: 0
|
||||
})
|
||||
|
||||
// 学生列表
|
||||
const studentList = ref<StudentExamInfo[]>([])
|
||||
|
||||
// 新增班级筛选选项
|
||||
const classFilterOptions = [
|
||||
{ label: '全部班级', value: '' },
|
||||
{ label: '计算机1班', value: '计算机1班' },
|
||||
{ label: '计算机2班', value: '计算机2班' },
|
||||
{ label: '软件工程1班', value: '软件工程1班' }
|
||||
]
|
||||
|
||||
// 分页配置
|
||||
const paginationReactive = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 50],
|
||||
onChange: (page: number) => {
|
||||
paginationReactive.page = page
|
||||
},
|
||||
onUpdatePageSize: (pageSize: number) => {
|
||||
paginationReactive.pageSize = pageSize
|
||||
paginationReactive.page = 1
|
||||
}
|
||||
})
|
||||
|
||||
// 表格列定义
|
||||
const columns: DataTableColumns<StudentExamInfo> = [
|
||||
{
|
||||
title: '序号',
|
||||
key: 'index',
|
||||
width: 80,
|
||||
render: (_, index) => {
|
||||
return (paginationReactive.page - 1) * paginationReactive.pageSize + index + 1
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '姓名',
|
||||
key: 'studentName',
|
||||
width: 120,
|
||||
ellipsis: {
|
||||
tooltip: true
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '学号',
|
||||
key: 'studentId',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '班级',
|
||||
key: 'className',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '开始时间',
|
||||
key: 'startTime',
|
||||
width: 180,
|
||||
render: (row) => {
|
||||
return row.startTime || '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '提交时间',
|
||||
key: 'submitTime',
|
||||
width: 180,
|
||||
render: (row) => {
|
||||
return row.submitTime || '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '考试用时',
|
||||
key: 'examDuration',
|
||||
width: 120,
|
||||
render: (row) => {
|
||||
return row.examDuration || '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '正确率',
|
||||
key: 'accuracy',
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
if (row.score === null) return '-'
|
||||
return `${row.score}%`
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
return h(NTag, {
|
||||
type: getStudentStatusType(row.status),
|
||||
bordered: false,
|
||||
size: 'small'
|
||||
}, {
|
||||
default: () => getStudentStatusText(row.status)
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '批阅时间',
|
||||
key: 'gradingTime',
|
||||
width: 180,
|
||||
render: (row) => {
|
||||
return row.gradingStatus === 'graded' ? '2025.07.25 09:20' : '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '得分',
|
||||
key: 'score',
|
||||
width: 80,
|
||||
render: (row) => {
|
||||
return row.score !== null ? `${row.score}` : '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
render: (row) => {
|
||||
return h('div', { style: 'display: flex; gap: 8px;' }, [
|
||||
h(NButton, {
|
||||
type: 'primary',
|
||||
size: 'small',
|
||||
onClick: () => handleViewAnswer(row)
|
||||
}, { default: () => '查看' }),
|
||||
h(NButton, {
|
||||
type: 'primary',
|
||||
size: 'small',
|
||||
disabled: row.status !== 'submitted',
|
||||
onClick: () => handleGrade(row)
|
||||
}, { default: () => '批阅' })
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 计算属性
|
||||
const filteredStudents = computed(() => {
|
||||
let filtered = studentList.value
|
||||
|
||||
// Tab 过滤
|
||||
if (activeTab.value === 'submitted') {
|
||||
filtered = filtered.filter(student => student.status === 'submitted' || student.status === 'graded')
|
||||
} else if (activeTab.value === 'not-submitted') {
|
||||
filtered = filtered.filter(student => student.status === 'not-started' || student.status === 'in-progress')
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
if (searchKeyword.value) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
filtered = filtered.filter(student =>
|
||||
student.studentName.toLowerCase().includes(keyword) ||
|
||||
student.studentId.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
// 班级过滤
|
||||
if (classFilter.value) {
|
||||
filtered = filtered.filter(student => student.className === classFilter.value)
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if (statusFilter.value) {
|
||||
filtered = filtered.filter(student => student.status === statusFilter.value)
|
||||
}
|
||||
|
||||
// 批阅状态过滤
|
||||
if (gradeFilter.value) {
|
||||
filtered = filtered.filter(student => student.gradingStatus === gradeFilter.value)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
// 方法
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'not-started':
|
||||
return 'default'
|
||||
case 'in-progress':
|
||||
return 'warning'
|
||||
case 'completed':
|
||||
return 'success'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'not-started':
|
||||
return '未开始'
|
||||
case 'in-progress':
|
||||
return '进行中'
|
||||
case 'completed':
|
||||
return '已结束'
|
||||
default:
|
||||
return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
const getStudentStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'not-started':
|
||||
return 'primary'
|
||||
case 'in-progress':
|
||||
return 'warning'
|
||||
case 'submitted':
|
||||
return 'info'
|
||||
case 'graded':
|
||||
return 'success'
|
||||
default:
|
||||
return 'primary'
|
||||
}
|
||||
}
|
||||
|
||||
const getStudentStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'not-started':
|
||||
return '待考试'
|
||||
case 'in-progress':
|
||||
return '考试中'
|
||||
case 'submitted':
|
||||
return '已提交'
|
||||
case 'graded':
|
||||
return '已批阅'
|
||||
default:
|
||||
return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewAnswer = (student: StudentExamInfo) => {
|
||||
// 跳转到答题详情页面
|
||||
router.push(`/teacher/exam-management/marking-center/answer-detail/${examInfo.value.id}/${student.id}`)
|
||||
}
|
||||
|
||||
const handleGrade = (student: StudentExamInfo) => {
|
||||
// 跳转到批阅页面
|
||||
router.push(`/teacher/exam-management/marking-center/grading/${examInfo.value.id}/${student.id}`)
|
||||
}
|
||||
|
||||
const exportResults = () => {
|
||||
message.info('导出功能开发中...')
|
||||
}
|
||||
|
||||
// 新增方法
|
||||
const handleTabChange = (value: string) => {
|
||||
activeTab.value = value
|
||||
}
|
||||
|
||||
const publishExam = () => {
|
||||
message.info('发布补考功能开发中...')
|
||||
}
|
||||
|
||||
const importStudents = () => {
|
||||
message.info('导入学生功能开发中...')
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
// 搜索已经通过 computed 属性自动实现
|
||||
message.info('搜索已应用')
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
const loadExamInfo = async (examId: string) => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// 模拟数据
|
||||
examInfo.value = {
|
||||
id: examId,
|
||||
title: '试卷名称试卷名称试卷名称试卷名称试卷名称',
|
||||
duration: '考试时间:2025.07.25 09:00 - 2025.07.25 09:20',
|
||||
status: 'in-progress',
|
||||
totalStudents: 12,
|
||||
submittedCount: 8,
|
||||
gradedCount: 5
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载考试信息失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadStudentList = async (_examId: string) => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// 模拟学生数据
|
||||
studentList.value = [
|
||||
{
|
||||
id: '1',
|
||||
studentId: '1826685554',
|
||||
studentName: '张张',
|
||||
className: '待考试',
|
||||
startTime: '2025.07.25 09:20',
|
||||
submitTime: '2025.07.25 09:20',
|
||||
examDuration: '9分钟29秒',
|
||||
score: 70,
|
||||
status: 'graded',
|
||||
gradingStatus: 'graded',
|
||||
totalQuestions: 10,
|
||||
answeredQuestions: 8
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
studentId: '1826685554',
|
||||
studentName: '张张',
|
||||
className: '待考试',
|
||||
startTime: null,
|
||||
submitTime: null,
|
||||
examDuration: null,
|
||||
score: null,
|
||||
status: 'submitted',
|
||||
gradingStatus: 'pending',
|
||||
totalQuestions: 10,
|
||||
answeredQuestions: 0
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
studentId: '1826685554',
|
||||
studentName: '张张',
|
||||
className: '已提交',
|
||||
startTime: '2025.07.25 09:20',
|
||||
submitTime: '2025.07.25 09:20',
|
||||
examDuration: '9分钟29秒',
|
||||
score: 60,
|
||||
status: 'graded',
|
||||
gradingStatus: 'graded',
|
||||
totalQuestions: 10,
|
||||
answeredQuestions: 9
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
studentId: '1826685554',
|
||||
studentName: '张张',
|
||||
className: '待考试',
|
||||
startTime: '2025.07.25 09:20',
|
||||
submitTime: '2025.07.25 09:20',
|
||||
examDuration: '9分钟29秒',
|
||||
score: 70,
|
||||
status: 'graded',
|
||||
gradingStatus: 'graded',
|
||||
totalQuestions: 10,
|
||||
answeredQuestions: 10
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
studentId: '1826685554',
|
||||
studentName: '张张',
|
||||
className: '待考试',
|
||||
startTime: null,
|
||||
submitTime: null,
|
||||
examDuration: null,
|
||||
score: null,
|
||||
status: 'not-started',
|
||||
gradingStatus: 'pending',
|
||||
totalQuestions: 10,
|
||||
answeredQuestions: 0
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
studentId: '1826685554',
|
||||
studentName: '张张',
|
||||
className: '已提交',
|
||||
startTime: '2025.07.25 09:20',
|
||||
submitTime: '2025.07.25 09:20',
|
||||
examDuration: '9分钟29秒',
|
||||
score: 60,
|
||||
status: 'graded',
|
||||
gradingStatus: 'graded',
|
||||
totalQuestions: 10,
|
||||
answeredQuestions: 8
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
studentId: '1826685554',
|
||||
studentName: '张张',
|
||||
className: '待考试',
|
||||
startTime: '2025.07.25 09:20',
|
||||
submitTime: '2025.07.25 09:20',
|
||||
examDuration: '9分钟29秒',
|
||||
score: 70,
|
||||
status: 'graded',
|
||||
gradingStatus: 'graded',
|
||||
totalQuestions: 10,
|
||||
answeredQuestions: 10
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
studentId: '1826685554',
|
||||
studentName: '张张',
|
||||
className: '待考试',
|
||||
startTime: null,
|
||||
submitTime: null,
|
||||
examDuration: null,
|
||||
score: null,
|
||||
status: 'not-started',
|
||||
gradingStatus: 'pending',
|
||||
totalQuestions: 10,
|
||||
answeredQuestions: 0
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
studentId: '1826685554',
|
||||
studentName: '张张',
|
||||
className: '已提交',
|
||||
startTime: '2025.07.25 09:20',
|
||||
submitTime: '2025.07.25 09:20',
|
||||
examDuration: '9分钟29秒',
|
||||
score: 90,
|
||||
status: 'graded',
|
||||
gradingStatus: 'graded',
|
||||
totalQuestions: 10,
|
||||
answeredQuestions: 10
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
studentId: '1826685554',
|
||||
studentName: '张张',
|
||||
className: '待考试',
|
||||
startTime: '2025.07.25 09:20',
|
||||
submitTime: '2025.07.25 09:20',
|
||||
examDuration: '9分钟29秒',
|
||||
score: 70,
|
||||
status: 'graded',
|
||||
gradingStatus: 'graded',
|
||||
totalQuestions: 10,
|
||||
answeredQuestions: 9
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
studentId: '1826685554',
|
||||
studentName: '张张',
|
||||
className: '待考试',
|
||||
startTime: null,
|
||||
submitTime: null,
|
||||
examDuration: null,
|
||||
score: null,
|
||||
status: 'not-started',
|
||||
gradingStatus: 'pending',
|
||||
totalQuestions: 10,
|
||||
answeredQuestions: 0
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
studentId: '1826685554',
|
||||
studentName: '张张',
|
||||
className: '已提交',
|
||||
startTime: '2025.07.25 09:20',
|
||||
submitTime: '2025.07.25 09:20',
|
||||
examDuration: '9分钟29秒',
|
||||
score: 90,
|
||||
status: 'graded',
|
||||
gradingStatus: 'graded',
|
||||
totalQuestions: 10,
|
||||
answeredQuestions: 10
|
||||
}
|
||||
]
|
||||
} catch (error) {
|
||||
message.error('加载学生列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载
|
||||
onMounted(async () => {
|
||||
const examId = route.params.paperId as string
|
||||
if (examId) {
|
||||
await Promise.all([
|
||||
loadExamInfo(examId),
|
||||
loadStudentList(examId)
|
||||
])
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.student-list-container {
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 页面头部 */
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #E6E6E6;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.exam-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.actions-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Tab 切换区域 */
|
||||
.tab-container {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab-container :deep(.n-tabs) {
|
||||
--n-tab-text-color: #666;
|
||||
--n-tab-text-color-active: #1890ff;
|
||||
--n-tab-text-color-hover: #1890ff;
|
||||
--n-bar-color: #1890ff;
|
||||
}
|
||||
|
||||
.tab-container :deep(.n-tabs-tab) {
|
||||
padding: 16px 0;
|
||||
margin-right: 32px;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
.student-table {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.header-section {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.actions-group {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.student-list-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.actions-group {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions-group .n-input {
|
||||
width: 150px !important;
|
||||
}
|
||||
|
||||
.actions-group .n-select {
|
||||
width: 100px !important;
|
||||
}
|
||||
|
||||
.tab-container :deep(.n-tabs-tab) {
|
||||
margin-right: 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header-section {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions-group {
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.actions-group .n-input {
|
||||
width: 120px !important;
|
||||
}
|
||||
|
||||
.actions-group .n-select {
|
||||
width: 80px !important;
|
||||
}
|
||||
|
||||
.tab-container :deep(.n-tabs-tab) {
|
||||
margin-right: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
Loading…
x
Reference in New Issue
Block a user