[FEAT] 優化機台 API 通訊識別、補齊前端必填驗證、並配置 Demo 站隊列自動化部署 🦾🚀
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 49s

This commit is contained in:
2026-03-26 13:09:48 +08:00
parent 19076c363c
commit f60e5a9c72
15 changed files with 488 additions and 31 deletions

View File

@@ -74,7 +74,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
<div>
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Machine Name') }}</label>
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Machine Name') }} <span class="text-rose-500">*</span></label>
<input type="text" name="name" value="{{ old('name', $machine->name) }}" class="luxury-input w-full" required>
</div>
<div>

View File

@@ -2,6 +2,7 @@
@section('content')
<div class="space-y-2 pb-20" x-data="{
tab: '{{ $tab }}',
showCreateMachineModal: false,
showPhotoModal: false,
showDetailDrawer: false,
@@ -24,8 +25,10 @@
this.maintenanceQrUrl = baseUrl.replace('SERIAL_NO', machine.serial_no);
this.showMaintenanceQrModal = true;
},
openDetail(machine) {
openDetail(machine, id, serial) {
this.currentMachine = machine;
window.activeMachineId = id || machine?.id;
window.activeMachineSerial = serial || machine?.serial_no;
this.showDetailDrawer = true;
},
openPhotoModal(machine) {
@@ -60,8 +63,57 @@
confirmDelete(action) {
this.deleteFormAction = action;
this.isDeleteConfirmOpen = true;
},
// API Token Management
showApiToken: false,
loadingRegenerate: false,
isRegenerateConfirmOpen: false,
copyToken(machine) {
if (!machine?.api_token) return;
navigator.clipboard.writeText(machine.api_token).then(() => {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('API Token Copied') }}', type: 'success' } }));
});
},
regenerateToken() {
this.isRegenerateConfirmOpen = true;
},
executeRegeneration(id, serial) {
// 僅使用機台序號 (Serial Number) 作為識別碼
const targetSerial = serial || window.activeMachineSerial || id;
if (!targetSerial) {
console.error('ExecuteRegeneration failed: No serial number available');
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: '{{ __('Missing machine identification') }}', type: 'error' }
}));
return;
}
console.log('ExecuteRegeneration using serial:', targetSerial);
this.isRegenerateConfirmOpen = false;
this.loadingRegenerate = true;
fetch(`/admin/basic-settings/machines/${targetSerial}/regenerate-token`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=\'csrf-token\']').content,
'Accept': 'application/json',
'Content-Type': 'application/json'
}
}).then(res => res.json()).then(data => {
this.loadingRegenerate = false;
if(data.success) {
if (this.currentMachine) {
this.currentMachine.api_token = data.api_token;
}
window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message, type: 'success' } }));
}
}).catch(() => {
this.loadingRegenerate = false;
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Error processing request') }}', type: 'error' } }));
});
}
}">
}" @execute-regenerate.window="executeRegeneration($event.detail)">
<!-- 1. Header Area -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
@@ -149,7 +201,7 @@
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($machines as $machine)
<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="openDetail({{ $machine->toJson() }})">
<td class="px-6 py-6 cursor-pointer" @click='openDetail({{ $machine->toJson() }}, {{ $machine->id }}, "{{ $machine->serial_no }}")'>
<div class="flex items-center gap-4">
<div
class="w-10 h-10 rounded-xl 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 overflow-hidden">
@@ -175,7 +227,7 @@
</div>
</div>
</td>
<td class="px-6 py-6" @click="openDetail({{ $machine->toJson() }})">
<td class="px-6 py-6 cursor-pointer" @click='openDetail({{ $machine->toJson() }}, {{ $machine->id }}, "{{ $machine->serial_no }}")'>
<span
class="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase tracking-widest">
{{ $machine->machineModel->name ?? '--' }}
@@ -183,7 +235,7 @@
</td>
<td class="px-6 py-6">
@php
$isOnline = $machine->last_heartbeat_at && $machine->last_heartbeat_at->diffInMinutes() < 5;
$isOnline = $machine->last_heartbeat_at && $machine->last_heartbeat_at->diffInSeconds() < 30;
@endphp <div class="flex items-center gap-2.5">
<div class="relative flex h-2.5 w-2.5">
@if($isOnline)
@@ -396,22 +448,25 @@
<div class="px-8 py-8 space-y-6">
<div>
<label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
__('Machine Name') }}</label>
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
{{ __('Machine Name') }} <span class="text-rose-500">*</span>
</label>
<input type="text" name="name" required class="luxury-input w-full"
placeholder="{{ __('Enter machine name') }}">
</div>
<div>
<label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
__('Serial No') }}</label>
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
{{ __('Serial No') }} <span class="text-rose-500">*</span>
</label>
<input type="text" name="serial_no" required class="luxury-input w-full"
placeholder="{{ __('Enter serial number') }}">
</div>
<div class="relative z-20">
<label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
__('Owner') }}</label>
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
{{ __('Owner') }}
</label>
<x-searchable-select name="company_id" required :placeholder="__('Select Owner')">
@foreach($companies as $company)
<option value="{{ $company->id }}" data-title="{{ $company->name }}{{ $company->code ? ' (' . $company->code . ')' : '' }}">
@@ -422,15 +477,17 @@
</div>
<div>
<label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
__('Location') }}</label>
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
{{ __('Location') }}
</label>
<input type="text" name="location" class="luxury-input w-full"
placeholder="{{ __('Enter machine location') }}">
</div>
<div class="relative z-10">
<label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
__('Model') }}</label>
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
{{ __('Model') }}
</label>
<x-searchable-select name="machine_model_id" required :placeholder="__('Select Model')">
@foreach($models as $model)
<option value="{{ $model->id }}">{{ $model->name }}</option>
@@ -439,8 +496,9 @@
</div>
<div>
<label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
__('Machine Images') }} ({{ __('Max 3') }})</label>
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
{{ __('Machine Images') }} ({{ __('Max 3') }})
</label>
<label
class="relative flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl cursor-pointer bg-slate-50/50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-800/80 transition-all group">
<template x-if="selectedFileCount === 0">
@@ -772,7 +830,7 @@
<div class="p-10 flex flex-col items-center gap-6">
<div class="p-4 bg-white rounded-3xl shadow-xl border border-slate-100">
<img :src="'https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=' + encodeURIComponent(maintenanceQrUrl)"
<img :src="'{{ route('admin.basic-settings.qr-code') }}?data=' + encodeURIComponent(maintenanceQrUrl)"
class="w-48 h-48"
alt="{{ __('Maintenance QR Code') }}">
</div>
@@ -893,11 +951,38 @@
<span class="text-xs font-black text-slate-700 dark:text-slate-300"
x-text="currentMachine?.card_reader_no || '--'"></span>
</div>
<div
class="flex items-center justify-between p-2 border-b border-slate-50 dark:border-white/5">
<span class="text-xs font-bold text-slate-500">{{ __('API Token') }}</span>
<span class="text-[10px] font-mono text-slate-400 truncate max-w-[150px]"
x-text="currentMachine?.api_token || '--'"></span>
<div class="flex flex-col gap-3 p-3 mt-1 bg-slate-50 dark:bg-slate-800/40 rounded-xl border border-slate-100 dark:border-slate-700/50 relative">
<div class="flex items-center justify-between">
<span class="text-[11px] font-black text-slate-500 uppercase tracking-widest">{{ __('API Token') }}</span>
<div class="flex items-center gap-1">
<template x-if="currentMachine?.api_token">
<div class="flex items-center gap-1">
<button @click="showApiToken = !showApiToken"
class="p-1.5 rounded-lg text-slate-400 hover:text-cyan-500 hover:bg-cyan-50 dark:hover:bg-cyan-900/40 transition-all font-bold"
:title="showApiToken ? '{{ __('Hide') }}' : '{{ __('Show') }}'">
<svg x-show="!showApiToken" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
<svg x-show="showApiToken" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/></svg>
</button>
<button @click="copyToken(currentMachine)"
class="p-1.5 rounded-lg text-slate-400 hover:text-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-900/40 transition-all font-bold"
title="{{ __('Copy') }}">
<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.5" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</template>
<button @click="regenerateToken()" :disabled="loadingRegenerate"
class="ml-2 px-2.5 py-1.5 rounded-lg bg-rose-50 dark:bg-rose-500/10 text-rose-500 hover:bg-rose-100 dark:hover:bg-rose-500/20 text-[10px] font-black uppercase tracking-widest transition-all disabled:opacity-50 flex items-center gap-1.5 border border-rose-100 dark:border-rose-500/20"
title="{{ __('Regenerate') }}">
<svg x-show="loadingRegenerate" class="animate-spin w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
<svg x-show="!loadingRegenerate" class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" 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>
<span>{{ __('Regenerate') }}</span>
</button>
</div>
</div>
<div class="bg-white dark:bg-slate-900/50 rounded-lg border border-slate-200 dark:border-slate-700/50 p-2.5 overflow-x-auto custom-scrollbar">
<span class="text-xs font-mono font-bold tracking-[0.1em] text-cyan-600 dark:text-cyan-400 select-all block whitespace-nowrap min-w-full"
x-text="currentMachine?.api_token ? (showApiToken ? currentMachine.api_token : '•'.repeat(40)) : '{{ __('None') }}'"></span>
</div>
</div>
</div>
</section>
@@ -926,6 +1011,16 @@
<!-- Global Delete Confirm Modal -->
<x-delete-confirm-modal />
<x-confirm-modal
alpine-var="isRegenerateConfirmOpen"
confirm-action="isRegenerateConfirmOpen = false; window.dispatchEvent(new CustomEvent('execute-regenerate', { detail: window.activeMachineSerial || window.activeMachineId }))"
icon-type="warning"
confirm-color="sky"
:title="__('Are you sure?')"
:message="__('Regenerating the token will disconnect the physical machine until it is updated. Continue?')"
:confirm-text="__('Yes, regenerate')"
/>
</div>
@endsection

