feat: 實作機台日誌核心功能與 IoT 高併發處理架構
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 36s

This commit is contained in:
2026-03-09 09:43:51 +08:00
parent 21e064ff91
commit c30c3a399d
53 changed files with 2300 additions and 2130 deletions

View File

@@ -1,528 +0,0 @@
---
trigger: always_on
---
STAR CLOUD 後台管理系統 - 智能販賣機管理平台
一、儀錶板模組 (Dashboard)
1.1 主頁面
功能描述: 系統總覽與關鍵數據展示
主要內容:
即時銷售數據
機台運行狀態
庫存警示
營收統計圖表
二、應用程式管理 (Application Management)
2.1 個人檔案
功能描述: 使用者個人資訊管理
包含功能:
基本資料編輯
密碼修改
通知設定
三、機台管理模組 (Machine Management)
3.1 機台日誌
功能描述: 機台操作歷史紀錄回溯
資料內容:
操作時間戳記
事件類型
操作人員
詳細描述
3.2 機台列表
功能描述: 所有機台資訊總覽
顯示資訊:
溫度監控
下位機狀態
刷卡機連線
掃碼機狀態
機台回傳訊息
3.3 機台權限
功能描述: 機台存取權限控管
設定項目:
人員權限分配
操作級別設定
3.4 機台稼動率
功能描述: 機台運行效率分析
統計數據:
運行時間
停機時間
稼動率百分比
3.5 效期管理
功能描述: 商品效期與貨道出貨控制
管理項目:
設定貨道是否可出貨
效期到期提醒
商品下架設定
3.6 維修管理單
功能描述: 機台維修工單系統
包含功能:
報修單建立
維修進度追蹤
維修歷史紀錄
3.7 機台管理擴充欄位
新增欄位:
保固區間
交機日期
租賃區間
保內/保外狀態顯示
3.8 機台設定參數
刷卡機秒數: 刷卡逾時設定
卡機結帳時間: 結帳流程時間限制
卡機結帳時間2: 備用結帳時間設定
金流緩衝時間: 金流處理緩衝時間
刷卡機編號: 刷卡機裝置識別
發票狀態碼: 發票開立狀態管理
3.9 APP到期提醒
功能描述: APP授權到期通知系統
四、APP管理模組 (APP Management)
4.1 UI元素設定
功能描述: APP版面配置設定
注意事項: 與新版差異較大,需特別處理
4.2 小幫手設定
功能描述: APP內建輔助功能設定
4.3 問卷設定
功能描述: 互動問卷建立與管理
4.4 互動遊戲設定
功能描述: APP互動遊戲配置
4.5 計時器
功能描述: 時間相關功能設定
五、倉庫管理模組 (Warehouse Management)
5.1 倉庫列表
5.1.1 倉庫列表(全部): 顯示所有倉庫
5.1.2 倉庫列表(個人): 顯示個人負責倉庫
5.2 庫存管理單
功能描述: 倉庫庫存異動管理
5.3 調撥單
功能描述: 倉庫間商品調撥作業
5.4 採購單
功能描述: 商品採購申請與管理
5.5 機台補貨管理
5.5.1 機台補貨單: 補貨工單建立
5.5.2 機台補貨紀錄: 個別補貨歷史
5.5.3 機台補貨紀錄(總): 所有補貨總覽
5.6 庫存查詢
5.6.1 機台庫存: 各機台即時庫存
5.6.2 人員庫存: 人員持有庫存
5.7 回庫單
功能描述: 商品退回倉庫管理
六、銷售管理模組 (Sales Management)
6.1 銷售&金流紀錄
功能描述: 銷售交易與金流明細
包含項目:
現金出貨 API
發票系統整合
各種出貨方式整理
6.2 取貨碼設定
功能描述: 取貨驗證碼管理
6.3 購買單
功能描述: 購買訂單管理
6.4 促銷時段設定
功能描述: 促銷活動時間設定
重要功能: (W) 重啟掃描商品 API
6.5 通行碼設定
功能描述: 特殊通行碼權限管理
6.6 來店禮設定
功能描述: 來店優惠活動設定
包含: 來店禮開關控制
七、分析管理模組 (Analysis Management)
7.1 零錢庫存分析
功能描述: 機台零錢數量監測與分析
7.2 機台報表分析
功能描述: 機台運營數據分析報表
7.3 商品報表分析
功能描述: 商品銷售數據分析
7.4 互動問卷分析
功能描述: 問卷結果統計與分析
八、稽核管理模組 (Audit Management)
8.1 採購單稽核
功能描述: 採購單審核流程
8.2 調撥單稽核
功能描述: 調撥單審核流程
8.3 補貨單稽核
功能描述: 補貨單審核流程
九、資料設定模組 (Data Configuration)
9.1 機台管理
功能描述: 機台基本資料設定
9.2 商品管理
功能描述: 商品資料維護
9.3 廣告管理
功能描述: 機台廣告影片管理
用途: 機台可讀取後台廣告影片
9.4 管理者可賣商品
功能描述: 管理者商品銷售權限
9.5 帳號管理
功能描述: 主帳號管理
9.6 子帳號管理
功能描述: 子帳號建立與管理
9.7 子帳號角色管理
功能描述: 子帳號權限角色設定
9.8 點數設定
功能描述: 客戶點數系統設定
特殊功能: 支援客戶自行新增點數
9.9 識別證管理
功能描述: 識別證資料管理
用途: 安霸系統使用
十、遠端管理模組 (Remote Management)
10.1 機台庫存
功能描述: 遠端修改機台庫存
10.2 機台重啟
功能描述: 遠端重啟機台系統
10.3 卡機重啟
功能描述: 遠端重啟刷卡機
10.4 遠端結帳
功能描述: 遠端執行結帳流程
10.5 遠端鎖定頁
功能描述: 遠端鎖定機台頁面
10.6 遠端找零
功能描述: 遠端執行找零功能
10.7 遠端出貨
功能描述: 遠端控制商品出貨
十一、Line管理模組 (Line Integration)
11.1 Line會員管理
功能描述: Line會員資料管理
11.2 Line機台管理
功能描述: Line綁定機台管理
11.3 Line商品管理
功能描述: Line商城商品設定
11.4 Line生活圈
功能描述: Line官方帳號整合
11.5 Line商城訂單
功能描述: Line商城訂單管理
11.6 Line優惠券
功能描述: Line優惠券發放與管理
十二、預約系統模組 (Reservation System)
12.1 Line會員管理
功能描述: 預約系統會員管理
12.2 Line店家管理
功能描述: 店家資訊設定
12.3 Line時段組合
功能描述: 預約時段設定
12.4 Line場地管理
功能描述: 場地資源管理
12.5 Line優惠券管理
功能描述: 預約優惠券管理
12.6 Line預約管理
功能描述: 預約單管理
12.7 Line訂單管理
功能描述: 預約訂單處理
十三、特殊權限管理模組 (Special Permissions)
13.1 庫存清空
功能描述: 特殊權限庫存清空功能
13.2 APK版本管理
功能描述: APP版本控制與更新
13.3 Discord通知設定
功能描述: Discord通知整合設定
十四、權限設定模組 (Permission Management)
14.1 功能權限設定
14.1.1 APP功能管理: APP功能權限
14.1.2 資料設定: 資料設定權限
14.1.3 銷售管理: 銷售管理權限
14.1.4 機台管理: 機台管理權限
14.1.5 倉庫管理: 倉庫管理權限
14.1.6 分析管理: 分析管理權限
14.1.7 稽核管理: 稽核管理權限
14.1.8 遠端管理: 遠端管理權限
14.1.9 Line管理: Line管理權限
14.2 權限角色設定
功能描述: 角色權限組合設定
14.3 其他功能管理
功能描述: 其他特殊功能權限
14.4 AI智能預測
功能描述: AI功能權限設定
資料庫設計建議
主要資料表規劃
machines # 機台資料表
├── warehouses # 倉庫資料表
├── products # 商品資料表
├── machine_stocks # 機台庫存表
├── warehouse_stocks # 倉庫庫存表
├── sales_records # 銷售紀錄表
├── purchase_orders # 採購單表
├── transfer_orders # 調撥單表
├── replenishment_orders # 補貨單表
├── maintenance_orders # 維修單表
├── machine_logs # 機台日誌表
├── users # 使用者表
├── roles # 角色表
├── permissions # 權限表
├── line_members # Line會員表
├── reservations # 預約表
└── advertisements # 廣告表
API 端點規劃
機台管理 API
GET /api/machines - 取得機台列表
POST /api/machines - 新增機台
PUT /api/machines/{id} - 更新機台
DELETE /api/machines/{id} - 刪除機台
GET /api/machines/{id}/logs - 取得機台日誌
POST /api/machines/{id}/restart - 遠端重啟
倉庫管理 API
GET /api/warehouses - 取得倉庫列表
GET /api/warehouses/{id}/stocks - 取得倉庫庫存
POST /api/transfer-orders - 建立調撥單
POST /api/purchase-orders - 建立採購單
POST /api/replenishment-orders - 建立補貨單
銷售管理 API
GET /api/sales - 取得銷售紀錄
POST /api/sales/cash - 現金出貨
GET /api/sales/invoice - 發票查詢
POST /api/pickup-codes - 建立取貨碼
遠端控制 API
POST /api/remote/checkout - 遠端結帳
POST /api/remote/dispense - 遠端出貨
POST /api/remote/change - 遠端找零
POST /api/remote/lock - 遠端鎖定
技術架構建議
後端技術
框架: Laravel 10+
資料庫: MySQL 8.0+
快取: Redis
佇列: Laravel Queue (Redis Driver)
API文件: Swagger/OpenAPI
前端技術
模板引擎: Blade
CSS框架: Tailwind CSS
JavaScript: Alpine.js / Vue.js
圖表庫: Chart.js / ApexCharts
第三方整合
Line API: Line Messaging API
Discord: Discord Webhook
金流: 藍新、綠界等
發票: 電子發票整合
開發優先順序建議
Phase 1 - 核心功能 (1-2個月)
使用者認證與權限系統
機台管理基本功能
倉庫管理基本功能
銷售紀錄查詢
Phase 2 - 進階功能 (2-3個月)
遠端控制功能
報表分析功能
稽核流程
APP管理功能
Phase 3 - 整合功能 (1-2個月)
Line整合
預約系統
AI智能預測
Discord通知
注意事項
安全性: 所有遠端控制功能需要雙重驗證
權限控管: 嚴格的角色權限分離
日誌記錄: 所有重要操作需記錄日誌
API限流: 防止API濫用
資料備份: 定期自動備份機制
錯誤處理: 完善的異常處理機制
測試: 重要功能需撰寫測試案例
RetryClaude can make mistakes. Please double-check responses.

