[FEAT] 優化機台硬體通訊協議與管理介面互動性

1. 修復帳號管理與角色權限頁面搜尋功能,支援 Enter 鍵快捷提交。
2. 完成 B013 (機台故障上報) API 實作,改用非同步隊列 (ProcessMachineError) 處理日誌上報。
3. 精簡 B013 API 參數,移除冗餘的 message 欄位,統一由雲端對照表翻譯。
4. 更新技術規格文件 (SKILL.md) 與系統 API 文件配置 (api-docs.php)。
5. 修正平台管理員帳號在搜尋過濾時的資料隔離邏輯。
This commit is contained in:
sky121113 2026-04-08 14:52:00 +08:00
parent c343df34ee
commit a599b14df1
21 changed files with 1039 additions and 117 deletions

View File

@ -24,6 +24,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- **URL**: POST /api/v1/app/admin/login/B000 - **URL**: POST /api/v1/app/admin/login/B000
- **Request Body:** - **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 | | 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| machine | String | 是 | 機台編號 (serial_no) | M-001 | | machine | String | 是 | 機台編號 (serial_no) | M-001 |
@ -35,6 +36,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- **Response Body:** - **Response Body:**
> [!IMPORTANT] > [!IMPORTANT]
> 為了相容 Java APP 現有邏輯,這裡嚴格規定成功必須回傳字串 Success。 > 為了相容 Java APP 現有邏輯,這裡嚴格規定成功必須回傳字串 Success。
| 參數 | 類型 | 說明 | 範例 | | 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| message | String | 驗證結果 (Success 或 Failed) | Success | | message | String | 驗證結果 (Success 或 Failed) | Success |
@ -48,6 +50,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- **Request Body:** 無 (GET 請求) - **Request Body:** 無 (GET 請求)
- **Response Body:** - **Response Body:**
| 參數 | 類型 | 說明 | 範例 | | 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | true | | success | Boolean | 請求是否成功 | true |
@ -74,12 +77,14 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- **URL**: PUT /api/v1/app/products/supplementary/B009 - **URL**: PUT /api/v1/app/products/supplementary/B009
- **Request Body:** - **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 | | 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| account | String | 是 | 操作人員帳號 | 0999123456 | | account | String | 是 | 操作人員帳號 | 0999123456 |
| data | Array | 是 | 貨道數據陣列 | [{"tid":"1", "t060v00":"1", "num":"10"}] | | data | Array | 是 | 貨道數據陣列 | [{"tid":"1", "t060v00":"1", "num":"10"}] |
- **data 陣列內部欄位:** - **data 陣列內部欄位:**
| 欄位 | 類型 | 說明 | 範例 | | 欄位 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| tid | Integer | 貨道編號 (Slot No) | 1 | | tid | Integer | 貨道編號 (Slot No) | 1 |
@ -92,6 +97,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
> 當後端收到 B009 時,會根據 type 自動從該商品的配置中選取 spring_limit 或 track_limit 並自動更新該貨道的 max_stock 欄位。機台端無需手動計算上限。 > 當後端收到 B009 時,會根據 type 自動從該商品的配置中選取 spring_limit 或 track_limit 並自動更新該貨道的 max_stock 欄位。機台端無需手動計算上限。
- **Response Body (Success 200):** - **Response Body (Success 200):**
| 參數 | 類型 | 說明 | 範例 | | 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| success | Boolean | 同步是否成功 | true | | success | Boolean | 同步是否成功 | true |
@ -107,6 +113,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- **URL**: POST /api/v1/app/machine/status/B010 - **URL**: POST /api/v1/app/machine/status/B010
- **Authentication**: Bearer Token (Header) - **Authentication**: Bearer Token (Header)
- **Request Body:** - **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 | | 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| current_page | Integer | 是 | 當前頁面代碼 (見下表) | 1 | | current_page | Integer | 是 | 當前頁面代碼 (見下表) | 1 |
@ -119,6 +126,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| log_payload | Object | 否 | 額外日誌 JSON 對象 | {"code":500} | | log_payload | Object | 否 | 額外日誌 JSON 對象 | {"code":500} |
- **Response Body:** - **Response Body:**
| 參數 | 類型 | 說明 | 範例 | | 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否處理成功 | true | | success | Boolean | 請求是否處理成功 | true |
@ -175,3 +183,36 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| t060v41 | String | 物料編碼 (Material Code) | SKU-001 | | t060v41 | String | 物料編碼 (Material Code) | SKU-001 |
| spring_limit | Integer | **彈簧貨道上限** (建議使用此欄位) | 10 | | spring_limit | Integer | **彈簧貨道上限** (建議使用此欄位) | 10 |
| track_limit | Integer | **履帶貨道上限** (建議使用此欄位) | 15 | | track_limit | Integer | **履帶貨道上限** (建議使用此欄位) | 15 |
---
### 3.6 B013: 機台故障與異常狀態上報 (Error/Status Report)
用於接收機台發出的即時硬體狀態代碼(如卡貨、門未關),並自動由雲端後端翻譯為易讀日誌。
- **URL**: POST /api/v1/app/machine/error/B013
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| tid | Integer | 否 | 涉及之貨道編號 (Slot No) | 12 |
| error_code | String | 是 | 硬體狀態代碼 (4 位 16 進位) | "0403" |
- **回應 (Success 202):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求已接收 | true |
| code | Integer | 200 | 200 |
#### B013 硬體代碼對照表 (由 MachineService 自動翻譯)
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
| :--- | :--- | :--- | :--- |
| **0402** | Dispense successful | info | 出貨成功 |
| **0403** | Slot jammed | error | **貨道卡貨** (重大異常) |
| **0202** | Product empty | warning | 貨道缺貨 |
| **0412** | Elevator rise error | error | 昇降機上升異常 |
| **0415** | Pickup door error | error | 取貨門異常 |
| **5402** | Pickup door not closed | warning | **取貨門未關** (警告) |
| **5403** | Elevator failure | error | 昇降系統故障 |

View File

@ -71,17 +71,21 @@ class MachineController extends AdminController
*/ */
public function logsAjax(Request $request, Machine $machine) public function logsAjax(Request $request, Machine $machine)
{ {
$per_page = $request->input('per_page', 10); $per_page = $request->input('per_page', 20);
$startDate = $request->get('start_date', now()->format('Y-m-d')); $startDate = $request->get('start_date');
$endDate = $request->get('end_date', now()->format('Y-m-d')); $endDate = $request->get('end_date');
$logs = $machine->logs() $logs = $machine->logs()
->when($request->level, function ($query, $level) { ->when($request->level, function ($query, $level) {
return $query->where('level', $level); return $query->where('level', $level);
}) })
->whereDate('created_at', '>=', $startDate) ->when($startDate, function ($query, $start) {
->whereDate('created_at', '<=', $endDate) return $query->where('created_at', '>=', str_replace('T', ' ', $start));
})
->when($endDate, function ($query, $end) {
return $query->where('created_at', '<=', str_replace('T', ' ', $end));
})
->when($request->type, function ($query, $type) { ->when($request->type, function ($query, $type) {
return $query->where('type', $type); return $query->where('type', $type);
}) })

View File

@ -318,7 +318,11 @@ class PermissionController extends Controller
} }
if ($user->isSystemAdmin() && $request->filled('company_id')) { if ($user->isSystemAdmin() && $request->filled('company_id')) {
$query->where('company_id', $request->company_id); if ($request->company_id === 'system') {
$query->whereNull('company_id');
} else {
$query->where('company_id', $request->company_id);
}
} }
$per_page = $request->input('per_page', 10); $per_page = $request->input('per_page', 10);

View File

