[FEAT] 完善 IoT API 規范化、機台管理介面優化與 B005 改為 GET
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m4s
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m4s
1. 將 B005 (廣告同步) 從 POST 改為 GET,符合 RESTful 規範。
2. 完善 B009 (庫存回報) 回應規格,加入業務代碼 (200 OK)。
3. API 文件 UI 優化:新增 Method Badge (方法標籤),並修正 JSON 中文/斜線轉義問題。
4. 機台管理介面優化:實作「唯讀庫存與效期」面板,並將日誌圖示改為「👁️」。
5. 標準化 ID 識別邏輯:資料表全面移除對 sku 的依賴,改以 id 為主、barcode 為輔。
6. 新增 Migration:正式移除 sku 欄位並同步 barcode 指向。
7. 更新多語系支援 (zh_TW, en, ja)。
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
return {
|
||||
showLogPanel: false,
|
||||
showEditModal: false,
|
||||
showInventoryPanel: false,
|
||||
editMachineId: '',
|
||||
editMachineName: '',
|
||||
activeTab: 'status',
|
||||
@@ -15,12 +16,14 @@
|
||||
currentMachineName: '',
|
||||
logs: [],
|
||||
loading: false,
|
||||
inventoryLoading: false,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
tab: 'list',
|
||||
viewMode: 'fleet',
|
||||
selectedMachine: null,
|
||||
slots: [],
|
||||
inventorySlots: [],
|
||||
|
||||
init() {
|
||||
const d = new Date();
|
||||
@@ -79,11 +82,43 @@
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
|
||||
// 庫存一覽面板 (唯讀)
|
||||
async openInventoryPanel(id, sn, name) {
|
||||
this.currentMachineId = id;
|
||||
this.currentMachineSn = sn;
|
||||
this.currentMachineName = name;
|
||||
this.inventorySlots = [];
|
||||
this.showInventoryPanel = true;
|
||||
this.inventoryLoading = true;
|
||||
try {
|
||||
const res = await fetch('/admin/machines/' + id + '/slots-ajax');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.inventorySlots = data.slots;
|
||||
}
|
||||
} catch (e) { console.error('openInventoryPanel error:', e); }
|
||||
finally { this.inventoryLoading = false; }
|
||||
},
|
||||
|
||||
getSlotColorClass(slot) {
|
||||
if (!slot.expiry_date) return 'bg-slate-50/50 dark:bg-slate-800/50 text-slate-400 border-slate-200/60 dark:border-slate-700/50';
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
const expiryStr = slot.expiry_date;
|
||||
if (expiryStr < todayStr) {
|
||||
return 'bg-rose-50/60 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-200 dark:border-rose-500/30 shadow-sm shadow-rose-500/5';
|
||||
}
|
||||
const diffDays = Math.round((new Date(expiryStr) - new Date(todayStr)) / 86400000);
|
||||
if (diffDays <= 7) {
|
||||
return 'bg-amber-50/60 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-500/30 shadow-sm shadow-amber-500/5';
|
||||
}
|
||||
return 'bg-emerald-50/60 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/30 shadow-sm shadow-emerald-500/5';
|
||||
},
|
||||
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 pb-20 mt-4" x-data="machineApp()" @keydown.escape.window="showLogPanel = false">
|
||||
<div class="space-y-4 pb-20 mt-4" x-data="machineApp()" @keydown.escape.window="showLogPanel = false; showInventoryPanel = false">
|
||||
<!-- Top Header & Actions -->
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -243,6 +278,16 @@
|
||||
</td>
|
||||
<td class="px-6 py-6 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button type="button"
|
||||
@click="openInventoryPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}')"
|
||||
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn tooltip"
|
||||
title="{{ __('View Inventory') }}">
|
||||
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button"
|
||||
@click="openEditModal('{{ $machine->id }}', '{{ addslashes($machine->name) }}')"
|
||||
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn tooltip"
|
||||
@@ -260,7 +305,9 @@
|
||||
<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="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -571,4 +618,194 @@
|
||||
</div>
|
||||
</div><!-- /Edit Modal -->
|
||||
|
||||
<!-- Inventory Offcanvas Panel (唯讀庫存一覽) -->
|
||||
<div x-show="showInventoryPanel" class="fixed inset-0 z-[100] overflow-hidden" style="display: none;"
|
||||
aria-labelledby="inventory-panel-title" role="dialog" aria-modal="true">
|
||||
|
||||
<!-- Background backdrop -->
|
||||
<div x-show="showInventoryPanel" x-transition:enter="ease-in-out duration-300" x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100" x-transition:leave="ease-in-out duration-300"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
||||
class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity" @click="showInventoryPanel = false">
|
||||
</div>
|
||||
|
||||
<div class="fixed inset-y-0 right-0 max-w-full flex">
|
||||
<!-- Sliding panel -->
|
||||
<div x-show="showInventoryPanel"
|
||||
x-transition:enter="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
|
||||
x-transition:leave="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
|
||||
class="w-screen max-w-4xl">
|
||||
|
||||
<div class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="px-5 py-6 sm:px-8 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 id="inventory-panel-title"
|
||||
class="text-xl sm:text-2xl font-black text-slate-800 dark:text-white font-display flex items-center gap-2 sm:gap-3">
|
||||
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-cyan-500 flex-shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<span class="truncate">{{ __('Stock & Expiry Overview') }}</span>
|
||||
</h2>
|
||||
<div
|
||||
class="mt-2 flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-[10px] sm:text-sm text-slate-500 dark:text-slate-400 font-bold uppercase tracking-widest overflow-hidden">
|
||||
<span x-text="currentMachineSn"
|
||||
class="font-mono text-cyan-600 dark:text-cyan-400 truncate"></span>
|
||||
<span class="hidden sm:inline opacity-50">—</span>
|
||||
<span x-text="currentMachineName" class="truncate"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0 h-7 flex items-center">
|
||||
<button type="button" @click="showInventoryPanel = false"
|
||||
class="bg-white dark:bg-slate-800 rounded-full p-2 text-slate-400 hover:text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 transition duration-300 shadow-sm border border-slate-200 dark:border-slate-700">
|
||||
<span class="sr-only">{{ __('Close Panel') }}</span>
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 統計摘要 -->
|
||||
<div class="mt-6 flex items-center gap-4">
|
||||
<div class="px-5 py-3 rounded-2xl bg-white dark:bg-slate-800/50 flex flex-col items-center min-w-[100px] border border-slate-100 dark:border-slate-800/50">
|
||||
<span class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{{ __('Total Slots') }}</span>
|
||||
<span class="text-2xl font-black text-slate-700 dark:text-slate-200" x-text="inventorySlots.length"></span>
|
||||
</div>
|
||||
<div class="px-5 py-3 rounded-2xl bg-rose-500/5 border border-rose-500/10 flex flex-col items-center min-w-[100px]">
|
||||
<span class="text-[9px] font-black text-rose-500 uppercase tracking-widest mb-0.5">{{ __('Low Stock') }}</span>
|
||||
<span class="text-2xl font-black text-rose-600" x-text="inventorySlots.filter(s => s != null && s.stock <= 5).length"></span>
|
||||
</div>
|
||||
<div class="px-5 py-3 rounded-2xl bg-amber-500/5 border border-amber-500/10 flex flex-col items-center min-w-[100px]">
|
||||
<span class="text-[9px] font-black text-amber-500 uppercase tracking-widest mb-0.5">{{ __('Expiring') }}</span>
|
||||
<span class="text-2xl font-black text-amber-600" x-text="inventorySlots.filter(s => { if (!s || !s.expiry_date) return false; const diff = Math.round((new Date(s.expiry_date) - new Date()) / 86400000); return diff >= 0 && diff <= 7; }).length"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /Header -->
|
||||
|
||||
<!-- Body / Cabinet Grid -->
|
||||
<div class="flex-1 overflow-y-auto p-6 sm:p-8">
|
||||
<div class="relative min-h-[400px]">
|
||||
<!-- Loading State -->
|
||||
<div x-show="inventoryLoading"
|
||||
class="absolute inset-0 bg-white/50 dark:bg-slate-900/50 backdrop-blur-[1px] flex items-center justify-center z-20">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin">
|
||||
</div>
|
||||
<span class="text-xs font-black text-slate-400 uppercase tracking-widest">{{
|
||||
__('Loading...') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Legend -->
|
||||
<div class="flex items-center gap-6 mb-6" x-show="!inventoryLoading">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-rose-500 shadow-lg shadow-rose-500/30"></span>
|
||||
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{ __('Expired') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-amber-500 shadow-lg shadow-amber-500/30"></span>
|
||||
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{ __('Warning') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/30"></span>
|
||||
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{ __('Normal') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slots Grid (唯讀) -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5" x-show="!inventoryLoading">
|
||||
<template x-for="slot in inventorySlots" :key="slot.id">
|
||||
<div :class="getSlotColorClass(slot)"
|
||||
class="min-h-[260px] rounded-[2rem] p-5 flex flex-col items-center justify-center border-2 transition-all duration-300 relative">
|
||||
|
||||
<!-- Slot Header -->
|
||||
<div class="absolute top-3.5 left-4 right-4 flex justify-between items-center z-10">
|
||||
<div class="px-2.5 py-1 rounded-xl bg-slate-900/10 dark:bg-white/10 backdrop-blur-md border border-slate-900/5 dark:border-white/10 flex-shrink-0">
|
||||
<span class="text-xs font-black uppercase tracking-tighter text-slate-800 dark:text-white" x-text="slot.slot_no"></span>
|
||||
</div>
|
||||
<template x-if="slot.stock <= 2">
|
||||
<div class="px-2 py-1 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 animate-pulse whitespace-nowrap select-none">
|
||||
{{ __('Low') }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Product Image -->
|
||||
<div class="relative w-16 h-16 mb-3 mt-2">
|
||||
<div class="absolute inset-0 rounded-2xl bg-white/20 dark:bg-slate-900/40 backdrop-blur-xl border border-white/30 dark:border-white/5 shadow-inner overflow-hidden">
|
||||
<template x-if="slot.product && slot.product.image_url">
|
||||
<img :src="slot.product.image_url" class="w-full h-full object-cover">
|
||||
</template>
|
||||
<template x-if="!slot.product || !slot.product.image_url">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<svg class="w-7 h-7 opacity-20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slot Info -->
|
||||
<div class="text-center w-full space-y-2">
|
||||
<template x-if="slot.product">
|
||||
<div class="text-sm font-black truncate w-full opacity-90 tracking-tight" x-text="slot.product.name"></div>
|
||||
</template>
|
||||
<template x-if="!slot.product">
|
||||
<div class="text-sm font-bold text-slate-300 dark:text-slate-600 tracking-tight">{{ __('Empty') }}</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Stock Level -->
|
||||
<div class="flex items-baseline justify-center gap-1">
|
||||
<span class="text-xl font-black tracking-tighter leading-none" x-text="slot.stock"></span>
|
||||
<span class="text-xs font-black opacity-30">/</span>
|
||||
<span class="text-sm font-bold opacity-50" x-text="slot.max_stock || 10"></span>
|
||||
</div>
|
||||
|
||||
<!-- Expiry Date -->
|
||||
<div class="text-sm font-black tracking-tight leading-none opacity-80" x-text="slot.expiry_date ? slot.expiry_date.replace(/-/g, '/') : '----/--/--'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template x-if="inventorySlots.length === 0 && !inventoryLoading">
|
||||
<div class="px-6 py-20 text-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class="p-4 rounded-full bg-slate-50 dark:bg-slate-800/50 mb-4 border border-slate-100 dark:border-slate-800/50">
|
||||
<svg class="w-8 h-8 text-slate-300 dark:text-slate-600"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
class="text-[11px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{
|
||||
__('No slot data available') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div><!-- /Body -->
|
||||
|
||||
</div>
|
||||
</div><!-- /Sliding panel -->
|
||||
</div>
|
||||
</div><!-- /Inventory Offcanvas -->
|
||||
|
||||
@endsection
|
||||
Reference in New Issue
Block a user