[FEAT] 優化廣告素材上傳體驗與進度顯示

1. 在廣告建立與編輯彈窗中實作基於 XMLHttpRequest 的 AJAX 上傳機制。
2. 新增即時上傳進度條顯示,並整合全域頂部載入條。
3. 實作上傳期間禁用提交按鈕與顯示「上傳中...」狀態,提升使用者反饋感。
4. 更新 AdvertisementController 確保 JSON 回應後能正確快閃成功訊息至 Session。
This commit is contained in:
sky121113 2026-05-13 17:37:18 +08:00
parent c482fb3c79
commit f9d3217099
3 changed files with 95 additions and 26 deletions

View File

@ -102,6 +102,7 @@ class AdvertisementController extends AdminController
]);
if ($request->wantsJson()) {
session()->flash('success', __('Advertisement created successfully.'));
return response()->json([
'success' => true,
'message' => __('Advertisement created successfully.'),
@ -163,6 +164,7 @@ class AdvertisementController extends AdminController
$advertisement->update($data);
if ($request->wantsJson()) {
session()->flash('success', __('Advertisement updated successfully.'));
return response()->json([
'success' => true,
'message' => __('Advertisement updated successfully.'),

View File

@ -728,6 +728,8 @@ $baseRoute = 'admin.data-config.advertisements';
document.addEventListener('alpine:init', () => {
Alpine.data('adManager', () => ({
isLoading: false,
isSubmitting: false,
uploadProgress: 0,
activeTab: 'list',
selectedMachineId: null,
machines: [],
@ -1035,7 +1037,7 @@ $baseRoute = 'admin.data-config.advertisements';
const form = this.$refs.adFormEl;
const formData = new FormData(form);
// Basic validation
// 基本驗證
if (!this.adForm.name) {
window.showToast?.('{{ __("Please enter a name") }}', 'error');
return;
@ -1045,31 +1047,88 @@ $baseRoute = 'admin.data-config.advertisements';
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');
// 防止重複提交
if (this.isSubmitting) return;
this.isSubmitting = true;
this.uploadProgress = 0;
const response = await fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
});
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 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');
// 取得頂部進度條並切換為真實進度模式
const bar = document.getElementById('top-loading-bar');
if (bar) {
bar.style.animation = 'none';
bar.style.width = '0%';
bar.style.opacity = '1';
bar.classList.add('loading');
}
const xhr = new XMLHttpRequest();
// 監聽上傳進度
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
this.uploadProgress = pct;
if (bar) {
// 最多到 95%,完成時才跳到 100%
bar.style.width = Math.min(pct, 95) + '%';
}
}
});
xhr.addEventListener('load', () => {
try {
const result = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300 && result.success) {
// 進度跳到 100%
if (bar) bar.style.width = '100%';
this.uploadProgress = 100;
this.isAdModalOpen = false;
// 延遲 300ms 讓進度條動畫完成後再重整
setTimeout(() => {
location.reload();
}, 300);
} else {
window.showToast?.(result.message || 'Error', 'error');
this.isSubmitting = false;
this.uploadProgress = 0;
if (bar) {
bar.style.width = '0%';
bar.style.opacity = '0';
bar.classList.remove('loading');
}
}
} catch (e) {
console.error('Failed to parse response', e);
window.showToast?.('System Error', 'error');
this.isSubmitting = false;
this.uploadProgress = 0;
if (bar) {
bar.style.width = '0%';
bar.style.opacity = '0';
bar.classList.remove('loading');
}
}
});
xhr.addEventListener('error', () => {
console.error('Upload failed');
window.showToast?.('{{ __("Upload failed, please try again") }}', 'error');
this.isSubmitting = false;
this.uploadProgress = 0;
if (bar) {
bar.style.width = '0%';
bar.style.opacity = '0';
bar.classList.remove('loading');
}
});
xhr.open('POST', url);
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
xhr.setRequestHeader('Accept', 'application/json');
xhr.send(formData);
},
formatDateForInput(dateStr) {

View File

@ -220,11 +220,19 @@
class="px-6 py-3 text-sm font-black text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors uppercase tracking-widest">
{{ __('Cancel') }}
</button>
<button type="submit" class="btn-luxury-primary px-10 py-3">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<button type="submit" class="btn-luxury-primary px-10 py-3 disabled:opacity-60 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none transition-all"
:disabled="isSubmitting">
{{-- 正常狀態:勾勾圖示 --}}
<svg x-show="!isSubmitting" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
<span>{{ __('Save Material') }}</span>
{{-- 上傳中:旋轉圖示 --}}
<svg x-show="isSubmitting" x-cloak class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span x-show="!isSubmitting">{{ __('Save Material') }}</span>
<span x-show="isSubmitting" x-cloak>{{ __('Uploading...') }}</span>
</button>
</div>
</form>