[FIX] 修正 IoT 管理介面分頁持久化與實作 B055 遠端出貨 API
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 3m58s

1. 視圖持久化優化:將 index/stock 視圖切換從 x-if 改為 x-show,解決 HSSelect 在切換分頁後失效的問題。
2. 變數存取安全:為所有 selectedMachine 屬性存取補上可選鏈 (?.) 保護,防止 x-show 模式下的 null 錯誤。
3. UI 體驗提升:放大「指令中心」與「庫存管理」歷史紀錄的時間字體至 15px 並加粗顯示。
4. API 功能實作:在 routes/api.php 與 MachineController 中實作 B055 遠端指令出貨控制端點。
5. 文件同步:更新 SKILL.md 技術規格文件,明確定義 B055 的請求與回應格式。
6. 樣式調整:修改 app.css 優化奢華風 UI 字體與間距細節。
This commit is contained in:
2026-04-15 10:54:58 +08:00
parent f49938d1a7
commit 376f43fa3a
8 changed files with 1404 additions and 797 deletions

View File

@@ -8,7 +8,8 @@ window.stockApp = function(initialMachineId) {
searchQuery: '',
selectedMachine: null,
slots: [],
viewMode: initialMachineId ? 'detail' : 'history',
viewMode: initialMachineId ? 'detail' : (new URLSearchParams(window.location.search).has('search') || new URLSearchParams(window.location.search).has('page') ? 'history' : 'history'),
// 預設為 history但我們會確保在有搜尋或分頁時維持 history
history: @js($history),
loading: false,
updating: false,
@@ -271,9 +272,89 @@ window.stockApp = function(initialMachineId) {
<div class="mt-6">
<!-- History View: Operation Records -->
<template x-if="viewMode === 'history'">
<div x-show="viewMode === 'history'" x-cloak>
<div class="space-y-6 animate-luxury-in">
<div class="luxury-card rounded-3xl p-8 overflow-hidden">
<!-- Filters Area -->
<div class="mb-8">
<form method="GET" action="{{ route('admin.remote.stock') }}" class="flex flex-wrap items-center gap-4">
<!-- Search Box -->
<div class="relative group flex-[1.5] min-w-[200px]">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" name="search" value="{{ request('search') }}"
placeholder="{{ __('Search machines...') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full">
</div>
<!-- Date Range -->
<div class="relative group flex-[2] min-w-[340px]"
x-data="{
fp: null,
startDate: '{{ request('start_date') }}',
endDate: '{{ request('end_date') }}'
}"
x-init="fp = flatpickr($refs.dateRange, {
mode: 'range',
dateFormat: 'Y-m-d H:i', enableTime: true, time_24hr: true,
locale: 'zh_tw',
defaultDate: startDate && endDate ? [startDate, endDate] : (startDate ? [startDate] : []),
onChange: function(selectedDates, dateStr, instance) {
if (selectedDates.length === 2) {
$refs.startDate.value = instance.formatDate(selectedDates[0], 'Y-m-d H:i');
$refs.endDate.value = instance.formatDate(selectedDates[1], 'Y-m-d H:i');
} else if (selectedDates.length === 0) {
$refs.startDate.value = '';
$refs.endDate.value = '';
}
}
})">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</span>
<input type="hidden" name="start_date" x-ref="startDate" value="{{ request('start_date') }}">
<input type="hidden" name="end_date" x-ref="endDate" value="{{ request('end_date') }}">
<input type="text" x-ref="dateRange"
value="{{ request('start_date') && request('end_date') ? request('start_date') . ' 至 ' . request('end_date') : (request('start_date') ?: '') }}"
placeholder="{{ __('Select Date Range') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full cursor-pointer">
</div>
<!-- Command Type -->
<!-- Status -->
<div class="flex-1 min-w-[160px]">
<x-searchable-select
name="status"
:options="[
'pending' => __('Pending'),
'sent' => __('Sent'),
'success' => __('Success'),
'failed' => __('Failed'),
'superseded' => __('Superseded'),
]"
:selected="request('status')"
:placeholder="__('All Status')"
:hasSearch="false"
onchange="this.form.submit()"
/>
</div>
<!-- Actions -->
<div class="flex items-center gap-2">
<button type="submit" class="p-2.5 rounded-xl bg-cyan-600 text-white hover:bg-cyan-500 shadow-lg shadow-cyan-500/20 transition-all active:scale-95">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</button>
<a href="{{ route('admin.remote.stock') }}" class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all active:scale-95">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</a>
</div>
</form>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0 text-sm">
<thead>
@@ -287,9 +368,9 @@ window.stockApp = function(initialMachineId) {
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
<template x-for="item in history" :key="item.id">
@foreach ($history as $item)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6 cursor-pointer" @click="selectMachine(item.machine)">
<td class="px-6 py-6 cursor-pointer" @click="selectMachine(@js($item->machine))">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 shadow-sm overflow-hidden">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
@@ -297,67 +378,65 @@ window.stockApp = function(initialMachineId) {
</svg>
</div>
<div>
<div class="text-[17px] font-black text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors tracking-tight" x-text="item.machine.name"></div>
<div class="text-[11px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="item.machine.serial_no"></div>
<div class="text-[17px] font-black text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors tracking-tight">{{ $item->machine->name }}</div>
<div class="text-[11px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5">{{ $item->machine->serial_no }}</div>
</div>
</div>
</td>
<td class="px-6 py-6 font-mono text-xs font-black text-slate-400 tracking-widest whitespace-nowrap text-center">
<div class="flex flex-col">
<span x-text="new Date(item.created_at).toLocaleDateString()"></span>
<span class="text-[10px] opacity-70" x-text="new Date(item.created_at).toLocaleTimeString()"></span>
<span>{{ $item->created_at->format('Y/m/d') }}</span>
<span class="text-[15px] font-bold text-slate-500 dark:text-slate-400">{{ $item->created_at->format('H:i:s') }}</span>
</div>
</td>
<td class="px-6 py-6 font-mono text-xs font-black text-slate-400 tracking-widest whitespace-nowrap text-center">
<template x-if="item.executed_at">
@if($item->executed_at)
<div class="flex flex-col text-cyan-600/80 dark:text-cyan-400/60">
<span x-text="new Date(item.executed_at).toLocaleDateString()"></span>
<span class="text-[10px] opacity-70" x-text="new Date(item.executed_at).toLocaleTimeString()"></span>
<span>{{ $item->executed_at->format('Y/m/d') }}</span>
<span class="text-[15px] font-bold">{{ $item->executed_at->format('H:i:s') }}</span>
</div>
</template>
<template x-if="!item.executed_at">
@else
<span class="text-slate-300 dark:text-slate-700">-</span>
</template>
@endif
</td>
<td class="px-6 py-6">
<div class="flex flex-col min-w-[200px]">
<span class="text-sm font-black text-slate-700 dark:text-slate-300 tracking-tight" x-text="getCommandName(item.command_type)"></span>
<span class="text-sm font-black text-slate-700 dark:text-slate-300 tracking-tight" x-text="getCommandName(@js($item->command_type))"></span>
<div class="flex flex-col gap-0.5 mt-1">
<template x-if="getPayloadDetails(item)">
<span class="text-[11px] font-bold text-cyan-600 dark:text-cyan-400/80 bg-cyan-500/5 px-2 py-0.5 rounded-md border border-cyan-500/10 w-fit" x-text="getPayloadDetails(item)"></span>
</template>
<template x-if="item.note">
<span class="text-[10px] text-slate-400 italic pl-1" x-text="translateNote(item.note)"></span>
</template>
<span x-show="getPayloadDetails(@js($item))" class="text-[11px] font-bold text-cyan-600 dark:text-cyan-400/80 bg-cyan-500/5 px-2 py-0.5 rounded-md border border-cyan-500/10 w-fit" x-text="getPayloadDetails(@js($item))"></span>
@if($item->note)
<span class="text-[10px] text-slate-400 italic pl-1" x-text="translateNote(@js($item->note))"></span>
@endif
</div>
</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-cyan-500/10 flex items-center justify-center text-[10px] font-black text-cyan-600 dark:text-cyan-400 border border-cyan-500/20"
x-text="getOperatorName(item.user).substring(0,1)"></div>
<span class="text-sm font-bold text-slate-600 dark:text-slate-300" x-text="getOperatorName(item.user)"></span>
<div class="w-6 h-6 rounded-full bg-cyan-500/10 flex items-center justify-center text-[10px] font-black text-cyan-600 dark:text-cyan-400 border border-cyan-500/20">
{{ mb_substr($item->user ? $item->user->name : __('System'), 0, 1) }}
</div>
<span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ $item->user ? $item->user->name : __('System') }}</span>
</div>
</td>
<td class="px-6 py-6 text-center">
<div class="flex flex-col items-center gap-1.5">
<div class="inline-flex items-center px-4 py-1.5 rounded-full border text-[10px] font-black uppercase tracking-widest shadow-sm"
:class="getCommandBadgeClass(item.status)">
:class="getCommandBadgeClass(@js($item->status))">
<div class="w-1.5 h-1.5 rounded-full mr-2"
:class="{
'bg-amber-500 animate-pulse': item.status === 'pending',
'bg-cyan-500': item.status === 'sent',
'bg-emerald-500': item.status === 'success',
'bg-rose-500': item.status === 'failed',
'bg-slate-400': item.status === 'superseded'
'bg-amber-500 animate-pulse': @js($item->status) === 'pending',
'bg-cyan-500': @js($item->status) === 'sent',
'bg-emerald-500': @js($item->status) === 'success',
'bg-rose-500': @js($item->status) === 'failed',
'bg-slate-400': @js($item->status) === 'superseded'
}"></div>
<span x-text="getCommandStatus(item.status)"></span>
<span x-text="getCommandStatus(@js($item->status))"></span>
</div>
</div>
</td>
</tr>
</template>
<template x-if="history.length === 0">
@endforeach
@if($history->isEmpty())
<tr>
<td colspan="6" class="px-6 py-20 text-center">
<div class="flex flex-col items-center gap-3">
@@ -370,17 +449,22 @@ window.stockApp = function(initialMachineId) {
</div>
</td>
</tr>
</template>
@endif
</tbody>
</table>
</div>
<!-- Pagination Area -->
<div class="mt-8">
{{ $history->appends(request()->query())->links('vendor.pagination.luxury') }}
</div>
</div>
</div>
</template>
</div>
<!-- Master View: Machine List -->
<template x-if="viewMode === 'list'">
<div x-show="viewMode === 'list'" x-cloak>
<div class="space-y-6 animate-luxury-in">
<div class="luxury-card rounded-3xl p-8 overflow-hidden">
<!-- Filters Area -->
@@ -503,10 +587,10 @@ window.stockApp = function(initialMachineId) {
</div>
</div>
</div>
</template>
</div>
<!-- Detail View: Cabinet Management -->
<template x-if="viewMode === 'detail'">
<div x-show="viewMode === 'detail'" x-cloak>
<div class="space-y-8 animate-luxury-in">
<!-- Machine Header Info -->
@@ -646,7 +730,7 @@ window.stockApp = function(initialMachineId) {
</div>
</div>
</div>
</template>
</div>
<!-- Integrated Edit Modal -->
<div x-show="showEditModal"