[FEAT] 新增環境溫度上限監測與遠端風扇指令控制功能

1. 資料庫移轉:新增機台環境溫度、環境溫度上限設定,以及是否啟用環境溫度監測等欄位。
2. 後端核心:更新 Machine Model 與 MachineController,實作機台環境溫度設定的更新與歷史日誌 Ajax API。
3. MQTT 佇列:於 ListenMqttQueue 指令與 ProcessAmbientTemp Job 實作機台上報環境溫度的接收與寫入,並加入 Redis 快取防止重複日誌洗版。
4. 遠端指令:擴充 RemoteController 並於指令中心實作 'fanon'、'fanoff'、'fanauto' 與 'ambient_temp_limit' (環境溫度上限) 指令下發。
5. 前端頁面:於機台列表加入「環境溫度上限」微調計數器 UI,並整合 ApexCharts 折線圖展示溫度趨勢;更新遠端控制中心按鈕與歷史篩選器。
6. 多語系:同步更新並完美對齊 'zh_TW.json'、'en.json' 與 'ja.json',遵循字母 ksort 排序與斜線不逸出規範。
This commit is contained in:
sky121113 2026-06-04 11:45:48 +08:00
parent 6a251df81b
commit 953581db38
18 changed files with 623 additions and 15 deletions

View File

@ -95,6 +95,10 @@ class ListenMqttQueue extends Command
$finalPayload = is_array($payload) ? $payload : ['raw_data' => $payload]; $finalPayload = is_array($payload) ? $payload : ['raw_data' => $payload];
ProcessHeartbeat::dispatch($serialNo, $finalPayload); ProcessHeartbeat::dispatch($serialNo, $finalPayload);
break; break;
case 'ambient_temp':
$finalPayload = is_array($payload) ? $payload : ['temperature' => $payload];
\App\Jobs\Machine\ProcessAmbientTemp::dispatch($serialNo, $finalPayload);
break;
case 'status': case 'status':
ProcessStatus::dispatch($serialNo, $payload); ProcessStatus::dispatch($serialNo, $payload);
break; break;

View File

@ -213,6 +213,7 @@ class MachineSettingController extends AdminController
'scan_pay_linepay_enabled' => 'boolean', 'scan_pay_linepay_enabled' => 'boolean',
'shopping_cart_enabled' => 'boolean', 'shopping_cart_enabled' => 'boolean',
'cash_module_enabled' => 'boolean', 'cash_module_enabled' => 'boolean',
'ambient_temp_monitoring_enabled' => 'boolean',
'machine_model_id' => 'required|exists:machine_models,id', 'machine_model_id' => 'required|exists:machine_models,id',
'payment_config_id' => 'nullable|exists:payment_configs,id', 'payment_config_id' => 'nullable|exists:payment_configs,id',
'location' => 'nullable|string|max:255', 'location' => 'nullable|string|max:255',
@ -333,6 +334,7 @@ class MachineSettingController extends AdminController
'welcome_gift_enabled', 'welcome_gift_enabled',
'cash_module_enabled', 'cash_module_enabled',
'member_system_enabled', 'member_system_enabled',
'ambient_temp_monitoring_enabled',
]; ];
$data = []; $data = [];
foreach ($allowedFields as $field) { foreach ($allowedFields as $field) {
@ -361,6 +363,7 @@ class MachineSettingController extends AdminController
'welcome_gift_enabled', 'welcome_gift_enabled',
'cash_module_enabled', 'cash_module_enabled',
'member_system_enabled', 'member_system_enabled',
'ambient_temp_monitoring_enabled',
]; ];
if (!in_array($field, $allowedFields)) { if (!in_array($field, $allowedFields)) {

View File

@ -43,10 +43,48 @@ class MachineController extends AdminController
{ {
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'ambient_temp_setting' => 'nullable|integer',
]); ]);
$machine->update($validated); $machine->update($validated);
if ($machine->wasChanged('ambient_temp_setting')) {
$newSetting = $machine->ambient_temp_setting;
// 將先前未執行的同類型指令標記為 superseded (被取代)
\App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'ambient_temp_limit')
->where('status', 'pending')
->update([
'status' => 'superseded',
'remark' => __('Superseded by new command'),
'executed_at' => now(),
]);
// 建立新指令
$command = \App\Models\Machine\RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => auth()->id() ?: 1,
'command_type' => 'ambient_temp_limit',
'payload' => [
'temperature' => $newSetting !== null ? (int)$newSetting : null
],
'status' => 'pending',
'remark' => $newSetting !== null
? __('Temperature reaches :temp degrees, the fan will turn on', ['temp' => $newSetting])
: __('Disabled ambient temperature monitoring')
]);
// 發送 MQTT 指令
$mqttService = app(\App\Services\Machine\MqttService::class);
$mqttService->pushCommand(
$machine->serial_no,
$command->command_type,
$command->payload,
(string)$command->id
);
}
return redirect()->route('admin.machines.index') return redirect()->route('admin.machines.index')
->with('success', __('Machine updated successfully.')); ->with('success', __('Machine updated successfully.'));
} }
@ -267,4 +305,35 @@ class MachineController extends AdminController
'data' => $chartData 'data' => $chartData
]); ]);
} }
/**
* AJAX: 取得機台環境溫度歷史紀錄 (供圖表使用)
*/
public function ambientTemperatureAjax(Request $request, Machine $machine)
{
$startDate = $request->get('start_date');
$endDate = $request->get('end_date');
$logs = $machine->logs()
->where('type', 'ambient_temp')
->where('message', 'Ambient temperature reported: :temp°C')
->when($startDate, function ($query, $start) {
return $query->where('created_at', '>=', str_replace('T', ' ', $start));
})
->when($endDate, function ($query, $end) {
return $query->where('created_at', '<=', str_replace('T', ' ', $end));
})
->oldest()
->get();
$chartData = $logs->map(fn($log) => [
'x' => $log->created_at->getTimestamp() * 1000,
'y' => (int) ($log->context['temp'] ?? 0),
])->values()->toArray();
return response()->json([
'success' => true,
'data' => $chartData
]);
}
} }

View File

