feat(ccgo): 新增 Vue3 在线做题网站(15 类题型引擎)
迁移旧版题库 EXAM_DATA,实现单选/多选/判断/填空/连线/排序/归类/材料/作图/写作/代码等题型引擎,Pinia 作答状态、计时器、答题卡与交卷结果页,deploy.ps1 一键部署
This commit is contained in:
commit
3d88c71e04
29 files changed
+5043
No files matched your search
+11
@@ -0,0 +1,11 @@
|
||||
# 依赖与构建产物
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# 日志与临时文件
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# 编辑器
|
||||
.vscode/
|
||||
.idea/
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
# CCGO 前端部署脚本:备份原站页面 → 全量编译 → 同步产物到生产目录(TempLate/CCGO)
|
||||
#
|
||||
# 用途:
|
||||
# 1. 备份已被 Vue 页面替换的 TempLate/CCGO 原始文件到 backup/ccgo
|
||||
# 2. 全量重新构建(vue-tsc 类型检查 + vite build)
|
||||
# 3. 同步 dist 产物到 TempLate/CCGO(index.html + CCGO 资产)
|
||||
# 4. 校验产物一致性
|
||||
#
|
||||
# 前置条件:
|
||||
# 1. gvsds/apps/core/views.py 中 SUBDOMAIN_CONFIG 已配置 'ccgo': {'dir': 'CCGO', 'root_is_index': True}
|
||||
# 2. CCGO 路由使用 createWebHistory(支持干净 URL)
|
||||
#
|
||||
# 用法:
|
||||
# PowerShell 中执行: .\deploy.ps1
|
||||
# (若报执行策略限制: Set-ExecutionPolicy -Scope Process Bypass 后再执行)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path # d:\SMANAGE\CCGO
|
||||
$DistDir = Join-Path $ProjectRoot 'dist'
|
||||
$TemplateRoot = Join-Path (Split-Path -Parent $ProjectRoot) 'TempLate' # d:\SMANAGE\TempLate
|
||||
$BackupRoot = Join-Path (Split-Path -Parent $ProjectRoot) 'backup\ccgo' # d:\SMANAGE\backup\ccgo
|
||||
|
||||
Write-Host "`n===== CCGO 部署开始 =====" -ForegroundColor Cyan
|
||||
Write-Host "工程目录: $ProjectRoot"
|
||||
Write-Host "生产目录: $TemplateRoot\CCGO"
|
||||
Write-Host "备份目录: $BackupRoot"
|
||||
Write-Host ""
|
||||
|
||||
# =====================
|
||||
# 0. 前置检查
|
||||
# =====================
|
||||
Write-Host '[0/5] 前置检查...' -ForegroundColor Yellow
|
||||
$ViewsPy = Join-Path (Split-Path -Parent $ProjectRoot) 'gvsds\apps\core\views.py'
|
||||
if (Test-Path $ViewsPy) {
|
||||
$ViewsContent = Get-Content $ViewsPy -Raw
|
||||
if ($ViewsContent -match "'ccgo':\s*\{'dir':\s*'CCGO',\s*'root_is_index':\s*True\s*\}") {
|
||||
Write-Host ' ccgo 子域配置检查通过' -ForegroundColor Green
|
||||
} else {
|
||||
Write-Warning ' ccgo 子域配置可能未匹配,请检查 views.py 的 SUBDOMAIN_CONFIG'
|
||||
}
|
||||
} else {
|
||||
Write-Warning " 未找到 $ViewsPy,跳过子域配置检查"
|
||||
}
|
||||
Write-Host ''
|
||||
|
||||
# =====================
|
||||
# 1. 备份被替换的原始页面
|
||||
# =====================
|
||||
Write-Host '[1/5] 备份被替换的 CCGO 原始页面...' -ForegroundColor Yellow
|
||||
$ProdCcgo = Join-Path $TemplateRoot 'CCGO'
|
||||
if (Test-Path $ProdCcgo) {
|
||||
if (-not (Test-Path $BackupRoot)) {
|
||||
New-Item -ItemType Directory -Path $BackupRoot -Force | Out-Null
|
||||
}
|
||||
# 备份所有旧文件(除构建产物目录外的全部内容)
|
||||
Get-ChildItem -Path $ProdCcgo -Force | ForEach-Object {
|
||||
$dest = Join-Path $BackupRoot $_.Name
|
||||
Copy-Item -Path $_.FullName -Destination $dest -Recurse -Force
|
||||
Write-Host " 已备份: $($_.Name)"
|
||||
}
|
||||
} else {
|
||||
Write-Host " TempLate/CCGO 不存在,跳过备份" -ForegroundColor DarkGray
|
||||
}
|
||||
Write-Host ''
|
||||
|
||||
# =====================
|
||||
# 2. 清理旧构建产物与旧页面
|
||||
# =====================
|
||||
Write-Host '[2/5] 清理旧构建产物...' -ForegroundColor Yellow
|
||||
if (Test-Path $DistDir) {
|
||||
Remove-Item -Recurse -Force $DistDir
|
||||
Write-Host " 已删除: $DistDir"
|
||||
}
|
||||
# 清理 TempLate/CCGO 旧产物目录(构建产物统一收敛到该子目录)
|
||||
if (Test-Path $ProdCcgo) {
|
||||
Get-ChildItem -Path $ProdCcgo -Recurse -Force | Remove-Item -Recurse -Force
|
||||
Write-Host " 已清理旧产物目录: CCGO/"
|
||||
} else {
|
||||
New-Item -ItemType Directory -Path $ProdCcgo -Force | Out-Null
|
||||
Write-Host " 已创建生产目录: CCGO/"
|
||||
}
|
||||
Write-Host ''
|
||||
|
||||
# =====================
|
||||
# 3. 全量构建
|
||||
# =====================
|
||||
Write-Host '[3/5] 执行 npm run build(类型检查 + 全量编译)...' -ForegroundColor Yellow
|
||||
Push-Location $ProjectRoot
|
||||
try {
|
||||
npm run build
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "构建失败,exit code = $LASTEXITCODE"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Write-Host ' 构建成功'
|
||||
Write-Host ''
|
||||
|
||||
# =====================
|
||||
# 4. 同步产物到生产目录
|
||||
# =====================
|
||||
Write-Host '[4/5] 同步产物到 ccgo 生产目录...' -ForegroundColor Yellow
|
||||
|
||||
# 复制 CCGO 构建产物到生产目录 CCGO/(vite assetsDir=CCGO,产物位于 dist/CCGO/,相对引用 CCGO/...)
|
||||
$DistAssets = Join-Path $DistDir 'CCGO'
|
||||
if (Test-Path $DistAssets) {
|
||||
Copy-Item -Recurse -Force (Join-Path $DistAssets '*') $ProdCcgo
|
||||
Write-Host " 已同步: CCGO/ 资产"
|
||||
} else {
|
||||
Write-Warning " dist 中未找到 CCGO,请检查 vite.config.ts 的 assetsDir 配置"
|
||||
}
|
||||
|
||||
# 复制 index.html(产物引用为 /CCGO/...,与部署位置 CCGO/ 一致,无需改写)
|
||||
$DistIndex = Join-Path $DistDir 'index.html'
|
||||
$ProdIndex = Join-Path $ProdCcgo 'index.html'
|
||||
if (Test-Path $DistIndex) {
|
||||
Copy-Item -Force $DistIndex $ProdIndex
|
||||
Write-Host " 已同步: index.html"
|
||||
}
|
||||
|
||||
Write-Host ' 同步完成'
|
||||
Write-Host ''
|
||||
|
||||
# =====================
|
||||
# 5. 校验产物一致性
|
||||
# =====================
|
||||
Write-Host '[5/5] 校验产物一致性...' -ForegroundColor Yellow
|
||||
|
||||
if (-not (Test-Path $ProdIndex)) {
|
||||
throw "index.html 不存在于生产目录: $ProdIndex"
|
||||
}
|
||||
|
||||
$indexContent = Get-Content $ProdIndex -Raw
|
||||
$entryMatch = [regex]::Match($indexContent, 'src="([^"]+\.js)"')
|
||||
if ($entryMatch.Success) {
|
||||
$entryPath = Join-Path $TemplateRoot ($entryMatch.Groups[1].Value.TrimStart('/'))
|
||||
if (Test-Path $entryPath) {
|
||||
Write-Host " 入口 JS 校验通过: $($entryMatch.Groups[1].Value)" -ForegroundColor Green
|
||||
} else {
|
||||
throw "index.html 引用的入口 JS 不存在: $entryPath"
|
||||
}
|
||||
} else {
|
||||
throw 'index.html 中未找到入口 JS 引用'
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "===== CCGO 部署完成 =====" -ForegroundColor Cyan
|
||||
Write-Host "备份位置: $BackupRoot" -ForegroundColor Cyan
|
||||
Write-Host "请浏览器强制刷新(Ctrl+F5)加载新产物。" -ForegroundColor Cyan
|
||||
Write-Host ''
|
||||
Write-Host '===== 部署后提醒 =====' -ForegroundColor Magenta
|
||||
Write-Host ' - ccgo.gvsds.com 现在使用 CCGO SPA(createWebHistory)'
|
||||
Write-Host ' - 原始文件已备份到 backup/ccgo,可随时恢复'
|
||||
Write-Host ' - 如需回退:删除 TempLate/CCGO/index.html 与 CCGO 资产,恢复 backup/ccgo 中的文件'
|
||||
Write-Host '================================================================================' -ForegroundColor Magenta
|
||||
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
export default component
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CCGO 在线评测</title>
|
||||
<link rel="icon" href="/assets/icon/ICON.png">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1717
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "ccgo",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^4.0.2",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0",
|
||||
"vue-tsc": "^3.3.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 根组件:仅承载路由出口
|
||||
</script>
|
||||
@@ -0,0 +1,177 @@
|
||||
/* CCGO 在线评测 全局样式:亮/暗主题变量(沿用旧版 exam.html 配色体系) */
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f0f4f8;
|
||||
--bg-tertiary: #e8edf3;
|
||||
--text-primary: #0d1b2a;
|
||||
--text-secondary: #415a77;
|
||||
--text-muted: #778da9;
|
||||
--accent: #1b3a6b;
|
||||
--accent-bright: #2563eb;
|
||||
--accent-light: #dbeafe;
|
||||
--accent-hover: #1d4ed8;
|
||||
--border: #c9d1db;
|
||||
--border-light: #e2e8f0;
|
||||
--card-bg: #ffffff;
|
||||
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
--option-bg: #f8fafc;
|
||||
--option-hover: #eff6ff;
|
||||
--option-selected: #dbeafe;
|
||||
--option-selected-border: #2563eb;
|
||||
--success: #16a34a;
|
||||
--success-light: #dcfce7;
|
||||
--warning: #d97706;
|
||||
--warning-light: #fef3c7;
|
||||
--danger: #dc2626;
|
||||
--danger-light: #fee2e2;
|
||||
--section-header-bg: #1b3a6b;
|
||||
--section-header-text: #ffffff;
|
||||
--qnum-bg: #f1f5f9;
|
||||
--qnum-border: #cbd5e1;
|
||||
--qnum-active-bg: #2563eb;
|
||||
--qnum-active-text: #ffffff;
|
||||
--qnum-answered-bg: #dbeafe;
|
||||
--qnum-answered-border: #93c5fd;
|
||||
--qnum-answered-text: #1d4ed8;
|
||||
--scrollbar-track: #f1f5f9;
|
||||
--scrollbar-thumb: #94a3b8;
|
||||
--canvas-bg: #ffffff;
|
||||
--code-bg: #1e293b;
|
||||
--code-text: #e2e8f0;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #cbd5e1;
|
||||
--input-focus: #2563eb;
|
||||
--matching-line: #2563eb;
|
||||
--tag-bg: #eff6ff;
|
||||
--tag-text: #1d4ed8;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] {
|
||||
--bg-primary: #0b1120;
|
||||
--bg-secondary: #111827;
|
||||
--bg-tertiary: #1e293b;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent: #3b82f6;
|
||||
--accent-bright: #60a5fa;
|
||||
--accent-light: #1e3a5f;
|
||||
--accent-hover: #2563eb;
|
||||
--border: #334155;
|
||||
--border-light: #1e293b;
|
||||
--card-bg: #111827;
|
||||
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--option-bg: #1e293b;
|
||||
--option-hover: #1e3a5f;
|
||||
--option-selected: #1e3a5f;
|
||||
--option-selected-border: #3b82f6;
|
||||
--success: #22c55e;
|
||||
--success-light: #14532d;
|
||||
--warning: #f59e0b;
|
||||
--warning-light: #451a03;
|
||||
--danger: #ef4444;
|
||||
--danger-light: #450a0a;
|
||||
--section-header-bg: #1e3a5f;
|
||||
--section-header-text: #e2e8f0;
|
||||
--qnum-bg: #1e293b;
|
||||
--qnum-border: #334155;
|
||||
--qnum-active-bg: #2563eb;
|
||||
--qnum-active-text: #ffffff;
|
||||
--qnum-answered-bg: #1e3a5f;
|
||||
--qnum-answered-border: #3b82f6;
|
||||
--qnum-answered-text: #60a5fa;
|
||||
--scrollbar-track: #1e293b;
|
||||
--scrollbar-thumb: #475569;
|
||||
--canvas-bg: #1e293b;
|
||||
--code-bg: #0f172a;
|
||||
--code-text: #e2e8f0;
|
||||
--input-bg: #1e293b;
|
||||
--input-border: #334155;
|
||||
--input-focus: #3b82f6;
|
||||
--matching-line: #60a5fa;
|
||||
--tag-bg: #1e3a5f;
|
||||
--tag-text: #60a5fa;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 14px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans SC', sans-serif;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* 倾斜按钮(沿用旧版视觉风格) */
|
||||
.btn-tilt {
|
||||
display: inline-block;
|
||||
transform: skewX(-8deg);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-tilt-inner {
|
||||
display: inline-block;
|
||||
transform: skewX(8deg);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-tilt-primary {
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
padding: 6px 20px;
|
||||
clip-path: polygon(4px 0, 100% 0, calc(100% - 4px) 100%, 0 100%);
|
||||
}
|
||||
.btn-tilt-primary:hover {
|
||||
background: #e0e7ff;
|
||||
}
|
||||
.btn-tilt-danger {
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
color: #fff;
|
||||
padding: 6px 20px;
|
||||
clip-path: polygon(4px 0, 100% 0, calc(100% - 4px) 100%, 0 100%);
|
||||
}
|
||||
.btn-tilt-danger:hover {
|
||||
background: rgba(220, 38, 38, 0.95);
|
||||
}
|
||||
.btn-tilt-ghost {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
padding: 6px 16px;
|
||||
clip-path: polygon(3px 0, 100% 0, calc(100% - 3px) 100%, 0 100%);
|
||||
}
|
||||
.btn-tilt-ghost:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div v-if="paper" class="card">
|
||||
<div class="card-header">
|
||||
<span>答题卡</span>
|
||||
<span class="card-stat">{{ store.answeredCount }}/{{ store.totalQuestions }}</span>
|
||||
</div>
|
||||
<div v-for="(sec, si) in paper.sections" :key="si" class="card-section">
|
||||
<div class="card-section-title">
|
||||
<span>{{ sec.title }}</span>
|
||||
<span class="card-section-count">{{ sec.questions.length }}题</span>
|
||||
</div>
|
||||
<div class="card-nums">
|
||||
<span
|
||||
v-for="q in sec.questions"
|
||||
:key="q.id"
|
||||
class="card-num"
|
||||
:class="{
|
||||
active: String(q.id) === store.currentQId,
|
||||
answered: isAnswered(q),
|
||||
}"
|
||||
@click="store.navigateTo(q.id)"
|
||||
>
|
||||
{{ q.id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: store.progressPercent + '%' }"></div>
|
||||
</div>
|
||||
<div class="progress-text">{{ store.answeredCount }}/{{ store.totalQuestions }} 已答 ({{ store.progressPercent }}%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useExamStore } from '@/stores/exam'
|
||||
import type { ExamQuestion } from '@/data/exams'
|
||||
|
||||
const store = useExamStore()
|
||||
const paper = computed(() => store.paper)
|
||||
|
||||
function isAnswered(q: ExamQuestion) {
|
||||
if (!store.paper) return false
|
||||
const sec = store.paper.sections.find((s) => s.questions.some((sq) => String(sq.id) === String(q.id)))
|
||||
return store.isAnswered(String(q.id), sec?.type ?? 'single_choice')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
max-height: calc(100vh - 92px);
|
||||
overflow-y: auto;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
.card-header {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
clip-path: polygon(0 0, 100% 0, 100% calc(100% - 4px), calc(100% - 12px) 100%, 0 100%);
|
||||
}
|
||||
.card-stat {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
font-weight: 400;
|
||||
}
|
||||
.card-section {
|
||||
padding: 10px 12px 6px;
|
||||
}
|
||||
.card-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.card-section-count {
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.card-nums {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.card-num {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: var(--qnum-bg);
|
||||
border: 1px solid var(--qnum-border);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: var(--text-primary);
|
||||
clip-path: polygon(2px 0, 100% 0, calc(100% - 2px) 100%, 0 100%);
|
||||
}
|
||||
.card-num:hover {
|
||||
border-color: var(--accent-bright);
|
||||
background: var(--option-hover);
|
||||
}
|
||||
.card-num.active {
|
||||
background: var(--qnum-active-bg);
|
||||
color: var(--qnum-active-text);
|
||||
border-color: var(--qnum-active-bg);
|
||||
}
|
||||
.card-num.answered {
|
||||
background: var(--qnum-answered-bg);
|
||||
color: var(--qnum-answered-text);
|
||||
border-color: var(--qnum-answered-border);
|
||||
}
|
||||
.card-progress {
|
||||
padding: 8px 12px 12px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-bright);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.progress-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<ChoiceQuestion
|
||||
v-if="type === 'single_choice' || type === 'multi_choice' || type === 'indefinite_choice'"
|
||||
:question="question as any"
|
||||
:type="type"
|
||||
/>
|
||||
<TrueFalseQuestion v-else-if="type === 'true_false'" :question="question as any" />
|
||||
<FillBlankQuestion v-else-if="type === 'fill_blank'" :question="question as any" />
|
||||
<MatchingQuestion v-else-if="type === 'matching'" :question="question as any" />
|
||||
<SortingQuestion v-else-if="type === 'sorting'" :question="question as any" />
|
||||
<CategorizeQuestion v-else-if="type === 'categorize'" :question="question as any" />
|
||||
<MaterialQuestion v-else-if="type === 'material_analysis' || type === 'case_analysis'" :question="question as any" />
|
||||
<DrawingQuestion v-else-if="type === 'drawing'" :question="question as any" />
|
||||
<TextQuestion v-else-if="isTextType(type)" :question="question as any" :type="type" />
|
||||
<div v-else class="q-body">
|
||||
<p style="color: var(--text-muted)">未知题型</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ExamQuestion, QuestionType } from '@/data/exams'
|
||||
import ChoiceQuestion from './question/ChoiceQuestion.vue'
|
||||
import TrueFalseQuestion from './question/TrueFalseQuestion.vue'
|
||||
import FillBlankQuestion from './question/FillBlankQuestion.vue'
|
||||
import MatchingQuestion from './question/MatchingQuestion.vue'
|
||||
import SortingQuestion from './question/SortingQuestion.vue'
|
||||
import CategorizeQuestion from './question/CategorizeQuestion.vue'
|
||||
import MaterialQuestion from './question/MaterialQuestion.vue'
|
||||
import DrawingQuestion from './question/DrawingQuestion.vue'
|
||||
import TextQuestion from './question/TextQuestion.vue'
|
||||
|
||||
defineProps<{ question: ExamQuestion; type: QuestionType }>()
|
||||
|
||||
function isTextType(t: QuestionType): boolean {
|
||||
return t === 'short_answer' || t === 'writing' || t === 'qa' || t === 'essay' || t === 'code_practice'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="categorize-area">
|
||||
<div class="categorize-items">
|
||||
<span
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
class="categorize-chip"
|
||||
:class="{ 'cat-placed': placedItems.includes(item), 'cat-selected': selected === item }"
|
||||
@click="store.catSelect(qid, item)"
|
||||
>
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="categorize-groups">
|
||||
<div v-for="(cat, i) in categories" :key="i" class="categorize-group">
|
||||
<div class="categorize-group-header">{{ cat }}</div>
|
||||
<div class="categorize-group-body" @click="store.catPlace(qid, cat)">
|
||||
<span
|
||||
v-for="(item, j) in placedOf(cat)"
|
||||
:key="j"
|
||||
class="categorize-group-chip"
|
||||
@click.stop="store.catRemove(qid, cat, item)"
|
||||
>
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useExamStore } from '@/stores/exam'
|
||||
import type { CategorizeQuestion } from '@/data/exams'
|
||||
|
||||
const props = defineProps<{ question: CategorizeQuestion }>()
|
||||
|
||||
const store = useExamStore()
|
||||
const qid = String(props.question.id)
|
||||
|
||||
const categories = computed(() => props.question.categories ?? [])
|
||||
const items = computed(() => props.question.items ?? [])
|
||||
|
||||
const cs = computed(() => store.ensureCat(qid))
|
||||
const selected = computed(() => cs.value.selected)
|
||||
const placedItems = computed(() => Object.values(cs.value.placed).flat())
|
||||
|
||||
function placedOf(cat: string): string[] {
|
||||
return cs.value.placed[cat] ?? []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.categorize-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
min-height: 40px;
|
||||
}
|
||||
.categorize-chip {
|
||||
padding: 6px 14px;
|
||||
background: var(--option-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
clip-path: polygon(2px 0, 100% 0, calc(100% - 2px) 100%, 0 100%);
|
||||
}
|
||||
.categorize-chip:hover {
|
||||
border-color: var(--accent-bright);
|
||||
}
|
||||
.categorize-chip.cat-selected {
|
||||
border-color: var(--accent-bright);
|
||||
background: var(--option-selected);
|
||||
}
|
||||
.categorize-chip.cat-placed {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
.categorize-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.categorize-group {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.categorize-group-header {
|
||||
padding: 8px 14px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.categorize-group-body {
|
||||
padding: 10px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
background: var(--option-bg);
|
||||
}
|
||||
.categorize-group-chip {
|
||||
padding: 4px 10px;
|
||||
background: var(--tag-bg);
|
||||
color: var(--tag-text);
|
||||
border-radius: 2px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.categorize-group-chip:hover {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="q-body">
|
||||
<div
|
||||
v-for="(opt, i) in options"
|
||||
:key="i"
|
||||
class="option-item"
|
||||
:class="{ selected: isSelected(i) }"
|
||||
:data-type="type === 'single_choice' ? 'radio' : 'checkbox'"
|
||||
@click="onClick(i)"
|
||||
>
|
||||
<span class="option-marker">{{ LETTERS[i] }}</span>
|
||||
<span class="option-text">{{ opt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useExamStore } from '@/stores/exam'
|
||||
import type { ChoiceQuestion } from '@/data/exams'
|
||||
|
||||
const props = defineProps<{
|
||||
question: ChoiceQuestion
|
||||
type: 'single_choice' | 'multi_choice' | 'indefinite_choice'
|
||||
}>()
|
||||
|
||||
const store = useExamStore()
|
||||
const qid = String(props.question.id)
|
||||
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
const options = computed(() => props.question.options ?? [])
|
||||
|
||||
const answer = computed(() => store.answers[qid])
|
||||
|
||||
function isSelected(i: number): boolean {
|
||||
const letter = LETTERS[i]
|
||||
if (props.type === 'single_choice') return answer.value === letter
|
||||
return Array.isArray(answer.value) && answer.value.includes(letter)
|
||||
}
|
||||
|
||||
function onClick(i: number) {
|
||||
const letter = LETTERS[i]
|
||||
if (props.type === 'single_choice') store.selectOption(qid, letter)
|
||||
else store.toggleOption(qid, letter)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 4px;
|
||||
background: var(--option-bg);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
clip-path: polygon(3px 0, 100% 0, calc(100% - 3px) 100%, 0 100%);
|
||||
}
|
||||
.option-item:hover {
|
||||
background: var(--option-hover);
|
||||
border-color: var(--accent-bright);
|
||||
}
|
||||
.option-item.selected {
|
||||
background: var(--option-selected);
|
||||
border-color: var(--option-selected-border);
|
||||
}
|
||||
.option-marker {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.option-item[data-type='checkbox'] .option-marker {
|
||||
border-radius: 2px;
|
||||
}
|
||||
.option-item.selected .option-marker {
|
||||
background: var(--accent-bright);
|
||||
border-color: var(--accent-bright);
|
||||
color: #fff;
|
||||
}
|
||||
.option-text {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="drawing-area">
|
||||
<div class="drawing-toolbar">
|
||||
<button class="draw-tool" :class="{ 'tool-active': tool === 'pen' }" @click="setTool('pen')">画笔</button>
|
||||
<button class="draw-tool" :class="{ 'tool-active': tool === 'eraser' }" @click="setTool('eraser')">橡皮</button>
|
||||
<button class="draw-tool" @click="clear()">清空</button>
|
||||
<span class="toolbar-sep">|</span>
|
||||
<button
|
||||
v-for="c in colors"
|
||||
:key="c"
|
||||
class="draw-color"
|
||||
:class="{ 'color-active': color === c }"
|
||||
:style="{ background: c }"
|
||||
@click="setColor(c)"
|
||||
></button>
|
||||
</div>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
class="drawing-canvas"
|
||||
width="600"
|
||||
height="300"
|
||||
@mousedown="onDown"
|
||||
@mousemove="onMove"
|
||||
@mouseup="onUp"
|
||||
@mouseleave="onUp"
|
||||
></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useExamStore } from '@/stores/exam'
|
||||
import type { DrawingQuestion } from '@/data/exams'
|
||||
|
||||
const props = defineProps<{ question: DrawingQuestion }>()
|
||||
|
||||
const store = useExamStore()
|
||||
const qid = String(props.question.id)
|
||||
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
const colors = ['#000000', '#dc2626', '#2563eb', '#16a34a', '#d97706', '#7c3aed']
|
||||
|
||||
const ds = computed(() => store.ensureDrawing(qid))
|
||||
const tool = computed(() => ds.value.tool)
|
||||
const color = computed(() => ds.value.color)
|
||||
|
||||
let drawing = false
|
||||
|
||||
onMounted(() => {
|
||||
// 切换模式重渲染时会重新挂载,保留已绘制内容的能力由 hasDrawing 状态标记
|
||||
})
|
||||
|
||||
function setTool(t: 'pen' | 'eraser') {
|
||||
store.setDrawTool(qid, t)
|
||||
}
|
||||
|
||||
function setColor(c: string) {
|
||||
store.setDrawColor(qid, c)
|
||||
}
|
||||
|
||||
function clear() {
|
||||
const canvas = canvasEl.value
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx?.clearRect(0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
store.clearCanvas(qid)
|
||||
}
|
||||
|
||||
function onDown(e: MouseEvent) {
|
||||
const canvas = canvasEl.value
|
||||
if (!canvas) return
|
||||
drawing = true
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top)
|
||||
}
|
||||
|
||||
function onMove(e: MouseEvent) {
|
||||
if (!drawing) return
|
||||
const canvas = canvasEl.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (tool.value === 'eraser') {
|
||||
ctx.globalCompositeOperation = 'destination-out'
|
||||
ctx.lineWidth = 20
|
||||
} else {
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
ctx.strokeStyle = color.value
|
||||
ctx.lineWidth = ds.value.size
|
||||
}
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top)
|
||||
ctx.stroke()
|
||||
store.markDrawn(qid)
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
drawing = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawing-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.draw-tool {
|
||||
padding: 4px 12px;
|
||||
background: var(--option-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.draw-tool:hover {
|
||||
border-color: var(--accent-bright);
|
||||
}
|
||||
.draw-tool.tool-active {
|
||||
background: var(--accent-bright);
|
||||
color: #fff;
|
||||
border-color: var(--accent-bright);
|
||||
}
|
||||
.draw-color {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.draw-color.color-active {
|
||||
border-color: var(--accent-bright);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
.drawing-canvas {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--canvas-bg);
|
||||
cursor: crosshair;
|
||||
display: block;
|
||||
border-radius: 2px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.toolbar-sep {
|
||||
margin: 0 6px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="q-body">
|
||||
<div class="fill-line">
|
||||
<template v-for="(part, i) in parts" :key="i">
|
||||
<span>{{ part }}</span>
|
||||
<input
|
||||
v-if="i < blanks"
|
||||
type="text"
|
||||
class="fill-input"
|
||||
:value="vals[i] ?? ''"
|
||||
@input="onInput(i, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useExamStore } from '@/stores/exam'
|
||||
import type { FillBlankQuestion } from '@/data/exams'
|
||||
|
||||
const props = defineProps<{ question: FillBlankQuestion }>()
|
||||
|
||||
const store = useExamStore()
|
||||
const qid = String(props.question.id)
|
||||
const blanks = computed(() => props.question.blanks)
|
||||
const parts = computed(() => {
|
||||
const base = props.question.question ?? ''
|
||||
const split = base.split('____')
|
||||
return split.length > 0 ? split : [base]
|
||||
})
|
||||
const vals = computed(() => (store.answers[qid] as string[]) ?? [])
|
||||
|
||||
function onInput(bidx: number, val: string) {
|
||||
store.setFillBlank(qid, bidx, val)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fill-line {
|
||||
line-height: 2.4;
|
||||
font-size: 14px;
|
||||
}
|
||||
.fill-input {
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--accent-bright);
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
padding: 2px 4px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.fill-input:focus {
|
||||
border-bottom-color: var(--accent-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="matching-area">
|
||||
<div class="matching-container">
|
||||
<div class="matching-col">
|
||||
<div class="matching-col-title">项目</div>
|
||||
<div
|
||||
v-for="(item, i) in left"
|
||||
:key="'l' + i"
|
||||
class="match-item"
|
||||
:class="{ 'match-selected': selected === item && !pairedLeft.includes(item), 'match-paired': pairedLeft.includes(item) }"
|
||||
@click="store.matchClick(qid, 'left', item)"
|
||||
>
|
||||
{{ LETTERS[i] }}. {{ item }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="matching-col">
|
||||
<div class="matching-col-title">描述</div>
|
||||
<div
|
||||
v-for="(item, i) in right"
|
||||
:key="'r' + i"
|
||||
class="match-item"
|
||||
:class="{ 'match-selected': selected === item && !pairedRight.includes(item), 'match-paired': pairedRight.includes(item) }"
|
||||
@click="store.matchClick(qid, 'right', item)"
|
||||
>
|
||||
{{ i + 1 }}. {{ item }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pairs.length > 0" class="match-pairs">
|
||||
<div v-for="[l, r] in pairs" :key="l" class="match-pair-row">
|
||||
<span>{{ l }}</span>
|
||||
<span class="match-pair-arrow">→</span>
|
||||
<span>{{ r }}</span>
|
||||
<span class="match-pair-remove" @click="store.matchRemove(qid, l)">✕</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useExamStore } from '@/stores/exam'
|
||||
import type { MatchingQuestion } from '@/data/exams'
|
||||
|
||||
const props = defineProps<{ question: MatchingQuestion }>()
|
||||
|
||||
const store = useExamStore()
|
||||
const qid = String(props.question.id)
|
||||
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
|
||||
const left = computed(() => props.question.left ?? [])
|
||||
const right = computed(() => props.question.right ?? [])
|
||||
|
||||
const ms = computed(() => store.ensureMatch(qid))
|
||||
const selected = computed(() => ms.value.selected)
|
||||
const pairs = computed(() => Object.entries(ms.value.pairs))
|
||||
const pairedLeft = computed(() => Object.keys(ms.value.pairs))
|
||||
const pairedRight = computed(() => Object.values(ms.value.pairs))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.matching-container {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
}
|
||||
.matching-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.matching-col-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.match-item {
|
||||
padding: 10px 14px;
|
||||
background: var(--option-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-size: 13px;
|
||||
clip-path: polygon(2px 0, 100% 0, calc(100% - 2px) 100%, 0 100%);
|
||||
}
|
||||
.match-item:hover {
|
||||
border-color: var(--accent-bright);
|
||||
background: var(--option-hover);
|
||||
}
|
||||
.match-item.match-selected {
|
||||
border-color: var(--accent-bright);
|
||||
background: var(--option-selected);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
.match-item.match-paired {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.match-pairs {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.match-pair-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.match-pair-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.match-pair-arrow {
|
||||
color: var(--accent-bright);
|
||||
font-weight: 700;
|
||||
}
|
||||
.match-pair-remove {
|
||||
margin-left: auto;
|
||||
cursor: pointer;
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.match-pair-remove:hover {
|
||||
background: var(--danger-light);
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="q-body">
|
||||
<div class="material-block">
|
||||
<div class="material-label">阅读材料</div>
|
||||
<span>{{ question.material || question.case || '' }}</span>
|
||||
</div>
|
||||
<div v-for="(sq, i) in subQuestions" :key="sq.id" class="sub-question">
|
||||
<div class="sub-q-label">({{ LETTERS[i] }}) {{ sq.question }}</div>
|
||||
<div v-if="sq.type === 'single_choice' && sq.options" class="sub-options">
|
||||
<div
|
||||
v-for="(opt, oi) in sq.options"
|
||||
:key="oi"
|
||||
class="option-item"
|
||||
:class="{ selected: isSubSelected(sq.id, oi) }"
|
||||
@click="store.selectOption(String(sq.id), LETTERS[oi])"
|
||||
>
|
||||
<span class="option-marker">{{ LETTERS[oi] }}</span>
|
||||
<span class="option-text">{{ opt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
v-else
|
||||
class="text-answer"
|
||||
:value="(store.answers[String(sq.id)] as string) ?? ''"
|
||||
@input="store.setTextAnswer(String(sq.id), ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useExamStore } from '@/stores/exam'
|
||||
import type { MaterialQuestion } from '@/data/exams'
|
||||
|
||||
const props = defineProps<{ question: MaterialQuestion }>()
|
||||
|
||||
const store = useExamStore()
|
||||
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
|
||||
const subQuestions = computed(() => props.question.subQuestions ?? props.question.questions ?? [])
|
||||
|
||||
function isSubSelected(sqId: string, oi: number): boolean {
|
||||
return store.answers[String(sqId)] === LETTERS[oi]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.material-block {
|
||||
padding: 14px 18px;
|
||||
background: var(--bg-tertiary);
|
||||
border-left: 3px solid var(--accent-bright);
|
||||
border-radius: 0 2px 2px 0;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.material-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-bright);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.sub-question {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.sub-q-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-bright);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sub-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: var(--option-bg);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
clip-path: polygon(3px 0, 100% 0, calc(100% - 3px) 100%, 0 100%);
|
||||
}
|
||||
.option-item:hover {
|
||||
background: var(--option-hover);
|
||||
border-color: var(--accent-bright);
|
||||
}
|
||||
.option-item.selected {
|
||||
background: var(--option-selected);
|
||||
border-color: var(--option-selected-border);
|
||||
}
|
||||
.option-marker {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.option-item.selected .option-marker {
|
||||
background: var(--accent-bright);
|
||||
border-color: var(--accent-bright);
|
||||
color: #fff;
|
||||
}
|
||||
.option-text {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.text-answer {
|
||||
width: 100%;
|
||||
min-height: 90px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 2px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
.text-answer:focus {
|
||||
border-color: var(--input-focus);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="sorting-area">
|
||||
<div class="sort-list">
|
||||
<div
|
||||
v-for="(itemIdx, pos) in order"
|
||||
:key="itemIdx"
|
||||
class="sort-item"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(itemIdx, $event)"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onDrop(itemIdx)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<span class="sort-handle">☰</span>
|
||||
<span class="sort-index">{{ pos + 1 }}</span>
|
||||
<span class="option-text">{{ items[itemIdx] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useExamStore } from '@/stores/exam'
|
||||
import type { SortingQuestion } from '@/data/exams'
|
||||
|
||||
const props = defineProps<{ question: SortingQuestion }>()
|
||||
|
||||
const store = useExamStore()
|
||||
const qid = String(props.question.id)
|
||||
|
||||
const items = computed(() => props.question.items ?? [])
|
||||
const ss = computed(() => store.ensureSort(qid, items.value))
|
||||
const order = computed(() => ss.value.order)
|
||||
|
||||
let dragSrc: number | null = null
|
||||
|
||||
function onDragStart(itemIdx: number, e: DragEvent) {
|
||||
dragSrc = itemIdx
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
dragSrc = null
|
||||
}
|
||||
|
||||
function onDrop(dropIdx: number) {
|
||||
if (dragSrc !== null && dragSrc !== dropIdx) {
|
||||
store.sortDrop(qid, dragSrc, dropIdx)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sort-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.sort-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--option-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
cursor: grab;
|
||||
transition: all 0.15s;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
.sort-item:hover {
|
||||
border-color: var(--accent-bright);
|
||||
background: var(--option-hover);
|
||||
}
|
||||
.sort-item:active {
|
||||
opacity: 0.5;
|
||||
border-style: dashed;
|
||||
}
|
||||
.sort-handle {
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
cursor: grab;
|
||||
}
|
||||
.sort-index {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="q-body">
|
||||
<div v-if="description" class="code-desc">
|
||||
<pre>{{ description }}</pre>
|
||||
</div>
|
||||
<textarea
|
||||
class="text-answer"
|
||||
:class="{
|
||||
tall: type === 'writing' || type === 'code_practice',
|
||||
essay: type === 'essay',
|
||||
'code-editor': type === 'code_practice',
|
||||
}"
|
||||
:placeholder="type === 'code_practice' ? '在此编写代码…' : '在此输入答案…'"
|
||||
:value="answer"
|
||||
:spellcheck="type !== 'code_practice'"
|
||||
@input="onInput(($event.target as HTMLTextAreaElement).value)"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<div v-if="showWordCount" class="word-count">
|
||||
已输入 {{ wordCount }} 字{{ minWords ? ' / 不少于 ' + minWords + ' 字' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useExamStore } from '@/stores/exam'
|
||||
import type { WritingQuestion, CodePracticeQuestion } from '@/data/exams'
|
||||
|
||||
const props = defineProps<{
|
||||
question: WritingQuestion | CodePracticeQuestion
|
||||
type: 'short_answer' | 'writing' | 'qa' | 'essay' | 'code_practice'
|
||||
}>()
|
||||
|
||||
const store = useExamStore()
|
||||
const qid = String(props.question.id)
|
||||
const answer = computed(() => (store.answers[qid] as string) ?? '')
|
||||
|
||||
const description = computed(() => (props.question as CodePracticeQuestion).description)
|
||||
const minWords = computed(() => (props.question as WritingQuestion).minWords ?? 0)
|
||||
const showWordCount = computed(() => props.type === 'writing' || props.type === 'essay')
|
||||
const wordCount = computed(() => answer.value.replace(/\s/g, '').length)
|
||||
|
||||
function onInput(val: string) {
|
||||
store.setTextAnswer(qid, val)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Tab' && props.type === 'code_practice') {
|
||||
e.preventDefault()
|
||||
const ta = e.target as HTMLTextAreaElement
|
||||
const start = ta.selectionStart
|
||||
const end = ta.selectionEnd
|
||||
const next = ta.value.substring(0, start) + ' ' + ta.value.substring(end)
|
||||
store.setTextAnswer(qid, next)
|
||||
requestAnimationFrame(() => {
|
||||
ta.selectionStart = ta.selectionEnd = start + 4
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-answer {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 2px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.text-answer:focus {
|
||||
border-color: var(--input-focus);
|
||||
}
|
||||
.text-answer.tall {
|
||||
min-height: 200px;
|
||||
}
|
||||
.text-answer.essay {
|
||||
min-height: 300px;
|
||||
}
|
||||
.code-desc {
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.code-desc pre {
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
.code-editor {
|
||||
background: var(--code-bg);
|
||||
color: var(--code-text);
|
||||
border: 1px solid var(--border);
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
tab-size: 4;
|
||||
}
|
||||
.word-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="q-body">
|
||||
<div class="tf-group">
|
||||
<div class="tf-btn" :class="{ selected: answer === true }" @click="store.selectTF(qid, true)">✔ 正确</div>
|
||||
<div class="tf-btn" :class="{ selected: answer === false }" @click="store.selectTF(qid, false)">✘ 错误</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useExamStore } from '@/stores/exam'
|
||||
import type { TrueFalseQuestion } from '@/data/exams'
|
||||
|
||||
const props = defineProps<{ question: TrueFalseQuestion }>()
|
||||
|
||||
const store = useExamStore()
|
||||
const qid = String(props.question.id)
|
||||
const answer = computed(() => store.answers[qid])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tf-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.tf-btn {
|
||||
flex: 1;
|
||||
max-width: 200px;
|
||||
padding: 12px 20px;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--option-bg);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
clip-path: polygon(4px 0, 100% 0, calc(100% - 4px) 100%, 0 100%);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.tf-btn:hover {
|
||||
border-color: var(--accent-bright);
|
||||
background: var(--option-hover);
|
||||
}
|
||||
.tf-btn.selected {
|
||||
border-color: var(--accent-bright);
|
||||
background: var(--option-selected);
|
||||
color: var(--accent-bright);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,395 @@
|
||||
/** 题库数据模型(迁移自旧版 TempLate/CCGO/exam.html 的 EXAM_DATA) */
|
||||
|
||||
export type QuestionType =
|
||||
| 'single_choice'
|
||||
| 'multi_choice'
|
||||
| 'indefinite_choice'
|
||||
| 'true_false'
|
||||
| 'fill_blank'
|
||||
| 'matching'
|
||||
| 'sorting'
|
||||
| 'categorize'
|
||||
| 'short_answer'
|
||||
| 'material_analysis'
|
||||
| 'drawing'
|
||||
| 'case_analysis'
|
||||
| 'writing'
|
||||
| 'qa'
|
||||
| 'essay'
|
||||
| 'code_practice'
|
||||
|
||||
export interface SubQuestion {
|
||||
id: string
|
||||
question: string
|
||||
type?: string
|
||||
options?: string[]
|
||||
answer?: string
|
||||
}
|
||||
|
||||
interface BaseQuestion {
|
||||
id: number | string
|
||||
question?: string
|
||||
score?: number | null
|
||||
answer?: unknown
|
||||
}
|
||||
|
||||
export interface ChoiceQuestion extends BaseQuestion {
|
||||
options: string[]
|
||||
answer?: string | string[]
|
||||
}
|
||||
|
||||
export interface TrueFalseQuestion extends BaseQuestion {
|
||||
answer?: boolean
|
||||
}
|
||||
|
||||
export interface FillBlankQuestion extends BaseQuestion {
|
||||
blanks: number
|
||||
answer?: string[]
|
||||
}
|
||||
|
||||
export interface MatchingQuestion extends BaseQuestion {
|
||||
left: string[]
|
||||
right: string[]
|
||||
answer?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface SortingQuestion extends BaseQuestion {
|
||||
items: string[]
|
||||
answer?: number[]
|
||||
}
|
||||
|
||||
export interface CategorizeQuestion extends BaseQuestion {
|
||||
categories: string[]
|
||||
items: string[]
|
||||
answer?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export interface MaterialQuestion extends BaseQuestion {
|
||||
material?: string
|
||||
case?: string
|
||||
subQuestions?: SubQuestion[]
|
||||
questions?: SubQuestion[]
|
||||
}
|
||||
|
||||
export interface WritingQuestion extends BaseQuestion {
|
||||
minWords?: number
|
||||
}
|
||||
|
||||
export interface CodePracticeQuestion extends BaseQuestion {
|
||||
description?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface DrawingQuestion extends BaseQuestion {}
|
||||
|
||||
export type ExamQuestion =
|
||||
| ChoiceQuestion
|
||||
| TrueFalseQuestion
|
||||
| FillBlankQuestion
|
||||
| MatchingQuestion
|
||||
| SortingQuestion
|
||||
| CategorizeQuestion
|
||||
| MaterialQuestion
|
||||
| WritingQuestion
|
||||
| CodePracticeQuestion
|
||||
| DrawingQuestion
|
||||
|
||||
export interface ExamSection {
|
||||
type: QuestionType
|
||||
title: string
|
||||
description: string
|
||||
questions: ExamQuestion[]
|
||||
}
|
||||
|
||||
export interface ExamPaper {
|
||||
id: string
|
||||
title: string
|
||||
subtitle: string
|
||||
timeLimit: number // 分钟,0 = 不限时
|
||||
description: string
|
||||
sections: ExamSection[]
|
||||
}
|
||||
|
||||
/** 全部试卷(当前 1 套,后续可扩充) */
|
||||
export const exams: ExamPaper[] = [
|
||||
{
|
||||
id: 'cs-literacy',
|
||||
title: '计算机素养评测',
|
||||
subtitle: '全国计算机应用水平考试',
|
||||
timeLimit: 120,
|
||||
description:
|
||||
'覆盖计算机基础、编程语言、操作系统、网络协议、信息安全等方向,包含单选、多选、判断、填空、连线、排序、归类、简答、材料分析、作图、案例、写作、问答、作文与代码实操共 15 类题型。',
|
||||
sections: [
|
||||
{
|
||||
type: 'single_choice',
|
||||
title: '单项选择题',
|
||||
description: '每题只有一个正确答案',
|
||||
questions: [
|
||||
{
|
||||
id: 1,
|
||||
question: '以下哪个不是计算机的基本组成部分?',
|
||||
options: ['中央处理器(CPU)', '内存(RAM)', '硬盘(HDD)', '电源适配器'],
|
||||
score: 2,
|
||||
answer: 'D',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
question: 'Python是一种什么类型的编程语言?',
|
||||
options: ['编译型语言', '解释型语言', '机器语言', '汇编语言'],
|
||||
score: 2,
|
||||
answer: 'B',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
question: '在Windows操作系统中,哪个快捷键可以复制选中的内容?',
|
||||
options: ['Ctrl+X', 'Ctrl+C', 'Ctrl+V', 'Ctrl+Z'],
|
||||
score: 2,
|
||||
answer: 'B',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
question: '在计算机中,1KB等于多少字节?',
|
||||
options: ['1000', '1024', '10000', '1048576'],
|
||||
score: 2,
|
||||
answer: 'B',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'multi_choice',
|
||||
title: '多项选择题',
|
||||
description: '每题有两个或以上正确答案',
|
||||
questions: [
|
||||
{
|
||||
id: 5,
|
||||
question: '以下哪些是计算机的输入设备?',
|
||||
options: ['键盘', '鼠标', '显示器', '打印机', '扫描仪'],
|
||||
score: 3,
|
||||
answer: ['A', 'B', 'E'],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
question: '以下哪些是Python的可变数据类型?',
|
||||
options: ['int', 'list', 'tuple', 'dict', 'str'],
|
||||
score: 3,
|
||||
answer: ['B', 'D'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'indefinite_choice',
|
||||
title: '不定项选择题',
|
||||
description: '正确答案数量不确定(1个或多个)',
|
||||
questions: [
|
||||
{
|
||||
id: 7,
|
||||
question: '关于TCP/IP协议,以下说法正确的是?',
|
||||
options: ['TCP是面向连接的协议', 'IP负责数据包的路由', 'UDP是可靠传输协议', 'HTTP基于TCP协议'],
|
||||
score: null,
|
||||
answer: ['A', 'B', 'D'],
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
question: '以下哪些属于操作系统?',
|
||||
options: ['Windows', 'Linux', 'Python', 'macOS'],
|
||||
score: null,
|
||||
answer: ['A', 'B', 'D'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'true_false',
|
||||
title: '判断正误',
|
||||
description: '判断下列说法是否正确',
|
||||
questions: [
|
||||
{ id: 9, question: '计算机病毒是一种生物病毒。', score: 1, answer: false },
|
||||
{ id: 10, question: 'HTTP协议默认使用80端口。', score: 1, answer: true },
|
||||
{ id: 11, question: 'RAM中的数据在断电后会丢失。', score: 1, answer: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'fill_blank',
|
||||
title: '填空题',
|
||||
description: '在横线处填入正确答案',
|
||||
questions: [
|
||||
{
|
||||
id: 12,
|
||||
question: '计算机的三大核心部件是____、____和____。',
|
||||
blanks: 3,
|
||||
score: 3,
|
||||
answer: ['CPU', '内存', '硬盘'],
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
question: 'IP地址由____位二进制数组成(IPv4),其中C类地址的第一个字节范围是____到____。',
|
||||
blanks: 3,
|
||||
score: null,
|
||||
answer: ['32', '192', '223'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'matching',
|
||||
title: '连线匹配题',
|
||||
description: '将左侧项目与右侧正确描述连线',
|
||||
questions: [
|
||||
{
|
||||
id: 14,
|
||||
score: 4,
|
||||
left: ['CPU', 'RAM', 'SSD', 'GPU'],
|
||||
right: ['图形处理', '临时存储', '永久存储', '中央运算'],
|
||||
answer: { CPU: '中央运算', RAM: '临时存储', SSD: '永久存储', GPU: '图形处理' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'sorting',
|
||||
title: '排序题',
|
||||
description: '将下列项目按正确顺序排列',
|
||||
questions: [
|
||||
{
|
||||
id: 15,
|
||||
score: 4,
|
||||
question: '请将计算机启动过程的步骤按正确顺序排列:',
|
||||
items: ['加载操作系统', 'POST自检', 'BIOS初始化', '显示桌面'],
|
||||
answer: [2, 1, 0, 3],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'categorize',
|
||||
title: '归类题',
|
||||
description: '将下列项目归入正确的类别',
|
||||
questions: [
|
||||
{
|
||||
id: 16,
|
||||
score: 5,
|
||||
categories: ['输入设备', '输出设备', '存储设备'],
|
||||
items: ['键盘', '显示器', 'U盘', '打印机', '鼠标', '硬盘'],
|
||||
answer: { '输入设备': ['键盘', '鼠标'], '输出设备': ['显示器', '打印机'], '存储设备': ['U盘', '硬盘'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'short_answer',
|
||||
title: '简答题',
|
||||
description: '简要回答下列问题',
|
||||
questions: [
|
||||
{
|
||||
id: 17,
|
||||
question: '简述什么是操作系统及其主要功能。',
|
||||
score: 5,
|
||||
answer: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'material_analysis',
|
||||
title: '材料分析题',
|
||||
description: '阅读材料后回答问题',
|
||||
questions: [
|
||||
{
|
||||
id: 18,
|
||||
score: null,
|
||||
material:
|
||||
'某公司网络系统遭受攻击,攻击者通过钓鱼邮件获取了员工账号密码,进而入侵公司内部服务器,窃取了大量客户数据。事后调查发现,公司未部署防火墙,员工安全意识薄弱,密码策略过于简单。',
|
||||
subQuestions: [
|
||||
{ id: '18a', question: '此次攻击使用了哪种社会工程学手段?', type: 'short_answer' },
|
||||
{ id: '18b', question: '请列举至少三项该公司应采取的安全改进措施。', type: 'short_answer' },
|
||||
{
|
||||
id: '18c',
|
||||
question: '以下哪项不是防范此类攻击的有效措施?',
|
||||
type: 'single_choice',
|
||||
options: ['定期更换密码', '部署防火墙', '关闭所有网络端口', '安全意识培训'],
|
||||
answer: 'C',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'drawing',
|
||||
title: '作图题',
|
||||
description: '在画布上完成作图',
|
||||
questions: [
|
||||
{
|
||||
id: 19,
|
||||
score: 6,
|
||||
question: '请绘制一个简单的星型拓扑网络结构图,包含中心节点和5个终端节点。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'case_analysis',
|
||||
title: '案例分析题',
|
||||
description: '分析案例并回答问题',
|
||||
questions: [
|
||||
{
|
||||
id: 20,
|
||||
score: null,
|
||||
case:
|
||||
'小明是一名大学生,他发现自己的电脑运行越来越慢。经检查,电脑开机启动项多达30个,C盘剩余空间不足5%,同时安装了多个杀毒软件。此外,他习惯将所有文件保存在桌面,从不整理。',
|
||||
questions: [
|
||||
{ id: '20a', question: '分析小明电脑变慢的可能原因。', type: 'short_answer' },
|
||||
{ id: '20b', question: '请给出优化建议。', type: 'short_answer' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'writing',
|
||||
title: '写作题',
|
||||
description: '根据要求撰写短文',
|
||||
questions: [
|
||||
{
|
||||
id: 21,
|
||||
score: 10,
|
||||
question: '请以"人工智能对教育的影响"为主题,撰写一篇不少于200字的短文,阐述你的观点。',
|
||||
minWords: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'qa',
|
||||
title: '问答题',
|
||||
description: '回答下列问题',
|
||||
questions: [
|
||||
{
|
||||
id: 22,
|
||||
question: '什么是云计算?它有哪些主要服务模式?请分别说明。',
|
||||
score: 8,
|
||||
answer: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'essay',
|
||||
title: '作文',
|
||||
description: '根据要求写作',
|
||||
questions: [
|
||||
{
|
||||
id: 23,
|
||||
score: 20,
|
||||
question: '请以"信息技术与未来社会"为题,写一篇议论文。要求:观点明确,论据充分,条理清晰,不少于500字。',
|
||||
minWords: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'code_practice',
|
||||
title: '代码实操题',
|
||||
description: '编写代码完成要求',
|
||||
questions: [
|
||||
{
|
||||
id: 24,
|
||||
score: 15,
|
||||
question: '编写一个Python函数',
|
||||
description:
|
||||
"编写一个名为 `is_palindrome` 的函数,接收一个字符串参数,判断该字符串是否为回文字符串(正读和反读相同),返回布尔值。\n\n示例:\nis_palindrome('abcba') → True\nis_palindrome('hello') → False\n\n注意:忽略大小写和空格。",
|
||||
language: 'python',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/HomeView.vue'),
|
||||
meta: { title: 'CCGO 在线评测' },
|
||||
},
|
||||
{
|
||||
path: '/exam/:id',
|
||||
name: 'Exam',
|
||||
component: () => import('@/views/ExamView.vue'),
|
||||
meta: { title: '在线答题' },
|
||||
},
|
||||
// 未匹配 → 首页
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior() {
|
||||
return { top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const title = (to.meta.title as string) || 'CCGO 在线评测'
|
||||
document.title = title
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,392 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { exams, type ExamPaper, type ExamQuestion, type QuestionType } from '@/data/exams'
|
||||
|
||||
export interface FlatQuestion {
|
||||
id: string
|
||||
type: QuestionType
|
||||
sectionTitle: string
|
||||
question: string
|
||||
}
|
||||
|
||||
export interface MatchState {
|
||||
selected: string | null
|
||||
selectedSide: 'left' | 'right' | null
|
||||
pairs: Record<string, string>
|
||||
}
|
||||
|
||||
export interface SortState {
|
||||
order: number[]
|
||||
userChanged: boolean
|
||||
}
|
||||
|
||||
export interface CatState {
|
||||
placed: Record<string, string[]>
|
||||
selected: string | null
|
||||
}
|
||||
|
||||
export interface DrawingState {
|
||||
tool: 'pen' | 'eraser'
|
||||
color: string
|
||||
size: number
|
||||
hasDrawing: boolean
|
||||
}
|
||||
|
||||
interface ExamState {
|
||||
paper: ExamPaper | null
|
||||
mode: 'switch' | 'continuous'
|
||||
currentQId: string | null
|
||||
answers: Record<string, unknown>
|
||||
matchState: Record<string, MatchState>
|
||||
sortState: Record<string, SortState>
|
||||
catState: Record<string, CatState>
|
||||
drawingState: Record<string, DrawingState>
|
||||
timer: number
|
||||
timerInterval: number | null
|
||||
submitted: boolean
|
||||
theme: 'light' | 'dark'
|
||||
}
|
||||
|
||||
/** isAnsweredCore 所需的最小状态子集(store/getter/action 均可满足) */
|
||||
type AnswerState = Pick<ExamState, 'answers' | 'matchState' | 'sortState' | 'catState' | 'drawingState' | 'paper'>
|
||||
|
||||
/** 作答判定核心函数(模块级,供 getter/action 共用) */
|
||||
function isAnsweredCore(state: AnswerState, qid: string, type: QuestionType): boolean {
|
||||
const ans = state.answers[qid]
|
||||
switch (type) {
|
||||
case 'single_choice':
|
||||
case 'true_false':
|
||||
return ans !== undefined && ans !== null
|
||||
case 'multi_choice':
|
||||
case 'indefinite_choice':
|
||||
return Array.isArray(ans) && ans.length > 0
|
||||
case 'fill_blank':
|
||||
return Array.isArray(ans) && ans.some((a) => typeof a === 'string' && a.trim())
|
||||
case 'matching': {
|
||||
const ms = state.matchState[qid]
|
||||
return !!ms && Object.keys(ms.pairs).length > 0
|
||||
}
|
||||
case 'sorting': {
|
||||
const ss = state.sortState[qid]
|
||||
return !!ss && ss.userChanged
|
||||
}
|
||||
case 'categorize': {
|
||||
const cs = state.catState[qid]
|
||||
return !!cs && Object.keys(cs.placed).length > 0
|
||||
}
|
||||
case 'short_answer':
|
||||
case 'writing':
|
||||
case 'qa':
|
||||
case 'essay':
|
||||
case 'code_practice':
|
||||
return typeof ans === 'string' && ans.trim().length > 0
|
||||
case 'material_analysis':
|
||||
case 'case_analysis': {
|
||||
const paper = state.paper
|
||||
if (!paper) return false
|
||||
for (const sec of paper.sections) {
|
||||
for (const q of sec.questions) {
|
||||
if (String(q.id) !== qid) continue
|
||||
const mq = q as { subQuestions?: { id: string; type?: string }[]; questions?: { id: string; type?: string }[] }
|
||||
const subQs = mq.subQuestions ?? mq.questions ?? []
|
||||
return subQs.some((sq) => isAnsweredCore(state, String(sq.id), (sq.type as QuestionType) || 'short_answer'))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
case 'drawing': {
|
||||
const ds = state.drawingState[qid]
|
||||
return !!ds && ds.hasDrawing
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const useExamStore = defineStore('exam', {
|
||||
state: (): ExamState => ({
|
||||
paper: null,
|
||||
mode: 'switch',
|
||||
currentQId: null,
|
||||
answers: {},
|
||||
matchState: {},
|
||||
sortState: {},
|
||||
catState: {},
|
||||
drawingState: {},
|
||||
timer: 0,
|
||||
timerInterval: null,
|
||||
submitted: false,
|
||||
theme: 'light',
|
||||
}),
|
||||
|
||||
getters: {
|
||||
/** 扁平化题目索引(含材料/案例子题),用于逐题导航 */
|
||||
allQuestions(state): FlatQuestion[] {
|
||||
const list: FlatQuestion[] = []
|
||||
if (!state.paper) return list
|
||||
state.paper.sections.forEach((sec) => {
|
||||
sec.questions.forEach((q) => {
|
||||
const id = String(q.id)
|
||||
list.push({ id, type: sec.type, sectionTitle: sec.title, question: q.question ?? '' })
|
||||
if (sec.type === 'material_analysis' || sec.type === 'case_analysis') {
|
||||
const mq = q as { subQuestions?: { id: string; question: string; type?: string }[]; questions?: { id: string; question: string; type?: string }[] }
|
||||
const subQs = mq.subQuestions ?? mq.questions ?? []
|
||||
subQs.forEach((sq) => {
|
||||
list.push({ id: String(sq.id), type: (sq.type as QuestionType) || 'short_answer', sectionTitle: sec.title, question: sq.question })
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
return list
|
||||
},
|
||||
|
||||
totalQuestions(): number {
|
||||
return this.allQuestions.length
|
||||
},
|
||||
|
||||
answeredCount(): number {
|
||||
let count = 0
|
||||
this.allQuestions.forEach((q) => {
|
||||
if (isAnsweredCore(this, q.id, q.type)) count++
|
||||
})
|
||||
return count
|
||||
},
|
||||
|
||||
progressPercent(): number {
|
||||
return this.totalQuestions > 0 ? Math.round((this.answeredCount / this.totalQuestions) * 100) : 0
|
||||
},
|
||||
|
||||
timeLimitMinutes(): number {
|
||||
return this.paper?.timeLimit ?? 0
|
||||
},
|
||||
|
||||
currentIndex(): number {
|
||||
if (!this.currentQId) return 0
|
||||
return this.allQuestions.findIndex((q) => q.id === this.currentQId)
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
initTheme() {
|
||||
const saved = localStorage.getItem('ccgo-theme')
|
||||
this.theme = saved === 'dark' || saved === 'light' ? saved : matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
document.documentElement.setAttribute('data-theme', this.theme)
|
||||
},
|
||||
|
||||
toggleTheme() {
|
||||
this.theme = this.theme === 'dark' ? 'light' : 'dark'
|
||||
document.documentElement.setAttribute('data-theme', this.theme)
|
||||
localStorage.setItem('ccgo-theme', this.theme)
|
||||
},
|
||||
|
||||
startExam(paper: ExamPaper) {
|
||||
this.paper = paper
|
||||
this.mode = paper.id === 'cs-literacy' ? 'switch' : 'switch'
|
||||
this.answers = {}
|
||||
this.matchState = {}
|
||||
this.sortState = {}
|
||||
this.catState = {}
|
||||
this.drawingState = {}
|
||||
this.submitted = false
|
||||
this.currentQId = this.allQuestions.length > 0 ? this.allQuestions[0].id : null
|
||||
this.timer = paper.timeLimit * 60
|
||||
this.stopTimer()
|
||||
this.startTimer()
|
||||
},
|
||||
|
||||
startTimer() {
|
||||
if (this.timer <= 0) return
|
||||
this.timerInterval = window.setInterval(() => {
|
||||
this.timer--
|
||||
if (this.timer <= 0) {
|
||||
this.stopTimer()
|
||||
this.submit()
|
||||
}
|
||||
}, 1000)
|
||||
},
|
||||
|
||||
stopTimer() {
|
||||
if (this.timerInterval !== null) {
|
||||
clearInterval(this.timerInterval)
|
||||
this.timerInterval = null
|
||||
}
|
||||
},
|
||||
|
||||
navigateTo(qid: string | number) {
|
||||
this.currentQId = String(qid)
|
||||
},
|
||||
|
||||
/** 单选 / 不定项单选项选中 */
|
||||
selectOption(qid: string | number, val: string) {
|
||||
const key = String(qid)
|
||||
this.answers[key] = val
|
||||
},
|
||||
|
||||
/** 多选 / 不定项多选切换 */
|
||||
toggleOption(qid: string | number, val: string) {
|
||||
const key = String(qid)
|
||||
const arr = (this.answers[key] as string[]) ?? []
|
||||
const idx = arr.indexOf(val)
|
||||
if (idx >= 0) arr.splice(idx, 1)
|
||||
else arr.push(val)
|
||||
arr.sort()
|
||||
this.answers[key] = [...arr]
|
||||
},
|
||||
|
||||
selectTF(qid: string | number, val: boolean) {
|
||||
this.answers[String(qid)] = val
|
||||
},
|
||||
|
||||
setFillBlank(qid: string | number, bidx: number, val: string) {
|
||||
const key = String(qid)
|
||||
const arr = (this.answers[key] as string[]) ?? []
|
||||
arr[bidx] = val
|
||||
this.answers[key] = [...arr]
|
||||
},
|
||||
|
||||
setTextAnswer(qid: string | number, val: string) {
|
||||
this.answers[String(qid)] = val
|
||||
},
|
||||
|
||||
// ===== 连线匹配 =====
|
||||
ensureMatch(qid: string | number): MatchState {
|
||||
const key = String(qid)
|
||||
if (!this.matchState[key]) this.matchState[key] = { selected: null, selectedSide: null, pairs: {} }
|
||||
return this.matchState[key]
|
||||
},
|
||||
|
||||
matchClick(qid: string | number, side: 'left' | 'right', val: string) {
|
||||
const key = String(qid)
|
||||
const ms = this.ensureMatch(qid)
|
||||
if (ms.pairs[val]) return
|
||||
if (!ms.selected) {
|
||||
ms.selected = val
|
||||
ms.selectedSide = side
|
||||
} else {
|
||||
if (ms.selectedSide === side) {
|
||||
ms.selected = val
|
||||
ms.selectedSide = side
|
||||
} else {
|
||||
const leftVal = side === 'right' ? ms.selected : val
|
||||
const rightVal = side === 'right' ? val : ms.selected
|
||||
// 移除该右侧已被占用的旧配对
|
||||
Object.keys(ms.pairs).forEach((k) => {
|
||||
if (ms.pairs[k] === rightVal) delete ms.pairs[k]
|
||||
})
|
||||
delete ms.pairs[leftVal]
|
||||
ms.pairs[leftVal] = rightVal
|
||||
ms.selected = null
|
||||
ms.selectedSide = null
|
||||
}
|
||||
}
|
||||
this.answers[key] = { ...ms.pairs }
|
||||
},
|
||||
|
||||
matchRemove(qid: string | number, leftVal: string) {
|
||||
const key = String(qid)
|
||||
const ms = this.matchState[key]
|
||||
if (ms) {
|
||||
delete ms.pairs[leftVal]
|
||||
this.answers[key] = { ...ms.pairs }
|
||||
}
|
||||
},
|
||||
|
||||
// ===== 排序 =====
|
||||
ensureSort(qid: string | number, items: string[]): SortState {
|
||||
const key = String(qid)
|
||||
if (!this.sortState[key]) this.sortState[key] = { order: items.map((_, i) => i), userChanged: false }
|
||||
return this.sortState[key]
|
||||
},
|
||||
|
||||
sortDrop(qid: string | number, srcIdx: number, dropIdx: number) {
|
||||
const key = String(qid)
|
||||
const ss = this.sortState[key]
|
||||
if (!ss) return
|
||||
const srcPos = ss.order.indexOf(srcIdx)
|
||||
const dropPos = ss.order.indexOf(dropIdx)
|
||||
if (srcPos < 0 || dropPos < 0 || srcPos === dropPos) return
|
||||
ss.order.splice(srcPos, 1)
|
||||
ss.order.splice(dropPos, 0, srcIdx)
|
||||
ss.userChanged = true
|
||||
this.answers[key] = [...ss.order]
|
||||
},
|
||||
|
||||
// ===== 归类 =====
|
||||
ensureCat(qid: string | number): CatState {
|
||||
const key = String(qid)
|
||||
if (!this.catState[key]) this.catState[key] = { placed: {}, selected: null }
|
||||
return this.catState[key]
|
||||
},
|
||||
|
||||
catSelect(qid: string | number, item: string) {
|
||||
const cs = this.ensureCat(qid)
|
||||
const placedItems = Object.values(cs.placed).flat()
|
||||
if (placedItems.includes(item)) return
|
||||
cs.selected = cs.selected === item ? null : item
|
||||
},
|
||||
|
||||
catPlace(qid: string | number, cat: string) {
|
||||
const key = String(qid)
|
||||
const cs = this.catState[key]
|
||||
if (!cs || !cs.selected) return
|
||||
if (!cs.placed[cat]) cs.placed[cat] = []
|
||||
cs.placed[cat].push(cs.selected)
|
||||
cs.selected = null
|
||||
this.answers[key] = { ...cs.placed }
|
||||
},
|
||||
|
||||
catRemove(qid: string | number, cat: string, item: string) {
|
||||
const key = String(qid)
|
||||
const cs = this.catState[key]
|
||||
if (cs?.placed[cat]) {
|
||||
cs.placed[cat] = cs.placed[cat].filter((i) => i !== item)
|
||||
if (cs.placed[cat].length === 0) delete cs.placed[cat]
|
||||
this.answers[key] = { ...cs.placed }
|
||||
}
|
||||
},
|
||||
|
||||
// ===== 作图 =====
|
||||
ensureDrawing(qid: string | number): DrawingState {
|
||||
const key = String(qid)
|
||||
if (!this.drawingState[key]) this.drawingState[key] = { tool: 'pen', color: '#000000', size: 2, hasDrawing: false }
|
||||
return this.drawingState[key]
|
||||
},
|
||||
|
||||
setDrawTool(qid: string | number, tool: 'pen' | 'eraser') {
|
||||
this.ensureDrawing(qid).tool = tool
|
||||
},
|
||||
|
||||
setDrawColor(qid: string | number, color: string) {
|
||||
this.ensureDrawing(qid).color = color
|
||||
},
|
||||
|
||||
clearCanvas(qid: string | number) {
|
||||
const key = String(qid)
|
||||
const ds = this.ensureDrawing(qid)
|
||||
ds.hasDrawing = false
|
||||
this.answers[key] = null
|
||||
},
|
||||
|
||||
markDrawn(qid: string | number) {
|
||||
const key = String(qid)
|
||||
const ds = this.ensureDrawing(qid)
|
||||
ds.hasDrawing = true
|
||||
this.answers[key] = 'drawn'
|
||||
},
|
||||
|
||||
// ===== 作答判定 =====
|
||||
isAnswered(qid: string, type: QuestionType): boolean {
|
||||
return isAnsweredCore(this, qid, type)
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.stopTimer()
|
||||
this.submitted = true
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export function getExamPaper(id: string): ExamPaper | undefined {
|
||||
return exams.find((e) => e.id === id)
|
||||
}
|
||||
|
||||
export type { ExamQuestion }
|
||||
@@ -0,0 +1,663 @@
|
||||
<template>
|
||||
<div v-if="store.paper" class="exam-wrap">
|
||||
<!-- 顶栏 -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<div>
|
||||
<div class="header-title">{{ store.paper.title }}</div>
|
||||
<div class="header-sub">{{ store.paper.subtitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="timer">
|
||||
<span class="timer-icon">🕐</span>
|
||||
<span>{{ timerText }}</span>
|
||||
</div>
|
||||
<button class="btn-tilt btn-tilt-ghost" @click="toggleMode()">
|
||||
<span class="btn-tilt-inner">{{ store.mode === 'switch' ? '连贯模式' : '切换模式' }}</span>
|
||||
</button>
|
||||
<button class="theme-toggle" @click="store.toggleTheme()" :title="store.theme === 'dark' ? '切换亮色' : '切换暗色'">
|
||||
{{ store.theme === 'dark' ? '☀' : '☾' }}
|
||||
</button>
|
||||
<button class="btn-tilt btn-tilt-danger" @click="openSubmitModal()">
|
||||
<span class="btn-tilt-inner">交卷</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 交卷结果 -->
|
||||
<div v-if="store.submitted" class="result-wrap">
|
||||
<div class="result-box">
|
||||
<div class="result-icon">✓</div>
|
||||
<h2>试卷已提交</h2>
|
||||
<p class="result-meta">共 {{ store.totalQuestions }} 题,已答 {{ store.answeredCount }} 题</p>
|
||||
<table class="result-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>题号</th>
|
||||
<th>题型</th>
|
||||
<th>状态</th>
|
||||
<th>作答内容</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="q in store.allQuestions" :key="q.id">
|
||||
<td>{{ q.id }}</td>
|
||||
<td>{{ typeLabel(q.type) }}</td>
|
||||
<td>
|
||||
<span class="status" :class="store.isAnswered(q.id, q.type) ? 'ok' : 'miss'">
|
||||
{{ store.isAnswered(q.id, q.type) ? '已答' : '未答' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="answer-cell">{{ formatAnswer(q) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="result-actions">
|
||||
<button class="btn-tilt btn-tilt-primary" @click="backHome()">
|
||||
<span class="btn-tilt-inner">返回首页</span>
|
||||
</button>
|
||||
<button class="btn-tilt btn-tilt-danger" @click="restart()">
|
||||
<span class="btn-tilt-inner">重新答题</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 答题主体 -->
|
||||
<div v-else class="main-layout">
|
||||
<div ref="answerArea" class="answer-area">
|
||||
<!-- 切换模式:单题 -->
|
||||
<template v-if="store.mode === 'switch'">
|
||||
<div v-if="currentQuestion" class="question-item active-question">
|
||||
<div class="q-header">
|
||||
<span class="q-number">{{ currentQuestion.id }}</span>
|
||||
<span class="q-title">{{ currentQuestion.question }}</span>
|
||||
<span v-if="scoreOf(currentQuestion) !== null" class="q-score">{{ scoreOf(currentQuestion) }}分</span>
|
||||
</div>
|
||||
<QuestionRenderer :question="currentQuestion" :type="currentType" />
|
||||
</div>
|
||||
<div class="switch-nav">
|
||||
<button class="btn-tilt btn-tilt-outline" :disabled="store.currentIndex <= 0" @click="prevQuestion()">
|
||||
<span class="btn-tilt-inner">上一题</span>
|
||||
</button>
|
||||
<span class="switch-indicator">{{ store.currentIndex + 1 }} / {{ store.totalQuestions }}</span>
|
||||
<button class="btn-tilt btn-tilt-accent" :disabled="store.currentIndex >= store.totalQuestions - 1" @click="nextQuestion()">
|
||||
<span class="btn-tilt-inner">下一题</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 连贯模式:整卷 -->
|
||||
<template v-else>
|
||||
<div v-for="(sec, si) in store.paper.sections" :key="si" class="section-block">
|
||||
<div class="section-header">
|
||||
<span>{{ sec.title }}</span>
|
||||
<span class="section-info">共{{ sec.questions.length }}题{{ sectionScoreText(sec) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="q in sec.questions"
|
||||
:key="q.id"
|
||||
class="question-item"
|
||||
:class="{ 'current-view': String(q.id) === store.currentQId }"
|
||||
:data-qid="q.id"
|
||||
>
|
||||
<div class="q-header">
|
||||
<span class="q-number">{{ q.id }}</span>
|
||||
<span class="q-title">{{ q.question || '' }}</span>
|
||||
<span v-if="q.score !== null && q.score !== undefined" class="q-score">{{ q.score }}分</span>
|
||||
</div>
|
||||
<QuestionRenderer :question="q" :type="sec.type" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<AnswerSheet />
|
||||
</div>
|
||||
|
||||
<!-- 交卷确认弹窗 -->
|
||||
<div v-if="showSubmitModal" class="modal-overlay" @click.self="showSubmitModal = false">
|
||||
<div class="modal-box">
|
||||
<div class="modal-header">确认交卷</div>
|
||||
<div class="modal-body">
|
||||
<p>您已完成 <strong>{{ store.answeredCount }}</strong>/{{ store.totalQuestions }} 题。</p>
|
||||
<p v-if="store.totalQuestions - store.answeredCount > 0" style="margin-top: 8px; color: var(--danger)">
|
||||
还有 {{ store.totalQuestions - store.answeredCount }} 题未作答!
|
||||
</p>
|
||||
<p style="margin-top: 8px">确定要提交试卷吗?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-tilt btn-tilt-outline" @click="showSubmitModal = false">
|
||||
<span class="btn-tilt-inner">继续答题</span>
|
||||
</button>
|
||||
<button class="btn-tilt btn-tilt-danger" @click="confirmSubmit()">
|
||||
<span class="btn-tilt-inner">确认交卷</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useExamStore, getExamPaper } from '@/stores/exam'
|
||||
import type { ExamQuestion, ExamSection, QuestionType } from '@/data/exams'
|
||||
import QuestionRenderer from '@/components/QuestionRenderer.vue'
|
||||
import AnswerSheet from '@/components/AnswerSheet.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useExamStore()
|
||||
|
||||
const showSubmitModal = ref(false)
|
||||
const answerArea = ref<HTMLElement | null>(null)
|
||||
|
||||
const timerText = computed(() => {
|
||||
if (store.timeLimitMinutes <= 0) return '不限时'
|
||||
const t = Math.max(0, store.timer)
|
||||
const h = Math.floor(t / 3600)
|
||||
const m = Math.floor((t % 3600) / 60)
|
||||
const s = t % 60
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
const currentQuestion = computed<ExamQuestion | null>(() => {
|
||||
if (!store.paper || !store.currentQId) return null
|
||||
for (const sec of store.paper.sections) {
|
||||
for (const q of sec.questions) {
|
||||
if (String(q.id) === store.currentQId) return q
|
||||
}
|
||||
}
|
||||
// 子题(材料/案例)无独立问题卡片,取父题
|
||||
const parent = store.paper.sections.find((sec) =>
|
||||
sec.questions.some((q) => {
|
||||
const mq = q as { subQuestions?: { id: string }[]; questions?: { id: string }[] }
|
||||
const subs = mq.subQuestions ?? mq.questions ?? []
|
||||
return subs.some((sq) => String(sq.id) === store.currentQId)
|
||||
}),
|
||||
)
|
||||
if (parent) {
|
||||
const q = parent.questions.find((qq) => {
|
||||
const mq = qq as { subQuestions?: { id: string }[]; questions?: { id: string }[] }
|
||||
const subs = mq.subQuestions ?? mq.questions ?? []
|
||||
return subs.some((sq) => String(sq.id) === store.currentQId)
|
||||
})
|
||||
return q ?? null
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const currentType = computed<QuestionType>(() => {
|
||||
if (!store.paper || !store.currentQId) return 'single_choice'
|
||||
for (const sec of store.paper.sections) {
|
||||
for (const q of sec.questions) {
|
||||
if (String(q.id) === store.currentQId) return sec.type
|
||||
const mq = q as { subQuestions?: { id: string; type?: string }[]; questions?: { id: string; type?: string }[] }
|
||||
const subs = mq.subQuestions ?? mq.questions ?? []
|
||||
const sub = subs.find((sq) => String(sq.id) === store.currentQId)
|
||||
if (sub) return (sub.type as QuestionType) || 'short_answer'
|
||||
}
|
||||
}
|
||||
return 'single_choice'
|
||||
})
|
||||
|
||||
function scoreOf(q: ExamQuestion): number | null {
|
||||
return q.score !== undefined ? q.score : null
|
||||
}
|
||||
|
||||
function sectionScoreText(sec: ExamSection): string {
|
||||
const hasNull = sec.questions.some((q) => q.score === null || q.score === undefined)
|
||||
if (hasNull) return ''
|
||||
const total = sec.questions.reduce((s, q) => s + (q.score || 0), 0)
|
||||
return `,合计${total}分`
|
||||
}
|
||||
|
||||
function typeLabel(t: QuestionType): string {
|
||||
const map: Record<QuestionType, string> = {
|
||||
single_choice: '单选',
|
||||
multi_choice: '多选',
|
||||
indefinite_choice: '不定项',
|
||||
true_false: '判断',
|
||||
fill_blank: '填空',
|
||||
matching: '连线',
|
||||
sorting: '排序',
|
||||
categorize: '归类',
|
||||
short_answer: '简答',
|
||||
material_analysis: '材料分析',
|
||||
drawing: '作图',
|
||||
case_analysis: '案例分析',
|
||||
writing: '写作',
|
||||
qa: '问答',
|
||||
essay: '作文',
|
||||
code_practice: '代码实操',
|
||||
}
|
||||
return map[t] ?? t
|
||||
}
|
||||
|
||||
function formatAnswer(q: { id: string; type: QuestionType }): string {
|
||||
const v = store.answers[q.id]
|
||||
if (v === undefined || v === null) return '—'
|
||||
if (Array.isArray(v)) return v.join(', ')
|
||||
if (typeof v === 'object') return JSON.stringify(v)
|
||||
return String(v)
|
||||
}
|
||||
|
||||
function prevQuestion() {
|
||||
const idx = store.currentIndex
|
||||
if (idx > 0) store.navigateTo(store.allQuestions[idx - 1].id)
|
||||
}
|
||||
|
||||
function nextQuestion() {
|
||||
const idx = store.currentIndex
|
||||
if (idx < store.totalQuestions - 1) store.navigateTo(store.allQuestions[idx + 1].id)
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
store.mode = store.mode === 'switch' ? 'continuous' : 'switch'
|
||||
if (store.mode === 'continuous' && store.currentQId) {
|
||||
scrollToCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
function openSubmitModal() {
|
||||
showSubmitModal.value = true
|
||||
}
|
||||
|
||||
function confirmSubmit() {
|
||||
showSubmitModal.value = false
|
||||
store.submit()
|
||||
}
|
||||
|
||||
function backHome() {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
function restart() {
|
||||
if (store.paper) store.startExam(store.paper)
|
||||
}
|
||||
|
||||
function scrollToCurrent() {
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(`q-${store.currentQId}`)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
})
|
||||
}
|
||||
|
||||
// 连贯模式滚动观察:更新当前题高亮
|
||||
let scrollCleanup: (() => void) | null = null
|
||||
|
||||
function setupScrollObserver() {
|
||||
if (scrollCleanup) scrollCleanup()
|
||||
const area = answerArea.value
|
||||
if (!area) return
|
||||
const onScroll = () => {
|
||||
if (store.mode !== 'continuous') return
|
||||
const items = area.querySelectorAll<HTMLElement>('.question-item')
|
||||
let current: string | null = null
|
||||
const mid = area.getBoundingClientRect().top + area.clientHeight * 0.3
|
||||
items.forEach((item) => {
|
||||
const rect = item.getBoundingClientRect()
|
||||
if (rect.top <= mid) current = item.getAttribute('data-qid')
|
||||
})
|
||||
if (current) store.navigateTo(current)
|
||||
}
|
||||
area.addEventListener('scroll', onScroll, { passive: true })
|
||||
scrollCleanup = () => area.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.mode,
|
||||
(m) => {
|
||||
if (m === 'continuous') {
|
||||
requestAnimationFrame(() => {
|
||||
setupScrollObserver()
|
||||
scrollToCurrent()
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
store.initTheme()
|
||||
const id = String(route.params.id ?? '')
|
||||
const paper = getExamPaper(id)
|
||||
if (!paper) {
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
store.startExam(paper)
|
||||
if (store.mode === 'continuous') {
|
||||
setupScrollObserver()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
store.stopTimer()
|
||||
scrollCleanup?.()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.exam-wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
padding: 0 24px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
border-bottom: 3px solid var(--accent-bright);
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.header-sub {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
font-weight: 400;
|
||||
}
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.timer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
padding: 4px 14px;
|
||||
border-radius: 2px;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.timer-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
.theme-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
display: flex;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 24px;
|
||||
gap: 20px;
|
||||
min-height: calc(100vh - 52px);
|
||||
width: 100%;
|
||||
}
|
||||
.answer-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.question-item {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-light);
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 2px;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.question-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.question-item.active-question {
|
||||
border-color: var(--accent-bright);
|
||||
border-left: 3px solid var(--accent-bright);
|
||||
}
|
||||
.question-item.current-view {
|
||||
box-shadow: inset 3px 0 0 var(--accent-bright);
|
||||
}
|
||||
.q-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.q-number {
|
||||
flex-shrink: 0;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
clip-path: polygon(3px 0, 100% 0, calc(100% - 3px) 100%, 0 100%);
|
||||
}
|
||||
.q-title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
font-weight: 500;
|
||||
}
|
||||
.q-score {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 8px;
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-block {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.section-header {
|
||||
background: var(--section-header-bg);
|
||||
color: var(--section-header-text);
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
clip-path: polygon(0 0, 100% 0, 100% calc(100% - 3px), calc(100% - 16px) 100%, 0 100%);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.section-info {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.switch-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-light);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.switch-nav .btn-tilt {
|
||||
clip-path: polygon(4px 0, 100% 0, calc(100% - 4px) 100%, 0 100%);
|
||||
}
|
||||
.switch-indicator {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.btn-tilt-outline {
|
||||
background: var(--option-bg);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 16px;
|
||||
clip-path: polygon(3px 0, 100% 0, calc(100% - 3px) 100%, 0 100%);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-tilt-outline:hover {
|
||||
border-color: var(--accent-bright);
|
||||
}
|
||||
.btn-tilt-outline:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-tilt-accent {
|
||||
background: var(--accent-bright);
|
||||
color: #fff;
|
||||
padding: 8px 24px;
|
||||
}
|
||||
.btn-tilt-accent:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.btn-tilt-accent:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
clip-path: polygon(0 0, 100% 0, 100% calc(100% - 6px), calc(100% - 20px) 100%, 0 100%);
|
||||
}
|
||||
.modal-header {
|
||||
padding: 14px 20px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.modal-footer {
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.result-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
.result-box {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-light);
|
||||
max-width: 860px;
|
||||
width: 100%;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
.result-icon {
|
||||
font-size: 48px;
|
||||
color: var(--success);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.result-meta {
|
||||
color: var(--text-muted);
|
||||
margin: 8px 0 20px;
|
||||
}
|
||||
.result-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.result-table th,
|
||||
.result-table td {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
.result-table th {
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.answer-cell {
|
||||
max-width: 320px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.status {
|
||||
padding: 2px 8px;
|
||||
border-radius: 2px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.status.ok {
|
||||
background: var(--success-light);
|
||||
color: var(--success);
|
||||
}
|
||||
.status.miss {
|
||||
background: var(--danger-light);
|
||||
color: var(--danger);
|
||||
}
|
||||
.result-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.main-layout {
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
}
|
||||
.header {
|
||||
padding: 0 12px;
|
||||
}
|
||||
.header-sub {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div class="home-wrap">
|
||||
<header class="home-header">
|
||||
<div class="home-brand">
|
||||
<span class="brand-badge">CCGO</span>
|
||||
<div>
|
||||
<h1>在线评测平台</h1>
|
||||
<p class="home-sub">校企合作 · 计算机素养评测</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="theme-toggle" @click="store.toggleTheme()" :title="store.theme === 'dark' ? '切换亮色' : '切换暗色'">
|
||||
{{ store.theme === 'dark' ? '☀' : '☾' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="home-main">
|
||||
<section class="hero">
|
||||
<h2>选择试卷开始作答</h2>
|
||||
<p class="hero-desc">支持单选、多选、判断、填空、连线、排序、归类、简答、材料分析、作图、案例、写作、问答、作文与代码实操等 15 类题型</p>
|
||||
</section>
|
||||
|
||||
<section class="exam-grid">
|
||||
<article v-for="exam in exams" :key="exam.id" class="exam-card">
|
||||
<div class="exam-card-head">
|
||||
<span class="exam-type">{{ exam.subtitle }}</span>
|
||||
<span class="exam-time">{{ exam.timeLimit > 0 ? exam.timeLimit + ' 分钟' : '不限时' }}</span>
|
||||
</div>
|
||||
<h3 class="exam-title">{{ exam.title }}</h3>
|
||||
<p class="exam-desc">{{ exam.description }}</p>
|
||||
<div class="exam-meta">
|
||||
<span>{{ totalCount(exam) }} 题</span>
|
||||
<span>{{ exam.sections.length }} 类题型</span>
|
||||
</div>
|
||||
<button class="exam-start" @click="startExam(exam)">开始答题 →</button>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="home-footer">GVSDS 银河锋锐软件开发工作室 · CCGO 校企合作</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { exams, type ExamPaper } from '@/data/exams'
|
||||
import { useExamStore } from '@/stores/exam'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useExamStore()
|
||||
|
||||
onMounted(() => {
|
||||
store.initTheme()
|
||||
})
|
||||
|
||||
function totalCount(exam: ExamPaper): number {
|
||||
return exam.sections.reduce((sum, sec) => sum + sec.questions.length, 0)
|
||||
}
|
||||
|
||||
function startExam(exam: ExamPaper) {
|
||||
router.push(`/exam/${exam.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.home-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 28px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.home-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.brand-badge {
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
padding: 6px 12px;
|
||||
clip-path: polygon(3px 0, 100% 0, calc(100% - 3px) 100%, 0 100%);
|
||||
}
|
||||
.home-brand h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.home-sub {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.theme-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.home-main {
|
||||
flex: 1;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px;
|
||||
width: 100%;
|
||||
}
|
||||
.hero {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.hero h2 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.hero-desc {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.exam-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.exam-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-light);
|
||||
border-left: 4px solid var(--accent-bright);
|
||||
padding: 24px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.exam-card:hover {
|
||||
box-shadow: var(--card-shadow-hover);
|
||||
}
|
||||
.exam-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.exam-type {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-bright);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.exam-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.exam-title {
|
||||
font-size: 22px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.exam-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.exam-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.exam-start {
|
||||
border: none;
|
||||
background: var(--accent-bright);
|
||||
color: #fff;
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.exam-start:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.home-footer {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.json",
|
||||
"include": ["vite.config.*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5175,
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 1500,
|
||||
// 构建产物收敛到 dist/CCGO/*(相对引用 CCGO/...),部署时同步到 TempLate/CCGO/
|
||||
assetsDir: 'CCGO',
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user