feat(ccgo): 新增 Vue3 在线做题网站(15 类题型引擎)

迁移旧版题库 EXAM_DATA,实现单选/多选/判断/填空/连线/排序/归类/材料/作图/写作/代码等题型引擎,Pinia 作答状态、计时器、答题卡与交卷结果页,deploy.ps1 一键部署
This commit is contained in:
TermiNexus committed 2026-08-11 01:41:38 +08:00
commit 3d88c71e04
29 files changed
+5043

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# 依赖与构建产物
node_modules/
dist/
# 日志与临时文件
*.log
.DS_Store
# 编辑器
.vscode/
.idea/
+157
View File
@@ -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
Vendored
+7
View File
@@ -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
View File
@@ -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>
+1717
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
<template>
<router-view />
</template>
<script setup lang="ts">
// 根组件:仅承载路由出口
</script>
+177
View File
@@ -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);
}
+157
View File
@@ -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>
+37
View File
@@ -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>
+158
View File
@@ -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>
+115
View File
@@ -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>
+395
View File
@@ -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
View File
@@ -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')
+33
View File
@@ -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
+392
View File
@@ -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 }
+663
View File
@@ -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>
+208
View File
@@ -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>
+14
View File
@@ -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/*"]
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@vue/tsconfig/tsconfig.json",
"include": ["vite.config.*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"types": ["node"]
}
}
+24
View File
@@ -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',
},
})