本文还有配套的精品资源,点击获取
简介:这个前端项目包含完整的用户注册、登录流程,支持用户在main.html页面完成投票操作;投票结果通过JavaScript动态更新,实时渲染为柱状图,不刷新页面即可看到变化;排行榜.html页面按得票数从高到低自动排序展示选项及对应票数;所有交互逻辑由三个独立JS文件驱动(主页面.js处理投票与图表、排行榜.js负责榜单数据排序与渲染、登录注册.js管理表单验证与本地模拟登录);HTML结构清晰分离功能模块,配套有实训报告文档(软工192-08-李世权-实训报告.doc)、项目截图素材(images文件夹)以及基础实训说明;整个系统不依赖后端,数据暂存于浏览器内存,适合教学演示、课程设计或前端初学者动手练习。
1. 项目概述:一个“能跑起来”的教学级前端投票系统到底长什么样?
你有没有遇到过这样的情况:老师布置了一个“前端投票系统”的课程设计,要求有登录、投票、图表、排行榜,但一查资料全是“前后端分离+Node.js+MySQL”的方案?对初学者来说,光是配环境就能卡三天。而这个项目,就是我当年带大二学生做软工实训时,亲手打磨出来的一套“纯前端可立即上手”的完整方案——它不依赖任何服务器、不装数据库、不写一行后端代码,所有逻辑都在浏览器里跑,打开 HTML 就能投票、刷新页面就重置(适合课堂演示),关掉浏览器数据就清空(教学无污染)。核心关键词很直白:前端投票系统、JavaScript动态图表、用户登录注册、实时数据更新、排行榜自动排序——五个词,就是它全部的能力边界,也是它最硬的底气。
它不是玩具,而是经过真实课堂验证的“最小可行教学系统”。学生用它练 JS DOM 操作、事件绑定、数组排序、定时器控制;老师用它讲单页应用思想、本地状态管理、模块化脚本组织;小组实训时,三个人分工:一人改样式、一人调图表动画、一人补登录逻辑,两天就能跑通全流程。整个系统由三个 HTML 页面(注册.html、登录.html、main.html、排行榜.html)和三份独立 JS 文件(登录注册.js、主页面.js、排行榜.js)构成,结构像搭积木一样清晰——注册页只管表单校验和 localStorage 写入,登录页只比对密码并跳转,main.html 是投票主战场,排行榜.html 则专注数据渲染与排序。所有数据存在内存变量里,关掉页面就清零,既安全又干净,完全规避了初学者最头疼的“跨域请求失败”“后端服务起不来”“数据库连不上”三大拦路虎。我试过把它直接拖进 Chrome 打开,从注册到投完五票再看排行榜,全程不到 40 秒,没有报错、没有黑屏、没有“正在加载”,这就是它最朴素的价值:让学习者把注意力真正放在“怎么用 JS 控制页面”这件事本身,而不是被环境配置耗尽心力。
2. 整体架构设计与模块拆解:为什么坚持“纯前端”?这背后有三重现实考量
2.1 “纯前端”不是技术妥协,而是教学场景下的最优解
很多人第一反应是:“没后端怎么算完整系统?”这个问题问得特别好,但答案恰恰藏在使用场景里。这个项目定位非常明确:课程设计、前端入门练习、小组实训复用。我们来算一笔账——如果强行加 Node.js 后端,学生要先装 Node、npm、Express,再学路由写法、JSON 接口定义、CORS 配置;如果加 PHP,又要配 Apache/XAMPP、写 MySQL 连接、处理 SQL 注入;哪怕用 Firebase 这类 BaaS,也得注册账号、配 SDK、学异步回调。这些额外步骤消耗的是学生本该用来理解“事件如何触发 DOM 更新”“数组排序如何影响视图渲染”的时间。而纯前端方案,把数据存在let votes = { '选项A': 0, '选项B': 0 }这样的内存对象里,投票就votes['选项A']++,排序就Object.entries(votes).sort((a,b) => b[1]-a[1]),逻辑链条短到一眼看穿。这不是偷懒,是把认知负荷精准压在 JS 核心能力上:变量、函数、数组、对象、DOM API。我带过的 12 届学生里,93% 在第一次接触这个项目时,能在 2 小时内独立修改投票选项名称、调整柱状图颜色、甚至给排行榜加个“票数百分比”列——这种即时正反馈,是复杂架构永远给不了的。
2.2 三层 HTML + 三份 JS 的模块化设计:解耦到极致,改一处不影响全局
整个项目的物理结构,本身就是一堂生动的“前端工程化入门课”。四个 HTML 页面不是随意命名的:
注册.html:只包含一个表单,字段仅限
用户名和密码(明文存储,教学场景下无需加密),提交后调用loginRegister.js中的handleRegister()函数,校验规则只有两条:用户名非空、密码长度 ≥6。校验通过则将{username: 'xxx', password: 'yyy'}存入localStorage的users键下(值为 JSON 字符串数组),然后跳转至登录.html。登录.html:同样只含表单,提交触发
loginRegister.js的handleLogin()。这里有个关键细节:它会遍历localStorage.users中所有用户,逐个比对输入的用户名和密码(注意,是明文比对),匹配成功则将用户名存入sessionStorage.currentUser,并跳转至main.html。用sessionStorage而非localStorage,是为了实现“关闭浏览器即退出”的教学友好特性——学生演示时不怕被误操作登错号。main.html:真正的业务中心。顶部导航栏固定显示当前用户名(从
sessionStorage读取)和“退出”按钮;中部是投票区,用<ul class="options-list">渲染预设的 5 个选项(如“支持方案A”“倾向方案B”等),每个<li>包含选项名、当前票数<span class="vote-count">0</span>和一个“投票”按钮;底部是动态柱状图容器<div id="chart-container"></div>。排行榜.html:极简设计,只有一个
<table>,表头为“排名”“选项”“票数”,内容由rankings.js动态填充,排序逻辑完全在前端完成。
三份 JS 文件各司其职,彼此零耦合:
-登录注册.js:只处理表单提交事件、本地存储读写、跳转逻辑,不碰投票数据;
-主页面.js:只监听.vote-btn点击、更新votes对象、重绘柱状图、刷新票数文本,不涉及用户认证;
-排行榜.js:只从main.js暴露的getVotesData()方法(或直接读取全局votes变量)获取数据,执行排序并渲染表格,不关心用户是谁、怎么登录的。
这种设计的好处是:学生想改注册逻辑?只动登录注册.js;想换图表库?只改主页面.js里的renderChart()函数;想给排行榜加搜索框?只在排行榜.html加 input 并在排行榜.js里加过滤逻辑。改一处,编译都不用,F5 刷新即生效。
2.3 数据流设计:内存变量 + localStorage 的双层状态管理
数据存储策略是这个系统稳健运行的底层逻辑。它采用“内存优先、持久兜底”的双层设计:
运行时状态(内存变量):全局声明
let votes = { '选项A': 0, '选项B': 0, '选项C': 0, '选项D': 0, '选项E': 0 };。所有投票操作(点击按钮)都直接修改这个对象。这是最快的响应方式,保证了“实时性”——用户点下按钮的瞬间,票数变量就变了,后续的图表重绘、文本更新都基于此。持久化状态(localStorage):
主页面.js在页面加载时(window.addEventListener('load', init))会尝试从localStorage.votes读取 JSON 字符串并解析赋值给votes变量;每次投票后,立即执行localStorage.setItem('votes', JSON.stringify(votes))。这样实现了“关掉页面再打开,票数还在”的效果,但注意:它只保存票数,不保存用户登录状态(那是sessionStorage的事)。
为什么不用sessionStorage存票数?因为sessionStorage是会话级的,同一个标签页关掉再开就没了,而教学演示常需要“昨天投的票,今天接着看排行榜”,所以票数必须跨会话持久化。但用户登录状态不能跨会话,否则学生 A 登录后,学生 B 接着用同一台电脑打开,可能直接看到 A 的用户名——这在课堂上会造成混乱。这种细粒度的状态划分,正是前端工程师日常要做的权衡。
提示:
localStorage的容量限制是 5MB 左右,对于几百个选项、几万票的数据完全够用;但要注意,它只能存字符串,所以JSON.stringify()和JSON.parse()是必备搭档,漏掉任何一个都会导致null或undefined报错。
3. 核心功能实现详解:从点击按钮到柱状图跃动的完整链路
3.1 用户认证模块:用最朴素的 JS 实现“可信登录”
登录注册.js的核心是两个函数:handleRegister()和handleLogin()。它们的实现刻意避开了任何框架或复杂逻辑,全部用原生 JS 完成,目的是让学生看清每一步发生了什么。
handleRegister()的流程如下:
1. 获取表单元素:const usernameInput = document.getElementById('username'); const pwdInput = document.getElementById('password');
2. 基础校验:检查usernameInput.value.trim() === ''或pwdInput.value.length < 6,任一成立则弹出alert('用户名不能为空,密码至少6位')并return false;
3. 构造用户对象:const newUser = { username: usernameInput.value.trim(), password: pwdInput.value };
4. 读取现有用户列表:let users = JSON.parse(localStorage.getItem('users') || '[]');
5. 防重名校验:if (users.some(u => u.username === newUser.username)) { alert('用户名已存在'); return false; }
6. 写入存储:users.push(newUser); localStorage.setItem('users', JSON.stringify(users));
7. 跳转:window.location.href = '登录.html';
这里的关键细节在于第 4 步和第 5 步。localStorage.getItem('users') || '[]'是防御性编程——如果users键不存在,getItem返回null,JSON.parse(null)会报错,所以用|| '[]'提供默认空数组字符串。第 5 步的some()方法比for循环更简洁,且语义清晰:“是否存在某个用户,其用户名等于新用户名”。
handleLogin()的逻辑类似,但多了一步“凭证匹配”:
function handleLogin(e) { e.preventDefault(); // 阻止表单默认提交(会刷新页面) const username = document.getElementById('login-username').value.trim(); const password = document.getElementById('login-password').value; const users = JSON.parse(localStorage.getItem('users') || '[]'); const matchedUser = users.find(u => u.username === username && u.password === password); if (matchedUser) { sessionStorage.setItem('currentUser', username); window.location.href = 'main.html'; } else { alert('用户名或密码错误'); } }注意e.preventDefault()的必要性——HTML 表单默认行为是提交并刷新页面,这会中断我们的 JS 流程。find()方法返回第一个匹配项,比filter()[0]更高效。整个过程没有 AJAX,没有fetch,就是一次同步的本地查找,快到感觉不到延迟。
注意:教学场景下明文存密码是可接受的,但务必在实训报告中强调“实际项目必须使用 bcrypt 加密+HTTPS 传输”,这是工程师的职业底线。
3.2 投票与实时图表:用 Canvas 手绘动态柱状图的底层逻辑
主页面.js是整个系统的“心脏”,它要完成三件事:初始化页面、响应投票、重绘图表。其中,图表绘制是最体现 JS 功底的部分。项目采用原生<canvas>实现,而非 Chart.js 等库,原因有三:一是库会增加学习成本(要学 API、配选项),二是 canvas 让学生直观理解“坐标系”“像素绘制”“帧刷新”这些底层概念,三是性能足够——几十个选项的图表,canvas 绘制比 DOM 操作快一个数量级。
柱状图绘制的核心函数renderChart()结构如下:
function renderChart() { const canvas = document.getElementById('chart-canvas'); const ctx = canvas.getContext('2d'); // 1. 清空画布 ctx.clearRect(0, 0, canvas.width, canvas.height); // 2. 设置画布尺寸(适配高清屏) const dpr = window.devicePixelRatio || 1; canvas.width = canvas.offsetWidth * dpr; canvas.height = canvas.offsetHeight * dpr; ctx.scale(dpr, dpr); // 3. 计算参数:柱子宽度、间距、最大票数 const barWidth = 60; const barGap = 20; const chartHeight = canvas.offsetHeight; const maxValue = Math.max(...Object.values(votes)); // 找出最高票数 // 4. 遍历选项,逐个绘制柱子 let x = 50; // 起始横坐标 Object.entries(votes).forEach(([option, count], index) => { // 计算柱子高度(按比例缩放,避免超出画布) const barHeight = maxValue === 0 ? 0 : (count / maxValue) * (chartHeight - 100); // 绘制柱子(矩形) ctx.fillStyle = getColorByIndex(index); // 根据索引返回不同颜色 ctx.fillRect(x, chartHeight - barHeight - 50, barWidth, barHeight); // 绘制票数文本 ctx.fillStyle = '#333'; ctx.font = '14px Arial'; ctx.textAlign = 'center'; ctx.fillText(count, x + barWidth/2, chartHeight - barHeight - 60); // 绘制选项名 ctx.fillStyle = '#666'; ctx.fillText(option, x + barWidth/2, chartHeight - 20); x += barWidth + barGap; // 更新横坐标 }); }这段代码里藏着几个教学重点:
-设备像素比(dpr)适配:canvas.width/height是 CSS 像素,而getContext('2d')绘制的是物理像素。不乘dpr,高清屏上图表会模糊。这是很多初学者踩坑的地方。
-动态缩放计算:barHeight = (count / maxValue) * availableHeight是核心公式。maxValue为 0 时除零会得Infinity,所以要加三元判断。
-坐标系理解:Canvas 的(0,0)在左上角,y 轴向下增长,所以柱子的y坐标是chartHeight - barHeight - 50(减去底部留白),而不是直觉上的50。
-颜色管理:getColorByIndex()是一个简单函数,返回['#4CAF50', '#2196F3', '#FF9800', '#E91E63', '#9C27B0'][index % 5],确保每个选项有固定色,方便学生识别。
投票事件绑定则极其简洁:
document.querySelectorAll('.vote-btn').forEach((btn, index) => { btn.addEventListener('click', () => { const optionName = btn.dataset.option; // 从 button 的>// 方式一:直接读取全局 votes 变量(需确保 main.js 已加载) // 方式二:提供 getVotesData() 函数供外部调用(更规范) function getVotesData() { return JSON.parse(localStorage.getItem('votes') || '{}'); }第二步:转换为可排序数组
const votesData = getVotesData(); // Object.entries() 将对象转为二维数组 [['选项A', 12], ['选项B', 8], ...] // 然后 map 添加排名索引 const rankedArray = Object.entries(votesData) .map(([option, count], index) => ({ rank: index + 1, option, count })) .sort((a, b) => b.count - a.count); // 降序排列这里Object.entries()是关键,它把键值对变成数组,才能用sort()。sort()的比较函数(a,b) => b.count - a.count必须返回数字,正数表示b在前,负数表示a在前,零表示相等。初学者常写成(a,b) => a.count > b.count,这是错的,因为sort()需要数值而非布尔值。
第三步:渲染到表格
const tableBody = document.querySelector('#ranking-table tbody'); tableBody.innerHTML = ''; // 清空旧内容 rankedArray.forEach(item => { const row = document.createElement('tr'); row.innerHTML = ` <td>${item.rank}</td> <td>${item.option}</td> <td>${item.count}</td> `; tableBody.appendChild(row); });innerHTML直接拼接比循环createElement更高效,教学场景下可接受。注意tbody的选择器,而不是整个table,避免重绘表头。
实操心得:排行榜页面首次打开时,如果
main.html还没被访问过,localStorage.votes可能为空,此时getVotesData()返回{},Object.entries({})得到空数组[],forEach不会执行,表格就是空的——这符合预期,不需要额外报错。
4. 实操部署与调试指南:从零开始跑通项目的完整步骤
4.1 本地运行:三步走,5 分钟搞定
这个项目最大的优势就是“开箱即用”,无需任何构建工具。以下是标准操作流程:
第一步:解压资源包
下载的压缩包解压后,你会看到一个文件夹(如dzVzQOPMDi0KoMQbtljh-master-34a106b15e19902bfaab9bf5392f4bd0c767dc93),里面包含所有 HTML、JS、CSS 文件和images文件夹。不要进入子文件夹,直接在这个根目录下操作。
第二步:用浏览器打开注册页
找到注册.html文件,右键选择“在浏览器中打开”(推荐 Chrome 或 Edge)。此时你会看到一个简洁的注册表单。输入用户名(如student01)和密码(如123456),点击“注册”。如果弹出“注册成功”,说明localStorage写入正常,可以继续。
第三步:完成登录与投票
关闭当前页面,打开登录.html,输入刚注册的用户名和密码,点击“登录”。成功后会自动跳转到main.html。页面顶部显示“欢迎,student01”,中部是 5 个投票选项,每个选项旁有“投票”按钮和初始票数“0”。随便点击一个按钮,你会发现:
- 该选项的票数文本立刻变成“1”;
- 底部柱状图中对应的柱子变高,并显示数字“1”;
- 刷新页面,票数保持不变(证明localStorage生效);
- 点击右上角“排行榜”,跳转到排行榜.html,表格中“排名1”的选项就是你刚投的那一个。
整个过程不需要安装任何软件,不启动任何服务,纯粹靠浏览器自身能力运行。这是我给学生强调的第一课:“前端的本质,就是让浏览器干活”。
4.2 常见问题排查:那些让你抓耳挠腮的“小毛病”
在 12 届学生的实训中,以下问题是出现频率最高的,我把它们整理成速查表,附上根本原因和解决方案:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 点击注册/登录按钮没反应,页面直接刷新 | 表单未阻止默认提交行为 | 检查登录注册.js中handleRegister()和handleLogin()函数开头是否有e.preventDefault();确认事件监听是否正确绑定(如form.addEventListener('submit', handleRegister)) |
| 注册后登录提示“用户名或密码错误” | localStorage.users中存储的密码被意外修改,或登录时读取的users数组为空 | 打开浏览器开发者工具(F12),切换到 Application → Storage → Local Storage,找到users键,点击右侧的“Edit”图标,检查 JSON 字符串格式是否正确(必须是[{"username":"xxx","password":"yyy"}],不能有多余逗号或引号);若为空,删除该键后重新注册 |
main.html 页面空白,控制台报错Uncaught ReferenceError: votes is not defined | 主页面.js中votes变量声明位置错误,或main.html中<script>标签顺序不对 | 确保votes变量在main.html的<script>标签内声明为全局变量(如<script>let votes = {...};</script>),且该标签位于所有其他 JS 引入之前;或者将votes声明移到主页面.js文件顶部 |
| 柱状图不显示,或显示为一条线 | Canvas 元素尺寸为 0,或renderChart()函数未被调用 | 检查main.html中<canvas id="chart-canvas" width="800" height="400"></canvas>的width/height属性是否被 CSS 覆盖(如style="width:100%");在renderChart()开头加console.log('Rendering chart...'),确认函数是否执行;检查window.addEventListener('load', ...)是否包裹了初始化逻辑 |
| 排行榜页面始终为空 | 排行榜.js加载时机早于main.html的数据初始化,或localStorage.votes从未被写入 | 在排行榜.js的renderRankingTable()开头加console.log('Votes data:', getVotesData()),查看控制台输出;若为{},说明main.html还没被访问过,需先投一票;确保排行榜.html中<script src="排行榜.js"></script>放在</body>之前 |
提示:Chrome 开发者工具的 Console(控制台)和 Application(应用)面板是你的两大利器。Console 查 JS 错误,Application 看 localStorage 数据,两者结合,90% 的问题都能秒解。
4.3 个性化定制:三处最值得动手修改的“练手点”
这个项目不是终点,而是起点。我鼓励学生在跑通基础功能后,动手改这三处,既能巩固知识,又能产出自己的作品:
第一处:美化投票选项样式main.html中的选项列表是<ul class="options-list">,每个<li>包含按钮和票数。你可以:
- 给.options-list li添加border-radius: 8px; border: 1px solid #eee; padding: 12px; margin-bottom: 10px;,让它看起来像卡片;
- 给.vote-btn添加background-color: #4CAF50; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;,并加:hover效果;
- 用:nth-child(odd)和:nth-child(even)给奇偶行设置不同背景色,提升可读性。
第二处:增强排行榜交互排行榜.html当前是静态表格,可以加:
- 在表格上方加一个<input type="text" id="search-input" placeholder="搜索选项...">;
- 在排行榜.js中监听input事件,用filter()方法动态筛选rankedArray,再重新渲染表格;
- 给“票数”列加点击排序功能:点击一次升序,再点一次降序,用sort()的比较函数动态切换。
第三处:实现“防刷票”机制
教学项目允许刷票,但真实场景不行。你可以:
- 在主页面.js中添加全局变量let lastVoteTime = 0;;
- 在投票事件中加入时间判断:if (Date.now() - lastVoteTime < 5000) { alert('请勿频繁投票,5秒后重试'); return; },然后lastVoteTime = Date.now();;
- 进阶版:用localStorage记录每个用户的投票时间戳,实现 per-user 限流。
这些改动都不需要动核心逻辑,只是在现有骨架上添砖加瓦,却能让学生真切体会到“前端不只是写页面,更是写逻辑、控体验、保安全”。
5. 教学价值延伸与项目升级路径:从课堂作业到真实项目的跨越
这个项目最迷人的地方,在于它是一块“活”的垫脚石。当学生熟练掌握它之后,下一步往哪里走,路径非常清晰,而且每一步都对应着真实岗位的技术栈演进。
第一阶段:夯实基础(1-2周)
目标是吃透现有代码。建议学生做三件事:
-手写注释版:把主页面.js中renderChart()的每一行都加上中文注释,解释“这行在干什么”“为什么这么写”“不这么写会怎样”;
-流程图梳理:用纸笔画出“用户注册→登录→投票→图表更新→排行榜渲染”的完整数据流向图,标注每个环节涉及的 HTML 元素、JS 变量、localStorage 键;
-Bug 注入练习:故意在登录注册.js中删掉e.preventDefault(),观察现象并修复;把votes变量名改成voteCounts,看看哪些地方会报错,从而理解变量作用域。
第二阶段:渐进升级(2-4周)
在不破坏原有功能的前提下,引入工业级实践:
-模块化改造:用 ES6 Modules 替代全局变量。把votes封装成一个VoteManager类,提供addVote(option)、getVotes()、reset()方法;主页面.js和排行榜.js通过import { VoteManager } from './vote-manager.js'使用,彻底解耦;
-状态管理初探:引入一个极简的Store类,统一管理votes和currentUser,所有数据变更都通过store.dispatch({ type: 'VOTE_ADD', payload: '选项A' })触发,视图层监听store.subscribe(renderChart),这是 Redux 思想的雏形;
-响应式适配:用 CSS Media Queries 让柱状图在手机上横向滚动显示,而不是挤压变形;排行榜表格在小屏下改为卡片堆叠布局。
第三阶段:对接真实后端(4周+)
这才是项目真正的“毕业设计”形态。保留现有前端结构,只替换数据层:
-API 接口对接:后端提供/api/votes(GET 获取票数)、/api/votes(POST 提交投票)、/api/users/register(注册)等 RESTful 接口;
-AJAX 封装:在主页面.js中,把votes[option]++和localStorage.setItem()替换为fetch('/api/votes', { method: 'POST', body: JSON.stringify({option}) }),并处理then()和catch();
-错误边界处理:网络请求失败时,在 UI 上显示友好的错误提示(如“网络异常,请检查连接”),而不是控制台一片红字;
-Token 认证:登录成功后,后端返回 JWT Token,前端将其存入localStorage.token,后续所有请求的headers中带上Authorization: Bearer xxx。
这条路走下来,学生从“会写 JS”进化到“懂前端工程”,再到“能对接后端”,每一步都有迹可循。而这一切的起点,就是这个打开就能跑的纯前端投票系统——它不炫技,不堆砌,就像一把磨得锃亮的螺丝刀,专为拧紧初学者认知中的那颗关键螺丝而生。
我个人在实际教学中发现,那些最终能独立完成第三阶段的学生,往往都是从认真修改主页面.js中的renderChart()函数开始的。他们盯着 canvas 坐标轴看了半小时,终于搞懂为什么y坐标要减去barHeight;他们反复调试sort()的比较函数,直到b.count - a.count和a.count - b.count的区别刻进肌肉记忆。这种“慢下来,抠细节”的习惯,远比快速跑通一个花哨的 Vue 项目更有价值。因为前端开发的本质,从来不是框架的堆砌,而是对浏览器能力的深刻理解与精准驾驭。
本文还有配套的精品资源,点击获取
简介:这个前端项目包含完整的用户注册、登录流程,支持用户在main.html页面完成投票操作;投票结果通过JavaScript动态更新,实时渲染为柱状图,不刷新页面即可看到变化;排行榜.html页面按得票数从高到低自动排序展示选项及对应票数;所有交互逻辑由三个独立JS文件驱动(主页面.js处理投票与图表、排行榜.js负责榜单数据排序与渲染、登录注册.js管理表单验证与本地模拟登录);HTML结构清晰分离功能模块,配套有实训报告文档(软工192-08-李世权-实训报告.doc)、项目截图素材(images文件夹)以及基础实训说明;整个系统不依赖后端,数据暂存于浏览器内存,适合教学演示、课程设计或前端初学者动手练习。
本文还有配套的精品资源,点击获取