[FEAT] 重構機台日誌 UI 與增加多語系支援,並整合 IoT API 核心架構

- 機台日誌:對齊 Luxury UI 規範,實作整合式佈局與分頁組件。
- 多語系:完成機台日誌繁、英、日三語系翻譯與動態處理。
- UI 規範:更新 SKILL.md 定義「標準列表 Bible」。
- 後端:完善 TenantScoped 隔離邏輯,修復儀表板死循環與 User Model 缺失。
- IoT:擴展機台、會員 Model 並建立交易、商品、狀態等核心表結構。
- 基礎設施:設置台北時區與 Docker 環境變數同步。
This commit is contained in:
2026-03-16 17:29:15 +08:00
parent 1851e91c86
commit 3ce88ed342
54 changed files with 2015 additions and 227 deletions

View File

@@ -0,0 +1,52 @@
# 多租戶與權限架構實作規範 (RBAC Rules)
本文件定義 Star Cloud 系統的多租戶與權限RBAC實作標準開發者必須嚴格遵守以下準則以確保資料隔離與安全性。
---
## 1. 資料隔離核心 (Data Isolation)
### 1.1 租戶欄位 (`company_id`)
任何屬於租戶資源的資料表(如 `users`, `machines`, `transactions` 等),**必須**包含 `company_id` 欄位。
- `company_id = null`系統管理員SaaS 平台營運商)。
- `company_id = {ID}`:特定租戶。
### 1.2 自動過濾 (Global Scopes)
- 資源 Model 必須套用 `TenantScoped` Trait。
- 當非系統管理員登入時,所有 Eloquent 查詢必須自動加上 `where('company_id', auth()->user()->company_id)`
- **嚴禁**在 Controller 手動撰寫重複的過濾邏輯,除非是複雜的 Raw SQL。
### 1.3 寫入安全
- 建立新資源時,必須在背景強制綁定 `company_id`,禁止由前端傳參決定。
- 範例:`$model->company_id = Auth::user()->company_id;`
---
## 2. 權限開發規範 (spatie/laravel-permission)
### 2.1 租戶感知角色 (Tenant-Aware Roles)
- `roles` 資料表已擴充 `company_id` 欄位。
- 撈取角色清單供指派時,必須過濾 `company_id` 或為 null 的系統預設角色。
### 2.2 權限命名
- 權限名稱應遵循 `[module].[action]` 格式(例如 `machine.view`, `machine.edit`)。
- 所有租戶共用相同的權限定義。
---
## 3. 介面安全 (UI/Blade)
### 3.1 身份判定 Helper
使用以下方法進行區分:
- `$user->isSystemAdmin()`: 判斷是否為平台營運人員。
- `$user->isTenant()`: 判斷是否為租戶帳號。
### 3.2 Blade 指令
- 涉及全站管理或跨租戶功能,必須使用 `@if(auth()->user()->isSystemAdmin())` 包裹。
- 確保租戶登入時,不會在 Sidebar 或選單看到不屬於其權限範圍的項目。
---
## 4. API 安全
- 所有的 API Route 應預設包含 `CheckTenantAccess` Middleware。
- 嚴禁透過 URL 修改 ID 存取不屬於該租戶的資料,必須依賴 `company_id` 的 Scope 過濾。

View File

