[FEAT] 廣告排程功能與 UI 優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 48s
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 48s
1. 新增廣告排程功能,支援設定發布時間與下架時間。 2. 整合 Flatpickr 時間選擇器,提供與機台日誌一致的極簡奢華風 UI。 3. 優化廣告列表中的數字字體,套用 font-mono 與 tabular-nums,與客戶管理模組風格同步。 4. 修正 Alpine.js 資料同步邏輯,確保編輯模式下排程時間能正確回填。
This commit is contained in:
@@ -71,6 +71,7 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
@endif
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Type') }}</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Duration') }}</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Schedule') }}</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
|
||||
</tr>
|
||||
@@ -104,15 +105,34 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
{{ __($ad->type) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center whitespace-nowrap text-sm font-black text-slate-700 dark:text-slate-200">
|
||||
<td class="px-6 py-4 text-center whitespace-nowrap text-sm font-mono font-bold text-slate-700 dark:text-slate-200">
|
||||
{{ $ad->duration }}s
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center whitespace-nowrap">
|
||||
<div class="flex flex-col items-center gap-0.5">
|
||||
<span class="text-[11px] font-mono font-bold text-slate-500 dark:text-slate-400 uppercase tracking-tight">{{ __('From') }}: {{ $ad->start_at?->format('Y-m-d H:i') ?? __('Immediate') }}</span>
|
||||
<span class="text-[11px] font-mono font-bold text-slate-500 dark:text-slate-400 uppercase tracking-tight">{{ __('To') }}: {{ $ad->end_at?->format('Y-m-d H:i') ?? __('Indefinite') }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
@if($ad->is_active)
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase">{{ __('Active') }}</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 tracking-widest uppercase">{{ __('Disabled') }}</span>
|
||||
@endif
|
||||
@php
|
||||
$now = now();
|
||||
$isStarted = !$ad->start_at || $ad->start_at <= $now;
|
||||
$isExpired = $ad->end_at && $ad->end_at < $now;
|
||||
$isPlaying = $ad->is_active && $isStarted && !$isExpired;
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
@if(!$ad->is_active)
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-slate-500/10 text-slate-500 border border-slate-500/20 tracking-widest uppercase">{{ __('Disabled') }}</span>
|
||||
@elseif($isExpired)
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 tracking-widest uppercase">{{ __('Expired') }}</span>
|
||||
@elseif(!$isStarted)
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-amber-500/10 text-amber-500 border border-amber-500/20 tracking-widest uppercase">{{ __('Waiting') }}</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase">{{ __('Ongoing') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex justify-end items-center gap-2">
|
||||
@@ -211,7 +231,7 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
</button>
|
||||
<div class="flex-1 min-w-0 flex flex-col justify-center cursor-pointer group-hover:text-cyan-500 transition-colors" @click="openPreview(assign.advertisement)">
|
||||
<p class="text-xs font-black text-slate-700 dark:text-white truncate transition-colors" x-text="assign.advertisement.name"></p>
|
||||
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter mt-0.5" x-text="assign.advertisement.duration + 's'"></p>
|
||||
<p class="text-[11px] font-mono font-bold text-slate-400 uppercase tracking-tight mt-0.5" x-text="assign.advertisement.duration + 's'"></p>
|
||||
</div>
|
||||
<button @click="removeAssignment(assign.id)" class="p-1.5 text-slate-300 hover:text-rose-500 transition-colors">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
@@ -324,7 +344,7 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-white/20"></span>
|
||||
<span class="text-cyan-400 font-black tracking-widest text-xs uppercase" x-text="(currentSequenceIndex + 1) + ' / ' + sequenceAds.length"></span>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-white/20"></span>
|
||||
<span class="text-white/80 font-bold tracking-widest text-xs tabular-nums" x-text="Math.ceil(sequenceRemainingTime) + 's'"></span>
|
||||
<span class="text-white/80 font-mono font-bold tracking-tight text-xs tabular-nums" x-text="Math.ceil(sequenceRemainingTime) + 's'"></span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -385,7 +405,9 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
type: 'image',
|
||||
duration: 15,
|
||||
is_active: true,
|
||||
url: ''
|
||||
url: '',
|
||||
start_at: '',
|
||||
end_at: ''
|
||||
},
|
||||
|
||||
// Assign Modal
|
||||
@@ -604,6 +626,17 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
}
|
||||
},
|
||||
|
||||
formatDateForInput(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}/${month}/${day} ${hours}:${minutes}`;
|
||||
},
|
||||
|
||||
async submitAssignment() {
|
||||
try {
|
||||
const response = await fetch(this.urls.assign, {
|
||||
@@ -650,6 +683,14 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
if (document.querySelector('#ad_company_select')) {
|
||||
window.HSSelect.getInstance('#ad_company_select')?.setValue(this.adForm.company_id || '');
|
||||
}
|
||||
|
||||
// 確保 Flatpickr 實例同步顯示目前的時間值
|
||||
if (this.$refs.startAtPicker?._flatpickr) {
|
||||
this.$refs.startAtPicker._flatpickr.setDate(this.adForm.start_at);
|
||||
}
|
||||
if (this.$refs.endAtPicker?._flatpickr) {
|
||||
this.$refs.endAtPicker._flatpickr.setDate(this.adForm.end_at);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -685,7 +726,7 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
|
||||
openAddModal() {
|
||||
this.adFormMode = 'add';
|
||||
this.adForm = { id: null, company_id: '', name: '', type: 'image', duration: 15, is_active: true, url: '' };
|
||||
this.adForm = { id: null, company_id: '', name: '', type: 'image', duration: 15, is_active: true, url: '', start_at: '', end_at: '' };
|
||||
this.fileName = '';
|
||||
this.mediaPreview = null;
|
||||
this.isAdModalOpen = true;
|
||||
@@ -693,7 +734,11 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
|
||||
openEditModal(ad) {
|
||||
this.adFormMode = 'edit';
|
||||
this.adForm = { ...ad };
|
||||
this.adForm = {
|
||||
...ad,
|
||||
start_at: this.formatDateForInput(ad.start_at),
|
||||
end_at: this.formatDateForInput(ad.end_at)
|
||||
};
|
||||
this.fileName = '';
|
||||
this.mediaPreview = ad.url; // Use existing URL as preview
|
||||
this.isAdModalOpen = true;
|
||||
|
||||
@@ -94,6 +94,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheduling -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
|
||||
{{ __('Publish Time') }}
|
||||
</label>
|
||||
<div class="relative group/input">
|
||||
<input type="text" name="start_at" x-ref="startAtPicker" x-model="adForm.start_at"
|
||||
x-init="flatpickr($refs.startAtPicker, {
|
||||
enableTime: true,
|
||||
dateFormat: 'Y/m/d H:i',
|
||||
time_24hr: true,
|
||||
locale: window.flatpickrLocale,
|
||||
onClose: (selectedDates, dateStr) => { adForm.start_at = dateStr; }
|
||||
})"
|
||||
class="w-full h-12 bg-slate-50 dark:bg-slate-800/50 border-none rounded-xl px-4 pr-10 text-sm font-bold text-slate-800 dark:text-white focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-400"
|
||||
placeholder="YYYY/MM/DD HH:MM">
|
||||
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 group-hover/input:text-cyan-500 transition-colors pointer-events-none">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
|
||||
{{ __('Expired Time') }}
|
||||
</label>
|
||||
<div class="relative group/input">
|
||||
<input type="text" name="end_at" x-ref="endAtPicker" x-model="adForm.end_at"
|
||||
x-init="flatpickr($refs.endAtPicker, {
|
||||
enableTime: true,
|
||||
dateFormat: 'Y/m/d H:i',
|
||||
time_24hr: true,
|
||||
locale: window.flatpickrLocale,
|
||||
onClose: (selectedDates, dateStr) => { adForm.end_at = dateStr; }
|
||||
})"
|
||||
class="w-full h-12 bg-slate-50 dark:bg-slate-800/50 border-none rounded-xl px-4 pr-10 text-sm font-bold text-slate-800 dark:text-white focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-400"
|
||||
placeholder="YYYY/MM/DD HH:MM">
|
||||
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 group-hover/input:text-cyan-500 transition-colors pointer-events-none">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload (Luxury UI Pattern) -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
|
||||
|
||||
Reference in New Issue
Block a user