@ -9,6 +9,7 @@ use App\Models\System\User;
use App\Models\Machine\Machine; use App\Models\Machine\Machine;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use App\Jobs\Machine\ProcessStateLog;
class MachineAuthController extends Controller class MachineAuthController extends Controller
{ {
@ -20,30 +21,14 @@ class MachineAuthController extends Controller
{ {
// 1. 驗證欄位 (相容舊版 Java App 發送的 JSON 格式) // 1. 驗證欄位 (相容舊版 Java App 發送的 JSON 格式)
$validated = $request->validate([ $validated = $request->validate([
'machine' => 'required|string', 'machine' => 'required|string',
'Su_Account' => 'required|string', 'Su_Account' => 'required|string',
'Su_Password' => 'required|string', 'Su_Password' => 'required|string',
'ip' => 'nullable|string', 'ip' => 'nullable|string',
'type' => 'nullable|string', 'type' => 'nullable|string',
]); ]);
// 2. 透過帳號尋找使用者 (允許使用 username 或 email) // 2. 取得機台物件 (需優先於帳密驗證,以便記錄日誌到正確機台)
$user = User::where('username', $validated['Su_Account'])
->orWhere('email', $validated['Su_Account'])
->first();
// 若無此帳號或密碼錯誤
if (!$user || !Hash::check($validated['Su_Password'], $user->password)) {
Log::warning("B000 機台登入失敗: 帳密錯誤", [
'account' => $validated['Su_Account'],
'machine' => $validated['machine']
]);
// 按現行設定Java 端只認 Success 字串,其餘視為帳密錯誤
return response()->json(['message' => 'Failed']);
}
// 3. 取得機台物件
// 因為此 API 無狀態 (沒有登入 session),為了避免被 global scope 擋住,直接取消所有 scope 來撈取
$machine = Machine::withoutGlobalScopes()->where('serial_no', $validated['machine'])->first(); $machine = Machine::withoutGlobalScopes()->where('serial_no', $validated['machine'])->first();
if (!$machine) { if (!$machine) {
@ -53,38 +38,79 @@ class MachineAuthController extends Controller
return response()->json(['message' => 'Failed']); return response()->json(['message' => 'Failed']);
} }
// 4. RBAC 權限驗證 (遵循多租戶與機台授權規範) // 3. 透過帳號尋找使用者 (允許使用 username 或 email)
$user = User::where('username', $validated['Su_Account'])
->orWhere('email', $validated['Su_Account'])
->first();
// 4. 驗證密碼
if (!$user || !Hash::check($validated['Su_Password'], $user->password)) {
Log::warning("B000 機台登入失敗: 帳密錯誤", [
'account' => $validated['Su_Account'],
'machine' => $validated['machine']
]);
// 寫入機台日誌
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
__("Login failed: :account", ['account' => $validated['Su_Account']]),
'warning',
[],
'login'
);
return response()->json(['message' => 'Failed']);
}
// 5. RBAC 權限驗證 (遵循多租戶與機台授權規範)
$isAuthorized = false;
if ($user->isSystemAdmin()) { if ($user->isSystemAdmin()) {
// [系統管理員] : 擁有最高權限,可登入平台下轄所有機台,直接放行 $isAuthorized = true;
} elseif ($user->is_admin) { } elseif ($user->is_admin) {
// [公司管理員] : 不需要檢查 machine_user 表,但【必須驗證】該機台是否隸屬於他的公司 if ($machine->company_id === $user->company_id) {
if ($machine->company_id !== $user->company_id) { $isAuthorized = true;
Log::warning("B000 機台登入失敗: 企圖越權登入其他公司的機台", [
'user_id' => $user->id,
'user_company' => $user->company_id,
'machine_company' => $machine->company_id
]);
return response()->json(['message' => 'Forbidden']);
} }
} else { } else {
// [一般租戶帳號] : (包括補貨員等)必須嚴格檢查該帳號有沒有被分配到這台機器的 machine_user 關聯授權 if ($user->machines()->where('machine_id', $machine->id)->exists()) {
if (!$user->machines()->where('machine_id', $machine->id)->exists()) { $isAuthorized = true;
Log::warning("B000 機台登入失敗: 該帳號沒有此機台的授權", [
'user_id' => $user->id,
'machine_id' => $machine->id
]);
return response()->json(['message' => 'Forbidden']);
} }
} }
// 5. 驗證完美通過!回傳固定字串 Success 讓 Java 端放行 if (!$isAuthorized) {
Log::warning("B000 機台登入失敗: 權限不足", [
'user_id' => $user->id,
'machine_id' => $machine->id
]);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
__("Unauthorized login attempt: :account", ['account' => $user->username]),
'warning',
[],
'login'
);
return response()->json(['message' => 'Forbidden']);
}
// 6. 驗證完美通過!
Log::info("B000 機台登入成功", [ Log::info("B000 機台登入成功", [
'account' => $user->username, 'account' => $user->username,
'machine' => $machine->serial_no 'machine' => $machine->serial_no
]); ]);
// 寫入成功登入日誌
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
__("User logged in: :name", ['name' => $user->name ?? $user->username]),
'info',
[],
'login'
);
return response()->json([ return response()->json([
'message' => 'Success' 'message' => 'Success'
]); ]);

View File

@ -9,8 +9,11 @@ use App\Models\System\User;
use App\Jobs\Machine\ProcessHeartbeat; use App\Jobs\Machine\ProcessHeartbeat;
use App\Jobs\Machine\ProcessTimerStatus; use App\Jobs\Machine\ProcessTimerStatus;
use App\Jobs\Machine\ProcessCoinInventory; use App\Jobs\Machine\ProcessCoinInventory;
use App\Jobs\Machine\ProcessMachineError;
use App\Jobs\Machine\ProcessStateLog;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Cache;
class MachineController extends Controller class MachineController extends Controller
{ {
@ -22,6 +25,69 @@ class MachineController extends Controller
$machine = $request->get('machine'); $machine = $request->get('machine');
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入的 Model 物件與認證 key $data = $request->except(['machine', 'key']); // 排除 Middleware 注入的 Model 物件與認證 key
// === 狀態異動觸發 (Redis 快取免查 DB) ===
$cacheKey = "machine:{$machine->serial_no}:state";
$oldState = Cache::get($cacheKey);
$currentPage = $data['current_page'] ?? null;
$doorStatus = $data['door_status'] ?? null;
$firmwareVersion = $data['firmware_version'] ?? null;
$model = $data['model'] ?? null;
if ($currentPage !== null || $doorStatus !== null || $firmwareVersion !== null || $model !== null) {
// 更新目前狀態到 Redis (保存 1 天)
$newState = $oldState ?? [];
if ($currentPage !== null) $newState['current_page'] = $currentPage;
if ($doorStatus !== null) $newState['door_status'] = $doorStatus;
if ($firmwareVersion !== null) $newState['firmware_version'] = $firmwareVersion;
if ($model !== null) $newState['model'] = $model;
Cache::put($cacheKey, $newState, 86400);
// 若有歷史紀錄才進行比對 (避開 Cache Miss 造成的雪崩)
if ($oldState !== null) {
// 1. 判斷頁面是否變更
if ($currentPage !== null && (string)$currentPage !== (string)($oldState['current_page'] ?? '')) {
// 只記錄「絕對狀態」,配合 lang 中 "Page X" 的翻譯
ProcessStateLog::dispatch($machine->id, $machine->company_id, "Page {$currentPage}", 'info');
}
// 2. 判斷門禁是否變更 (0: 關閉, 1: 開啟)
if ($doorStatus !== null && (string)$doorStatus !== (string)($oldState['door_status'] ?? '')) {
$doorMessage = $doorStatus == 1 ? "Door Opened" : "Door Closed";
$doorLevel = 'info'; // 不論開關門皆為 info避免觸發異常狀態
ProcessStateLog::dispatch($machine->id, $machine->company_id, $doorMessage, $doorLevel);
}
// 3. 判斷韌體版本是否變更
if ($firmwareVersion !== null && (string)$firmwareVersion !== (string)($oldState['firmware_version'] ?? '')) {
$oldVersion = $oldState['firmware_version'] ?? 'Unknown';
// 直接在 Controller 進行翻譯並填值,確保儲存到 DB 的是最終正確字串
$versionMessage = __("Firmware updated to :version", ['version' => $firmwareVersion]);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$versionMessage,
'info',
['old' => $oldVersion, 'new' => $firmwareVersion]
);
}
// 4. 判斷型號是否變更
if ($model !== null && (string)$model !== (string)($oldState['model'] ?? '')) {
$oldModel = $oldState['model'] ?? 'Unknown';
$modelMessage = __("Model changed to :model", ['model' => $model]);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$modelMessage,
'info',
['old' => $oldModel, 'new' => $model]
);
}
}
}
// 異步處理狀態更新 // 異步處理狀態更新
ProcessHeartbeat::dispatch($machine->serial_no, $data); ProcessHeartbeat::dispatch($machine->serial_no, $data);
@ -372,4 +438,22 @@ class MachineController extends Controller
'data' => $products 'data' => $products
]); ]);
} }
/**
* B013: Report Machine Hardware Error/Status (Asynchronous)
*/
public function reportError(Request $request)
{
$machine = $request->get('machine');
$data = $request->only(['tid', 'error_code']);
// 異步分派處理 (Dispatch to queue)
ProcessMachineError::dispatch($machine->serial_no, $data);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Error report accepted',
], 202); // 202 Accepted
}
} }

View File

@ -0,0 +1,40 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use App\Services\Machine\MachineService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessMachineError implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $serialNo;
protected $data;
/**
* Create a new job instance.
*/
public function __construct(string $serialNo, array $data)
{
$this->serialNo = $serialNo;
$this->data = $data;
}
/**
* Execute the job.
*/
public function handle(MachineService $service): void
{
$machine = Machine::where('serial_no', $this->serialNo)->first();
if ($machine) {
$service->recordErrorLog($machine, $this->data);
}
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\MachineLog;
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;
class ProcessStateLog implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $machineId;
protected $companyId;
protected $message;
protected $level;
protected $type;
protected $context;
/**
* Create a new job instance.
*/
public function __construct(int $machineId, ?int $companyId, string $message, string $level = 'info', array $context = [], string $type = 'status')
{
$this->machineId = $machineId;
$this->companyId = $companyId;
$this->message = $message;
$this->level = $level;
$this->context = $context;
$this->type = $type;
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
MachineLog::create([
'machine_id' => $this->machineId,
'company_id' => $this->companyId,
'type' => $this->type,
'level' => $this->level,
'message' => $this->message,
'context' => $this->context,
]);
} catch (\Exception $e) {
Log::error("Failed to create state log for machine {$this->machineId}: " . $e->getMessage());
throw $e;
}
}
}

View File

