From 953d6a41f303c6880e26b380038367b43cec59b4 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Wed, 1 Apr 2026 13:01:45 +0800 Subject: [PATCH] =?UTF-8?q?[FEAT]=20=E5=84=AA=E5=8C=96=E5=BB=A3=E5=91=8A?= =?UTF-8?q?=E8=88=87=E6=A9=9F=E5=8F=B0=E7=AE=A1=E7=90=86=E4=BB=8B=E9=9D=A2?= =?UTF-8?q?=E5=8F=8A=E6=95=88=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. [廣告管理] 修復編輯素材時刪除按鈕顯示邏輯並優化預覽功能。 2. [廣告管理] 修正請求回傳格式為 JSON,解決 AJAX 解析錯誤。 3. [機台管理] 實作 Alpine.js 無感頁籤切換(機台列表與效期管理)。 4. [機台管理] 移除冗餘返回按鈕,改為動態標題與頁籤重設邏輯。 5. [機台管理] 統一後端查詢,減少切換分頁時的延遲感。 6. [商品管理] 支援商品圖片 WebP 自動轉換,並調整上傳大小限制 (10MB)。 7. [UI] 修正多個管理模組的 JS 時序競爭與 Preline HSSelect 重置問題。 --- .../Admin/AdvertisementController.php | 48 ++++++- .../BasicSettings/MachinePhotoController.php | 6 + .../MachineSettingController.php | 59 +++++--- .../Controllers/Admin/MachineController.php | 49 +++---- .../Controllers/Admin/ProductController.php | 4 +- resources/views/admin/ads/index.blade.php | 109 ++++++++++++++- .../admin/ads/partials/ad-modal.blade.php | 127 ++++++++---------- .../admin/ads/partials/assign-modal.blade.php | 5 +- .../basic-settings/machines/edit.blade.php | 95 ++++++++++++- .../basic-settings/machines/index.blade.php | 6 +- .../views/admin/machines/index.blade.php | 33 ++--- .../views/admin/products/create.blade.php | 2 +- resources/views/admin/products/edit.blade.php | 2 +- .../views/admin/products/index.blade.php | 8 +- 14 files changed, 387 insertions(+), 166 deletions(-) diff --git a/app/Http/Controllers/Admin/AdvertisementController.php b/app/Http/Controllers/Admin/AdvertisementController.php index 7a17c50..e9ded16 100644 --- a/app/Http/Controllers/Admin/AdvertisementController.php +++ b/app/Http/Controllers/Admin/AdvertisementController.php @@ -6,11 +6,14 @@ use App\Models\Machine\Machine; use App\Models\Machine\MachineAdvertisement; use App\Models\System\Advertisement; use App\Models\System\Company; +use App\Traits\ImageHandler; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; class AdvertisementController extends AdminController { + use ImageHandler; + public function index(Request $request) { $user = auth()->user(); @@ -48,13 +51,19 @@ class AdvertisementController extends AdminController 'required', 'file', 'mimes:jpeg,png,jpg,gif,webp,mp4,mov,avi', - $request->type === 'image' ? 'max:5120' : 'max:51200', // Image 5MB, Video 50MB + $request->type === 'image' ? 'max:10240' : 'max:51200', // Image 10MB, Video 50MB ], 'company_id' => 'nullable|exists:companies,id', ]); $user = auth()->user(); - $path = $request->file('file')->store('ads', 'public'); + $file = $request->file('file'); + + if ($request->type === 'image') { + $path = $this->storeAsWebp($file, 'ads'); + } else { + $path = $file->store('ads', 'public'); + } if ($user->isSystemAdmin()) { $companyId = $request->filled('company_id') ? $request->company_id : null; @@ -71,6 +80,14 @@ class AdvertisementController extends AdminController 'is_active' => true, ]); + if ($request->wantsJson()) { + return response()->json([ + 'success' => true, + 'message' => __('Advertisement created successfully.'), + 'data' => $advertisement + ]); + } + return redirect()->back()->with('success', __('Advertisement created successfully.')); } @@ -88,7 +105,7 @@ class AdvertisementController extends AdminController $rules['file'] = [ 'file', 'mimes:jpeg,png,jpg,gif,webp,mp4,mov,avi', - $request->type === 'image' ? 'max:5120' : 'max:51200', + $request->type === 'image' ? 'max:10240' : 'max:51200', ]; } @@ -104,16 +121,32 @@ class AdvertisementController extends AdminController if ($request->hasFile('file')) { // 刪除舊檔案 + // 處理 URL 可能包含 storage 或原始路徑的情況 $oldPath = str_replace(Storage::disk('public')->url(''), '', $advertisement->url); + // 去除開頭可能的斜線 + $oldPath = ltrim($oldPath, '/'); Storage::disk('public')->delete($oldPath); // 存入新檔案 - $path = $request->file('file')->store('ads', 'public'); + $file = $request->file('file'); + if ($request->type === 'image') { + $path = $this->storeAsWebp($file, 'ads'); + } else { + $path = $file->store('ads', 'public'); + } $data['url'] = Storage::disk('public')->url($path); } $advertisement->update($data); + if ($request->wantsJson()) { + return response()->json([ + 'success' => true, + 'message' => __('Advertisement updated successfully.'), + 'data' => $advertisement + ]); + } + return redirect()->back()->with('success', __('Advertisement updated successfully.')); } @@ -129,6 +162,13 @@ class AdvertisementController extends AdminController Storage::disk('public')->delete($path); $advertisement->delete(); + if ($request->wantsJson()) { + return response()->json([ + 'success' => true, + 'message' => __('Advertisement deleted successfully.') + ]); + } + return redirect()->back()->with('success', __('Advertisement deleted successfully.')); } diff --git a/app/Http/Controllers/Admin/BasicSettings/MachinePhotoController.php b/app/Http/Controllers/Admin/BasicSettings/MachinePhotoController.php index 9f03d23..e19b80c 100644 --- a/app/Http/Controllers/Admin/BasicSettings/MachinePhotoController.php +++ b/app/Http/Controllers/Admin/BasicSettings/MachinePhotoController.php @@ -23,6 +23,12 @@ class MachinePhotoController extends Controller 'machine_id' => $machine->id, 'files' => $request->allFiles() ]); + + $request->validate([ + 'machine_image_0' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', + 'machine_image_1' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', + 'machine_image_2' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', + ]); try { $images = $machine->images ?? []; diff --git a/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php b/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php index d131a3d..ff2c54d 100644 --- a/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php +++ b/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php @@ -94,7 +94,7 @@ class MachineSettingController extends AdminController 'machine_model_id' => 'required|exists:machine_models,id', 'payment_config_id' => 'nullable|exists:payment_configs,id', 'location' => 'nullable|string|max:255', - 'images.*' => 'image|mimes:jpeg,png,jpg,gif|max:2048', + 'images.*' => 'image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB ]); $imagePaths = []; @@ -163,6 +163,12 @@ class MachineSettingController extends AdminController 'machine_model_id' => 'required|exists:machine_models,id', 'payment_config_id' => 'nullable|exists:payment_configs,id', 'location' => 'nullable|string|max:255', + 'image_0' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', + 'image_1' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', + 'image_2' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', + 'remove_image_0' => 'nullable|boolean', + 'remove_image_1' => 'nullable|boolean', + 'remove_image_2' => 'nullable|boolean', ]); // 僅限系統管理員可修改公司 @@ -178,34 +184,47 @@ class MachineSettingController extends AdminController throw $e; } - $machine->update(array_merge($validated, [ + // 排除虛擬欄位 (圖片上傳、移除標記),這些欄位不在資料表內 + $dataToUpdate = \Illuminate\Support\Arr::except($validated, [ + 'image_0', 'image_1', 'image_2', + 'remove_image_0', 'remove_image_1', 'remove_image_2' + ]); + + $machine->update(array_merge($dataToUpdate, [ 'updater_id' => auth()->id(), ])); - // 處理圖片更新 (支援 3 個獨立槽位) - if ($request->hasFile('images')) { - $currentImages = $machine->images ?? []; - $newImages = $request->file('images'); - $updated = false; + // 處理圖片更新 (支援 3 個獨立槽位: image_0, image_1, image_2) + $currentImages = $machine->images ?? []; + $updated = false; - foreach ($newImages as $index => $file) { - // 限制 3 個槽位 (0, 1, 2) - if ($index < 0 || $index > 2) continue; + for ($i = 0; $i < 3; $i++) { + $inputName = "image_$i"; + $removeName = "remove_image_$i"; - // 刪除該槽位的舊圖 - if (isset($currentImages[$index]) && !empty($currentImages[$index])) { - \Illuminate\Support\Facades\Storage::disk('public')->delete($currentImages[$index]); + // 如果有新圖片上傳 + if ($request->hasFile($inputName)) { + // 刪除舊圖 + if (isset($currentImages[$i]) && !empty($currentImages[$i])) { + \Illuminate\Support\Facades\Storage::disk('public')->delete($currentImages[$i]); } - - // 處理並儲存新圖 - $currentImages[$index] = $this->storeAsWebp($file, 'machines'); + // 儲存新圖 + $currentImages[$i] = $this->storeAsWebp($request->file($inputName), 'machines'); $updated = true; + } + // 否則,如果有刪除標記 + elseif ($request->input($removeName) === '1') { + if (isset($currentImages[$i]) && !empty($currentImages[$i])) { + \Illuminate\Support\Facades\Storage::disk('public')->delete($currentImages[$i]); + unset($currentImages[$i]); + $updated = true; + } } + } - if ($updated) { - ksort($currentImages); - $machine->update(['images' => array_values($currentImages)]); - } + if ($updated) { + ksort($currentImages); + $machine->update(['images' => array_values($currentImages)]); } return redirect()->route('admin.basic-settings.machines.index') diff --git a/app/Http/Controllers/Admin/MachineController.php b/app/Http/Controllers/Admin/MachineController.php index 06a0705..e8f0af9 100644 --- a/app/Http/Controllers/Admin/MachineController.php +++ b/app/Http/Controllers/Admin/MachineController.php @@ -8,9 +8,6 @@ use Illuminate\View\View; class MachineController extends AdminController { - /** - * 顯示所有機台列表或效期管理 - */ public function index(Request $request): View { $tab = $request->input('tab', 'list'); @@ -26,33 +23,27 @@ class MachineController extends AdminController }); } - if ($tab === 'list') { - $machines = $query->when($request->status, function ($query, $status) { - return $query->where('status', $status); - }) - ->orderBy("last_heartbeat_at", "desc")->orderBy("id", "desc") - ->paginate($per_page) - ->withQueryString(); + // 統一預加載貨道統計資料 (無論在哪一個頁籤) + $machines = $query->withCount(['slots as total_slots']) + ->withCount(['slots as expired_count' => function ($q) { + $q->where('expiry_date', '<', now()->toDateString()); + }]) + ->withCount(['slots as pending_count' => function ($q) { + $q->whereNull('expiry_date'); + }]) + ->withCount(['slots as warning_count' => function ($q) { + $q->whereBetween('expiry_date', [now()->toDateString(), now()->addDays(7)->toDateString()]); + }]) + // 只有在機台列表且有狀態篩選時才套用狀態過濾 + ->when($request->status && $tab === 'list', function ($q, $status) { + return $q->where('status', $status); + }) + ->orderBy("last_heartbeat_at", "desc") + ->orderBy("id", "desc") + ->paginate($per_page) + ->withQueryString(); - return view('admin.machines.index', compact('machines', 'tab')); - } else { - // 效期管理模式:獲取機台及其貨道統計 - $machines = $query->withCount(['slots as total_slots']) - ->withCount(['slots as expired_count' => function ($q) { - $q->where('expiry_date', '<', now()->toDateString()); - }]) - ->withCount(['slots as pending_count' => function ($q) { - $q->whereNull('expiry_date'); - }]) - ->withCount(['slots as warning_count' => function ($q) { - $q->whereBetween('expiry_date', [now()->toDateString(), now()->addDays(7)->toDateString()]); - }]) - ->orderBy("last_heartbeat_at", "desc")->orderBy("id", "desc") - ->paginate($per_page) - ->withQueryString(); - - return view('admin.machines.index', compact('machines', 'tab')); - } + return view('admin.machines.index', compact('machines', 'tab')); } /** diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php index 9d1d6cf..271679f 100644 --- a/app/Http/Controllers/Admin/ProductController.php +++ b/app/Http/Controllers/Admin/ProductController.php @@ -126,7 +126,7 @@ class ProductController extends Controller 'metadata' => 'nullable|array', 'is_active' => 'nullable|boolean', 'company_id' => 'nullable|exists:companies,id', - 'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', + 'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB ]); try { @@ -216,7 +216,7 @@ class ProductController extends Controller 'member_price' => 'required|numeric|min:0', 'metadata' => 'nullable|array', 'is_active' => 'nullable|boolean', - 'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', + 'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB 'remove_image' => 'nullable|boolean', ]); diff --git a/resources/views/admin/ads/index.blade.php b/resources/views/admin/ads/index.blade.php index 647e681..7ba8127 100644 --- a/resources/views/admin/ads/index.blade.php +++ b/resources/views/admin/ads/index.blade.php @@ -378,12 +378,14 @@ $baseRoute = 'admin.data-config.advertisements'; adFormMode: 'add', fileName: '', + mediaPreview: null, adForm: { id: null, name: '', type: 'image', duration: 15, - is_active: true + is_active: true, + url: '' }, // Assign Modal @@ -522,8 +524,83 @@ $baseRoute = 'admin.data-config.advertisements'; handleFileChange(e) { const file = e.target.files[0]; - if (file) { - this.fileName = file.name; + if (!file) return; + + // Validation + const isVideo = file.type.startsWith('video/'); + const isImage = file.type.startsWith('image/'); + const maxSize = isVideo ? 50 * 1024 * 1024 : 10 * 1024 * 1024; // 50MB for video, 10MB for image + + if (file.size > maxSize) { + window.showToast?.(`{{ __("File is too large") }} (${isVideo ? '50MB' : '10MB'} MAX)`, 'error'); + e.target.value = ''; + return; + } + + this.fileName = file.name; + + // Set form type based on file + if (isVideo) this.adForm.type = 'video'; + else if (isImage) this.adForm.type = 'image'; + + // Local Preview + const reader = new FileReader(); + reader.onload = (event) => { + this.mediaPreview = event.target.result; + }; + reader.readAsDataURL(file); + + // Update Select UI + this.$nextTick(() => { + window.HSSelect.getInstance('#ad_type_select')?.setValue(this.adForm.type); + }); + }, + + removeMedia() { + this.fileName = ''; + this.mediaPreview = null; + if (this.$refs.fileInput) this.$refs.fileInput.value = ''; + // If editing, we still keep the original url in adForm.url but the UI shows "empty" + }, + + async submitAdForm() { + const form = this.$refs.adFormEl; + const formData = new FormData(form); + + // Basic validation + if (!this.adForm.name) { + window.showToast?.('{{ __("Please enter a name") }}', 'error'); + return; + } + if (this.adFormMode === 'add' && !this.fileName) { + window.showToast?.('{{ __("Please select a file") }}', 'error'); + return; + } + + try { + const url = this.adFormMode === 'add' ? this.urls.store : this.urls.update.replace(':id', this.adForm.id); + if (this.adFormMode === 'edit') formData.append('_method', 'PUT'); + + const response = await fetch(url, { + method: 'POST', + body: formData, + headers: { + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + 'Accept': 'application/json' + } + }); + + const result = await response.json(); + if (result.success) { + window.showToast?.(result.message, 'success'); + this.isAdModalOpen = false; + location.reload(); // Reload to refresh the list + } else { + window.showToast?.(result.message || 'Error', 'error'); + } + } catch (e) { + console.error('Failed to submit ad', e); + window.showToast?.('System Error', 'error'); } }, @@ -533,10 +610,18 @@ $baseRoute = 'admin.data-config.advertisements'; method: 'POST', headers: { 'Content-Type': 'application/json', + 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }, body: JSON.stringify(this.assignForm) }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Server error response:', errorText); + throw new Error(`Server responded with ${response.status}`); + } + const result = await response.json(); if (result.success) { this.isAssignModalOpen = false; @@ -600,13 +685,17 @@ $baseRoute = 'admin.data-config.advertisements'; openAddModal() { this.adFormMode = 'add'; - this.adForm = { id: null, company_id: '', name: '', type: 'image', duration: 15, is_active: true }; + this.adForm = { id: null, company_id: '', name: '', type: 'image', duration: 15, is_active: true, url: '' }; + this.fileName = ''; + this.mediaPreview = null; this.isAdModalOpen = true; }, openEditModal(ad) { this.adFormMode = 'edit'; this.adForm = { ...ad }; + this.fileName = ''; + this.mediaPreview = ad.url; // Use existing URL as preview this.isAdModalOpen = true; }, @@ -678,6 +767,11 @@ $baseRoute = 'admin.data-config.advertisements'; wrapper.appendChild(selectEl); + // Set the initial value after appending but before autoInit + if (this.assignForm.advertisement_id) { + selectEl.value = this.assignForm.advertisement_id; + } + selectEl.addEventListener('change', (e) => { this.assignForm.advertisement_id = e.target.value; }); @@ -685,6 +779,13 @@ $baseRoute = 'admin.data-config.advertisements'; this.$nextTick(() => { if (window.HSStaticMethods && window.HSStaticMethods.autoInit) { window.HSStaticMethods.autoInit(['select']); + + // If we have a value, ensure the Preline instance reflects it + if (this.assignForm.advertisement_id) { + setTimeout(() => { + window.HSSelect.getInstance(selectEl)?.setValue(this.assignForm.advertisement_id); + }, 50); + } } }); }, diff --git a/resources/views/admin/ads/partials/ad-modal.blade.php b/resources/views/admin/ads/partials/ad-modal.blade.php index b84af01..fe8c0bf 100644 --- a/resources/views/admin/ads/partials/ad-modal.blade.php +++ b/resources/views/admin/ads/partials/ad-modal.blade.php @@ -8,11 +8,10 @@ x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"> -
+
-
+
@@ -25,7 +24,8 @@
-
@@ -94,7 +94,7 @@
- +
+
-
- + + + + +
- -

{{ __('Active Status') }} @@ -132,63 +167,11 @@ {{ __('Cancel') }}
- - diff --git a/resources/views/admin/ads/partials/assign-modal.blade.php b/resources/views/admin/ads/partials/assign-modal.blade.php index 13e3460..2e8ab92 100644 --- a/resources/views/admin/ads/partials/assign-modal.blade.php +++ b/resources/views/admin/ads/partials/assign-modal.blade.php @@ -8,11 +8,10 @@ x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"> -
+
-
+
diff --git a/resources/views/admin/basic-settings/machines/edit.blade.php b/resources/views/admin/basic-settings/machines/edit.blade.php index 22d7cf4..e9becb5 100644 --- a/resources/views/admin/basic-settings/machines/edit.blade.php +++ b/resources/views/admin/basic-settings/machines/edit.blade.php @@ -34,7 +34,29 @@
-
+ url($machine->images[0]) . "'" : 'null' }}, + {{ isset($machine->images[1]) ? "'" . Storage::disk('public')->url($machine->images[1]) . "'" : 'null' }}, + {{ isset($machine->images[2]) ? "'" . Storage::disk('public')->url($machine->images[2]) . "'" : 'null' }} + ], + removeImages: [false, false, false], + handleImageUpload(event, index) { + const file = event.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (e) => { + this.imagePreviews[index] = e.target.result; + this.removeImages[index] = false; + }; + reader.readAsDataURL(file); + }, + removeImage(index) { + this.imagePreviews[index] = null; + this.removeImages[index] = true; + if (this.$refs['imageInput_' + index]) this.$refs['imageInput_' + index].value = ''; + } + }" action="{{ route('admin.basic-settings.machines.update', $machine) }}" method="POST" enctype="multipart/form-data" class="space-y-6"> @csrf @method('PUT') @@ -58,7 +80,9 @@
@endif -
+
+ +
@@ -196,8 +220,71 @@
- -
+ +
+ +
+
+
+ + + +
+

