[FEAT] 優化廣告與機台管理介面及效能

1. [廣告管理] 修復編輯素材時刪除按鈕顯示邏輯並優化預覽功能。
2. [廣告管理] 修正請求回傳格式為 JSON,解決 AJAX 解析錯誤。
3. [機台管理] 實作 Alpine.js 無感頁籤切換(機台列表與效期管理)。
4. [機台管理] 移除冗餘返回按鈕,改為動態標題與頁籤重設邏輯。
5. [機台管理] 統一後端查詢,減少切換分頁時的延遲感。
6. [商品管理] 支援商品圖片 WebP 自動轉換,並調整上傳大小限制 (10MB)。
7. [UI] 修正多個管理模組的 JS 時序競爭與 Preline HSSelect 重置問題。
This commit is contained in:
2026-04-01 13:01:45 +08:00
parent 2e49129d77
commit 953d6a41f3
14 changed files with 387 additions and 166 deletions

View File

@@ -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);
}
}
});
},