@@ -17,6 +17,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
| 機台通訊, IoT, 日誌上報, Log Ingestion, 異步隊列, Queue, Heartbeat, 心跳發報 | **IoT 通訊與高併發處理規範** | `.agents/skills/iot-communication/SKILL.md` |
| 介面, UI, 佈局, CSS, Tailwind, 奢華, 深色模式, Light Mode, Dark Mode, Blade, 樣式, 間距, 陰影, 動畫 | **極簡奢華風 UI 實作規範** | `.agents/skills/ui-minimal-luxury/SKILL.md` |
| 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` |
| RBAC, 權限, 角色, 租戶, Tenant, Company, Access Control, 多租戶, 權限控管 | **多租戶與權限架構實作規範** | `.agents/rules/rbac-rules.md` |
---
@@ -27,6 +28,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
### 🔴 新增或修改頁面 (Views/Blade) 時
必須讀取:
1. **ui-minimal-luxury** — 確保符合極簡奢華風視覺與互動規範
2. **rbac-rules** — 確認 UI 區塊的權限顯示控制
### 🔴 新增機台通訊 API 端點時
必須讀取:
@@ -39,3 +41,4 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時
必須讀取:
1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範
2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用

View File

@@ -64,14 +64,9 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
- **互動原則**: 點擊觸發下拉選單時,必須使用 `x-transition` 且帶有 `scale` 偏移。
- **樣式要求**: 選單背景需使用玻璃擬態 (Glassmorphism) 或帶透明度的深色背景。
## 4. UI 檢查清單 (AI 助手執行前必讀)
- [ ] 是否使用了正確的 `rounded-2xl` (或更圓) 的導角
- [ ] 所有的圖示是否一致使用 `lucide-react` 風格?
- [ ] 卡片是否有適當的間距 (通常為 `p-6`)
- [ ] 文字色階是否符合:
- **標題**: `text-slate-900` / `dark:text-white`
- **副標/標籤**: 最小應為 `text-slate-500` / `dark:text-slate-400`(避免使用 `slate-400` 以下等級,以確保對比度足以閱讀)。
- [ ] **字體大小**: 確保所有文字至少為 `text-xs`,重要的標籤建議為 `text-sm`
- [ ] **列表佈局**: 是否採用「整合式卡片」結構且內距設為 `p-8`
- [ ] **分頁與總數**: 列表底部是否正確召喚 `vendor.pagination.luxury`
- [ ] **文字色階**: 符合標題 `slate-900/white` 與標籤 `slate-500` 的對比度。
## 5. 開發注意事項 (Important Notes)
@@ -85,6 +80,23 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
## 6. 頁面佈局規範 (Page Layout)
### 佈局決策規則 (Layout Decision Rules)
根據篩選條件的複雜程度,選擇適當的清單頁面佈局:
#### 1. 整合式佈局 (Integrated Layout) - 【預設推薦】
- **適用場景**: 絕大多數 CRUD 列表。
- **實作方式**: 篩選器、工具列與資料表格全部封裝在同一個 `luxury-card` 中。
- **內距規範**: 強制使用 `p-8` 以獲得最佳空氣感。
- **元件間距**: 篩選區與表格之間固定使用 `mb-10`
- **範例**: 帳號管理、角色設定、機台日誌。
#### 2. 分離式佈局 (Split Layout)
- **適用場景**: 複雜查詢 (Filtered Fields >= 3 或多行篩選)。
- **實作方式**: 篩選區獨立為一個 `luxury-card`,下方間隔 `mb-6` 後再放置資料清單卡片。
- **樣式規範**: 篩選卡片通常使用 `p-6`(緊湊式),清單卡片使用 `p-8`(寬鬆式)。
- **範例**: 交易紀錄、機台日誌。
### 標準寬版佈局 (Wide Layout)
```html
@@ -146,23 +158,21 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
### 文字大小與權重 (Typography Hierarchy)
- **表頭 (Table Header)**:
- 類別: `text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest`
- 類別: `text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em]`
- 作用: 提供清晰的欄位定義而不奪取資料視覺焦點。
- **主標題 (Primary Item)**:
- 類別: `text-base font-extrabold text-slate-800 dark:text-slate-100`
- 範例: 公司名稱、機台標題
- 範例: 公司名稱、機體名稱
- **次要資訊 (Secondary Info)**:
- 類別: `text-[11px] font-bold text-slate-400 dark:text-slate-500 tracking-[0.15em]`
- 範例: 機台序號 (SN)、公司代碼
- 類別: `text-[11px] font-bold text-slate-400 dark:text-slate-500 tracking-[0.1em]`
- 範例: 使用者帳號、備註、權限名稱
- **狀態標籤 (Status Badge)**:
- 類別: `text-[11px] font-black tracking-widest`
- 樣式: 在線 (`emerald`)、離線 (`rose`)。
- **時間訊號 (Signals/Time)**:
- 類別: `text-[13px] font-bold font-display tracking-widest`
- 作用: 解決數字黏滯感,提升判讀舒適度。
- 範例: 啟用 (`emerald`)、禁用 (`rose`) / 角色名稱 (`sky`/`indigo`)。
- 特性: `px-2.5 py-1 rounded-lg text-[11px] font-black border tracking-wider`
- **內距 (Padding)**: 單元格統一使用 `px-6 py-6` 以維持呼吸感。
- **懸停 (Hover)**: 表格行需具備 `hover:bg-slate-50/80` (深色: `dark:hover:bg-slate-800/40`) 動態反饋
### 空間與反應 (Spacing & Interaction)
- **單元格內距**: 統一使用 `px-6 py-6`
- **懸停反應**: 必須在 `tr` 套用 `group` 且子元素套用 `group-hover:bg-slate-50/80` (深色: `dark:group-hover:bg-slate-800/40`) 以提供高級的互動感知。
### 分頁與列表控制項 (Pagination & Controls)
為了維持操作一致性所有列表的分頁與切換組件必須遵循以下「Luxury Jump」模式
@@ -177,6 +187,73 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
- **指示文字**:
- 行動端隱藏多餘詞彙僅保留「1 - 10 / 50」格式。
- 數字顏色對齊 `text-slate-600` (深色: `text-slate-300`)。
### 底部清單控制項 (Bottom List Controls)
為了確保長列表的操作便利,清單底部必須具備以下元素:
- **位置**: 卡片底部,內距 `px-8 py-6`,並帶有 `border-t border-slate-100/50 dark:border-slate-800/50`
- **左側:每頁筆數 (Per Page Selector)**:
- 樣式: `luxury-select` (緊湊型),高度固定為 `h-9`
- 規範: 提供 `20, 50, 100` 等選項,並在變更時立即提交。
- **中央:資料指示 (Info)**:
- 樣式: `text-[11px] font-bold tracking-widest uppercase text-slate-400`
- 內容: `Showing X to Y of Z results`
- **右側:分頁導航 (Pagination)**:
- 模式: 優先使用 `Luxury Jump` (跳轉下拉選單) 以節省空間並提升效率。
### 標準清單萬用模板 (Standard List View Bible)
建立新列表頁面時,**必須**以此結構為基底:
```html
<div class="space-y-10 pb-20">
<!-- 1. Header Area: 標題與全局按鈕 -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Title') }}</h1>
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Subtitle') }}</p>
</div>
<div>
<button class="btn-luxury-primary items-center gap-2">...</button>
</div>
</div>
<!-- 2. Main Integrated Card -->
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
<!-- Toolbar & Filters (mb-10) -->
<div class="flex items-center justify-between mb-10">
<form class="flex items-center gap-4">
<!-- luxury-select & luxury-input -->
</form>
</div>
<!-- Scrollable Table Area -->
<div class="overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Name') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Action') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6 font-extrabold text-slate-800 dark:text-slate-100 italic">Example Name</td>
<td class="px-6 py-6 text-right"> <!-- Action row --> </td>
</tr>
</tbody>
</table>
</div>
<!-- 3. Standard Pagination Footer (mt-8) -->
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $items->links('vendor.pagination.luxury') }}
</div>
</div>
</div>
```
### 清單欄位規範 (Column Visibility & Standards)
- **固定欄位**: 第一欄通常為「關鍵標識」(如 ID 或時間),應具備特殊字體樣式。
- **操作欄位**: 統一位於表格最右端,並命名為 `Action` (或 `操作`),標題與內容皆應 `text-right`
## 9. 系統兼容性與標準化 (Compatibility & Standardization)
為了確保在不同版本的開發環境中(如目前專案使用的 Tailwind CSS v3.1UI 都能正確呈現,並維持全站操作感一致,必須遵守以下額外規範。

View File

@@ -8,6 +8,7 @@ APP_PORT=8090
APP_LOCALE=zh_TW
APP_TIMEZONE=Asia/Taipei
DB_TIMEZONE="+08:00"
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US

View File

@@ -42,7 +42,7 @@ class MachineController extends AdminController
*/
public function logs(Request $request): View
{
$per_page = $request->input('per_page', 20);
$per_page = $request->input('per_page', 10);
$logs = \App\Models\Machine\MachineLog::with('machine')
->when($request->level, function ($query, $level) {
return $query->where('level', $level);

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Api\V1\App;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Machine\Machine;
use App\Jobs\Machine\ProcessHeartbeat;
use App\Jobs\Machine\ProcessTimerStatus;
use App\Jobs\Machine\ProcessCoinInventory;
use Illuminate\Support\Facades\Validator;
class MachineController extends Controller
{
/**
* B010: Machine Heartbeat & Status Update (Asynchronous)
*/
public function heartbeat(Request $request)
{
$machine = $request->get('machine');
$data = $request->all();
// 異步處理狀態更新
ProcessHeartbeat::dispatch($machine->serial_no, $data);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'OK',
'status' => '49' // 某些硬體可能需要的成功碼
], 202); // 202 Accepted
}
/**
* B017: Get Slot Info & Stock (Synchronous)
*/
public function getSlots(Request $request)
{
$machine = $request->get('machine');
$slots = $machine->slots()->with('product')->get();
return response()->json([
'success' => true,
'code' => 200,
'data' => $slots->map(function ($slot) {
return [
'slot_no' => $slot->slot_no,
'product_id' => $slot->product_id,
'stock' => $slot->stock,
'capacity' => $slot->capacity,
'price' => $slot->price,
'status' => $slot->status,
];
})
]);
}
/**
* B710: Sync Timer status (Asynchronous)
*/
public function syncTimer(Request $request)
{
$machine = $request->get('machine');
$data = $request->all();
ProcessTimerStatus::dispatch($machine->serial_no, $data);
return response()->json(['success' => true], 202);
}
/**
* B220: Sync Coin Inventory (Asynchronous)
*/
public function syncCoinInventory(Request $request)
{
$machine = $request->get('machine');
$data = $request->all();
ProcessCoinInventory::dispatch($machine->serial_no, $data);
return response()->json(['success' => true], 202);
}
/**
* B650: Verify Member Code/Barcode (Synchronous)
*/
public function verifyMember(Request $request)
{
$validator = Validator::make($request->all(), [
'code' => 'required|string',
]);
if ($validator->fails()) {
return response()->json(['success' => false, 'message' => 'Invalid code'], 400);
}
$code = $request->input('code');
// 搜尋會員 (barcode 或特定驗證碼)
$member = \App\Models\Member\Member::where('barcode', $code)
->orWhere('id', $code) // 暫時支援 ID
->first();
if (!$member) {
return response()->json([
'success' => false,
'code' => 404,
'message' => 'Member not found'
], 404);
}
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'member_id' => $member->id,
'name' => $member->name,
'points' => $member->points,
'wallet_balance' => $member->wallet_balance ?? 0,
]
]);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Api\V1\App;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Jobs\Transaction\ProcessTransaction;
use App\Jobs\Transaction\ProcessInvoice;
use App\Jobs\Transaction\ProcessDispenseRecord;
class TransactionController extends Controller
{
/**
* B600: Record Transaction (Asynchronous)
*/
public function store(Request $request)
{
$machine = $request->get('machine');
$data = $request->all();
$data['serial_no'] = $machine->serial_no;
ProcessTransaction::dispatch($data);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Accepted'
], 202);
}
/**
* B601: Record Invoice (Asynchronous)
*/
public function recordInvoice(Request $request)
{
$machine = $request->get('machine');
$data = $request->all();
$data['serial_no'] = $machine->serial_no;
ProcessInvoice::dispatch($data);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Accepted'
], 202);
}
/**
* B602: Record Dispense Result (Asynchronous)
*/
public function recordDispense(Request $request)
{
$machine = $request->get('machine');
$data = $request->all();
$data['serial_no'] = $machine->serial_no;
ProcessDispenseRecord::dispatch($data);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Accepted'
], 202);
}
}

View File

