iT邦幫忙

2022 iThome 鐵人賽

DAY 21
0
Modern Web

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

Day21 儲存使用者的測驗結果

  • 分享至 

  • xImage
  •  

花了不少時間在處理後台的架構及一些套件的使用,接著,我們要來把測驗結果做儲存,僅限註冊會員可以儲存,非會員則是使用先前的 excel 匯出來自行留存。

測驗結果的資料表只需要儲存整份測驗的json格式就可以了,因此不需要設計太複雜的欄位,測驗結果也是會簡化成json的格式直接存入欄位即可,不過這樣的簡化對於之後的數據分析會是一個挑戰,但到時再說了,沒有解決不了的問題。

先建立一個測驗資料表

php artisan make:model Test -m

database\migrations\xxxxxx_create_tests_table.php 資料表欄位設定

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::create('tests', function (Blueprint $table) {
            $table->id();
            $table->unsignedInteger('user_id');
            $table->text('subjects');
            $table->text('result')->default(null);
            $table->string('type');
            $table->unsignedTinyInteger('score')->default(0);
            $table->timestamps();
        });
    }

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

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

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Test extends Model
{
    use HasFactory;
    protected $guarded=[];
    
    function user()
    {
        return $this->belongsTo(User::class);
    }
}

app\Models\User.php

function tests()
{
    return $this->hasMany(Test::class);
}

在測驗結果頁增加一個按鈕,只有登入的會員才能看到,點擊後儲存測驗結果:

.....略
<button v-if="$page.props.auth.user"
        class="px-4 py-2 bg-lime-100 mx-3 text-green-900 border rounded-lg"
        @click="testSave">
  儲存結果
</button>

增加一個儲存測試結果用的路由
routes\web.php

Route::post('/test/save/{userId}',[TestController::class,'testSave'])
        ->name('test.save');

在Controller傳遞資料,這裏其實應該要對傳來的資料做基本的驗證,不過我們先略過。
app\Http\Controllers\TestController.php

function testSave(Request $request ,$userId)
{
    $this->test->resultSave($request->input(),$userId);

    return redirect("/");
}

在Service中處理測驗的資料,這邊主要是把題目的內容簡化,像是文字的部份就不存了,讓資料表中的資料最小化,之後取出時再去撈關聯的資料就可以了,而result欄位則是用來統計測試的結果,減少日後每次重新讀取測驗再計算的工夫。

app\Services\TestService.php

//之前是用臨時的數字,現在有資料表了,可以做正式的資料回傳
function infos()
{
    return ['count'=>$this->test->count()];
}

function resultSave($data,$userId)
{
    $subjects=[];
    $result=[];
    collect($data['subjects'])
        ->each(function($subject,$idx)use(&$subjects){
            $subjects[]=['id'=>$subject['id'],
                         'seq'=>$idx+1,
                         'select'=>$subject['selectSeq'],
                         'options'=>collect($subject['options'])
                                    ->map(function($option,$i){
                                        unset($option['option'],$option['subject_id']);
                                        $option['seq']=$i+1;
                                        return $option;
                                    }),
                         'result'=>$subject['result'],
                         ];
        });
    $result['total']=count($data['subjects']);
    $result['correct']=collect($data['subjects'])
                        ->where('result',true)
                        ->count();
    $result['wrong']=collect($data['subjects'])
                        ->where('result',false)
                        ->count();

    $this->test->save(['user_id'=>$userId,
                       'subjects'=>json_encode($subjects),
                       'result'=>json_encode($result),
                       'type'=>$data['type']]);
}

在Repository中做存入資料表的動作
app\Repositories\TestRepository.php

namespace App\Repositories;

use App\Models\Test;

class TestRepository 
{
    function __construct(protected Test $test){}

    function count(){ return $this->test->count(); }

    function all(){ return $this->test->all(); }

    function save($testResult)
    {
        //補上測驗存入的時間
        $testResult['created_at']=date("Y-m-d H:i:s");
        $testResult['updated_at']=date("Y-m-d H:i:s");
        $this->test->insert($testResult);
    }
}

由於我們可以在管理者後台檢視測驗活動的狀況,所以如果有人完成測驗並存入資料表中,管理者後台可以看到最新的數據:

可以儲存測驗的結果,當然也要讓使用者可以再次拿出來看結果,所以我們來整修一下會員中心,先把會員中心仿照管理後台的版面重建一下,未來會慢慢加上更多的功能上去;

