[FEAT] 優化機台硬體通訊協議與管理介面互動性
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m6s

1. 修復帳號管理與角色權限頁面搜尋功能,支援 Enter 鍵快捷提交。
2. 完成 B013 (機台故障上報) API 實作,改用非同步隊列 (ProcessMachineError) 處理日誌上報。
3. 精簡 B013 API 參數,移除冗餘的 message 欄位,統一由雲端對照表翻譯。
4. 更新技術規格文件 (SKILL.md) 與系統 API 文件配置 (api-docs.php)。
5. 修正平台管理員帳號在搜尋過濾時的資料隔離邏輯。
This commit is contained in:
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,8 +318,12 @@ class PermissionController extends Controller
} }
if ($user->isSystemAdmin() && $request->filled('company_id')) { if ($user->isSystemAdmin() && $request->filled('company_id')) {
if ($request->company_id === 'system') {
$query->whereNull('company_id');
} else {
$query->where('company_id', $request->company_id); $query->where('company_id', $request->company_id);
} }
}
$per_page = $request->input('per_page', 10); $per_page = $request->input('per_page', 10);
$users = $query->latest()->paginate($per_page)->withQueryString(); $users = $query->latest()->paginate($per_page)->withQueryString();

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
{ {
@@ -27,23 +28,7 @@ class MachineAuthController extends Controller
'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)
if ($user->isSystemAdmin()) { $user = User::where('username', $validated['Su_Account'])
// [系統管理員] : 擁有最高權限,可登入平台下轄所有機台,直接放行 ->orWhere('email', $validated['Su_Account'])
->first();
} elseif ($user->is_admin) { // 4. 驗證密碼
// [公司管理員] : 不需要檢查 machine_user 表,但【必須驗證】該機台是否隸屬於他的公司 if (!$user || !Hash::check($validated['Su_Password'], $user->password)) {
if ($machine->company_id !== $user->company_id) { Log::warning("B000 機台登入失敗: 帳密錯誤", [
Log::warning("B000 機台登入失敗: 企圖越權登入其他公司的機台", [ 'account' => $validated['Su_Account'],
'user_id' => $user->id, 'machine' => $validated['machine']
'user_company' => $user->company_id,
'machine_company' => $machine->company_id
]); ]);
return response()->json(['message' => 'Forbidden']);
// 寫入機台日誌
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()) {
$isAuthorized = true;
} elseif ($user->is_admin) {
if ($machine->company_id === $user->company_id) {
$isAuthorized = true;
}
} 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 機台登入失敗: 該帳號沒有此機台的授權", [ }
}
if (!$isAuthorized) {
Log::warning("B000 機台登入失敗: 權限不足", [
'user_id' => $user->id, 'user_id' => $user->id,
'machine_id' => $machine->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']); return response()->json(['message' => 'Forbidden']);
} }
}
// 5. 驗證完美通過!回傳固定字串 Success 讓 Java 端放行 // 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']);