前段时间和朋友做了一个局域网考试系统,总共有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
,后者无法保证上传顺序。
试题编辑器——组件设计
改考试系统的试题有五种题型:单选题、多选题、判断题、简答题、作图题。
每一种题目的数据结构不同,而又具有部分相同的视图,故采用如下的设计:
在试题编辑组件中,使用el-select
组件选择相应题型:
// 当前编辑的题型
const type = ref(1)
// 题型列表
const typeList = reactive([
{label: '单选题',value: 1,routeName: 'singleSelect'},
{label: '多选题',value: 2,routeName: 'multiSelect'},
{label: '判断题',value: 3,routeName: 'booleanSelect'},
{label: '简答题',value: 4,routeName: 'shortAnswer'},
{label: '作图题',value: 5,routeName: 'drawPhoto'},
])
五种题型的编辑器(用到了el-input
,el-checkbox
等组件)分为五个组件实现,并引入到QuestionEditor.vue
中:
<div class="editor-container">
<!-- 编辑器 -->
<single-select ref="singleSelectRef" v-if="type===1"/>
<multi-select ref="multiSelectRef" v-if="type===2"/>
<boolean-select ref="booleanSelectRef" v-if="type===3"/>
<short-answer ref="shortAnswerRef" v-if="type===4"/>
<draw-photo ref="drawPhotoRef" v-if="type===5"/>
</div>
由于试题的创建需要实现批量新增试题,需要实现“上一道题、下一道题”的切换,因此还需要实现试题的本地存储,以及读写操作:
- 读操作在于存储试题到store的时候,需要读取试题对象;
- 写操作在于返回上一试题的时候,需要从store中读取试题对象,并写入到当前的编辑器中。
额外地,这里实现了clear操作,即清空试题编辑器的功能。
这是因为如果当前编辑中的试题是最后一题,那么点击”下一道题“之后,就会开启一个空白的试题。
本质上就是做了一个clear操作。
试题编辑器组件与上文的pinia实现的试题管理模块是绑定的。
本地存储试题对象,使用JSON做简单的深拷贝。
试题的读写操作、清空操作的具体实现,在每一种试题对应的组件里,而QuestionEditor.vue
组件只调用相应的接口:
// 获取题目内容(读操作)
const getQuestionHandler = ()=>{
let q = {}
q = editorRefList[type.value-1].value.getQuestion()
q.type = type.value
return q
}
// 设置题目内容(写操作)
const setQuestionHandler = (q)=>{
nextTick(()=>{
type.value = q.type
nextTick(()=>{
editorRefList[type.value-1].value.setQuestion(q)
})
})
}
// 清空编辑器内容
const clearQuestionHandler = ()=>{
editorRefList[type.value-1].value.clear()
}
下面以单选题编辑组件为例,内部实现读写操作和清除操作:
// 向外暴露获取题目内容的方法
const getQuestion = ()=>{
return question
}
const setQuestion = (q)=>{
...
}
const clear = ()=>{
...
}
defineExpose({ getQuestion, setQuestion, clear })
使用defineExpose
向外暴露方法。
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;
}
响应式数组的数据更新
不能直接将一个数组赋值给响应式数组,否则会失去响应式,而是应该:
const list = reactive([])
axios.get('...').then((newList)=>{
list.length = 0
list.push(...newList)
})
应该使用将长度设置为0,重新push
新元素的方式进行数组更新。
如果数组是对象数组,且后端返回的对象数组中,不是所有属性都需要,则可以使用map
方法进行选择性地保留属性:
list.push(...newList.map(item=>({
a: item.aa,
b: item.bb,
// 可以在后端返回的对象的基础上,保留部分属性,
// 也可能添加仅前端需要的新属性,即数据预处理
newKey: 'xxx'
})))
最终打包
最终打包项目给后端的时候,三个端,即三个Vue项目分别执行npm build
进行打包,然后和
- 根路径入口页面(
index.html
); - 静态文件
/static
; - 插件
/plugins
; - 三个端共享的资源预览页面
pdf-preview.html
;
放在一个文件夹里。
1.本站内容仅供参考,不作为任何法律依据。用户在使用本站内容时,应自行判断其真实性、准确性和完整性,并承担相应风险。
2.本站部分内容来源于互联网,仅用于交流学习研究知识,若侵犯了您的合法权益,请及时邮件或站内私信与本站联系,我们将尽快予以处理。
3.本文采用知识共享 署名4.0国际许可协议 [BY-NC-SA] 进行授权
4.根据《计算机软件保护条例》第十七条规定“为了学习和研究软件内含的设计思想和原理,通过安装、显示、传输或者存储软件等方式使用软件的,可以不经软件著作权人许可,不向其支付报酬。”您需知晓本站所有内容资源均来源于网络,仅供用户交流学习与研究使用,版权归属原版权方所有,版权争议与本站无关,用户本人下载后不能用作商业或非法用途,需在24个小时之内从您的电脑中彻底删除上述内容,否则后果均由用户承担责任;如果您访问和下载此文件,表示您同意只将此文件用于参考、学习而非其他用途,否则一切后果请您自行承担,如果您喜欢该程序,请支持正版软件,购买注册,得到更好的正版服务。
5.本站是非经营性个人站点,所有软件信息均来自网络,所有资源仅供学习参考研究目的,并不贩卖软件,不存在任何商业目的及用途
暂无评论内容