@ -139,7 +139,7 @@ class RemoteController extends Controller
{ {
$validated = $request->validate([ $validated = $request->validate([
'machine_id' => 'required|exists:machines,id', 'machine_id' => 'required|exists:machines,id',
'command_type' => 'required|string|in:reboot,reboot_card,checkout,lock,unlock,change,dispense', 'command_type' => 'required|string|in:reboot,reboot_card,checkout,lock,unlock,change,dispense,fanon,fanoff,fanauto',
'amount' => 'nullable|integer|min:0', 'amount' => 'nullable|integer|min:0',
'slot_no' => 'nullable|string', 'slot_no' => 'nullable|string',
'note' => 'nullable|string|max:255', 'note' => 'nullable|string|max:255',

View File

@ -0,0 +1,94 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
class ProcessAmbientTemp implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $serialNo;
protected $payload;
/**
* Create a new job instance.
*/
public function __construct(string $serialNo, $payload)
{
$this->serialNo = $serialNo;
$this->payload = (array) $payload;
}
/**
* Execute the job.
*/
public function handle(): void
{
$machine = Machine::withoutGlobalScopes()->where('serial_no', $this->serialNo)->first();
if (!$machine) {
Log::warning("MQTT Ambient Temp: Machine not found", ['serial_no' => $this->serialNo]);
return;
}
// 環境溫度欄位更新
// 支持機台傳送 payload 為 {"temperature": 25} 或 {"ambient_temp": 25} 等結構
$temp = null;
if (isset($this->payload['temperature'])) {
$temp = (int) $this->payload['temperature'];
} elseif (isset($this->payload['ambient_temp'])) {
$temp = (int) $this->payload['ambient_temp'];
} elseif (isset($this->payload['raw_data'])) {
$temp = (int) $this->payload['raw_data'];
}
if ($temp === null) {
Log::warning("MQTT Ambient Temp: Missing temperature field in payload", [
'serial_no' => $this->serialNo,
'payload' => $this->payload
]);
return;
}
Log::debug("ProcessAmbientTemp: Ambient temperature reported for {$this->serialNo}: {$temp}");
$updateData = [
'ambient_temperature' => $temp,
];
// 讀取最後一次記錄日誌的溫度與時間,避免頻繁寫入相同的溫度日誌
$tempLogCacheKey = "machine:{$this->serialNo}:last_ambient_temp_log";
$lastLogEntry = Cache::get($tempLogCacheKey);
// 優先從快取取值,若無則從資料庫取
$oldTemp = isset($lastLogEntry['value']) ? (int)$lastLogEntry['value'] : $machine->ambient_temperature;
if ($temp !== (int)$oldTemp) {
Log::debug("ProcessAmbientTemp: Ambient temperature changed from {$oldTemp} to {$temp}. Triggering log.");
\App\Jobs\Machine\ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Ambient temperature reported: :temp°C",
'info',
['temp' => $temp],
'ambient_temp' // type 欄位
);
}
// 更新快取 (存活時間為 7 天)
Cache::put($tempLogCacheKey, [
'value' => $temp,
'at' => now()->toDateTimeString()
], 604800);
$machine->update($updateData);
}
}

View File

@ -70,6 +70,9 @@ class Machine extends Model
'current_page', 'current_page',
'door_status', 'door_status',
'temperature', 'temperature',
'ambient_temperature',
'ambient_temp_setting',
'ambient_temp_monitoring_enabled',
'firmware_version', 'firmware_version',
'api_token', 'api_token',
'last_heartbeat_at', 'last_heartbeat_at',
@ -309,6 +312,9 @@ class Machine extends Model
protected $casts = [ protected $casts = [
'last_heartbeat_at' => 'datetime', 'last_heartbeat_at' => 'datetime',
'temperature' => 'integer', 'temperature' => 'integer',
'ambient_temperature' => 'integer',
'ambient_temp_setting' => 'integer',
'ambient_temp_monitoring_enabled' => 'boolean',
'welcome_gift_enabled' => 'boolean', 'welcome_gift_enabled' => 'boolean',
'is_spring_slot_1_10' => 'boolean', 'is_spring_slot_1_10' => 'boolean',
'is_spring_slot_11_20' => 'boolean', 'is_spring_slot_11_20' => 'boolean',

View File

@ -773,7 +773,7 @@ return [
'qos' => 1, 'qos' => 1,
'description' => '雲端主動下發的系統級別控制指令。包含重啟、設備鎖定解鎖、購物車結帳等無額外參數的指令。', 'description' => '雲端主動下發的系統級別控制指令。包含重啟、設備鎖定解鎖、購物車結帳等無額外參數的指令。',
'payload_parameters' => [ 'payload_parameters' => [
'command' => ['type' => 'string', 'description' => '系統指令類型。支援reboot (重啟機台), reboot_card (重啟刷卡機), checkout (購物車結帳), lock (設備鎖定), unlock (設備解鎖), update_ads (廣告同步), update_products (商品資料同步)'], 'command' => ['type' => 'string', 'description' => '系統指令類型。支援reboot (重啟機台), reboot_card (重啟刷卡機), checkout (購物車結帳), lock (設備鎖定), unlock (設備解鎖), update_ads (廣告同步), update_products (商品資料同步), fanon (開啟風扇), fanoff (關閉風扇), fanauto (風扇自動控制)'],
'command_id' => ['type' => 'string', 'description' => '指令唯一 ID'], 'command_id' => ['type' => 'string', 'description' => '指令唯一 ID'],
'payload' => ['type' => 'object', 'description' => '空物件 (無額外參數)'], 'payload' => ['type' => 'object', 'description' => '空物件 (無額外參數)'],
], ],
@ -784,6 +784,28 @@ return [
], ],
], ],
[
'name' => '指令下發:環境溫度上限 (Ambient Temperature Upper Limit)',
'slug' => 'mqtt-command-ambient-temp-limit',
'action' => 'SUB',
'topic' => 'machine/{serial_no}/command',
'qos' => 1,
'description' => '雲端主動下發「環境溫度上限」指令。指示機台更新環境溫度開啟風扇的閾值設定。',
'payload_parameters' => [
'command' => ['type' => 'string', 'description' => '固定為 "ambient_temp_limit"'],
'command_id' => ['type' => 'string', 'description' => '指令唯一 ID'],
'payload' => ['type' => 'object', 'description' => '溫度設定參數'],
'payload.temperature' => ['type' => 'integer', 'description' => '環境溫度上限閾值(可為 null 表示停用監測)'],
],
'payload_example' => [
'command' => 'ambient_temp_limit',
'command_id' => '1122334455',
'payload' => [
'temperature' => 28
]
],
],
[ [
'name' => '指令執行回報 (Machine to Cloud)', 'name' => '指令執行回報 (Machine to Cloud)',
'slug' => 'mqtt-command-ack', 'slug' => 'mqtt-command-ack',

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('machines', function (Blueprint $table) {
if (!Schema::hasColumn('machines', 'ambient_temperature')) {
$table->integer('ambient_temperature')->nullable()->after('temperature')->comment('環境溫度');
}
if (!Schema::hasColumn('machines', 'ambient_temp_setting')) {
$table->integer('ambient_temp_setting')->nullable()->after('ambient_temperature')->comment('環境溫度設定值');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machines', function (Blueprint $table) {
$table->dropColumn(['ambient_temperature', 'ambient_temp_setting']);
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('machines', function (Blueprint $table) {
if (!Schema::hasColumn('machines', 'ambient_temp_monitoring_enabled')) {
$table->boolean('ambient_temp_monitoring_enabled')->default(false)->after('ambient_temp_setting')->comment('是否啟用環境溫度監測');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machines', function (Blueprint $table) {
$table->dropColumn('ambient_temp_monitoring_enabled');
});
}
};

View File

@ -120,6 +120,13 @@
"All Warehouses": "All Warehouses", "All Warehouses": "All Warehouses",
"All issues marked as resolved.": "All issues marked as resolved.", "All issues marked as resolved.": "All issues marked as resolved.",
"All slots are fully stocked": "All slots are fully stocked", "All slots are fully stocked": "All slots are fully stocked",
"Ambient Temperature": "Ambient Temperature",
"Ambient Temperature Log": "Ambient Temperature Log",
"Ambient Temperature Monitoring": "Ambient Temperature Monitoring",
"Ambient Temperature Setting": "Ambient Temperature Setting",
"Ambient Temperature Trend": "Ambient Temperature Trend",
"Ambient Temperature Upper Limit": "Ambient Temperature Upper Limit",
"Ambient temperature reported: :temp°C": "Ambient temperature reported: :temp°C",
"Amount": "Amount", "Amount": "Amount",
"Amount / Payment": "Amount / Payment", "Amount / Payment": "Amount / Payment",
"Amount Discount": "Amount Discount", "Amount Discount": "Amount Discount",
@ -236,6 +243,7 @@
"CASH": "CASH", "CASH": "CASH",
"CREATE STOCK TRANSFERS BETWEEN WAREHOUSES AND MACHINE RETURNS": "CREATE STOCK TRANSFERS BETWEEN WAREHOUSES AND MACHINE RETURNS", "CREATE STOCK TRANSFERS BETWEEN WAREHOUSES AND MACHINE RETURNS": "CREATE STOCK TRANSFERS BETWEEN WAREHOUSES AND MACHINE RETURNS",
"Calculate Replenishment": "Calculate Replenishment", "Calculate Replenishment": "Calculate Replenishment",
"Can be empty. If left blank, it means ambient temperature monitoring is disabled.": "Can be empty. If left blank, it means ambient temperature monitoring is disabled.",
"Cancel": "Cancel", "Cancel": "Cancel",
"Cancel Gift": "Cancel Gift", "Cancel Gift": "Cancel Gift",
"Cancel Order": "Cancel Order", "Cancel Order": "Cancel Order",
@ -511,6 +519,7 @@
"Disable Product Confirmation": "Disable Product Confirmation", "Disable Product Confirmation": "Disable Product Confirmation",
"Disable Warehouse": "Disable Warehouse", "Disable Warehouse": "Disable Warehouse",
"Disabled": "Disabled", "Disabled": "Disabled",
"Disabled ambient temperature monitoring": "Disabled ambient temperature monitoring",
"Disabling this customer will also disable all accounts under this customer. Are you sure you want to continue?": "Disabling this customer will also disable all accounts under this customer. Are you sure you want to continue?", "Disabling this customer will also disable all accounts under this customer. Are you sure you want to continue?": "Disabling this customer will also disable all accounts under this customer. Are you sure you want to continue?",
"Discord Notifications": "Discord Notifications", "Discord Notifications": "Discord Notifications",
"Discord Webhook URL": "Discord Webhook URL", "Discord Webhook URL": "Discord Webhook URL",
@ -632,6 +641,7 @@
"English": "English", "English": "English",
"Ensure your account is using a long, random password to stay secure.": "Ensure your account is using a long, random password to stay secure.", "Ensure your account is using a long, random password to stay secure.": "Ensure your account is using a long, random password to stay secure.",
"Enter ad material name": "Enter ad material name", "Enter ad material name": "Enter ad material name",
"Enter ambient temperature setting...": "Enter ambient temperature setting...",
"Enter full address": "Enter full address", "Enter full address": "Enter full address",
"Enter login ID": "Enter login ID", "Enter login ID": "Enter login ID",
"Enter machine location": "Enter machine location", "Enter machine location": "Enter machine location",
@ -690,6 +700,10 @@
"Failed to send test notification.": "Failed to send test notification.", "Failed to send test notification.": "Failed to send test notification.",
"Failed to send test notification. Please verify the Webhook URL.": "Failed to send test notification. Please verify the Webhook URL.", "Failed to send test notification. Please verify the Webhook URL.": "Failed to send test notification. Please verify the Webhook URL.",
"Failed to update machine images: ": "Failed to update machine images: ", "Failed to update machine images: ": "Failed to update machine images: ",
"Fan Auto": "Fan Auto",
"Fan Controls": "Fan Controls",
"Fan Off": "Fan Off",
"Fan On": "Fan On",
"Feature Settings": "Feature Settings", "Feature Settings": "Feature Settings",
"Feature Toggles": "Feature Toggles", "Feature Toggles": "Feature Toggles",
"Field": "Field", "Field": "Field",
@ -822,6 +836,7 @@
"Half Points Amount": "Half Points Amount", "Half Points Amount": "Half Points Amount",
"Hardware & Network": "Hardware & Network", "Hardware & Network": "Hardware & Network",
"Hardware & Slots": "Hardware & Slots", "Hardware & Slots": "Hardware & Slots",
"Hardware Peripheral Settings": "Hardware Peripheral Settings",
"HashIV": "HashIV", "HashIV": "HashIV",
"HashKey": "HashKey", "HashKey": "HashKey",
"Heartbeat": "Heartbeat", "Heartbeat": "Heartbeat",
@ -1955,6 +1970,7 @@
"Temperature Limit": "Temperature Limit", "Temperature Limit": "Temperature Limit",
"Temperature Restored": "Temperature Restored", "Temperature Restored": "Temperature Restored",
"Temperature Trend": "Temperature Trend", "Temperature Trend": "Temperature Trend",
"Temperature reaches :temp degrees, the fan will turn on": "Temperature reaches :temp°C, the fan will turn on",
"Temperature reported: :temp°C": "Temperature reported: :temp°C", "Temperature reported: :temp°C": "Temperature reported: :temp°C",
"Temperature updated to :temp°C": "Temperature updated to :temp°C", "Temperature updated to :temp°C": "Temperature updated to :temp°C",
"Tenant": "Tenant", "Tenant": "Tenant",
@ -2084,6 +2100,7 @@
"Update Product": "Update Product", "Update Product": "Update Product",
"Update Settings": "Update Settings", "Update Settings": "Update Settings",
"Update Welcome Gift": "Update Welcome Gift", "Update Welcome Gift": "Update Welcome Gift",
"Update ambient temperature setting to :temp°C": "Update ambient temperature setting to :temp°C",
"Update existing role and permissions.": "Update existing role and permissions.", "Update existing role and permissions.": "Update existing role and permissions.",
"Update failed": "Update failed", "Update failed": "Update failed",
"Update identification for your asset": "Update identification for your asset", "Update identification for your asset": "Update identification for your asset",
@ -2098,6 +2115,7 @@
"Upload file or provide URL": "Upload file or provide URL", "Upload file or provide URL": "Upload file or provide URL",
"Uploading new images will replace all existing images.": "Uploading new images will replace all existing images.", "Uploading new images will replace all existing images.": "Uploading new images will replace all existing images.",
"Uploading...": "Uploading...", "Uploading...": "Uploading...",
"Upper limit; the fan will turn on when reached": "Upper limit; the fan will turn on when reached",
"Usage Completed": "Usage Completed", "Usage Completed": "Usage Completed",
"Usage Limit": "Usage Limit", "Usage Limit": "Usage Limit",
"Usage Logs": "Usage Logs", "Usage Logs": "Usage Logs",

View File

@ -120,6 +120,13 @@
"All Warehouses": "すべての倉庫", "All Warehouses": "すべての倉庫",
"All issues marked as resolved.": "すべての問題が解決済みとしてマークされました。", "All issues marked as resolved.": "すべての問題が解決済みとしてマークされました。",
"All slots are fully stocked": "すべてのスロットが補充完了しました", "All slots are fully stocked": "すべてのスロットが補充完了しました",
"Ambient Temperature": "周囲温度",
"Ambient Temperature Log": "周囲温度ログ",
"Ambient Temperature Monitoring": "環境温度監視",
"Ambient Temperature Setting": "周囲温度設定",
"Ambient Temperature Trend": "周囲温度トレンド",
"Ambient Temperature Upper Limit": "環境温度上限",
"Ambient temperature reported: :temp°C": "環境温度受信: :temp°C",
"Amount": "金額", "Amount": "金額",
"Amount / Payment": "金額 / 支払い", "Amount / Payment": "金額 / 支払い",
"Amount Discount": "金額割引", "Amount Discount": "金額割引",
@ -236,6 +243,7 @@
"CASH": "現金", "CASH": "現金",
"CREATE STOCK TRANSFERS BETWEEN WAREHOUSES AND MACHINE RETURNS": "倉庫間の移動または機器からの返品を作成", "CREATE STOCK TRANSFERS BETWEEN WAREHOUSES AND MACHINE RETURNS": "倉庫間の移動または機器からの返品を作成",
"Calculate Replenishment": "補充量を計算", "Calculate Replenishment": "補充量を計算",
"Can be empty. If left blank, it means ambient temperature monitoring is disabled.": "空欄にすることができます。空欄の場合、周囲温度の監視は無効になります。",
"Cancel": "キャンセル", "Cancel": "キャンセル",
"Cancel Gift": "ギフトをキャンセル", "Cancel Gift": "ギフトをキャンセル",
"Cancel Order": "注文キャンセル", "Cancel Order": "注文キャンセル",
@ -511,6 +519,7 @@
"Disable Product Confirmation": "商品無効化確認", "Disable Product Confirmation": "商品無効化確認",
"Disable Warehouse": "倉庫無効化", "Disable Warehouse": "倉庫無効化",
"Disabled": "無効済み", "Disabled": "無効済み",
"Disabled ambient temperature monitoring": "環境温度監視を停止しました",
"Disabling this customer will also disable all accounts under this customer. Are you sure you want to continue?": "顧客を無効にすると配下の全アカウントも無効になります。続行しますか?", "Disabling this customer will also disable all accounts under this customer. Are you sure you want to continue?": "顧客を無効にすると配下の全アカウントも無効になります。続行しますか?",
"Discord Notifications": "Discord通知", "Discord Notifications": "Discord通知",
"Discord Webhook URL": "Discord Webhook URL", "Discord Webhook URL": "Discord Webhook URL",
@ -632,6 +641,7 @@
"English": "英語", "English": "英語",
"Ensure your account is using a long, random password to stay secure.": "安全を保つため、長くランダムなパスワードを使用してください。", "Ensure your account is using a long, random password to stay secure.": "安全を保つため、長くランダムなパスワードを使用してください。",
"Enter ad material name": "広告素材名を入力", "Enter ad material name": "広告素材名を入力",
"Enter ambient temperature setting...": "周囲温度設定を入力してください...",
"Enter full address": "完全な住所を入力", "Enter full address": "完全な住所を入力",
"Enter login ID": "ログインIDを入力してください", "Enter login ID": "ログインIDを入力してください",
"Enter machine location": "機器の設置場所を入力してください", "Enter machine location": "機器の設置場所を入力してください",
@ -690,6 +700,10 @@
"Failed to send test notification.": "テスト通知の送信に失敗しました。", "Failed to send test notification.": "テスト通知の送信に失敗しました。",
"Failed to send test notification. Please verify the Webhook URL.": "テスト通知の送信に失敗しました。Webhook URLを確認してください。", "Failed to send test notification. Please verify the Webhook URL.": "テスト通知の送信に失敗しました。Webhook URLを確認してください。",
"Failed to update machine images: ": "機器画像の更新に失敗しました: ", "Failed to update machine images: ": "機器画像の更新に失敗しました: ",
"Fan Auto": "ファン自動",
"Fan Controls": "ファン制御",
"Fan Off": "ファン停止",
"Fan On": "ファン起動",
"Feature Settings": "機能設定", "Feature Settings": "機能設定",
"Feature Toggles": "機能切り替え", "Feature Toggles": "機能切り替え",
"Field": "Field", "Field": "Field",
@ -822,6 +836,7 @@
"Half Points Amount": "ハーフポイント金額", "Half Points Amount": "ハーフポイント金額",
"Hardware & Network": "ハードウェア・ネットワーク", "Hardware & Network": "ハードウェア・ネットワーク",
"Hardware & Slots": "ハードウェア・スロット", "Hardware & Slots": "ハードウェア・スロット",
"Hardware Peripheral Settings": "ハードウェア周辺機器設定",
"HashIV": "HashIV", "HashIV": "HashIV",
"HashKey": "HashKey", "HashKey": "HashKey",
"Heartbeat": "ハートビート", "Heartbeat": "ハートビート",
@ -1955,6 +1970,7 @@
"Temperature Limit": "温度しきい値", "Temperature Limit": "温度しきい値",
"Temperature Restored": "温度正常復帰", "Temperature Restored": "温度正常復帰",
"Temperature Trend": "温度傾向", "Temperature Trend": "温度傾向",
"Temperature reaches :temp degrees, the fan will turn on": "温度が :temp度 に達するとファンが作動します",
"Temperature reported: :temp°C": "温度が報告されました: :temp°C", "Temperature reported: :temp°C": "温度が報告されました: :temp°C",
"Temperature updated to :temp°C": "温度が :temp°C に更新されました", "Temperature updated to :temp°C": "温度が :temp°C に更新されました",
"Tenant": "顧客 (テナント)", "Tenant": "顧客 (テナント)",
@ -2084,6 +2100,7 @@
"Update Product": "商品更新", "Update Product": "商品更新",
"Update Settings": "設定更新", "Update Settings": "設定更新",
"Update Welcome Gift": "ウェルカムギフトを更新", "Update Welcome Gift": "ウェルカムギフトを更新",
"Update ambient temperature setting to :temp°C": "環境温度設定を :temp°C に更新しました",
"Update existing role and permissions.": "既存のロールと権限設定を更新します。", "Update existing role and permissions.": "既存のロールと権限設定を更新します。",
"Update failed": "更新失敗", "Update failed": "更新失敗",
"Update identification for your asset": "資産の識別情報を更新", "Update identification for your asset": "資産の識別情報を更新",
@ -2098,6 +2115,7 @@
"Upload file or provide URL": "Upload file or provide URL", "Upload file or provide URL": "Upload file or provide URL",
"Uploading new images will replace all existing images.": "新しい写真をアップロードすると、既存のすべての写真が置き換えられます。", "Uploading new images will replace all existing images.": "新しい写真をアップロードすると、既存のすべての写真が置き換えられます。",
"Uploading...": "アップロード中...", "Uploading...": "アップロード中...",
"Upper limit; the fan will turn on when reached": "温度上限;この温度に達するとファンが作動します",
"Usage Completed": "使用完了", "Usage Completed": "使用完了",
"Usage Limit": "使用回数上限", "Usage Limit": "使用回数上限",
"Usage Logs": "使用履歴", "Usage Logs": "使用履歴",

View File

@ -120,6 +120,13 @@
"All Warehouses": "所有倉庫", "All Warehouses": "所有倉庫",
"All issues marked as resolved.": "所有異常已標記為已排除。", "All issues marked as resolved.": "所有異常已標記為已排除。",
"All slots are fully stocked": "所有貨道已滿貨", "All slots are fully stocked": "所有貨道已滿貨",
"Ambient Temperature": "環境溫度",
"Ambient Temperature Log": "環境溫度回傳",
"Ambient Temperature Monitoring": "環境溫度監測",
"Ambient Temperature Setting": "環境溫度設定",
"Ambient Temperature Trend": "環境溫度趨勢",
"Ambient Temperature Upper Limit": "環境溫度上限",
"Ambient temperature reported: :temp°C": "環境溫度回傳::temp°C",
"Amount": "金額", "Amount": "金額",
"Amount / Payment": "金額 / 支付", "Amount / Payment": "金額 / 支付",
"Amount Discount": "固定金額", "Amount Discount": "固定金額",
@ -236,6 +243,7 @@
"CASH": "CASH", "CASH": "CASH",
"CREATE STOCK TRANSFERS BETWEEN WAREHOUSES AND MACHINE RETURNS": "建立倉庫間調撥或機台退庫單", "CREATE STOCK TRANSFERS BETWEEN WAREHOUSES AND MACHINE RETURNS": "建立倉庫間調撥或機台退庫單",
"Calculate Replenishment": "計算補貨量", "Calculate Replenishment": "計算補貨量",
"Can be empty. If left blank, it means ambient temperature monitoring is disabled.": "可留空,留空表示不啟用監控功能",
"Cancel": "取消", "Cancel": "取消",
"Cancel Gift": "取消此來店禮", "Cancel Gift": "取消此來店禮",
"Cancel Order": "取消訂單", "Cancel Order": "取消訂單",
@ -511,6 +519,7 @@
"Disable Product Confirmation": "停用商品確認", "Disable Product Confirmation": "停用商品確認",
"Disable Warehouse": "停用倉庫", "Disable Warehouse": "停用倉庫",
"Disabled": "已停用", "Disabled": "已停用",
"Disabled ambient temperature monitoring": "已停用環境溫度監測",
"Disabling this customer will also disable all accounts under this customer. Are you sure you want to continue?": "停用此客戶將會連帶停用該客戶下的所有帳號,確定要繼續嗎?", "Disabling this customer will also disable all accounts under this customer. Are you sure you want to continue?": "停用此客戶將會連帶停用該客戶下的所有帳號,確定要繼續嗎?",
"Discord Notifications": "Discord通知", "Discord Notifications": "Discord通知",
"Discord Webhook URL": "Discord Webhook 網址", "Discord Webhook URL": "Discord Webhook 網址",
@ -632,6 +641,7 @@
"English": "英文", "English": "英文",
"Ensure your account is using a long, random password to stay secure.": "確保您的帳號使用了足夠強度的隨機密碼以維持安全。", "Ensure your account is using a long, random password to stay secure.": "確保您的帳號使用了足夠強度的隨機密碼以維持安全。",
"Enter ad material name": "輸入廣告素材名稱", "Enter ad material name": "輸入廣告素材名稱",
"Enter ambient temperature setting...": "請輸入環境溫度設定...",
"Enter full address": "輸入完整地址", "Enter full address": "輸入完整地址",
"Enter login ID": "請輸入登入帳號", "Enter login ID": "請輸入登入帳號",
"Enter machine location": "請輸入機台地點", "Enter machine location": "請輸入機台地點",
@ -690,6 +700,10 @@
"Failed to send test notification.": "發送測試通知失敗。", "Failed to send test notification.": "發送測試通知失敗。",
"Failed to send test notification. Please verify the Webhook URL.": "發送測試通知失敗,請確認 Webhook 網址。", "Failed to send test notification. Please verify the Webhook URL.": "發送測試通知失敗,請確認 Webhook 網址。",
"Failed to update machine images: ": "更新機台圖片失敗:", "Failed to update machine images: ": "更新機台圖片失敗:",
"Fan Auto": "風扇自動",
"Fan Controls": "風扇控制",
"Fan Off": "關閉風扇",
"Fan On": "開啟風扇",
"Feature Settings": "功能設定", "Feature Settings": "功能設定",
"Feature Toggles": "功能開關", "Feature Toggles": "功能開關",
"Field": "欄位", "Field": "欄位",
@ -822,6 +836,7 @@
"Half Points Amount": "半點數金額", "Half Points Amount": "半點數金額",
"Hardware & Network": "硬體與網路", "Hardware & Network": "硬體與網路",
"Hardware & Slots": "硬體與貨道", "Hardware & Slots": "硬體與貨道",
"Hardware Peripheral Settings": "硬體周邊設定",
"HashIV": "HashIV", "HashIV": "HashIV",
"HashKey": "HashKey", "HashKey": "HashKey",
"Heartbeat": "心跳狀態", "Heartbeat": "心跳狀態",
@ -1955,6 +1970,7 @@
"Temperature Limit": "溫度限制", "Temperature Limit": "溫度限制",
"Temperature Restored": "溫度恢復正常", "Temperature Restored": "溫度恢復正常",
"Temperature Trend": "溫度趨勢", "Temperature Trend": "溫度趨勢",
"Temperature reaches :temp degrees, the fan will turn on": "溫度達 :temp度 將開啟風扇",
"Temperature reported: :temp°C": "回報溫度 : :temp°C", "Temperature reported: :temp°C": "回報溫度 : :temp°C",
"Temperature updated to :temp°C": "溫度已更新為::temp°C", "Temperature updated to :temp°C": "溫度已更新為::temp°C",
"Tenant": "客戶", "Tenant": "客戶",
@ -2084,6 +2100,7 @@
"Update Product": "更新商品", "Update Product": "更新商品",
"Update Settings": "更新設定", "Update Settings": "更新設定",
"Update Welcome Gift": "更新來店禮", "Update Welcome Gift": "更新來店禮",
"Update ambient temperature setting to :temp°C": "更新環境溫度設定為 :temp°C",
"Update existing role and permissions.": "更新現有角色與權限設定。", "Update existing role and permissions.": "更新現有角色與權限設定。",
"Update failed": "更新失敗", "Update failed": "更新失敗",
"Update identification for your asset": "更新您的資產識別名稱", "Update identification for your asset": "更新您的資產識別名稱",
@ -2098,6 +2115,7 @@
"Upload file or provide URL": "上傳檔案或提供連結", "Upload file or provide URL": "上傳檔案或提供連結",
"Uploading new images will replace all existing images.": "上傳新照片將會取代所有現有照片。", "Uploading new images will replace all existing images.": "上傳新照片將會取代所有現有照片。",
"Uploading...": "上傳中...", "Uploading...": "上傳中...",
"Upper limit; the fan will turn on when reached": "溫度上限,達到此溫度時風扇將會開啟",
"Usage Completed": "使用完成", "Usage Completed": "使用完成",
"Usage Limit": "使用次數上限", "Usage Limit": "使用次數上限",
"Usage Logs": "使用紀錄", "Usage Logs": "使用紀錄",

View File

@ -106,7 +106,8 @@
shopping_cart_enabled: machine.shopping_cart_enabled === true || machine.shopping_cart_enabled === 1 || machine.shopping_cart_enabled === '1', shopping_cart_enabled: machine.shopping_cart_enabled === true || machine.shopping_cart_enabled === 1 || machine.shopping_cart_enabled === '1',
welcome_gift_enabled: machine.welcome_gift_enabled === true || machine.welcome_gift_enabled === 1 || machine.welcome_gift_enabled === '1', welcome_gift_enabled: machine.welcome_gift_enabled === true || machine.welcome_gift_enabled === 1 || machine.welcome_gift_enabled === '1',
cash_module_enabled: machine.cash_module_enabled === true || machine.cash_module_enabled === 1 || machine.cash_module_enabled === '1', cash_module_enabled: machine.cash_module_enabled === true || machine.cash_module_enabled === 1 || machine.cash_module_enabled === '1',
member_system_enabled: machine.member_system_enabled === true || machine.member_system_enabled === 1 || machine.member_system_enabled === '1' member_system_enabled: machine.member_system_enabled === true || machine.member_system_enabled === 1 || machine.member_system_enabled === '1',
ambient_temp_monitoring_enabled: machine.ambient_temp_monitoring_enabled === true || machine.ambient_temp_monitoring_enabled === 1 || machine.ambient_temp_monitoring_enabled === '1'
}; };
this.showMachineSettingsModal = true; this.showMachineSettingsModal = true;
}, },
@ -610,6 +611,29 @@
</label> </label>
</div> </div>
</div> </div>
<!-- 硬體周邊設定 -->
<div
class="grid grid-cols-1 md:grid-cols-4 gap-4 items-center p-4 rounded-xl bg-slate-50/50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors">
<div class="md:col-span-1">
<h4 class="text-sm font-black text-slate-700 dark:text-slate-200">{{ __('Hardware Peripheral Settings') }}</h4>
</div>
<div class="md:col-span-3 flex flex-wrap gap-x-8 gap-y-4">
<label class="flex items-center gap-3 cursor-pointer group">
<div class="relative inline-flex items-center">
<input type="hidden" name="settings[ambient_temp_monitoring_enabled]" value="0">
<input type="checkbox" name="settings[ambient_temp_monitoring_enabled]" value="1"
x-model="machineSettings.ambient_temp_monitoring_enabled" class="peer sr-only">
<div
class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-cyan-500">
</div>
</div>
<span
class="text-sm font-bold text-slate-600 dark:text-slate-300 group-hover:text-slate-900 dark:group-hover:text-white transition-colors">{{
__('Ambient Temperature Monitoring') }}</span>
</label>
</div>
</div>
</div> </div>
<div <div

View File

@ -82,6 +82,7 @@
if ($machine->welcome_gift_enabled) $activeFeatures[] = __('Welcome Gift'); if ($machine->welcome_gift_enabled) $activeFeatures[] = __('Welcome Gift');
if ($machine->cash_module_enabled) $activeFeatures[] = __('Coin/Banknote Machine'); if ($machine->cash_module_enabled) $activeFeatures[] = __('Coin/Banknote Machine');
if ($machine->member_system_enabled) $activeFeatures[] = __('Member System'); if ($machine->member_system_enabled) $activeFeatures[] = __('Member System');
if ($machine->ambient_temp_monitoring_enabled) $activeFeatures[] = __('Ambient Temperature Monitoring');
@endphp @endphp
<tr class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200"> <tr class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
<td class="px-6 py-6 w-1/4"> <td class="px-6 py-6 w-1/4">
@ -139,6 +140,7 @@
if ($machine->welcome_gift_enabled) $activeFeatures[] = __('Welcome Gift'); if ($machine->welcome_gift_enabled) $activeFeatures[] = __('Welcome Gift');
if ($machine->cash_module_enabled) $activeFeatures[] = __('Coin/Banknote Machine'); if ($machine->cash_module_enabled) $activeFeatures[] = __('Coin/Banknote Machine');
if ($machine->member_system_enabled) $activeFeatures[] = __('Member System'); if ($machine->member_system_enabled) $activeFeatures[] = __('Member System');
if ($machine->ambient_temp_monitoring_enabled) $activeFeatures[] = __('Ambient Temperature Monitoring');
@endphp @endphp
<div class="luxury-card p-5 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group"> <div class="luxury-card p-5 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">

View File

@ -11,6 +11,8 @@
showResolveConfirm: false, showResolveConfirm: false,
editMachineId: '', editMachineId: '',
editMachineName: '', editMachineName: '',
editAmbientTempSetting: '',
editAmbientTempMonitoringEnabled: false,
activeTab: 'status', activeTab: 'status',
currentMachineId: '', currentMachineId: '',
isLoadingTable: false, isLoadingTable: false,
@ -55,6 +57,7 @@
totalLogs: 0, totalLogs: 0,
temperatureChart: null, temperatureChart: null,
ambientTemperatureChart: null,
init() { init() {
const now = new Date(); const now = new Date();
@ -63,11 +66,24 @@
this.startDate = formatDate(now, '00:00'); this.startDate = formatDate(now, '00:00');
this.endDate = formatDate(now, '23:59'); this.endDate = formatDate(now, '23:59');
this.$watch('activeTab', () => this.fetchLogs(1)); this.$watch('activeTab', (val) => {
this.fetchLogs(1);
if (val === 'status') {
this.fetchTemperatureData();
} else if (val === 'ambient_temp') {
this.fetchAmbientTemperatureData();
}
});
// 監聽日期變動,同步更新圖表 (僅在當前為狀態分頁時) // 監聽日期變動,同步更新圖表 (僅在當前為狀態分頁時)
this.$watch('startDate', () => { if(this.activeTab === 'status') this.fetchTemperatureData(); }); this.$watch('startDate', () => {
this.$watch('endDate', () => { if(this.activeTab === 'status') this.fetchTemperatureData(); }); if(this.activeTab === 'status') this.fetchTemperatureData();
if(this.activeTab === 'ambient_temp') this.fetchAmbientTemperatureData();
});
this.$watch('endDate', () => {
if(this.activeTab === 'status') this.fetchTemperatureData();
if(this.activeTab === 'ambient_temp') this.fetchAmbientTemperatureData();
});
}, },
async openLogPanel(id, sn, name) { async openLogPanel(id, sn, name) {
@ -96,6 +112,106 @@
} catch (e) { console.error('fetchTemperatureData error:', e); } } catch (e) { console.error('fetchTemperatureData error:', e); }
}, },
async fetchAmbientTemperatureData() {
if (this.activeTab !== 'ambient_temp') return;
try {
let url = `/admin/machines/${this.currentMachineId}/ambient-temperature-ajax?`;
if (this.startDate) url += '&start_date=' + this.startDate;
if (this.endDate) url += '&end_date=' + this.endDate;
const res = await fetch(url);
const data = await res.json();
if (data.success) {
this.initAmbientTemperatureChart(data.data);
}
} catch (e) { console.error('fetchAmbientTemperatureData error:', e); }
},
initAmbientTemperatureChart(chartData) {
this.$nextTick(() => {
const chartEl = document.querySelector("#ambient-temperature-chart");
if (!chartEl) return;
const isDark = document.documentElement.classList.contains('dark');
const options = {
series: [{
name: "{{ __('Ambient Temperature') }}",
data: chartData
}],
chart: {
id: 'ambient-temp-chart',
type: 'area',
height: 200,
toolbar: { show: false },
zoom: { enabled: false },
animations: { enabled: false },
background: 'transparent',
accessibility: { enabled: false }
},
colors: ['#3b82f6'],
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.45,
opacityTo: 0.05,
stops: [20, 100]
}
},
dataLabels: { enabled: false },
stroke: {
curve: 'smooth',
width: 3
},
grid: {
borderColor: isDark ? '#1e293b' : '#f1f5f9',
strokeDashArray: 4,
padding: { left: 10, right: 10 }
},
xaxis: {
type: 'datetime',
labels: {
show: true,
style: { colors: '#94a3b8', fontSize: '10px', fontWeight: 600 },
datetimeUTC: false,
format: 'HH:mm',
hideOverlappingLabels: true,
},
axisBorder: { show: false },
axisTicks: { show: false },
tooltip: { enabled: false },
tickAmount: 6
},
yaxis: {
labels: {
style: { colors: '#94a3b8', fontSize: '10px', fontWeight: 600 },
formatter: (val) => val + '°C'
}
},
tooltip: {
theme: isDark ? 'dark' : 'light',
x: {
show: true,
format: 'yyyy/MM/dd HH:mm:ss'
},
y: {
title: {
formatter: (seriesName) => seriesName + ': '
}
}
}
};
if (this.ambientTemperatureChart) {
this.ambientTemperatureChart.updateOptions(options);
} else {
this.ambientTemperatureChart = new ApexCharts(chartEl, options);
this.ambientTemperatureChart.render();
}
});
},
initTemperatureChart(chartData) { initTemperatureChart(chartData) {
this.$nextTick(() => { this.$nextTick(() => {
const chartEl = document.querySelector("#temperature-chart"); const chartEl = document.querySelector("#temperature-chart");
@ -181,9 +297,11 @@
}); });
}, },
openEditModal(id, name) { openEditModal(id, name, ambientTempSetting = '', ambientTempMonitoringEnabled = false) {
this.editMachineId = id; this.editMachineId = id;
this.editMachineName = name; this.editMachineName = name;
this.editAmbientTempSetting = ambientTempSetting;
this.editAmbientTempMonitoringEnabled = ambientTempMonitoringEnabled === true || ambientTempMonitoringEnabled === 1 || ambientTempMonitoringEnabled === '1' || ambientTempMonitoringEnabled === 'true';
this.showEditModal = true; this.showEditModal = true;
}, },
@ -493,7 +611,7 @@
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<button type="button" <button type="button"
@click="openEditModal('{{ $machine->id }}', '{{ addslashes($machine->name) }}')" @click="openEditModal('{{ $machine->id }}', '{{ addslashes($machine->name) }}', '{{ $machine->ambient_temp_setting }}', '{{ $machine->ambient_temp_monitoring_enabled ? 1 : 0 }}')"
class="p-2.5 rounded-xl 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 duration-200" class="p-2.5 rounded-xl 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 duration-200"
title="{{ __('Edit Name') }}"> title="{{ __('Edit Name') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" <svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
@ -622,7 +740,7 @@
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button type="button" <button type="button"
@click="openEditModal('{{ $machine->id }}', '{{ addslashes($machine->name) }}')" @click="openEditModal('{{ $machine->id }}', '{{ addslashes($machine->name) }}', '{{ $machine->ambient_temp_setting }}', '{{ $machine->ambient_temp_monitoring_enabled ? 1 : 0 }}')"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-cyan-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50"> class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-cyan-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
@ -802,18 +920,23 @@
class="whitespace-nowrap py-4 px-1 border-b-2 font-bold text-[13px] sm:text-sm transition duration-300"> class="whitespace-nowrap py-4 px-1 border-b-2 font-bold text-[13px] sm:text-sm transition duration-300">
{{ __('Machine Login Logs') }} {{ __('Machine Login Logs') }}
</button> </button>
<button @click="activeTab = 'submachine'" <button @click="activeTab = 'submachine'"
:class="{'border-cyan-500 text-cyan-600 dark:text-cyan-400': activeTab === 'submachine', 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:hover:text-slate-300': activeTab !== 'submachine'}" :class="{'border-cyan-500 text-cyan-600 dark:text-cyan-400': activeTab === 'submachine', 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:hover:text-slate-300': activeTab !== 'submachine'}"
class="whitespace-nowrap py-4 px-1 border-b-2 font-bold text-[13px] sm:text-sm transition duration-300"> class="whitespace-nowrap py-4 px-1 border-b-2 font-bold text-[13px] sm:text-sm transition duration-300">
{{ __('Sub-machine Status Request') }} {{ __('Sub-machine Status Request') }}
</button> </button>
<button @click="activeTab = 'ambient_temp'"
:class="{'border-cyan-500 text-cyan-600 dark:text-cyan-400': activeTab === 'ambient_temp', 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:hover:text-slate-300': activeTab !== 'ambient_temp'}"
class="whitespace-nowrap py-4 px-1 border-b-2 font-bold text-[13px] sm:text-sm transition duration-300">
{{ __('Ambient Temperature Log') }}
</button>
</nav> </nav>
</div> </div>
<!-- Tab Contents --> <!-- Tab Contents -->
<div class="flex-1 overflow-y-auto p-6 sm:p-8"> <div class="flex-1 overflow-y-auto p-6 sm:p-8">
<div class="relative min-h-[400px]"> <div class="relative min-h-[400px]">
<!-- Loading State --> <!-- Loading State -->
<div x-show="loading" x-transition:enter="transition ease-out duration-300" <div x-show="loading" x-transition:enter="transition ease-out duration-300"
@ -822,7 +945,7 @@
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="absolute inset-0 z-50 flex flex-col items-center justify-center bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] rounded-2xl" class="absolute inset-0 z-50 flex flex-col items-center justify-center bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] rounded-2xl"
x-cloak> x-cloak>
<div class="relative w-16 h-16 mb-4 flex items-center justify-center"> <div class="relative w-16 h-16 mb-4 flex items-center justify-center">
<!-- 外圈:快速旋轉 --> <!-- 外圈:快速旋轉 -->
<div <div
@ -844,7 +967,7 @@
class="text-[12px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse"> class="text-[12px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">
{{ __('Loading Data') }}...</p> {{ __('Loading Data') }}...</p>
</div> </div>
<!-- Temperature Trend Chart --> <!-- Temperature Trend Chart -->
<div x-show="activeTab === 'status'" <div x-show="activeTab === 'status'"
class="mb-6 p-4 rounded-2xl bg-slate-50/50 dark:bg-white/[0.02] border border-slate-100 dark:border-slate-800/50"> class="mb-6 p-4 rounded-2xl bg-slate-50/50 dark:bg-white/[0.02] border border-slate-100 dark:border-slate-800/50">
@ -859,6 +982,21 @@
</div> </div>
<div id="temperature-chart" class="min-h-[200px] w-full"></div> <div id="temperature-chart" class="min-h-[200px] w-full"></div>
</div> </div>
<!-- Ambient Temperature Trend Chart -->
<div x-show="activeTab === 'ambient_temp'"
class="mb-6 p-4 rounded-2xl bg-slate-50/50 dark:bg-white/[0.02] border border-slate-100 dark:border-slate-800/50" x-cloak>
<div class="flex items-center justify-between mb-4 px-2">
<h3 class="text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]"></span>
{{ __('Ambient Temperature Trend') }}
</h3>
<span class="text-[10px] font-bold text-slate-400" x-show="logs.length > 0">
<span x-text="currentMachineName"></span> @ <span x-text="startDate.split(' ')[0]"></span>
</span>
</div>
<div id="ambient-temperature-chart" class="min-h-[200px] w-full"></div>
</div>
<!-- Logs Container --> <!-- Logs Container -->
<div x-show="activeTab !== 'expiry'" <div x-show="activeTab !== 'expiry'"
@ -1063,6 +1201,56 @@
class="luxury-input block w-full px-6 py-4 text-base font-bold text-slate-800 dark:text-white bg-slate-50/50 dark:bg-slate-900/50" class="luxury-input block w-full px-6 py-4 text-base font-bold text-slate-800 dark:text-white bg-slate-50/50 dark:bg-slate-900/50"
placeholder="{{ __('Enter machine name...') }}"> placeholder="{{ __('Enter machine name...') }}">
</div> </div>
<div class="space-y-4" x-show="editAmbientTempMonitoringEnabled" x-cloak>
<label
class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.1em]">{{
__('Ambient Temperature Upper Limit') }} ({{ __('Upper limit; the fan will turn on when reached') }})</label>
{{-- Luxury +/- Counter UI --}}
<div class="flex items-center h-14 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden w-full group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
{{-- Minus Button --}}
<button type="button" @click="editAmbientTempSetting = (editAmbientTempSetting ? Math.max(0, parseInt(editAmbientTempSetting) - 1) : 25)"
class="shrink-0 w-14 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4"/>
</svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
{{-- Input Field --}}
<div class="flex-1 relative flex items-center">
<input type="number" name="ambient_temp_setting" x-model="editAmbientTempSetting"
class="w-full bg-transparent border-none text-center text-base font-extrabold text-slate-800 dark:text-white focus:ring-0 p-0 placeholder:text-slate-400 placeholder:font-bold placeholder:text-sm [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
placeholder="{{ __('Enter ambient temperature setting...') }}">
{{-- Clear Button --}}
<button type="button" x-show="editAmbientTempSetting !== '' && editAmbientTempSetting !== null" @click="editAmbientTempSetting = ''"
class="absolute right-3 p-1 rounded-full text-slate-400 hover:text-rose-500 hover:bg-rose-500/10 active:scale-95 transition-all"
style="display: none;">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
{{-- Plus Button --}}
<button type="button" @click="editAmbientTempSetting = (editAmbientTempSetting ? parseInt(editAmbientTempSetting) + 1 : 25)"
class="shrink-0 w-14 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14"/>
</svg>
</button>
</div>
{{-- Help Text --}}
<p class="text-[11px] font-bold text-slate-400 dark:text-slate-500 tracking-wide mt-1 pl-1">
{{ __('Can be empty. If left blank, it means ambient temperature monitoring is disabled.') }}
</p>
</div>
<div class="flex items-center gap-4 pt-4"> <div class="flex items-center gap-4 pt-4">
<button type="button" @click="showEditModal = false" <button type="button" @click="showEditModal = false"