View File

@@ -100,34 +100,21 @@ Request / Response 均採 JSON個資欄位請遵守最小授權原則。
### 3) Machine (機台) ### 3) Machine (機台)
* GET /api/v1/machines * **GET /api/v1/machines**
* Params: page, per_page, status
* Params: page, per_page, status * **GET /api/v1/machines/{id}**
* **POST /api/v1/machines/{id}/logs** (IoT)
* GET /api/v1/machines/{id} * 用於機台回傳日誌,後端固定走 **Redis Queue 異步寫入**。
* 回傳 `202 Accepted` 表示任務已接收,由 `ProcessMachineLog` 背景處理。
* POST /api/v1/machines * Request Example:
* PUT /api/v1/machines/{id}
* DELETE /api/v1/machines/{id}
* POST /api/v1/machines/{id}/status
* 用於下位機或 APP 回傳機台狀態
* request example:
```json ```json
{ {
"temperature": 23.4, "level": "info",
"status_code": "OK", "message": "Temperature stabilized at 23C",
"firmware_version": "1.2.3", "context": { "temp": 23.0 }
"timestamp": "2025-11-20T15:00:00Z"
} }
``` ```
* GET /api/v1/machines/{id}/logs
--- ---
### 4) Orders / ShoppingCart ### 4) Orders / ShoppingCart

View File

