iT邦幫忙

2022 iThome 鐵人賽

DAY 13
0
Modern Web

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

Day13 完善題組及題目功能

  • 分享至 

  • xImage
  •  

昨天我們把題庫的功能補上了,但因為我們中間調整了題庫和題目間的欄位關係,所以接下來是為題目加上題組的功能,每一個題目一定是屬於某個題庫中的某個題組。

加上題組

題組的功能和題庫類似,就是把題目再做第二層的分類,為了避免像之前一樣,偷了一點工,償還的時候要還十倍,所以我們這次不偷懶,先把題組的功能完整的補上,再來做題目的功能.

首先我們規範每個題庫至少會有一個題組,如果沒有特別設的話,那麼這個預設的題組會和題庫是一樣的名字。

這次我們可以先建立資料表:

php artisan make:model BankGroup -m

database\migrations\xxxxx_create_bank_groups_table.php

return new class extends Migration
{
    public function up()
    {
        Schema::create('bank_groups', function (Blueprint $table) {
            $table->id();
            $table->unsignedTinyInteger('seq');
            $table->string('name');
            $table->unsignedInteger('bank_id');
            
        });
    }

    public function down()
    {
        Schema::dropIfExists('bank_groups');
    }
};

執行migrate

php artisan migrate

設定Model關聯
app\Models\BankGroup.php

class BankGroup extends Model
{
    use HasFactory;

    protected $guarded=[];
    public $timestamps = false;

    function subjects(){ return $this->hasMany(Subject::class); }

    function bank(){ return $this->belongsTo(Bank::class); }
}

app\Models\Bank.php

class Bank extends Model
{
    function bankgroups(){ return $this->hasMany(BankGroup::class); }
}

app\Models\Subject.php

class Subject extends Model
{
    function bankgroup(){ return $this->belongsTo(BankGroup::class); }
}

建立CRUD路由,後面的很多項功能都會是同樣的套路...

Route::get('/bankgroup/{id}/subjects',[BankGroupController::class,'subjectsInGroup'])
        ->name('bankgroup.subjects');
Route::get('/bank/{bank_id}/bankgroup/create',[BankGroupController::class,'create'])
        ->name('bankgroup.create');
Route::post('/bankgroup',[BankGroupController::class,'store'])
        ->name('bankgroup.store');
Route::get('/bankgroup/{id}/edit',[BankGroupController::class,'edit'])
        ->name('bankgroup.edit');
Route::put('/bankgroup/{id}',[BankGroupController::class,'update'])
        ->name('bankgroup.update');
Route::delete('/bankgroup/{id}',[BankGroupController::class,'destroy'])
        ->name('bankgroup.destroy');

接著建立 Controller / Service / Repository 套路是一樣的,所以直接上程式碼,朋友們也可以自己依需求做調整。

app\Http\Controllers\BankGroupController.php

.....略
class BankGroupController extends Controller
{
    function __construct(protected BankGroupService $bankgroup){}

    function create($bank_id)
    {
        /*因為新增時的空白表單中也要帶入題庫的資料
         *所以我們在這不只回傳頁面,
         *也同時取得題庫的資料*/
        $bank=$this->bankgroup->getBank($bank_id);

        //由後端來產生一個空白的資料物件給前端頁面使用
        $bankgroup=$this->bankgroup->getNewGroup($bank);

        return Inertia::render('Backstage/BankGroupForm',
            ['bank'=>$bank,
             'group'=>$bankgroup]);
    }

    function store(Request $request)
    {
        $this->bankgroup->store($request->input());
        return redirect()->route('bank.subjects',$request->bankId);
    }

    function edit($id)
    {
        $bankgroup=$this->bankgroup->find($id);
        $bank=$this->bankgroup->getBank($bankgroup->bank_id);
        unset($bankgroup->bank); //移除不需要回傳給前端的資料
        return Inertia::render('Backstage/BankGroupForm',[
            'header'=>'編輯題組',
            'button'=>'修改',
            'group'=>$bankgroup,
            'bank'=>$bank
        ]);
    }