View File

@ -38,6 +38,11 @@
'Remote Settlement' => __('Remote Settlement'), 'Remote Settlement' => __('Remote Settlement'),
'Lock Page Lock' => __('Lock Page Lock'), 'Lock Page Lock' => __('Lock Page Lock'),
'Lock Page Unlock' => __('Lock Page Unlock'), 'Lock Page Unlock' => __('Lock Page Unlock'),
'Fan On' => __('Fan On'),
'Fan Off' => __('Fan Off'),
'Fan Auto' => __('Fan Auto'),
'Ambient Temperature' => __('Ambient Temperature'),
'Ambient Temperature Upper Limit' => __('Ambient Temperature Upper Limit'),
'Remote Change' => __('Remote Change'), 'Remote Change' => __('Remote Change'),
'Remote Dispense' => __('Remote Dispense'), 'Remote Dispense' => __('Remote Dispense'),
'Adjust Stock & Expiry' => __('Adjust Stock & Expiry'), 'Adjust Stock & Expiry' => __('Adjust Stock & Expiry'),
@ -442,6 +447,10 @@
'checkout': this.translations['Remote Settlement'], 'checkout': this.translations['Remote Settlement'],
'lock': this.translations['Lock Page Lock'], 'lock': this.translations['Lock Page Lock'],
'unlock': this.translations['Lock Page Unlock'], 'unlock': this.translations['Lock Page Unlock'],
'fanon': this.translations['Fan On'],
'fanoff': this.translations['Fan Off'],
'fanauto': this.translations['Fan Auto'],
'ambient_temp_limit': this.translations['Ambient Temperature Upper Limit'],
'change': this.translations['Remote Change'], 'change': this.translations['Remote Change'],
'dispense': this.translations['Remote Dispense'], 'dispense': this.translations['Remote Dispense'],
'reload_stock': this.translations['Adjust Stock & Expiry'], 'reload_stock': this.translations['Adjust Stock & Expiry'],
@ -512,6 +521,11 @@
return details; return details;
} }
if (item.command_type === 'ambient_temp_limit' && item.payload) {
const temp = item.payload.temperature;
return `${this.translations['Ambient Temperature Upper Limit']}: ${temp !== null && temp !== undefined ? temp + '°C' : this.translations['Empty']}`;
}
return ''; return '';
} }
}; };
@ -685,6 +699,66 @@
</div> </div>
</div> </div>
<!-- Fan Controls -->
<div class="space-y-4">
<div class="flex items-center gap-3 ml-1">
<div class="w-1 h-3 bg-slate-300 dark:bg-slate-700 rounded-full"></div>
<span
class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">{{
__('Fan Controls') }}</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Fan On -->
<button @click="sendCommand('fanon')"
class="p-6 rounded-3xl border border-slate-100 dark:border-slate-800 flex items-center gap-5 hover:border-cyan-500/50 dark:hover:border-cyan-400/60 hover:bg-cyan-500/5 dark:hover:bg-cyan-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
<div
class="w-12 h-12 rounded-2xl bg-cyan-500/10 flex items-center justify-center text-cyan-500 dark:text-cyan-400 group-hover:scale-110 transition-transform duration-500 border border-cyan-500/20 dark:border-cyan-400/20">
<svg class="w-6 h-6 animate-[spin_10s_linear_infinite]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div class="text-left">
<div
class="text-sm font-black text-slate-800 dark:text-white group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">
{{ __('Fan On') }}</div>
</div>
</button>
<!-- Fan Off -->
<button @click="sendCommand('fanoff')"
class="p-6 rounded-3xl border border-slate-100 dark:border-slate-800 flex items-center gap-5 hover:border-cyan-500/50 dark:hover:border-cyan-400/60 hover:bg-cyan-500/5 dark:hover:bg-cyan-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
<div
class="w-12 h-12 rounded-2xl bg-cyan-500/10 flex items-center justify-center text-cyan-500 dark:text-cyan-400 group-hover:scale-110 transition-transform duration-500 border border-cyan-500/20 dark:border-cyan-400/20">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</div>
<div class="text-left">
<div
class="text-sm font-black text-slate-800 dark:text-white group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">
{{ __('Fan Off') }}</div>
</div>
</button>
<!-- Fan Auto -->
<button @click="sendCommand('fanauto')"
class="p-6 rounded-3xl border border-slate-100 dark:border-slate-800 flex items-center gap-5 hover:border-cyan-500/50 dark:hover:border-cyan-400/60 hover:bg-cyan-500/5 dark:hover:bg-cyan-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
<div
class="w-12 h-12 rounded-2xl bg-cyan-500/10 flex items-center justify-center text-cyan-500 dark:text-cyan-400 group-hover:scale-110 transition-transform duration-500 border border-cyan-500/20 dark:border-cyan-400/20">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div class="text-left">
<div
class="text-sm font-black text-slate-800 dark:text-white group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">
{{ __('Fan Auto') }}</div>
</div>
</button>
</div>
</div>
<!-- Security Controls --> <!-- Security Controls -->
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center gap-3 ml-1"> <div class="flex items-center gap-3 ml-1">

