昨天提到我們可以在後台看到測驗紀錄被儲存,今天除了要讓紀錄可以被重新讀取並顯示外,也要來聊聊前端組件設計的經驗,
首先我們在測驗列表上加入連結,點擊後會顯示整個測驗的完整紀錄,這個紀錄和測驗結束的畫面原則上是一樣的,但是別忘了,我們存在資料表中的測驗內容是簡化過的,所以需要透過後端來還原這個資料,並延用原本測驗結果頁面的資料來顯示內容,而不是重新寫一套幾乎一樣的東西。
增加一個路由,雖然是會員中心的功能,但並不是全部的邏輯都往 UserController
丟,有時還是要看狀況來決定,所以測驗的顯示我認為比較屬於測驗這件事本身要處理的,所以顯示使用者的測驗紀錄我會丟去 TestController
而不是 UserController
:
Route::middleware(['auth', 'verified','role:user'])->group(function(){
.....略
Route::get('/userhome/{user_id}/test/{id}', [TestController::class,'testRecordByUser'])
->name('test.record');
});
這邊我先偷個懶,我想先確認讀出的功能是可以的,所以我先讓參數及資料型式和 TestResult
的 ‵props‵ 是一致的,這樣我們才能確保可以快速套用
app\Http\Controllers\TestController.php
function testRecordByUser($user_id,$id){
$record=$this->test->getRecordById($id);
$bank=$this->bank->find($record['subjects'][0]->bank_id);
return Inertia::render('UserHome/TestRecord', [
'canLogin' => Route::has('login'),
'canRegister' => Route::has('register'),
'username'=>Auth::user()->name??'訪客',
'role'=>Auth::user()->role??'guest',
'bank'=>$bank,
'subjects'=>$record['subjects'],
'type'=>$record['type']
]);
}
這邊要把資料還原成前台可用的狀態,所以需要一些資料處理的過程
app\Services\TestService.php
function getRecordById($id){
$test=$this->test->find($id);
$subjects=collect(json_decode($test->subjects))
->map(function($subject){
$sub=$this->subject->find($subject->id);
$options=$sub->options;
$sub->selectOption=$options[$subject->select-1]->id;
$sub->selectSeq=$subject->select;
$sub->result=(collect($subject->options)->where('ans',1)->first()->seq==$subject->select)?true:false;
return $sub;
});
return ['subjects'=>$subjects,'type'=>$test->type];
}
app\Repositories\TestRepository.php
function find($id)
{
return $this->test->find($id);
}
resources\js\Pages\UserHome\UserHome.vue
.....略
<!--在紀錄項目中增加路由-->
<Link v-for="test in tests" :key="test.id"
:href="route('test.record',{user_id:$page.props.auth.user.id,id:test.id})"
class="py-2 px-4 border rounded flex justify-between text-center -mt-[1px] hover:bg-slate-200 cursor-pointer">
<div class="w-1/2">{{ test.time }}</div>
<div class="w-2/12">{{ test.result.total }}</div>
<div class="w-2/12">{{ test.result.correct }}</div>
<div class="w-2/12">{{ test.result.wrong }}</div>
</Link>
.....略
resources\js\Pages\UserHome\TestRecord.vue
<script setup>
import AuthenticatedUserLayout from "@/Layouts/AuthenticatedUserLayout.vue";
import { Head, Link } from "@inertiajs/inertia-vue3";
import { ref } from 'vue';
const props = defineProps({
bank: Object,
subjects:Array,
type: String,
});
const correctClass=ref('text-green-600 font-semibold bg-green-100');
const errorClass=ref('text-red-600 font-semibold bg-red-100')
</script>
<template>
<Head title="會員中心" />
<AuthenticatedUserLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
會員中心
</h2>
</template>
<!--右側內容區-->
<div class="w-[calc(100%_-_12rem)] p-4 ">
<div>
<div>測驗結果</div>
<div>題庫:{{ bank.name }}{{bank.levelC}}級</div>
<div>類型:{{ type }}</div>
</div>
<div v-for="subject , idx in subjects" :key="subject.id">
{{ idx+1 }}.
(<span class="inline-block w-6 text-center">
{{ subject.selectSeq }}
</span>)
{{ subject.subject }}
<div class="ml-6">
<div v-for="option ,i in subject.options"
:key="option.id"
class="m-2 hover:cursor-pointer">
<label>
<span class="py-0.5 px-2 align-middle rounded-full border inline-block text-sm text-gray-500 ">
{{ i+1 }}
</span>
<span v-if="subject.result===true" class="mx-1 inline-block w-1/2 border border-transparent hover:border hover:border-green-400"
:class="[option.ans===1?correctClass :'' ]">
{{ option.option }}
</span>
<span v-else class="mx-1 inline-block w-1/2 border border-transparent hover:border hover:border-green-400"
:class="[option.ans===1 ? correctClass :'' ,
subject.selectOption===option.id ?errorClass : '']">
{{ option.option }}
</span>
</label>
</div>
</div>
</div>
</div>
</AuthenticatedUserLayout>
</template>
當然,我們前面有引入了 vue-final-modal
這個套件,如果你不想要每個紀錄都跳到另外一頁來看,也可以使用 Modal
的方式來看,不過要小心的是之前我在 Store 中有設了一個 BankId ,這容易被誤解,也限制了Modal的複用性,建議可以改成 id 就好。
走了不少時間,總算愈來愈有一個完整系統的樣子了,使用框架一段時間後,我發現我比較少去思考程式碼怎麼寫這件事,因為很多基礎的程式碼撰寫都被框架提供的功能做完了,所以現在比較多時間都是花在思考設計上,或是日後的擴充及維護要怎麼進行,像是前面不斷出現的把資料流分成不同功能別的Class資料夾,然後資料在不同的Class中丟來丟去的狀況,對新手來說會很崩潰,但習慣後,會發現到後面都只是在組合這些小function的功能而已,真正需要去寫程式碼的地方愈來愈少了。
前端也是,以這個題目顯示來說,可以預想到除了一開始的試題顯示,考完的結果顯示,後台的紀錄顯示,未來還會做管理者查看測試紀錄的顯示,都會使用到相同的顯示內容,可是因為還是有些不同的地方在,所以直覺的做法就是複製一份檔案過來,然後調整一下有差異的地方就交出去了,一旦日後有要修改的話,就四五個檔案都要改到才行,但只要改的東西一多,就難免容易有錯。
既然我們已經預想到了這個功能未來會大量的重覆使用,那就按照之前折解元件的做法來思考怎麼把組件拆出去,是整個測驗列表拆成一個組件?還是一個題目一個組件?或是題目的主題和選項也拆成不同的組件??
朋友們,這個問題要和團隊好好討論清楚,但我是一人團隊,所以我想怎麼做隨我高興,如果時間夠,我會拆到主題和選項都是各自的組件為止,但今天我只想先拆到題目就好。
在QuizComponents下新增一個題目的組件檔案:
resources\js\QuizComponents\SingalSubject.vue
<script setup>
import { ref } from 'vue';
const props=defineProps({ subject:Object })
const correctClass=ref('text-green-600 font-semibold bg-green-100');
const errorClass=ref('text-red-600 font-semibold bg-red-100')
</script>
<template>
(<span class="inline-block w-6 text-center">
{{ subject.selectSeq }}
</span>)
{{ subject.subject }}
<div class="ml-6">
<div v-for="option ,i in subject.options"
:key="option.id"
class="m-2 hover:cursor-pointer">
<label>
<span class="py-0.5 px-2 align-middle rounded-full border inline-block text-sm text-gray-500 ">
{{ i+1 }}
</span>
<span v-if="subject.result===true" class="mx-1 inline-block w-1/2 border border-transparent hover:border hover:border-green-400"
:class="[option.ans===1?correctClass :'' ]">
{{ option.option }}
</span>
<span v-else class="mx-1 inline-block w-1/2 border border-transparent hover:border hover:border-green-400"
:class="[option.ans===1 ? correctClass :'' ,
subject.selectOption===option.id ?errorClass : '']">
{{ option.option }}
</span>
</label>
</div>
</div>
</template>
這個拆出來的組件需要一個 subject
的資料物件,我們由父組件來傳入:
resources\js\Pages\TestResult.vue
<script setup>
.....略
//先匯入組件
import SingalSubject from '@/QuizComponents/SingalSubject.vue';
</script>
<template>
.....略
<!--原本的列表還是使用v-for來顯示,只是改成把資料傳入組件-->
<div v-for="subject , idx in subjects" :key="subject.id">
{{ idx+1 }}.
<SingalSubject :subject="subject"/>
</div>
</template>
原本一長串的題目相關頁面及程式的內容被縮減成一個組件標籤,這個測試結果頁面內容不僅是程式碼減少,使用組件標籤也提高頁面原始碼的可讀性。
我們知道會員中心的測驗紀錄會顯示一樣的內容,所以這個組件可以很容易的搬到會員中心的頁面去,這裏就不多說了,我現在要做的是希望測驗開始時的題目顯示也能使用一樣的組件,可是當你把這個組件用在測驗開始時,會發現答案都跑出來了,而且沒有填答的功能:
也許有些人會想,功能不一樣,當然不能這樣用啊,另外做一個測驗用的組件不就好了了?這麼說也沒錯,但別忘了我們先前在做新增表單時也是做兩個頁面,後來改成了一個頁面就可以同時處理新增和修改的工作,也就是說,如果可以,至少我個人會比較希望和題目相關的功能都在一個組件中解決,只需要透過傳 props
的方式來啟用或關閉某些功能,當然也不排除組件的功能會複雜到必須再往下拆分的可能;
簡單來說,拆解組件有時不只是把頁面的一部份分出去,同時也是在建立一個小應用程式。
觀察一下測驗結果和測驗兩個組件的內容,發現主要的差異在於 v-mode
而表單資料是由 form
這個物件帶入的,所以我們可以在題目的組件內根據 form
物件的有無來決定要呈現的是結果還是表單:
resources\js\QuizComponents\SingalSubject.vue
<script setup>
import { ref } from 'vue';
const props=defineProps({ subject:Object,
form:{type:Object,default:null} //增加一個form的定義
})
const correctClass=ref('text-green-600 font-semibold bg-green-100');
const errorClass=ref('text-red-600 font-semibold bg-red-100')
</script>
<template>
<!--使用v-if的方式來切換兩個不同狀態下要顯示的內容-->
(<span v-if="form" class="inline-block w-6 text-center">
{{ form.seq === 0 ? '':form.seq }}
</span>
<span v-else class="inline-block w-6 text-center">
{{ subject.selectSeq }}
</span>)
{{ subject.subject }}
<div class="ml-6">
<div v-for="option ,i in subject.options"
:key="option.id"
class="m-2 hover:cursor-pointer">
<label>
<input v-if="form"
type="radio"
v-model="form.optionId"
:value="option.id"
@click="form.seq=i+1"
class="opacity-0">
<span class="py-0.5 px-2 align-middle rounded-full border inline-block text-sm text-gray-500 ">
{{ i+1 }}
</span>
<!--使用到v-if v-else-if 的巢狀判斷來切換三種不同狀態下顯示的內容-->
<span v-if="form" class="mx-1 inline-block w-1/2 border border-transparent hover:border hover:border-green-400"
:class="{'text-green-600 font-semibold bg-green-100':form.seq==i+1}">
{{ option.option }}
</span>
<span class="mx-1 inline-block w-1/2 border border-transparent hover:border hover:border-green-400"
:class="[option.ans===1?correctClass :'' ]"
v-else-if="subject.result===true" >
{{ option.option }}
</span>
<span v-else class="mx-1 inline-block w-1/2 border border-transparent hover:border hover:border-green-400"
:class="[option.ans===1 ? correctClass :'' ,
subject.selectOption===option.id ?errorClass : '']">
{{ option.option }}
</span>
</label>
</div>
</div>
</template>
然後我發現傳遞進來的 form
物件是自動嚮應的,所以你在這個組件做的選擇動作,父組件的 form
也會收到資料,所以父組件的行為完全不用修改,如果有特殊狀況需要把資料傳回父層,可以考慮使用 emit
或 pinia
:
resources\js\Pages\StartTest.vue
<script setup>
.....略
//引入題目的組件
import SingalSubject from '@/QuizComponents/SingalSubject.vue';
</script>
<template>
<!--把原本的題目使用組件標籤來取代,並記得傳入需要的prop-->
<div v-for="subject , idx in subjects" :key="subject.id">
{{ idx+1 }}.
<SingalSubject :subject="subject" :form="form[idx+1]" />
</div>
</template>
這樣我們就拆完了題目這個組件了,會這樣做還有一個原因是目前的測驗是一個頁面列出所有的題目來,但我將來想增加一個一頁一題的測驗形式,如果頁面寫死了題目的程式碼,那當我想換不同的形式來呈現時,就又得再做出一個頁面來,但改成組件後,我可以在不同的父層組件中使用同一個子組件。