[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

@@ -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