[FEAT] 完善 IoT API 規范化、機台管理介面優化與 B005 改為 GET
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m4s

1. 將 B005 (廣告同步) 從 POST 改為 GET,符合 RESTful 規範。
2. 完善 B009 (庫存回報) 回應規格,加入業務代碼 (200 OK)。
3. API 文件 UI 優化:新增 Method Badge (方法標籤),並修正 JSON 中文/斜線轉義問題。
4. 機台管理介面優化:實作「唯讀庫存與效期」面板,並將日誌圖示改為「👁️」。
5. 標準化 ID 識別邏輯:資料表全面移除對 sku 的依賴,改以 id 為主、barcode 為輔。
6. 新增 Migration:正式移除 sku 欄位並同步 barcode 指向。
7. 更新多語系支援 (zh_TW, en, ja)。
This commit is contained in:
2026-04-07 14:37:57 +08:00
parent b60afc3abe
commit f2147ae6c4
14 changed files with 548 additions and 85 deletions

View File

@@ -41,7 +41,68 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
---
### 3.2 B010: 心跳上報與狀態同步
### 3.2 B005: 廣告清單同步
用於機台端獲取目前應播放的廣告檔案 URL 清單。
- **URL**: `GET /api/v1/app/machine/ad/B005`
- **Request Body:** 無 (GET 請求)
- **Response Body:**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| `success` | Boolean | 請求是否成功 | `true` |
| `code` | Integer | 內部業務狀態碼 | `200` |
| `data` | Array | 廣告物件陣列 | `[{"t070v04": "https://..."}]` |
**data 陣列內部欄位:**
- `t070v01`: 廣告名稱 (Name)
- `t070v02`: 播放長度 (Duration) — 秒數,若後台未設定,預設為 15 秒。
- `t070v03`: 廣告位置 (Position/Flag) — (`3`: 待機廣告, `1`: 販賣頁, `2`: 來店禮)。
- `t070v04`: 廣告 URL。
- `t070v05`: 播放順位 (Sort Order)。
---
### 3.3 B009: 貨道庫存即時回報 (Supplementary Report)
當維修或補貨人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。
#### B009 權限驗證邏輯 (RBAC Compliance)
系統會依據 `account` 欄位進行三層式權限核查:
1. **系統層 (System Admin)**:當 `company_id``null` 時,具備全局管理權限,直接放行。
2. **公司層 (Company Admin)**:當 `is_admin``true` 時,檢查機台的 `company_id` 是否與該帳號一致。
3. **人員層 (Operator/User)**:當帳號僅為一般人員時,檢查 `machine_user` 授權表,確認該帳號有被分配至此機台。
- **URL**: `PUT /api/v1/app/products/supplementary/B009`
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| `account` | String | 是 | 操作人員帳號 | `0999123456` |
| `data` | Array | 是 | 貨道數據陣列 | `[{"tid":"1", "t060v00":"1", "num":"10"}]` |
- **data 陣列內部欄位:**
- `tid`: 貨道編號 (Slot No)
- `t060v00`: **商品資料庫 ID**
- `num`: 實體剩餘庫存數量
- **Response Body (Success 200):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| `success` | Boolean | 同步是否成功 | `true` |
| `code` | Integer | 內部業務狀態碼 | `200` |
| `message` | String | 回應訊息 | `Slot report synchronized success` |
| `status` | String | 固定回傳 `49` 代表同步完成 | `49` |
- **Response Body (Forbidden 403):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| `success` | Boolean | 同步失敗 | `false` |
| `status` | String | 回傳空字串 `""` 防止觸發指令 | `""` |
| `code` | Integer | HTTP 狀態碼 | `403` |
| `message` | String | 錯誤訊息 | `Unauthorized` |
---
### 3.4 B010: 心跳上報與狀態同步
用於確認機台在線狀態、更新感測數據、提交事件日誌並獲取雲端指令。
- **URL**: `POST /api/v1/app/machine/status/B010`
@@ -85,60 +146,3 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- `71`: lock (鎖定)
- `85`: reload B0552 (遠端出貨)
- `待定義`: change (遠端找零 - 註:指令中心有此功能,但目前 Java App 尚無對接對應的連動事件)
---
### 3.3 B017: 貨道與庫存同步
- **URL**: `POST /api/v1/app/machine/reload_msg/B017`
- **說明**:當機台收到 B010 回應 `status: 49` 時,呼叫此此 API 獲取雲端最新的貨道佈局與庫存設定。
---
### 3.4 B005: 廣告清單同步
用於機台端獲取目前應播放的廣告檔案 URL 清單。
- **URL**: `POST /api/v1/app/machine/ad/B005`
- **Request Body:** (無須額外 Body 參數,僅需傳送空 JSON `{}`)
- **Response Body:**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| `success` | Boolean | 請求是否成功 | `true` |
| `data` | Array | 廣告物件陣列 | `[{"t070v04": "https://..."}]` |
**data 陣列內部欄位:**
- `t070v01`: 廣告名稱 (Name)
- `t070v02`: 播放長度 (Duration) — 秒數,若後台未設定,預設為 15 秒。
- `t070v03`: 廣告位置 (Position/Flag) — (`3`: 待機廣告, `1`: 販賣頁, `2`: 來店禮)。
- `t070v04`: 廣告 URL。
- `t070v05`: 播放順位 (Sort Order)。
---
### 3.5 B009: 貨道庫存即時回報 (Supplementary Report)
當維修或補貨人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。
- **URL**: `PUT /api/v1/app/products/supplementary/B009`
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| `account` | String | 是 | 操作人員帳號 | `technician_01` |
| `vmcType` | String | 否 | VMC 韌體/機型類別 | `XinYuan` |
| `data` | Array | 貨道數據陣列 | `[{"tid":"1", "t060v00":"SKU001", "num":"10"}]` |
- **data 陣列內部欄位:**
- `tid`: 貨道編號 (Slot No)
- `t060v00`: 商品編碼 (SKU / Product Code)
- `num`: 實體剩餘庫存數量
- **Response Body:**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| `success` | Boolean | 同步是否成功 | `true` |
| `status` | String | 固定回傳 `49` 代表已處理 | `49` |
---
### 3.6 B600: 交易數據回傳 (規劃中)
- **URL**: `POST /api/v1/app/B600`
- 說明:交易完成後提交支付方式、金額、商品與出貨結果。

