考試系統前端項目復盤
前段時間和朋友做了一個局域網考試系統,總共有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;
放在一個文件夾里。

浙公網安備 33010602011771號