{{ __('Machine Photos') }}

+
+ +
+ @for($i = 0; $i < 3; $i++) +
+
+ {{ __('Photo Slot') }} {{ $i + 1 }} + +
+ +
+ + + + + + +
+
+ @endfor + +
+

+ {{ __('PNG, JPG, WEBP up to 10MB') }} +

+
+
+
diff --git a/resources/views/admin/basic-settings/machines/index.blade.php b/resources/views/admin/basic-settings/machines/index.blade.php index d9c0933..a23259a 100644 --- a/resources/views/admin/basic-settings/machines/index.blade.php +++ b/resources/views/admin/basic-settings/machines/index.blade.php @@ -707,6 +707,9 @@

{{ __('Click to upload') }}

+

+ {{ __('PNG, JPG, WEBP up to 10MB') }} ({{ __('Max 3') }}) +

diff --git a/resources/views/admin/products/edit.blade.php b/resources/views/admin/products/edit.blade.php index 3add2cf..628e73c 100644 --- a/resources/views/admin/products/edit.blade.php +++ b/resources/views/admin/products/edit.blade.php @@ -75,7 +75,7 @@

{{ __('Click to upload') }}

-

{{ __('PNG, JPG up to 2MB') }}

+

{{ __('PNG, JPG, WEBP up to 10MB') }}

diff --git a/resources/views/admin/products/index.blade.php b/resources/views/admin/products/index.blade.php index a07e78e..123059b 100644 --- a/resources/views/admin/products/index.blade.php +++ b/resources/views/admin/products/index.blade.php @@ -110,9 +110,9 @@ $roleSelectConfig = [ @forelse($products as $product) - +
-
+
@if($product->image_url) @else @@ -120,12 +120,12 @@ $roleSelectConfig = [ @endif
- {{ $product->localized_name }} + {{ $product->localized_name }}
@php $catName = $product->category->localized_name ?? __('Uncategorized'); @endphp - {{ $catName }} + {{ $catName }} @if(($companySettings['enable_material_code'] ?? false) && isset($product->metadata['material_code'])) #{{ $product->metadata['material_code'] }} @endif