[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

@@ -96,6 +96,7 @@ jobs:
php artisan optimize:clear && php artisan optimize:clear &&
php artisan optimize && php artisan optimize &&
php artisan view:cache && php artisan view:cache &&
php artisan queue:restart &&
php artisan db:seed --class=RoleSeeder --force && php artisan db:seed --class=RoleSeeder --force &&
php artisan db:seed --class=AdminUserSeeder --force php artisan db:seed --class=AdminUserSeeder --force
" "

View File

@@ -84,6 +84,7 @@ class MachineSettingController extends AdminController
$machine = Machine::create(array_merge($validated, [ $machine = Machine::create(array_merge($validated, [
'status' => 'offline', 'status' => 'offline',
'api_token' => \Illuminate\Support\Str::random(60),
'creator_id' => auth()->id(), 'creator_id' => auth()->id(),
'updater_id' => auth()->id(), 'updater_id' => auth()->id(),
'card_reader_seconds' => 30, // 預設值 'card_reader_seconds' => 30, // 預設值
@@ -119,6 +120,7 @@ class MachineSettingController extends AdminController
try { try {
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'serial_no' => 'sometimes|required|string|unique:machines,serial_no,' . $machine->id,
'card_reader_seconds' => 'required|integer|min:0', 'card_reader_seconds' => 'required|integer|min:0',
'payment_buffer_seconds' => 'required|integer|min:0', 'payment_buffer_seconds' => 'required|integer|min:0',
'card_reader_checkout_time_1' => 'nullable|string', 'card_reader_checkout_time_1' => 'nullable|string',
@@ -187,4 +189,25 @@ class MachineSettingController extends AdminController
return redirect()->route('admin.basic-settings.machines.index') return redirect()->route('admin.basic-settings.machines.index')
->with('success', __('Machine settings updated successfully.')); ->with('success', __('Machine settings updated successfully.'));
} }
public function regenerateToken(Request $request, $serial): \Illuminate\Http\JsonResponse
{
// 僅使用機台序號 (serial_no) 作為識別碼,最直覺且穩定
$machine = Machine::where('serial_no', $serial)->firstOrFail();
$newToken = \Illuminate\Support\Str::random(60);
$machine->update(['api_token' => $newToken]);
\Log::info('Machine API Token Regenerated', [
'machine_id' => $machine->id,
'serial_no' => $machine->serial_no,
'user_id' => auth()->id()
]);
return response()->json([
'success' => true,
'api_token' => $newToken,
'message' => __('API Token regenerated successfully.')
]);
}
} }

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
class QrCodeController extends Controller
{
/**
* Generate a QR Code image.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function generate(Request $request)
{
$data = $request->query('data');
$size = $request->query('size', 250);
if (!$data) {
return response()->noContent();
}
// Generate SVG QR Code
$qrCode = QrCode::size($size)
->format('svg')
->margin(1)
->generate($data);
return response($qrCode)->header('Content-Type', 'image/svg+xml');
}
}

View File

@@ -28,6 +28,24 @@ services:
- mysql - mysql
- redis - redis
laravel.queue:
image: 'sail-8.5/app'
container_name: star-cloud-queue
hostname: star-cloud-queue
command: php artisan queue:work --tries=3 --timeout=90
environment:
WWWUSER: '${WWWUSER}'
LARAVEL_SAIL: 1
TZ: 'Asia/Taipei'
volumes:
- '.:/var/www/html'
networks:
- sail
depends_on:
- mysql
- redis
restart: always
mysql: mysql:
image: 'mysql/mysql-server:8.0' image: 'mysql/mysql-server:8.0'
container_name: star-cloud-mysql container_name: star-cloud-mysql

View File

@@ -14,6 +14,7 @@
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/sanctum": "^4.3", "laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"simplesoftwareio/simple-qrcode": "^4.2",
"spatie/laravel-permission": "^7.2" "spatie/laravel-permission": "^7.2"
}, },
"require-dev": { "require-dev": {

174
composer.lock generated
View File

@@ -4,8 +4,62 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "a723334f883b537b67e4475890eb949e", "content-hash": "2889e194212440faeb9f8f3dd7513795",
"packages": [ "packages": [
{
"name": "bacon/bacon-qr-code",
"version": "2.0.8",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22",
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22",
"shasum": ""
},
"require": {
"dasprid/enum": "^1.0.3",
"ext-iconv": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phly/keep-a-changelog": "^2.1",
"phpunit/phpunit": "^7 | ^8 | ^9",
"spatie/phpunit-snapshot-assertions": "^4.2.9",
"squizlabs/php_codesniffer": "^3.4"
},
"suggest": {
"ext-imagick": "to generate QR code images"
},
"type": "library",
"autoload": {
"psr-4": {
"BaconQrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "BaconQrCode is a QR code generator for PHP.",
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8"
},
"time": "2022-12-07T17:46:57+00:00"
},
{ {
"name": "brick/math", "name": "brick/math",
"version": "0.14.8", "version": "0.14.8",
@@ -135,6 +189,56 @@
], ],
"time": "2024-02-09T16:56:22+00:00" "time": "2024-02-09T16:56:22+00:00"
}, },
{
"name": "dasprid/enum",
"version": "1.0.7",
"source": {
"type": "git",
"url": "https://github.com/DASPRiD/Enum.git",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"shasum": ""
},
"require": {
"php": ">=7.1 <9.0"
},
"require-dev": {
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "*"
},
"type": "library",
"autoload": {
"psr-4": {
"DASPRiD\\Enum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "PHP 7.1 enum implementation",
"keywords": [
"enum",
"map"
],
"support": {
"issues": "https://github.com/DASPRiD/Enum/issues",
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
},
"time": "2025-09-16T12:23:56+00:00"
},
{ {
"name": "dflydev/dot-access-data", "name": "dflydev/dot-access-data",
"version": "v3.0.3", "version": "v3.0.3",
@@ -3554,6 +3658,74 @@
}, },
"time": "2025-12-14T04:43:48+00:00" "time": "2025-12-14T04:43:48+00:00"
}, },
{
"name": "simplesoftwareio/simple-qrcode",
"version": "4.2.0",
"source": {
"type": "git",
"url": "https://github.com/SimpleSoftwareIO/simple-qrcode.git",
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SimpleSoftwareIO/simple-qrcode/zipball/916db7948ca6772d54bb617259c768c9cdc8d537",
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537",
"shasum": ""
},
"require": {
"bacon/bacon-qr-code": "^2.0",
"ext-gd": "*",
"php": ">=7.2|^8.0"
},
"require-dev": {
"mockery/mockery": "~1",
"phpunit/phpunit": "~9"
},
"suggest": {
"ext-imagick": "Allows the generation of PNG QrCodes.",
"illuminate/support": "Allows for use within Laravel."
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"QrCode": "SimpleSoftwareIO\\QrCode\\Facades\\QrCode"
},
"providers": [
"SimpleSoftwareIO\\QrCode\\QrCodeServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"SimpleSoftwareIO\\QrCode\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Simple Software LLC",
"email": "support@simplesoftware.io"
}
],
"description": "Simple QrCode is a QR code generator made for Laravel.",
"homepage": "https://www.simplesoftware.io/#/docs/simple-qrcode",
"keywords": [
"Simple",
"generator",
"laravel",
"qrcode",
"wrapper"
],
"support": {
"issues": "https://github.com/SimpleSoftwareIO/simple-qrcode/issues",
"source": "https://github.com/SimpleSoftwareIO/simple-qrcode/tree/4.2.0"
},
"time": "2021-02-08T20:43:55+00:00"
},
{ {
"name": "spatie/laravel-package-tools", "name": "spatie/laravel-package-tools",
"version": "1.93.0", "version": "1.93.0",

View File

@@ -702,5 +702,11 @@
"Confirm Account Deactivation": "Confirm Deactivation", "Confirm Account Deactivation": "Confirm Deactivation",
"Are you sure you want to deactivate this account? After deactivating, this account will no longer be able to log in to the system.": "Are you sure you want to deactivate this account? After deactivating, this account will no longer be able to log in to the system.", "Are you sure you want to deactivate this account? After deactivating, this account will no longer be able to log in to the system.": "Are you sure you want to deactivate this account? After deactivating, this account will no longer be able to log in to the system.",
"Enable": "Enable", "Enable": "Enable",
"Disable": "Disable" "Disable": "Disable",
} "Regenerate": "Regenerate",
"API Token Copied": "API Token Copied",
"Yes, regenerate": "Yes, regenerate",
"Regenerating the token will disconnect the physical machine until it is updated. Continue?": "Regenerating the token will disconnect the physical machine until it is updated. Continue?",
"Error processing request": "Error processing request",
"API Token regenerated successfully.": "API Token regenerated successfully."
}

View File

@@ -703,5 +703,11 @@
"Confirm Account Deactivation": "アカウント停止の確認", "Confirm Account Deactivation": "アカウント停止の確認",
"Are you sure you want to deactivate this account? After deactivating, this account will no longer be able to log in to the system.": "アカウントを停止してもよろしいですか?一旦停止するとシステムにログインできなくなります。", "Are you sure you want to deactivate this account? After deactivating, this account will no longer be able to log in to the system.": "アカウントを停止してもよろしいですか?一旦停止するとシステムにログインできなくなります。",
"Enable": "有効にする", "Enable": "有効にする",
"Disable": "無効にする" "Disable": "無効にする",
} "Regenerate": "再生成する",
"API Token Copied": "APIトークンがコピーされました",
"Yes, regenerate": "はい、再生成します",
"Regenerating the token will disconnect the physical machine until it is updated. Continue?": "トークンを再生成すると、更新されるまで物理マシンの接続が切断されます。続行しますか?",
"Error processing request": "リクエストの処理中にエラーが発生しました",
"API Token regenerated successfully.": "APIトークンが正常に再生成されました。"
}

View File

@@ -699,5 +699,11 @@
"Confirm Account Deactivation": "停用帳號確認", "Confirm Account Deactivation": "停用帳號確認",
"Are you sure you want to deactivate this account? After deactivating, this account will no longer be able to log in to the system.": "確定要停用此帳號嗎?停用後將無法登入系統。", "Are you sure you want to deactivate this account? After deactivating, this account will no longer be able to log in to the system.": "確定要停用此帳號嗎?停用後將無法登入系統。",
"Enable": "啟用", "Enable": "啟用",
"Disable": "停用" "Disable": "停用",
} "Regenerate": "重新產生",
"API Token Copied": "API 金鑰已複製",
"Yes, regenerate": "確認重新產生",
"Regenerating the token will disconnect the physical machine until it is updated. Continue?": "重新產生金鑰將導致實體機台暫時失去連線,必須於機台端更新此新金鑰才能恢復。確定繼續嗎?",
"Error processing request": "處理請求時發生錯誤",
"API Token regenerated successfully.": "API 金鑰重新產生成功。"
}

BIN
public/S1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

BIN
public/S1_edited.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

View File

@@ -74,7 +74,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
<div> <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> <input type="text" name="name" value="{{ old('name', $machine->name) }}" class="luxury-input w-full" required>
</div> </div>
<div> <div>

View File

@@ -2,6 +2,7 @@
@section('content') @section('content')
<div class="space-y-2 pb-20" x-data="{ <div class="space-y-2 pb-20" x-data="{
tab: '{{ $tab }}',
showCreateMachineModal: false, showCreateMachineModal: false,
showPhotoModal: false, showPhotoModal: false,
showDetailDrawer: false, showDetailDrawer: false,
@@ -24,8 +25,10 @@
this.maintenanceQrUrl = baseUrl.replace('SERIAL_NO', machine.serial_no); this.maintenanceQrUrl = baseUrl.replace('SERIAL_NO', machine.serial_no);
this.showMaintenanceQrModal = true; this.showMaintenanceQrModal = true;
}, },
openDetail(machine) { openDetail(machine, id, serial) {
this.currentMachine = machine; this.currentMachine = machine;
window.activeMachineId = id || machine?.id;
window.activeMachineSerial = serial || machine?.serial_no;
this.showDetailDrawer = true; this.showDetailDrawer = true;
}, },
openPhotoModal(machine) { openPhotoModal(machine) {
@@ -60,8 +63,57 @@
confirmDelete(action) { confirmDelete(action) {
this.deleteFormAction = action; this.deleteFormAction = action;
this.isDeleteConfirmOpen = true; 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 --> <!-- 1. Header Area -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div> <div>
@@ -149,7 +201,7 @@
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80"> <tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($machines as $machine) @forelse($machines as $machine)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300"> <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="flex items-center gap-4">
<div <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"> 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>
</div> </div>
</td> </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 <span
class="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase tracking-widest"> class="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase tracking-widest">
{{ $machine->machineModel->name ?? '--' }} {{ $machine->machineModel->name ?? '--' }}
@@ -183,7 +235,7 @@
</td> </td>
<td class="px-6 py-6"> <td class="px-6 py-6">
@php @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"> @endphp <div class="flex items-center gap-2.5">
<div class="relative flex h-2.5 w-2.5"> <div class="relative flex h-2.5 w-2.5">
@if($isOnline) @if($isOnline)
@@ -396,22 +448,25 @@
<div class="px-8 py-8 space-y-6"> <div class="px-8 py-8 space-y-6">
<div> <div>
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
__('Machine Name') }}</label> {{ __('Machine Name') }} <span class="text-rose-500">*</span>
</label>
<input type="text" name="name" required class="luxury-input w-full" <input type="text" name="name" required class="luxury-input w-full"
placeholder="{{ __('Enter machine name') }}"> placeholder="{{ __('Enter machine name') }}">
</div> </div>
<div> <div>
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
__('Serial No') }}</label> {{ __('Serial No') }} <span class="text-rose-500">*</span>
</label>
<input type="text" name="serial_no" required class="luxury-input w-full" <input type="text" name="serial_no" required class="luxury-input w-full"
placeholder="{{ __('Enter serial number') }}"> placeholder="{{ __('Enter serial number') }}">
</div> </div>
<div class="relative z-20"> <div class="relative z-20">
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
__('Owner') }}</label> {{ __('Owner') }}
</label>
<x-searchable-select name="company_id" required :placeholder="__('Select Owner')"> <x-searchable-select name="company_id" required :placeholder="__('Select Owner')">
@foreach($companies as $company) @foreach($companies as $company)
<option value="{{ $company->id }}" data-title="{{ $company->name }}{{ $company->code ? ' (' . $company->code . ')' : '' }}"> <option value="{{ $company->id }}" data-title="{{ $company->name }}{{ $company->code ? ' (' . $company->code . ')' : '' }}">
@@ -422,15 +477,17 @@
</div> </div>
<div> <div>
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
__('Location') }}</label> {{ __('Location') }}
</label>
<input type="text" name="location" class="luxury-input w-full" <input type="text" name="location" class="luxury-input w-full"
placeholder="{{ __('Enter machine location') }}"> placeholder="{{ __('Enter machine location') }}">
</div> </div>
<div class="relative z-10"> <div class="relative z-10">
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
__('Model') }}</label> {{ __('Model') }}
</label>
<x-searchable-select name="machine_model_id" required :placeholder="__('Select Model')"> <x-searchable-select name="machine_model_id" required :placeholder="__('Select Model')">
@foreach($models as $model) @foreach($models as $model)
<option value="{{ $model->id }}">{{ $model->name }}</option> <option value="{{ $model->id }}">{{ $model->name }}</option>
@@ -439,8 +496,9 @@
</div> </div>
<div> <div>
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
__('Machine Images') }} ({{ __('Max 3') }})</label> {{ __('Machine Images') }} ({{ __('Max 3') }})
</label>
<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"> 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"> <template x-if="selectedFileCount === 0">
@@ -772,7 +830,7 @@
<div class="p-10 flex flex-col items-center gap-6"> <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"> <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" class="w-48 h-48"
alt="{{ __('Maintenance QR Code') }}"> alt="{{ __('Maintenance QR Code') }}">
</div> </div>
@@ -893,11 +951,38 @@
<span class="text-xs font-black text-slate-700 dark:text-slate-300" <span class="text-xs font-black text-slate-700 dark:text-slate-300"
x-text="currentMachine?.card_reader_no || '--'"></span> x-text="currentMachine?.card_reader_no || '--'"></span>
</div> </div>
<div <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">
class="flex items-center justify-between p-2 border-b border-slate-50 dark:border-white/5"> <div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-500">{{ __('API Token') }}</span> <span class="text-[11px] font-black text-slate-500 uppercase tracking-widest">{{ __('API Token') }}</span>
<span class="text-[10px] font-mono text-slate-400 truncate max-w-[150px]" <div class="flex items-center gap-1">
x-text="currentMachine?.api_token || '--'"></span> <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>
</div> </div>
</section> </section>
@@ -926,6 +1011,16 @@
<!-- Global Delete Confirm Modal --> <!-- Global Delete Confirm Modal -->
<x-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> </div>
@endsection @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>

View File

@@ -186,6 +186,7 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
Route::get('/{machine}/edit', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'edit'])->name('edit'); Route::get('/{machine}/edit', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'edit'])->name('edit');
Route::put('/{machine}', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'update'])->name('update'); Route::put('/{machine}', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'update'])->name('update');
Route::post('/', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'store'])->name('store'); Route::post('/', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'store'])->name('store');
Route::post('/{machine}/regenerate-token', [App\Http\Controllers\Admin\BasicSettings\MachineSettingController::class, 'regenerateToken'])->name('regenerate-token');
}); });
// 客戶金流設定 // 客戶金流設定
@@ -193,6 +194,9 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
// 機台型號設定 // 機台型號設定
Route::resource('machine-models', App\Http\Controllers\Admin\BasicSettings\MachineModelController::class)->except(['show']); Route::resource('machine-models', App\Http\Controllers\Admin\BasicSettings\MachineModelController::class)->except(['show']);
// QR Code 生成
Route::get('qr-code', [App\Http\Controllers\Admin\QrCodeController::class, 'generate'])->name('qr-code');
}); });
// 15. 權限設定 // 15. 權限設定