[PROMOTE] demo -> main

This commit is contained in:
sky121113 2026-06-04 13:33:41 +08:00
commit c6ce58f14a
23 changed files with 653 additions and 354 deletions

View File

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

View File

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

View File

@ -43,10 +43,48 @@ class MachineController extends AdminController
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'ambient_temp_setting' => 'nullable|integer',
]);
$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')
->with('success', __('Machine updated successfully.'));
}
@ -267,4 +305,35 @@ class MachineController extends AdminController
'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([
'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',
'slot_no' => 'nullable|string',
'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

@ -45,10 +45,10 @@ class ProcessMachineEvent implements ShouldQueue
return;
}
// 寫入機台狀態日誌type 為 'status' 以便正確歸類顯示於「機台狀態」日誌中
// 寫入環境溫度日誌type 為 'ambient_temp' 以便正確歸類顯示於「環境溫度回傳」日誌中
$machine->logs()->create([
'company_id' => $machine->company_id,
'type' => 'status', // 忠實呈現於機台狀態中
'type' => 'ambient_temp', // 歸類至環境溫度回傳
'level' => 'info',
'message' => $event, // 例如 "fanon" 或 "fanoff"
'context' => $this->payload,

View File

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

View File

@ -540,6 +540,22 @@ return [
'status' => 'online'
],
],
[
'name' => '機台環境溫度上報 (Ambient Temperature Report)',
'slug' => 'mqtt-ambient-temp-report',
'action' => 'PUB',
'topic' => 'machine/{serial_no}/ambient_temp',
'qos' => 1,
'description' => '機台主動上報當前環境溫度。支援 temperature 或 ambient_temp 欄位上報。',
'payload_parameters' => [
'temperature' => ['type' => 'integer', 'description' => '當前環境溫度值 (例如 28)'],
'ambient_temp' => ['type' => 'integer', 'description' => '當前環境溫度值 (相容相應硬體參數)'],
],
'payload_example' => [
'temperature' => 28
],
],
[
'name' => '機台硬體事件上報 (Event)',
'slug' => 'mqtt-event',
@ -773,7 +789,7 @@ return [
'qos' => 1,
'description' => '雲端主動下發的系統級別控制指令。包含重啟、設備鎖定解鎖、購物車結帳等無額外參數的指令。',
'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'],
'payload' => ['type' => 'object', 'description' => '空物件 (無額外參數)'],
],
@ -784,6 +800,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)',
'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

@ -58,8 +58,6 @@ class RoleSeeder extends Seeder
'menu.reservation',
'menu.special-permission',
'menu.special-permission.clear-stock',
'menu.special-permission.apk-versions',
'menu.special-permission.discord-notifications',
'menu.basic',
'menu.basic.machines',
'menu.basic.payment-configs',
@ -123,10 +121,6 @@ class RoleSeeder extends Seeder
'menu.reservation',
'menu.special-permission',
'menu.special-permission.clear-stock',
'menu.special-permission.apk-versions',
'menu.special-permission.discord-notifications',
'menu.basic.discord-notifications',
'menu.basic.apk-versions',
]);
}
}

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",
"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 / 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.": "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": "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 Notifications",
"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 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.": "Failed to send test notification. Please verify the Webhook URL.",
"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 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": "Hardware Peripheral Settings",
"HashIV": "HashIV",
"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": "Temperature reaches :temp°C, the fan will turn on",
"Temperature reported: :temp°C": "Temperature reported: :temp°C",
"Temperature updated to :temp°C": "Temperature updated to :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": "Update ambient temperature setting to :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": "Upper limit; the fan will turn on when reached",
"Usage Completed": "Usage Completed",
"Usage Limit": "Usage Limit",
"Usage Logs": "Usage Logs",
@ -2343,9 +2361,7 @@
"menu.sales.records": "Sales Records",
"menu.sales.store-gifts": "Store Gifts",
"menu.special-permission": "Special Permissions",
"menu.special-permission.apk-versions": "APK Versions",
"menu.special-permission.clear-stock": "Clear Stock",
"menu.special-permission.discord-notifications": "Discord Notifications",
"menu.warehouses": "Warehouse Management",
"menu.warehouses.inventory": "Inventory Management",
"menu.warehouses.machine-inventory": "Machine Inventory Overview",

View File

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

View File

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

View File

