iT邦幫忙

2022 iThome 鐵人賽

DAY 15
0
Modern Web

LV的全端開發體驗系列 第 15

Day15 前台測驗功能試做

  • 分享至 

  • xImage
  •  

接著我們要來先來實現一下前台最主要的功能,測驗,在不考慮是否登入的狀況下,基本的測驗方式就是使用者進入測驗頁面後,亂數拉出一些題目出來,接者使用者開始填寫答案,填完送出,然後後台收到填寫結果要去比對結果,返回結果給使用者看,如果是登入的使用者
,可以把測驗結果存起來,如果是一般訪客則是可以匯出測驗結果自己留存,後面兩個紀錄的功能我們之後再做,今天先完成整個測驗的過程就好;

先增加一些題目在題庫中:

修改一下原本首頁中寫死的表單內容,改以資料庫中的題庫來顯示:

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 來傳遞填答的結果回頁面,這部份的工作可以留給朋友們來試試看。


上一篇
Day14 補完題組編輯及刪除 - 引入fontawesome
下一篇
Day16 使用Laravel Excel來匯入資料
系列文
LV的全端開發體驗30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言