View File

@@ -5,9 +5,11 @@ namespace App\Http\Controllers\Api\V1\App;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Jobs\Machine\ProcessHeartbeat;
use App\Jobs\Machine\ProcessTimerStatus;
use App\Jobs\Machine\ProcessCoinInventory;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
class MachineController extends Controller
@@ -203,9 +205,11 @@ class MachineController extends Controller
$machine = $request->get('machine');
$advertisements = \App\Models\Machine\MachineAdvertisement::where('machine_id', $machine->id)
->with(['advertisement' => function ($query) {
->with([
'advertisement' => function ($query) {
$query->active();
}])
}
])
->get()
->filter(fn($ma) => $ma->advertisement !== null)
->map(function ($ma) {
@@ -260,10 +264,51 @@ class MachineController extends Controller
{
$machine = $request->get('machine');
$payload = $request->all();
$account = $payload['account'] ?? null;
// 映射舊版機台回傳格式 (Map legacy machine format)
// t060v00 -> product_id, num -> stock, tid -> slot_no
// 1. 驗證帳號是否存在 (驗證執行補貨的人員身分)
$user = User::where('username', $account)
->orWhere('email', $account)
->first();
if (!$user) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Unauthorized: Account not found',
'status' => ''
], 403);
}
// 2. 階層式權限驗證 (遵循 RBAC 多租戶規範)
$isAuthorized = false;
if ($user->isSystemAdmin()) {
// [系統層]:系統管理員可異動所有機台
$isAuthorized = true;
} elseif ($user->is_admin) {
// [公司層]:公司管理員需驗證此機台是否隸屬於該公司
$isAuthorized = ($machine->company_id === $user->company_id);
} else {
// [人員層]:一般人員需檢查 machine_user 授權表
$isAuthorized = $user->machines()->where('machine_id', $machine->id)->exists();
}
if (!$isAuthorized) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Unauthorized: Account not authorized for this machine',
'status' => ''
], 403);
}
// 3. 映射舊版機台回傳格式 (Map legacy machine format)
// 支持單個物件 data: {} 或 陣列 data: [{}] (Handle both single object and array)
$legacyData = $payload['data'] ?? [];
if (Arr::isAssoc($legacyData)) {
$legacyData = [$legacyData];
}
$mappedSlots = array_map(function ($item) {
return [
'slot_no' => $item['tid'] ?? null,

View File

@@ -16,7 +16,6 @@ class Product extends Model
'category_id',
'name',
'name_dictionary_key',
'sku',
'barcode',
'spec',
'manufacturer',

View File

@@ -14,7 +14,7 @@ class OrderItem extends Model
'order_id',
'product_id',
'product_name',
'sku',
'barcode',
'price',
'quantity',
'subtotal',

View File

@@ -67,11 +67,10 @@ class MachineService
// 蒐集所有傳入的商品 ID (可能是 SKU 或 實際 ID)
$productCodes = collect($slotsData)->pluck('product_id')->filter()->unique()->toArray();
// 批次查詢商品 (支援以 SKU 查詢,確保對應至資料庫 ID)
$products = \App\Models\Product\Product::whereIn('sku', $productCodes)
->orWhereIn('id', $productCodes)
->get()
->keyBy(fn($p) => $p->sku ?: $p->id);
// 優先以 ID 查詢商品,若 ID 不存在則嘗試 Barcode (Prioritize ID lookup, fallback to Barcode)
$products = \App\Models\Product\Product::whereIn('id', $productCodes)
->orWhereIn('barcode', $productCodes)
->get();
foreach ($slotsData as $slotData) {
$slotNo = $slotData['slot_no'] ?? null;
@@ -79,11 +78,13 @@ class MachineService
$existingSlot = $machine->slots()->where('slot_no', $slotNo)->first();
// 查找對應的實體 ID
// 查找對應的實體 ID (支援 ID 與 Barcode 比對)
$productCode = $slotData['product_id'] ?? null;
$actualProductId = null;
if ($productCode) {
$actualProductId = $products->get($productCode)?->id;
$actualProductId = $products->first(function ($p) use ($productCode) {
return (string)$p->id === (string)$productCode || $p->barcode === (string)$productCode;
})?->id;
}
$updateData = [

View File

@@ -42,7 +42,7 @@ class TransactionService
$order->items()->create([
'product_id' => $item['product_id'],
'product_name' => $item['product_name'] ?? 'Unknown',
'sku' => $item['sku'] ?? null,
'barcode' => $item['barcode'] ?? null,
'price' => $item['price'],
'quantity' => $item['quantity'],
'subtotal' => $item['price'] * $item['quantity'],

View File

@@ -8,6 +8,119 @@ return [
[
'name' => '機台核心通訊 (IoT Core)',
'apis' => [
[
'name' => 'B005: 廣告清單同步 (Ad Sync)',
'slug' => 'b005-ad-sync',
'method' => 'GET',
'path' => '/api/v1/app/machine/ad/B005',
'description' => '用於機台端獲取目前應播放的廣告檔案 URL 清單。此介面無需 Request Body。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '請求是否成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
'data' => [
'type' => 'array',
'description' => '廣告物件陣列。內部欄位包含t070v01 (名稱), t070v02 (秒數), t070v03 (位置1:販賣頁, 2:來店禮, 3:待機廣告), t070v04 (URL), t070v05 (順位)',
'example' => [
[
't070v01' => '測試機台廣告',
't070v02' => 15,
't070v03' => 3,
't070v04' => 'https://example.com/ad1.mp4',
't070v05' => 1
]
]
],
],
'request' => [],
'response' => [
'success' => true,
'code' => 200,
'message' => 'OK',
'data' => [
[
't070v01' => '測試機台廣告',
't070v02' => 15,
't070v03' => 3,
't070v04' => 'https://example.com/ad1.mp4',
't070v05' => 1
]
]
],
],
[
'name' => 'B009: 貨道庫存即時回報 (Inventory Report)',
'slug' => 'b009-inventory-report',
'method' => 'PUT',
'path' => '/api/v1/app/products/supplementary/B009',
'description' => '當人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。需進行 RBAC 權限核查。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'account' => [
'type' => 'string',
'required' => true,
'description' => '操作人員帳號',
'example' => '0999123456'
],
'data' => [
'type' => 'array',
'required' => true,
'description' => '貨道數據陣列。tid: 貨道號, t060v00: 商品 ID, num: 庫存量',
'example' => [
['tid' => '1', 't060v00' => '1', 'num' => '10']
]
],
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '同步是否成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
'message' => [
'type' => 'string',
'description' => '回應訊息',
'example' => 'Slot report synchronized success'
],
'status' => [
'type' => 'string',
'description' => '固定回傳 49 代表同步完成',
'example' => '49'
],
],
'request' => [
'account' => '0999123456',
'data' => [
['tid' => '1', 't060v00' => '1', 'num' => '10']
]
],
'response' => [
'success' => true,
'code' => 200,
'message' => 'Slot report synchronized success',
'status' => '49'
],
],
[
'name' => 'B010: 心跳上報與狀態同步 (Heartbeat)',
'slug' => 'b010-heartbeat',

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. 從 products 表移除 sku
Schema::table('products', function (Blueprint $table) {
// 因為公司外鍵正在使用這個複合索引,必須先補一個獨立索引給公司
$table->index('company_id');
// 現在可以安全移除含有 sku 的索引了
$table->dropIndex(['company_id', 'sku']);
$table->dropColumn('sku');
});
// 2. 從 order_items 表移除 sku 並新增 barcode
Schema::table('order_items', function (Blueprint $table) {
$table->dropColumn('sku');
$table->string('barcode')->nullable()->after('product_name')->comment('商品條碼 (備份)');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('order_items', function (Blueprint $table) {
$table->dropColumn('barcode');
$table->string('sku')->nullable()->after('product_name')->comment('商品編號 (備份)');
});
Schema::table('products', function (Blueprint $table) {
$table->string('sku')->nullable()->after('name_dictionary_key')->comment('商品編號');
$table->index(['company_id', 'sku']);
});
}
};

View File

@@ -615,6 +615,7 @@
"No roles available": "No roles available",
"No roles found.": "No roles found.",
"No slots found": "No slots found",
"No slot data available": "No slot data available",
"No users found": "No users found",
"None": "None",
"Normal": "Normal",
@@ -904,6 +905,7 @@
"Stock": "Stock",
"Stock & Expiry": "Stock & Expiry",
"Stock & Expiry Management": "Stock & Expiry Management",
"Stock & Expiry Overview": "Stock & Expiry Overview",
"Stock Management": "Stock Management",
"Stock Quantity": "Stock Quantity",
"Stock:": "Stock:",
@@ -1025,6 +1027,7 @@
"Venue Management": "Venue Management",
"video": "video",
"View Details": "View Details",
"View Inventory": "View Inventory",
"View Logs": "View Logs",
"View More": "View More",
"Visit Gift": "Visit Gift",

View File

@@ -615,6 +615,7 @@
"No roles available": "利用可能な権限はありません",
"No roles found.": "権限が見つかりません。",
"No slots found": "スロットが見つかりません",
"No slot data available": "スロットデータがありません",
"No users found": "ユーザーが見つかりません",
"None": "なし",
"Normal": "通常",
@@ -904,6 +905,7 @@
"Stock": "在庫",
"Stock & Expiry": "在庫と消費期限",
"Stock & Expiry Management": "在庫・期限管理",
"Stock & Expiry Overview": "在庫・期限一覧",
"Stock Management": "在庫管理",
"Stock Quantity": "在庫数",
"Stock:": "在庫:",
@@ -1025,6 +1027,7 @@
"Venue Management": "会場管理",
"video": "video",
"View Details": "詳細を見る",
"View Inventory": "在庫を見る",
"View Logs": "ログを見る",
"View More": "もっと見る",
"Visit Gift": "来店ギフト",

View File

@@ -615,6 +615,7 @@
"No roles available": "目前沒有角色資料。",
"No roles found.": "找不到角色資料。",
"No slots found": "未找到貨道資訊",
"No slot data available": "尚無貨道資料",
"No users found": "找不到用戶資料",
"None": "無",
"Normal": "正常",
@@ -904,6 +905,7 @@
"Stock": "庫存",
"Stock & Expiry": "庫存與效期",
"Stock & Expiry Management": "庫存與效期管理",
"Stock & Expiry Overview": "庫存與效期一覽",
"Stock Management": "庫存管理單",
"Stock Quantity": "庫存數量",
"Stock:": "庫存:",
@@ -1025,6 +1027,7 @@
"Venue Management": "場地管理",
"video": "影片",
"View Details": "查看詳情",
"View Inventory": "查看庫存",
"View Logs": "查看日誌",
"View More": "查看更多",
"Visit Gift": "來店禮",

View File

@@ -7,6 +7,7 @@
return {
showLogPanel: false,
showEditModal: false,
showInventoryPanel: false,
editMachineId: '',
editMachineName: '',
activeTab: 'status',
@@ -15,12 +16,14 @@
currentMachineName: '',
logs: [],
loading: false,
inventoryLoading: false,
startDate: '',
endDate: '',
tab: 'list',
viewMode: 'fleet',
selectedMachine: null,
slots: [],
inventorySlots: [],
init() {
const d = new Date();
@@ -79,11 +82,43 @@
finally { this.loading = false; }
},
// 庫存一覽面板 (唯讀)
async openInventoryPanel(id, sn, name) {
this.currentMachineId = id;
this.currentMachineSn = sn;
this.currentMachineName = name;
this.inventorySlots = [];
this.showInventoryPanel = true;
this.inventoryLoading = true;
try {
const res = await fetch('/admin/machines/' + id + '/slots-ajax');
const data = await res.json();
if (data.success) {
this.inventorySlots = data.slots;
}
} catch (e) { console.error('openInventoryPanel error:', e); }
finally { this.inventoryLoading = false; }
},
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';
const todayStr = new Date().toISOString().split('T')[0];
const expiryStr = slot.expiry_date;
if (expiryStr < todayStr) {
return 'bg-rose-50/60 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-200 dark:border-rose-500/30 shadow-sm shadow-rose-500/5';
}
const diffDays = Math.round((new Date(expiryStr) - new Date(todayStr)) / 86400000);
if (diffDays <= 7) {
return 'bg-amber-50/60 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-500/30 shadow-sm shadow-amber-500/5';
}
return 'bg-emerald-50/60 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/30 shadow-sm shadow-emerald-500/5';
},
};
};
</script>
<div class="space-y-4 pb-20 mt-4" x-data="machineApp()" @keydown.escape.window="showLogPanel = false">
<div class="space-y-4 pb-20 mt-4" x-data="machineApp()" @keydown.escape.window="showLogPanel = false; showInventoryPanel = false">
<!-- Top Header & Actions -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="flex items-center gap-4">
@@ -243,6 +278,16 @@
</td>
<td class="px-6 py-6 text-right">
<div class="flex items-center justify-end gap-2">
<button type="button"
@click="openInventoryPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn tooltip"
title="{{ __('View Inventory') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</button>
<button type="button"
@click="openEditModal('{{ $machine->id }}', '{{ addslashes($machine->name) }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn tooltip"
@@ -260,7 +305,9 @@
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</button>
</div>
@@ -571,4 +618,194 @@
</div>
</div><!-- /Edit Modal -->
<!-- Inventory Offcanvas Panel (唯讀庫存一覽) -->
<div x-show="showInventoryPanel" class="fixed inset-0 z-[100] overflow-hidden" style="display: none;"
aria-labelledby="inventory-panel-title" role="dialog" aria-modal="true">
<!-- Background backdrop -->
<div x-show="showInventoryPanel" x-transition:enter="ease-in-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in-out duration-300"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity" @click="showInventoryPanel = false">
</div>
<div class="fixed inset-y-0 right-0 max-w-full flex">
<!-- Sliding panel -->
<div x-show="showInventoryPanel"
x-transition:enter="transform transition ease-in-out duration-500 sm:duration-700"
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
x-transition:leave="transform transition ease-in-out duration-500 sm:duration-700"
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
class="w-screen max-w-4xl">
<div class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl">
<!-- Header -->
<div
class="px-5 py-6 sm:px-8 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<h2 id="inventory-panel-title"
class="text-xl sm:text-2xl font-black text-slate-800 dark:text-white font-display flex items-center gap-2 sm:gap-3">
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-cyan-500 flex-shrink-0"
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<span class="truncate">{{ __('Stock & Expiry Overview') }}</span>
</h2>
<div
class="mt-2 flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-[10px] sm:text-sm text-slate-500 dark:text-slate-400 font-bold uppercase tracking-widest overflow-hidden">
<span x-text="currentMachineSn"
class="font-mono text-cyan-600 dark:text-cyan-400 truncate"></span>
<span class="hidden sm:inline opacity-50"></span>
<span x-text="currentMachineName" class="truncate"></span>
</div>
</div>
<div class="flex-shrink-0 h-7 flex items-center">
<button type="button" @click="showInventoryPanel = false"
class="bg-white dark:bg-slate-800 rounded-full p-2 text-slate-400 hover:text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 transition duration-300 shadow-sm border border-slate-200 dark:border-slate-700">
<span class="sr-only">{{ __('Close Panel') }}</span>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- 統計摘要 -->
<div class="mt-6 flex items-center gap-4">
<div class="px-5 py-3 rounded-2xl bg-white dark:bg-slate-800/50 flex flex-col items-center min-w-[100px] border border-slate-100 dark:border-slate-800/50">
<span class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{{ __('Total Slots') }}</span>
<span class="text-2xl font-black text-slate-700 dark:text-slate-200" x-text="inventorySlots.length"></span>
</div>
<div class="px-5 py-3 rounded-2xl bg-rose-500/5 border border-rose-500/10 flex flex-col items-center min-w-[100px]">
<span class="text-[9px] font-black text-rose-500 uppercase tracking-widest mb-0.5">{{ __('Low Stock') }}</span>
<span class="text-2xl font-black text-rose-600" x-text="inventorySlots.filter(s => s != null && s.stock <= 5).length"></span>
</div>
<div class="px-5 py-3 rounded-2xl bg-amber-500/5 border border-amber-500/10 flex flex-col items-center min-w-[100px]">
<span class="text-[9px] font-black text-amber-500 uppercase tracking-widest mb-0.5">{{ __('Expiring') }}</span>
<span class="text-2xl font-black text-amber-600" x-text="inventorySlots.filter(s => { if (!s || !s.expiry_date) return false; const diff = Math.round((new Date(s.expiry_date) - new Date()) / 86400000); return diff >= 0 && diff <= 7; }).length"></span>
</div>
</div>
</div><!-- /Header -->
<!-- Body / Cabinet Grid -->
<div class="flex-1 overflow-y-auto p-6 sm:p-8">
<div class="relative min-h-[400px]">
<!-- Loading State -->
<div x-show="inventoryLoading"
class="absolute inset-0 bg-white/50 dark:bg-slate-900/50 backdrop-blur-[1px] flex items-center justify-center z-20">
<div class="flex flex-col items-center gap-3">
<div
class="w-8 h-8 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin">
</div>
<span class="text-xs font-black text-slate-400 uppercase tracking-widest">{{
__('Loading...') }}</span>
</div>
</div>
<!-- Status Legend -->
<div class="flex items-center gap-6 mb-6" x-show="!inventoryLoading">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-rose-500 shadow-lg shadow-rose-500/30"></span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{ __('Expired') }}</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-amber-500 shadow-lg shadow-amber-500/30"></span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{ __('Warning') }}</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/30"></span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{ __('Normal') }}</span>
</div>
</div>
<!-- Slots Grid (唯讀) -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5" x-show="!inventoryLoading">
<template x-for="slot in inventorySlots" :key="slot.id">
<div :class="getSlotColorClass(slot)"
class="min-h-[260px] rounded-[2rem] p-5 flex flex-col items-center justify-center border-2 transition-all duration-300 relative">
<!-- Slot Header -->
<div class="absolute top-3.5 left-4 right-4 flex justify-between items-center z-10">
<div class="px-2.5 py-1 rounded-xl bg-slate-900/10 dark:bg-white/10 backdrop-blur-md border border-slate-900/5 dark:border-white/10 flex-shrink-0">
<span class="text-xs font-black uppercase tracking-tighter text-slate-800 dark:text-white" x-text="slot.slot_no"></span>
</div>
<template x-if="slot.stock <= 2">
<div class="px-2 py-1 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 animate-pulse whitespace-nowrap select-none">
{{ __('Low') }}
</div>
</template>
</div>
<!-- Product Image -->
<div class="relative w-16 h-16 mb-3 mt-2">
<div class="absolute inset-0 rounded-2xl bg-white/20 dark:bg-slate-900/40 backdrop-blur-xl border border-white/30 dark:border-white/5 shadow-inner overflow-hidden">
<template x-if="slot.product && slot.product.image_url">
<img :src="slot.product.image_url" class="w-full h-full object-cover">
</template>
<template x-if="!slot.product || !slot.product.image_url">
<div class="w-full h-full flex items-center justify-center">
<svg class="w-7 h-7 opacity-20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
</template>
</div>
</div>
<!-- Slot Info -->
<div class="text-center w-full space-y-2">
<template x-if="slot.product">
<div class="text-sm font-black truncate w-full opacity-90 tracking-tight" x-text="slot.product.name"></div>
</template>
<template x-if="!slot.product">
<div class="text-sm font-bold text-slate-300 dark:text-slate-600 tracking-tight">{{ __('Empty') }}</div>
</template>
<div class="space-y-2">
<!-- Stock Level -->
<div class="flex items-baseline justify-center gap-1">
<span class="text-xl font-black tracking-tighter leading-none" x-text="slot.stock"></span>
<span class="text-xs font-black opacity-30">/</span>
<span class="text-sm font-bold opacity-50" x-text="slot.max_stock || 10"></span>
</div>
<!-- Expiry Date -->
<div class="text-sm font-black tracking-tight leading-none opacity-80" x-text="slot.expiry_date ? slot.expiry_date.replace(/-/g, '/') : '----/--/--'"></div>
</div>
</div>
</div>
</template>
</div>
<!-- Empty state -->
<template x-if="inventorySlots.length === 0 && !inventoryLoading">
<div class="px-6 py-20 text-center">
<div class="flex flex-col items-center">
<div
class="p-4 rounded-full bg-slate-50 dark:bg-slate-800/50 mb-4 border border-slate-100 dark:border-slate-800/50">
<svg class="w-8 h-8 text-slate-300 dark:text-slate-600"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.5">
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<span
class="text-[11px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{
__('No slot data available') }}</span>
</div>
</div>
</template>
</div>
</div><!-- /Body -->
</div>
</div><!-- /Sliding panel -->
</div>
</div><!-- /Inventory Offcanvas -->
@endsection

View File

@@ -166,7 +166,12 @@
<a href="#{{ $api['slug'] }}" class="luxury-nav-link"
:class="{ 'active': activeSection === '{{ $api['slug'] }}' }"
@click="activeSection = '{{ $api['slug'] }}'">
<span>{{ $api['name'] }}</span>
<div class="flex items-center gap-2 overflow-hidden w-full">
<span class="flex-shrink-0 text-[10px] w-10 text-center font-black px-1.5 py-0.5 rounded-md uppercase tracking-tighter {{ $api['method'] === 'GET' ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' : ($api['method'] === 'POST' ? 'bg-cyan-50 text-cyan-600 border border-cyan-200' : 'bg-amber-50 text-amber-600 border border-amber-200') }}">
{{ $api['method'] }}
</span>
<span class="truncate">{{ $api['name'] }}</span>
</div>
</a>
@endforeach
@endforeach
@@ -187,7 +192,12 @@
<div id="{{ $api['slug'] }}" class="mb-16 animate-luxury-in" x-intersect="activeSection = '{{ $api['slug'] }}'">
<div class="mb-4"></div>
<h3 class="font-display text-3xl font-black text-slate-900 mb-6 tracking-tight">{{ $api['name'] }}</h3>
<div class="flex items-center gap-4 mb-6">
<span class="px-3 py-1 text-sm font-black rounded-lg uppercase tracking-widest {{ $api['method'] === 'GET' ? 'bg-emerald-100 text-emerald-700' : ($api['method'] === 'POST' ? 'bg-cyan-100 text-cyan-700' : 'bg-amber-100 text-amber-700') }}">
{{ $api['method'] }}
</span>
<h3 class="font-display text-3xl font-black text-slate-900 tracking-tight">{{ $api['name'] }}</h3>
</div>
<p class="text-slate-600 mb-8 text-lg font-medium">{{ $api['description'] }}</p>
<!-- Headers & URL -->
@@ -264,7 +274,7 @@
<span
class="text-[11px] font-black text-slate-400 uppercase tracking-tight">範例:</span>
<code
class="text-sm text-cyan-600 font-bold">{{ is_array($param['example']) ? json_encode($param['example']) : $param['example'] }}</code>
class="text-sm text-cyan-600 font-bold">{{ is_array($param['example']) ? json_encode($param['example'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $param['example'] }}</code>
</div>
@endif
</td>
@@ -280,7 +290,7 @@
<!-- Request Example -->
<div>
<h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-6">請求範例 (Request Body)</h4>
<pre><code>{{ json_encode($api['request'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</code></pre>
<pre><code>{{ json_encode($api['request'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) }}</code></pre>
</div>
<!-- Response Examples -->
@@ -310,7 +320,7 @@
@if(isset($param['example']))
<div class="mt-2 flex items-center gap-2">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-tight">範例:</span>
<code class="text-xs text-cyan-600 font-bold bg-cyan-50/50 px-2 py-0.5 rounded">{{ $param['example'] }}</code>
<code class="text-xs text-cyan-600 font-bold bg-cyan-50/50 px-2 py-0.5 rounded">{{ is_array($param['example']) ? json_encode($param['example'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $param['example'] }}</code>
</div>
@endif
</td>
@@ -322,7 +332,7 @@
@endif
<h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">回應範例 (Response Body)</h4>
<pre><code>{{ json_encode($api['response'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</code></pre>
<pre><code>{{ json_encode($api['response'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) }}</code></pre>
</div>
@if(isset($api['notes']))

View File

@@ -62,7 +62,7 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
Route::post('machine/member/verify/B650', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyMember']);
// 廣告與貨道清單 (B005, B009)
Route::post('machine/ad/B005', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getAdvertisements']);
Route::get('machine/ad/B005', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getAdvertisements']);
Route::put('products/supplementary/B009', [App\Http\Controllers\Api\V1\App\MachineController::class, 'reportSlotList']);
// 交易、發票與出貨 (B600, B601, B602)