2026年1月26日 星期一

Laravel 12 CSV 匯入與雙向查詢系統

 一、系統定位
        本系統使用 Laravel 12 建立一套 CSV 匯入系統,同時兼顧 資料驗證、安全性、API 設計與效能可將學生帳號資料寫入資料庫,並支援以「年班座號」查詢帳號 或 以「帳號」反查年班座號


二、功能清單
    1.CSV 上傳 UI(Blade)
    2.資料驗證(CSV 欄位、重複資料)
    3.密碼加密(Hash)
    4.API 回傳時隱藏密碼
    5.RESTful API 設計
    6.錯誤處理(找不到資料)

三、建立 Laravel 12 專案
    指令:composer create-project laravel/laravel csv-demo
                cd csv-demo

四、建立資料表 create_student_accounts_table
    指令:php artisan make:migration create_student_accounts_table
    編輯 database/migrations/xxxx_create_student_accounts_table.php
    =>為了提升查詢效能,針對查詢欄位建立 unique index

<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('student_accounts', function (Blueprint $table) {
            $table->id();
            $table->string('grade_class_seat_no')->unique()->index();
            $table->string('account')->unique()->index();
            $table->string('password');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('student_accounts');
    }
};


五、建立Model StudentAccount
    指令:php artisan make:model StudentAccount
    編輯 app/Models/StudentAccount.php
   
=>API 回傳時自動隱藏密碼,避免敏感資料外洩
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class StudentAccount extends Model
{
    //
    protected $fillable = [
        'grade_class_seat_no',
        'account',
        'password',
    ];

    protected $hidden = [
        'password',
    ];
}


六、建立Controller StudentAccountController
    指令:php artisan make:controller StudentAccountController
    編輯 app/Http/Controllers/StudentAccountController.php
    => 使用 Hash::make 保護密碼
          使用 firstOrFail 統一錯誤處理
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use App\Models\StudentAccount;

class StudentAccountController extends Controller
{
    /**
     * CSV 匯入(Web + API 共用)
     */
    public function import(Request $request)
    {
        $request->validate([
            'csv' => 'required|file|mimes:csv,txt',
        ]);

        $file = $request->file('csv');
        $handle = fopen($file->getRealPath(), 'r');

        $header = fgetcsv($handle);

        if ($header !== ['GradeClassSeatNo', 'Account', 'Password']) {
            return back()->withErrors(['CSV 欄位格式錯誤']);
        }

        $count = 0;

        while (($row = fgetcsv($handle)) !== false) {
            $data = array_combine($header, $row);

            StudentAccount::updateOrCreate(
                ['grade_class_seat_no' => $data['GradeClassSeatNo']],
                [
                    'account' => $data['Account'],
                    'password' => Hash::make($data['Password']),
                ]
            );

            $count++;
        }

        fclose($handle);

        /**
         * 判斷來源:Web or API
         */
        if ($request->is('api/*')) {
            return response()->json([
                'message' => 'CSV 匯入完成',
                'count'   => $count,
            ]);
        }

        return redirect('/import-page')
            ->with('success', "匯入成功,共 {$count} 筆資料");
    }

    /**
     * 用年班座號查(API)
     */
    public function findBySeat($seatNo)
    {
        return StudentAccount::where('grade_class_seat_no', $seatNo)
            ->firstOrFail();
    }

    /**
     * 用帳號查(API)
     */
    public function findByAccount($account)
    {
        return StudentAccount::where('account', $account)
            ->firstOrFail();
    }

    /**
     * Blade 查詢頁
     */
    public function search(Request $request)
    {
        $request->validate([
            'seat_no' => 'nullable|string',
            'account' => 'nullable|string',
        ]);

        $student = null;

        if ($request->filled('seat_no')) {
            $student = StudentAccount::where(
                'grade_class_seat_no',
                $request->seat_no
            )->first();
        }

        if ($request->filled('account')) {
            $student = StudentAccount::where(
                'account',
                $request->account
            )->first();
        }

        return view('search', compact('student'));
    }
}


