系列文章:
1.利用 laravel 12 做一個類似Google文件的簡單範例
2.在本機Laravel 12 中實作 Google OAuth2 登入
3.如何在 Laravel 12 使用 Gmail SMTP 寄信
4.在本機Laravel 12 中實作 FaceBook OAuth2 登入
5.在本機 laravel 12 ,上傳很多PDF,上傳之後可以自己排序,合併之後下載-方法一
6.在本機 laravel 12 ,上傳很多PDF,上傳之後可以自己排序,合併之後下載-方法二
1.利用 laravel 12 做一個類似Google文件的簡單範例
2.在本機Laravel 12 中實作 Google OAuth2 登入
3.如何在 Laravel 12 使用 Gmail SMTP 寄信
4.在本機Laravel 12 中實作 FaceBook OAuth2 登入
5.在本機 laravel 12 ,上傳很多PDF,上傳之後可以自己排序,合併之後下載-方法一
6.在本機 laravel 12 ,上傳很多PDF,上傳之後可以自己排序,合併之後下載-方法二
一、功能摘要
1.多檔 PDF 上傳
2.拖曳排序
3.依排序順序合併 PDF
4.合併後直接下載
5.完全本機運作
二、專案結構
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/
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 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
八、編輯 Route
<?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\"");
}
}
編輯 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.刪除資料庫紀錄
7.修改resources/views/pdf/index.blade.php
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>
沒有留言:
張貼留言