先來建立會員中心的幾個後台路由,並且將會員中心的路由都加上中介層的檢查,而且會員中心應該要綁定會員的ID來做區隔(突然想到後台管理者如果不只一人,是不是也一樣?),這關係到我們前面做的一堆和路由相關的設計,所以和會員中心相關的路由都要再加上id:
routes\web.php

Route::middleware(['auth', 'verified','role:user'])->group(function(){
    Route::get('/userhome/{user_id}', [UserController::class,'userCenter'])
            ->name('userhome');
    Route::get('/userhome/{user_id}/tests', [UserController::class,'testList'])
            ->name('userhome.tests');
    Route::get('/userhome/{user_id}/analysis', [UserController::class,'testAnalysis'])
            ->name('userhome.analysis');
});

登入及中介層的路由也要修改
app\Http\Middleware\RedirectIfAuthenticated.php

public function handle(Request $request, Closure $next, ...$guards)
{
    $guards = empty($guards) ? [null] : $guards;

    foreach ($guards as $guard) {
        if (Auth::guard($guard)->check()) {
            //根據登入者不同的角色,導向到不同的頁面去
            switch(Auth::user()->role){
                case 'user':
                    dd(Auth::user()->id);
                    return redirect()->route('userhome',Auth::user()->id);
                break;
                case 'admin':
                    return redirect(RouteServiceProvider::HOME);
                break;
            }
        }
    }
    return $next($request);
}

app\Http\Middleware\RedirectByAuthcatedRole.php

public function handle(Request $request, Closure $next, $guard)
{       
    //根據登入者不同的角色,導向到不同的頁面去
    if(Auth::user()->role!==$guard){
        switch(Auth::user()->role){
            case 'user':
                return redirect()->route('userhome',Auth::user()->id);
            break;
            case 'admin':
                return redirect(RouteServiceProvider::HOME);
            break;
        }
    }
    return $next($request);
}

登入
app\Http\Controllers\Auth\AuthenticatedSessionController.php

public function store(LoginRequest $request)
{
    $request->authenticate();

    $request->session()->regenerate();

    switch(Auth::user()->role){
        case 'user':
            return redirect()->intended(route('userhome',Auth::user()->id));
        break;
        case 'admin':
            return redirect()->intended(RouteServiceProvider::HOME);
        break;
    }
}

註冊
app\Http\Controllers\Auth\RegisteredUserController.php

public function store(Request $request)
{
    $request->validate([
        'name' => 'required|string|max:255',
        'email' => 'required|string|email|max:255|unique:users',
        'password' => ['required', 'confirmed', Rules\Password::defaults()],
    ]);

    $user = User::create([
        'name' => $request->name,
        'email' => $request->email,
        'password' => Hash::make($request->password),
    ]);

    event(new Registered($user));

    Auth::login($user);

    return redirect()->route('userhome',Auth::user()->id);
}

調整一下會員中心的頁面
resources\js\Layouts\AuthenticatedUserLayout.vue 和管理者後台幾乎一樣,只是路由連結要加上user_id及增加一個會員中心的選單不一樣

.....略

  <!-- Logo -->
  <div class="shrink-0 flex items-center">
    <Link :href="route('userhome',$page.props.auth.user.id)">
      <ApplicationLogo class="block h-9 w-auto" />
    </Link>
  </div>
  
.....略

  <!-- Navigation Links -->
  <div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
    <NavLink 
      :href="route('userhome',$page.props.auth.user.id)" 
      :active="route().current('userhome',$page.props.auth.user.id)">
      會員中心
    </NavLink>
  </div>

.....略

<!-- Responsive Navigation Menu -->
<div class="pt-2 pb-3 space-y-1">
  <ResponsiveNavLink :href="route('userhome',$page.props.auth.user.id)"
                     :active="route().current('userhome',$page.props.auth.user.id)">
        會員中心
  </ResponsiveNavLink>
</div>

.....略

<!-- Page Content -->
<main class="relative z-10">
   <!--後台主內容區-->
   <div class="w-full bg-white flex">
      <UserhomeLeftMenu />
      <slot />
   </div>
</main>

resources\js\Layouts\UserhomeLeftMenu.vue也幾乎一樣,只是選單名稱不一樣,及路由加上ID

<script setup>
import { storeToRefs } from "pinia";
import { menuStore } from "@/Stores/MenuStore.js";
import MenuItem from "@/QuizComponents/MenuItem.vue";