七、建立路由 與 Api 路由
    同一個匯入邏輯同時支援 Web 與 API,但依照請求來源回傳不同格式,兼顧 UX 與 API 設計。
    編輯 routes/web.php
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\StudentAccountController;

Route::get('/', function () {
    return view('welcome');
});

// CSV 匯入頁
Route::get('/import-page', function () {
    return view('import');
});
Route::post('/import', [StudentAccountController::class, 'import'])
    ->name('students.import');

// 查詢頁
Route::get('/search', function () {
    return view('search');
});

Route::post('/search', [StudentAccountController::class, 'search']);


    編輯 routes/web.php
<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\StudentAccountController;

Route::prefix('students')->group(function () {
    Route::post('/import', [StudentAccountController::class, 'import']);
    Route::get('/seat/{seatNo}', [StudentAccountController::class, 'findBySeat']);
    Route::get('/account/{account}', [StudentAccountController::class, 'findByAccount']);
});



八、建立Blade 上傳頁面 與 Blade 查詢頁面
    編輯 resources/views/import.blade.php
<!DOCTYPE html>
<html>
<head>
    <title>CSV 匯入</title>
</head>
<body>
<h2>學生帳號 CSV 匯入</h2>

<form action="{{ route('students.import') }}" method="POST" enctype="multipart/form-data">
    @csrf
    <input type="file" name="csv" required>
    <button type="submit">匯入</button>
</form>

@if ($errors->any())
    <p style="color:red">{{ $errors->first() }}</p>
@endif
@if (session('success'))
    <p style="color:green">{{ session('success') }}</p>
@endif
</body>
</html>


    編輯 resources/views/search.blade.php
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>學生帳號查詢系統</title>
    <style>
        body {
            font-family: Arial;
            margin: 40px;
        }
        input {
            padding: 6px;
            width: 250px;
        }
        button {
            padding: 6px 12px;
        }
        .box {
            margin-bottom: 20px;
        }
        .result {
            margin-top: 20px;
            padding: 15px;
            border: 1px solid #ccc;
        }
        .error {
            color: red;
        }
    </style>
</head>
<body>

<h2>學生帳號查詢(Demo)</h2>

<form method="POST" action="/search">
    @csrf

    <div class="box">
        <label>GradeClassSeatNo:</label><br>
        <input type="text" name="seat_no" placeholder="例如:30101">
    </div>

    <div class="box">
        <label>Account:</label><br>
        <input type="text" name="account" placeholder="例如:student01">
    </div>

    <button type="submit">查詢</button>
</form>

{{-- 查詢結果 --}}
@if(isset($student) && $student)
    <div class="result">
        <h4>查詢結果</h4>
        <p><strong>GradeClassSeatNo:</strong> {{ $student->grade_class_seat_no }}</p>
        <p><strong>Account:</strong> {{ $student->account }}</p>
    </div>
@endif

{{-- 查無資料 --}}
@if(isset($student) && !$student)
    <p class="error">查無資料</p>
@endif
{{-- 顯示錯誤訊息 --}}
@if ($errors->any())
    <ul class="error">
        @foreach ($errors->all() as $error)
            <li>{{ $error }}</li>
        @endforeach
    </ul>
@endif

</body>
</html>

































2026年1月25日 星期日

在本機 laravel 12 ,上傳很多PDF,上傳之後可以自己排序,合併之後下載-方法二

二、專案結構
laravel12Pdf/
├── app/
│   ├── Http/
│   │   ├── Controllers/
│   │   │   └── PdfController.php
│   │   └── ...
│   ├── Models/
│   │   └── Pdf.php
│   └── ...
├── database/
│   ├── migrations/
│   │   └── xxxx_xx_xx_create_pdfs_table.php
│   └── ...
├── resources/
│   ├── views/
│   │   └── pdf/
│   │       └── index.blade.php
│   └── ...
├── routes/
│   └── web.php
├── storage/
│   └── app/
│       └──private/
└──pdfs/          (上傳的 PDF 存放在這裡)
├── vendor/
│   └── setasign/
│       ├── fpdi/
│       └── fpdf/

