接著我們要來先來實現一下前台最主要的功能,測驗,在不考慮是否登入的狀況下,基本的測驗方式就是使用者進入測驗頁面後,亂數拉出一些題目出來,接者使用者開始填寫答案,填完送出,然後後台收到填寫結果要去比對結果,返回結果給使用者看,如果是登入的使用者
,可以把測驗結果存起來,如果是一般訪客則是可以匯出測驗結果自己留存,後面兩個紀錄的功能我們之後再做,今天先完成整個測驗的過程就好;
先增加一些題目在題庫中:
修改一下原本首頁中寫死的表單內容,改以資料庫中的題庫來顯示:
app\Http\Controllers\HomeController.php
.....略
use App\Services\BankService;
class HomeController extends Controller
{
function __construct(protected BankService $bank){}
function home() {
return Inertia::render('Home', [
'canLogin' => Route::has('login'),
'canRegister' => Route::has('register'),
'username'=>Auth::user()->name??'訪客',
'role'=>Auth::user()->role??'guest',
'banks'=>$this->bank->all() //撈出全部的題庫
]);
}
}
resources\js\Pages\Home.vue
<script setup>
import { Head, Link } from "@inertiajs/inertia-vue3";
import { reactive } from "vue";
const props = defineProps({
canLogin: Boolean,
canRegister: Boolean,
username: String,
role: String,
banks:Array, //題庫
});
const userInfo = reactive({
user: { link: route("userhome"),
string: "會員中心" },
admin: { link: route("backstage"),
string: "管理中心" },
});
const testSelect = reactive({ quizbank: props.banks[0].id, type: "test" });
</script>
<template>
<!--inertia的HEAD組件可以用來改變瀏灠器分頁上的標籤文字(title)-->
<Head title="學科測驗系統" />
<!--這一段是註冊登入用的,將來可以改成sticky的標題功能列-->
<div class="relative">
<div v-if="canLogin"
class="hidden sticky top-0 right-0 bg-sky-200 text-sm text-sky-900
text-right px-6 py-4 sm:block w-full">
{{ username }}
<Link v-if="$page.props.auth.user" :href="userInfo[role].link"
class="text-sm text-gray-700 dark:text-gray-500 underline">
{{ userInfo[role].string }}
</Link>
<template v-else>
<Link :href="route('login')"
class="text-sm text-gray-700 dark:text-gray-500 underline">
登入
</Link>
<Link v-if="canRegister"
:href="route('register')"
class="ml-4 text-sm text-gray-700 dark:text-gray-500 underline">
註冊
</Link>
</template>
</div>
<!--前台主要內容區域-->
<div class="max-w-7xl m-auto p-4">
<div class="m-4">
<div>選擇測驗項目:</div>
<!--使用v-for 把所有題庫列出-->
<div v-for="bank in banks " :key="bank.id"
class="py-3 px-6 inline-block">
<input type="radio" name="bank" :value="bank.id"
v-model="testSelect.quizbank"/>
{{ bank.name }}{{bank.levelC}}級
</div>
</div>
<div class="m-4">
<div>選擇測驗類型:</div>
<input type="radio" name="type" value="test" v-model="testSelect.type" />測驗
<input type="radio" name="type" value="practice" v-model="testSelect.type" />練習
</div>
<Link :href="route('test.start', testSelect)"
class="px-6 py-2 border rounded-lg bg-blue-700 text-blue-100">
開始
</Link>
<div class="m-4">
<div>瀏灠題庫:</div>
<!--使用v-for 把所有題庫列出-->
<Link v-for="bank in banks" :key="bank.id"
:href="route('quiz.browser', bank.id)"
class="ml-4 border py-3 px-6 rounded-lg text-lime-900 dark:text-gray-500 inline-block bg-lime-100 font-bold">
{{ bank.name }}{{ bank.levelC }}級
</Link>
</div>
</div>
</div>
</template>
然後,設定一下測試控制器,在繪製頁面時同時帶入亂數選出的題目(暫定10題,未來可以參數化):
app\Http\Controllers\TestController.php
.....略
use App\Services\BankService;
class TestController extends Controller
{
//引入題庫的Service用來取得題目
function __construct(protected BankService $bank){}
function index(){
return Inertia::render('Backstage/Tests');
}
function startTest($bank_id,$type){
return Inertia::render('StartTest', [
'canLogin' => Route::has('login'),
'canRegister' => Route::has('register'),
'username'=>Auth::user()->name??'訪客',
'role'=>Auth::user()->role??'guest',
'bank'=>$this->bank->find($bank_id),
'subjects'=>$this->bank->subjects($bank_id,10),
'type'=>$type
]);
}
}
在題庫Service中增加亂數選題的方法,目前的方法是暫定的,之後會增加功能,有不同的選題方式;
要注意的是,因為Laravel大量使用了 Collection
來包裝陣列,所以我這邊的資料處理會用上不少的 Collection
方法,讓我的程式碼可以簡潔許多。
app\Services\BankService.php
//回傳指定題庫的亂數選題題目
function subjects($bank_id,$number){
return $this->find($bank_id) //找出題庫
->subjects //撈出題庫關聯的題目
->random(10) //隨機取出10筆
->map(function($subject){
$subject->options; //在題目中指定選項關聯
return $subject;
})
->shuffle(); //把題目亂數排序
}
在前端把題目列出來
resources\js\Pages\StartTest.vue
<script setup>
const props = defineProps({ canLogin: Boolean,
canRegister: Boolean,
username: String,
role: String,
bank: Object,
subjects:Array,
type: String, });
</script>
<template>
<div>測驗及練習</div>
<div>題庫:{{ bank.name }}{{bank.levelC}}級</div>
<div>類型:{{ type }}</div>
<!--題目區塊-->
<div>
<div v-for="subject , idx in subjects" :key="subject.id">
{{ idx+1 }}.( ){{ subject.subject }}
<!--選項區塊-->
<div class="ml-6">
<div v-for="option ,idx in subject.options" :key="option.id"
class="m-2 hover:cursor-pointer">
<span class="py-0.5 px-2 align-middle rounded-full border inline-block">
{{ idx+1 }}
</span>
<span class="mx-1">{{ option.option }}</span>
</div>
</div>
</div>
</div>
</template>
當初是為了快速驗證所以這個頁面沒有任何修飾,最初的welcome也很簡陋,所以我們仿照後台的做法,建立一個前台頁面的共同組件,我們暫時先讓前台每一頁都有上方的NavBar,登入指示及主內容區有限定的寬度,其它的美化或區塊規劃可以隨專案的進行再調整。
resources\js\Layouts\FrontLayout.vue
<script setup>
import { Head, Link } from "@inertiajs/inertia-vue3";
import { reactive } from "vue";
const userInfo = reactive({
user: { link: route("userhome"),
string: "會員中心" },
admin: { link: route("backstage"),
string: "管理中心" },
});
</script>
<template>
<Head title="學科測驗系統" />
<div class="relative">
<!--原本的屬性由props改為$page.props來帶入-->
<div v-if="$page.props.canLogin"
class="hidden sticky top-0 right-0 bg-sky-200 text-sm text-sky-900 text-right px-6 py-4 sm:block w-full">
{{ $page.props.username }}
<Link v-if="$page.props.auth.user"
:href="userInfo[$page.props.role].link"
class="text-sm text-gray-700 dark:text-gray-500 underline">
{{ userInfo[$page.props.role].string }}
</Link>
<template v-else>
<Link :href="route('login')"
class="text-sm text-gray-700 dark:text-gray-500 underline">
登入
</Link>
<Link v-if="$page.props.canRegister" :href="route('register')"
class="ml-4 text-sm text-gray-700 dark:text-gray-500 underline">
註冊
</Link>
</template>
</div>
<!--前台主要內容區域-->
<div class="max-w-7xl m-auto p-4">
<!--提供slot給外部使用-->
<slot />
</div>
</div>
</template>
其它前台頁面只要匯入 FrontLayout
就可以有基本的版型及上方的 Navbar
,程式碼比較清爽,也容易閱讀。
resources\js\Pages\StartTest.vue
<script setup>
import FrontLayout from '@/Layouts/FrontLayout.vue';
import { useForm } from "@inertiajs/inertia-vue3";
const props = defineProps({ bank: Object,
subjects:Array,
type: String });
</script>
<template>
<FrontLayout>
<div>測驗及練習</div>
<div>題庫:{{ bank.name }}{{bank.levelC}}級</div>
<div>類型:{{ type }}</div>
<!--題目區塊-->
<div>
<div v-for="subject , idx in subjects" :key="subject.id">
{{ idx+1 }}.( ){{ subject.subject }}
<!--選項區塊-->
<div class="ml-6">
<div v-for="option ,idx in subject.options" :key="option.id"
class="m-2 hover:cursor-pointer">
<span class="py-0.5 px-2 align-middle rounded-full border inline-block">
{{ idx+1 }}
</span>
<span class="mx-1"> {{ option.option }} </span>
</div>
</div>
</div>
</div>
</FrontLayout>
</template>
線上測驗可以多一點互動,我們在選項上加入 hover
和點選的回饋:
其中我們利用到 <label></label>
的一個特性,當你點擊 <label>
標籤時,包在其中的表單元件事件是可以被觸發的,所以我們把input 標籤放在其中,並設為透明及綁定 v-model
,這樣我們只要點選 <label>
標籤,就可以選中該選項,可以省下不少事件的處理。
<script setup>
import FrontLayout from '@/Layouts/FrontLayout.vue';
const props = defineProps({ bank: Object,
subjects:Array,
type: String });
const data={}
//建立一個有所有題目預設資料的物件
props.subjects.forEach(function(subject,idx){
data[idx+1]={seq:0,subjectId:subject.id,optionId:0}
})
//在資料物件中傳入共同的資料
data['bankId']=props.bank.id;
data['type']=props.type;
//把資料物件放入 Inertia 的 useForm 工具
const form=useForm(data);
//送出資料
const submit=()=>{ form.post(route('test.result')) }
</script>
<template>
<FrontLayout>
<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">
{{ form[idx+1].seq === 0 ? '':form[idx+1].seq }}
</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 type="radio"
v-model="form[idx+1].optionId"
:value="option.id"
@click="form[idx+1].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>
<span 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[idx+1].seq==i+1}">
{{ option.option }}
</span>
</label>
</div>
</div>
</div>
<button @click="submit"
class="my-4 py-1.5 px-5 border rounded-2xl shadow-sm bg-teal-500 text-teal-900 font-semibold "> 送出 </button>
</div>
</FrontLayout>
</template>
填答選擇
傳到後端的資料
接著我們來處理後端對答及回傳測驗結果:
app\Http\Controllers\TestController.php
use App\Services\BankService;
use App\Services\TestService;
class TestController extends Controller
{
function __construct(protected BankService $bank,
protected TestService $test){}
function testResult(Request $request){
return Inertia::render('TestResult', [
'canLogin' => Route::has('login'),
'canRegister' => Route::has('register'),
'username'=>Auth::user()->name??'訪客',
'role'=>Auth::user()->role??'guest',
'bank'=>$this->bank->find($request->bankId), //撈出題庫資料
'subjects'=>$this->test->check($request->input()), //撈出對答結果
'type'=>$request->type
]);
}
}
app\Services\TestService.php
use App\Repositories\TestRepository;
use App\Repositories\SubjectRepository;
class TestService
{
function __construct( protected TestRepository $test,
protected SubjectRepository $subject){}
function check($form)
{
$bankId=$form['bankId'];
$type=$form['type'];
unset($form['bankId'],$form['type']);
$form=collect($form); //把只剩填答的陣列丟進collection中
$subjects=$this->subjectsFromTest($form->pluck('subjectId')); //撈出題目
//把題目和填答結果做比對,並加上對錯的屬性,回傳給前端使用
$subjects=$subjects->map(function($subject,$idx)use($form){
//加上使用者填答的選項及項目號
$subject->selectOption=$form[$idx+1]['optionId'];
$subject->selectSeq=$form[$idx+1]['seq'];
if($subject->options
->where('ans',1)
->first()->id === $subject->selectOption){
$subject->result=true;
}else{
$subject->result=false;
}
return $subject;
});
return $subjects;
}
function subjectsFromTest($ids){
return $this->subject->whereIn($ids->toArray());
}
}
app\Repositories\SubjectRepository.php
use Illuminate\Support\Facades\DB;
class SubjectRepository
{
//自訂的whereIn方法
function whereIn($ids)
{
/*因為laravel提供的whereIn方法會自動把資料排序
*但我們希望撈出的題目和我們原本的題目順序是一樣的
*所以使用 order by FIELD() 這個sql語法
*來讓撈出的資料和原本的順序一樣*/
return $this->subject
->with('options') //引入關聯,避免n+1問題
->whereIn('id',$ids) //使用ORM的whereIn方法來撈出題目
->orderByRaw(DB::raw("FIELD(id,".join(',',$ids).")"))
->get();
}
}
resources\js\Pages\TestResult.vue
<script setup>
import FrontLayout from '@/Layouts/FrontLayout.vue';
import { Head, Link, useForm } from "@inertiajs/inertia-vue3";
import { ref } from 'vue';
const props = defineProps({ bank: Object,
subjects:Array,
type: String });
//建立選項的class變數
const correctClass=ref('text-green-600 font-semibold bg-green-100');
const errorClass=ref('text-red-600 font-semibold bg-red-100')
</script>
<template>
<FrontLayout>
<div>測驗結果</div>
<div>題庫:{{ bank.name }}{{bank.levelC}}級</div>
<div>類型:{{ type }}</div>
<Link href="/">回首頁</Link>
<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>
</FrontLayout>
</template>
基本的測驗流程這樣算是完成了,之後會再針對使用者的身份來進行測驗後的不同行為。
之後因為想對測驗部份做出不同模式的切換,所以我會把題目的項目單獨分出來變成一個組件,然後再整合 pinia
來傳遞填答的結果回頁面,這部份的工作可以留給朋友們來試試看。