    function update(Request $request,$id)
    {
        $this->bankgroup->update($request->input());
        return redirect()->route('backstage.banks',$request->bank_id);
    }

    function destroy($id)
    {
        $this->bankgroup->destroy($id);
        return redirect()->back();
    }
}

app\Services\BankGroupService.php

namespace App\Services;

use App\Repositories\BankGroupRepository;
use App\Repositories\BankRepository;
use stdClass;

class BankGroupService
{
    function __construct(protected BankGroupRepository $bankgroup,
                         protected BankRepository $bank){}

    function all()
    {
        return $this->bankgroup->all();
    }
    
    function getBank($bank_id)
    {
        $bank=$this->bank->find($bank_id);
        $bank->levelC=$bank->levelC;
        return $bank;
    }

    function find($id)
    {
        return $this->bankgroup->find($id);
    }
    
    function getNewGroup($bank)
    {
        $bankgroup=new stdClass;
        $bankgroup->seq=$bank->bankgroups->max('seq')+1;
        $bankgroup->name='';
        $bankgroup->bankId=$bank->id;

        return $bankgroup;
    }
    
    function store($bankgroup)
    {
        $bankgroup['bank_id']=$bankgroup['bankId'];
        unset($bankgroup['bankId']);
        $this->bankgroup->store($bankgroup);
    }

    function update($bankgroup)
    {
        $this->bankgroup->update($group,$id);
    }

    function destroy($id)
    {
        $this->bankgroup->destroy($id);
    }
}

app\Repositories\BankGroupRepository.php

namespace App\Repositories;

use App\Models\BankGroup;

class BankGroupRepository
{
    function __construct(protected BankGroup $bankgroup){}
    
    function all()
    {
        return $this->bankgroup->all();
    }

    function store($bankgroup)
    {
        $this->bankgroup->insert($bankgroup);
    }

    function find($id)
    {
        return $this->bankgroup->find($id);
    }

    function update($bankgroup)
    {
        $this->find($bankgroup['id'])->update($bankgroup);
    }

    function destroy($id)
    {
        $this->bankgroup->find($id)->delete();
    }    
}

題組在題目列表中的功能比較像是篩選條件的存在,所以我沒有打算單獨為題組做一個管理的頁面,而是把題組和題目列表放在一起,使用者可以優先看到不分類的的題目列表,然後選擇題組做篩選;
因此,題組的增/查/改/刪會和題目列表在一起,也就是都在 Backstage\Banks.vue 中,所以我們先來調整一下原本題庫相關的前後端內容:
app\Http\Controllers\SubjectController.php

function subjectsInBank($bank_id)
{
    $subjects=$this->subject->subjectsInBank($bank_id);
    $bank=$this->subject->getBank($bank_id);
    $bankgroups=$this->subject->getGroups($bank_id); //取得題組

    return Inertia::render('Backstage/Subjects',
                    [
                     'subjects'=>$subjects,
                     'bankgroups'=>$bankgroups, //前端資料增加題組
                     'bank'=>$bank,     //前端資料增加題庫
                     'count'=>$subjects->count()
                    ]);
}

app\Services\SubjectService.php

namespace App\Services;

use App\Repositories\SubjectRepository;
use App\Repositories\BankRepository; //引入題庫資源
use App\Repositories\BankGroupRepository;

class SubjectService
{
    function __construct( protected SubjectRepository $subject,
                          protected BankRepository $bank,
                          protected BankGroupRepository $bankgroup){}

    function subjectsInBank($bank_id)
    {
        return $this->subject->subjectsInBank($bank_id);
    }

    function getGroups($bank_id)
    {
        if($this->bank->find($bank_id)->bankgroups()->exists()){
            return $this->bank->find($bank_id)->bankgroups;
        }else{
            return [];
        }
    }
}

app\Repositories\SubjectRepository.php

function subjectsInBank($bank_id)
{
    return $this->subject->where('bank_id',$bank_id)->get();
}