三、核心技術選擇
功能技術選擇原因
後端框架    Laravel 12    快速開發、完整生態
檔案儲存    Storage local    本機、簡單、統一管理
PDF 合併    FPDI + FPDF    純 PHP、穩定、無外部依賴
前端排序    SortableJS    輕量、好用、拖曳排序
AJAX    Fetch + CSRF    無刷新操作、符合 Laravel 安全機制
資料庫    MySQL/SQLite    記錄檔案、排序、可擴充

四、建立專案 laravel12Pdf 與 安裝套件 setasign/fpdi 與 setasign/fpdf
    指令:composer create-project --prefer-dist laravel/laravel laravel12Pdf
                cd laravel12Pdf
    指令:composer require setasign/fpdi
    指令:composer require setasign/fpdf

五、建資料表(存 PDF 檔案與排序)
    指令:php artisan make:migration create_pdfs_table
    編輯:
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('pdfs', function (Blueprint $table) {
            $table->id();
            $table->string('filename');
            $table->string('path');
            $table->integer('sort')->default(0);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('pdfs');
    }
};


六、建立 Model
    指令:php artisan make:model Pdf
    編輯:app/Models/Pdf.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;

// app/Models/Pdf.php
class Pdf extends Model
{
    protected $fillable = ['filename','path','sort'];
}

七、建立 Controller
    指令:php artisan make:controller PdfController
    編輯:app/Http/Controllers/PdfController.php
<?php

namespace App\Http\Controllers;

use App\Models\Pdf;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use setasign\Fpdi\Fpdi;

class PdfController extends Controller
{
    // 1. 顯示頁面
    public function index(){
        $pdfs = Pdf::orderBy('sort')->get();
        return view('pdf.index', compact('pdfs'));
    }
    // 2. 上傳 PDF
    public function upload(Request $request){
        $request->validate([
            'pdfs.*' => 'required|file|mimes:pdf|max:20480',
        ]);
        foreach ($request->file('pdfs') as $file) {
            // 這裡一定要指定 disk 為 local
            $path = $file->store('pdfs', 'local');
            // 檢查是否真的存在
            if (!Storage::disk('local')->exists($path)) {
                return back()->with('error', '檔案存檔失敗:' . $file->getClientOriginalName());
            }
            Pdf::create([
                'filename' => $file->getClientOriginalName(),
                'path' => $path,
                'sort' => Pdf::max('sort') + 1,
            ]);
        }
        return back()->with('success', '上傳成功');
    }

    // 3. 更新排序
    public function sort(Request $request){
        $order = $request->order; // array [id1, id2, id3]
        foreach ($order as $index => $id) {
            Pdf::where('id', $id)->update(['sort' => $index]);
        }
        return response()->json(['status' => 'ok']);
    }
    // 4. 合併並下載
    public function merge(){
        $pdfs = Pdf::orderBy('sort')->get();
        // 如果沒有任何 PDF,直接提示
        if ($pdfs->isEmpty()) {
            return back()->with('error', '沒有任何 PDF 可合併');
        }
        $pdf = new Fpdi();
        foreach ($pdfs as $item) {
            // 檢查檔案是否存在
            if (!Storage::disk('local')->exists($item->path)) {
                return back()->with('error', "檔案不存在:{$item->filename}");
            }
            $filePath = Storage::disk('local')->path($item->path);
            $pageCount = $pdf->setSourceFile($filePath);
            for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) {
                $templateId = $pdf->importPage($pageNo);
                $size = $pdf->getTemplateSize($templateId);

                $pdf->AddPage($size['orientation'], [$size['width'], $size['height']]);
                $pdf->useTemplate($templateId);
            }
        }
        $filename = 'merged_' . time() . '.pdf';
        return response($pdf->Output('S'))
            ->header('Content-Type', 'application/pdf')
            ->header('Content-Disposition', "attachment; filename=\"$filename\"");
    }
}

