[FEAT] 優化廣告同步與指令中心 UI

1. 廣告管理:將原生 confirm 替換為極簡奢華風 Alpine.js 彈窗,並新增「操作備註」欄位與「目標機台」顯示。
2. 指令中心:在指令確認彈窗中新增「目標機台」顯示,並優化 UI 佈局。
3. 多語系支援:新增 Sync Ads (同步廣告) 至 zh_TW, en, ja 語系檔,並應用於指令中心與篩選列。
4. 後端邏輯:更新 AdvertisementController,將操作備註正確存入 RemoteCommand 的 remark 欄位。
5. 指令規範:同步更新 MQTT 通訊規範 (SKILL.md) 與 API 文件 (api-docs.php),加入 update_ads 指令說明。
This commit is contained in:
sky121113 2026-05-13 13:51:24 +08:00
parent 7d3e25d090
commit ce87f453a2
10 changed files with 232 additions and 10 deletions

View File

@ -279,6 +279,7 @@ Mqtt3Client client = MqttClient.builder()
- `change`: 遠端找零(夾帶 amount 參數)。
- `update_inventory`: 遠端即時修改庫存。
- `dispense`: 遠端出貨指令。
- `update_ads`: 遠端廣告同步指令。
**`update_inventory` Payload 範例:**
```json

View File

@ -282,4 +282,64 @@ class AdvertisementController extends AdminController
'message' => __('Assignment removed successfully.')
]);
}
/**
* 同步廣告至機台 (透過 MQTT 發送 update_ads 指令)
*/
public function syncToMachine(Request $request, Machine $machine, \App\Services\Machine\MqttService $mqttService)
{
// 併行檢查:若有 pending 指令,超過 1 分鐘視為逾時可覆蓋
$pendingCommand = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'update_ads')
->where('status', 'pending')
->latest()
->first();
if ($pendingCommand) {
$isTimedOut = $pendingCommand->created_at->lt(now()->subMinute());
if (!$isTimedOut) {
return response()->json([
'success' => false,
'message' => __('This machine has a pending command. Please wait.')
], 422);
}
// 超過 1 分鐘:視為逾時,自動標記舊指令
$pendingCommand->update([
'status' => 'timeout',
'note' => __('Superseded by new command (Timeout)'),
]);
}
$command = \App\Models\Machine\RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => auth()->id(),
'command_type' => 'update_ads',
'payload' => [],
'status' => 'pending',
'remark' => $request->filled('note') ? $request->input('note') : __('Manual Sync Ads'),
]);
$success = $mqttService->pushCommand(
$machine->serial_no,
'update_ads',
[],
(string) $command->id
);
if ($success) {
return response()->json([
'success' => true,
'message' => __('Sync command sent successfully.')
]);
}
$command->update(['status' => 'failed', 'note' => 'MQTT push failed']);
return response()->json([
'success' => false,
'message' => __('Failed to send sync command.')
], 500);
}
}

View File

@ -674,6 +674,24 @@ return [
]
],
],
[
'name' => '指令下發:廣告同步 (Update Ads)',
'slug' => 'mqtt-command-update-ads',
'action' => 'SUB',
'topic' => 'machine/{serial_no}/command',
'qos' => 1,
'description' => '雲端主動下發「廣告同步」指令。指示機台重新拉取 B005 廣告清單。',
'payload_parameters' => [
'command' => ['type' => 'string', 'description' => '固定為 "update_ads"'],
'command_id' => ['type' => 'string', 'description' => '指令唯一 ID'],
'payload' => ['type' => 'object', 'description' => '空物件 (無額外參數)'],
],
'payload_example' => [
'command' => 'update_ads',
'command_id' => '1122334456',
'payload' => new \stdClass()
],
],
[
'name' => '指令下發:遠端找零 (Change)',
'slug' => 'mqtt-command-change',

View File

@ -1943,5 +1943,6 @@
"Total Orders": "Total Orders",
"Real-time fleet status and revenue monitoring": "Real-time fleet status and revenue monitoring",
"[StaffCard] Verification failed: :uid": "[StaffCard] Verification failed: :uid",
"[PickupCode] Verification failed: :code": "[PickupCode] Verification failed: :code"
"[PickupCode] Verification failed: :code": "[PickupCode] Verification failed: :code",
"Sync Ads": "Sync Ads"
}

View File

@ -1942,5 +1942,6 @@
"Total Orders": "本日の合計注文",
"Real-time fleet status and revenue monitoring": "フリート全体のリアルタイムステータスと収益監視",
"[StaffCard] Verification failed: :uid": "[スタッフカード] 認証失敗: :uid",
"[PickupCode] Verification failed: :code": "[受取コード] 認証失敗: :code"
"[PickupCode] Verification failed: :code": "[受取コード] 認証失敗: :code",
"Sync Ads": "広告を同期"
}

View File

@ -1591,6 +1591,7 @@
"This is a system administrator role. Its name is locked to ensure system stability.": "這是系統管理員角色,名稱已鎖定以確保系統穩定性。",
"This role belongs to another company and cannot be assigned.": "此角色屬於其他公司,無法指派。",
"This slot has a pending command. Please wait.": "此貨道已有指令正在執行,請稍後。",
"This machine has a pending command. Please wait.": "此機台已有指令正在執行,請稍後。",
"This slot has a pending update. Please wait for the previous command to complete.": "此貨道已有更新指令在執行中,請等候前一個指令完成。",
"This slot is syncing with the machine. Please wait.": "此貨道正在與機台同步中,請稍候。",
"Ticket": "憑證",
@ -1943,5 +1944,10 @@
"Total Orders": "今日訂單",
"Real-time fleet status and revenue monitoring": "全機台即時狀態與營收監控",
"[StaffCard] Verification failed: :uid": "[員工卡] 驗證失敗: :uid",
"[PickupCode] Verification failed: :code": "[取貨碼] 驗證失敗: :code"
"[PickupCode] Verification failed: :code": "[取貨碼] 驗證失敗: :code",
"Sync to Machine": "同步廣告至機台",
"Manual Sync Ads": "手動同步機台廣告",
"Sync command sent successfully.": "同步指令已成功送出",
"Failed to send sync command.": "無法送出同步指令",
"Sync Ads": "同步廣告"
}

View File

@ -22,7 +22,8 @@ $baseRoute = 'admin.data-config.advertisements';
"getMachineAds": "{{ route($baseRoute . ".machine.get", ":id") }}",
"assign": "{{ route($baseRoute . ".assign") }}",
"reorder": "{{ route($baseRoute . ".assignments.reorder") }}",
"removeAssignment": "{{ route($baseRoute . ".assignment.remove", ":id") }}"
"removeAssignment": "{{ route($baseRoute . ".assignment.remove", ":id") }}",
"syncToMachine": "{{ route($baseRoute . ".machine.sync", ":id") }}"
}'>
<x-luxury-spinner show="isLoading" />
@ -369,13 +370,26 @@ $baseRoute = 'admin.data-config.advertisements';
<div x-show="activeTab === 'machine'" class="space-y-6" x-cloak>
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
<!-- Machine Filter -->
<div class="max-w-md mx-auto mb-10">
<div class="max-w-3xl mx-auto mb-10">
<label
class="block text-sm font-black text-slate-700 dark:text-slate-200 mb-3 text-center uppercase tracking-widest">{{
__('Please select a machine first') }}</label>
<x-searchable-select name="machine_selector"
:options="$machines->map(fn($m) => (object)['id' => $m->id, 'name' => $m->name . ' (' . $m->serial_no . ')'])"
placeholder="{{ __('Search Machine...') }}" @change="selectMachine($event.target.value)" />
<div class="flex flex-col sm:flex-row items-center gap-4 justify-center">
<div class="w-full sm:w-[400px]">
<x-searchable-select name="machine_selector"
:options="$machines->map(fn($m) => (object)['id' => $m->id, 'name' => $m->name . ' (' . $m->serial_no . ')'])"
placeholder="{{ __('Search Machine...') }}" @change="selectMachine($event.target.value)" />
</div>
<div x-show="selectedMachineId" x-transition class="shrink-0 w-full sm:w-auto" x-cloak>
<button @click="syncAdsToMachine()" type="button"
class="w-full sm:w-auto btn-luxury-primary h-[42px] whitespace-nowrap flex items-center justify-center gap-2 transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span class="text-sm font-bold uppercase tracking-widest">{{ __('Sync to Machine') }}</span>
</button>
</div>
</div>
</div>
<div x-show="selectedMachineId" class="animate-luxury-in">
@ -509,6 +523,76 @@ $baseRoute = 'admin.data-config.advertisements';
@include('admin.ads.partials.ad-modal')
@include('admin.ads.partials.assign-modal')
<!-- Sync Confirmation Modal -->
<template x-teleport="body">
<div x-show="isSyncConfirmOpen" class="fixed inset-0 z-[100] overflow-y-auto" x-cloak>
<div class="flex min-h-screen items-center justify-center p-4 text-center sm:p-0">
<!-- Background Backdrop -->
<div x-show="isSyncConfirmOpen" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
@click="isSyncConfirmOpen = false"></div>
<!-- Modal Content -->
<div x-show="isSyncConfirmOpen" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="relative transform overflow-hidden rounded-[2.5rem] bg-white dark:bg-slate-900 p-8 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-lg border border-slate-200 dark:border-slate-800">
<div class="flex items-center gap-4 mb-6">
<div
class="w-14 h-14 rounded-2xl bg-amber-500/10 flex items-center justify-center text-amber-500 border border-amber-500/20">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight uppercase">{{
__('Command Confirmation') }}</h3>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mt-0.5">{{ __('Please confirm the details below') }}</p>
</div>
</div>
<div class="space-y-4 bg-slate-50 dark:bg-slate-950/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800/50 mb-8">
<div class="flex justify-between items-center px-1">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Command Type') }}</span>
<span class="text-sm font-black text-slate-800 dark:text-slate-200">{{ __('Sync to Machine') }}</span>
</div>
<div class="flex justify-between items-center px-1 pt-3 border-t border-slate-200/50 dark:border-slate-800/50">
<span class="text-[10px] font-black text-cyan-500 uppercase tracking-widest">{{ __('Target Machine') }}</span>
<span class="text-sm font-black text-slate-800 dark:text-slate-200" x-text="machines.find(m => m.id == selectedMachineId)?.name || ''"></span>
</div>
<div class="space-y-2 px-1 pt-3 border-t border-slate-200/50 dark:border-slate-800/50">
<label
class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.2em] ml-1">{{
__('Operation Note') }}</label>
<textarea x-model="syncNote"
class="luxury-input w-full min-h-[100px] text-sm py-3 px-4 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 focus:border-cyan-500/50 rounded-2xl"
placeholder="{{ __('Reason for this command...') }}"></textarea>
</div>
</div>
<div class="flex gap-4">
<button @click="isSyncConfirmOpen = false"
class="flex-1 px-6 py-4 rounded-2xl bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 text-xs font-black uppercase tracking-widest hover:bg-slate-200 dark:hover:bg-slate-700 transition-all">
{{ __('Cancel') }}
</button>
<button @click="executeSyncAds()"
class="flex-1 px-6 py-4 rounded-2xl bg-cyan-600 text-white text-xs font-black uppercase tracking-widest hover:bg-cyan-500 shadow-lg shadow-cyan-500/20 active:scale-[0.98] transition-all">
{{ __('Execute') }}
</button>
</div>
</div>
</div>
</div>
</template>
<!-- Preview Modal -->
<div x-show="isPreviewOpen" class="fixed inset-0 z-[120] flex items-center justify-center p-4 sm:p-6" x-cloak
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
@ -686,6 +770,10 @@ $baseRoute = 'admin.data-config.advertisements';
position: ''
},
// Sync Confirmation
isSyncConfirmOpen: false,
syncNote: '',
// Sequence Preview
isSequencePreviewOpen: false,
sequenceAds: [],
@ -751,6 +839,44 @@ $baseRoute = 'admin.data-config.advertisements';
}
},
syncAdsToMachine() {
if (!this.selectedMachineId) {
window.showToast?.('{{ __('Please select a machine first') }}', 'error');
return;
}
this.isSyncConfirmOpen = true;
},
async executeSyncAds() {
this.isSyncConfirmOpen = false;
try {
const url = this.urls.syncToMachine.replace(':id', this.selectedMachineId);
const formData = new FormData();
formData.append('note', this.syncNote);
const response = await fetch(url, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: formData
});
const result = await response.json();
if (result.success) {
window.showToast?.(result.message || '指令已發送', 'success');
} else {
window.showToast?.(result.message || '發送失敗', 'error');
}
this.syncNote = ''; // reset note on finish
} catch (error) {
console.error('Error syncing ads:', error);
window.showToast?.('發送失敗,請重試', 'error');
}
},
startSequencePreview(pos) {
if (!this.machineAds[pos] || this.machineAds[pos].length === 0) return;
this.sequenceAds = this.machineAds[pos];

View File

@ -41,6 +41,7 @@
'Remote Change' => __('Remote Change'),
'Remote Dispense' => __('Remote Dispense'),
'Adjust Stock & Expiry' => __('Adjust Stock & Expiry'),
'Sync Ads' => __('Sync Ads'),
'Pending' => __('Pending'),
'Sent' => __('Sent'),
'Success' => __('Success'),
@ -441,7 +442,8 @@
'unlock': this.translations['Lock Page Unlock'],
'change': this.translations['Remote Change'],
'dispense': this.translations['Remote Dispense'],
'reload_stock': this.translations['Adjust Stock & Expiry']
'reload_stock': this.translations['Adjust Stock & Expiry'],
'update_ads': this.translations['Sync Ads']
};
return names[type] || type;
},
@ -951,7 +953,12 @@
<span class="text-sm font-black text-slate-800 dark:text-slate-200"
x-text="getCommandName(confirmModal.type)"></span>
</div>
<div class="space-y-2 px-1">
<div class="flex justify-between items-center px-1 pt-3 border-t border-slate-200/50 dark:border-slate-800/50">
<span class="text-[10px] font-black text-cyan-500 uppercase tracking-widest">{{ __('Target Machine') }}</span>
<span class="text-sm font-black text-slate-800 dark:text-slate-200"
x-text="selectedMachine ? selectedMachine.name : ''"></span>
</div>
<div class="space-y-2 px-1 pt-3 border-t border-slate-200/50 dark:border-slate-800/50">
<label
class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.2em] ml-1">{{
__('Operation Note') }}</label>

View File

@ -83,6 +83,7 @@
'unlock' => __('Lock Page Unlock'),
'change' => __('Remote Change'),
'dispense' => __('Remote Dispense'),
'update_ads' => __('Sync Ads'),
]" :selected="request('command_type')" :placeholder="__('All Command Types')"
:hasSearch="false"
@change="searchInTab('history')" />

View File

@ -173,6 +173,7 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
Route::post('/advertisements/assign', [App\Http\Controllers\Admin\AdvertisementController::class, 'assign'])->name('advertisements.assign');
Route::post('/advertisements/assignments/reorder', [App\Http\Controllers\Admin\AdvertisementController::class, 'reorderAssignments'])->name('advertisements.assignments.reorder');
Route::delete('/advertisements/assignment/{id}', [App\Http\Controllers\Admin\AdvertisementController::class, 'removeAssignment'])->name('advertisements.assignment.remove');
Route::post('/advertisements/machine/{machine}/sync', [App\Http\Controllers\Admin\AdvertisementController::class, 'syncToMachine'])->name('advertisements.machine.sync');
});
Route::get('/sub-accounts', [App\Http\Controllers\Admin\PermissionController::class, 'accounts'])->name('sub-accounts')->middleware('can:menu.data-config.sub-accounts');