@ -106,7 +106,8 @@
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',
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;
},
@ -610,6 +611,29 @@
</label>
</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

View File

@ -82,6 +82,7 @@
if ($machine->welcome_gift_enabled) $activeFeatures[] = __('Welcome Gift');
if ($machine->cash_module_enabled) $activeFeatures[] = __('Coin/Banknote Machine');
if ($machine->member_system_enabled) $activeFeatures[] = __('Member System');
if ($machine->ambient_temp_monitoring_enabled) $activeFeatures[] = __('Ambient Temperature Monitoring');
@endphp
<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">
@ -139,6 +140,7 @@
if ($machine->welcome_gift_enabled) $activeFeatures[] = __('Welcome Gift');
if ($machine->cash_module_enabled) $activeFeatures[] = __('Coin/Banknote Machine');
if ($machine->member_system_enabled) $activeFeatures[] = __('Member System');
if ($machine->ambient_temp_monitoring_enabled) $activeFeatures[] = __('Ambient Temperature Monitoring');
@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">

View File

@ -11,6 +11,8 @@
showResolveConfirm: false,
editMachineId: '',
editMachineName: '',
editAmbientTempSetting: '',
editAmbientTempMonitoringEnabled: false,
activeTab: 'status',
currentMachineId: '',
isLoadingTable: false,
@ -40,6 +42,7 @@
},
currentMachineSn: '',
currentMachineName: '',
currentMachineAmbientTempEnabled: false,
logs: [],
loading: false,
inventoryLoading: false,
@ -55,6 +58,7 @@
totalLogs: 0,
temperatureChart: null,
ambientTemperatureChart: null,
init() {
const now = new Date();
@ -63,17 +67,31 @@
this.startDate = formatDate(now, '00:00');
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('endDate', () => { if(this.activeTab === 'status') this.fetchTemperatureData(); });
this.$watch('startDate', () => {
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, ambientTempEnabled = false) {
this.currentMachineId = id;
this.currentMachineSn = sn;
this.currentMachineName = name;
this.currentMachineAmbientTempEnabled = ambientTempEnabled === true || ambientTempEnabled === 1 || ambientTempEnabled === '1' || ambientTempEnabled === 'true';
this.slots = [];
this.showLogPanel = true;
this.activeTab = 'status';
@ -96,6 +114,106 @@
} 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) {
this.$nextTick(() => {
const chartEl = document.querySelector("#temperature-chart");
@ -181,9 +299,11 @@
});
},
openEditModal(id, name) {
openEditModal(id, name, ambientTempSetting = '', ambientTempMonitoringEnabled = false) {
this.editMachineId = id;
this.editMachineName = name;
this.editAmbientTempSetting = ambientTempSetting;
this.editAmbientTempMonitoringEnabled = ambientTempMonitoringEnabled === true || ambientTempMonitoringEnabled === 1 || ambientTempMonitoringEnabled === '1' || ambientTempMonitoringEnabled === 'true';
this.showEditModal = true;
},
@ -356,7 +476,7 @@
<tr
class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
<td class="px-6 py-6 cursor-pointer group"
@click="openLogPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}')">
@click="openLogPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}', {{ $machine->ambient_temp_monitoring_enabled ? 1 : 0 }})">
<div class="flex items-center gap-3">
<div
class="w-12 h-12 rounded-2xl 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 shadow-sm">
@ -493,7 +613,7 @@
<div class="flex items-center justify-end gap-2">
<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"
title="{{ __('Edit Name') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
@ -503,7 +623,7 @@
</svg>
</button>
<button type="button"
@click="openLogPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}')"
@click="openLogPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}', {{ $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"
title="{{ __('View Logs') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
@ -543,7 +663,7 @@
@endif
</div>
<div class="min-w-0">
<h3 @click="openLogPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}')"
<h3 @click="openLogPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}', {{ $machine->ambient_temp_monitoring_enabled ? 1 : 0 }})"
class="text-base font-extrabold text-slate-800 dark:text-slate-100 hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight cursor-pointer flex items-center gap-2 min-w-0">
<span class="truncate">{{ $machine->name }}</span>
@if($machine->has_login_warning)
@ -622,7 +742,7 @@
<!-- Action Buttons -->
<div class="flex items-center gap-3">
<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">
<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"
@ -631,8 +751,8 @@
{{ __('Edit') }}
</button>
<button type="button"
@click="openLogPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}')"
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">
@click="openLogPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}', {{ $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">
<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"
d="M2.036 12.322a1.012 1.012 0 0 1 0-.644C3.67 8.5 7.652 6 12 6c4.348 0 8.332 2.5 9.964 5.678a1.012 1.012 0 0 1 0 .644C20.33 15.5 16.348 18 12 18c-4.348 0-8.332-2.5-9.964-5.678z" />
@ -802,18 +922,25 @@
class="whitespace-nowrap py-4 px-1 border-b-2 font-bold text-[13px] sm:text-sm transition duration-300">
{{ __('Machine Login Logs') }}
</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="whitespace-nowrap py-4 px-1 border-b-2 font-bold text-[13px] sm:text-sm transition duration-300">
{{ __('Sub-machine Status Request') }}
</button>
<button @click="activeTab = 'ambient_temp'"
x-show="currentMachineAmbientTempEnabled"
: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"
x-cloak>
{{ __('Ambient Temperature Log') }}
</button>
</nav>
</div>
<!-- Tab Contents -->
<div class="flex-1 overflow-y-auto p-6 sm:p-8">
<div class="relative min-h-[400px]">
<!-- Loading State -->
<div x-show="loading" x-transition:enter="transition ease-out duration-300"
@ -822,7 +949,7 @@
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"
x-cloak>
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
<!-- 外圈:快速旋轉 -->
<div
@ -844,7 +971,7 @@
class="text-[12px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">
{{ __('Loading Data') }}...</p>
</div>
<!-- Temperature Trend Chart -->
<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">
@ -859,6 +986,21 @@
</div>
<div id="temperature-chart" class="min-h-[200px] w-full"></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 -->
<div x-show="activeTab !== 'expiry'"
@ -1063,6 +1205,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"
placeholder="{{ __('Enter machine name...') }}">
</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">
<button type="button" @click="showEditModal = false"

View File

@ -38,6 +38,11 @@
'Remote Settlement' => __('Remote Settlement'),
'Lock Page Lock' => __('Lock Page Lock'),
'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 Dispense' => __('Remote Dispense'),
'Adjust Stock & Expiry' => __('Adjust Stock & Expiry'),
@ -442,6 +447,10 @@
'checkout': this.translations['Remote Settlement'],
'lock': this.translations['Lock Page Lock'],
'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'],
'dispense': this.translations['Remote Dispense'],
'reload_stock': this.translations['Adjust Stock & Expiry'],
@ -512,6 +521,11 @@
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 '';
}
};
@ -685,6 +699,66 @@
</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 -->
<div class="space-y-4">
<div class="flex items-center gap-3 ml-1">