後端的調整大概如上。

接著是前端,我們在題目列表的頁面上加入關於題組的相關功能:
resources\js\Pages\Backstage\Subjects.vue

<script setup>
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import { Head, Link } from "@inertiajs/inertia-vue3";

const props = defineProps({ subjects: Array, 
                            bankgroups:Array, 
                            count: Number ,
                            bank:Object
                          });
</script>
.....略
<div class="w-[calc(100%_-_12rem)] p-4">
  <Link class="inline-block py-2 px-3 border rounded-xl bg-blue-700 
               text-blue-100 my-4 mx-2">
        新增題目
  </Link>

  <!--增加一個新增題組的按鈕-->
  <Link :href="route('bankgroup.create',bank.id)" 
        class="inline-block py-2 px-3 border rounded-xl bg-lime-700 
               text-lime-50 my-4 mx-2">
        新增題組
  </Link>
  <div class="w-full px-4 py-2">題目總數:{{ count }}</div>

  <!--根據目前題庫有無題組來決定是否顯示增加題組的設定-->
  <div v-if="bankgroups.length===0" 
      class="text-2xl font-bold text-amber-100 bg-amber-700 
             m-auto w-1/2 text-center p-5">
    本題庫尚無題組,是否先
    <Link :href="route('bankgroup.create',bank.id)" class="text-lime-300">
        增加
    </Link>
    一個題組?
  </div>
  <!--有題組則列出題組-->
  <div v-else>
    <button class="px-4 py-2 border rounded-lg hover:bg-sky-400 
                   hove:text-sky-900 text-sky-700 bg-sky-200"
            v-for="bankgroup in bankgroups" :key="bankgroup.id">
          {{ bankgroup.name }}
    </button>
  </div>
  <div v-for="subject in subjects"
       :key="subject.id"
       class="w-full border rounded-xl flex p-4 bg-green-400 justify-between">
    <div>{{ subject.seq }}.{{ subject.subject }}
    </div>
    <div>
      <Link>編輯</Link> / <Link>刪除</Link>
    </div>
  </div>
</div>
.....略

</template>

接著就是題組的功能:
resources\js\Pages\Backstage\BankGroupForm.vue

<script setup>
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import { Head, Link, useForm } from "@inertiajs/inertia-vue3";

const props = defineProps({
  bank:Object,
  header: { type: String, default: "新增題組" },
  button: { type: String, default: "新增" },
  group: Object,
});

const form = useForm(props.group);

const submit = () => {
  if (typeof form.id !== "undefined") {
    form.put(route("bankgroup.update", form.id));
  } else {
    form.post(route("bankgroup.store"));
  }
};
</script>

<template>
  <Head title="管理中心" />

  <AuthenticatedLayout>
    <template #header>
      <h2 class="font-semibold text-xl text-gray-800 leading-tight">管理中心</h2>
    </template>

    <div class="w-[calc(100%_-_12rem)] p-4">
      <h2 class="text-xl font-bold">{{ header }}</h2>
      <div>
        <div>
          <label>題組序號:</label>
          <input type="number" name="seq" id="seq" v-model="form.seq" />
        </div>
        <div>
          <label>題組名稱:</label>
          <input type="text" name="name" id="name" v-model="form.name" />
        </div>
        <div>
          <label>題庫:</label>
          <span>{{ bank.name }}{{bank.levelC}}級</span>
        </div>
        <button @click="submit" class="my-2 border px-6 py-2 rounded-xl shadow">
          {{ button }}
        </button>
      </div>
    </div>
  </AuthenticatedLayout>
</template>

和題庫新增不一樣的地方在於題組一定附屬於某個題庫之下,所以我們對於新增時需要的題庫id這個需求可以放到後端來處理,由後端來產生這一個題組的空白物件,並預先把題庫id加上去。

至於題組序號的部份,我也是在後端產生空白物件時先去查一下目前的這個題庫有沒有題組在,如果有題組在就找出最大的seq值然後加1就是新題組的序號。