@@ -0,0 +1,78 @@
---
trigger: always_on
---
# 開發框架規範說明書Cloud 後台管理系統 (star-cloud)
## 1. 專案概述
* **目標**打造一個強大且穩定的智能販賣機後台管理系統Cloud 平台),負責管理機台、商品、銷售數據以及提供給端點機台串接的 API。
* **核心架構**:採用 **傳統單體式架構 (Monolithic Architecture)** 配 Laravel Blade 模板引擎進行伺服器端渲染 (SSR)。
* **工作流程**:後端處理業務邏輯與資料庫存取,並透過 Blade 引擎渲染包含 Tailwind CSS 類別的 HTML。前端互動行為由輕量級 Alpine.js 負責UI 元件以 Preline UI 為主體。
## 2. 技術棧 (Tech Stack)
* **後端框架**PHP 8.5 / Laravel 12
* **核心組件**Redis (用於高併發 IoT 隊列與快取,為系統穩定之必要條件)
* **前端視圖 (View)**Laravel Blade
* **前端互動 (JS)**Alpine.js (專注於行為,不負責渲染)
* **介面與樣式 (CSS)**Tailwind CSS + Preline UI (直接寫作於 Blade 模板中)
* **前端建置工具**Vite
* **資料庫**MySQL 8.0
* **開發環境**Laravel Sail (Docker / WSL2)
## 3. 目錄結構與慣例
### 3.1 後端 (Laravel)
與標準 Laravel 結構保持一致,無過度拆分的模組化(與 ERP 的 Modular Monolith 區別):
* **Controllers**`app/Http/Controllers/`,負責接收請求並回傳 `view()` 或 JSON。
* **Models**`app/Models/{Domain}/`,按領域分群 (例如 `Machine`, `Member`, `System`)。
* **Routes**`routes/web.php` 用於後台管理介面;`routes/api.php` 提供外部或機台調用介面 (需 V1 版本化)。
* **Services** (建議)`app/Services/{Domain}/`,將商業邏輯與資料異動封裝於 Service 中。
* **Traits**`app/Traits/ApiResponse.php` 用於統一 API JSON 回傳格式。
* **Jobs**`app/Jobs/{Domain}/`**高併發 IoT 場景之必要實作**。所有日誌、心跳上報必須進入 Redis Queue 進行背景異步處理,嚴禁在 API 直連 DB 寫入日誌。
### 3.2 前端 (Blade / Tailwind / Alpine)
* **Views (頁面)**:位於 `resources/views/`。通常依功能建立資料夾(如 `resources/views/admin/machines/index.blade.php`)。
* **Layouts (版面)**:位於 `resources/views/layouts/`。定義全站的共用版面結構(如 header, sidebar, footer
* **Components (組件)**:位於 `resources/views/components/`。封裝可重用的 Blade 元件(如 Button, Modal, Table支援透過 `<x-button>` 語法呼叫。
## 4. 開發標準 (Coding Standards)
* **命名規範**
* Controllers: `PascalCaseController.php` (例如 `MachineController.php`)
* Models: `PascalCase.php` (例如 `Machine.php`)
* Blade Views: `kebab-case.blade.php` 或按資源名稱 (例如 `index.blade.php`, `create.blade.php`)
* Routes uri: `kebab-case` (例如 `/machine-logs`)
* **回傳格式**
* Web 路由:回傳 `view()`,表單驗證失敗時直接使用 Laravel 內建的 redirect with errors。
* API 路由:回傳標準 JSON 格式的 `JsonResponse`
## 5. UI 與前端開發指南
* **樣式撰寫**:全面使用 Tailwind CSS utility classes**避免撰寫自訂 CSS**(除非少數特定動畫或覆寫)。
* **UI 元件庫**:遵循 **Preline UI** 的類別與 HTML 結構進行開發。
* **前端腳本**
* 優先使用 **Alpine.js** (`x-data`, `x-show`, `@click` 等) 在 HTML 標籤內完成簡單的 DOM 狀態切換與互動邏輯。
* 避免在 Blade 內撰寫冗長的 `<script>` Vanilla JS若邏輯過於複雜可將 Alpine state 獨立成 js 檔案再於 Vite 引入,但原則上保持輕量。
## 6. AI 協作規則 (給 Antigravity AI)
* **角色設定**:你是一位專業的全端開發工程師助手。
* **代碼生成指令**
* 所有的解釋說明請使用 **繁體中文**。
* **【警告】** 此專案前端禁用 React / Vue / Inertia.js。所有的前端頁面生成必須使用 **Blade 模板** 結合 **Tailwind CSS****Alpine.js**
* 生成 UI 區塊時,必須優先參考與產生 **Preline UI** 風格與結構的標記語法。
* 開發新功能時,請建立標準的 Controller 搭配對應的 `resources/views/.../` 目錄。
## 7. 運行機制 (Docker / Sail)
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
* **啟動環境**`./vendor/bin/sail up -d`
* **執行 PHP 指令**`./vendor/bin/sail php -v`
* **執行 Artisan 指令**`./vendor/bin/sail artisan route:list`
* **執行 Composer**`./vendor/bin/sail composer install`
* **執行 Node/NPM**`./vendor/bin/sail npm run dev`
## 8. 部署與查修環境 (CI/CD)
* **自動化部署**:專案具備基於 Gitea Actions 的 CI/CD 自動化部署流程 (`.gitea/workflows/`)。
* **Demo 環境 (對應 `demo` 分支)**
* 透過 `deploy-demo.yaml`,合併或推送到 `demo` 分支會自動部署至 `demo-cloud.taiwan-star.com.tw`
* 登入伺服器查修:`ssh gitea_work`,路徑為 `/var/www/star-cloud-demo`
* **Production 環境 (對應 `main` 分支)**
* 透過 `deploy-prod.yaml`,推進到 `main` 會自動部署至正式站。

View File

@@ -0,0 +1,31 @@
---
trigger: always_on
---
# 技能觸發規範 (Skill Trigger Rules)
本文件確保 AI 助手在對話中能**主動辨識**需要參照技能 (Skill) 的時機。
Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
**若對話內容命中以下任一觸發條件,必須先使用 `view_file` 讀取對應的 `SKILL.md` 後再進行作業。**
---
## 觸發對照表
| 觸發詞 / 情境 | 對應 Skill | 路徑 |
|---|---|---|
| 機台通訊, IoT, 日誌上報, Log Ingestion, 異步隊列, Queue, Heartbeat, 心跳發報 | **IoT 通訊與高併發處理規範** | `.agents/skills/iot-communication/SKILL.md` |
---
## 強制觸發場景
以下場景**無論對話中是否出現觸發詞**,都必須主動載入對應 Skill
### 🔴 新增機台通訊 API 端點時
必須讀取:
1. **iot-communication** — 決定是否使用異步隊列流程
### 🔴 修改 Job 或 Service 邏輯時
必須讀取:
1. **iot-communication** — 確保符合高併發處理架構

View File

@@ -0,0 +1,63 @@
---
name: IoT 通訊與高併發處理規範
description: 規範智能販賣機與 Cloud 平台間的高頻通訊處理流程,包含 API 接收、異步隊列、業務邏輯拆分與日誌記錄。
---
# IoT 通訊與高併發處理規範 (IoT Communication Skill)
本規範確保 Star-Cloud 系統在處理成千上萬台機台的高頻發報時,能維持伺服器響應速度與資料一致性。
## 1. 處理管線 (Processing Pipeline)
所有來自機台的非即時性資料(日誌、心跳、狀態上報)必須遵循以下 pipeline
1. **API Controller (接收層)**:驗證 Request 合法性,隨即分派 (Dispatch) 任務至 Queue並回傳 `202 Accepted`
2. **Job (異步層)**:由背景 Worker 讀取隊列任務,呼叫對應 Service 處理。
3. **Service (邏輯層)**:封裝商業邏輯,更新資料庫。
4. **Model (儲存層)**:執行資料存取。
> [!IMPORTANT]
> **嚴禁**在 API Controller 直接進行資料庫寫入操作(針對機台發訊端點)。
## 2. 異步任務實作範例
### 2.1 API Endpoint
```php
public function storeLog(Request $request, int $id): JsonResponse
{
// 1. 驗證
$data = $request->validate([...]);
// 2. 異步分派
ProcessMachineLog::dispatch($id, $data);
// 3. 快速回應
return $this->successResponse([], 'Accepted', 202);
}
```
### 2.2 Job 處理邏輯
Job 應保持單純,僅作為 Service 的觸發點:
```php
public function handle(MachineService $service): void
{
$service->recordLog($this->machineId, $this->logData);
}
```
## 3. 隊列配置規範
- **連接驅動 (Driver)**:預設使用 `Redis` 以確保高併發吞吐量。
- **重試機制 (Retry)**IoT 任務應設定合理的重試次數,避免因網路短暫波動遺失日誌。
- **失敗處理 (Failed Jobs)**:關鍵業務(如訂單同步)必須監控 `failed_jobs`
## 4. 速率限制 (Rate Limiting)
- 所有的 IoT API 必須在 `routes/api.php` 中使用 `throttle:api` 或自定義 Middleware。
- 針對單一機台 ID 應限制其每一分鐘的最高連線數,防止遭受攻擊或機台 Bug 導致的連線暴衝。
## 5. 檢核項目 (Checklist)
- [ ] 是否使用了 `ApiResponse` Trait
- [ ] 業務邏輯是否已封裝至 `App\Services`
- [ ] 是否使用了 Redis Queue 進行非同步處理?
- [ ] 是否在 API 層級進行了基礎的參數驗證?

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Console\Commands\Machine;
use App\Models\Machine\Machine;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
class SimulateMachineLogs extends Command
{
/**
* @var string
*/
protected $signature = 'simulate:machine-logs {--count=10 : 發送日誌的次數}';
/**
* @var string
*/
protected $description = '模擬機台發送 API 日誌請求到後端 (用於壓測與驗證 Queue)';
public function handle(): void
{
$count = (int) $this->option('count');
$machines = Machine::all();
if ($machines->isEmpty()) {
$this->error('No machines found. Please run MachineSeeder first.');
return;
}
$this->info("Starting simulation of {$count} logs...");
$bar = $this->output->createProgressBar($count);
$bar->start();
// 由於是在同一個開發環境,且在 Sail 容器內部執行,
// 外部 8090 埠對應容器內部 8080 埠。
$baseUrl = 'http://localhost:8080/api/v1/machines/';
for ($i = 0; $i < $count; $i++) {
$machine = $machines->random();
$level = collect(['info', 'warning', 'error'])->random();
try {
Http::post($baseUrl . $machine->id . '/logs', [
'level' => $level,
'message' => "Simulated message #{$i} for machine {$machine->name}",
'context' => [
'simulated' => true,
'timestamp' => now()->toIso8601String(),
]
]);
} catch (\Exception $e) {
$this->error("\nFailed to send log: " . $e->getMessage());
}
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info('Simulation completed.');
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
abstract class AdminController extends Controller
{
// Admin 相關的共用邏輯可寫於此
}

View File

@@ -2,142 +2,36 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Models\Machine\Machine;
use App\Models\Machine;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View;
class MachineController extends Controller class MachineController extends AdminController
{ {
/** /**
* Display a listing of the resource. * 顯示所有機台列表
*/ */
public function index() public function index(Request $request): View
{ {
$machines = Machine::latest()->paginate(10); $machines = Machine::query()
->when($request->status, function ($query, $status) {
return $query->where('status', $status);
})
->latest()
->paginate(10);
return view('admin.machines.index', compact('machines')); return view('admin.machines.index', compact('machines'));
} }
/** /**
* Show the form for creating a new resource. * 顯示特定機台的日誌與詳細資訊
*/ */
public function create() public function show(int $id): View
{ {
return view('admin.machines.create'); $machine = Machine::with(['logs' => function ($query) {
} $query->latest()->limit(50);
}])->findOrFail($id);
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'location' => 'nullable|string|max:255',
'status' => 'required|in:online,offline,error',
'temperature' => 'nullable|numeric',
'firmware_version' => 'nullable|string|max:50',
]);
Machine::create($validated);
return redirect()->route('admin.machines.index')
->with('success', '機台建立成功');
}
/**
* Display the specified resource.
*/
public function show(Machine $machine)
{
return view('admin.machines.show', compact('machine')); return view('admin.machines.show', compact('machine'));
} }
/**
* Show the form for editing the specified resource.
*/
public function edit(Machine $machine)
{
return view('admin.machines.edit', compact('machine'));
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Machine $machine)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'location' => 'nullable|string|max:255',
'status' => 'required|in:online,offline,error',
'temperature' => 'nullable|numeric',
'firmware_version' => 'nullable|string|max:50',
]);
$machine->update($validated);
return redirect()->route('admin.machines.index')
->with('success', '機台更新成功');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Machine $machine)
{
$machine->delete();
return redirect()->route('admin.machines.index')
->with('success', '機台已刪除');
}
// 機台日誌
public function logs()
{
return view('admin.placeholder', [
'title' => '機台日誌',
'description' => '機台操作歷史紀錄回溯',
'features' => [
'操作時間戳記',
'事件類型分類',
'操作人員記錄',
'詳細描述查詢',
]
]);
}
// 機台權限
public function permissions()
{
return view('admin.placeholder', [
'title' => '機台權限',
'description' => '機台存取權限控管',
]);
}
// 機台稼動率
public function utilization()
{
return view('admin.placeholder', [
'title' => '機台稼動率',
'description' => '機台運行效率分析',
]);
}
// 效期管理
public function expiry()
{
return view('admin.placeholder', [
'title' => '效期管理',
'description' => '商品效期與貨道出貨控制',
]);
}
// 維修管理單
public function maintenance()
{
return view('admin.placeholder', [
'title' => '維修管理單',
'description' => '機台維修工單系統',
]);
}
} }

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Traits\ApiResponse;
abstract class ApiController extends Controller
{
use ApiResponse;
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Jobs\Machine\ProcessMachineLog;
use App\Models\Machine\Machine;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class MachineController extends ApiController
{
/**
* 接收機台回傳的日誌 (IoT Endpoint)
* 採用異步處理 (Queue)
*/
public function storeLog(Request $request, int $id): JsonResponse
{
$validator = Validator::make($request->all(), [
'level' => 'required|string|in:info,warning,error',
'message' => 'required|string',
'context' => 'nullable|array',
]);
if ($validator->fails()) {
return $this->errorResponse('Validation error', 422, $validator->errors());
}
// 檢查機台是否存在
if (!Machine::where('id', $id)->exists()) {
return $this->errorResponse('Machine not found', 404);
}
// 丟入隊列進行異步處理,回傳 202 Accepted
ProcessMachineLog::dispatch($id, $request->only(['level', 'message', 'context']));
return $this->successResponse([], 'Log accepted. Processing asynchronously.', 202);
}
}

View File

@@ -3,7 +3,7 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\System\User;
use App\Providers\RouteServiceProvider; use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;

View File

@@ -2,7 +2,7 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Models\User; use App\Models\System\User;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;

View File

@@ -0,0 +1,46 @@
<?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 ProcessMachineLog implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* @var int
*/
protected $machineId;
/**
* @var array
*/
protected $logData;
public function __construct(int $machineId, array $logData)
{
$this->machineId = $machineId;
$this->logData = $logData;
}
public function getMachineId(): int
{
return $this->machineId;
}
public function handle(MachineService $service): void
{
try {
$service->recordLog($this->machineId, $this->logData);
} catch (\Exception $e) {
Log::error("Failed to process machine log for machine {$this->machineId}: " . $e->getMessage());
}
}
}

