iT邦幫忙

2022 iThome 鐵人賽

DAY 22
0
Modern Web

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

Day22 查看測驗紀錄-組件的組件的組件,聊聊重覆利用的設計

  • 分享至 

  • xImage
  •  

昨天提到我們可以在後台看到測驗紀錄被儲存,今天除了要讓紀錄可以被重新讀取並顯示外,也要來聊聊前端組件設計的經驗,

首先我們在測驗列表上加入連結,點擊後會顯示整個測驗的完整紀錄,這個紀錄和測驗結束的畫面原則上是一樣的,但是別忘了,我們存在資料表中的測驗內容是簡化過的,所以需要透過後端來還原這個資料,並延用原本測驗結果頁面的資料來顯示內容,而不是重新寫一套幾乎一樣的東西。

增加一個路由,雖然是會員中心的功能,但並不是全部的邏輯都往 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 也會收到資料,所以父組件的行為完全不用修改,如果有特殊狀況需要把資料傳回父層,可以考慮使用 emitpinia
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>

這樣我們就拆完了題目這個組件了,會這樣做還有一個原因是目前的測驗是一個頁面列出所有的題目來,但我將來想增加一個一頁一題的測驗形式,如果頁面寫死了題目的程式碼,那當我想換不同的形式來呈現時,就又得再做出一個頁面來,但改成組件後,我可以在不同的父層組件中使用同一個子組件。


上一篇
Day21 儲存使用者的測驗結果
下一篇
Day23 再談前端組件設計-Button Component
系列文
LV的全端開發體驗30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言