app\Http\Controllers\BankGroupController.php

function create($bank)
{
    $bank=$this->bankgroup->getBank($bank_id);
    $bankgroup=$this->bankgroup->getNewGroup($bank);
    return Inertia::render('Backstage/BankGroupForm',
        ['bank'=>$bank,
         'group'=>$bankgroup]);
}

app\Services\BankGroupService.php

function find($id)
{
    return $this->bankgroup->find($id);
}

function getNewGroup($bank)
{
    $bankgroup=new stdClass;
    $bankgroup->seq=$bank->bankgroups->max('seq')+1;
    $bankgroup->name='';
    $bankgroup->bankId=$bank->id;

    return $bankgroup;
}

function store($bankgroup)
{
    //把原本前端的變數bankId改成資料表欄位bank_id
    $bankgroup['bank_id']=$bankgroup['bankId'];
    unset($bankgroup['bankId']);
    $this->bankgroup->store($bankgroup);
}

這邊我又挖了個坑給自己,是這樣的,有時前後端對於資料欄位的命名方式有不同的需求,比如後端資料表的欄位命名大多是蛇形命名(aa_bb_cc),而前端比較喜歡駝峰式命名(AaaBbbCcc),這也沒有什麼對錯的問題,就是團隊溝通好就好,如果溝通的結果是兩種命名都要用,那我們可以考慮再加一個格式化資料的類別來處理前後端資料傳遞時的命名轉換問題,不過我目前還沒有很統一做法,所以可能有時有轉換有時沒有,請見諒Orz。

新增完題組的畫面

新增題組表單及自動填入題組序號

題目CRUD修改

接著我們來解決如果沒有新增題組就直接要新增題目怎麼辦?傳統做法就是擋下來,沒有題組就是不能新增題目;

但我是個貼心的人,所以我打算在使用者新增題目時,去檢查目前有沒有題組,如果沒有題組的話就自動新增一個題組,自動新增的題組會是和題庫一樣的名稱,所以我們先解決題目的CRUD,因為我們現在的流程加上了題庫和題組,原本的題目CRUD程式碼需要跟著做修改:

首先是調整一下路由,原本我們是使用Inertia的功能直接在路由回傳頁面,這是因為一開始我們假設了這個新增題目的頁面不會有任何後端資料的傳遞,就只是一個靜態的頁面,所有的資料都是要使用者手動填入的,不過現在因為在進入題目前會走過題庫和題組,所以我們在新增題目時,其實是要先確認一下題庫和題組是誰,然後可以把題庫和題組的id,傳給表單,做法和題組的新增類似:

Route::inertia('/subject/create','Backstage/CreateSubject')
        ->name('subject.create');

改成

//加上題庫的路徑用來表示題目是那個題庫的,同時也把這個題庫id傳到後端
Route::get('/subject/{bank_id}/create',[SubjectController::class,'create'])
->name('subject.create');

app\Http\Controllers\SubjectController.php 新增 create 方法

function create($bank_id){
    return Inertia::render('Backstage/SubjectForm',
                            $this->subject
                                 ->createDefautSubject($bank_id));
}

function store(Request $request)
{
    $this->subject->store($request->input());
    return redirect()->route('bank.subjects',$request->bankId);
}

維持 Controller 不直接處理資料的原則,所以我們把表單需要的資料都交由 Service 來處理
app\Services\SubjectService.php

use App\Repositories\BankGroupRepository; //引入題庫資源庫

