[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:
parent
7d3e25d090
commit
ce87f453a2
@ -279,6 +279,7 @@ Mqtt3Client client = MqttClient.builder()
|
||||
- `change`: 遠端找零(夾帶 amount 參數)。
|
||||
- `update_inventory`: 遠端即時修改庫存。
|
||||
- `dispense`: 遠端出貨指令。
|
||||
- `update_ads`: 遠端廣告同步指令。
|
||||
|
||||
**`update_inventory` Payload 範例:**
|
||||
```json
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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": "広告を同期"
|
||||
}
|
||||
@ -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": "同步廣告"
|
||||
}
|
||||
@ -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,14 +370,27 @@ $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>
|
||||
<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">
|
||||
<!-- Positions Grid -->
|
||||
@ -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];
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')" />
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user