View File

@ -89,6 +89,10 @@
'checkout' => __('Remote Settlement'),
'lock' => __('Lock Page Lock'),
'unlock' => __('Lock Page Unlock'),
'fanon' => __('Fan On'),
'fanoff' => __('Fan Off'),
'fanauto' => __('Fan Auto'),
'ambient_temp_limit' => __('Ambient Temperature Upper Limit'),
'change' => __('Remote Change'),
'dispense' => __('Remote Dispense'),
'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::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}/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::resource('machines', App\Http\Controllers\Admin\MachineController::class);

View File

@ -1,253 +0,0 @@
import json
import os
files = {
'zh_TW': '/home/mama/projects/star-cloud/lang/zh_TW.json',
'en': '/home/mama/projects/star-cloud/lang/en.json',
'ja': '/home/mama/projects/star-cloud/lang/ja.json'
}
new_keys = {
"CREATE STOCK TRANSFERS BETWEEN WAREHOUSES AND MACHINE RETURNS": {
"zh_TW": "建立倉庫間調撥或機台退庫單",
"en": "Create stock transfers between warehouses and machine returns",
"ja": "倉庫間の転送および機台からの返品伝票を作成"
},
"Create and manage replenishment orders from warehouse to machines": {
"zh_TW": "建立並管理從倉庫到機台的補貨單",
"en": "Create and manage replenishment orders from warehouse to machines",
"ja": "倉庫から機台への補充伝票の作成と管理"
},
"Create stock transfers between warehouses and machine returns": {
"zh_TW": "建立倉庫間調撥或機台退庫單",
"en": "Create stock transfers between warehouses and machine returns",
"ja": "倉庫間の転送および機台からの返品伝票を作成"
},
"Create Transfer Order": {
"zh_TW": "建立調撥單",
"en": "Create Transfer Order",
"ja": "転送伝票を作成"
},
"Create Replenishment Order": {
"zh_TW": "建立補貨單",
"en": "Create Replenishment Order",
"ja": "補充伝票を作成"
},
"New Transfer": {
"zh_TW": "新增調撥單",
"en": "New Transfer",
"ja": "新規転送"
},
"New Replenishment": {
"zh_TW": "新增補貨單",
"en": "New Replenishment",
"ja": "新規補充"
},
"New Stock-In Order": {
"zh_TW": "新增進貨單",
"en": "New Stock-In Order",
"ja": "新規入庫伝票"
},
"No transfer orders found": {
"zh_TW": "查無調撥單紀錄",
"en": "No transfer orders found",
"ja": "転送伝票が見つかりません"
},
"No replenishment orders found": {
"zh_TW": "查無補貨單紀錄",
"en": "No replenishment orders found",
"ja": "補充伝票が見つかりません"
},
"No stock data found": {
"zh_TW": "查無庫存資料",
"en": "No stock data found",
"ja": "在庫データが見つかりません"
},
"No stock-in orders found": {
"zh_TW": "查無進貨單紀錄",
"en": "No stock-in orders found",
"ja": "入庫伝票が見つかりません"
},
"No movement records found": {
"zh_TW": "查無異動紀錄",
"en": "No movement records found",
"ja": "移動履歴が見つかりません"
},
"Replenishment Orders": {
"zh_TW": "機台補貨單",
"en": "Replenishment Orders",
"ja": "機台補充伝票"
},
"Search products...": {
"zh_TW": "搜尋商品...",
"en": "Search products...",
"ja": "商品を検索..."
},
"Search Product": {
"zh_TW": "搜尋商品",
"en": "Search Product",
"ja": "商品を検索"
},
"Select Warehouse": {
"zh_TW": "選擇倉庫",
"en": "Select Warehouse",
"ja": "倉庫を選択"
},
"Select Machine": {
"zh_TW": "選擇機台",
"en": "Select Machine",
"ja": "機台を選択"
},
"Select Product": {
"zh_TW": "選擇商品",
"en": "Select Product",
"ja": "商品を選択"
},
"Source Warehouse": {
"zh_TW": "來源倉庫",
"en": "Source Warehouse",
"ja": "発送元倉庫"
},
"Target Warehouse": {
"zh_TW": "目標倉庫",
"en": "Target Warehouse",
"ja": "対象倉庫"
},
"Stock-In Orders": {
"zh_TW": "進貨單管理",
"en": "Stock-In Orders",
"ja": "入庫伝票"
},
"Stock-In Management": {
"zh_TW": "進貨管理",
"en": "Stock-In Management",
"ja": "入庫管理"
},
"Stock flow history": {
"zh_TW": "庫存流向歷史",
"en": "Stock flow history",
"ja": "在庫移動履歴"
},
"Stock-In Orders Tab": {
"zh_TW": "進貨單管理",
"en": "Stock-In Orders",
"ja": "入庫伝票"
},
"Transfer Orders": {
"zh_TW": "調撥單管理",
"en": "Transfer Orders",
"ja": "転送伝票"
},
"Track stock-in orders and movement history": {
"zh_TW": "追蹤進貨單據與庫存異動紀錄",
"en": "Track stock-in orders and movement history",
"ja": "入庫伝票および移動履歴の追跡"
},
"Warehouse to Warehouse": {
"zh_TW": "倉庫對倉庫",
"en": "Warehouse to Warehouse",
"ja": "倉庫間転送"
},
"Warehouse Transfer": {
"zh_TW": "倉庫調撥",
"en": "Warehouse Transfer",
"ja": "倉庫転送"
},
"Warehouse Management": {
"zh_TW": "倉儲管理",
"en": "Warehouse Management",
"ja": "倉庫管理"
},
"Warehouse purchase records": {
"zh_TW": "倉庫採購進貨紀錄",
"en": "Warehouse purchase records",
"ja": "倉庫購入記録"
},
"Machine to Warehouse": {
"zh_TW": "機台對倉庫",
"en": "Machine to Warehouse",
"ja": "機台から倉庫"
},
"Machine Return": {
"zh_TW": "機台退庫",
"en": "Machine Return",
"ja": "機台返品"
},
"Machine Replenishment": {
"zh_TW": "機台補貨單",
"en": "Machine Replenishment",
"ja": "機台補充"
},
"Movement History": {
"zh_TW": "異動紀錄",
"en": "Movement History",
"ja": "移動履歴"
},
"Movement Logs": {
"zh_TW": "異動紀錄",
"en": "Movement Logs",
"ja": "移動ログ"
},
"Manage stock transfers between warehouses and machine returns": {
"zh_TW": "建立倉庫間調撥或機台退庫單",
"en": "Manage stock transfers between warehouses and machine returns",
"ja": "倉庫間の転送および機台からの返品伝票を作成"
},
"Main": {
"zh_TW": "總倉",
"en": "Main",
"ja": "総倉庫"
},
"Note": {
"zh_TW": "備註",
"en": "Note",
"ja": "備考"
},
"Transfer Audit": {
"zh_TW": "調撥單",
"en": "Transfer Audit",
"ja": "転送監査"
},
"Transfers": {
"zh_TW": "調撥單",
"en": "Transfers",
"ja": "転送"
},
"Inventory Management": {
"zh_TW": "庫存管理",
"en": "Inventory Management",
"ja": "在庫管理"
},
"Manage stock levels, stock-in orders, and movement history": {
"zh_TW": "追蹤庫存水位、進貨單與異動紀錄",
"en": "Manage stock levels, stock-in orders, and movement history",
"ja": "在庫レベル、入庫伝票、および移動履歴の管理"
},
"Track stock levels, stock-in orders, and movement history": {
"zh_TW": "追蹤庫存水位、進貨單與異動紀錄",
"en": "Track stock levels, stock-in orders, and movement history",
"ja": "在庫レベル、入庫伝票、および移動履歴の追跡"
}
}
for lang, path in files.items():
if not os.path.exists(path):
continue
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
# Update menu.warehouses
if lang == 'zh_TW':
data['menu.warehouses'] = "倉儲管理"
# Add/Update keys
for k, v in new_keys.items():
if lang in v:
data[k] = v[lang]
elif lang == 'en':
data[k] = k
# Sort keys and write back
with open(path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=4, sort_keys=True)