八、編輯 Route 
    編輯 routes/web.php
<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\PdfController;
Route::get('/', function () {
    return view('welcome');
});
Route::get('/pdf', [PdfController::class, 'index']);
Route::post('/pdf/upload', [PdfController::class, 'upload']);
Route::post('/pdf/sort', [PdfController::class, 'sort']);
Route::get('/pdf/merge', [PdfController::class, 'merge']);


九、新增並編輯 resources/views/pdf/index.blade.php
<!-- resources/views/pdf/index.blade.php -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>PDF 合併</title>
</head>
<body>

<h2>上傳 PDF</h2>
<form action="/pdf/upload" method="POST" enctype="multipart/form-data">
    @csrf
    <input type="file" name="pdfs[]" multiple accept="application/pdf">
    <button type="submit">上傳</button>
</form>

<hr>

<h2>排序 PDF(拖曳)</h2>
<ul id="pdf-list">
    @foreach($pdfs as $pdf)
        <li data-id="{{ $pdf->id }}">
            {{ $pdf->filename }}
            <button class="delete-btn" data-id="{{ $pdf->id }}">刪除</button>
        </li>
    @endforeach
</ul>

<button id="save-sort">儲存排序</button>

<hr>

<a href="/pdf/merge" target="_blank">合併並下載</a>

<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<script>
    const el = document.getElementById('pdf-list');
    const sortable = new Sortable(el, {
        animation: 150
    });

    document.getElementById('save-sort').addEventListener('click', function () {
        const order = Array.from(el.children).map(li => li.getAttribute('data-id'));

        fetch('/pdf/sort', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': '{{ csrf_token() }}'
            },
            body: JSON.stringify({ order })
        }).then(res => res.json())
          .then(data => alert('排序已儲存'));
    });

</script>
</body>
</html>

十、加上刪除 PDF 的功能
    1.刪除資料庫紀錄
    2.刪除 storage 內的實體檔案
    3.前端按鈕、AJAX 刪除、排序不會被打亂
    4.刪除後自動更新列表
    5.修改 routes/web.php
<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\PdfController;
Route::get('/', function () {
    return view('welcome');
});
Route::get('/pdf', [PdfController::class, 'index']);
Route::post('/pdf/upload', [PdfController::class, 'upload']);
Route::post('/pdf/sort', [PdfController::class, 'sort']);
Route::get('/pdf/merge', [PdfController::class, 'merge']);
// 新增刪除
Route::delete('/pdf/{id}', [PdfController::class, 'destroy']);
    
    6.修改 PdfController(新增 destroy)
<?php