class SubjectService
{
function __construct( protected SubjectRepository $subject,
                      protected BankRepository $bank,
                      protected BankGroupRepository $bankgroup){}

//新增一個題組,題組名稱和題庫一樣
function createDefautGroup($bank_id){
    $bank=$this->bank->find($bank_id);
    return $this->bankgroup
                ->store(['seq'=>1,
                         'name'=>$bank->name,
                         'bank_id'=>$bank->id]);
}

//在題目的Service中可以根據題庫id來取得題庫
function getBank($bank_id)
{
    return $this->bank->find($bank_id);
}

//建立前端題目表單用的空白題目資料
function createDefautSubject($bank_id)
{
    $bank=$this->bank->find($bank_id); //取得題庫的資料
    $bank->levelC=$bank->levelC; //取得級別的中文字
    $bankgroups=$this->getGroups($bank_id); //取得題庫的題組資料
    if(is_null($groups)){

        //如果沒有題組資料,自動產生一個預設的題組
        $groups=[$this->createDefautGroup($bank)];
    }

    $seq=$bankgroups[0]->subjects->max('seq')+1; //從第一個題組中找到最大的題號加 1
    unset($bankgroups[0]->subjects); //移除關聯資料
    
    //建立四個空白項目
    $options=[
                ['option'=>'','ans'=>false],
                ['option'=>'','ans'=>false],
                ['option'=>'','ans'=>false],
                ['option'=>'','ans'=>false],
             ];
    
    //回傳一個資料陣列,包含題庫,題組及空白題目資料
    return  [
             'bank'=>$bank,
             'bankgroups'=>$bankgroups,
             'subject'=>['subject'=>'',
                         'seq'=>$seq,
                         'multiple'=>0,
                         'bank_id'=>$bank->id,
                         'bankGroupId'=>$bankgroups[0]->id,
                         'options'=>$options]
            ];
}
}

重新設計題目表單,我們希望維持和題庫及題組表單一樣的狀況,一個表單可以同時處理新增和修改:

<script setup>
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import { Head, Link, useForm } from "@inertiajs/inertia-vue3";

const props=defineProps({
  header: { type: String, default: "新增題目" },
  button: { type: String, default: "新增" },
  bank:Object,
  bankgroups:Array,
  subject:Object
})

//Inertia的表單物件
const form = useForm(props.subject);

//表單傳送時改用Inertia的Form組件來傳送,這是一個ajax的傳送
const submit = () => {
  console.log(form)
  if (typeof form.id !== "undefined") {
    form.put(route("subject.update", form.id));
  } else {
    form.post(route("subject.store",props.bank.code));
  }
};
</script>

<template>
  <Head title="管理中心" />

  <AuthenticatedLayout>
    <template #header>
      <h2 class="font-semibold text-xl text-gray-800 leading-tight">管理中心</h2>
    </template>
    <div class="w-[calc(100%_-_12rem)] p-4">
      <h2 class="text-2xl font-bold my-3">{{ header }}</h2>
      <!--利用Vue的動作修飾詞來中斷表單submit的動作,並觸發自訂的submit函式-->
      <form @submit.prevent="submit">
        <div class="text-xl font-bold flex">
          <div>
              <label>題庫:</label><span>{{ bank.name }}</span>
          </div>
          <div class="ml-6">
              <label>級別:</label><span>{{ bank.levelC }}</span>
          </div>
        </div>
        <div>
          <div>
              <label>題組:</label>
              <select name="bankgroup" v-model="form.bankGroupId">
                <option v-for="bankgroup in bankgroups" 
                        :key="bankgroup.id" 
                        :value="bankgroup.id">
                    {{ bankgroup.name }}
                </option>
              </select>
          </div>
          <div>
            <label>題型:</label>
            <input type="radio" name="multiple" v-model="form.multiple"
                   value="0" />單選
            <input type="radio" name="multiple" v-model="form.multiple"
                   value="1" />複選
          </div>
        </div>
        <div class="my-1">
          <label>題號:</label>
          <input type="number" name="seq" min="1" v-model="form.seq" />
        </div>
        <div class="my-1">
          <label>題目:</label>
          <input type="text" name="subject" v-model="form.subject"
                 class="w-[90%]" />
        </div>
        <!--選項由寫死四個項目改成v-for -->
        <div class="my-1 ml-[3%]" 
             v-for="(option ,idx) in form.options" 
             :key="idx">
          <label for="">選項{{ idx+1 }}:</label>
          <input type="text" class="w-[89%]" v-model="option.option" />
          <input type="checkbox" v-model="option.ans" />
        </div>
        
        <button type="submit"
                class="border py-2 px-4 bg-blue-700 text-blue-100 rounded-xl">
          {{ button }}
        </button>
      </form>
    </div>
  </AuthenticatedLayout>