View File

@@ -0,0 +1,91 @@
@props([
'alpineVar' => 'isOpen',
'confirmAction' => 'confirm()', // The JS expression to run on confirm
'iconType' => 'warning', // warning, info, danger, success
'title' => __('Confirm'),
'message' => __('Are you sure?'),
'confirmText' => __('Confirm'),
'cancelText' => __('Cancel'),
'confirmColor' => 'sky', // sky, rose, amber, emerald
])
@php
$iconClasses = [
'warning' => 'bg-amber-100 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400',
'danger' => 'bg-rose-100 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400',
'info' => 'bg-sky-100 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400',
'success' => 'bg-emerald-100 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
][$iconType];
$btnClasses = [
'sky' => 'bg-sky-500 hover:bg-sky-600 shadow-sky-200',
'rose' => 'bg-rose-500 hover:bg-rose-600 shadow-rose-200',
'amber' => 'bg-amber-500 hover:bg-amber-600 shadow-amber-200',
'emerald' => 'bg-emerald-500 hover:bg-emerald-600 shadow-emerald-200',
][$confirmColor];
@endphp
<template x-teleport="body">
<div x-show="{{ $alpineVar }}" class="fixed inset-0 z-[200] overflow-y-auto" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div x-show="{{ $alpineVar }}" 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 transition-opacity bg-slate-900/60 backdrop-blur-sm"
@click="{{ $alpineVar }} = false"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div x-show="{{ $alpineVar }}" 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="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white dark:bg-slate-900 rounded-3xl shadow-2xl sm:my-8 sm:align-middle sm:max-w-md sm:w-full sm:p-8 border border-slate-100 dark:border-slate-800 relative z-10">
<div class="sm:flex sm:items-start text-center sm:text-left">
<div class="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto rounded-2xl sm:mx-0 sm:h-12 sm:w-12 {{ $iconClasses }}">
@if($iconType === 'warning')
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
@elseif($iconType === 'danger')
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<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>
@elseif($iconType === 'info')
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@elseif($iconType === 'success')
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@endif
</div>
<div class="mt-3 sm:mt-0 sm:ml-6">
<h3 class="text-xl font-black text-slate-800 dark:text-white leading-6 tracking-tight font-display uppercase">
{{ $title }}
</h3>
<div class="mt-4">
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 leading-relaxed">
{{ $message }}
</p>
</div>
</div>
</div>
<div class="mt-8 sm:mt-10 sm:flex sm:flex-row-reverse gap-3">
<button type="button" @click="{{ $confirmAction }}"
class="inline-flex justify-center w-full px-6 py-3 text-sm font-black text-white transition-all rounded-xl shadow-lg dark:shadow-none hover:scale-[1.02] active:scale-[0.98] sm:w-auto uppercase tracking-widest font-display {{ $btnClasses }}">
{{ $confirmText }}
</button>
<button type="button" @click="{{ $alpineVar }} = false"
class="inline-flex justify-center w-full px-6 py-3 mt-3 text-sm font-black text-slate-700 dark:text-slate-200 transition-all bg-slate-100 dark:bg-slate-800 rounded-xl hover:bg-slate-200 dark:hover:bg-slate-700 sm:mt-0 sm:w-auto uppercase tracking-widest font-display">
{{ $cancelText }}
</button>
</div>
</div>
</div>
</div>
</template>