View File

@@ -1,12 +1,14 @@
<?php <?php
namespace App\Models; namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class Machine extends Model class Machine extends Model
{ {
use HasFactory;
protected $fillable = [ protected $fillable = [
'name', 'name',
'location', 'location',

View File

@@ -1,12 +1,16 @@
<?php <?php
namespace App\Models; namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class MachineLog extends Model class MachineLog extends Model
{ {
use HasFactory;
const UPDATED_AT = null;
protected $fillable = [ protected $fillable = [
'machine_id', 'machine_id',
'level', 'level',

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Models; namespace App\Models\Member;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Models; namespace App\Models\Member;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Models; namespace App\Models\Member;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Models; namespace App\Models\Member;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Models; namespace App\Models\Member;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Models; namespace App\Models\Member;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Models; namespace App\Models\Member;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Models; namespace App\Models\Member;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Models; namespace App\Models\Member;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Models; namespace App\Models\Member;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Models; namespace App\Models\Member;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Models; namespace App\Models\Member;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Models; namespace App\Models\System;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Models; namespace App\Models\System;
// use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Services\Machine;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineLog;
use Illuminate\Support\Facades\Log;
class MachineService
{
/**
* 處理機台日誌寫入與狀態更新
*/
public function recordLog(int $machineId, array $data): MachineLog
{
$machine = Machine::findOrFail($machineId);
// 建立日誌紀錄
$log = $machine->logs()->create([
'level' => $data['level'] ?? 'info',
'message' => $data['message'],
'context' => $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,49 @@
<?php
namespace App\Traits;
use Illuminate\Http\JsonResponse;
trait ApiResponse
{
/**
* 回傳成功的回應
*
* @param mixed $data
* @param string $message
* @param int $code
* @return JsonResponse
*/
public function successResponse($data = [], string $message = 'OK', int $code = 200): JsonResponse
{
return response()->json([
'success' => true,
'code' => $code,
'message' => $message,
'data' => empty($data) ? new \stdClass() : $data, // 確保前端收到的是 Object 而非 Empty Array
], $code);
}
/**
* 回傳錯誤的回應
*
* @param string $message
* @param int $code
* @param mixed $errors
* @return JsonResponse
*/
public function errorResponse(string $message, int $code = 400, $errors = null): JsonResponse
{
$response = [
'success' => false,
'code' => $code,
'message' => $message,
];
if (!is_null($errors)) {
$response['errors'] = $errors;
}
return response()->json($response, $code);
}
}

49
artisan
View File

@@ -1,53 +1,18 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true)); define('LARAVEL_START', microtime(true));
/* // Register the Composer autoloader...
|--------------------------------------------------------------------------
| Register The Auto Loader
|--------------------------------------------------------------------------
|
| Composer provides a convenient, automatically generated class loader
| for our application. We just need to utilize it! We'll require it
| into the script here so that we do not have to worry about the
| loading of any of our classes manually. It's great to relax.
|
*/
require __DIR__.'/vendor/autoload.php'; require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php'; $app = require_once __DIR__.'/bootstrap/app.php';
/* $status = $app->handleCommand(new ArgvInput);
|--------------------------------------------------------------------------
| Run The Artisan Application
|--------------------------------------------------------------------------
|
| When we run the console application, the current CLI command will be
| executed in this console and the response sent back to a terminal
| or another output device for the developers. Here goes nothing!
|
*/
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$status = $kernel->handle(
$input = new Symfony\Component\Console\Input\ArgvInput,
new Symfony\Component\Console\Output\ConsoleOutput
);
/*
|--------------------------------------------------------------------------
| Shutdown The Application
|--------------------------------------------------------------------------
|
| Once Artisan has finished running, we will fire off the shutdown events
| so that any final work may be done by the application before we shut
| down the process. This is the last thing to happen to the request.
|
*/
$kernel->terminate($input, $status);
exit($status); exit($status);

View File

@@ -2,24 +2,27 @@
"name": "laravel/laravel", "name": "laravel/laravel",
"type": "project", "type": "project",
"description": "The skeleton application for the Laravel framework.", "description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"], "keywords": [
"laravel",
"framework"
],
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.1", "php": "^8.2",
"guzzlehttp/guzzle": "^7.2", "guzzlehttp/guzzle": "^7.8",
"laravel/framework": "^10.10", "laravel/framework": "^12.0",
"laravel/sanctum": "^3.3", "laravel/sanctum": "^4.3",
"laravel/tinker": "^2.8" "laravel/tinker": "^2.10.1"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.9.1", "fakerphp/faker": "^1.23",
"laravel/breeze": "^1.29", "laravel/breeze": "^2.0",
"laravel/pint": "^1.0", "laravel/pail": "^1.2.2",
"laravel/sail": "^1.18", "laravel/pint": "^1.24",
"mockery/mockery": "^1.4.4", "laravel/sail": "^1.41",
"nunomaduro/collision": "^7.0", "mockery/mockery": "^1.6",
"phpunit/phpunit": "^10.1", "nunomaduro/collision": "^8.6",
"spatie/laravel-ignition": "^2.0" "phpunit/phpunit": "^11.5.3"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@@ -64,4 +67,4 @@
}, },
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true "prefer-stable": true
} }

2631
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -62,7 +62,7 @@ return [
'providers' => [ 'providers' => [
'users' => [ 'users' => [
'driver' => 'eloquent', 'driver' => 'eloquent',
'model' => App\Models\User::class, 'model' => App\Models\System\User::class,
], ],
// 'users' => [ // 'users' => [

View File

@@ -0,0 +1,23 @@
<?php
namespace Database\Factories\Machine;
use App\Models\Machine\Machine;
use Illuminate\Database\Eloquent\Factories\Factory;
class MachineFactory extends Factory
{
protected $model = Machine::class;
public function definition(): array
{
return [
'name' => 'Machine-' . $this->faker->unique()->numberBetween(100, 999),
'location' => $this->faker->address(),
'status' => $this->faker->randomElement(['online', 'offline', 'error']),
'temperature' => $this->faker->randomFloat(2, 2, 10),
'firmware_version' => 'v' . $this->faker->randomElement(['1.0.0', '1.1.2', '2.0.1']),
'last_heartbeat_at' => $this->faker->dateTimeBetween('-1 day', 'now'),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Database\Factories\Machine;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineLog;
use Illuminate\Database\Eloquent\Factories\Factory;
class MachineLogFactory extends Factory
{
protected $model = MachineLog::class;
public function definition(): array
{
return [
'machine_id' => Machine::factory(),
'level' => $this->faker->randomElement(['info', 'warning', 'error']),
'message' => $this->faker->sentence(),
'context' => [
'ip' => $this->faker->ipv4(),
'uptime' => $this->faker->numberBetween(1000, 100000),
],
'created_at' => now(),
];
}
}

View File

@@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; use Illuminate\Support\Str;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User> * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\System\User>
*/ */
class UserFactory extends Factory class UserFactory extends Factory
{ {

View File

@@ -2,7 +2,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User; use App\Models\System\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;

View File

@@ -0,0 +1,21 @@
<?php
namespace Database\Seeders;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineLog;
use Illuminate\Database\Seeder;
class MachineSeeder extends Seeder
{
public function run(): void
{
// 建立 50 台機台
Machine::factory()->count(50)->create()->each(function ($machine) {
// 每台機台隨機建立 5-10 筆初始日誌
MachineLog::factory()->count(rand(5, 10))->create([
'machine_id' => $machine->id,
]);
});
}
}

View File

@@ -21,8 +21,8 @@
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/> <env name="CACHE_DRIVER" value="array"/>
<!-- <env name="DB_CONNECTION" value="sqlite"/> --> <env name="DB_CONNECTION" value="sqlite"/>
<!-- <env name="DB_DATABASE" value=":memory:"/> --> <env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/> <env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>

View File

@@ -1,55 +1,20 @@
<?php <?php
use Illuminate\Contracts\Http\Kernel; use Illuminate\Foundation\Application;
use Illuminate\Http\Request; use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true)); define('LARAVEL_START', microtime(true));
/* // Determine if the application is in maintenance mode...
|--------------------------------------------------------------------------
| Check If The Application Is Under Maintenance
|--------------------------------------------------------------------------
|
| If the application is in maintenance / demo mode via the "down" command
| we will load this file so that any pre-rendered content can be shown
| instead of starting the framework, which could cause an exception.
|
*/
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) { if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance; require $maintenance;
} }
/* // Register the Composer autoloader...
|--------------------------------------------------------------------------
| Register The Auto Loader
|--------------------------------------------------------------------------
|
| Composer provides a convenient, automatically generated class loader for
| this application. We just need to utilize it! We'll simply require it
| into the script here so we don't need to manually load our classes.
|
*/
require __DIR__.'/../vendor/autoload.php'; require __DIR__.'/../vendor/autoload.php';
/* // Bootstrap Laravel and handle the request...
|-------------------------------------------------------------------------- /** @var Application $app */
| Run The Application
|--------------------------------------------------------------------------
|
| Once we have the application, we can handle the incoming request using
| the application's HTTP kernel. Then, we will send the response back
| to this client's browser, allowing them to enjoy our application.
|
*/
$app = require_once __DIR__.'/../bootstrap/app.php'; $app = require_once __DIR__.'/../bootstrap/app.php';
$kernel = $app->make(Kernel::class); $app->handleRequest(Request::capture());
$response = $kernel->handle(
$request = Request::capture()
)->send();
$kernel->terminate($request, $response);

34
refactor_tests2.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
$dirs = ['tests', 'database', 'app'];
$files = [];
foreach ($dirs as $dirName) {
// Note: sail runs in /var/www/html
if (!is_dir(__DIR__ . '/' . $dirName)) continue;
$dir = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__ . '/' . $dirName));
foreach ($dir as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$files[] = $file->getPathname();
}
}
}
$replacements = [
'App\Models\User' => 'App\Models\System\User',
];
foreach ($files as $file) {
$content = file_get_contents($file);
$original = $content;
foreach ($replacements as $old => $new) {
$content = str_replace($old, $new, $content);
}
if ($content !== $original) {
file_put_contents($file, $content);
echo "Updated: $file\n";
}
}
echo "Done.\n";

View File

@@ -1,74 +1,80 @@
@extends('layouts.admin') @extends('layouts.admin')
@section('content') @section('header')
@php <h2 class="font-semibold text-xl text-gray-800 leading-tight">
@endphp {{ __('機台管理') }}
<div class="container mx-auto px-6 py-8"> </h2>
<div class="flex justify-between items-center"> @endsection
<h3 class="text-gray-900 dark:text-gray-200 text-3xl font-medium">機台管理</h3>
<a href="{{ route('admin.machines.create') }}" class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded">
新增機台
</a>
</div>
<div class="mt-8"> @section('content')
<div class="flex flex-col"> <div class="py-12">
<div class="-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="align-middle inline-block min-w-full shadow overflow-hidden sm:rounded-lg border-b border-gray-200 dark:border-gray-700"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg border border-gray-200">
<table class="min-w-full"> <div class="p-6 text-gray-900">
<thead> <div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-medium">機台列表</h3>
<div class="flex space-x-2">
<a href="{{ route('admin.machines.index') }}" class="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-md text-sm transition">全部</a>
<a href="{{ route('admin.machines.index', ['status' => 'online']) }}" class="px-4 py-2 bg-green-100 text-green-700 hover:bg-green-200 rounded-md text-sm transition">線上</a>
<a href="{{ route('admin.machines.index', ['status' => 'error']) }}" class="px-4 py-2 bg-red-100 text-red-700 hover:bg-red-200 rounded-md text-sm transition">異常</a>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr> <tr>
<th class="px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-left text-xs leading-4 font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wider">名稱</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">名稱</th>
<th class="px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-left text-xs leading-4 font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wider">位置</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">位置</th>
<th class="px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-left text-xs leading-4 font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wider">狀態</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">狀態</th>
<th class="px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-left text-xs leading-4 font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wider">溫度</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">溫度</th>
<th class="px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-left text-xs leading-4 font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wider">最後心跳</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">最後心跳</th>
<th class="px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-left text-xs leading-4 font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wider">操作</th> <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white dark:bg-gray-800"> <tbody class="bg-white divide-y divide-gray-200">
@foreach($machines as $machine) @foreach ($machines as $machine)
<tr> <tr class="hover:bg-gray-50 transition">
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 dark:border-gray-700"> <td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm leading-5 font-medium text-gray-900 dark:text-gray-200">{{ $machine->name }}</div> <div class="text-sm font-medium text-gray-900">{{ $machine->name }}</div>
<div class="text-xs text-gray-500">{{ $machine->firmware_version }}</div>
</td> </td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 dark:border-gray-700"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div class="text-sm leading-5 text-gray-600 dark:text-gray-400">{{ $machine->location ?? '-' }}</div> {{ $machine->location }}
</td> </td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 dark:border-gray-700"> <td class="px-6 py-4 whitespace-nowrap">
@if($machine->status === 'online') @php
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">連線中</span> $statusClasses = [
@elseif($machine->status === 'offline') 'online' => 'bg-green-100 text-green-800',
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">離線</span> 'offline' => 'bg-gray-100 text-gray-800',
@else 'error' => 'bg-red-100 text-red-800',
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">異常</span> ];
@endif $class = $statusClasses[$machine->status] ?? 'bg-blue-100 text-blue-800';
@endphp
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ $class }}">
{{ strtoupper($machine->status) }}
</span>
</td> </td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 dark:border-gray-700"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div class="text-sm leading-5 text-gray-600 dark:text-gray-400">{{ $machine->temperature ? $machine->temperature . '°C' : '-' }}</div> {{ $machine->temperature ?? '--' }} °C
</td> </td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 dark:border-gray-700"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div class="text-sm leading-5 text-gray-600 dark:text-gray-400">{{ $machine->last_heartbeat_at ? $machine->last_heartbeat_at->diffForHumans() : '-' }}</div> {{ $machine->last_heartbeat_at ? $machine->last_heartbeat_at->diffForHumans() : '從未連線' }}
</td> </td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 dark:border-gray-700 text-sm leading-5 font-medium"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ route('admin.machines.show', $machine) }}" class="text-indigo-400 hover:text-indigo-600 mr-3">查看</a> <a href="{{ route('admin.machines.show', $machine->id) }}" class="text-indigo-600 hover:text-indigo-900 bg-indigo-50 px-3 py-1 rounded-md transition border border-indigo-100">查看日誌</a>
<a href="{{ route('admin.machines.edit', $machine) }}" class="text-yellow-400 hover:text-yellow-600 mr-3">編輯</a>
<form action="{{ route('admin.machines.destroy', $machine) }}" method="POST" class="inline-block" onsubmit="return confirm('確定要刪除嗎?');">
@csrf
@method('DELETE')
<button type="submit" class="text-red-400 hover:text-red-600">刪除</button>
</form>
</td> </td>
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="mt-6">
{{ $machines->links() }}
</div>
</div> </div>
</div> </div>
<div class="mt-4">
{{ $machines->links() }}
</div>
</div> </div>
</div> </div>
@endsection @endsection

View File

@@ -1,78 +1,85 @@
@extends('layouts.admin') @extends('layouts.admin')
@section('content') @section('header')
<div class="container mx-auto px-6 py-8">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<h3 class="text-gray-900 dark:text-gray-300 text-3xl font-medium">機台詳情:{{ $machine->name }}</h3> <h2 class="font-semibold text-xl text-gray-800 leading-tight">
<div> 機台詳情: {{ $machine->name }}
<a href="{{ route('admin.machines.edit', $machine) }}" class="bg-yellow-600 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded mr-2"> </h2>
編輯 <a href="{{ route('admin.machines.index') }}" class="text-sm text-gray-600 hover:text-gray-900"> 返回列表</a>
</a>
<a href="{{ route('admin.machines.index') }}" class="bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-800 dark:text-white font-bold py-2 px-4 rounded">
返回列表
</a>
</div>
</div> </div>
@endsection
<div class="mt-8 grid grid-cols-1 md:grid-cols-2 gap-6"> @section('content')
<!-- Basic Info --> <div class="py-12">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl overflow-hidden p-6"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<h4 class="text-xl font-semibold text-gray-200 mb-4">基本資訊</h4> <!-- 基本資訊卡片 -->
<div class="grid grid-cols-2 gap-4"> <div class="bg-white shadow sm:rounded-lg p-6 border border-gray-200">
<h3 class="text-lg font-medium text-gray-900 mb-4 border-b pb-2">基本資訊</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div> <div>
<p class="text-sm text-gray-700 dark:text-gray-400">位置</p> <p class="text-xs text-gray-500 uppercase">當前狀態</p>
<p class="text-lg text-gray-200">{{ $machine->location ?? '-' }}</p> <p class="text-lg font-bold {{ $machine->status === 'online' ? 'text-green-600' : ($machine->status === 'error' ? 'text-red-600' : 'text-gray-600') }}">
{{ strtoupper($machine->status) }}
</p>
</div> </div>
<div> <div>
<p class="text-sm text-gray-700 dark:text-gray-400">狀態</p> <p class="text-xs text-gray-500 uppercase">位置</p>
@if($machine->status === 'online') <p class="text-sm">{{ $machine->location }}</p>
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">連線中</span>
@elseif($machine->status === 'offline')
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">離線</span>
@else
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">異常</span>
@endif
</div> </div>
<div> <div>
<p class="text-sm text-gray-700 dark:text-gray-400">溫度</p> <p class="text-xs text-gray-500 uppercase">最後心跳時間</p>
<p class="text-lg text-gray-200">{{ $machine->temperature ? $machine->temperature . '°C' : '-' }}</p> <p class="text-sm">{{ $machine->last_heartbeat_at ?? 'N/A' }}</p>
</div>
<div>
<p class="text-sm text-gray-700 dark:text-gray-400">韌體版本</p>
<p class="text-lg text-gray-200">{{ $machine->firmware_version ?? '-' }}</p>
</div>
<div>
<p class="text-sm text-gray-700 dark:text-gray-400">最後心跳</p>
<p class="text-lg text-gray-200">{{ $machine->last_heartbeat_at ? $machine->last_heartbeat_at->diffForHumans() : '-' }}</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Logs --> <!-- 日誌顯示區 -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl overflow-hidden p-6"> <div class="bg-gray-900 shadow sm:rounded-lg overflow-hidden border border-gray-700">
<h4 class="text-xl font-semibold text-gray-200 mb-4">最近日誌</h4> <div class="p-4 bg-gray-800 border-b border-gray-700 flex justify-between items-center">
<div class="overflow-y-auto max-h-64"> <h3 class="text-md font-medium text-gray-200">即時操作日誌 (最後 50 )</h3>
<ul class="divide-y divide-gray-700"> <span class="text-xs text-gray-400">所有時間為系統時區</span>
@forelse($machine->logs()->latest()->take(10)->get() as $log) </div>
<li class="py-3"> <div class="p-0 max-h-[600px] overflow-y-auto">
<div class="flex items-center justify-between"> <table class="min-w-full divide-y divide-gray-800 font-mono text-xs">
<div class="flex items-center"> <thead class="bg-gray-800 sticky top-0">
@if($log->level === 'error') <tr>
<span class="h-2 w-2 rounded-full bg-red-500 mr-2"></span> <th class="px-4 py-2 text-left text-gray-500">時間</th>
@elseif($log->level === 'warning') <th class="px-4 py-2 text-left text-gray-500">層級</th>
<span class="h-2 w-2 rounded-full bg-yellow-500 mr-2"></span> <th class="px-4 py-2 text-left text-gray-500">訊息</th>
@else </tr>
<span class="h-2 w-2 rounded-full bg-blue-500 mr-2"></span> </thead>
<tbody class="divide-y divide-gray-800">
@forelse ($machine->logs as $log)
<tr class="hover:bg-gray-800/50">
<td class="px-4 py-2 text-gray-400 whitespace-nowrap">{{ $log->created_at->format('Y-m-d H:i:s') }}</td>
<td class="px-4 py-2">
@php
$levelClasses = [
'info' => 'text-blue-400',
'warning' => 'text-yellow-400',
'error' => 'text-red-400 font-bold',
];
@endphp
<span class="{{ $levelClasses[$log->level] ?? 'text-gray-300' }}">
[{{ strtoupper($log->level) }}]
</span>
</td>
<td class="px-4 py-2 text-gray-200">
{{ $log->message }}
@if($log->context)
<div class="text-[10px] text-gray-500 mt-1">
{{ json_encode($log->context) }}
</div>
@endif @endif
<p class="text-sm text-gray-900 dark:text-gray-300">{{ $log->message }}</p> </td>
</div> </tr>
<span class="text-xs text-gray-500">{{ $log->created_at->format('m/d H:i') }}</span> @empty
</div> <tr>
</li> <td colspan="3" class="px-4 py-8 text-center text-gray-500 italic">暫無相關日誌</td>
@empty </tr>
<li class="py-3 text-center text-gray-500">尚無日誌</li> @endforelse
@endforelse </tbody>
</ul> </table>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,39 +2,53 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\MemberController; use App\Http\Controllers\Api\V1\MemberController;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| API Routes | API Routes
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Here is where you can register API routes for your application. These | 這裡註冊所有的 API 路由,預設套用 api middleware group。
| routes are loaded by the RouteServiceProvider and all of them will | 加入 v1 前綴與 throttle 進行速率限制防護。
| be assigned to the "api" middleware group. Make something great!
| |
*/ */
Route::middleware('auth:sanctum')->get('/user', function (Request $request) { Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
return $request->user();
}); // 基本的使用者資料查詢
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| 會員 API Routes | 會員 API Routes
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
// 公開路由(無需認證)
Route::prefix('members')->group(function () {
Route::post('/register', [MemberController::class, 'register']);
Route::post('/login', [MemberController::class, 'login']);
Route::post('/social-login', [MemberController::class, 'socialLogin']);
});
// 公開路由(無需認證 // 需認證路由
Route::prefix('members')->group(function () { Route::prefix('members')->middleware('auth:sanctum')->group(function () {
Route::post('/register', [MemberController::class, 'register']); Route::get('/profile', [MemberController::class, 'profile']);
Route::post('/login', [MemberController::class, 'login']); Route::put('/profile', [MemberController::class, 'updateProfile']);
Route::post('/social-login', [MemberController::class, 'socialLogin']); Route::post('/logout', [MemberController::class, 'logout']);
}); });
/*
|--------------------------------------------------------------------------
| 機台 API Routes (IoT)
|--------------------------------------------------------------------------
| 專門用於機台通訊,頻率較高,建議搭配異步處理。
*/
Route::prefix('machines')->group(function () {
Route::post('/{id}/logs', [\App\Http\Controllers\Api\V1\MachineController::class, 'storeLog']);
});
// 需認證路由
Route::prefix('members')->middleware('auth:sanctum')->group(function () {
Route::get('/profile', [MemberController::class, 'profile']);
Route::put('/profile', [MemberController::class, 'updateProfile']);
Route::post('/logout', [MemberController::class, 'logout']);
}); });

View File

@@ -0,0 +1,68 @@
<?php
namespace Tests\Feature\Api\V1;
use App\Jobs\Machine\ProcessMachineLog;
use App\Models\Machine\Machine;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class MachineLogTest extends TestCase
{
use RefreshDatabase;
/**
* 測試機台日誌 API 是否能正確接收並派發 Job
*/
public function test_machine_can_send_log_and_dispatch_job(): void
{
Queue::fake();
$machine = Machine::factory()->create();
$response = $this->postJson("/api/v1/machines/{$machine->id}/logs", [
'level' => 'info',
'message' => 'Test log message',
'context' => ['foo' => 'bar'],
]);
$response->assertStatus(202)
->assertJson([
'success' => true,
'message' => 'Log accepted. Processing asynchronously.',
]);
Queue::assertPushed(ProcessMachineLog::class, function ($job) use ($machine) {
return $job->getMachineId() === $machine->id;
});
}
/**
* 測試不存在的機台應回傳 404
*/
public function test_send_log_to_non_existent_machine_returns_404(): void
{
$response = $this->postJson("/api/v1/machines/999/logs", [
'level' => 'info',
'message' => 'Should fail',
]);
$response->assertStatus(404);
}
/**
* 測試屬性驗證失敗
*/
public function test_send_invalid_log_data_returns_422(): void
{
$machine = Machine::factory()->create();
$response = $this->postJson("/api/v1/machines/{$machine->id}/logs", [
'level' => 'invalid-level', // 不符合 in:info,warning,error
'message' => '', // 必填
]);
$response->assertStatus(422);
}
}

View File

@@ -2,7 +2,7 @@
namespace Tests\Feature\Auth; namespace Tests\Feature\Auth;
use App\Models\User; use App\Models\System\User;
use App\Providers\RouteServiceProvider; use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;

View File

@@ -2,7 +2,7 @@
namespace Tests\Feature\Auth; namespace Tests\Feature\Auth;
use App\Models\User; use App\Models\System\User;
use App\Providers\RouteServiceProvider; use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified; use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;

View File

@@ -2,7 +2,7 @@
namespace Tests\Feature\Auth; namespace Tests\Feature\Auth;
use App\Models\User; use App\Models\System\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;

View File

@@ -2,7 +2,7 @@
namespace Tests\Feature\Auth; namespace Tests\Feature\Auth;
use App\Models\User; use App\Models\System\User;
use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;

View File

@@ -2,7 +2,7 @@
namespace Tests\Feature\Auth; namespace Tests\Feature\Auth;
use App\Models\User; use App\Models\System\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Tests\TestCase; use Tests\TestCase;

View File

@@ -2,7 +2,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\User; use App\Models\System\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;