- A+
前段时间和朋友做了一个局域网考试系统,总共有3个端:考生端、监考端、管理端。
框架与相关的库
先简单说明一下我使用的框架和相关的库:
构建工具:Vite
框架:Vue3
UI组件库:element-plus
网络请求库:axios
路由跳转:vue-router
状态管理:pinia
CSS扩展语言:sass
其它与项目功能需求相关的库这里就不一一列出了
多端非根路径部署
考虑到每一个用户理论上只会使用其中一个端,如果将三个端绑定在一个Vue项目上,则会导致“捆绑销售”。因此,将三个端用三个Vue项目完成,然后让后端开发人员使用nginx配置好映射。最后我需要再写一个根路径的入口页面,用于跳转到三个端。
/
:根路径,页面的内容主要是三个按钮,分别跳转到三个端;/admin
:管理端;/teacher
:监考端;/student
:考生端。
三个端的路径经由nginx配置之后,指向三个Vue项目的index.html
,然后再加载各自的main.js
。
与以往将前端项目部署在根路径的情况不同,将前端项目部署在非根路径需要做相关配置。
主要是需要修改vite.config.js
和vue-router
的配置文件。
以管理端为例,由于其项目部署在/admin
,因此需要配置项目的base
。
vite.config.js
export default defineConfig({ ... base: '/admin/', ... })
vue-router
配置文件
const router = createRouter({ ... history: createWebHistory(import.meta.env.BASE_URL), ... })
使用history模式,需要后端在nginx上做配置。而createWebHistory
函数的参数需要传入base
,即上面配置的/admin/
。
而余下的routes
配置,就根据以往的编写方式就可以。
例如,管理端的登录页面,在配置了base: '/admin/'
的情况下,在配置登录页面的路由的时候,只需要写/login
:
const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', redirect: {name: 'login'} }, { path: '/login', name: 'login', component: ()=>import('../views/LoginView.vue') } ] })
实际上,从整个考试系统的角度看来,它匹配到的路径应该是:/admin/login
。
这是因为/admin/
会先被nginx的配置捕获到,然后指向管理端这个Vue项目,返回管理端的index.html
和main.js
给用户(该系统的管理员),然后路径后续的/login
会因为main.js
中引入的路由配置文件,匹配上LoginView.vue
,即登录页面。
盒子的最大宽度
页面中的文字依据来源可以分为两种:
- 静态文本:即本身固化在代码中的文本;
- 动态文本:由用户输入并显示在页面中的文本。
静态文本,例如侧边导航栏的按钮的文本,文本的字数是固定的。因此,侧边导航栏的宽度可以写成固定的。
而动态文本,是由用户输入的,并且大多数时候没有严格的字数限制。
我一开始犯了一个错,就是只使用flex:3
和flex:7
简单地将页面分为左右布局,然后左边是一个列表,每一项都是一行用户输入的数据,即不做换行处理。
当用户输入了长文本之后,左边的列表会被子元素撑大,从而导致页面的左右布局比例被破坏。
因此这里由用户输入的数据构成的列表,应该使用css设置一个max-width
,限制其最大宽度。
对象的深拷贝
使用JSON简单地实现了对象的深拷贝
// 存储对象的数组 list: [] // 添加新对象 list.push(JSON.stringify(newItem)) // 获取对象 function getItem(params){ ...do some search return Json.parse(target) }
pinia 实现试题管理模块
这里的试题是指添加试题时的阶段,即需要提供读与写操作。
- state:
state: ()=>({ // 题目列表,存储题目对象,使用JSON简单实现了对象的深拷贝 qList: [], // 当前编辑的题目的指针 currIdx: -1 }),
- getter:(返回常用数据)
getters: { // 题目数量 count(){ return this.qList.length }, // 当前编辑的题目是否存在“上一题” hasPrev(){ return this.currIdx>0 }, hasNext(){ return this.currIdx<this.count } },
- actions:向外提供操作方法
actions: { // 初始化 init(){ this.qList.length = 0 this.currIdx = 0 }, // 写操作 saveQuestion(q){ this.qList[this.currIdx] = JSON.stringify(q) }, // 前一道题 goPrevQuestion(){ if(this.hasPrev){ return JSON.parse(this.qList[--this.currIdx] || "") } }, // 后一道题 goNextQuestion(){ const q = this.qList[++this.currIdx] return q===undefined?undefined:JSON.parse(q) }, // 上传题目列表到后端 async uploadQuestionList(){ for await (let q of this.qList){ q = JSON.parse(q) if(this.checkCompleteness(q)){ await uploadQuestion(q) } } }, checkCompleteness(q){ // 用于检查一道题目是否设置完整 }, isEmpty(q){ // 用于检查一道题目是否没有填写任何内容 } }
上述代码中的checkCompleteness
和isEmpty
函数的实现涉及到试题对象的设计,较为复杂,这里不给出代码。
上传题目列表到后端的操作中,为了实现按顺序上传,需要使用for await(... of ...)
,而不能使用foreach await
,后者无法保证上传顺序。
vite打包配置
在vite.config.js
中,通过如下配置,可以去除代码中的console.log
,避免将数据带到生产环境,同时将js
文件和assets
文件打包到不同文件夹。
export default defineConfig({ ... build: { terserOptions: { compress: { // 生产环境时移除console.log调试代码 drop_console:true, drop_debugger: true } }, rollupOptions: { output: { //对静态文件进行打包处理(文件分类) chunkFileNames: 'assets/js/[name]-[hash].js', entryFileNames: 'assets/js/[name]-[hash].js', assetFileNames: 'assets/[ext]/[name]-[hash].[ext]' } } } ... })
文件下载功能
项目中有需求是:用户点击按钮之后下载文件。使用js实现:
// 下载文件 const downloadFile = () => { const tempDom = document.createElement('a') tempDom.href = "/file/demo.txt" tempDom.download = 'fileName.txt' tempDom.click() }
这里创建了一个DOM对象,路径href
是服务器上的文件路径,download
属性的字符串是用户下载到的文件名。
pdf预览功能
我写了一个pdf-previewer.html
文件,并放在根路径下,然后每次不同端的项目中,需要访问pdf文件的时候,就调用:
window.open('/pdf-preview.html?url='+path)
path是后端传过来的文件路径。
在pdf-previewer.html
中,
- 使用
iframe
标签; - 封装了
getQueryVariable
函数,用来获取访问地址携带的参数(即文件的地址); - 为了解决缓存问题(利用iframe打开pdf后,当再次利用iframe打开另一个pdf时会显示第一份pdf,原因是浏览器对url的缓存处理),在url上添加时间戳。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>PDF预览窗口</title> <link rel="icon" href="/icon.png"> <style> *{ margin: 0; padding: 0; box-sizing: border-box; } body{ width: 100%; height: 100vh; overflow: hidden; } </style> </head> <body> <iframe id="viewer" src="" style="width: 100%;height: 100vh;" frameborder="0"></iframe> <script> window.addEventListener ('load', () => { function getQueryVariable(variable) { let query = window.location.search.substring(1); let vars = query.split("&"); for (let i = 0; i < vars.length; i++) { let pair = vars[i].split("="); if (pair[0] === variable) { return pair[1]; } } return (false); } let path = getQueryVariable('url') const fresh = new Date().getTime() path += '?fresh=' + fresh document.getElementById('viewer').setAttribute('src', path) }); </script> </body> </html>
常用Message封装
el-message
组件对于反馈功能很常用,封装成函数:
import { ElMessage } from 'element-plus' const showError = (msg)=>{ return ElMessage({ type: 'error', message: msg }) } const showSuccess = (msg)=>{ return ElMessage({ type: 'success', message: msg }) } const showInfo = (msg)=>{ return ElMessage({ type: 'info', message: msg }) } export { showError, showSuccess, showInfo }
使用CSS常量
使用CSS常量记录常用的尺寸、颜色,可以改一处,而变全局。
以下常量是我的项目中的一部分颜色,仅供参考,不具有普适性。
:root { --main-color: #31364d; --header-height: 60px; --border-color: #DCDFE6; --border-color-light: #E4E7ED; --border-color-darker: #CDD0D6; --page-background: #F2F3F5; }