const store = menuStore();
const { selected } = storeToRefs(store);
</script>
<template>
  <!--左側選單-->
  <div class="w-48 max-w-96 bg-black text-white py-8 px-2 shadow-lg h-[calc(100vh_-_138px)]">
    <MenuItem :href="route('userhome.tests',$page.props.auth.user.id)" 
              :onSelected="selected['測驗紀錄']">
        測驗紀錄
    </MenuItem>
    <MenuItem :href="route('userhome.analysis',$page.props.auth.user.id)" 
              :onSelected="selected['統計分析']">
        統計分析
    </MenuItem>
  </div>
</template>

最後是前台首頁的路由,右上的會員資訊及連結的網址也要帶上user_id

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

const userInfo = reactive({
  user: { string: "會員中心", },
  admin: { string: "管理中心", },
});
</script>
<template>
  <Head title="學科測驗系統" />

  <div class="relative">

    <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="route('userhome',$page.props.auth.user.id)"
            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 />
    </div>
  </div>    
</template>

接著到 app\Http\Controllers\UserController.php 把使用者的相關方法建立起來:

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Inertia\Inertia;
use App\Services\TestService;

class UserController extends Controller
{
    function __construct(protected TestService $test){}

    function userCenter($user_id){
        return Inertia::render('UserHome/UserHome',
                ['tests'=>$this->test->testsByUserId($user_id)]);
    }

    function testList($user_id){
    }

    function testAnalysis($user_id){
    }
}

app\Services\TestService.php中調整下回傳測驗資料的方法。

function all(){ return $this->test->all(); }

function testsByUserId($user_id)
{
    return $this->test
                ->testsByUserId($user_id)
                ->map(function($test){
                    $t=[];
                    $t['id']=$test->id;
                    $t['time']=date("Y-m-d H:i:s",strtotime($test->created_at));
                    $t['result']=json_decode($test->result);
                    return $t;
                });
}

app\Repositories\TestRepository.php 處理資料表的資料回傳:

function all(){ return $this->test->all(); }

function testsByUserId($user_id)
{
    return $this->test->where('user_id',$user_id)->get();
}

最後是 resources\js\Pages\UserHome\UserHome.vue 頁面的呈現

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

const props=defineProps({tests:Array});

</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 flex flex-wrap">
      <div class="w-1/2 border rounded-xl flex p-4 h-full bg-sky-400">
        <div class="w-full bg-white overflow-hidden shadow-sm sm:rounded-lg">
          <div class=" p-4">
            <div class="font-bold text-xl my-4">測驗紀錄:{{ tests.length }}</div>
              <ul>
                <li class="py-2 px-4 border rounded flex justify-between 
                           text-center bg-lime-600 text-lime-50">
                    <div class="w-1/2">測驗時間</div>
                    <div class="w-2/12">題數</div>
                    <div class="w-2/12">正確</div>
                    <div class="w-2/12">錯誤</div>
                </li>
                <li v-for="test in tests" :key="test.id"
                    class="py-2 px-4 border rounded flex justify-between 
                           text-center -mt-[1px]">
                    <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>
                </li>
              </ul>
          </div>
        </div>
      </div>
      <div class="w-1/2 border rounded-xl flex p-4 h-full bg-green-400">
        <div class="w-full bg-white overflow-hidden shadow-sm sm:rounded-lg">
            <div class="p-4">
              <div class="font-bold text-xl my-4">統計分析</div>
              <div class="pl-4">最高成績:</div>
              <div class="pl-4">最低成績:</div>
              <div class="pl-4">平均成績:</div>
              <div class="pl-4">測驗次數:</div>
            </div>
        </div>
      </div>

    </div>
  </AuthenticatedUserLayout>
</template>

不同的使用者會有不同的測驗紀錄

會員中心只會顯示近期的五筆紀錄,統計分析也只會顯示最基本的資料,然後左側選單點擊後會顯示各自更完整的內容。

顯示紀錄的部份我們明天再說,單純的顯示紀錄並不難,但是由於顯示測驗題目這件事在前台,兩個後台都會常用到,如果照原本的流程設計方式,我們會建立三個頁面來顯示,但其實這三個頁面的內容是差不多的,如果將來我想改變題目顯示的外觀或一些互動的話,是不是三個頁面都要改?只要同樣的事可能重覆三次以上,我們就要來想想有沒有可以簡化做法的方式(絕不是為了湊天數


上一篇
Day20 解決一下後台路由導向問題-Middleware及Guard
下一篇
Day22 查看測驗紀錄-組件的組件的組件,聊聊重覆利用的設計
系列文
LV的全端開發體驗30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言