</template>

app\Repositories\SubjectRepository.php 處理新增題目

function create($subject)
{
    $options=$subject['options'];
    $subjectId=$this->createSubject($subject);
    $this->createOptions($options,$subjectId);
}

function createSubject($subject)
{
    $subject['bank_id']=$subject['bankId'];
    $subject['bank_group_id']=$subject['bankGroupId'];
    unset($subject['bankId'],$subject['bankGroupId'],$subject['options']);
    return $this->subject->create($subject)->id;
}

function createOptions($options,$subjectId)
{
    foreach($options as $option)
    {
        $option['subject_id']=$subjectId;
        $this->option->create($option);
    }
}

接著把編輯和刪除都補上:
app\Http\Controllers\SubjectController.php

function edit($id)
{
    return Inertia::render('Backstage/SubjectForm',
                            $this->subject
                                 ->subjectEdit($id));
}

function update(Request $request,$id)
{
    $this->subject->update($request->input());
    return redirect()->route('bank.subjects',$request->bankId);
}

function destroy($id)
{
    $this->subject->destroy($id);
    return redirect()->back();
}

app\Services\SubjectService.php

//製作題目編輯時需要的資料給前端使用
function subjectEdit($id)
{
    $subject=$this->find($id);
    $bank=$this->bank->find($subject->bank_id);
    $bank->levelC=$bank->levelC;
    $bankgroups=$this->getGroups($bank->id);
    $options=$subject->options->map(function($opt){
        $opt->ans=($opt->ans==1)?true:false;
        return $opt;
    });
    $subject->bankId=$subject->bank_id;
    $subject->bankGroupId=$subject->bank_group_id;
    unset($subject->bank_id,$subject->bank_group_id);
    return  [
             'bank'=>$bank,
             'bankgroups'=>$bankgroups,
             'subject'=>$subject,
             'header'=>'編輯題目',
             'button'=>'修改'
            ];
}

app\Repositories\SubjectRepository.php


//更新
function update($subject)
{
    $options=$subject['options'];
    unset($subject['options']);
    $this->updateSubject($subject);
    $this->updateOptions($options);
}

//更新題目
function updateSubject($subject)
{
    $id=$subject['id'];
    $subject['bank_id']=$subject['bankId'];
    $subject['bank_group_id']=$subject['bankGroupId'];
    unset($subject['id'],$subject['bankId'],$subject['bankGroupId']);
    $this->subject->find($id)->update($subject);
}

//更新選項
function updateOptions($options)
{
    foreach($options as $option)
    {
        $id=$option['id'];
        unset($option['id']);
        $this->option->find($id)->update($option);
    }
}

//當題目被刪除時,關聯的選項也應該一併刪除
function destroy($id)
{
    $subject=$this->subject->find($id);
    $options=$subject->options;
    $options->map(function($opt){
        $opt->delete();
    });

    $subject->delete();
}

resources\js\Pages\Backstage\Subjects.vue

...略
<div>
  <!--加上編輯題目的路由-->
  <Link :href="route('subject.edit',subject.id)" 
        method="get" 
        as="button">
        編輯
  </Link>
  /
 <!--加上刪除題目的路由-->
  <Link :href="route('subject.destroy',subject.id)" 
        method="delete" 
        as="button">
        刪除
  </Link>
</div>

編輯題目時會自動帶入資料

呼,好累,今天總算是有點正式的完成了核心的功能,題庫系統。


上一篇
Day12 完善題庫設定功能-善用ORM
下一篇
Day14 補完題組編輯及刪除 - 引入fontawesome
系列文
LV的全端開發體驗30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言