namespace App\Http\Controllers;
use App\Models\Pdf;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use setasign\Fpdi\Fpdi;
class PdfController extends Controller
{
    public function index(){
        $pdfs = Pdf::orderBy('sort')->get();
        return view('pdf.index', compact('pdfs'));
    }
    public function upload(Request $request){
        $request->validate([
            'pdfs.*' => 'required|file|mimes:pdf|max:20480',
        ]);
        foreach ($request->file('pdfs') as $file) {
            $path = $file->store('pdfs', 'local');
            if (!Storage::disk('local')->exists($path)) {
                return back()->with('error', '檔案存檔失敗:' . $file->getClientOriginalName());
            }
            Pdf::create([
                'filename' => $file->getClientOriginalName(),
                'path' => $path,
                'sort' => Pdf::max('sort') + 1,
            ]);
        }
        return back()->with('success', '上傳成功');
    }
    public function sort(Request $request){
        $order = $request->order;
        foreach ($order as $index => $id) {
            Pdf::where('id', $id)->update(['sort' => $index]);
        }
        return response()->json(['status' => 'ok']);
    }
    public function merge(){
        $pdfs = Pdf::orderBy('sort')->get();
        if ($pdfs->isEmpty()) {
            return back()->with('error', '沒有任何 PDF 可合併');
        }
        $pdf = new Fpdi();
        foreach ($pdfs as $item) {
            if (!Storage::disk('local')->exists($item->path)) {
                return back()->with('error', "檔案不存在:{$item->filename}");
            }
            $filePath = Storage::disk('local')->path($item->path);
            $pageCount = $pdf->setSourceFile($filePath);
            for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) {
                $templateId = $pdf->importPage($pageNo);
                $size = $pdf->getTemplateSize($templateId);
                $pdf->AddPage($size['orientation'], [$size['width'], $size['height']]);
                $pdf->useTemplate($templateId);
            }
        }
        $filename = 'merged_' . time() . '.pdf';
        return response($pdf->Output('S'))
            ->header('Content-Type', 'application/pdf')
            ->header('Content-Disposition', "attachment; filename=\"$filename\"");
    }
    // ✅ 新增:刪除 PDF
    public function destroy($id){
        $pdf = Pdf::findOrFail($id);
        // 刪除實體檔案
        if (Storage::disk('local')->exists($pdf->path)) {
            Storage::disk('local')->delete($pdf->path);
        }
        // 刪除資料庫紀錄
        $pdf->delete();
        return response()->json(['status' => 'deleted']);
    }
}


    7.修改
resources/views/pdf/index.blade.php
<!-- resources/views/pdf/index.blade.php -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>PDF 合併</title>
</head>
<body>

<h2>上傳 PDF</h2>
<form action="/pdf/upload" method="POST" enctype="multipart/form-data">
    @csrf
    <input type="file" name="pdfs[]" multiple accept="application/pdf">
    <button type="submit">上傳</button>
</form>

<hr>

<h2>排序 PDF(拖曳)</h2>
<ul id="pdf-list">
    @foreach($pdfs as $pdf)
        <li data-id="{{ $pdf->id }}">
            {{ $pdf->filename }}
        </li>
    @endforeach
</ul>

<button id="save-sort">儲存排序</button>

<hr>

<a href="/pdf/merge" target="_blank">合併並下載</a>

<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<script>
    const el = document.getElementById('pdf-list');
    const sortable = new Sortable(el, {
        animation: 150
    });
    document.getElementById('save-sort').addEventListener('click', function () {
        const order = Array.from(el.children).map(li => li.getAttribute('data-id'));

        fetch('/pdf/sort', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': '{{ csrf_token() }}'
            },
            body: JSON.stringify({ order })
        }).then(res => res.json())
          .then(data => alert('排序已儲存'));
    });

    document.querySelectorAll('.delete-btn').forEach(btn => {
        btn.addEventListener('click', function () {
            const id = this.getAttribute('data-id');
            if (!confirm('確定要刪除嗎?')) return;
            fetch(`/pdf/${id}`, {
                method: 'DELETE',
                headers: {
                    'X-CSRF-TOKEN': '{{ csrf_token() }}',
                    'Content-Type': 'application/json'
                }
            })
            .then(res => res.json())
            .then(data => {
                if (data.status === 'deleted') {
                    // 刪除畫面上項目
                    const li = document.querySelector(`li[data-id="${id}"]`);
                    li.remove();
                }
            });
        });
    });
</script>
</body>
</html>

















Laravel 12 CSV 匯入與雙向查詢系統

 一、系統定位           本系統使用 Laravel 12 建立一套 CSV 匯入系統 ,同時兼顧 資料驗證、安全性、API 設計與效能 可將學生帳號資料寫入資料庫,並支援 以「年班座號」查詢帳號 或  以「帳號」反查 年班座號 二、功能清單     1. CSV 上傳...