简介:这份基于 Vue3 与 Element Plus 的在线编程闯关挑战网站设计源码,面向需要学习 Vue 前端开发、希望搭建在线编程练习平台的学习者,适合有一定前端基础、想要通过完整项目提升实战能力的读者,可用于课程设计、毕业设计或项目自学。压缩包共 132 个文件,大小约 5.42MB,文件类型以 Vue 组件、JavaScript 脚本、SQL 数据库为主,同时包含 JSON 配置、CSS 样式、SVG 图标、PNG 与 JPG 图片等,覆盖从页面展示到数据存储的完整开发链路。已有 287 人学习下载。资源内除核心功能页面外,还提供登录页、首页效果截图与数据库脚本,便于对照理解闯关题目的读取、提交与结果反馈过程;目录划分较为清晰,适合在此基础上扩展新关卡或调整界面风格。
1. 在线编程闯关网站的 Vue3 落地路线:先想清楚评测,再谈界面
很多开发者在搜索引擎里找“基于Vue3和Element Plus的在线编程闯关挑战网站设计源码”,目的往往不是抄一套代码,而是搞清楚一个真实可运行的 OJ 类前端项目是怎么组织的。这类网站的界面难度并不高,真正卡壳的是:关卡数据放哪、解锁进度怎么管理、用户提交的代码怎么在页面上跑起来并返回判定结果。基于 Vue3 + Element Plus,前两件事能快速成型,最后一件事需要配一小段判题服务。下文按“环境骨架 → 关卡模型 → 编辑器交互 → 判题服务 → 工程化收尾”的顺序,把在线编程闯关网站的设计源码拆开讲,适合正在学 Vue3 做作品集、或需要给团队搭内部训练系统的前端开发者。
2. 从零配置 Vue3 与 Element Plus 环境:布局骨架与编辑器接入
标题里的 Elment 是 Element Plus 的笔误,搜索和安装时记得用正确拼写。一个在线编程闯关项目的前端工程,初始化动作并不复杂,麻烦的是后续把代码编辑器、路由、状态层接进来时版本对不上。先给出推荐的环境组合,能省掉大部分“装完跑不起来”的抱怨。
2.1 环境版本选择与 Vue3 安装:Vite 5 + Node 18 的推荐组合
Vite 5 要求 Node.js 18 及以上版本,如果你本机还在用 Node 16,建议先升级再用 nvm 管理版本。下表是我在搭建类似在线编程与 OJ 评测系统时的基准配置:
| 工具 | 推荐版本 | 说明 |
|---|---|---|
| Node.js | 18.x LTS | Vite 5 与 Element Plus 均要求 18+ |
| Vue | 3.4.x | Vite 官方模板默认版本 |
| Vite | 5.x | 开发服务器与构建工具 |
| Element Plus | 2.7.x | 当前稳定版,组件 API 与 Vue3 对齐 |
| CodeMirror 6 | 6.x | 在线编程编辑器内核 |
打开终端,用 npm 初始化 Vue3 工程并安装依赖:
npm create vite@latest code-quest -- --template vue cd code-quest npm install npm install vue-router@4 pinia npm install element-plus @element-plus/icons-vue npm install vue-codemirror codemirror @codemirror/lang-javascript @codemirror/lang-python命令里--template vue让 Vite 生成 Vue3 单文件组件模板,省去手动写入口文件。element-plus和图标库是并列的包,图标库负责提供菜单折叠、返回、播放等操作类图标;vue-codemirror是对 CodeMirror 6 的 Vue3 封装,后面章节会用它搭在线编辑器。
2.2 集成 Element Plus:完整引入还是按需引入
开发前期建议直接在入口文件完整引入 Element Plus,理由是少配置、少报错,等最后做工程化收尾再换成按需加载。完整引入的 main.js 这样写:
import { createApp } from 'vue' import { createPinia } from 'pinia' import ElementPlus from 'element-plus' import zhCn from 'element-plus/es/locale/lang/zh-cn' import 'element-plus/dist/index.css' import * as ElementPlusIconsVue from '@element-plus/icons-vue' import App from './App.vue' import router from './router' const app = createApp(App) for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component) } app.use(createPinia()) app.use(router) app.use(ElementPlus, { locale: zhCn }) app.mount('#app')这里逐层说明:app.use(ElementPlus, { locale: zhCn })会把全部组件注册到全局,同时把分页器、日期选择这类组件的文案切成中文;图标组件通过遍历ElementPlusIconsVue注册,这样模板里可以直接写<el-icon><Back /></el-icon>而不需要逐个 import。createPinia()和router要先于mount注册,否则组件里的useStore()和useRoute()会拿不到实例。
2.3 页面骨架与路由设计:侧边关卡栏 + 主操作区
在线编程闯关网站的空间布局基本固定:左侧窄栏放关卡列表和进度,右侧主区域放题目描述与代码编辑器。路由配置按功能模块拆成两个页面,一个管关卡列表,一个管关卡详情与编辑器:
import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', component: () => import('@/views/Layout.vue'), children: [ { path: '', redirect: '/levels' }, { path: 'levels', component: () => import('@/views/LevelList.vue') }, { path: 'level/:id', component: () => import('@/views/LevelDetail.vue') } ] } ] export default createRouter({ history: createWebHistory(), routes })Layout.vue是壳组件,左侧用el-aside嵌套el-menu渲染关卡导航,右侧el-main放router-view。level/:id这种动态路由是关键,进入关卡详情后组件里用useRoute().params.id去加载对应的关卡配置。深度嵌套的/levels和/level/:id对应了“先看列表再做题”的自然流程,也能让浏览器的前进后退行为符合直觉。
2.4 CodeMirror 编辑器接入:最短路径跑通“能写代码”
先做一个最小编辑器组件,不接题目数据,只验证 CodeMirror 在 Vue3 里能正常输入和取值。这个组件是后面所有在线编程功能的基础:
<template> <div class="editor-box"> <Codemirror v-model="code" placeholder="在此输入代码" :extensions="extensions" :style="{ height: '100%' }" /> </div> </template> <script setup> import { ref, shallowRef } from 'vue' import { Codemirror } from 'vue-codemirror' import { javascript } from '@codemirror/lang-javascript' const code = ref('function twoSum(nums, target) {\n // 在这里实现\n}') const extensions = [javascript()] </script>v-model双向绑定用户编辑内容,extensions是 CodeMirror 6 的语言扩展数组,后续切换 Python、JavaScript 语法时只需要替换这个数组。组件内的code变量会在用户停笔时自动更新,后面提交判题时直接读它即可。
3. 闯关关卡的数据模型与 Pinia 状态管理:从关卡配置到进度持久化
在线编程闯关网站的内容核心是关卡配置:题目描述、模板代码、测试用例、难度、解锁条件。这些数据如果散落在各个组件里,后期加一个关卡要改三四个文件。这个章节先把关卡资源组织成一份份 JSON,再用 Pinia 把跳关、解锁、通过记录管起来。
3.1 关卡资源的 JSON 结构:关卡信息、模板代码与测试用例
每个关卡建议用一个独立模块文件描述,约定字段名,前端渲染和判题接口都遵循这个结构:
// src/levels/py-01-two-sum.js export default { id: 'py-01', title: '两数之和', difficulty: 1, category: '数组', language: 'python', description: `给定一个整数数组 nums 和一个目标值 target, 请你在数组中找出和为目标值的那两个整数,并返回它们的下标。`, template: `def twoSum(nums, target): # 在这里编写代码 pass`, solution: `def twoSum(nums, target): seen = {} for i, num in enumerate(nums): if target - num in seen: return [seen[target - num], i] seen[num] = i`, testCases: [ { input: [[2, 7, 11, 15], 9], expected: [0, 1] }, { input: [[3, 2, 4], 6], expected: [1, 2] }, { input: [[3, 3], 6], expected: [0, 1] } ] }language字段是编辑器切换语法的依据;template是用户进入关卡后编辑器里的初始代码,设计上要比solution少关键几行,既给了思考起点又不至于直接暴露答案;testCases里的input是数组,判题时展开后作为函数参数传入,expected是标准输出。这里有个容易踩的约定:input里的第一个元素如果是数组类型,传参时要特别注意语言之间的类型差异,JavaScript 的[2, 7, 11, 15]到 Python 里就是 list,语义一致,但判题函数内部要做一次深比较,不能只做===浅比较。
3.2 拿到设计源码后,从哪些文件定位关卡配置与判题接口
如果你手头已经有一套“在线编程闯关挑战网站设计源码”,先不要急着看页面组件,按下面顺序找:打开package.json看依赖,确认是纯前端还是前后端分离;接着看src/router的页面路径,找到展示关卡列表的那个视图;再去src/api或src/levels目录搜索testCases关键字,通常能找到全部关卡数据。
常见做法是关卡数据由后端接口下发,前端通过/api/levels拉取,这样新增关卡不用发版。但纯前端的“设计源码”会把关卡放在src/levels目录里,图片素材和题目描述往往也一起放。判题接口如果存在,一般叫/api/judge或/api/submit,在后端项目里搜judge或runCode就能定位。理解这两处的位置,整个源码的阅读成本会低很多。
3.3 Pinia 管理解锁状态与 localStorage 双写
闯关机制最简单的实现是“通过当前关解锁下一关”,用 Pinia 保存通关记录,同时写入 localStorage 防止刷新丢失。下面这个 store 覆盖了最常见的状态管理诉求:
import { defineStore } from 'pinia' const STORAGE_KEY = 'code-quest-progress' export const useLevelStore = defineStore('level', { state: () => ({ currentLevelId: null, progress: JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') }), getters: { isPassed: (state) => (id) => !!state.progress[id]?.passed, isUnlocked: (state) => (id) => { return true } }, actions: { markPassed(id) { this.progress[id] = { passed: true, passedAt: Date.now() } localStorage.setItem(STORAGE_KEY, JSON.stringify(this.progress)) } } })这段代码的关键在于progress的状态初始化,直接从localStorage读,避免页面刷新后全部重置。markPassed写入通关时间和标记后立即落本地存储,这样下次打开网站仍能恢复进度。你做闯关解锁规则时,可以在isUnlocked里读上一个关卡的passed状态,形如return index === 0 || isPassed(prevId),业务语义就完全收口在 store 层。
3.4 Element Plus 渲染关卡列表:卡片、步骤条与锁定的组合
关卡列表的常见形式是用el-card包裹每个关卡,左上角显示序号,中间显示标题和分类标签,右下角显示通过状态。加一个el-steps放在列表顶部,可以把整体闯关进度直观展示出来:
<template> <div class="level-list"> <el-steps :active="passedCount" align-center> <el-step v-for="level in levels" :key="level.id" :title="level.title" /> </el-steps> <el-row :gutter="12"> <el-col v-for="(level, index) in levels" :key="level.id" :span="8"> <el-card :class="{ locked: !store.isUnlocked(level.id) }" @click="goLevel(level.id, index)" > <template #header> <span>第 {{ index + 1 }} 关</span> <el-tag>{{ level.category }}</el-tag> </template> <p>{{ level.title }}</p> <el-icon v-if="store.isPassed(level.id)"><SuccessFilled /></el-icon> </el-card> </el-col> </el-row> </div> </template>el-steps的active绑定通过关卡数量,起到进度条作用;el-card上的lockedclass 用来做半透明遮罩,点击时判断isUnlocked再决定跳转还是弹出ElMessage提示。注意el-col的span可以根据关卡数量调整,数量多时改成:span="6",一屏显示 4 个。
4. 在线编程编辑器的交互实现:语言切换、快捷键与判题结果展示
编辑器外壳搭好、关卡数据就位之后,这一章把编辑器从“能打字”升级成“能做题”:语法跟随关卡语言切换、Ctrl+Enter 触发运行、判题结果用 Element Plus 的通知组件反馈给用户。交互体验直接决定这个闯关网站是“玩具”还是“可用的练习平台”。
4.1 按关卡语言切换 CodeMirror 语法扩展
不同关卡使用的语言可能不同,编辑器要根据level.language动态加载对应的语法扩展。用computed来组合扩展数组是 Vue3 里最直观的做法:
import { computed } from 'vue' import { javascript } from '@codemirror/lang-javascript' import { python } from '@codemirror/lang-python' import { oneDark } from '@codemirror/theme-one-dark' const props = defineProps({ language: { type: String, default: 'javascript' }, modelValue: { type: String, default: '' } }) const extensions = computed(() => { const exts = [] if (props.language === 'python') { exts.push(python()) } else { exts.push(javascript()) } return exts })编辑器组件本身不直接修改modelValue,而是通过update:modelValue事件触发父组件的v-model更新,保持单向数据流。oneDark主题只有在技能偏好为暗色时引入,否则在亮色页面里会显得突兀。
4.2 提交运行的快捷键:Ctrl+Enter 与工具栏按钮
在线编程场景里,“运行”是最频繁的操作,除了工具栏按钮,还要支持Ctrl+Enter。在Codemirror组件上监听keydown事件,取到按键组合后触发同一段运行逻辑,可以避免按钮和快捷键行为分叉:
function handleKeydown(cm, event) { if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { event.preventDefault() runCode() } } function runCode() { running.value = true emit('run', props.modelValue) }metaKey是为了兼容 Mac 键盘上的 Command 键,否则用户用 Mac 按Cmd+Enter没有反应,这个细节会直接影响 Mac 用户的做题体验。emit('run')把当前用户代码抛给父组件,父组件负责调用后端判题接口;这样编辑器组件不关心网络请求,职责单一,测试时也容易 mock。Edge 浏览器上偶尔会遇到键盘事件被浏览器插件抢占的情况,判断里加上event.preventDefault()后能规避大部分冲突。
4.3 判题结果展示:El-Result、El-Alert 与 El-Dialog 的组合
判题结果通常在编辑器下方直接展示,全部通过时给一个大的成功标识,失败时给出具体用例编号。用el-result做成功态,用el-alert做失败态,这种组合比“弹窗 + Toast”更适合闯关场景,因为用户可以边改代码边盯结果区,不用反复关闭弹窗:
<template> <div class="judge-result"> <el-result v-if="result && result.status === 'PASS'" icon="success" title="挑战成功" sub-title="所有测试用例均已通过" /> <el-alert v-else-if="result && result.status === 'FAIL'" :title="`失败于第 ${result.failedCase} 个用例`" type="error" :description="`期望: ${result.expected},实际得到: ${result.actual}`" show-icon closable /> </div> </template>判题结果对象result里至少包含status、failedCase、expected和actual四个字段。FAIL状态下把期望值和实际值并列展示,用户定位问题的时间会明显缩短。el-result的 sub-title 可以放一句“点击下一关继续挑战”的引导文案,闯关的连续性就补上了。
5. 判题评测怎么做:Node.js 本地评测服务与测试用例对比协议
在线编程与 OJ 评测系统的核心是三件事:收代码、跑用例、比输出。前端页面只负责收集用户代码和展示结果,真正的判题动作必须放到服务端。这里用 Node.js 写一个轻量评测服务,演示 JavaScript 与 Python 两种语言的判题思路。注意这是教学级实现,生产环境要做容器级隔离。
5.1 为什么判题不能全部放在前端完成
浏览器里虽然能用 Web Worker 跑 JavaScript,但存在两个硬伤:一是用户可以打开开发者工具直接看请求和源码,绕过评测逻辑;二是 Python 这类语言无法在浏览器里原生执行。即使是纯 JavaScript 关卡,如果把判题逻辑放到前端,用户用fetch拿不到服务端业务数据,那这个闯关网站就退化成“不能验证的静态代码输入框”。
我一般会在项目里单独开一个server/目录,用 Express 提供两个接口:POST /api/judge统一收评测请求,内部按语言分发;GET /api/levels返回关卡配置。下面这段代码是 JavaScript 语言判题的核心逻辑,重点在node:vm模块的用法:
const express = require('express') const vm = require('node:vm') const app = express() app.use(express.json({ limit: '64kb' })) app.post('/api/judge', (req, res) => { const { code, language, testCases } = req.body if (language !== 'javascript') { return res.status(400).json({ error: 'unsupported language' }) } const result = testCases.map((tc, index) => { try { const wrapped = `${code}\n;JSON.stringify(fn(...${JSON.stringify(tc.input)}));` const output = vm.runInContext(wrapped, vm.createContext({}), { timeout: 2000 }) const actual = JSON.parse(output) const passed = JSON.stringify(actual) === JSON.stringify(tc.expected) return { index, passed, actual, expected: tc.expected } } catch (err) { return { index, passed: false, error: err.message } } }) res.json({ status: result.every(r => r.passed) ? 'PASS' : 'FAIL', result }) }) app.listen(3000, () => console.log('judge server on :3000'))timeout: 2000表示每个用例最多执行 2 秒,防止用户代码里写while (true) {}拖垮进程;但要注意timeout只对同步代码生效,Promise或setTimeout不会被中断。JSON.stringify(tc.input)把测试用例的输入参数转换成代码字面量,...展开后作为函数参数传入,这里拼接的是开发者在关卡里写好的数据,不是用户输入,所以没有注入风险。如果用户代码里有require或process,vm.createContext({})创建的空上下文会直接抛ReferenceError,这也是一个轻量隔离的体现。
提示:
node:vm不能当作安全沙箱使用,它只做执行环境隔离,无法防御资源耗尽型攻击。生产环境应当把判题服务部署在容器中,用 cgroup 限制 CPU 和内存。
5.2 Python 判题的独立进程方案与超时清理
Python 没有浏览器内置运行时,判题时必须调用系统 Python 解释器。常见做法是先把用户代码和测试用例拼成临时 Python 文件,再用child_process.spawn启动子进程执行。超时和僵尸进程是需要特别处理的两个点:
const { spawn } = require('child_process') const fs = require('fs') const os = require('os') const path = require('path') app.post('/api/judge/python', (req, res) => { const { code, testCases } = req.body const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-quest-')) const scriptFile = path.join(tmpDir, 'main.py') const runner = `${code} import json cases = ${JSON.stringify(testCases)} for i, c in enumerate(cases): result = two_sum(*c['input']) print(json.dumps({'case': i, 'actual': result})) ` fs.writeFileSync(scriptFile, runner) const child = spawn('python3', [scriptFile], { timeout: 3000 }) const stdout = [] child.stdout.on('data', chunk => stdout.push(chunk.toString())) child.on('error', err => res.status(500).json({ error: err.message })) child.on('close', code => { fs.rmSync(tmpDir, { recursive: true, force: true }) if (code === 0) { res.json({ status: 'PASS', output: stdout.join('') }) } else { res.json({ status: 'RUNTIME_ERROR', output: stdout.join('') }) } }) })mkdtempSync创建临时目录,判题结束后rmSync强制清理,避免用户代码文件堆积在服务器上占用空间。timeout: 3000是spawn自带的超时选项,但要注意 Python 的死循环在超时后进程可能没有立即退出,更稳妥的做法是在close事件里检查退出码的同时,调用child.kill('SIGKILL')确保进程树被清理干净。如果用户代码里存在语法错误,Python 会在启动阶段就输出到 stderr 并退出,此时拿到的 stdout 为空,把 stderr 也收集起来返回前端,排查问题会快很多。
JavaScript 与 Python 判题方式的对比如下:
| 语言 | 执行方式 | 超时处理 | 典型风险 |
|---|---|---|---|
| JavaScript | node:vm 进程内执行 | 同步代码用 timeout 参数 | 异步死循环无法截断 |
| Python | spawn 独立子进程 | timeout + kill SIGKILL | 死循环占 CPU,需强制杀进程 |
5.3 评测结果协议:把五个状态码固化成前后端约定
判题接口的返回格式如果不提前约定,前端展示和后端实现会各自发明一套,联调时浪费时间。我在多个项目里用过这套状态码,稳定且语义清晰:
| 状态码 | 含义 | 前端展示 |
|---|---|---|
| PASS | 所有用例通过 | el-result 成功提示 |
| FAIL | 有用例未通过 | 显示失败用例与期望值 |
| TIMEOUT | 执行超过时限 | 提示检查死循环 |
| RUNTIME_ERROR | 运行异常 | 显示异常堆栈 |
| SYSTEM_ERROR | 评测服务自身出错 | 提示稍后重试 |
这个协议表放进项目 README 或接口文档里,后端和前端照着实现,能少一半沟通成本。
6. 工程化收尾:Markdown 渲染、按需引入与评测链路验证
前面的功能链路已经能跑通,这章补三个上线前值得做的事:题目描述用 Markdown 渲染但禁用 HTML,Element Plus 切换到按需引入以压缩体积,以及用三组 curl 验证整个评测链路。在线编程网站的题目描述往往包含换行和代码块,直接渲染description字段里的换行符会很难看。
6.1 关卡描述用 markdown-it 渲染并禁用 HTML
安装 markdown-it,把题目描述从纯文本升级成带格式的富文本说明:
npm install markdown-itimport MarkdownIt from 'markdown-it' const md = new MarkdownIt({ html: false, linkify: true }) const renderDescription = (description) => md.render(description)html: false会转义<script>和<iframe>,防止关卡描述里嵌入恶意 HTML;linkify: true会允许 URL 自动变成链接,方便引用外部资料。渲染出来的 HTML 在组件内用v-html输出,但要给外层容器加一个 scoped 样式,把code、pre标签的字体和背景统一,否则 Element Plus 的全局样式可能把题目里的行内代码渲染得很大。
6.2 Vue3 性能优化:按需引入 Element Plus 与路由懒加载
完整引入 Element Plus 在开发期很方便,但打包后样式和组件体积会明显偏大。切换到自动按需引入插件,能只打包用到的组件和样式:
npm install -D unplugin-auto-import unplugin-vue-componentsimport AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default { plugins: [ AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ] }配置两个插件后,模板里写的el-button会被解析成ElementPlusButton并自动引入对应样式。注意原 main.js 里app.use(ElementPlus)要删掉,否则按需引入不生效。路由懒加载在前面章节已经用() => import()处理过,这一章检查一下编辑器页面是否拆出来了,避免把 Codemirror 打进首屏 bundle 拖慢加载。
6.3 上线前验证:三组 curl 检查判题链路与异常处理
本地起服务后,用三组 curl 快速验证判题接口的稳定性。第一组是正常通过场景:
curl -X POST http://localhost:3000/api/judge \ -H 'Content-Type: application/json' \ -d '{ "code": "function fn(a, b) { return a + b }", "language": "javascript", "testCases": [ { "input": [1, 2], "expected": 3 } ] }'第二组验证运行时报错能否被捕获:
curl -X POST http://localhost:3000/api/judge \ -H 'Content-Type: application/json' \ -d '{ "code": "function fn() { throw new Error(\"boom\") }", "language": "javascript", "testCases": [{ "input": [], "expected": 1 }] }'第三组验证超时限制是否生效,代码里写while(true){}再观察 2 秒后返回的状态码。如果返回 JSON 里result[0].error包含ERR_SCRIPT_EXECUTION_TIMEOUT,说明超时逻辑生效;如果接口长时间不返回,说明timeout没有挡住同步死循环,需要回到 5.1 节检查vm.runInContext的传参是否正确。一次把这三组命令跑通,判题链路的基本可靠性就有保障了。
本文还有配套的精品资源,点击获取