@@ -69,5 +69,6 @@ class Kernel extends HttpKernel
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
'iot.auth' => \App\Http\Middleware\IotAuth::class,
];
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Models\Machine\Machine;
use Symfony\Component\HttpFoundation\Response;
class IotAuth
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
$token = $request->bearerToken();
// Phase 1: 暫時也接受 Request Body 中的 key 欄位 (相容模式)
if (!$token) {
$token = $request->input('key');
}
if (!$token) {
return response()->json(['success' => false, 'message' => 'Unauthorized: Missing Token'], 401);
}
$machine = Machine::where('api_token', $token)->first();
if (!$machine) {
return response()->json(['success' => false, 'message' => 'Unauthorized: Invalid Token'], 401);
}
// 將機台物件注入 Request 供後端使用
$request->merge(['machine' => $machine]);
return $next($request);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use App\Models\Machine\CoinInventory;
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 ProcessCoinInventory 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(): void
{
try {
$machine = Machine::where('serial_no', $this->serialNo)->firstOrFail();
// Sync inventory: typically the IoT device sends the full state
// If it sends partial, logic would differ. For now, we assume simple updateOrCreate per denomination.
if (isset($this->data['inventories']) && is_array($this->data['inventories'])) {
foreach ($this->data['inventories'] as $inv) {
CoinInventory::updateOrCreate(
[
'machine_id' => $machine->id,
'denomination' => $inv['denomination'],
'type' => $inv['type'] ?? 'coin'
],
['count' => $inv['count']]
);
}
}
} catch (\Exception $e) {
Log::error("Failed to process coin inventory for machine {$this->serialNo}: " . $e->getMessage());
throw $e;
}
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Jobs\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;
use Illuminate\Support\Facades\Log;
class ProcessHeartbeat 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 $machineService): void
{
try {
$machineService->updateHeartbeat($this->serialNo, $this->data);
} catch (\Exception $e) {
Log::error("Failed to process heartbeat for machine {$this->serialNo}: " . $e->getMessage());
throw $e;
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use App\Models\Machine\TimerStatus;
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 ProcessTimerStatus 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(): void
{
try {
$machine = Machine::where('serial_no', $this->serialNo)->firstOrFail();
TimerStatus::updateOrCreate(
['machine_id' => $machine->id, 'slot_no' => $this->data['slot_no']],
[
'status' => $this->data['status'],
'remaining_seconds' => $this->data['remaining_seconds'],
'end_at' => isset($this->data['end_at']) ? \Carbon\Carbon::parse($this->data['end_at']) : null,
]
);
} catch (\Exception $e) {
Log::error("Failed to process timer status for machine {$this->serialNo}: " . $e->getMessage());
throw $e;
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Jobs\Transaction;
use App\Services\Transaction\TransactionService;
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 ProcessDispenseRecord implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $data;
/**
* Create a new job instance.
*/
public function __construct(array $data)
{
$this->data = $data;
}
/**
* Execute the job.
*/
public function handle(TransactionService $transactionService): void
{
try {
$transactionService->recordDispense($this->data);
} catch (\Exception $e) {
Log::error("Failed to record dispense for machine {$this->data['serial_no']}: " . $e->getMessage());
throw $e;
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Jobs\Transaction;
use App\Services\Transaction\TransactionService;
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 ProcessInvoice implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $data;
/**
* Create a new job instance.
*/
public function __construct(array $data)
{
$this->data = $data;
}
/**
* Execute the job.
*/
public function handle(TransactionService $transactionService): void
{
try {
$transactionService->recordInvoice($this->data);
} catch (\Exception $e) {
Log::error('Failed to process invoice: ' . $e->getMessage(), [
'data' => $this->data,
'exception' => $e
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Jobs\Transaction;
use App\Services\Transaction\TransactionService;
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 ProcessTransaction implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $data;
/**
* Create a new job instance.
*/
public function __construct(array $data)
{
$this->data = $data;
}
/**
* Execute the job.
*/
public function handle(TransactionService $transactionService): void
{
try {
$transactionService->processTransaction($this->data);
} catch (\Exception $e) {
Log::error("Failed to process transaction: " . $e->getMessage());
throw $e;
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class CoinInventory extends Model
{
use HasFactory;
protected $fillable = [
'machine_id',
'denomination',
'count',
'type',
];
public function machine()
{
return $this->belongsTo(Machine::class);
}
}

View File

@@ -10,14 +10,20 @@ use App\Traits\TenantScoped;
class Machine extends Model
{
use HasFactory, TenantScoped;
use \Illuminate\Database\Eloquent\SoftDeletes;
protected $fillable = [
'company_id',
'name',
'serial_no',
'model',
'location',
'status',
'current_page',
'door_status',
'temperature',
'firmware_version',
'api_token',
'last_heartbeat_at',
];

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Models\Product\Product;
class MachineSlot extends Model
{
use HasFactory;
protected $fillable = [
'machine_id',
'product_id',
'slot_no',
'slot_name',
'capacity',
'stock',
'price',
'status',
'last_restocked_at',
];
protected $casts = [
'price' => 'decimal:2',
'last_restocked_at' => 'datetime',
];
public function machine()
{
return $this->belongsTo(Machine::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class RemoteCommand extends Model
{
use HasFactory;
protected $fillable = [
'machine_id',
'command',
'payload',
'status',
'response_payload',
'executed_at',
];
protected $casts = [
'payload' => 'array',
'response_payload' => 'array',
'executed_at' => 'datetime',
];
public function machine()
{
return $this->belongsTo(Machine::class);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class TimerStatus extends Model
{
use HasFactory;
protected $fillable = [
'machine_id',
'slot_no',
'status',
'remaining_seconds',
'end_at',
];
protected $casts = [
'end_at' => 'datetime',
];
public function machine()
{
return $this->belongsTo(Machine::class);
}
}

View File

@@ -31,6 +31,10 @@ class Member extends Authenticatable
'avatar',
'is_active',
'email_verified_at',
'company_id',
'barcode',
'points',
'wallet_balance',
];
/**
@@ -49,6 +53,8 @@ class Member extends Authenticatable
'birthday' => 'date',
'is_active' => 'boolean',
'password' => 'hashed',
'points' => 'integer',
'wallet_balance' => 'decimal:2',
];
/**

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models\Product;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\TenantScoped;
class Product extends Model
{
use HasFactory, SoftDeletes, TenantScoped;
protected $fillable = [
'company_id',
'category_id',
'name',
'sku',
'barcode',
'description',
'price',
'cost',
'type',
'image_url',
'status',
'name_dictionary_key',
'metadata',
];
protected $casts = [
'price' => 'decimal:2',
'cost' => 'decimal:2',
'metadata' => 'array',
];
public function category()
{
return $this->belongsTo(ProductCategory::class, 'category_id');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models\Product;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\TenantScoped;
class ProductCategory extends Model
{
use HasFactory, SoftDeletes, TenantScoped;
protected $fillable = [
'company_id',
'name',
'name_dictionary_key',
];
public function products()
{
return $this->hasMany(Product::class, 'category_id');
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models\System;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Translation extends Model
{
use HasFactory;
protected $fillable = [
'group',
'key',
'locale',
'value',
];
}

View File

@@ -8,11 +8,13 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable, HasRoles;
use HasApiTokens, HasFactory, Notifiable, HasRoles, TenantScoped, SoftDeletes;
/**
* The attributes that are mass assignable.

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models\Transaction;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Models\Machine\Machine;
use App\Models\Product\Product;
class DispenseRecord extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'company_id',
'order_id',
'flow_id',
'machine_id',
'product_id',
'slot_no',
'amount',
'remaining_stock',
'dispense_status',
'member_barcode',
'machine_time',
'points_used',
];
protected $casts = [
'amount' => 'decimal:2',
'machine_time' => 'datetime',
'dispense_status' => 'integer',
];
public function order()
{
return $this->belongsTo(Order::class);
}
public function machine()
{
return $this->belongsTo(Machine::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models\Transaction;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Invoice extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'company_id',
'order_id',
'machine_id',
'flow_id',
'invoice_no',
'amount',
'carrier_id',
'invoice_date',
'random_number',
'love_code',
'rtn_code',
'rtn_msg',
'metadata',
];
protected $casts = [
'total_amount' => 'decimal:2',
'tax_amount' => 'decimal:2',
'metadata' => 'array',
];
public function order()
{
return $this->belongsTo(Order::class);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Models\Transaction;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\TenantScoped;
use App\Models\Machine\Machine;
use App\Models\Member\Member;
class Order extends Model
{
use HasFactory, SoftDeletes, TenantScoped;
protected $fillable = [
'company_id',
'flow_id',
'order_no',
'machine_id',
'member_id',
'total_amount',
'discount_amount',
'pay_amount',
'payment_type',
'payment_status',
'payment_at',
'status',
'metadata',
];
protected $casts = [
'total_amount' => 'decimal:2',
'discount_amount' => 'decimal:2',
'pay_amount' => 'decimal:2',
'payment_at' => 'datetime',
'metadata' => 'array',
];
public function machine()
{
return $this->belongsTo(Machine::class);
}
public function member()
{
return $this->belongsTo(Member::class);
}
public function items()
{
return $this->hasMany(OrderItem::class);
}
public function invoice()
{
return $this->hasOne(Invoice::class);
}
public function dispenseRecords()
{
return $this->hasMany(DispenseRecord::class);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models\Transaction;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Models\Product\Product;
class OrderItem extends Model
{
use HasFactory;
protected $fillable = [
'order_id',
'product_id',
'product_name',
'sku',
'price',
'quantity',
'subtotal',
'metadata',
];
protected $casts = [
'price' => 'decimal:2',
'subtotal' => 'decimal:2',
'metadata' => 'array',
];
public function order()
{
return $this->belongsTo(Order::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models\Transaction;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class PaymentType extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'name',
'code',
'config',
'status',
];
protected $casts = [
'config' => 'array',
];
}

View File

@@ -4,42 +4,69 @@ namespace App\Services\Machine;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineLog;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class MachineService
{
/**
* 處理機台日誌寫入與狀態更新
* Update machine heartbeat and status.
*
* @param string $serialNo
* @param array $data
* @return Machine
*/
public function updateHeartbeat(string $serialNo, array $data): Machine
{
return DB::transaction(function () use ($serialNo, $data) {
$machine = Machine::where('serial_no', $serialNo)->firstOrFail();
$updateData = [
'status' => 'online',
'temperature' => $data['temperature'] ?? $machine->temperature,
'current_page' => $data['current_page'] ?? $machine->current_page,
'door_status' => $data['door_status'] ?? $machine->door_status,
'firmware_version' => $data['firmware_version'] ?? $machine->firmware_version,
'last_heartbeat_at' => now(),
];
$machine->update($updateData);
// Record log if provided
if (!empty($data['log'])) {
$machine->logs()->create([
'level' => $data['log_level'] ?? 'info',
'message' => $data['log'],
'payload' => $data['log_payload'] ?? null,
]);
}
return $machine;
});
}
/**
* Update machine slot stock.
*/
public function updateSlotStock(Machine $machine, int $slotNo, int $stock): void
{
$machine->slots()->where('slot_no', $slotNo)->update([
'stock' => $stock,
'last_restocked_at' => now(),
]);
}
/**
* Legacy support for recordLog (Existing code).
*/
public function recordLog(int $machineId, array $data): MachineLog
{
$machine = Machine::findOrFail($machineId);
// 建立日誌紀錄
$log = $machine->logs()->create([
return $machine->logs()->create([
'level' => $data['level'] ?? 'info',
'message' => $data['message'],
'context' => $data['context'] ?? null,
'payload' => $data['context'] ?? null,
]);
// 同步更新機台最後活耀時間與狀態
$machine->update([
'last_heartbeat_at' => now(),
'status' => $this->resolveStatus($data),
]);
return $log;
}
/**
* 根據日誌內容判斷機台是否應標記成錯誤
*/
protected function resolveStatus(array $data): string
{
if (isset($data['level']) && $data['level'] === 'error') {
return 'error';
}
return 'online';
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Services\Transaction;
use App\Models\Transaction\Order;
use App\Models\Transaction\OrderItem;
use App\Models\Transaction\Invoice;
use App\Models\Transaction\DispenseRecord;
use Illuminate\Support\Facades\DB;
use App\Models\Machine\Machine;
class TransactionService
{
/**
* Process a new transaction (B600).
*/
public function processTransaction(array $data): Order
{
return DB::transaction(function () use ($data) {
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
// Create Order
$order = Order::create([
'company_id' => $machine->company_id,
'flow_id' => $data['flow_id'] ?? null,
'order_no' => $data['order_no'] ?? $this->generateOrderNo(),
'machine_id' => $machine->id,
'member_id' => $data['member_id'] ?? null,
'total_amount' => $data['total_amount'],
'discount_amount' => $data['discount_amount'] ?? 0,
'pay_amount' => $data['pay_amount'],
'payment_type' => $data['payment_type'] ?? 0,
'payment_status' => $data['payment_status'] ?? 1,
'payment_at' => now(),
'status' => 'completed',
'metadata' => $data['metadata'] ?? null,
]);
// Create Order Items
if (!empty($data['items'])) {
foreach ($data['items'] as $item) {
$order->items()->create([
'product_id' => $item['product_id'],
'product_name' => $item['product_name'] ?? 'Unknown',
'sku' => $item['sku'] ?? null,
'price' => $item['price'],
'quantity' => $item['quantity'],
'subtotal' => $item['price'] * $item['quantity'],
]);
}
}
return $order;
});
}
/**
* Generate a unique order number.
*/
protected function generateOrderNo(): string
{
return 'ORD-' . now()->format('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(3)));
}
/**
* Record Invoice (B601).
*/
public function recordInvoice(array $data): Invoice
{
return DB::transaction(function () use ($data) {
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
$order = null;
if (!empty($data['flow_id'])) {
$order = Order::where('flow_id', $data['flow_id'])->first();
}
return Invoice::create([
'company_id' => $machine->company_id,
'order_id' => $order?->id ?? ($data['order_id'] ?? null),
'machine_id' => $machine->id,
'flow_id' => $data['flow_id'] ?? null,
'invoice_no' => $data['invoice_no'] ?? null,
'amount' => $data['amount'] ?? 0,
'carrier_id' => $data['carrier_id'] ?? null,
'invoice_date' => $data['invoice_date'] ?? null,
'random_number' => $data['random_no'] ?? null,
'love_code' => $data['love_code'] ?? null,
'metadata' => $data['metadata'] ?? null,
]);
});
}
/**
* Record dispense result (B602).
*/
public function recordDispense(array $data): DispenseRecord
{
return DB::transaction(function () use ($data) {
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
$order = null;
if (!empty($data['flow_id'])) {
$order = Order::where('flow_id', $data['flow_id'])->first();
}
return DispenseRecord::create([
'company_id' => $machine->company_id,
'order_id' => $order?->id ?? ($data['order_id'] ?? null),
'flow_id' => $data['flow_id'] ?? null,
'machine_id' => $machine->id,
'slot_no' => $data['slot_no'] ?? 'unknown',
'product_id' => $data['product_id'] ?? null,
'amount' => $data['amount'] ?? 0,
'dispense_status' => $data['dispense_status'] ?? 0,
'machine_time' => $data['machine_time'] ?? now(),
]);
});
}
}

View File

@@ -12,6 +12,16 @@ trait TenantScoped
public static function bootTenantScoped(): void
{
static::addGlobalScope('tenant', function (Builder $query) {
// 避免在 User Model 本身套用此 Scope否則在 auth()->user() 讀取 User 時會產生循環引用
if (static::class === \App\Models\System\User::class) {
return;
}
// check if running in console/migration
if (app()->runningInConsole()) {
return;
}
$user = auth()->user();
// 如果使用者已登入且有綁定公司,則自動注入過濾條件

View File

@@ -19,6 +19,7 @@ services:
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
IGNITION_LOCAL_SITES_PATH: '${PWD}'
TZ: 'Asia/Taipei'
volumes:
- '.:/var/www/html'
networks:
@@ -41,6 +42,7 @@ services:
MYSQL_PASSWORD: '${DB_PASSWORD}'
MYSQL_ALLOW_EMPTY_PASSWORD: 1
MYSQL_EXTRA_OPTIONS: '${MYSQL_EXTRA_OPTIONS:-}'
TZ: 'Asia/Taipei'
volumes:
- 'sail-mysql:/var/lib/mysql'
- './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'

View File

@@ -57,6 +57,7 @@ return [
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'timezone' => env('DB_TIMEZONE', '+08:00'),
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),

View File

@@ -15,6 +15,7 @@ return new class extends Migration
$table->foreignId('company_id')->nullable()->after('id')
->constrained('companies')->nullOnDelete();
$table->tinyInteger('status')->default(1)->after('role'); // 1:啟用, 0:停用
$table->softDeletes();
});
}
@@ -25,7 +26,7 @@ return new class extends Migration
{
Schema::table('users', function (Blueprint $table) {
$table->dropConstrainedForeignId('company_id');
$table->dropColumn('status');
$table->dropColumn(['status', 'deleted_at']);
});
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('roles', function (Blueprint $table) {
$table->foreignId('company_id')->nullable()->after('id')
->constrained('companies')->onDelete('cascade');
// 移除舊有的唯一鍵
$table->dropUnique('roles_name_guard_name_unique');
// 新增複合唯一鍵 (涵蓋租戶)
$table->unique(['name', 'guard_name', 'company_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('roles', function (Blueprint $table) {
$table->dropUnique(['name', 'guard_name', 'company_id']);
$table->unique(['name', 'guard_name']);
$table->dropConstrainedForeignId('company_id');
});
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('machines', function (Blueprint $table) {
$table->string('serial_no')->unique()->after('name')->comment('機台序號');
$table->string('model')->nullable()->after('serial_no')->comment('型號');
$table->tinyInteger('current_page')->default(0)->after('status')->comment('當前頁面狀態');
$table->string('door_status')->nullable()->after('current_page')->comment('門禁狀態');
$table->string('api_token')->nullable()->after('firmware_version')->comment('API Token');
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machines', function (Blueprint $table) {
$table->dropColumn(['serial_no', 'model', 'current_page', 'door_status', 'api_token', 'deleted_at']);
});
}
};

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('members', function (Blueprint $table) {
if (!Schema::hasColumn('members', 'company_id')) {
$table->foreignId('company_id')->nullable()->constrained()->onDelete('cascade');
}
if (!Schema::hasColumn('members', 'barcode')) {
$table->string('barcode')->nullable()->index();
}
if (!Schema::hasColumn('members', 'points')) {
$table->integer('points')->default(0);
}
if (!Schema::hasColumn('members', 'wallet_balance')) {
$table->decimal('wallet_balance', 10, 2)->default(0);
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('members', function (Blueprint $table) {
$table->dropColumn(['company_id', 'barcode', 'points', 'wallet_balance']);
});
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('product_categories', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->nullable()->constrained('companies')->onDelete('cascade');
$table->string('name');
$table->string('name_dictionary_key')->nullable()->comment('多語系 Key');
$table->timestamps();
$table->softDeletes();
});
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->nullable()->constrained('companies')->onDelete('cascade');
$table->foreignId('category_id')->nullable()->constrained('product_categories')->onDelete('set null');
$table->string('name');
$table->string('name_dictionary_key')->nullable()->comment('多語系 Key');
$table->string('sku')->nullable()->comment('商品編號');
$table->decimal('price', 10, 2)->default(0)->comment('售價');
$table->decimal('cost', 10, 2)->nullable()->comment('成本');
$table->string('image')->nullable()->comment('圖片 URL');
$table->string('barcode')->nullable()->comment('條碼');
$table->boolean('is_timer_product')->default(false)->comment('是否為計時型商品');
$table->boolean('is_active')->default(true)->comment('是否上架');
$table->timestamps();
$table->softDeletes();
$table->index(['company_id', 'sku']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('products');
Schema::dropIfExists('product_categories');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('machine_slots', function (Blueprint $table) {
$table->id();
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
$table->string('slot_no')->comment('貨道編號 (B017 tid, B710 cid)');
$table->foreignId('product_id')->nullable()->constrained('products')->onDelete('set null');
$table->integer('stock')->default(0)->comment('當前庫存');
$table->integer('max_stock')->default(0)->comment('最大容量');
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->unique(['machine_id', 'slot_no']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('machine_slots');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('translations', function (Blueprint $table) {
$table->id();
$table->string('group', 50)->comment('分組 (product, category, system)');
$table->string('key', 100)->comment('字典鍵值');
$table->string('locale', 10)->comment('語系代碼');
$table->text('value')->comment('翻譯內容');
$table->timestamps();
$table->unique(['group', 'key', 'locale']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('translations');
}
};

View File

@@ -0,0 +1,115 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('payment_types', function (Blueprint $table) {
$table->id();
$table->smallInteger('code')->unique()->comment('金流代碼');
$table->string('name')->comment('中文名稱');
$table->string('category')->nullable()->comment('大分類');
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->nullable()->constrained('companies')->onDelete('cascade');
$table->string('flow_id')->nullable()->unique()->comment('Cloud 金流 ID (B600 response)');
$table->string('order_no')->nullable()->index()->comment('APP 訂單號 (B600 req9)');
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
$table->foreignId('member_id')->nullable()->constrained('members')->onDelete('set null');
$table->smallInteger('payment_type')->comment('金流類型碼');
$table->decimal('total_amount', 10, 2)->comment('消費金額');
$table->decimal('discount_amount', 10, 2)->default(0)->comment('折扣金額');
$table->decimal('pay_amount', 10, 2)->comment('實付金額');
$table->decimal('change_amount', 10, 2)->default(0)->comment('找零');
$table->integer('points_used')->default(0)->comment('使用點數');
$table->decimal('original_amount', 10, 2)->nullable()->comment('折扣前金額');
$table->tinyInteger('payment_status')->comment('0:失敗/1:成功');
$table->text('payment_request')->nullable()->comment('金流送出 data');
$table->text('payment_response')->nullable()->comment('金流回傳 data');
$table->datetime('payment_at')->nullable()->comment('支付時間');
$table->string('member_barcode')->nullable()->comment('會員條碼');
$table->string('invoice_info')->nullable()->comment('發票歸戶');
$table->datetime('machine_time')->nullable()->comment('機台時間');
$table->string('status')->default('completed')->comment('訂單狀態');
$table->json('metadata')->nullable()->comment('其他資訊');
$table->timestamps();
$table->softDeletes();
$table->index(['company_id', 'machine_id', 'created_at']);
});
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained('orders')->onDelete('cascade');
$table->foreignId('product_id')->constrained('products')->onDelete('cascade');
$table->string('product_name')->nullable()->comment('商品名稱 (備份)');
$table->string('sku')->nullable()->comment('商品編號 (備份)');
$table->integer('quantity');
$table->decimal('price', 10, 2);
$table->decimal('subtotal', 10, 2);
$table->timestamps();
});
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->nullable()->constrained('companies')->onDelete('cascade');
$table->foreignId('order_id')->nullable()->constrained('orders')->onDelete('set null');
$table->string('flow_id')->index()->comment('金流 ID');
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
$table->string('rtn_code')->nullable();
$table->string('rtn_msg')->nullable();
$table->string('invoice_no')->nullable();
$table->decimal('amount', 10, 2)->default(0);
$table->string('carrier_id')->nullable();
$table->date('invoice_date')->nullable();
$table->string('random_number')->nullable();
$table->string('love_code')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->softDeletes();
});
Schema::create('dispense_records', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->nullable()->constrained('companies')->onDelete('cascade');
$table->foreignId('order_id')->nullable()->constrained('orders')->onDelete('set null');
$table->string('flow_id')->nullable()->index()->comment('金流 ID');
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
$table->string('product_id')->comment('機台端傳入之商品 ID');
$table->string('slot_no')->comment('貨道編號');
$table->decimal('amount', 10, 2);
$table->integer('remaining_stock')->nullable();
$table->tinyInteger('dispense_status')->comment('0:成功/1:失敗');
$table->string('member_barcode')->nullable();
$table->datetime('machine_time')->nullable();
$table->integer('points_used')->default(0);
$table->timestamps();
$table->softDeletes();
$table->index(['company_id', 'machine_id', 'flow_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('dispense_records');
Schema::dropIfExists('invoices');
Schema::dropIfExists('order_items');
Schema::dropIfExists('orders');
Schema::dropIfExists('payment_types');
}
};

View File

@@ -0,0 +1,63 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('remote_commands', function (Blueprint $table) {
$table->id();
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
$table->string('command_type', 50)->comment('reboot, lock, stock_update, dispense...');
$table->enum('status', ['pending', 'sent', 'success', 'failed'])->default('pending');
$table->json('payload')->nullable()->comment('指令參數');
$table->integer('ttl')->default(60)->comment('失效秒數');
$table->timestamp('executed_at')->nullable();
$table->timestamps();
$table->index(['machine_id', 'status']);
});
Schema::create('coin_inventories', function (Blueprint $table) {
$table->id();
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
$table->integer('value_1')->default(0);
$table->integer('value_5')->default(0);
$table->integer('value_10')->default(0);
$table->integer('value_50')->default(0);
$table->integer('value_100')->default(0);
$table->integer('value_500')->default(0);
$table->integer('value_1000')->default(0);
$table->string('operator')->nullable()->comment('操作人 (0=消費者)');
$table->timestamps();
});
Schema::create('timer_statuses', function (Blueprint $table) {
$table->id();
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
$table->string('slot_no')->comment('貨道 ID (B710 cid)');
$table->foreignId('product_id')->nullable()->constrained('products')->onDelete('set null');
$table->tinyInteger('status')->default(0)->comment('0:未啟用/1:使用中/2:異常');
$table->integer('remaining_seconds')->default(0);
$table->timestamps();
$table->unique(['machine_id', 'slot_no']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('timer_statuses');
Schema::dropIfExists('coin_inventories');
Schema::dropIfExists('remote_commands');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('machines', function (Blueprint $table) {
$table->string('current_page')->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machines', function (Blueprint $table) {
$table->tinyInteger('current_page')->default(0)->change();
});
}
};

87
docs/api/iot-spec.md Normal file
View File

@@ -0,0 +1,87 @@
# IoT API 測試與對接文件 (IoT API Testing & Documentation)
本文件紀錄 Star Cloud IoT API 的測試紀錄與對接規格,供後續開發與測試追蹤。
---
## 🟢 B010: 心跳上報與狀態同步 (Heartbeat & Status)
機台定時(建議每 5-10 秒)上送,用於確認連線狀態、溫度及門禁狀態。
### 1. API 資訊
- **Endpoint**: `POST /api/v1/app/machine/status/B010`
- **認證方式**: Bearer Token (或 Request Body 帶 `key`)
- **處理方式**: 異步處理 (Redis Queue),立即回傳 202。
### 2. 請求範例 (JSON)
```json
{
"temperature": 5.2,
"door_status": 0,
"current_page": "home",
"firmware_version": "1.0.5",
"log": "Status heartbeat test",
"log_level": "info"
}
```
### 3. 回應規格
- **成功**: `202 Accepted`
```json
{
"success": true,
"code": 200,
"message": "OK",
"status": "49"
}
```
---
## 🔵 B600: 交易紀錄上報 (Transaction Record)
當機台端完成交易(收款或扣點成功)後上傳。
### 1. API 資訊
- **Endpoint**: `POST /api/v1/app/B600`
- **認證方式**: Bearer Token
- **處理方式**: 異步處理 (Redis Queue)。
### 2. 請求範例 (JSON)
```json
{
"flow_id": "F123456789",
"total_amount": 100,
"pay_amount": 100,
"payment_type": 1,
"items": [
{
"product_id": 1,
"product_name": "Test Product",
"price": 50,
"quantity": 2
}
]
}
```
---
## 📝 測試紀錄 (Test Logs)
### 2026-03-16: B600 交易功能測試
- **測試機台**: `SN001`
- **測試方式**: `curl` 命令模擬
- **驗證項目**:
- [ ] HTTP 回傳 `202`
- [ ] 資料庫 `orders` 產生紀錄
- [ ] 資料庫 `order_items` 產生紀錄
- **結果**: 準備中...
### 2026-03-16: B010 首次演練測試
- **測試機台**: `SN001`
- **測試方式**: `curl` 命令模擬
- **驗證項目**:
- [x] HTTP 回傳 `202`
- [x] 資料庫 `machines.last_heartbeat_at` 更新為台北時間
- [x] `machine_logs` 產生對應日誌
- **測試備註**: 過程中發現 `current_page``tinyInteger` 導致寫入失敗,已將其修改為 `string` 以支援彈性的頁面名稱。
- **結果**: ✅ **成功 (SUCCESS)**。機台狀態已同步為 5.2度當前頁面「home」。

View File

@@ -25,7 +25,6 @@
"Permanently Delete Account": "Permanently Delete Account",
"Password": "Password",
"Enter your password to confirm": "Enter your password to confirm",
"Dashboard": "Dashboard",
"Connectivity Status": "Connectivity Status",
"Real-time status monitoring": "Real-time status monitoring",
@@ -259,5 +258,15 @@
"to": "to",
"of": "of",
"items": "items",
"Showing": "Showing"
"Showing": "Showing",
"Monitor events and system activity across your vending fleet.": "Monitor events and system activity across your vending fleet.",
"All Machines": "All Machines",
"All Levels": "All Levels",
"Timestamp": "Timestamp",
"Message Content": "Message Content",
"No matching logs found": "No matching logs found",
"Unknown": "Unknown",
"Info": "Info",
"Warning": "Warning",
"Error": "Error"
}

View File

@@ -25,7 +25,6 @@
"Permanently Delete Account": "アカウントを永久に削除",
"Password": "パスワード",
"Enter your password to confirm": "確認のためパスワードを入力してください",
"Dashboard": "ダッシュボード",
"Connectivity Status": "接続ステータス概況",
"Real-time status monitoring": "リアルタイムステータス監視",
@@ -260,5 +259,15 @@
"to": "から",
"of": "件中",
"items": "個の項目",
"Showing": "表示中"
"Showing": "表示中",
"Monitor events and system activity across your vending fleet.": "自販機フリート全体のイベントとシステムアクティビティを監視します。",
"All Machines": "すべての機体",
"All Levels": "すべてのレベル",
"Timestamp": "タイムスタンプ",
"Message Content": "ログ内容",
"No matching logs found": "一致するログが見つかりません",
"Unknown": "不明",
"Info": "情報",
"Warning": "警告",
"Error": "エラー"
}

View File

@@ -263,5 +263,15 @@
"super-admin": "超級管理員",
"admin": "管理員",
"user": "一般用戶",
"Product Status": "商品狀態"
"Product Status": "商品狀態",
"Monitor events and system activity across your vending fleet.": "跨機台連線動態與系統日誌監控。",
"All Machines": "所有機台",
"All Levels": "所有層級",
"Timestamp": "時間戳記",
"Message Content": "日誌內容",
"No matching logs found": "找不到符合條件的日誌",
"Unknown": "未知",
"Info": "一般",
"Warning": "警告",
"Error": "錯誤"
}

View File

@@ -29,29 +29,32 @@
}
}">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<div>
<h1 class="text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Account Management') }}</h1>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Manage administrative and tenant accounts') }}</p>
<p class="text-xs font-bold text-slate-400 dark:text-slate-500 mt-1 uppercase tracking-[0.2em]">{{ __('Manage administrative and tenant accounts') }}</p>
</div>
<div class="flex items-center gap-3">
<button @click="openCreateModal()" class="btn-luxury-primary">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
<span>{{ __('Add Account') }}</span>
</button>
</div>
</div>
<!-- Filters & Search -->
<!-- Accounts Content (Integrated Card) -->
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
<form action="{{ route('admin.permission.accounts') }}" method="GET" class="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div class="flex flex-col md:flex-row items-start md:items-center gap-4">
<div class="relative group">
<!-- Filters & Search -->
<form action="{{ route('admin.permission.accounts') }}" method="GET" class="mb-10">
<div class="flex flex-col md:flex-row items-start md:items-center gap-4 w-full md:w-auto">
<div class="relative group w-full md:w-80">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" name="search" value="{{ request('search') }}" class="py-3 pl-12 pr-6 block w-full md:w-80 luxury-input" placeholder="{{ __('Search users...') }}">
<input type="text" name="search" value="{{ request('search') }}" class="py-2.5 pl-12 pr-6 block w-full luxury-input" placeholder="{{ __('Search users...') }}">
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
</div>
@@ -63,42 +66,37 @@
@endforeach
</select>
@endif
</div>
<div class="flex items-center gap-3">
<!-- 移除了冗餘的 Filter 按鈕,下拉選單具備自動提交功能 -->
</div>
</form>
<div class="overflow-x-auto mt-8">
<div class="overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/30">
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800">{{ __('User Info') }}</th>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('User Info') }}</th>
@if(auth()->user()->isSystemAdmin())
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800">{{ __('Belongs To') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Belongs To') }}</th>
@endif
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Role') }}</th>
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Role') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/50">
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($users as $user)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6">
<div class="flex items-center gap-x-4">
<div class="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="w-10 h-10 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 overflow-hidden group-hover:bg-cyan-500/10 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-all duration-500">
@if($user->avatar)
<img src="{{ Storage::url($user->avatar) }}" class="w-full h-full object-cover">
@else
<span class="text-sm font-black">{{ substr($user->name, 0, 1) }}</span>
<span class="text-xs font-black">{{ substr($user->name, 0, 1) }}</span>
@endif
</div>
<div class="flex flex-col">
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{ $user->name }}</span>
<span class="text-[11px] font-bold text-slate-400 dark:text-slate-500 mt-0.5 tracking-[0.15em]">{{ $user->username }} @if($user->email) {{ $user->email }} @endif</span>
<span class="text-[11px] font-bold text-slate-400 dark:text-slate-500 mt-0.5 tracking-tight">{{ $user->username }} @if($user->email) {{ $user->email }} @endif</span>
</div>
</div>
</td>
@@ -107,37 +105,38 @@
@if($user->company)
<span class="text-xs font-bold text-slate-600 dark:text-slate-300 tracking-tight">{{ $user->company->name }}</span>
@else
<span class="text-xs font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-widest">{{ __('SYSTEM') }}</span>
<span class="px-2.5 py-1 rounded-lg text-[10px] font-black bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 uppercase tracking-widest">{{ __('SYSTEM') }}</span>
@endif
</td>
@endif
<td class="px-6 py-6 text-center">
@foreach($user->roles as $role)
<span class="inline-flex items-center px-2.5 py-0.5 rounded-lg text-[11px] font-black bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700 uppercase tracking-widest">
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-[10px] font-black bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700 uppercase tracking-widest">
{{ $role->name }}
</span>
@endforeach
</td>
<td class="px-6 py-6 text-center">
@if($user->status)
<span class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-wider uppercase">
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-wider uppercase">
<span class="size-1.5 rounded-full bg-emerald-500 mr-2 animate-pulse"></span>
{{ __('Active') }}
</span>
@else
<span class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500 border border-slate-200 dark:border-slate-700 tracking-wider uppercase">
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500 border border-slate-200 dark:border-slate-700 tracking-wider uppercase">
{{ __('Disabled') }}
</span>
@endif
</td>
<td class="px-6 py-6 text-right">
<div class="flex justify-end items-center gap-2">
<button @click="openEditModal({{ json_encode($user) }})" class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20">
<button @click='openEditModal(@json($user))' class="p-2 rounded-xl bg-slate-50/50 dark:bg-slate-900/30 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/10 transition-all border border-transparent hover:border-cyan-500/20 shadow-sm">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
</button>
<form action="{{ route('admin.permission.accounts.destroy', $user->id) }}" method="POST" onsubmit="return confirm('{{ addslashes(__('Are you sure you want to delete this account?')) }}')">
<form action="{{ route('admin.permission.accounts.destroy', $user->id) }}" method="POST" onsubmit="return confirm('{{ __('Are you sure you want to delete this account?') }}')">
@csrf
@method('DELETE')
<button type="submit" class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20">
<button type="submit" class="p-2 rounded-xl bg-slate-50/50 dark:bg-slate-900/30 text-slate-400 hover:text-rose-500 hover:bg-rose-500/10 transition-all border border-transparent hover:border-rose-500/20 shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
</button>
</form>
@@ -147,7 +146,10 @@
@empty
<tr>
<td colspan="{{ auth()->user()->isSystemAdmin() ? 5 : 4 }}" class="px-6 py-24 text-center">
<p class="text-slate-400 font-bold">{{ __('No users found') }}</p>
<div class="flex flex-col items-center gap-3 opacity-20">
<svg class="size-16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2m16-10a4 4 0 11-8 0 4 4 0 018 0zM23 21v-2a4 4 0 00-3-3.87m-4-12a4 4 0 010 7.75"/></svg>
<p class="text-slate-400 font-extrabold tracking-widest uppercase text-xs">{{ __('No accounts found') }}</p>
</div>
</td>
</tr>
@endforelse

View File

@@ -1,29 +1,26 @@
@extends('layouts.admin')
@section('header')
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('所有機台日誌') }}
</h2>
</div>
@endsection
@section('title', __('Machine Logs'))
@section('content')
<div class="py-12">
<div class="sm:px-6 lg:px-8 space-y-6">
<!-- 篩選器 -->
<div class="luxury-card rounded-2xl p-6 animate-luxury-in">
<div class="flex items-center gap-x-2 mb-4">
<p class="text-xs font-semibold uppercase tracking-wider text-slate-400">
條件篩選
</p>
</div>
<form method="GET" action="{{ route('admin.machines.logs') }}" class="flex flex-wrap gap-4 items-end">
<div class="space-y-10 pb-20">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">機台</label>
<select name="machine_id" class="block w-48 rounded-md border-slate-300 shadow-sm focus:border-cyan-500 focus:ring focus:ring-cyan-500/20 text-sm dark:bg-slate-800 dark:border-slate-700 dark:text-white dark:focus:border-cyan-500">
<option value="">全部機台</option>
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Machine Logs') }}</h1>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Monitor events and system activity across your vending fleet.') }}</p>
</div>
</div>
<!-- Machine Logs Content (Integrated Card - Same as Roles) -->
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
<!-- Toolbar (Integrated Filters) -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-10">
<form method="GET" action="{{ route('admin.machines.logs') }}" class="flex flex-wrap items-center gap-4 group">
<div class="space-y-1">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{{ __('Machine') }}</label>
<select name="machine_id" class="luxury-select text-xs h-9 py-0" onchange="this.form.submit()">
<option value="">{{ __('All Machines') }}</option>
@foreach($machines as $machine)
<option value="{{ $machine->id }}" {{ request('machine_id') == $machine->id ? 'selected' : '' }}>
{{ $machine->name }}
@@ -31,97 +28,102 @@
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">層級</label>
<select name="level" class="block w-32 rounded-md border-slate-300 shadow-sm focus:border-cyan-500 focus:ring focus:ring-cyan-500/20 text-sm dark:bg-slate-800 dark:border-slate-700 dark:text-white dark:focus:border-cyan-500">
<option value="">全部層級</option>
<option value="info" {{ request('level') == 'info' ? 'selected' : '' }}>Info</option>
<option value="warning" {{ request('level') == 'warning' ? 'selected' : '' }}>Warning</option>
<option value="error" {{ request('level') == 'error' ? 'selected' : '' }}>Error</option>
<div class="space-y-1">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{{ __('Level') }}</label>
<select name="level" class="luxury-select text-xs h-9 py-0" onchange="this.form.submit()">
<option value="">{{ __('All Levels') }}</option>
<option value="info" {{ request('level') == 'info' ? 'selected' : '' }}>{{ __('Info') }}</option>
<option value="warning" {{ request('level') == 'warning' ? 'selected' : '' }}>{{ __('Warning') }}</option>
<option value="error" {{ request('level') == 'error' ? 'selected' : '' }}>{{ __('Error') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">筆數</label>
<select name="per_page" class="h-9 text-[11px] font-black bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 rounded-lg focus:ring-cyan-500/20 focus:border-cyan-500 transition-all">
@foreach([20, 50, 100, 200] as $size)
<option value="{{ $size }}" {{ request('per_page', 20) == $size ? 'selected' : '' }}>{{ $size }} </option>
@endforeach
</select>
</div>
<div class="flex items-center gap-2">
<button type="submit" class="btn-luxury-primary">
<svg class="w-4 h-4" 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"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<span>篩選</span>
<div class="flex items-end gap-2 mt-5">
<button type="submit" class="p-2 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 transition-colors border border-slate-200 dark:border-slate-700">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
</button>
<a href="{{ route('admin.machines.logs') }}" class="btn-luxury-ghost">
重設
<a href="{{ route('admin.machines.logs') }}" class="p-2 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 transition-colors border border-slate-200 dark:border-slate-700">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
</a>
</div>
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
</form>
</div>
<!-- 日誌清單 -->
<div class="luxury-card rounded-2xl p-6 animate-luxury-in overflow-hidden" style="animation-delay: 100ms">
<div class="flex justify-between items-center mb-6">
<h2 class="text-lg font-bold text-slate-800 dark:text-white">系統日誌清單</h2>
<span class="text-xs text-slate-400">所有時間為系統時區</span>
</div>
<div class="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-[#0f172a]">
<table class="min-w-full divide-y divide-slate-200 dark:divide-slate-700/50 font-mono text-xs">
<thead class="bg-slate-50 dark:bg-slate-800/80">
<tr>
<th class="px-4 py-3 text-left font-semibold text-slate-600 dark:text-slate-300">時間</th>
<th class="px-4 py-3 text-left font-semibold text-slate-600 dark:text-slate-300">機台</th>
<th class="px-4 py-3 text-left font-semibold text-slate-600 dark:text-slate-300">層級</th>
<th class="px-4 py-3 text-left font-semibold text-slate-600 dark:text-slate-300">訊息</th>
<div class="overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Timestamp') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Level') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Message Content') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700/50 bg-white dark:bg-transparent">
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse ($logs as $log)
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<td class="px-4 py-3 text-slate-500 dark:text-slate-400 whitespace-nowrap">{{ $log->created_at->format('Y-m-d H:i:s') }}</td>
<td class="px-4 py-3 text-slate-700 dark:text-slate-300 whitespace-nowrap">
<a href="{{ route('admin.machines.show', $log->machine_id) }}" class="hover:text-cyan-600 dark:hover:text-cyan-400 underline decoration-slate-300 dark:decoration-slate-600 underline-offset-2 transition-colors">
{{ $log->machine->name ?? '未知機台' }}
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6 transition-colors">
<div class="text-[13px] font-bold font-display tracking-widest text-slate-600 dark:text-slate-300">
{{ $log->created_at->format('Y-m-d') }}
</div>
<div class="text-[11px] font-bold text-slate-400 dark:text-slate-500 tracking-wider mt-0.5 uppercase">
{{ $log->created_at->format('H:i:s') }}
</div>
</td>
<td class="px-6 py-6 transition-colors">
<a href="{{ route('admin.machines.show', $log->machine_id) }}" class="inline-flex items-center gap-2 group/link">
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover/link:text-cyan-500 transition-colors">
{{ $log->machine->name ?? __('Unknown') }}
</span>
<svg class="w-3.5 h-3.5 text-slate-300 dark:text-slate-600 opacity-0 group-hover/link:opacity-100 transition-all -translate-x-2 group-hover/link:translate-x-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/></svg>
</a>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<td class="px-6 py-6 transition-colors">
@php
$levelClasses = [
'info' => 'text-cyan-600 dark:text-cyan-400 bg-cyan-50 dark:bg-cyan-500/20 border-cyan-200 dark:border-cyan-500/30',
'warning' => 'text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/20 border-amber-200 dark:border-amber-500/30 font-semibold',
'error' => 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/20 border-rose-200 dark:border-rose-500/30 font-bold',
$badgeStyles = [
'info' => 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/20',
'warning' => 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20',
'error' => 'bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/20',
];
$currentStyle = $badgeStyles[$log->level] ?? 'bg-slate-500/10 text-slate-600 border-slate-500/20';
@endphp
<span class="px-2 py-0.5 rounded border {{ $levelClasses[$log->level] ?? 'text-slate-500 bg-slate-100 border-slate-200 dark:text-slate-300 dark:bg-slate-800 dark:border-slate-700' }}">
{{ strtoupper($log->level) }}
<span class="inline-flex items-center px-3 py-1 rounded-lg border text-[11px] font-black uppercase tracking-wider {{ $currentStyle }}">
<span class="w-1.5 h-1.5 rounded-full bg-current mr-2 animate-pulse"></span>
{{ __(ucfirst($log->level)) }}
</span>
</td>
<td class="px-4 py-3 text-slate-700 dark:text-slate-200 max-w-xl break-words">
<td class="px-6 py-6 transition-colors">
<p class="text-[14px] font-medium text-slate-700 dark:text-slate-200 leading-relaxed max-w-xl">
{{ $log->message }}
</p>
@if($log->context)
<div class="text-[10px] text-slate-500 dark:text-slate-400 mt-2 max-h-24 overflow-y-auto bg-slate-100 dark:bg-[#0f172a] p-2 rounded-lg border border-slate-200 dark:border-slate-800/50 shadow-inner">
{{ json_encode($log->context, JSON_UNESCAPED_UNICODE) }}
<div class="mt-3 p-4 rounded-xl bg-slate-50/50 dark:bg-[#0f172a]/50 border border-slate-100 dark:border-slate-800/50 group-hover:bg-white dark:group-hover:bg-[#0f172a] transition-colors">
<pre class="text-[10px] font-bold text-slate-400 dark:text-slate-500 whitespace-pre-wrap break-all">{{ json_encode($log->context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) }}</pre>
</div>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-12 text-center text-slate-500 dark:text-slate-400 italic">暫無相關日誌</td>
<td colspan="4" class="px-6 py-24 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="M21 7v10c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V7c0-1.1.9-2 2-2h14c1.1 0 2 .9 2 2z"/><path d="M12 11l4-4"/><path d="M8 15l4-4"/></svg>
</div>
<span class="text-sm font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('No matching logs found') }}</span>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($logs->hasPages())
<div class="mt-6">
<div class="mt-8 border-t border-slate-100 dark:border-slate-800 pt-6">
{{ $logs->links('vendor.pagination.luxury') }}
</div>
@endif
</div>
</div>
</div>
@endsection

View File

@@ -29,9 +29,10 @@
</button>
</div>
<!-- Roles Content (Integrated Card) -->
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
<!-- Toolbar -->
<div class="luxury-card rounded-3xl p-6 mb-6 animate-luxury-in" style="animation-delay: 100ms">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-10">
<form action="{{ route('admin.permission.roles') }}" method="GET" class="relative group">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -43,30 +44,27 @@
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
</form>
</div>
</div>
<!-- Roles List -->
<div class="luxury-card rounded-3xl p-8 animate-luxury-in" style="animation-delay: 200ms">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="border-b border-slate-100 dark:border-slate-700">
<th class="px-6 py-5 text-sm font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest">{{ __('Role Name') }}</th>
<th class="px-6 py-5 text-sm font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest">{{ __('Type') }}</th>
<th class="px-6 py-5 text-sm font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest">{{ __('Permissions') }}</th>
<th class="px-6 py-5 text-sm font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest text-center">{{ __('Users') }}</th>
<th class="px-6 py-5 text-sm font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest text-right">{{ __('Actions') }}</th>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Role Name') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Type') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Permissions') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Users') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800">
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($roles as $role)
<tr class="hover:bg-slate-50/80 dark:hover:bg-slate-800/50 transition-colors group">
<td class="px-6 py-5">
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-xl flex items-center justify-center bg-slate-50 dark:bg-slate-800 text-slate-400 group-hover:bg-cyan-500/10 group-hover:text-cyan-500 transition-all duration-300">
<div class="w-10 h-10 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500/10 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-all duration-500">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
</div>
<span class="text-sm font-bold text-slate-700 dark:text-slate-200">{{ $role->name }}</span>
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{ $role->name }}</span>
@if($role->is_system)
<span class="p-1.5 bg-cyan-50 dark:bg-cyan-900/30 text-cyan-600 dark:text-cyan-400 rounded-lg tooltip" title="{{ __('System Role') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
@@ -74,42 +72,42 @@
@endif
</div>
</td>
<td class="px-6 py-5">
<td class="px-6 py-6">
@if($role->is_system)
<span class="px-2.5 py-1 text-[11px] font-black uppercase tracking-tight bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400 rounded-full">
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-[10px] font-black bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700 uppercase tracking-widest">
{{ __('System') }}
</span>
@else
<span class="px-2.5 py-1 text-[11px] font-black uppercase tracking-tight bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 rounded-full">
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-[10px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-wider uppercase">
{{ __('Custom') }}
</span>
@endif
</td>
<td class="px-6 py-5">
<td class="px-6 py-6">
<div class="flex flex-wrap gap-1 max-w-xs">
@forelse($role->permissions->take(5) as $permission)
<span class="px-2 py-0.5 text-[10px] bg-slate-100 dark:bg-slate-800 text-slate-500 rounded uppercase font-bold">{{ __(str_replace('menu.', '', $permission->name)) }}</span>
@forelse($role->permissions->take(6) as $permission)
<span class="px-2 py-0.5 text-[10px] bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 rounded border border-slate-200 dark:border-slate-700 uppercase font-bold tracking-tight">{{ __(str_replace('menu.', '', $permission->name)) }}</span>
@empty
<span class="text-xs text-slate-400 italic">{{ __('No permissions') }}</span>
<span class="text-[11px] font-bold text-slate-400 italic tracking-tight">{{ __('No permissions') }}</span>
@endforelse
@if($role->permissions->count() > 5)
<span class="px-2 py-0.5 text-[10px] bg-slate-100 dark:bg-slate-800 text-slate-400 rounded uppercase font-bold">+{{ $role->permissions->count() - 5 }}</span>
@if($role->permissions->count() > 6)
<span class="px-2 py-0.5 text-[10px] bg-slate-100 dark:bg-slate-800 text-slate-400 rounded border border-slate-200 dark:border-slate-700 uppercase font-bold tracking-tight">+{{ $role->permissions->count() - 6 }}</span>
@endif
</div>
</td>
<td class="px-6 py-5 text-center">
<td class="px-6 py-6 text-center">
<span class="text-sm font-black text-slate-600 dark:text-slate-400">{{ $role->users()->count() }}</span>
</td>
<td class="px-6 py-5 text-right">
<div class="flex items-center justify-end gap-2 text-slate-400">
<button @click="openModal(true, '{{ $role->id }}', '{{ $role->name }}', {{ $role->permissions->pluck('name') }})" class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 hover:text-cyan-500 hover:bg-cyan-500/10 transition-all border border-transparent hover:border-cyan-500/20 shadow-sm tooltip" title="{{ __('Edit') }}">
<td class="px-6 py-6 text-right">
<div class="flex items-center justify-end gap-2">
<button @click="openModal(true, '{{ $role->id }}', '{{ $role->name }}', {{ $role->permissions->pluck('name') }})" class="p-2 rounded-xl bg-slate-50/50 dark:bg-slate-900/30 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/10 transition-all border border-transparent hover:border-cyan-500/20 shadow-sm tooltip" title="{{ __('Edit') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
</button>
@if(!$role->is_system)
<form action="{{ route('admin.permission.roles.destroy', $role->id) }}" method="POST" @submit.prevent="if(confirm('{{ __('Are you sure you want to delete this role?') }}')) $el.submit()" class="inline text-slate-400">
@csrf
@method('DELETE')
<button type="submit" class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 hover:text-rose-500 hover:bg-rose-500/10 transition-all border border-transparent hover:border-rose-500/20 shadow-sm tooltip" title="{{ __('Delete') }}">
<button type="submit" class="p-2 rounded-xl bg-slate-50/50 dark:bg-slate-900/30 text-slate-400 hover:text-rose-500 hover:bg-rose-500/10 transition-all border border-transparent hover:border-rose-500/20 shadow-sm tooltip" title="{{ __('Delete') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
</button>
</form>

View File

@@ -80,7 +80,7 @@
// 3. 處理最後一個動作/頁面
if ($lastSegment !== 'index') {
$pageLabel = match($lastSegment) {
'edit' => __('Edit'),
'edit' => str_starts_with($routeName, 'profile') ? null : __('Edit'),
'create' => __('Create'),
'show' => __('Detail'),
'logs' => __('Machine Logs'),

View File

@@ -47,6 +47,20 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
|--------------------------------------------------------------------------
| 專門用於機台通訊,頻率較高,建議搭配異步處理。
*/
Route::prefix('app')->middleware(['iot.auth', 'throttle:100,1'])->group(function () {
// 心跳與狀態 (B010, B017, B710, B220)
Route::post('machine/status/B010', [App\Http\Controllers\Api\V1\App\MachineController::class, 'heartbeat']);
Route::post('machine/reload_msg/B017', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getSlots']);
Route::post('machine/timer/B710', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncTimer']);
Route::post('machine/coins/B220', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncCoinInventory']);
Route::post('machine/member/verify/B650', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyMember']);
// 交易、發票與出貨 (B600, B601, B602)
Route::post('B600', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'store']);
Route::post('B601', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'recordInvoice']);
Route::post('B602', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'recordDispense']);
});
Route::prefix('machines')->group(function () {
Route::post('/{id}/logs', [\App\Http\Controllers\Api\V1\MachineController::class, 'storeLog']);
});