View File

@ -89,6 +89,10 @@
'checkout' => __('Remote Settlement'), 'checkout' => __('Remote Settlement'),
'lock' => __('Lock Page Lock'), 'lock' => __('Lock Page Lock'),
'unlock' => __('Lock Page Unlock'), 'unlock' => __('Lock Page Unlock'),
'fanon' => __('Fan On'),
'fanoff' => __('Fan Off'),
'fanauto' => __('Fan Auto'),
'ambient_temp_limit' => __('Ambient Temperature Upper Limit'),
'change' => __('Remote Change'), 'change' => __('Remote Change'),
'dispense' => __('Remote Dispense'), 'dispense' => __('Remote Dispense'),
'update_ads' => __('Sync Ads'), 'update_ads' => __('Sync Ads'),

View File

@ -63,6 +63,7 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::post('/{machine}/slots/expiry', [App\Http\Controllers\Admin\MachineController::class, 'updateSlotExpiry'])->name('slots.expiry.update'); Route::post('/{machine}/slots/expiry', [App\Http\Controllers\Admin\MachineController::class, 'updateSlotExpiry'])->name('slots.expiry.update');
Route::get('/{machine}/logs-ajax', [App\Http\Controllers\Admin\MachineController::class, 'logsAjax'])->name('logs-ajax'); Route::get('/{machine}/logs-ajax', [App\Http\Controllers\Admin\MachineController::class, 'logsAjax'])->name('logs-ajax');
Route::get('/{machine}/temperature-ajax', [App\Http\Controllers\Admin\MachineController::class, 'temperatureAjax'])->name('temperature-ajax'); Route::get('/{machine}/temperature-ajax', [App\Http\Controllers\Admin\MachineController::class, 'temperatureAjax'])->name('temperature-ajax');
Route::get('/{machine}/ambient-temperature-ajax', [App\Http\Controllers\Admin\MachineController::class, 'ambientTemperatureAjax'])->name('ambient-temperature-ajax');
Route::post('/{machine}/resolve-logs', [App\Http\Controllers\Admin\MachineController::class, 'resolveLogs'])->name('resolve-logs'); Route::post('/{machine}/resolve-logs', [App\Http\Controllers\Admin\MachineController::class, 'resolveLogs'])->name('resolve-logs');
}); });
Route::resource('machines', App\Http\Controllers\Admin\MachineController::class); Route::resource('machines', App\Http\Controllers\Admin\MachineController::class);