[FEAT] 廣告排程功能與 UI 優化
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:
2026-04-13 11:44:26 +08:00
parent 5415b14a53
commit ad256d3d3b
9 changed files with 188 additions and 19 deletions

View File

@@ -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;

View File

@@ -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">