View File

@ -1,66 +0,0 @@
<?php
// Mocking Route and Laravel environment for breadcrumb logic test
function __($str) { return $str; }
class Route {
public static $currentName = '';
public static function currentRouteName() { return self::$currentName; }
}
function testBreadcrumbs($routeName) {
Route::$currentName = $routeName;
$links = [];
$links[] = [
'label' => __('Dashboard'),
'url' => '/admin/dashboard',
'active' => $routeName === 'admin.dashboard'
];
$moduleMap = [
'admin.warehouses' => __('Warehouse Management'),
// ... simplified
];
$foundModule = null;
foreach ($moduleMap as $prefix => $label) {
if (str_starts_with($routeName, $prefix)) {
$foundModule = [
'label' => $label,
'url' => '#',
'active' => false
];
break;
}
}
if ($foundModule) {
$links[] = $foundModule;
}
$segments = explode('.', $routeName);
$lastSegment = end($segments);
// Simplifed page labels
$pageLabel = match($lastSegment) {
'inventory' => __('Inventory Management'),
'index' => 'Warehouse Overview',
default => null,
};
if ($pageLabel) {
$links[] = [
'label' => $pageLabel,
'active' => true
];
}
echo "Route: $routeName\n";
foreach ($links as $link) {
echo " - " . $link['label'] . ($link['active'] ? " (active)" : "") . "\n";
}
echo "\n";
}
testBreadcrumbs('admin.warehouses.inventory');
testBreadcrumbs('admin.warehouses.index');

View File

@ -61,6 +61,8 @@ class DiscordNotificationSecurityTest extends TestCase
// 確保 tenantAdminTemplate 包含我們新加的 menu.basic.discord-notifications
$tenantRole->syncPermissions($tenantAdminTemplate->permissions);
// 手動指派以測試有權限時的存取行為 (因現已改為比照機台設定預設不配給範本)
$tenantRole->givePermissionTo('menu.basic.discord-notifications');
$this->tenantAdmin->assignRole($tenantRole);
// 5. 建立普通無此權限之帳號