@ -24,6 +24,33 @@ class MachineLog extends Model
'context' => 'array', 'context' => 'array',
]; ];
protected $appends = [
'translated_message',
];
/**
* 動態重組翻譯後的訊息
*/
public function getTranslatedMessageAttribute(): string
{
$context = $this->context;
// 若 context 中已有翻譯標籤 (B013 封裝),則進行動態重組
if (isset($context['translated_label'])) {
$label = __($context['translated_label']);
$tid = $context['tid'] ?? null;
$code = $context['raw_code'] ?? '0000';
if ($tid) {
return __('Slot') . " {$tid}: {$label} (Code: {$code})";
}
return "{$label} (Code: {$code})";
}
// 預設退回原始 message (支援歷史資料的翻譯判定與佔位符替換)
return __($this->message, $context ?? []);
}
public function machine() public function machine()
{ {
return $this->belongsTo(Machine::class); return $this->belongsTo(Machine::class);

View File

@ -10,6 +10,56 @@ use Carbon\Carbon;
class MachineService class MachineService
{ {
/**
* B013: 硬體狀態代碼對照表 (Hardware Status Code Mapping)
*/
public const ERROR_CODE_MAP = [
// 出貨狀態類 (Prefix: 04 - BUY_STATUS)
'0401' => ['label' => 'Dispensing in progress', 'level' => 'info'],
'0402' => ['label' => 'Dispense successful', 'level' => 'info'],
'0403' => ['label' => 'Slot jammed', 'level' => 'error'],
'0404' => ['label' => 'Motor not stopped', 'level' => 'warning'],
'0406' => ['label' => 'Slot not found', 'level' => 'error'],
'0407' => ['label' => 'Dispense error (0407)', 'level' => 'error'],
'0408' => ['label' => 'Dispense error (0408)', 'level' => 'error'],
'0409' => ['label' => 'Dispense error (0409)', 'level' => 'error'],
'040A' => ['label' => 'Dispense error (040A)', 'level' => 'error'],
'0410' => ['label' => 'Elevator rising', 'level' => 'info'],
'0411' => ['label' => 'Elevator descending', 'level' => 'info'],
'0412' => ['label' => 'Elevator rise error', 'level' => 'error'],
'0413' => ['label' => 'Elevator descent error', 'level' => 'error'],
'0414' => ['label' => 'Pickup door closed', 'level' => 'info'],
'0415' => ['label' => 'Pickup door error', 'level' => 'error'],
'0416' => ['label' => 'Delivery door opened', 'level' => 'info'],
'0417' => ['label' => 'Delivery door open error', 'level' => 'error'],
'0418' => ['label' => 'Delivering product', 'level' => 'info'],
'0419' => ['label' => 'Delivery door closed', 'level' => 'info'],
'0420' => ['label' => 'Delivery door close error', 'level' => 'error'],
'0421' => ['label' => 'Hopper empty', 'level' => 'warning'],
'0422' => ['label' => 'Hopper overheated', 'level' => 'warning'],
'0423' => ['label' => 'Hopper heating timeout', 'level' => 'error'],
'0424' => ['label' => 'Hopper error (0424)', 'level' => 'error'],
'0426' => ['label' => 'Microwave door opened', 'level' => 'info'],
'0427' => ['label' => 'Microwave door error', 'level' => 'error'],
'04FF' => ['label' => 'Dispense stopped', 'level' => 'info'],
// 貨道狀態類 (Prefix: 02 - SLOT_STATUS)
'0201' => ['label' => 'Slot normal', 'level' => 'info'],
'0202' => ['label' => 'Product empty', 'level' => 'warning'],
'0203' => ['label' => 'Slot empty', 'level' => 'warning'],
'0206' => ['label' => 'Slot not closed', 'level' => 'warning'],
'0207' => ['label' => 'Slot motor error (0207)', 'level' => 'error'],
'0208' => ['label' => 'Slot motor error (0208)', 'level' => 'error'],
'0209' => ['label' => 'Slot motor error (0209)', 'level' => 'error'],
'0212' => ['label' => 'Hopper empty (0212)', 'level' => 'warning'],
// 機台整體狀態類 (Prefix: 54 - MACHINE_STATUS)
'5400' => ['label' => 'Machine normal', 'level' => 'info'],
'5401' => ['label' => 'Elevator sensor error', 'level' => 'error'],
'5402' => ['label' => 'Pickup door not closed', 'level' => 'warning'],
'5403' => ['label' => 'Elevator failure', 'level' => 'error'],
];
/** /**
* Update machine heartbeat and status. * Update machine heartbeat and status.
* *
@ -178,6 +228,36 @@ class MachineService
}); });
} }
/**
* B013: Record machine hardware error/status log with auto-translation.
*
* @param Machine $machine
* @param array $data
* @return MachineLog
*/
public function recordErrorLog(Machine $machine, array $data): MachineLog
{
$errorCode = $data['error_code'] ?? '0000';
$mapping = self::ERROR_CODE_MAP[$errorCode] ?? ['label' => 'Unknown Status', 'level' => 'error'];
$slotNo = $data['tid'] ?? null;
$label = $mapping['label'];
// 儲存原始英文格式作為 DB 備用,前端顯示會優先使用 model accessor 的動態翻譯內容
$message = $slotNo ? "Slot {$slotNo}: {$label} (Code: {$errorCode})" : "{$label} (Code: {$errorCode})";
return $machine->logs()->create([
'company_id' => $machine->company_id,
'type' => 'submachine',
'level' => $mapping['level'],
'message' => $message,
'context' => array_merge($data, [
'translated_label' => $label,
'raw_code' => $errorCode
]),
]);
}
/** /**
* Update machine slot stock (single slot). * Update machine slot stock (single slot).
* Legacy support for recordLog (Existing code). * Legacy support for recordLog (Existing code).

View File

@ -302,6 +302,54 @@ return [
] ]
], ],
'notes' => '運作邏輯 (Client-side Logic): GET 執行全量同步App 應於收到成功回應後,先執行 deleteAll() 再進行 insertAll()。PATCH 執行增量更新App 僅對記憶體中的既存商品進行欄位值覆蓋 (Patching)。' 'notes' => '運作邏輯 (Client-side Logic): GET 執行全量同步App 應於收到成功回應後,先執行 deleteAll() 再進行 insertAll()。PATCH 執行增量更新App 僅對記憶體中的既存商品進行欄位值覆蓋 (Patching)。'
],
[
'name' => 'B013: 機台故障與異常狀態上報 (Error/Status Report)',
'slug' => 'b013-error-report',
'method' => 'POST',
'path' => '/api/v1/app/machine/error/B013',
'description' => '用於接收機台發出的即時硬體狀態代碼(如卡貨、門未關)。身份由 Bearer Token 識別,回傳成功代表伺服器已將任務列入異步隊列處理。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'tid' => [
'type' => 'integer',
'required' => false,
'description' => '涉及到之具體貨道編號 (Slot No)',
'example' => 12
],
'error_code' => [
'type' => 'string',
'required' => true,
'description' => '硬體狀態代碼 (4 位 16 進位字串)',
'example' => '0403'
],
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '請求是否已成功接收',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
],
'request' => [
'tid' => 12,
'error_code' => '0403',
],
'response' => [
'success' => true,
'code' => 200,
'message' => 'Error report accepted'
],
'notes' => '硬體代碼對照表見後端 MachineService::ERROR_CODE_MAP 定義。
0402: 出貨成功, 0403: 貨道卡貨, 0202: 貨道缺貨, 0415: 取貨門異常...等。'
] ]
] ]
] ]

View File

@ -17,7 +17,7 @@
"Account updated successfully.": "Account updated successfully.", "Account updated successfully.": "Account updated successfully.",
"Account:": "Account:", "Account:": "Account:",
"accounts": "Account Management", "accounts": "Account Management",
"Accounts / Machines": "Accounts / Machines", "Accounts \/ Machines": "Accounts \/ Machines",
"Action": "Action", "Action": "Action",
"Actions": "Actions", "Actions": "Actions",
"Active": "Active", "Active": "Active",
@ -51,7 +51,7 @@
"Advertisement Management": "Advertisement Management", "Advertisement Management": "Advertisement Management",
"Advertisement updated successfully": "Advertisement updated successfully", "Advertisement updated successfully": "Advertisement updated successfully",
"Advertisement updated successfully.": "Advertisement updated successfully.", "Advertisement updated successfully.": "Advertisement updated successfully.",
"Advertisement Video/Image": "Advertisement Video/Image", "Advertisement Video\/Image": "Advertisement Video\/Image",
"Affiliated Company": "Affiliated Company", "Affiliated Company": "Affiliated Company",
"Affiliated Unit": "Company Name", "Affiliated Unit": "Company Name",
"Affiliation": "Company Name", "Affiliation": "Company Name",
@ -127,7 +127,7 @@
"Back to List": "Back to List", "Back to List": "Back to List",
"Badge Settings": "Badge Settings", "Badge Settings": "Badge Settings",
"Barcode": "Barcode", "Barcode": "Barcode",
"Barcode / Material": "Barcode / Material", "Barcode \/ Material": "Barcode \/ Material",
"Basic Information": "Basic Information", "Basic Information": "Basic Information",
"Basic Settings": "Basic Settings", "Basic Settings": "Basic Settings",
"Basic Specifications": "Basic Specifications", "Basic Specifications": "Basic Specifications",
@ -164,7 +164,7 @@
"Change": "Change", "Change": "Change",
"Change Stock": "Change Stock", "Change Stock": "Change Stock",
"Channel Limits": "Channel Limits", "Channel Limits": "Channel Limits",
"Channel Limits (Track/Spring)": "Channel Limits (Track/Spring)", "Channel Limits (Track\/Spring)": "Channel Limits (Track\/Spring)",
"Channel Limits Configuration": "Channel Limits Configuration", "Channel Limits Configuration": "Channel Limits Configuration",
"ChannelId": "ChannelId", "ChannelId": "ChannelId",
"ChannelSecret": "ChannelSecret", "ChannelSecret": "ChannelSecret",
@ -277,7 +277,7 @@
"Dispensing": "Dispensing", "Dispensing": "Dispensing",
"Duration": "Duration", "Duration": "Duration",
"Duration (Seconds)": "Duration (Seconds)", "Duration (Seconds)": "Duration (Seconds)",
"e.g. 500ml / 300g": "e.g. 500ml / 300g", "e.g. 500ml \/ 300g": "e.g. 500ml \/ 300g",
"e.g. John Doe": "e.g. John Doe", "e.g. John Doe": "e.g. John Doe",
"e.g. johndoe": "e.g. johndoe", "e.g. johndoe": "e.g. johndoe",
"e.g. Taiwan Star": "e.g. Taiwan Star", "e.g. Taiwan Star": "e.g. Taiwan Star",
@ -315,7 +315,7 @@
"Enable Material Code": "Enable Material Code", "Enable Material Code": "Enable Material Code",
"Enable Points": "Enable Points", "Enable Points": "Enable Points",
"Enabled": "Enabled", "Enabled": "Enabled",
"Enabled/Disabled": "Enabled/Disabled", "Enabled\/Disabled": "Enabled\/Disabled",
"End Date": "End Date", "End Date": "End Date",
"Engineer": "Engineer", "Engineer": "Engineer",
"English": "English", "English": "English",
@ -341,7 +341,7 @@
"Execution Time": "Execution Time", "Execution Time": "Execution Time",
"Exp": "Exp", "Exp": "Exp",
"Expired": "Expired", "Expired": "Expired",
"Expired / Disabled": "Expired / Disabled", "Expired \/ Disabled": "Expired \/ Disabled",
"Expiring": "Expiring", "Expiring": "Expiring",
"Expiry": "Expiry", "Expiry": "Expiry",
"Expiry Date": "Expiry Date", "Expiry Date": "Expiry Date",
@ -570,7 +570,7 @@
"Monthly cumulative revenue overview": "Monthly cumulative revenue overview", "Monthly cumulative revenue overview": "Monthly cumulative revenue overview",
"Monthly Transactions": "Monthly Transactions", "Monthly Transactions": "Monthly Transactions",
"Multilingual Names": "Multilingual Names", "Multilingual Names": "Multilingual Names",
"N/A": "N/A", "N\/A": "N\/A",
"Name": "Name", "Name": "Name",
"Name in English": "Name in English", "Name in English": "Name in English",
"Name in Japanese": "Name in Japanese", "Name in Japanese": "Name in Japanese",
@ -713,7 +713,7 @@
"Position": "Position", "Position": "Position",
"Preview": "Preview", "Preview": "Preview",
"Previous": "Previous", "Previous": "Previous",
"Price / Member": "Price / Member", "Price \/ Member": "Price \/ Member",
"Pricing Information": "Pricing Information", "Pricing Information": "Pricing Information",
"Product Count": "Product Count", "Product Count": "Product Count",
"Product created successfully": "Product created successfully", "Product created successfully": "Product created successfully",
@ -825,7 +825,7 @@
"Scale level and access control": "層級與存取控制", "Scale level and access control": "層級與存取控制",
"Scan this code to quickly access the maintenance form for this device.": "Scan this code to quickly access the maintenance form for this device.", "Scan this code to quickly access the maintenance form for this device.": "Scan this code to quickly access the maintenance form for this device.",
"Search accounts...": "Search accounts...", "Search accounts...": "Search accounts...",
"Search by name or S/N...": "Search by name or S/N...", "Search by name or S\/N...": "Search by name or S\/N...",
"Search cargo lane": "Search cargo lane", "Search cargo lane": "Search cargo lane",
"Search Company Title...": "Search Company Title...", "Search Company Title...": "Search Company Title...",
"Search company...": "Search company...", "Search company...": "Search company...",
@ -880,7 +880,6 @@
"Showing :from to :to of :total items": "Showing :from to :to of :total items", "Showing :from to :to of :total items": "Showing :from to :to of :total items",
"Sign in to your account": "Sign in to your account", "Sign in to your account": "Sign in to your account",
"Signed in as": "Signed in as", "Signed in as": "Signed in as",
"Slot": "Slot",
"Slot Mechanism (default: Conveyor, check for Spring)": "Slot Mechanism (default: Conveyor, check for Spring)", "Slot Mechanism (default: Conveyor, check for Spring)": "Slot Mechanism (default: Conveyor, check for Spring)",
"Slot No": "Slot No", "Slot No": "Slot No",
"Slot Status": "Slot Status", "Slot Status": "Slot Status",
@ -902,7 +901,7 @@
"Start Date": "Start Date", "Start Date": "Start Date",
"Statistics": "Statistics", "Statistics": "Statistics",
"Status": "Status", "Status": "Status",
"Status / Temp / Sub / Card / Scan": "Status / Temp / Sub / Card / Scan", "Status \/ Temp \/ Sub \/ Card \/ Scan": "Status \/ Temp \/ Sub \/ Card \/ Scan",
"Stock": "Stock", "Stock": "Stock",
"Stock & Expiry": "Stock & Expiry", "Stock & Expiry": "Stock & Expiry",
"Stock & Expiry Management": "Stock & Expiry Management", "Stock & Expiry Management": "Stock & Expiry Management",
@ -1052,5 +1051,73 @@
"You cannot delete your own account.": "You cannot delete your own account.", "You cannot delete your own account.": "You cannot delete your own account.",
"Your email address is unverified.": "Your email address is unverified.", "Your email address is unverified.": "Your email address is unverified.",
"Your recent account activity": "Your recent account activity", "Your recent account activity": "Your recent account activity",
"待填寫": "待填寫" "待填寫": "待填寫",
"Dispensing in progress": "Dispensing in progress",
"Dispense successful": "Dispense successful",
"Slot jammed": "Slot jammed",
"Motor not stopped": "Motor not stopped",
"Slot not found": "Slot not found",
"Dispense error (0407)": "Dispense error (0407)",
"Dispense error (0408)": "Dispense error (0408)",
"Dispense error (0409)": "Dispense error (0409)",
"Dispense error (040A)": "Dispense error (040A)",
"Elevator rising": "Elevator rising",
"Elevator descending": "Elevator descending",
"Elevator rise error": "Elevator rise error",
"Elevator descent error": "Elevator descent error",
"Pickup door closed": "Pickup door closed",
"Pickup door error": "Pickup door error",
"Delivery door opened": "Delivery door opened",
"Delivery door open error": "Delivery door open error",
"Delivering product": "Delivering product",
"Delivery door closed": "Delivery door closed",
"Delivery door close error": "Delivery door close error",
"Hopper empty": "Hopper empty",
"Hopper overheated": "Hopper overheated",
"Hopper heating timeout": "Hopper heating timeout",
"Hopper error (0424)": "Hopper error (0424)",
"Microwave door opened": "Microwave door opened",
"Microwave door error": "Microwave door error",
"Dispense stopped": "Dispense stopped",
"Slot normal": "Slot normal",
"Product empty": "Product empty",
"Slot empty": "Slot empty",
"Slot not closed": "Slot not closed",
"Slot motor error (0207)": "Slot motor error (0207)",
"Slot motor error (0208)": "Slot motor error (0208)",
"Slot motor error (0209)": "Slot motor error (0209)",
"Hopper empty (0212)": "Hopper empty (0212)",
"Machine normal": "Machine normal",
"Elevator sensor error": "Elevator sensor error",
"Pickup door not closed": "Pickup door not closed",
"Elevator failure": "Elevator failure",
"Slot": "Slot",
"Page 0": "Offline",
"Page 1": "Home",
"Page 2": "Vending",
"Page 3": "Admin",
"Page 4": "Restock",
"Page 5": "Tutorial",
"Page 6": "Purchasing",
"Page 7": "Locked",
"Page 60": "Dispense Success",
"Page 61": "Slot Test",
"Page 62": "Payment Selection",
"Page 63": "Waiting for Payment",
"Page 64": "Dispensing",
"Page 65": "Receipt",
"Page 66": "Passcode",
"Page 67": "Pickup Code",
"Page 68": "Message",
"Page 69": "Purchase Cancelled",
"Page 610": "Purchase Ended",
"Page 611": "Store Gift",
"Page 612": "Dispense Failed",
"Door Opened": "Door Opened",
"Door Closed": "Door Closed",
"Firmware updated to :version": "Firmware updated to :version",
"Model changed to :model": "Model changed to :model",
"User logged in: :name": "User logged in: :name",
"Login failed: :account": "Login failed: :account",
"Unauthorized login attempt: :account": "Unauthorized login attempt: :account"
} }

View File

@ -17,7 +17,7 @@
"Account updated successfully.": "アカウントが正常に更新されました。", "Account updated successfully.": "アカウントが正常に更新されました。",
"Account:": "アカウント:", "Account:": "アカウント:",
"accounts": "アカウント管理", "accounts": "アカウント管理",
"Accounts / Machines": "アカウント / 機体", "Accounts \/ Machines": "アカウント \/ 機体",
"Action": "操作", "Action": "操作",
"Actions": "操作", "Actions": "操作",
"Active": "有効", "Active": "有効",
@ -51,7 +51,7 @@
"Advertisement Management": "広告管理", "Advertisement Management": "広告管理",
"Advertisement updated successfully": "広告の更新に成功しました", "Advertisement updated successfully": "広告の更新に成功しました",
"Advertisement updated successfully.": "広告の更新に成功しました。", "Advertisement updated successfully.": "広告の更新に成功しました。",
"Advertisement Video/Image": "広告動画/画像", "Advertisement Video\/Image": "広告動画\/画像",
"Affiliated Company": "所属会社", "Affiliated Company": "所属会社",
"Affiliated Unit": "所属会社", "Affiliated Unit": "所属会社",
"Affiliation": "所属会社", "Affiliation": "所属会社",
@ -127,7 +127,7 @@
"Back to List": "リストに戻る", "Back to List": "リストに戻る",
"Badge Settings": "バッジ設定", "Badge Settings": "バッジ設定",
"Barcode": "バーコード", "Barcode": "バーコード",
"Barcode / Material": "バーコード / 素材", "Barcode \/ Material": "バーコード \/ 素材",
"Basic Information": "基本情報", "Basic Information": "基本情報",
"Basic Settings": "基本設定", "Basic Settings": "基本設定",
"Basic Specifications": "基本仕様", "Basic Specifications": "基本仕様",
@ -164,7 +164,7 @@
"Change": "変更", "Change": "変更",
"Change Stock": "在庫変更", "Change Stock": "在庫変更",
"Channel Limits": "チャネル制限", "Channel Limits": "チャネル制限",
"Channel Limits (Track/Spring)": "チャネル上限(トラック/スプリング)", "Channel Limits (Track\/Spring)": "チャネル上限(トラック\/スプリング)",
"Channel Limits Configuration": "チャネル制限設定", "Channel Limits Configuration": "チャネル制限設定",
"ChannelId": "チャネルID", "ChannelId": "チャネルID",
"ChannelSecret": "チャネルシークレット", "ChannelSecret": "チャネルシークレット",
@ -277,7 +277,7 @@
"Dispensing": "払い出し中", "Dispensing": "払い出し中",
"Duration": "再生時間", "Duration": "再生時間",
"Duration (Seconds)": "再生時間(秒)", "Duration (Seconds)": "再生時間(秒)",
"e.g. 500ml / 300g": "例: 500ml / 300g", "e.g. 500ml \/ 300g": "例: 500ml \/ 300g",
"e.g. John Doe": "例:山田太郎", "e.g. John Doe": "例:山田太郎",
"e.g. johndoe": "例yamadataro", "e.g. johndoe": "例yamadataro",
"e.g. Taiwan Star": "例Taiwan Star", "e.g. Taiwan Star": "例Taiwan Star",
@ -315,7 +315,7 @@
"Enable Material Code": "品目コードを有効にする", "Enable Material Code": "品目コードを有効にする",
"Enable Points": "ポイントを有効にする", "Enable Points": "ポイントを有効にする",
"Enabled": "有効", "Enabled": "有効",
"Enabled/Disabled": "有効/無効", "Enabled\/Disabled": "有効\/無効",
"End Date": "終了日", "End Date": "終了日",
"Engineer": "エンジニア", "Engineer": "エンジニア",
"English": "英語", "English": "英語",
@ -341,7 +341,7 @@
"Execution Time": "実行時間", "Execution Time": "実行時間",
"Exp": "有効期限", "Exp": "有効期限",
"Expired": "期限切れ", "Expired": "期限切れ",
"Expired / Disabled": "期限切れ / 無効", "Expired \/ Disabled": "期限切れ \/ 無効",
"Expiring": "期限間近", "Expiring": "期限間近",
"Expiry": "期限", "Expiry": "期限",
"Expiry Date": "有効期限", "Expiry Date": "有効期限",
@ -569,7 +569,7 @@
"Monthly cumulative revenue overview": "月間累積収益の概要", "Monthly cumulative revenue overview": "月間累積収益の概要",
"Monthly Transactions": "月間取引数", "Monthly Transactions": "月間取引数",
"Multilingual Names": "多言語名称", "Multilingual Names": "多言語名称",
"N/A": "N/A", "N\/A": "N\/A",
"Name": "名前", "Name": "名前",
"Name in English": "英語名", "Name in English": "英語名",
"Name in Japanese": "日本語名", "Name in Japanese": "日本語名",
@ -630,7 +630,7 @@
"OEE.Hours": "時間", "OEE.Hours": "時間",
"OEE.Orders": "注文数", "OEE.Orders": "注文数",
"OEE.Sales": "売上高", "OEE.Sales": "売上高",
"of": "/", "of": "\/",
"Offline": "オフライン", "Offline": "オフライン",
"Offline Machines": "オフライン機体", "Offline Machines": "オフライン機体",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "アカウントを削除すると、そのすべてのリソースとデータが完全に削除されます。削除する前に、保存しておきたいデータをダウンロードしてください。", "Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "アカウントを削除すると、そのすべてのリソースとデータが完全に削除されます。削除する前に、保存しておきたいデータをダウンロードしてください。",
@ -712,7 +712,7 @@
"Position": "位置", "Position": "位置",
"Preview": "プレビュー", "Preview": "プレビュー",
"Previous": "前へ", "Previous": "前へ",
"Price / Member": "価格 / 会員", "Price \/ Member": "価格 \/ 会員",
"Pricing Information": "価格情報", "Pricing Information": "価格情報",
"Product Count": "商品数", "Product Count": "商品数",
"Product created successfully": "商品が正常に作成されました", "Product created successfully": "商品が正常に作成されました",
@ -824,7 +824,7 @@
"Scale level and access control": "規模レベルとアクセス制御", "Scale level and access control": "規模レベルとアクセス制御",
"Scan this code to quickly access the maintenance form for this device.": "このコードをスキャンして、端末のメンテナンスフォームに素早くアクセスします。", "Scan this code to quickly access the maintenance form for this device.": "このコードをスキャンして、端末のメンテナンスフォームに素早くアクセスします。",
"Search accounts...": "アカウントを検索...", "Search accounts...": "アカウントを検索...",
"Search by name or S/N...": "名称または製造番号で検索...", "Search by name or S\/N...": "名称または製造番号で検索...",
"Search cargo lane": "貨道を検索", "Search cargo lane": "貨道を検索",
"Search Company Title...": "会社名を検索...", "Search Company Title...": "会社名を検索...",
"Search company...": "会社を検索...", "Search company...": "会社を検索...",
@ -901,7 +901,7 @@
"Start Date": "開始日", "Start Date": "開始日",
"Statistics": "統計", "Statistics": "統計",
"Status": "ステータス", "Status": "ステータス",
"Status / Temp / Sub / Card / Scan": "状態 / 温度 / 子機 / カード / スキャン", "Status \/ Temp \/ Sub \/ Card \/ Scan": "状態 \/ 温度 \/ 子機 \/ カード \/ スキャン",
"Stock": "在庫", "Stock": "在庫",
"Stock & Expiry": "在庫と消費期限", "Stock & Expiry": "在庫と消費期限",
"Stock & Expiry Management": "在庫・期限管理", "Stock & Expiry Management": "在庫・期限管理",
@ -1051,5 +1051,72 @@
"You cannot delete your own account.": "自分自身のアカウントを削除することはできません。", "You cannot delete your own account.": "自分自身のアカウントを削除することはできません。",
"Your email address is unverified.": "メールアドレスが未認証です。", "Your email address is unverified.": "メールアドレスが未認証です。",
"Your recent account activity": "最近のアカウントアクティビティ", "Your recent account activity": "最近のアカウントアクティビティ",
"待填寫": "待填寫" "待填寫": "待填寫",
"Dispensing in progress": "商品搬送中",
"Dispense successful": "搬送成功",
"Slot jammed": "貨道詰まり (K-PDT)",
"Motor not stopped": "モーター停止異常",
"Slot not found": "指定貨道が見つかりません",
"Dispense error (0407)": "搬送異常 (0407)",
"Dispense error (0408)": "搬送異常 (0408)",
"Dispense error (0409)": "搬送異常 (0409)",
"Dispense error (040A)": "搬送異常 (040A)",
"Elevator rising": "昇降機上昇中",
"Elevator descending": "昇降機下降中",
"Elevator rise error": "昇降機上昇異常",
"Elevator descent error": "昇降機下降異常",
"Pickup door closed": "取出口ドア閉鎖",
"Pickup door error": "取出口ドア異常",
"Delivery door opened": "配送口ドア開放",
"Delivery door open error": "配送口ドア開放異常",
"Delivering product": "商品配送中",
"Delivery door closed": "配送口ドア閉鎖",
"Delivery door close error": "配送口ドア閉鎖異常",
"Hopper empty": "ホッパー空",
"Hopper overheated": "ホッパー過熱",
"Hopper heating timeout": "ホッパー加熱タイムアウト",
"Hopper error (0424)": "ホッパー異常 (0424)",
"Microwave door opened": "電子レンジドア開放",
"Microwave door error": "電子レンジドア異常",
"Dispense stopped": "搬送停止",
"Slot normal": "貨道正常",
"Product empty": "品切れ (PDT_EMPTY)",
"Slot empty": "貨道空 (SLOT_EMPTY)",
"Slot not closed": "貨道未閉鎖",
"Slot motor error (0207)": "貨道モーター故障 (0207)",
"Slot motor error (0208)": "貨道モーター故障 (0208)",
"Slot motor error (0209)": "貨道モーター故障 (0209)",
"Hopper empty (0212)": "ホッパー空 (0212)",
"Machine normal": "システム正常",
"Elevator sensor error": "昇降センサー異常",
"Pickup door not closed": "取出口ドア未閉鎖",
"Elevator failure": "昇降システム故障",
"Page 0": "オフライン",
"Page 1": "ホーム",
"Page 2": "販売ページ",
"Page 3": "管理ページ",
"Page 4": "補充ページ",
"Page 5": "チュートリアル",
"Page 6": "購入中",
"Page 7": "ロック中",
"Page 60": "排出成功",
"Page 61": "スロットテスト",
"Page 62": "支払い方法選択",
"Page 63": "支払い待ち",
"Page 64": "排出中",
"Page 65": "レシート",
"Page 66": "パスコード",
"Page 67": "受取コード",
"Page 68": "メッセージ",
"Page 69": "購入キャンセル",
"Page 610": "購入完了",
"Page 611": "来店ギフト",
"Page 612": "排出失敗",
"Door Opened": "機ドア開放",
"Door Closed": "機ドア閉鎖",
"Firmware updated to :version": "ファームウェア更新::version",
"Model changed to :model": "モデル変更::model",
"User logged in: :name": "ユーザーログイン::name",
"Login failed: :account": "ログイン失敗::account",
"Unauthorized login attempt: :account": "不許可のログイン試行::account"
} }

View File

@ -17,7 +17,7 @@
"Account updated successfully.": "帳號已成功更新。", "Account updated successfully.": "帳號已成功更新。",
"Account:": "帳號:", "Account:": "帳號:",
"accounts": "帳號管理", "accounts": "帳號管理",
"Accounts / Machines": "帳號 / 機台", "Accounts \/ Machines": "帳號 \/ 機台",
"Action": "操作", "Action": "操作",
"Actions": "操作", "Actions": "操作",
"Active": "使用中", "Active": "使用中",
@ -51,7 +51,7 @@
"Advertisement Management": "廣告管理", "Advertisement Management": "廣告管理",
"Advertisement updated successfully": "廣告更新成功", "Advertisement updated successfully": "廣告更新成功",
"Advertisement updated successfully.": "廣告更新成功。", "Advertisement updated successfully.": "廣告更新成功。",
"Advertisement Video/Image": "廣告影片/圖片", "Advertisement Video\/Image": "廣告影片\/圖片",
"Affiliated Company": "公司名稱", "Affiliated Company": "公司名稱",
"Affiliated Unit": "公司名稱", "Affiliated Unit": "公司名稱",
"Affiliation": "所屬單位", "Affiliation": "所屬單位",
@ -127,7 +127,7 @@
"Back to List": "返回列表", "Back to List": "返回列表",
"Badge Settings": "識別證", "Badge Settings": "識別證",
"Barcode": "條碼", "Barcode": "條碼",
"Barcode / Material": "條碼 / 物料編碼", "Barcode \/ Material": "條碼 \/ 物料編碼",
"Basic Information": "基本資訊", "Basic Information": "基本資訊",
"Basic Settings": "基本設定", "Basic Settings": "基本設定",
"Basic Specifications": "基本規格", "Basic Specifications": "基本規格",
@ -164,7 +164,7 @@
"Change": "更換", "Change": "更換",
"Change Stock": "零錢庫存", "Change Stock": "零錢庫存",
"Channel Limits": "貨道上限", "Channel Limits": "貨道上限",
"Channel Limits (Track/Spring)": "貨道上限 (履帶/彈簧)", "Channel Limits (Track\/Spring)": "貨道上限 (履帶\/彈簧)",
"Channel Limits Configuration": "貨道上限配置", "Channel Limits Configuration": "貨道上限配置",
"ChannelId": "ChannelId", "ChannelId": "ChannelId",
"ChannelSecret": "ChannelSecret", "ChannelSecret": "ChannelSecret",
@ -277,7 +277,7 @@
"Dispensing": "出貨", "Dispensing": "出貨",
"Duration": "時長", "Duration": "時長",
"Duration (Seconds)": "播放秒數", "Duration (Seconds)": "播放秒數",
"e.g. 500ml / 300g": "例如500ml / 300g", "e.g. 500ml \/ 300g": "例如500ml \/ 300g",
"e.g. John Doe": "例如:張曉明", "e.g. John Doe": "例如:張曉明",
"e.g. johndoe": "例如xiaoming", "e.g. johndoe": "例如xiaoming",
"e.g. Taiwan Star": "例如:台灣之星", "e.g. Taiwan Star": "例如:台灣之星",
@ -315,7 +315,7 @@
"Enable Material Code": "啟用物料編號", "Enable Material Code": "啟用物料編號",
"Enable Points": "啟用點數規則", "Enable Points": "啟用點數規則",
"Enabled": "已啟用", "Enabled": "已啟用",
"Enabled/Disabled": "啟用/停用", "Enabled\/Disabled": "啟用\/停用",
"End Date": "截止日", "End Date": "截止日",
"Engineer": "維修人員", "Engineer": "維修人員",
"English": "英文", "English": "英文",
@ -341,7 +341,7 @@
"Execution Time": "執行時間", "Execution Time": "執行時間",
"Exp": "效期", "Exp": "效期",
"Expired": "已過期", "Expired": "已過期",
"Expired / Disabled": "已過期 / 停用", "Expired \/ Disabled": "已過期 \/ 停用",
"Expiring": "效期將屆", "Expiring": "效期將屆",
"Expiry": "效期", "Expiry": "效期",
"Expiry Date": "有效日期", "Expiry Date": "有效日期",
@ -570,7 +570,7 @@
"Monthly cumulative revenue overview": "本月累計營收概況", "Monthly cumulative revenue overview": "本月累計營收概況",
"Monthly Transactions": "本月交易統計", "Monthly Transactions": "本月交易統計",
"Multilingual Names": "多語系名稱", "Multilingual Names": "多語系名稱",
"N/A": "不適用", "N\/A": "不適用",
"Name": "名稱", "Name": "名稱",
"Name in English": "英文名稱", "Name in English": "英文名稱",
"Name in Japanese": "日文名稱", "Name in Japanese": "日文名稱",
@ -713,7 +713,7 @@
"Position": "投放位置", "Position": "投放位置",
"Preview": "預覽", "Preview": "預覽",
"Previous": "上一頁", "Previous": "上一頁",
"Price / Member": "售價 / 會員價", "Price \/ Member": "售價 \/ 會員價",
"Pricing Information": "價格資訊", "Pricing Information": "價格資訊",
"Product Count": "商品數量", "Product Count": "商品數量",
"Product created successfully": "商品已成功建立", "Product created successfully": "商品已成功建立",
@ -825,7 +825,7 @@
"Scale level and access control": "層級與存取控制", "Scale level and access control": "層級與存取控制",
"Scan this code to quickly access the maintenance form for this device.": "掃描此 QR Code 即可快速進入此設備的維修單填寫頁面。", "Scan this code to quickly access the maintenance form for this device.": "掃描此 QR Code 即可快速進入此設備的維修單填寫頁面。",
"Search accounts...": "搜尋帳號...", "Search accounts...": "搜尋帳號...",
"Search by name or S/N...": "搜尋名稱或序號...", "Search by name or S\/N...": "搜尋名稱或序號...",
"Search cargo lane": "搜尋貨道編號或商品名稱", "Search cargo lane": "搜尋貨道編號或商品名稱",
"Search Company Title...": "搜尋公司名稱...", "Search Company Title...": "搜尋公司名稱...",
"Search company...": "搜尋公司...", "Search company...": "搜尋公司...",
@ -880,7 +880,6 @@
"Showing :from to :to of :total items": "顯示第 :from 到 :to 項,共 :total 項", "Showing :from to :to of :total items": "顯示第 :from 到 :to 項,共 :total 項",
"Sign in to your account": "隨時隨地掌控您的業務。", "Sign in to your account": "隨時隨地掌控您的業務。",
"Signed in as": "登入身份", "Signed in as": "登入身份",
"Slot": "貨道",
"Slot Mechanism (default: Conveyor, check for Spring)": "貨道機制 (預設履帶,勾選為彈簧)", "Slot Mechanism (default: Conveyor, check for Spring)": "貨道機制 (預設履帶,勾選為彈簧)",
"Slot No": "貨道編號", "Slot No": "貨道編號",
"Slot Status": "貨道效期", "Slot Status": "貨道效期",
@ -902,7 +901,7 @@
"Start Date": "起始日", "Start Date": "起始日",
"Statistics": "數據統計", "Statistics": "數據統計",
"Status": "狀態", "Status": "狀態",
"Status / Temp / Sub / Card / Scan": "狀態 / 溫度 / 下位機 / 刷卡機 / 掃碼機", "Status \/ Temp \/ Sub \/ Card \/ Scan": "狀態 \/ 溫度 \/ 下位機 \/ 刷卡機 \/ 掃碼機",
"Stock": "庫存", "Stock": "庫存",
"Stock & Expiry": "庫存與效期", "Stock & Expiry": "庫存與效期",
"Stock & Expiry Management": "庫存與效期管理", "Stock & Expiry Management": "庫存與效期管理",
@ -1052,5 +1051,73 @@
"You cannot delete your own account.": "您無法刪除自己的帳號。", "You cannot delete your own account.": "您無法刪除自己的帳號。",
"Your email address is unverified.": "您的電子郵件地址尚未驗證。", "Your email address is unverified.": "您的電子郵件地址尚未驗證。",
"Your recent account activity": "最近的帳號活動", "Your recent account activity": "最近的帳號活動",
"待填寫": "待填寫" "待填寫": "待填寫",
"Dispensing in progress": "正在出貨中",
"Dispense successful": "出貨成功",
"Slot jammed": "貨道卡貨 (K-PDT)",
"Motor not stopped": "電機未停止",
"Slot not found": "找不到指定貨道",
"Dispense error (0407)": "出貨過程異常 (0407)",
"Dispense error (0408)": "出貨過程異常 (0408)",
"Dispense error (0409)": "出貨過程異常 (0409)",
"Dispense error (040A)": "出貨過程異常 (040A)",
"Elevator rising": "升降平台上升中",
"Elevator descending": "升降平台下降中",
"Elevator rise error": "升降平台上升異常",
"Elevator descent error": "升降平台下降異常",
"Pickup door closed": "取貨門已關閉",
"Pickup door error": "取貨門運作異常",
"Delivery door opened": "送貨門開啟",
"Delivery door open error": "送貨門開啟異常",
"Delivering product": "正在送出商品",
"Delivery door closed": "送貨門關閉",
"Delivery door close error": "送貨門關閉異常",
"Hopper empty": "料斗箱空",
"Hopper overheated": "料斗箱過熱",
"Hopper heating timeout": "料斗箱加熱逾時",
"Hopper error (0424)": "料斗箱異常 (0424)",
"Microwave door opened": "微波爐門開啟",
"Microwave door error": "微波爐門異常",
"Dispense stopped": "出貨停止",
"Slot normal": "貨道正常",
"Product empty": "貨道缺貨 (PDT_EMPTY)",
"Slot empty": "貨道空 (SLOT_EMPTY)",
"Slot not closed": "貨道未關閉",
"Slot motor error (0207)": "貨道電機故障 (0207)",
"Slot motor error (0208)": "貨道電機故障 (0208)",
"Slot motor error (0209)": "貨道電機故障 (0209)",
"Hopper empty (0212)": "料斗空 (0212)",
"Machine normal": "機台系統正常",
"Elevator sensor error": "升降箱感測異常",
"Pickup door not closed": "取貨門未關閉",
"Elevator failure": "升降系統故障",
"Slot": "貨道",
"Page 0": "離線",
"Page 1": "主頁面",
"Page 2": "販賣頁",
"Page 3": "管理頁",
"Page 4": "補貨頁",
"Page 5": "教學頁",
"Page 6": "購買中",
"Page 7": "鎖定頁",
"Page 60": "出貨成功",
"Page 61": "貨道測試",
"Page 62": "付款選擇",
"Page 63": "等待付款",
"Page 64": "出貨",
"Page 65": "收據簽單",
"Page 66": "通行碼",
"Page 67": "取貨碼",
"Page 68": "訊息顯示",
"Page 69": "取消購買",
"Page 610": "購買結束",
"Page 611": "來店禮",
"Page 612": "出貨失敗",
"Door Opened": "機門已開啟",
"Door Closed": "機門已關閉",
"Firmware updated to :version": "韌體版本更新::version",
"Model changed to :model": "型號變更::model",
"User logged in: :name": "使用者登入::name",
"Login failed: :account": "登入失敗::account",
"Unauthorized login attempt: :account": "越權登入嘗試::account"
} }

7
package-lock.json generated
View File

@ -5,6 +5,7 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"flatpickr": "^4.6.13",
"pptxgenjs": "^4.0.1" "pptxgenjs": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
@ -1506,6 +1507,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/flatpickr": {
"version": "4.6.13",
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
"license": "MIT"
},
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.15.11", "version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",

View File

@ -19,6 +19,7 @@
"vite": "^5.0.0" "vite": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"flatpickr": "^4.6.13",
"pptxgenjs": "^4.0.1" "pptxgenjs": "^4.0.1"
} }
} }

View File

@ -1,3 +1,5 @@
@import 'flatpickr/dist/flatpickr.min.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@ -155,6 +157,17 @@
[x-cloak] { [x-cloak] {
display: none !important; display: none !important;
} }
/* Hide scrollbar for Chrome, Safari and Opera */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
} }
@layer components { @layer components {
@ -318,3 +331,203 @@
@apply py-2.5 text-sm !important; @apply py-2.5 text-sm !important;
} }
} }
/* Flatpickr Luxury Theme Overrides */
.flatpickr-calendar {
border: 1px solid #e2e8f0 !important;
border-radius: 1.25rem !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important;
background: #ffffff !important;
padding: 4px !important;
}
.dark .flatpickr-calendar {
background: #1e293b !important;
border-color: #334155 !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5) !important;
}
.flatpickr-day {
color: #475569 !important;
border-radius: 12px !important;
font-weight: 500 !important;
}
.dark .flatpickr-day {
color: #cbd5e1 !important;
}
.flatpickr-day.prevMonthDay,
.flatpickr-day.nextMonthDay {
color: #cbd5e1 !important;
opacity: 0.3;
}
.dark .flatpickr-day.prevMonthDay,
.dark .flatpickr-day.nextMonthDay {
color: #475569 !important;
opacity: 0.8;
}
.flatpickr-day.today {
border-color: #06b6d4 !important;
color: #06b6d4 !important;
}
.flatpickr-day.selected {
background: linear-gradient(135deg, #06b6d4, #3b82f6) !important;
border-color: transparent !important;
color: white !important;
box-shadow: 0 8px 15px -3px rgba(6, 182, 212, 0.4) !important;
}
.flatpickr-day:not(.selected):hover {
background: #f1f5f9 !important;
}
.dark .flatpickr-day:not(.selected):hover {
background: #334155 !important;
}
/* Weekdays & Header */
.flatpickr-weekday {
color: #94a3b8 !important;
font-weight: 800 !important;
font-size: 11px !important;
}
.dark .flatpickr-weekday {
color: #475569 !important;
}
.flatpickr-months .flatpickr-month {
color: #1e293b !important;
fill: currentColor !important;
}
.dark .flatpickr-months .flatpickr-month {
color: #f8fafc !important;
}
.flatpickr-prev-month,
.flatpickr-next-month {
color: #1e293b !important;
fill: currentColor !important;
@apply transition-colors duration-200 !important;
}
.dark .flatpickr-prev-month,
.dark .flatpickr-next-month {
color: #cbd5e1 !important;
}
.flatpickr-prev-month:hover,
.flatpickr-next-month:hover {
color: #06b6d4 !important;
}
.flatpickr-current-month .flatpickr-monthDropdown-months {
font-weight: 800 !important;
@apply bg-transparent dark:bg-slate-800 text-slate-900 dark:text-slate-100 !important;
border: none !important;
border-radius: 6px !important;
padding: 2px 4px !important;
cursor: pointer !important;
}
.flatpickr-monthDropdown-month {
@apply bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 !important;
}
/* Time Section */
.flatpickr-time {
border-top: 1px solid #f1f5f9 !important;
margin-top: 8px !important;
padding-top: 8px !important;
}
.dark .flatpickr-time {
border-top-color: #334155 !important;
}
.flatpickr-time input {
color: #1e293b !important;
font-weight: 800 !important;
border-radius: 8px !important;
}
.dark .flatpickr-time input {
color: #f8fafc !important;
}
.flatpickr-time input:hover,
.flatpickr-time input:focus {
background: #f1f5f9 !important;
}
.dark .flatpickr-time input:hover,
.dark .flatpickr-time input:focus {
background: #334155 !important;
}
.dark .flatpickr-time .flatpickr-am-pm {
color: #f8fafc !important;
}
/* Time Stepper Arrows & Separator */
.flatpickr-time .numInputWrapper span.arrowUp:after {
border-bottom-color: #94a3b8 !important;
}
.flatpickr-time .numInputWrapper span.arrowUp:hover:after {
border-bottom-color: #06b6d4 !important;
}
.flatpickr-time .numInputWrapper span.arrowDown:after {
border-top-color: #94a3b8 !important;
}
.flatpickr-time .numInputWrapper span.arrowDown:hover:after {
border-top-color: #06b6d4 !important;
}
.dark .flatpickr-time .numInputWrapper span.arrowUp:after {
border-bottom-color: #64748b !important;
}
.dark .flatpickr-time .numInputWrapper span.arrowUp:hover:after {
border-bottom-color: #06b6d4 !important;
}
.dark .flatpickr-time .numInputWrapper span.arrowDown:after {
border-top-color: #64748b !important;
}
.dark .flatpickr-time .numInputWrapper span.arrowDown:hover:after {
border-top-color: #06b6d4 !important;
}
.flatpickr-time .numInputWrapper span {
border-color: #f1f5f9 !important;
}
.dark .flatpickr-time .numInputWrapper span {
border-color: #334155 !important;
background: transparent !important;
}
.dark .flatpickr-time .numInputWrapper span:hover {
background: #334155 !important;
}
.flatpickr-time .flatpickr-time-separator {
color: #94a3b8 !important;
}
.dark .flatpickr-time .flatpickr-time-separator {
color: #64748b !important;
}
.flatpickr-weekdays {
background: transparent !important;
}

View File

@ -3,11 +3,32 @@ import './bootstrap';
import Alpine from 'alpinejs'; import Alpine from 'alpinejs';
import collapse from '@alpinejs/collapse'; import collapse from '@alpinejs/collapse';
// 初始化 Preline UI
import 'preline';
// 引入 Flatpickr 與語系
import flatpickr from "flatpickr";
import { MandarinTraditional } from "flatpickr/dist/l10n/zh-tw.js";
import { Japanese } from "flatpickr/dist/l10n/ja.js";
const docLang = document.documentElement.lang.toLowerCase();
if (docLang.includes('zh')) {
flatpickr.localize(MandarinTraditional);
window.flatpickrLocale = MandarinTraditional;
} else if (docLang.includes('ja')) {
flatpickr.localize(Japanese);
window.flatpickrLocale = Japanese;
} else {
// English is the default in flatpickr
window.flatpickrLocale = 'default';
}
window.flatpickr = flatpickr;
Alpine.plugin(collapse); Alpine.plugin(collapse);
window.Alpine = Alpine; window.Alpine = Alpine;
// 確保其他套件都初始化完成後再啟動 Alpine
Alpine.start(); Alpine.start();
// 初始化 Preline UI
import 'preline';

View File

@ -95,7 +95,10 @@ $roleSelectConfig = [
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg> </svg>
</span> </span>
<input type="text" name="search" value="{{ request('search') }}" class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input" placeholder="{{ __('Search roles...') }}"> <input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search roles...') }}"
@keydown.enter="$el.form.submit()">
</div> </div>
@if(auth()->user()->isSystemAdmin()) @if(auth()->user()->isSystemAdmin())
@ -106,6 +109,7 @@ $roleSelectConfig = [
</div> </div>
@endif @endif
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}"> <input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
<button type="submit" class="hidden"></button>
</form> </form>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@ -211,7 +215,8 @@ $roleSelectConfig = [
</span> </span>
<input type="text" name="search" value="{{ request('search') }}" <input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input" class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search users...') }}"> placeholder="{{ __('Search users...') }}"
@keydown.enter="$el.form.submit()">
</div> </div>
@if(auth()->user()->isSystemAdmin()) @if(auth()->user()->isSystemAdmin())
@ -222,6 +227,7 @@ $roleSelectConfig = [
</div> </div>
@endif @endif
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}"> <input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
<button type="submit" class="hidden"></button>
</form> </form>
<div class="overflow-x-auto"> <div class="overflow-x-auto">

View File

@ -24,17 +24,17 @@
selectedMachine: null, selectedMachine: null,
slots: [], slots: [],
inventorySlots: [], inventorySlots: [],
currentPage: 1,
lastPage: 1,
init() { init() {
const d = new Date(); const now = new Date();
const today = [ const pad = (n) => String(n).padStart(2, '0');
d.getFullYear(), const formatDate = (date, time) => `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${time}`;
String(d.getMonth() + 1).padStart(2, '0'),
String(d.getDate()).padStart(2, '0') this.startDate = formatDate(now, '00:00');
].join('-'); this.endDate = formatDate(now, '23:59');
this.startDate = today; this.$watch('activeTab', () => this.fetchLogs(1));
this.endDate = today;
this.$watch('activeTab', () => this.fetchLogs());
}, },
async openLogPanel(id, sn, name) { async openLogPanel(id, sn, name) {
@ -54,15 +54,21 @@
}, },
async fetchLogs() { async fetchLogs(page = 1) {
this.loading = true; this.loading = true;
this.currentPage = page;
try { try {
let url = '/admin/machines/' + this.currentMachineId + '/logs-ajax?type=' + this.activeTab; let url = '/admin/machines/' + this.currentMachineId + '/logs-ajax?type=' + this.activeTab + '&page=' + page;
if (this.startDate) url += '&start_date=' + this.startDate; if (this.startDate) url += '&start_date=' + this.startDate;
if (this.endDate) url += '&end_date=' + this.endDate; if (this.endDate) url += '&end_date=' + this.endDate;
const res = await fetch(url); const res = await fetch(url);
const data = await res.json(); const data = await res.json();
if (data.success) this.logs = data.data.data || data.data || []; if (data.success) {
this.logs = data.data || [];
this.currentPage = data.pagination.current_page;
this.lastPage = data.pagination.last_page;
}
} catch (e) { console.error('fetchLogs error:', e); } } catch (e) { console.error('fetchLogs error:', e); }
finally { this.loading = false; } finally { this.loading = false; }
}, },
@ -100,6 +106,13 @@
finally { this.inventoryLoading = false; } finally { this.inventoryLoading = false; }
}, },
formatDateTime(dateStr) {
if (!dateStr) return '--';
const d = new Date(dateStr);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
},
getSlotColorClass(slot) { getSlotColorClass(slot) {
if (!slot.expiry_date) return 'bg-slate-50/50 dark:bg-slate-800/50 text-slate-400 border-slate-200/60 dark:border-slate-700/50'; if (!slot.expiry_date) return 'bg-slate-50/50 dark:bg-slate-800/50 text-slate-400 border-slate-200/60 dark:border-slate-700/50';
const todayStr = new Date().toISOString().split('T')[0]; const todayStr = new Date().toISOString().split('T')[0];
@ -390,19 +403,35 @@
<label <label
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em] whitespace-nowrap">{{ class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em] whitespace-nowrap">{{
__('From') }}</label> __('From') }}</label>
<input type="date" x-model="startDate" @change="fetchLogs()" <input type="text" x-ref="startDatePicker" x-model="startDate"
class="luxury-input text-[11px] h-9 sm:h-8 py-0 w-full sm:w-32 bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700"> x-init="flatpickr($refs.startDatePicker, {
enableTime: true,
dateFormat: 'Y/m/d H:i',
time_24hr: true,
locale: window.flatpickrLocale,
defaultDate: startDate,
onClose: (selectedDates, dateStr) => { startDate = dateStr; fetchLogs(1); }
})"
class="luxury-input text-[11px] h-9 sm:h-8 py-0 w-full sm:w-44 bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700">
</div> </div>
<div class="flex flex-col sm:flex-row sm:items-center gap-1.5 sm:gap-2"> <div class="flex flex-col sm:flex-row sm:items-center gap-1.5 sm:gap-2">
<label <label
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em] whitespace-nowrap">{{ class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em] whitespace-nowrap">{{
__('To') }}</label> __('To') }}</label>
<input type="date" x-model="endDate" @change="fetchLogs()" <input type="text" x-ref="endDatePicker" x-model="endDate"
class="luxury-input text-[11px] h-9 sm:h-8 py-0 w-full sm:w-32 bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700"> x-init="flatpickr($refs.endDatePicker, {
enableTime: true,
dateFormat: 'Y/m/d H:i',
time_24hr: true,
locale: window.flatpickrLocale,
defaultDate: endDate,
onClose: (selectedDates, dateStr) => { endDate = dateStr; fetchLogs(1); }
})"
class="luxury-input text-[11px] h-9 sm:h-8 py-0 w-full sm:w-44 bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700">
</div> </div>
</div> </div>
<div class="flex justify-start sm:justify-end"> <div class="flex justify-start sm:justify-end">
<button @click="startDate = ''; endDate = ''; fetchLogs()" <button @click="startDate = ''; endDate = ''; fetchLogs(1)"
class="text-[10px] font-bold text-cyan-600 dark:text-cyan-400 uppercase tracking-widest hover:text-cyan-500 transition-colors flex items-center gap-1.5"> class="text-[10px] font-bold text-cyan-600 dark:text-cyan-400 uppercase tracking-widest hover:text-cyan-500 transition-colors flex items-center gap-1.5">
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5"> stroke-width="2.5">
@ -419,7 +448,7 @@
<!-- Body / Navigation Tabs --> <!-- Body / Navigation Tabs -->
<div class="flex-1 flex flex-col min-h-0"> <div class="flex-1 flex flex-col min-h-0">
<div <div
class="border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 sticky top-0 z-10 px-6 sm:px-8 overflow-x-auto hide-scrollbar"> class="border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 sticky top-0 z-10 px-6 sm:px-8 overflow-x-auto overflow-y-hidden hide-scrollbar">
<nav class="-mb-px flex space-x-6 sm:space-x-8" aria-label="Tabs"> <nav class="-mb-px flex space-x-6 sm:space-x-8" aria-label="Tabs">
<button @click="activeTab = 'status'" <button @click="activeTab = 'status'"
:class="{'border-cyan-500 text-cyan-600 dark:text-cyan-400': activeTab === 'status', 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:hover:text-slate-300': activeTab !== 'status'}" :class="{'border-cyan-500 text-cyan-600 dark:text-cyan-400': activeTab === 'status', 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:hover:text-slate-300': activeTab !== 'status'}"
@ -486,7 +515,7 @@
class="group hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-all"> class="group hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-all">
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="text-[12px] font-bold text-slate-600 dark:text-slate-300" <div class="text-[12px] font-bold text-slate-600 dark:text-slate-300"
x-text="new Date(log.created_at).toLocaleString()"> x-text="formatDateTime(log.created_at)">
</div> </div>
</td> </td>
<td class="px-6 py-4 text-center"> <td class="px-6 py-4 text-center">
@ -501,7 +530,7 @@
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<p class="text-[13px] font-medium text-slate-700 dark:text-slate-200 truncate max-w-md" <p class="text-[13px] font-medium text-slate-700 dark:text-slate-200 truncate max-w-md"
:title="log.message" x-text="log.message"></p> :title="log.translated_message || log.message" x-text="log.translated_message || log.message"></p>
</td> </td>
</tr> </tr>
</template> </template>
@ -517,7 +546,7 @@
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span <span
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest" class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest"
x-text="new Date(log.created_at).toLocaleString()"></span> x-text="formatDateTime(log.created_at)"></span>
<span :class="{ <span :class="{
'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/20': log.level === 'info', 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/20': log.level === 'info',
'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20': log.level === 'warning', 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20': log.level === 'warning',
@ -528,7 +557,7 @@
</span> </span>
</div> </div>
<p class="text-[13px] font-bold text-slate-700 dark:text-slate-200 line-clamp-3 leading-relaxed" <p class="text-[13px] font-bold text-slate-700 dark:text-slate-200 line-clamp-3 leading-relaxed"
x-text="log.message"></p> x-text="log.translated_message || log.message"></p>
</div> </div>
</template> </template>
</div> </div>
@ -554,6 +583,33 @@
</div> </div>
</div> </div>
</template> </template>
<!-- Pagination Footer -->
<div x-show="logs.length > 0" class="px-6 py-4 bg-slate-50/50 dark:bg-slate-800/30 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between">
<div class="flex items-center gap-4">
<button @click="fetchLogs(currentPage - 1)"
:disabled="currentPage <= 1"
class="p-2 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-400 disabled:opacity-30 disabled:cursor-not-allowed hover:text-cyan-500 hover:border-cyan-500/30 transition-all shadow-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="flex items-center gap-1.5">
<span class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-widest" x-text="currentPage"></span>
<span class="text-[10px] font-black text-slate-300 dark:text-slate-600 uppercase">/</span>
<span class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest" x-text="lastPage"></span>
</div>
<button @click="fetchLogs(currentPage + 1)"
:disabled="currentPage >= lastPage"
class="p-2 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-400 disabled:opacity-30 disabled:cursor-not-allowed hover:text-cyan-500 hover:border-cyan-500/30 transition-all shadow-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -61,7 +61,10 @@
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg> </svg>
</span> </span>
<input type="text" name="search" value="{{ request('search') }}" class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input" placeholder="{{ __('Search roles...') }}"> <input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search roles...') }}"
@keydown.enter="$el.form.submit()">
</div> </div>
@if(auth()->user()->isSystemAdmin()) @if(auth()->user()->isSystemAdmin())
@ -79,6 +82,7 @@
</div> </div>
@endif @endif
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}"> <input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
<button type="submit" class="hidden"></button>
</form> </form>
</div> </div>

View File

@ -68,6 +68,9 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
// 統一商品主檔 API (B012 整合版) // 統一商品主檔 API (B012 整合版)
Route::match(['get', 'patch'], 'machine/products/B012', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getProducts']); Route::match(['get', 'patch'], 'machine/products/B012', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getProducts']);
// 機台故障與異常上報 (B013)
Route::post('machine/error/B013', [App\Http\Controllers\Api\V1\App\MachineController::class, 'reportError']);
// 交易、發票與出貨 (B600, B601, B602) // 交易、發票與出貨 (B600, B601, B602)
Route::post('machine/restock/B018', [App\Http\Controllers\Api\V1\App\MachineController::class, 'recordRestock']); Route::post('machine/restock/B018', [App\Http\Controllers\Api\V1\App\MachineController::class, 'recordRestock']);
Route::post('B600', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'store']); Route::post('B600', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'store']);