Compare commits
56 Commits
main
...
99243d4206
| Author | SHA1 | Date | |
|---|---|---|---|
| 99243d4206 | |||
| fc79148879 | |||
| 3ce88ed342 | |||
| 1851e91c86 | |||
| 09e1d0dc48 | |||
| 42f96d54c3 | |||
| 56daf8940b | |||
| 39d25ed1d4 | |||
| 78597f1c68 | |||
| 588704642b | |||
| e5516193b0 | |||
| 7f9f76111c | |||
| 3fbb7bc286 | |||
| bb5d212569 | |||
| 6fab048461 | |||
| ea460cf6d9 | |||
| 773396fc90 | |||
| 8ee14eaa29 | |||
| 5708c4f12a | |||
| d679c0df17 | |||
| 03dfcc9c7e | |||
| 75a70a256d | |||
| 682a9e7ac3 | |||
| 02918ce0e1 | |||
| a38387f2ad | |||
| 80436caee7 | |||
| c1b40185eb | |||
| f15038fcbd | |||
| 1d035d2766 | |||
| 1c9edf8e46 | |||
| fba4f26575 | |||
| 4b64fede2e | |||
| d905501a77 | |||
| 56c9a55944 | |||
| c30c3a399d | |||
| 21e064ff91 | |||
| adea7feb7b | |||
| 2a6170b4ce | |||
| 4a1fa2ad1b | |||
| acc81b2156 | |||
| 74b6c71c95 | |||
| 88c3678a4d | |||
| 649cbaab02 | |||
| 9c2ef60463 | |||
| f67a1dc11e | |||
| a578c7f261 | |||
| 84ef0c24e2 | |||
| 55ba08c88f | |||
| 11491e07aa | |||
| d3684385b2 | |||
| 7db3ee3a05 | |||
| a0d107ca79 | |||
| 96b22cd577 | |||
| 3ed8b00cab | |||
| 2aff99fc76 | |||
| 2ed0ee272e |
@@ -1,222 +0,0 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# Backend API Specification (backend.md)
|
||||
|
||||
---
|
||||
|
||||
## 目標範圍
|
||||
|
||||
* 使用 Laravel (RESTful API)
|
||||
* 資料庫:MySQL(migration + seeder)
|
||||
* 提供給現有 Android 團隊的完整 API 規格(JSON 格式)
|
||||
|
||||
---
|
||||
|
||||
## 認證與安全
|
||||
|
||||
* 採用 JWT 或 Laravel Sanctum
|
||||
* 所有需要授權的 API 回傳 401/403 規範
|
||||
* Error 格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"code": 401,
|
||||
"message": "Unauthorized",
|
||||
"errors": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 一般回應格式
|
||||
|
||||
成功:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"message": "OK",
|
||||
"data": { }
|
||||
}
|
||||
```
|
||||
|
||||
錯誤:同上 Error 範例
|
||||
|
||||
---
|
||||
|
||||
## API 清單(建議先開發順序)
|
||||
|
||||
1. Auth (登入/登出/註冊/權杖)
|
||||
2. User / Profile
|
||||
3. Device / Machine (機台管理、狀態回傳、日誌)
|
||||
4. Order / ShoppingCart(若需)
|
||||
5. Notification / Push 訊息
|
||||
6. Activity / Campaign
|
||||
7. Report / Analytics
|
||||
8. Admin CRUD for resources
|
||||
|
||||
---
|
||||
|
||||
## 主要 Endpoints 範例
|
||||
|
||||
### 1) Auth
|
||||
|
||||
* POST /api/v1/auth/login
|
||||
|
||||
* request:
|
||||
|
||||
```json
|
||||
{"email":"user@example.com","password":"pa55"}
|
||||
```
|
||||
|
||||
* response:
|
||||
|
||||
```json
|
||||
{"success":true,"code":200,"data":{"token":"...","user":{"id":1,"name":"..."}}}
|
||||
```
|
||||
|
||||
* POST /api/v1/auth/logout
|
||||
|
||||
* header: Authorization: Bearer <token>
|
||||
* response: 200
|
||||
|
||||
* POST /api/v1/auth/refresh
|
||||
|
||||
---
|
||||
|
||||
### 2) User / Profile
|
||||
|
||||
* GET /api/v1/users/{id}
|
||||
* PUT /api/v1/users/{id}
|
||||
* GET /api/v1/users (admin)
|
||||
|
||||
Request / Response 均採 JSON,個資欄位請遵守最小授權原則。
|
||||
|
||||
---
|
||||
|
||||
### 3) Machine (機台)
|
||||
|
||||
* GET /api/v1/machines
|
||||
|
||||
* Params: page, per_page, status
|
||||
|
||||
* GET /api/v1/machines/{id}
|
||||
|
||||
* POST /api/v1/machines
|
||||
|
||||
* PUT /api/v1/machines/{id}
|
||||
|
||||
* DELETE /api/v1/machines/{id}
|
||||
|
||||
* POST /api/v1/machines/{id}/status
|
||||
|
||||
* 用於下位機或 APP 回傳機台狀態
|
||||
* request example:
|
||||
|
||||
```json
|
||||
{
|
||||
"temperature": 23.4,
|
||||
"status_code": "OK",
|
||||
"firmware_version": "1.2.3",
|
||||
"timestamp": "2025-11-20T15:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
* GET /api/v1/machines/{id}/logs
|
||||
|
||||
---
|
||||
|
||||
### 4) Orders / ShoppingCart
|
||||
|
||||
* POST /api/v1/cart/add
|
||||
* GET /api/v1/cart
|
||||
* POST /api/v1/orders
|
||||
* GET /api/v1/orders/{id}
|
||||
|
||||
支付與第三方串接請另行設計 callback endpoint。
|
||||
|
||||
---
|
||||
|
||||
### 5) Notification
|
||||
|
||||
* POST /api/v1/notifications/send (admin)
|
||||
* GET /api/v1/notifications (user)
|
||||
|
||||
Payload for push could include: title, body, target_user_ids, data
|
||||
|
||||
---
|
||||
|
||||
### 6) Activity / Campaign
|
||||
|
||||
* GET /api/v1/campaigns
|
||||
* POST /api/v1/campaigns
|
||||
* PUT /api/v1/campaigns/{id}
|
||||
|
||||
---
|
||||
|
||||
### 7) Report / Analytics
|
||||
|
||||
* GET /api/v1/reports/machines/summary?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
* GET /api/v1/reports/sales/summary
|
||||
|
||||
Response should include aggregated numbers and paginated lists when needed.
|
||||
|
||||
---
|
||||
|
||||
## Database 基本表(初步)
|
||||
|
||||
* users
|
||||
* roles
|
||||
* machines
|
||||
* machine_logs
|
||||
* orders
|
||||
* order_items
|
||||
* carts
|
||||
* notifications
|
||||
* campaigns
|
||||
* activity_logs
|
||||
* translations (i18n)
|
||||
|
||||
(每張表建議 migration 欄位、index、外鍵,請於開發前定稿)
|
||||
|
||||
---
|
||||
|
||||
## API 規格輸出
|
||||
|
||||
* 建議產出 Swagger (OpenAPI 3.0) 與 Postman Collection
|
||||
* 每個 endpoint 必要欄位、範例、error code、rate limit
|
||||
|
||||
---
|
||||
|
||||
## 測試建議
|
||||
|
||||
* Unit tests:Laravel Feature tests for API
|
||||
* Contract tests:與 Android 團隊一同建立 contract tests(或使用 Postman tests)
|
||||
|
||||
---
|
||||
|
||||
## 部署與運營
|
||||
|
||||
* 建議使用 queue 與 cache(Redis)處理非同步任務
|
||||
* Logging: Sentry or similar
|
||||
* 定期備份 MySQL
|
||||
|
||||
---
|
||||
|
||||
## 交付項目清單
|
||||
|
||||
1. 完整 Laravel 專案(註冊、登入、ACL)
|
||||
2. MySQL migration + seeders
|
||||
3. Swagger/OpenAPI 文件
|
||||
4. Postman Collection
|
||||
5. 後台管理系統(Star Cloud)
|
||||
6. 測試報告
|
||||
7. 部署腳本與上線說明
|
||||
|
||||
---
|
||||
|
||||
*註:本檔為初版 backend.md,若要我把 Excel 的每一列功能自動轉成對應的 API endpoint(含 request/response 範例),我可以直接在此檔案中展開更詳細的 endpoint 清單。*
|
||||
@@ -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.
|
||||
83
.agents/rules/api-rules.md
Normal file
83
.agents/rules/api-rules.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# Backend API Specification (api-rules.md)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 1. 目標範圍與分類
|
||||
|
||||
本系統 API 分為兩大類,遵循不同的設計慣例:
|
||||
|
||||
* **Admin/Web API (`/api/v1/...`)**: 供後台管理介面、APP UI 使用。遵循標準 RESTful 與 JSON 結構。
|
||||
* **Machine IoT API (`/api/app/...`)**: 供販賣機、計時器等硬體端點使用。需相容既有 PDF 規格(如 B010, B600),欄位命名多為 `req1`, `req2` 或特定縮寫。
|
||||
|
||||
---
|
||||
|
||||
## 🔐 2. 認證與安全性
|
||||
|
||||
* **Admin API**: 採用 **Laravel Sanctum (Session/Token)** 認證。
|
||||
* **Machine IoT API**:
|
||||
* **核心機制**: 必須在 Header 帶入 `Authorization: Bearer <api_token>` 進行身份驗證。
|
||||
* **Phase 1 (兼容模式)**: 若為相容既有機動硬體,可暫時接受 Request 包含 `key` 欄位,但後端應過渡至 Bearer 驗證。
|
||||
* **安全性強化**: 改用每台機台專屬的 `api_token` (透過 B010 初始化或派發),並配合 `serial_no` (即 `workid`) 進行資料歸屬權驗證。
|
||||
* **傳輸安全**: 必須強制使用 **HTTPS**。
|
||||
|
||||
---
|
||||
|
||||
## 📦 3. 回應格式規範
|
||||
|
||||
### 3.1 標準回應 (Admin/Web API)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"message": "OK",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 IoT 指令回應 (B010/B055 等)
|
||||
機台端通常透過 response 的 `status` 欄位或特定的 `message` 字串來執行動作:
|
||||
* **成功但有指令**: `{"status": "49", "message": "reload B017"}`
|
||||
* **純資料回傳**: 直接返回對象陣列或 PDF 定義的欄位。
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 4. 主要 Endpoints 與命名慣例
|
||||
|
||||
### 4.1 管理類 (Admin UI)
|
||||
* `GET /api/v1/users`: 管理員清單
|
||||
* `GET /api/v1/machines`: 機台清單
|
||||
* `PUT /api/v1/machines/{id}`: 更新機台參數
|
||||
* 遵循 **kebab-case** 路由與 **snake_case** JSON 欄位。
|
||||
|
||||
### 4.2 終端類 (IoT / Machine) — 須嚴格遵守 PDF 規格
|
||||
* **API 識別碼 (workid)**: URL 中的 `{workid}` 參數固定為該 API 的功能代碼 (如 `B010`, `B017`, `B600`),不隨機台改變。
|
||||
* **機台識別方式**:
|
||||
1. **Header**: 透過 `Authorization: Bearer <api_token>` 識別。
|
||||
2. **Request Body**: 透過 `machine` 或 `serial_no` 等欄位識別具體機台。
|
||||
* **主要 Endpoint 範例**:
|
||||
* **心跳上報 (B010)**: `POST /api/app/machine/status/B010`
|
||||
* **交易回傳 (B600)**: `POST /api/app/B600` (Body 欄位 `req2` 為機台編號)
|
||||
* **貨道庫存 (B017)**: `POST /api/app/machine/reload_msg/B017`
|
||||
* **遠端出貨 (B055)**: `POST /api/app/machine/dispense/B055`
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 5. 高併發處理與隊列
|
||||
|
||||
為了系統穩定性,以下 API **嚴禁直寫資料庫**,必須進入 **Redis Queue** 異步處理:
|
||||
1. **B010**: 心跳上傳(每 5-10 秒一次)。
|
||||
2. **B600 / B602**: 交易與出貨紀錄。
|
||||
3. **B220**: 零錢機庫存變動。
|
||||
4. **B710**: 計時器狀態同步。
|
||||
|
||||
後端應立即回傳 `202 Accepted` 或業務定義的成功碼,由 Job 背景完成數據持久化。
|
||||
|
||||
---
|
||||
|
||||
## 📄 6. 交付與文件
|
||||
* **OpenAPI**: 應區分 `admin.yaml` 與 `iot.yaml`。
|
||||
* **Postman**: 提供帶有環境變數(機台金鑰、Base URL)的 Collection。
|
||||
97
.agents/rules/framework.md
Normal file
97
.agents/rules/framework.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
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 模板中)。
|
||||
* **重要規範**:Preline UI 僅作為「原子組件」與「JS 互動邏輯」的參考庫。整體的「佈局」與「美學」必須嚴格遵守「極簡奢華風 UI 實作規範 (SKILL.md)」。
|
||||
* **前端建置工具**: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. 多語系 I18n 規範 (Multi-language Standards)
|
||||
* **視圖開發**:所有使用者可見的文字、按鈕、提示訊息,必須使用 Laravel 的 `@lang('key')` 或 `__('key')` 函式包裹。
|
||||
* **語系 Key 命名**:語系 Key 必須採用 **英文原始詞彙 (English phrases)** 作為 Key 名稱為原則,以提高代碼可讀性並作為預設回退(除非該字串過長,才建議使用點號分隔的 key)。
|
||||
* 範例:使用 `__('Account Settings')`。
|
||||
* **翻譯檔維護**:
|
||||
* 主語系檔案位於 `lang/` 目錄。
|
||||
* 開發新功能時,必須同步更新以下三個 JSON 翻譯檔:`zh_TW.json` (主要)、`en.json` (預設)、`ja.json` (日文)。
|
||||
|
||||
## 7. AI 協作規則 (給 Antigravity AI)
|
||||
* **角色設定**:你是一位專業的全端開發工程師助手。
|
||||
* **代碼生成指令**:
|
||||
* 所有的解釋說明請使用 **繁體中文**。
|
||||
* **【警告:Preline 冗餘】** Preline UI 的官方範例常包含多餘的控制項(如頂部筆數切換)。**嚴禁**照抄其佈局,必須確保頂部工具列(Header/Toolbar)維持極簡,重複功能一律收納至底部。
|
||||
* **【警告】** 此專案前端禁用 React / Vue / Inertia.js。所有的前端頁面生成必須使用 **Blade 模板** 結合 **Tailwind CSS** 與 **Alpine.js**。
|
||||
* **【多語系強制要求】** 任何新增的 Blade UI 區塊,禁止硬編碼 (Hard-coded) 中文或英文。必須使用 `__('...')` 並同步在 `lang/*.json` 補上翻譯。
|
||||
* 生成 UI 區塊時,必須優先參考與產生 **Preline UI** 風格與結構的標記語法。
|
||||
* 開發新功能時,請建立標準的 Controller 搭配對應的 `resources/views/.../` 目錄。
|
||||
|
||||
## 8. 運行機制 (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`。
|
||||
|
||||
## 9. 瀏覽器測試規範 (Browser Testing)
|
||||
當需要進行瀏覽器自動化測試或手動驗證時,必須遵守以下連線資訊:
|
||||
|
||||
* **本地測試網址**:`http://localhost:8090/` (注意:非 8000 或 8080)
|
||||
* **預設管理員帳號**:`admin`
|
||||
* **預設管理員密碼**:`password`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 在執行 `open_browser_url` 或進行 E2E 測試時,請務必優先確認 Port 是否為 `8090`,以避免連線至錯誤的服務環境。
|
||||
52
.agents/rules/rbac-rules.md
Normal file
52
.agents/rules/rbac-rules.md
Normal 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 過濾。
|
||||
44
.agents/rules/skill-trigger.md
Normal file
44
.agents/rules/skill-trigger.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# 技能觸發規範 (Skill Trigger Rules)
|
||||
|
||||
本文件確保 AI 助手在對話中能**主動辨識**需要參照技能 (Skill) 的時機。
|
||||
Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
|
||||
**若對話內容命中以下任一觸發條件,必須先使用 `view_file` 讀取對應的 `SKILL.md` 後標記為 active 再進行作業。**
|
||||
|
||||
---
|
||||
|
||||
## 觸發對照表
|
||||
|
||||
| 觸發詞 / 情境 | 對應 Skill | 路徑 |
|
||||
|---|---|---|
|
||||
| 機台通訊, 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` |
|
||||
|
||||
---
|
||||
|
||||
## 強制觸發場景
|
||||
|
||||
以下場景**無論對話中是否出現觸發詞**,都必須主動載入對應 Skill:
|
||||
|
||||
### 🔴 新增或修改頁面 (Views/Blade) 時
|
||||
必須讀取:
|
||||
1. **ui-minimal-luxury** — 確保符合極簡奢華風視覺與互動規範
|
||||
2. **rbac-rules** — 確認 UI 區塊的權限顯示控制
|
||||
|
||||
### 🔴 新增機台通訊 API 端點時
|
||||
必須讀取:
|
||||
1. **iot-communication** — 決定是否使用異步隊列流程
|
||||
|
||||
### 🔴 修改 Job 或 Service 邏輯時
|
||||
必須讀取:
|
||||
1. **iot-communication** — 確保符合高併發處理架構
|
||||
|
||||
### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時
|
||||
必須讀取:
|
||||
1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範
|
||||
2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用
|
||||
63
.agents/skills/iot-communication/SKILL.md
Normal file
63
.agents/skills/iot-communication/SKILL.md
Normal 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 層級進行了基礎的參數驗證?
|
||||
348
.agents/skills/ui-minimal-luxury/SKILL.md
Normal file
348
.agents/skills/ui-minimal-luxury/SKILL.md
Normal file
@@ -0,0 +1,348 @@
|
||||
---
|
||||
name: 璆萇陛憟Z虾憸<E899BE> UI 撖虫<E69296>閬讐<E996AC> (Minimal Luxury UI)
|
||||
description: 摰𡁶儔 Star Cloud 蝞∠<E89D9E>敺<EFBFBD>蝱<EFBFBD><E89DB1><EFBFBD>峕扔蝪∪失<E288AA>舫◢<E888AB>滩身閮<E8BAAB><E996AE>蝭<EFBFBD><E89DAD><EFBFBD><EFBFBD>鉄 CSS Tokens<6E><73>虜<EFBFBD>函<EFBFBD>隞嗆見撘譌<E69298><E8AD8C><EFBFBD><EFBFBD>急<EFBFBD><E680A5>𡏭<EFBFBD>鈭鍦<E988AD>璅∪<E79285>嚗𣬚Ⅱ靽嘥<E99DBD>蝡<EFBFBD> 15+ 璅∠<E79285><E288A0><EFBFBD><EFBFBD>閬箔<E996AC><E7AE94>湔<EFBFBD>扼<EFBFBD><E689BC>
|
||||
---
|
||||
|
||||
# 璆萇陛憟Z虾憸<E899BE> UI 撖虫<E69296>閬讐<E996AC> (Minimal Luxury UI)
|
||||
|
||||
<EFBFBD>祆<EFBFBD>隞嗅<EFBFBD>蝢拐<EFBFBD> Star Cloud 撠<><E692A0><EFBFBD><EFBFBD>瓲敹<E793B2><E695B9>閬箄<E996AC>閮<EFBFBD><E996AE><EFBFBD><EFBFBD><EFBFBD>㗇鰵<E39787><E9B0B5>𢒰<EFBFBD><F0A292B0><EFBFBD>隞園<E99A9E><E59C92>澆<EFBFBD><E6BE86><EFBFBD>𠂔<EFBFBD>潮<EFBFBD>摰<EFBFBD>迨閬讐<E996AC><E8AE90><EFBFBD>
|
||||
|
||||
## 1. <20>詨<EFBFBD>閮剛<E996AE>隞斤<E99A9E> (Design Tokens)
|
||||
|
||||
### <20>脣蔗蝟餌絞 (CSS Variables)
|
||||
雿齿䲰 `resources/css/app.css`嚗<EFBFBD>
|
||||
- `--color-luxury-deep`: `#0f172a` (瘛梯𠧧<E6A2AF>峕艶)
|
||||
- `--color-luxury-card`: `#1e293b` (<28>∠<EFBFBD><E288A0>峕艶)
|
||||
- `--color-accent`: `#06b6d4` (<28>坿𠧧暺䂿韌嚗屸<E59A97><E5B1B8>冽䲰<E586BD>厰<EFBFBD><E58EB0><EFBFBD><EFBFBD>蝷<EFBFBD>)
|
||||
|
||||
### 摮烾<E691AE> (Typography)
|
||||
- **<EFBFBD>扳<EFBFBD>摮烾<EFBFBD>**: `Plus Jakarta Sans`
|
||||
- **璅䠷<E79285>/憿舐內摮烾<E691AE>**: `Outfit`
|
||||
- **<EFBFBD>寞<EFBFBD><EFBFBD>**: 璅䠷<E79285><E4A0B7><EFBFBD>撣嗆<E692A3> `letter-spacing: -0.02em` 隞亙<E99A9E>撘瑞移撖<E7A7BB><E69296><EFBFBD><EFBFBD>
|
||||
|
||||
## 2. <20>詨<EFBFBD>蝯<EFBFBD>辣璅<E8BEA3><E79285>
|
||||
|
||||
### 鞊芾虾<E88ABE>∠<EFBFBD> (Luxury Card)
|
||||
```html
|
||||
<div class="luxury-card p-6 rounded-2xl animate-luxury-in">
|
||||
<!-- <20>批捆 -->
|
||||
</div>
|
||||
```
|
||||
- **<EFBFBD>寞<EFBFBD>**: <20>詨<EFBFBD><E8A9A8><EFBFBD>葆<EFBFBD><E89186> Y 頠詨像蝘餉<E89D98>瘛勗漲<E58B97>訫蔣<E8A8AB><E894A3>
|
||||
|
||||
### <20>湧<EFBFBD>撠舘汗<E88898><E6B197> (Luxury Nav Item)
|
||||
```html
|
||||
<a href="#" class="luxury-nav-item active">
|
||||
<i class="lucide-icon"></i>
|
||||
<span>蝭<>暺𧼮<E69ABA>蝔<EFBFBD></span>
|
||||
</a>
|
||||
```
|
||||
- **<EFBFBD>毺鍂<EFBFBD><EFBFBD><EFBFBD><EFBFBD>**: 撌血<E6928C>撣嗆<E692A3><E59786>栞<EFBFBD><E6A09E>湔<EFBFBD><E6B994><EFBFBD>內<EFBFBD>剁<EFBFBD>銝西<E98A9D>隞仿<E99A9E><E4BBBF>脩䔄<E884A9>厰苊敶晞<E695B6><E6999E>
|
||||
|
||||
### <20>厰<EFBFBD>蝯<EFBFBD>辣 (Buttons)
|
||||
- **Primary**: `.btn-luxury-primary` (<28>坿𠧧瞍詨惜嚗屸<E59A97><E5B1B8>冽䲰撱箇<E692B1><E7AE87><EFBFBD><EFBFBD>摮<EFBFBD>)
|
||||
- **Secondary**: `.btn-luxury-secondary` (<28>質𠧧/瘛梯𠧧<E6A2AF>峕艶嚗<E889B6>葆<EFBFBD>𦠜<EFBFBD>嚗屸<E59A97><E5B1B8>冽䲰蝺刻摩<E588BB><E691A9>祟<EFBFBD><E7A59F>)
|
||||
- **Ghost**: `.btn-luxury-ghost` (<28>∟<EFBFBD><E2889F>荔<EFBFBD><E88D94>拍鍂<E68B8D>澆<EFBFBD>瘨<EFBFBD><E798A8><EFBFBD>䰻<EFBFBD>𧢲凒憭<E58792>)
|
||||
|
||||
```html
|
||||
<!-- Primary -->
|
||||
<button class="btn-luxury-primary">
|
||||
<i class="lucide-plus size-4"></i>
|
||||
<span>撱箇<E692B1><E7AE87>唳<EFBFBD><E594B3><EFBFBD></span>
|
||||
</button>
|
||||
|
||||
<!-- Ghost -->
|
||||
<button class="btn-luxury-ghost"><3E>𡝗<EFBFBD></button>
|
||||
```
|
||||
|
||||
## 3. <20>閧𧞄<E996A7><F0A79E84><EFBFBD><EFBFBD><EFBFBD>
|
||||
|
||||
### <20>脣聦<E884A3>閧𧞄
|
||||
- **`.animate-luxury-in`**: <20><><EFBFBD>厩<EFBFBD>銝餃<E98A9D>摰孵<E691B0><E5ADB5><EFBFBD><EFBFBD><EFBFBD>∠<EFBFBD><E288A0>券<EFBFBD><E588B8>Z<EFBFBD><EFBCBA>交<EFBFBD>嚗峕<E59A97><E5B395>瑕<EFBFBD><E79195>曹<EFBFBD><E69BB9>諹<EFBFBD><E8ABB9><EFBFBD>楚<EFBFBD>交<EFBFBD><E4BAA4>栶<EFBFBD><E6A0B6>
|
||||
|
||||
### 鈭鍦<E988AD><E98DA6>擧腹 (Transitions)
|
||||
- **璅蹱<E79285><E8B9B1><EFBFBD><EFBFBD>**: <20><><EFBFBD>厩<EFBFBD><E58EA9>詨<EFBFBD><E8A9A8><EFBFBD>𠧧敶抵<E695B6><E68AB5>𤤿<EFBFBD><F0A4A4BF>擧腹<E693A7><E885B9><EFBFBD>嚗𣬚絞銝<E7B59E>撱箄降雿輻鍂 **`duration-300`** (300ms)<29><>
|
||||
- **靘见<E99D98>**: 璆萄<E79286>蝝啣凝<E595A3><E5879D><EFBFBD>𤩺<EFBFBD>摨西<E691A8><E8A5BF>硋虾蝮桃<E89DAE><E6A183><EFBFBD> `150ms`嚗䔶<EFBFBD>瘨匧<EFBFBD><EFBFBD>峕艶<EFBFBD>脰<EFBFBD>雿滨宏<EFBFBD><EFBFBD><EFBFBD><EFBFBD>蓥<EFBFBD>敺衤誑 `300ms` <20>箸<EFBFBD><E7AEB8><EFBFBD>
|
||||
|
||||
### Alpine.js 鈭鍦<E988AD>璅∪<E79285> (隞交<E99A9E><E4BAA4>㯄<EFBFBD><E3AF84><EFBFBD>膥<EFBFBD>箔<EFBFBD>)
|
||||
- **鈭鍦<E988AD><E98DA6>笔<EFBFBD>**: 暺墧<E69ABA>閫貊䔄銝𧢲<E98A9D><F0A7A2B2>詨鱓<E8A9A8><E9B193><EFBFBD>敹<EFBFBD><E695B9>雿輻鍂 `x-transition` 銝𥪜葆<F0A5AA9C><E89186> `scale` <20>讐宏<E8AE90><E5AE8F>
|
||||
- **璅<><E79285>閬<EFBFBD><E996AC>**: <20>詨鱓<E8A9A8>峕艶<E5B395><E889B6>雿輻鍂<E8BCBB>餌<EFBFBD><E9A48C>祆<EFBFBD> (Glassmorphism) <20>硋葆<E7A18B>𤩺<EFBFBD>摨衣<E691A8>瘛梯𠧧<E6A2AF>峕艶<E5B395><E889B6>
|
||||
|
||||
- [ ] **<EFBFBD>𡑒”雿<EFBFBD><EFBFBD>**: <20>臬炏<E887AC>∠鍂<E288A0>峕㟲<E5B395><E39FB2><EFBFBD><EFBFBD>∠<EFBFBD><E288A0>滨<EFBFBD>瑽衤<E791BD><E8A1A4>扯<EFBFBD>閮剔<E996AE> `p-8`嚗<EFBFBD>
|
||||
- [ ] **<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>蜇<EFBFBD><EFBFBD>**: <20>𡑒”摨閖<E691A8><E99696>臬炏甇<E7828F>Ⅱ<EFBFBD>砍<EFBFBD> `vendor.pagination.luxury`嚗<EFBFBD>
|
||||
- [ ] **<EFBFBD><EFBFBD><EFBFBD><EFBFBD>脤<EFBFBD>**: 蝚血<E89D9A>璅䠷<E79285> `slate-900/white` <20><><EFBFBD>蝐<EFBFBD> `slate-500` <20><><EFBFBD>瘥𥪜漲<F0A5AA9C><E6BCB2>
|
||||
- [ ] **<EFBFBD>航<EFBFBD><EFBFBD>扳炎<EFBFBD><EFBFBD>**: 鈭𣬚<E988AD>鞈<EFBFBD><E99E88><EFBFBD>臬炏<E887AC>𥪜<EFBFBD> `text-xs` (12px) 銝娍<E98A9D><E5A88D>滢<EFBFBD>頞<EFBFBD><E9A09E> `font-bold`嚗<EFBFBD>
|
||||
|
||||
## 5. <20>讠䔄瘜冽<E7989C>鈭钅<E988AD> (Important Notes)
|
||||
|
||||
### <20><>銵㯄<E98AB5><E3AF84>嗅<EFBFBD>敹<EFBFBD>
|
||||
- **CSS 蝺刻陌**: 銴<><E98AB4><EFBFBD><EFBFBD> `box-shadow` <20>𡝗撓撅斗<E69285><E69697>湔𦻖撖怠<E69296><E680A0><EFBFBD> CSS 撅祆<E69285>改<EFBFBD><E694B9>踹<EFBFBD><E8B8B9><EFBFBD> `@apply` 銝凋蝙<E5878B>典葆蝛箸聢<E7AEB8><E881A2>彍<EFBFBD>澆<EFBFBD><E6BE86>渡楊霅臬仃<E887AC>梹<EFBFBD>閰唾<E996B0> KI: `tailwind-luxury-ui-patterns`嚗剹<EFBFBD><EFBFBD>
|
||||
- **瘛梯𠧧璅∪<E79285>**: 鈭鍦<E988AD>撘𤩺<E69298><F0A4A9BA>訫銁瘛梯𠧧璅∪<E79285>銝见<E98A9D><E8A781><EFBFBD>撥<EFBFBD>𡝗<EFBFBD>摮𦯀漁摨佗<E691A8>`dark:text-white`嚗㚁<EFBFBD>銝西<EFBFBD>隞仿<EFBFBD><EFBFBD>脩䔄<EFBFBD>㗇<EFBFBD><EFBFBD>栶<EFBFBD><EFBFBD>
|
||||
|
||||
### <20>單<EFBFBD><E596AE>閙<EFBFBD><E99699><EFBFBD>𣶹閬讐<E996AC>
|
||||
- **<EFBFBD>澆<EFBFBD>**: `#璈笔蝱蝺刻<E89DBA> <20>蓥<EFBFBD><E893A5>批捆` (靘见<E99D98> `#V-001 <20>瑁<EFBFBD><E79181>箄疏`)<29><>
|
||||
- **<EFBFBD><EFBFBD>窗**: 敹<><E695B9><EFBFBD><EFBFBD>𣶹<EFBFBD>詨<EFBFBD><E8A9A8><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>唬<EFBFBD>蝵柴<E89DB5><E69FB4>
|
||||
|
||||
## 6. <20><>𢒰雿<F0A292B0><E99BBF>閬讐<E996AC> (Page Layout)
|
||||
|
||||
### 雿<><E99BBF>瘙箇<E79899>閬誩<E996AC> (Layout Decision Rules)
|
||||
|
||||
<EFBFBD>寞<EFBFBD>蝭拚<EFBFBD>璇苷辣<EFBFBD><EFBFBD><EFBFBD><EFBFBD>𦦵<EFBFBD>摨佗<EFBFBD><EFBFBD>豢<EFBFBD><EFBFBD>拍訜<EFBFBD><EFBFBD><EFBFBD><EFBFBD>桅<EFBFBD><EFBFBD>V<EFBFBD>撅<EFBFBD>嚗<EFBFBD>
|
||||
|
||||
#### 1. <20>游<EFBFBD>撘譍<E69298>撅<EFBFBD> (Integrated Layout) - <20>鞾<EFBFBD>閮剜綫<E5899C>艾<EFBFBD><E889BE>
|
||||
- **<EFBFBD>拍鍂<EFBFBD>湔艶**: 蝯訫之憭𡁏彍 CRUD <20>𡑒”<F0A19192><E2809D>
|
||||
- **撖虫<E69296><E899AB>孵<EFBFBD>**: 蝭拚<E89DAD><E68B9A>具<EFBFBD><E585B7>極<EFBFBD>瑕<EFBFBD><E79195><EFBFBD><EFBFBD><EFBFBD>躰”<E8BAB0>澆<EFBFBD><E6BE86>典<EFBFBD>鋆嘥銁<E598A5>䔶<EFBFBD><E494B6><EFBFBD> `luxury-card` 銝准<E98A9D><E58786>
|
||||
- **<EFBFBD>扯<EFBFBD>閬讐<EFBFBD>**: 撘瑕<E69298>雿輻鍂 `p-8` 隞亦㬢敺埈<E695BA>雿喟征瘞<E5BE81><E7989E><EFBFBD><EFBFBD>
|
||||
- **<EFBFBD><EFBFBD>辣<EFBFBD>栞<EFBFBD>**: 蝭拚<E89DAD><E68B9A><EFBFBD><EFBFBD><EFBFBD>”<EFBFBD>潔<EFBFBD><E6BD94>枏𤐄摰帋蝙<E5B88B><E89D99> `mb-10`<EFBFBD><EFBFBD>
|
||||
- **蝭<><E89DAD>**: 撣唾<E692A3>蝞∠<E89D9E><E288A0><EFBFBD><EFBFBD><EFBFBD>脰身摰𠾼<E691B0><F0A0BEBC><EFBFBD><EFBFBD>唳𠯫隤䎚<E99AA4><E48E9A>
|
||||
|
||||
#### 2. <20><>𣪧撘譍<E69298>撅<EFBFBD> (Split Layout)
|
||||
- **<EFBFBD>拍鍂<EFBFBD>湔艶**: 銴<><E98AB4><EFBFBD>亥岷 (Filtered Fields >= 3 <20>硋<EFBFBD>銵𣬚祟<F0A3AC9A><E7A59F>)<29><>
|
||||
- **撖虫<E69296><E899AB>孵<EFBFBD>**: 蝭拚<E89DAD><E68B9A><EFBFBD><EFBFBD>函<EFBFBD><E587BD>箔<EFBFBD><E7AE94><EFBFBD> `luxury-card`嚗䔶<EFBFBD><EFBFBD>寥<EFBFBD><EFBFBD><EFBFBD> `mb-6` 敺<><E695BA><EFBFBD>曄蔭鞈<E894AD><E99E88>皜<EFBFBD>鱓<EFBFBD>∠<EFBFBD><E288A0><EFBFBD>
|
||||
- **璅<><E79285>閬讐<E996AC>**: 蝭拚<E89DAD><E68B9A>∠<EFBFBD><E288A0>𡁜虜雿輻鍂 `p-6`嚗<EFBFBD><EFBFBD>皝𠰴<EFBFBD>嚗㚁<EFBFBD>皜<EFBFBD>鱓<EFBFBD>∠<EFBFBD>雿輻鍂 `p-8`嚗<EFBFBD>祝擛<EFBFBD><EFBFBD>嚗剹<EFBFBD><EFBFBD>
|
||||
- **蝭<><E89DAD>**: 鈭斗<E988AD>蝝<EFBFBD><E89D9D><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>唳𠯫隤䎚<E99AA4><E48E9A>
|
||||
|
||||
### 璅蹱<E79285>撖祉<E69296>雿<EFBFBD><E99BBF> (Wide Layout)
|
||||
|
||||
```html
|
||||
@section('content')
|
||||
<div class="space-y-6">
|
||||
<!-- Header: 璅䠷<E79285><E4A0B7><EFBFBD><EFBFBD>雿𨀣<E99BBF><F0A880A3><EFBFBD> -->
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Title') }}</h1>
|
||||
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Subtitle') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="btn-luxury-primary">...</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Container: <20>∠<EFBFBD><E288A0><EFBFBD>”<EFBFBD><E2809D> -->
|
||||
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<!-- Table Content -->
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
```
|
||||
|
||||
### 雿<><E99BBF><EFBFBD>詨<EFBFBD><E8A9A8>笔<EFBFBD>:
|
||||
1. **蝘駁膄<E9A781>滩<EFBFBD><E6BBA9>扯<EFBFBD>**: <20>孵捆<E5ADB5><E68D86> `div` <20><>**蝳<>迫**雿輻鍂 `p-6` <20><> `p-10`嚗<EFBFBD><EFBFBD><EFBFBD>箔<EFBFBD>撅<EFBFBD><EFBFBD>箏<EFBFBD>撌脫<EFBFBD>靘𥕦抅蝷𡡞<EFBFBD>頝腈<EFBFBD><EFBFBD><EFBFBD>雿輻鍂 `space-y-6` (<28><> `space-y-8`) <20>批<EFBFBD><E689B9><EFBFBD>憛𢠃<E6869B><F0A2A083>踺<EFBFBD><E8B8BA>
|
||||
2. **銝餃捆<E9A483>冽見撘<E8A68B>**: 撘瑕<E69298>撠漤<E692A0><E6BCA4><EFBFBD> `luxury-card rounded-3xl p-8`<EFBFBD><EFBFBD>
|
||||
3. **璅䠷<E79285><E4A0B7>垍<EFBFBD>**:
|
||||
- 銝餅<E98A9D>憿屸<E686BF><E5B1B8>厩鍂 `font-display` (Outfit)<29><>
|
||||
- <20>讛膩<E8AE9B><E886A9><EFBFBD><EFBFBD><EFBFBD><EFBFBD>厩鍂 `uppercase tracking-widest font-bold` 隞亙<E99A9E><E4BA99>暸<EFBFBD>蝝朞身閮<E8BAAB><E996AE><EFBFBD><EFBFBD>
|
||||
|
||||
## 7. 銵典鱓<E585B8><E9B193>辣閬讐<E996AC> (Form Elements)
|
||||
|
||||
<EFBFBD>嘥<EFBFBD>頛詨<EFBFBD>獢<EFBFBD><EFBFBD>銝𧢲<EFBFBD><EFBFBD>詨鱓嚗<EFBFBD>撥<EFBFBD>嗡蝙<EFBFBD>其誑銝钅<EFBFBD><EFBFBD>乩誑蝣箔<EFBFBD>瘛梯𠧧璅∪<EFBFBD>鞈芣<EFBFBD><EFBFBD><EFBFBD>
|
||||
|
||||
### 頛詨<E9A09B>獢<EFBFBD><E78DA2><EFBFBD>詨鱓
|
||||
- **憿𧼮ê̌**: `.luxury-input`, `.luxury-select`
|
||||
- **<EFBFBD>寞<EFBFBD><EFBFBD>**:
|
||||
- 瘛梯𠧧璅∪<E79285>銝见<E98A9D><E8A781>坔<EFBFBD><E59D94>𤩺<EFBFBD><F0A4A9BA>峕艶<E5B395><E889B6><EFBFBD><EFBFBD>舀芋蝟𦠜<E89D9F><F0A6A09C>栶<EFBFBD><E6A0B6>
|
||||
- 蝯曹<E89DAF><E69BB9><EFBFBD> `rounded-xl` <20>栞<EFBFBD><E6A09E><EFBFBD> `font-bold` 摮烾<E691AE><E783BE><EFBFBD>
|
||||
- <20>𡁶<EFBFBD><F0A181B6><EFBFBD>葆<EFBFBD>厰<EFBFBD><E58EB0><EFBFBD> (`Cyan`) <20>澆<EFBFBD><E6BE86>𦠜<EFBFBD><F0A6A09C><EFBFBD>
|
||||
|
||||
```html
|
||||
<input type="text" class="luxury-input" placeholder="隢贝撓<E8B49D>亙<EFBFBD>摰<EFBFBD>">
|
||||
|
||||
<select class="luxury-select">
|
||||
<option value="1"><3E>毺鍂</option>
|
||||
<option value="0">蝳<>鍂</option>
|
||||
</select>
|
||||
```
|
||||
|
||||
## 8. 蝺刻摩<E588BB><E691A9>底<EFBFBD><E5BA95><EFBFBD>閬讐<E996AC> (Detail & Edit Views)
|
||||
|
||||
<EFBFBD>箔<EFBFBD>霈枏<EFBFBD>撅方<EFBFBD>閮𦠜凒<EFBFBD>瑁<EFBFBD>閬箏<EFBFBD>撠𠬍<EFBFBD><EFBFBD><EFBFBD><EFBFBD>见<EFBFBD>憛<EFBFBD> (Section) <20><><EFBFBD>蝷箸<E89DB7><E7AEB8>∠鍂銝滚<E98A9D><E6BB9A><EFBFBD><EFBFBD><EFBFBD>脫<EFBFBD>鞊~<E99E8A><EFBD9E>
|
||||
|
||||
### <20><>憛𠰴<E6869B>蝷箄𠧧敶拇<E695B6>鞊<EFBFBD> (Section Icon Palette)
|
||||
- **<EFBFBD>箸𧋦鞈<EFBFBD><EFBFBD> (Basic Info)**: **蝧删<E89DA7><E588A0><EFBFBD> (`Emerald`)**<EFBFBD><EFBFBD>誨銵冽瓲敹<EFBFBD><EFBFBD><EFBFBD>帘摰朞<EFBFBD>韏琿<EFBFBD><EFBFBD><EFBFBD>
|
||||
- 璅<><E79285>: `bg-emerald-500/10 text-emerald-500`
|
||||
- **蝖祇<E89D96>/<2F>埝局閮剖<E996AE>**: **<EFBFBD>亦<EFBFBD><EFBFBD><EFBFBD> (`Amber/Orange`)**<2A><>誨銵典<E98AB5>雿栶<E99BBF><E6A0B6><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>𦻖<EFBFBD><F0A6BB96>′擃磰郎<E7A3B0>𨳍<EFBFBD><F0A8B38D>
|
||||
- 璅<><E79285>: `bg-amber-500/10 text-amber-500`
|
||||
- **蝟餌絞/<2F>脤<EFBFBD>閮剖<E996AE>**: **<EFBFBD>𥡝<EFBFBD><EFBFBD><EFBFBD> (`Indigo`)**<2A><>誨銵券<E98AB5>頛胯<E9A09B><E883AF><EFBFBD><EFBFBD>鞱<EFBFBD>瘛勗惜<E58B97>滨蔭<E6BBA8><E894AD>
|
||||
- 璅<><E79285>: `bg-indigo-500/10 text-indigo-500`
|
||||
- **<EFBFBD>梢麬/蝘駁膄<E9A781>蓥<EFBFBD>**: **<EFBFBD>怎麯蝝<EFBFBD> (`Rose`)**<2A><>誨銵函聦憯墧<E686AF>扳<EFBFBD>雿栶<E99BBF><E6A0B6>
|
||||
- 璅<><E79285>: `bg-rose-500/10 text-rose-500`
|
||||
```
|
||||
|
||||
## 8. 鞈<><E99E88>銵冽聢閬讐<E996AC> (Data Tables)
|
||||
|
||||
<EFBFBD>箔<EFBFBD>蝣箔<EFBFBD>蝞∠<EFBFBD>敺<EFBFBD>蝱鞈<EFBFBD><EFBFBD><EFBFBD><EFBFBD>虾霈<EFBFBD><EFBFBD>扯<EFBFBD>蝎曉<EFBFBD><EFBFBD><EFBFBD><EFBFBD>銵冽聢<EFBFBD>抒<EFBFBD><EFBFBD><EFBFBD><EFBFBD>㗇<EFBFBD>摮㛖<EFBFBD><EFBFBD>亙<EFBFBD><EFBFBD><EFBFBD><EFBFBD>朣𠹺誑銝贝<EFBFBD>蝭<EFBFBD><EFBFBD>
|
||||
|
||||
### <20><><EFBFBD>憭批<E686AD><E689B9><EFBFBD><EFBFBD><EFBFBD><EFBFBD> (Typography Hierarchy)
|
||||
- **銵券<E98AB5> (Table Header)**:
|
||||
- 憿𧼮ê̌: `text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em]`
|
||||
- 雿𦦵鍂: <20>𣂷<EFBFBD>皜<EFBFBD>苊<EFBFBD><E88B8A><EFBFBD>雿滚<E99BBF>蝢抵<E89DA2>䔶<EFBFBD>憟芸<E6869F>鞈<EFBFBD><E99E88>閬𤥁死<F0A4A581>阡<EFBFBD><E998A1><EFBFBD><EFBFBD><EFBFBD>躰雲憭惩<E686AD>瘥𥪜漲<F0A5AA9C><E6BCB2>
|
||||
- **銝餅<E98A9D>憿<EFBFBD> (Primary Item)**:
|
||||
- 憿𧼮ê̌: `text-base font-extrabold text-slate-800 dark:text-slate-100`
|
||||
- 蝭<><E89DAD>: <20>砍虬<E7A08D>滨迂<E6BBA8><E8BF82><EFBFBD>擃𥪜<E69383>蝔晞<E89D94><E6999E>
|
||||
- **甈∟<E79488>鞈<EFBFBD><E99E88> (Secondary Info)**:
|
||||
- 憿𧼮ê̌: `text-xs font-bold text-slate-500 dark:text-slate-400 tracking-wide`
|
||||
- 蝭<><E89DAD>: 雿輻鍂<E8BCBB><E98D82>董<EFBFBD>麄<EFBFBD><E9BA84><EFBFBD>閮颯<E996AE><E9A2AF><EFBFBD><EFBFBD>𣂼<EFBFBD>蝔晞<E89D94><E6999E>
|
||||
- **<EFBFBD><EFBFBD><EFBFBD>𧢲<EFBFBD>蝐<EFBFBD> (Status Badge)**:
|
||||
- 蝭<><E89DAD>: <20>毺鍂 (`emerald`)<29><><EFBFBD><EFBFBD><EFBFBD> (`rose`) / 閫坿𠧧<E59DBF>滨迂 (`sky`/`indigo`)<29><>
|
||||
- <20>寞<EFBFBD><E5AF9E>: `px-2.5 py-1 rounded-lg text-xs font-bold border tracking-wider`
|
||||
|
||||
### 蝛粹<E89D9B><E7B2B9><EFBFBD><EFBFBD><EFBFBD><EFBFBD> (Spacing & Interaction)
|
||||
- **<EFBFBD>桀<EFBFBD><EFBFBD>澆<EFBFBD>頝<EFBFBD>**: 蝯曹<E89DAF>雿輻鍂 `px-6 py-6`<EFBFBD><EFBFBD>
|
||||
- **<EFBFBD>詨<EFBFBD><EFBFBD>齿<EFBFBD>**: 敹<><E695B9><EFBFBD><EFBFBD> `tr` 憟㛖鍂 `group` 銝𥪜<E98A9D><F0A5AA9C><EFBFBD><EFBFBD>憟㛖鍂 `group-hover:bg-slate-50/80` (瘛梯𠧧: `dark:group-hover:bg-slate-800/40`) 隞交<E99A9E>靘偦<E99D98>蝝𡁶<E89D9D>鈭鍦<E988AD><E98DA6>毺䰻<E6AFBA><E4B0BB>
|
||||
- **<EFBFBD>𣇉內摰孵膥<EFBFBD>詨<EFBFBD> (Icon Hover Palette)**:
|
||||
- <20>𡑒”撌血<E6928C><E8A180><EFBFBD>蜓<EFBFBD>𣇉內摰孵膥<E5ADB5><E886A5> `group-hover` <20><><EFBFBD><EFBFBD>厩眏瘛∟𠧧<E2889F>峕艶頧厩<E9A0A7> **撖阡<E69296>銝駁<E98A9D><E9A781><EFBFBD>**<EFBFBD><EFBFBD>
|
||||
- 憿𧼮ê̌: `group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300`<EFBFBD><EFBFBD>
|
||||
- **<EFBFBD><EFBFBD><EFBFBD><EFBFBD>峕郊霈𡃏𠧧**:
|
||||
- 銝餅<E98A9D>憿峕<E686BF>摮堒銁 `group-hover` <20><><EFBFBD><EFBFBD>峕郊霈𡃏𠧧嚗䔶誑撘瑕<E69298>暺墧<E69ABA>撘訫<E69298><E8A8AB><EFBFBD>
|
||||
- 憿𧼮ê̌: `group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors`<EFBFBD><EFBFBD>
|
||||
|
||||
### <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>銵冽綉<E586BD>園<EFBFBD> (Pagination & Controls)
|
||||
<EFBFBD>箔<EFBFBD>蝬剜<EFBFBD><EFBFBD>滢<EFBFBD>銝<EFBFBD><EFBFBD>湔<EFBFBD>改<EFBFBD><EFBFBD><EFBFBD><EFBFBD>匧<EFBFBD>銵函<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>𤤿<EFBFBD>隞嗅<EFBFBD><EFBFBD><EFBFBD><EFBFBD>敺芯誑銝卝<EFBFBD>𥴰uxury Jump<6D>齿芋撘𧶏<E69298>
|
||||
- **蝯曹<E89DAF>擃睃漲**: <20><><EFBFBD>㗇綉<E39787>園<EFBFBD>嚗<EFBFBD><E59A97><EFBFBD>𨰻<EFBFBD><F0A8B0BB><EFBFBD><EFBFBD>厰<EFBFBD><E58EB0>殷<EFBFBD><E6AEB7>箏<EFBFBD><E7AE8F><EFBFBD> `h-9` (36px)<29><>
|
||||
- **蝑<>彍<EFBFBD><E5BD8D><EFBFBD> (Limit Selector)**:
|
||||
- 閬讐<E996AC>: **蝳<>迫**<EFBFBD>刻”<EFBFBD>潔<EFBFBD><EFBFBD>對<EFBFBD>Header/Toolbar嚗厰<E59A97>銴<EFBFBD>𦆮蝵桃<E89DB5><E6A183>詨<EFBFBD><E8A9A8>偦<EFBFBD><E581A6>柴<EFBFBD><E69FB4>絞銝<E7B59E><E98A9D>嗥<EFBFBD><E597A5>澆<EFBFBD><E6BE86>典<EFBFBD><E585B8><EFBFBD><EFBFBD>雿溻<E99BBF><E6BABB>
|
||||
- **<EFBFBD><EFBFBD><EFBFBD>撠舘⏛ (Luxury Jump)**:
|
||||
- 璅∪<E79285>: <20>冽<EFBFBD><E586BD>喟絞<E5969F><E7B59E>Ⅳ<EFBFBD>厰<EFBFBD>嚗<EFBFBD><E59A97>蝡舐絞銝<E7B59E>雿輻鍂<E8BCBB>諹歲頧厰<E9A0A7><E58EB0>柴<EFBFBD>溻<EFBFBD><E6BABB>
|
||||
- 撖砍漲: 銝𧢲<E98A9D><F0A7A2B2>詨鱓<E8A9A8>折<EFBFBD> Padding <20><> `pl-4 pr-10`<EFBFBD><EFBFBD>
|
||||
- 摮烾<E691AE>: 雿輻鍂 `text-xs font-black tracking-widest`<EFBFBD><EFBFBD>
|
||||
- **<EFBFBD><EFBFBD>內<EFBFBD><EFBFBD><EFBFBD>**:
|
||||
- 銵<><E98AB5>蝡舫黸<E888AB>誩<EFBFBD>擗䁅<E69397>敶辷<E695B6><E8BEB7><EFBFBD><EFBFBD><EFBFBD>踺<EFBFBD><E8B8BA>1 - 10 / 50<35>齿聢撘譌<E69298><E8AD8C>
|
||||
- <20>詨<EFBFBD>憿讛𠧧撠漤<E692A0> `text-slate-600` (瘛梯𠧧: `text-slate-300`)<29><>
|
||||
|
||||
### 摨閖<E691A8>皜<EFBFBD>鱓<EFBFBD>批<EFBFBD><E689B9><EFBFBD> (Bottom List Controls)
|
||||
<EFBFBD>箔<EFBFBD>蝣箔<EFBFBD><EFBFBD>瑕<EFBFBD>銵函<EFBFBD><EFBFBD>滢<EFBFBD>靘踹⏚嚗峕<EFBFBD><EFBFBD>桀<EFBFBD><EFBFBD>### 璅蹱<E79285><E8B9B1>滢<EFBFBD><E6BBA2>厰<EFBFBD> (Standard Action Icons)
|
||||
銵冽聢<EFBFBD>抒<EFBFBD><EFBFBD>滢<EFBFBD>甈<EFBFBD><EFBFBD>嚗<EFBFBD><EFBFBD><EFBFBD>𣬚楊頛胯<EFBFBD>溻<EFBFBD><EFBFBD><EFBFBD><EFBFBD>⏛<EFBFBD>扎<EFBFBD>溻<EFBFBD><EFBFBD><EFBFBD>諹底<EFBFBD><EFBFBD><EFBFBD>㵪<EFBFBD>敹<EFBFBD><EFBFBD>雿輻鍂隞乩<EFBFBD>摰𡁶儔銋<EFBFBD> **<EFBFBD>屸<EFBFBD><EFBFBD>烐<EFBFBD>皞<EFBFBD> (Gold Standard)<29><>**嚗<>
|
||||
|
||||
- **<EFBFBD>勗<EFBFBD>璅<EFBFBD><EFBFBD>**:
|
||||
- 摰孵膥: `p-2 rounded-lg bg-slate-50 dark:bg-slate-800`
|
||||
- 銝餉𠧧: `text-slate-400`
|
||||
- <20>𦠜<EFBFBD>: `border border-transparent` (<28>脤<EFBFBD><E884A4>滩<EFBFBD><E6BBA9><EFBFBD>)
|
||||
- <20>擧腹: `transition-all` (雿輻鍂<E8BCBB>鞱身<E99EB1>笔漲隞亦Ⅱ靽苷<E99DBD><E88BB7>賣<EFBFBD>)
|
||||
- <20>𣇉內蝎㛖敦: `stroke-width="2.5"`
|
||||
- 撠箏站: `w-4 h-4`
|
||||
|
||||
- **蝺刻摩<E588BB>厰<EFBFBD> (Edit)**:
|
||||
- <20>詨<EFBFBD><E8A9A8>寞<EFBFBD>: `hover:text-cyan-500 hover:bg-cyan-500/5 hover:border-cyan-500/20`
|
||||
- SVG 頝臬<E9A09D>:
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
- **<EFBFBD>亦<EFBFBD>閰單<EFBFBD> (View/Detail)**:
|
||||
- <20>詨<EFBFBD><E8A9A8>寞<EFBFBD>: `hover:text-indigo-500 hover:bg-indigo-500/5 hover:border-indigo-500/20`
|
||||
- SVG 頝臬<E9A09D>:
|
||||
```html
|
||||
<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="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"/></svg>
|
||||
```
|
||||
|
||||
- **<EFBFBD>芷膄<EFBFBD>厰<EFBFBD> (Delete)**:
|
||||
- <20>詨<EFBFBD><E8A9A8>寞<EFBFBD>: `hover:text-rose-500 hover:bg-rose-500/5 hover:border-rose-500/20`
|
||||
- SVG 頝臬<E9A09D>:
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
y 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="relative group">
|
||||
<!-- <20><><EFBFBD><EFBFBD>蹱<EFBFBD>撠𧢲<E692A0><F0A7A2B2>硋<EFBFBD>閬<EFBFBD><E996AC><EFBFBD>擧蕪<E693A7>剁<EFBFBD>蝳<EFBFBD>迫<EFBFBD>滩<EFBFBD>蝑<EFBFBD>彍<EFBFBD><E5BD8D><EFBFBD> -->
|
||||
<input type="text" class="luxury-input pl-12 pr-6 w-64" placeholder="{{ __('Search...') }}">
|
||||
</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>
|
||||
```
|
||||
|
||||
### 皜<>鱓甈<E9B193><E79488>閬讐<E996AC> (Column Visibility & Standards)
|
||||
- **<EFBFBD>箏<EFBFBD>甈<EFBFBD><EFBFBD>**: 蝚砌<E89D9A>甈<EFBFBD><E79488>𡁜虜<F0A1819C>箝<EFBFBD>屸<EFBFBD><E5B1B8>菜<EFBFBD>霅塩<E99C85>㵪<EFBFBD>憒<EFBFBD> ID <20>𡝗<EFBFBD><F0A19D97>橒<EFBFBD>嚗峕<E59A97><E5B395>瑕<EFBFBD><E79195>寞<EFBFBD>摮烾<E691AE>璅<EFBFBD><E79285><EFBFBD><EFBFBD>
|
||||
- **<EFBFBD>滢<EFBFBD>甈<EFBFBD><EFBFBD>**: 蝯曹<E89DAF>雿齿䲰銵冽聢<E586BD><E881A2><EFBFBD>喟垢嚗䔶蒂<E494B6>賢<EFBFBD><E8B3A2><EFBFBD> `Action` (<28><> `<60>滢<EFBFBD>`)嚗峕<E59A97>憿諹<E686BF><E8ABB9>批捆<E689B9><E68D86><EFBFBD> `text-right`<EFBFBD><EFBFBD>
|
||||
## 9. 蝟餌絞<E9A48C>澆捆<E6BE86>扯<EFBFBD>璅蹱<E79285><E8B9B1><EFBFBD> (Compatibility & Standardization)
|
||||
|
||||
<EFBFBD>箔<EFBFBD>蝣箔<EFBFBD><EFBFBD>其<EFBFBD><EFBFBD>𣬚<EFBFBD><EFBFBD>祉<EFBFBD><EFBFBD>讠䔄<EFBFBD>啣<EFBFBD>銝哨<EFBFBD>憒<EFBFBD>𤌍<EFBFBD>滚<EFBFBD>獢<EFBFBD>蝙<EFBFBD>函<EFBFBD> Tailwind CSS v3.1嚗务I <20>質<EFBFBD>甇<EFBFBD>Ⅱ<EFBFBD><E285A1>𣶹嚗䔶蒂蝬剜<E89DAC><E5899C>函<EFBFBD><E587BD>滢<EFBFBD><E6BBA2>煺<EFBFBD><E785BA>湛<EFBFBD>敹<EFBFBD><E695B9><EFBFBD>萄<EFBFBD>隞乩<E99A9E>憿滚<E686BF>閬讐<E996AC><E8AE90><EFBFBD>
|
||||
|
||||
### Tailwind CSS <20><>𧋦<EFBFBD>澆捆<E6BE86><E68D86> (v3.1)
|
||||
- **蝳<>迫雿輻鍂 `size-` 撅祆<E69285><E7A586>**: <20>羓<EFBFBD>銝齿𣈲<E9BDBF><F0A388B2> `size-4` 蝑㕑<E89D91>瘜𤏪<E7989C>隢衤<E99AA2>敺见<E695BA><E8A781><EFBFBD>神雿<E7A59E> `w-4 h-4`<EFBFBD><EFBFBD>
|
||||
- **<EFBFBD>踹<EFBFBD><EFBFBD>墧<EFBFBD>皞㚚<EFBFBD>頝<EFBFBD>**: <20>踹<EFBFBD>雿輻鍂 `4.5` (`18px`) 蝑劐遙<E58A90>誩<EFBFBD>潘<EFBFBD><E6BD98>芸<EFBFBD>雿輻鍂璅蹱<E79285>蝑厩<E89D91>憒<EFBFBD> `4` (`16px`) <20><> `5` (`20px`)<29><>
|
||||
|
||||
### 璅蹱<E79285><E8B9B1>滢<EFBFBD><E6BBA2>厰<EFBFBD> (Standard Action Icons)
|
||||
銵冽聢<EFBFBD>抒<EFBFBD><EFBFBD>滢<EFBFBD>甈<EFBFBD><EFBFBD>嚗<EFBFBD><EFBFBD><EFBFBD>𣬚楊頛胯<EFBFBD>溻<EFBFBD><EFBFBD><EFBFBD><EFBFBD>⏛<EFBFBD>扎<EFBFBD>㵪<EFBFBD>敹<EFBFBD><EFBFBD>雿輻鍂隞乩<EFBFBD>摰𡁶儔銋𧢲<EFBFBD>皞吔<EFBFBD>
|
||||
|
||||
- **<EFBFBD>勗<EFBFBD>璅<EFBFBD><EFBFBD>**:
|
||||
- 摰孵膥: `p-2 rounded-lg bg-slate-50 dark:bg-slate-800`
|
||||
- 銝餉𠧧: `text-slate-400`
|
||||
- <20>𣇉內蝎㛖敦: `stroke-width="2.5"`
|
||||
- 撠箏站: `w-4 h-4`
|
||||
|
||||
- **蝺刻摩<E588BB>厰<EFBFBD> (Edit)**:
|
||||
- <20>詨<EFBFBD><E8A9A8>寞<EFBFBD>: `hover:text-cyan-500 hover:bg-cyan-500/10 hover:border-cyan-500/20`
|
||||
- SVG 頝臬<E9A09D>:
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
- **<EFBFBD>芷膄<EFBFBD>厰<EFBFBD> (Delete)**:
|
||||
- <20>詨<EFBFBD><E8A9A8>寞<EFBFBD>: `hover:text-rose-500 hover:bg-rose-500/10 hover:border-rose-500/20`
|
||||
- SVG 頝臬<E9A09D>:
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
|
||||
## 10. 摮烾<E691AE><E783BE><EFBFBD><EFBFBD>銵栞<E98AB5>閮𡃏<E996AE>蝭<EFBFBD> (Typography & Technical Data)
|
||||
|
||||
<EFBFBD>箔<EFBFBD>蝣箔<EFBFBD><EFBFBD>函<EFBFBD><EFBFBD>峕活閬<EFBFBD><EFBFBD>閮𨳍<EFBFBD>滚<EFBFBD><EFBFBD>蹱扔銝<EFBFBD><EFBFBD>渡<EFBFBD>擃条<EFBFBD><EFBFBD><EFBFBD><EFBFBD>敹<EFBFBD><EFBFBD><EFBFBD>萄<EFBFBD>隞乩<EFBFBD><EFBFBD>峕<EFBFBD><EFBFBD>唳<EFBFBD>蝡踴<EFBFBD>滩<EFBFBD>蝭<EFBFBD><EFBFBD>
|
||||
|
||||
### <20>詨<EFBFBD>璅<EFBFBD><E79285>蝝𡁜ê̌ (Core Typography Scale)
|
||||
| 鞈<><E99E88>憿𧼮<E686BF> | 摰X<E691B0>/<2F>滨蔭<E6BBA8>滨迂 (璅䠷<E79285>) | <20><>銵㮖誨蝣<E8AAA8> (ID, SN, Code) | <20><><EFBFBD>蝚西<E89D9A> (<28><>) |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **摮烾<E691AE><E783BE><EFBFBD>** | `font-sans` (Plus Jakarta Sans) | `font-mono` (敺桃葬<E6A183>见鱓<E8A781>蹱聢) | `font-sans` |
|
||||
| **撠箏站** | `text-base` | `text-xs` (銝滚虾雿輻鍂 10px) | `text-xs` |
|
||||
| **摮烾<E691AE>** | `font-extrabold` (800) | `font-bold` (700) | `font-bold` |
|
||||
| **摮𡑒<E691AE>** | `tracking-tight` (-0.02em) | `tracking-widest` (<28><>撖<EFBFBD>) | `tracking-normal` |
|
||||
| **<EFBFBD>澆<EFBFBD>** | 靽脲<E99DBD><E884B2>笔<EFBFBD><E7AC94>滨迂 | `uppercase` (撘瑕<E69298>憭批神) | N/A |
|
||||
| **<EFBFBD>脣蔗** | `slate-900` / `slate-100` | `slate-500` / `slate-400` | `slate-300` / `slate-700` |
|
||||
|
||||
### 撖虫<E69296>蝳<EFBFBD><E89DB3><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
- **蝳<>迫<EFBFBD>𣈯<EFBFBD> (No Italics)**: <20>滨迂甈<E8BF82><E79488><EFBFBD>渡<EFBFBD><E6B8A1><EFBFBD>葆 `italic`嚗<EFBFBD>鸌<EFBFBD>交糓璅䠷<EFBFBD><EFBFBD>㚚<EFBFBD>蝵桀<EFBFBD>蝔梧<EFBFBD>嚗䔶<EFBFBD><EFBFBD><EFBFBD>凒<EFBFBD>箏<EFBFBD>璆剜<EFBFBD><EFBFBD><EFBFBD>
|
||||
- **雿𦦵鍂蝭<E98D82><E89DAD> (Mono Scoping)**: `font-mono` <20><><EFBFBD>雿𦦵鍂<F0A6A6B5>潦<EFBFBD>𣬚<EFBFBD><F0A3AC9A>望<EFBFBD>/<2F>詨<EFBFBD><E8A9A8>滨<EFBFBD>隞<EFBFBD>Ⅳ<EFBFBD><E285A3>mail <20>硋<EFBFBD><E7A18B>娪<EFBFBD>敹<EFBFBD><E695B9><EFBFBD>墧飛 `font-sans` 隞亦Ⅱ靽嘥<E99DBD>瞏扎<E79E8F><E6898E>
|
||||
- **甈𢠃<E79488>頛匧<E9A09B> (Font Weights)**: 蝣箔<E89DA3> HTML Header 頛匧<E9A09B>鈭<EFBFBD> `800` <20><> `900` 甈𢠃<E79488>嚗屸<E59A97><E5B1B8>滨<EFBFBD>讛汗<E8AE9B>冽芋<E586BD>砍枂<E7A08D><E69E82><EFBFBD>蝎烾<E89D8E><E783BE><EFBFBD>
|
||||
---
|
||||
> [!IMPORTANT]
|
||||
> **<EFBFBD>讠䔄<EFBFBD>啣<EFBFBD><EFBFBD>賢<EFBFBD>嚗<EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ⅱ隤<EFBFBD> `app.css` 銝剔<E98A9D> `.btn-luxury-*` 蝟餃<E89D9F>蝯<EFBFBD>辣<EFBFBD>臬炏皛輯雲<E8BCAF><E99BB2>瘙<EFBFBD><E79899><EFBFBD>**
|
||||
> <20>渡<EFBFBD><E6B8A1><EFBFBD> Blade 銝剖神<E58996>亙之<E4BA99>誯<EFBFBD>銴<EFBFBD><E98AB4> `bg-indigo-600` 蝑㕑<E89D91>撘誯<E69298><E8AAAF>乓<EFBFBD><E4B993>
|
||||
|
||||
---
|
||||
> [!TIP]
|
||||
> <20>園<EFBFBD><E59C92>唳𧊋摰𡁶儔<F0A181B6><E58494> UI <20><>憛𦠜<E6869B>嚗<EFBFBD><E59A97><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> `admin.dashboard.blade.php` <20><>㨃<EFBFBD><E3A883><EFBFBD><EFBFBD>單<EFBFBD><E596AE>閙<EFBFBD>撖虫<E69296><E899AB>孵<EFBFBD><E5ADB5>脰<EFBFBD>銵滨<E98AB5><E6BBA8><EFBFBD>
|
||||
14
.env.example
14
.env.example
@@ -1,12 +1,14 @@
|
||||
APP_NAME=startCloud
|
||||
COMPOSE_PROJECT_NAME=start-cloud
|
||||
APP_NAME=starCloud
|
||||
COMPOSE_PROJECT_NAME=star-cloud
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8090
|
||||
APP_PORT=8090
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_LOCALE=zh_TW
|
||||
APP_TIMEZONE=Asia/Taipei
|
||||
DB_TIMEZONE="+08:00"
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
@@ -25,7 +27,7 @@ LOG_LEVEL=debug
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=start-cloud
|
||||
DB_DATABASE=star_cloud
|
||||
DB_USERNAME=sail
|
||||
DB_PASSWORD=password
|
||||
# FORWARD_DB_PORT=3308
|
||||
@@ -38,7 +40,7 @@ SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
@@ -49,7 +51,7 @@ REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
# FORWARD_REDIS_PORT=6380
|
||||
FORWARD_REDIS_PORT=6380
|
||||
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_SCHEME=null
|
||||
|
||||
115
.gitea/workflows/deploy-demo.yaml
Normal file
115
.gitea/workflows/deploy-demo.yaml
Normal file
@@ -0,0 +1,115 @@
|
||||
name: star-cloud-deploy-demo
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- demo
|
||||
|
||||
jobs:
|
||||
deploy-demo:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
github-server-url: https://gitea.taiwan-star.com.tw
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- name: Step 1 - Push Code to Demo
|
||||
run: |
|
||||
apt-get update && apt-get install -y rsync openssh-client
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEMO_SSH_KEY }}" > ~/.ssh/id_rsa_demo
|
||||
chmod 600 ~/.ssh/id_rsa_demo
|
||||
rsync -avz --delete \
|
||||
--exclude='/.git' \
|
||||
--exclude='/node_modules' \
|
||||
--exclude='/vendor' \
|
||||
--exclude='/storage' \
|
||||
--exclude='/.env' \
|
||||
--exclude='/public/build' \
|
||||
-e "ssh -p 2227 -i ~/.ssh/id_rsa_demo -o StrictHostKeyChecking=no" \
|
||||
./ root@220.132.7.82:/var/www/star-cloud-demo/
|
||||
rm ~/.ssh/id_rsa_demo
|
||||
|
||||
- name: Step 2 - Check if Rebuild Needed
|
||||
id: check_rebuild
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 220.132.7.82
|
||||
port: 2227
|
||||
username: root
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
cd /var/www/star-cloud-demo
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.demo\.yaml|docker-compose\.yaml)'; then
|
||||
echo "REBUILD_NEEDED=true"
|
||||
else
|
||||
echo "REBUILD_NEEDED=false"
|
||||
fi
|
||||
|
||||
- name: Step 3 - Container Up & Health Check
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 220.132.7.82
|
||||
port: 2227
|
||||
username: root
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
cd /var/www/star-cloud-demo
|
||||
chown -R 1000:1000 .
|
||||
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.demo\.yaml|docker-compose\.yaml)'; then
|
||||
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --build --wait
|
||||
else
|
||||
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
|
||||
if ! docker ps --format '{{.Names}}' | grep -q 'star-cloud-demo-laravel'; then
|
||||
echo "容器未運行,正在啟動..."
|
||||
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --wait
|
||||
else
|
||||
echo "容器已運行,跳過 docker compose,直接進行程式碼部署..."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "容器狀態:" && docker ps --filter "name=star-cloud-demo"
|
||||
|
||||
- name: Step 4 - Composer & NPM Build
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: 220.132.7.82
|
||||
port: 2227
|
||||
username: root
|
||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||
script: |
|
||||
docker exec -u 1000:1000 -w /var/www/html star-cloud-demo-laravel sh -c "
|
||||
# 1. 後端依賴
|
||||
composer install --no-dev --optimize-autoloader --no-interaction &&
|
||||
|
||||
# 2. 前端編譯
|
||||
npm install &&
|
||||
npm run build &&
|
||||
|
||||
# 3. Laravel 初始化與優化
|
||||
php artisan migrate --force &&
|
||||
php artisan optimize:clear &&
|
||||
php artisan optimize &&
|
||||
php artisan view:cache &&
|
||||
php artisan db:seed --class=RoleSeeder --force &&
|
||||
php artisan db:seed --class=AdminUserSeeder --force
|
||||
"
|
||||
docker exec star-cloud-demo-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||
|
||||
- name: Step 5 - Auto Sync workflows to main
|
||||
run: |
|
||||
git config --global user.email "bot@taiwan-star.com.tw"
|
||||
git config --global user.name "CICD Bot"
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
git checkout ${{ github.ref_name }} -- .gitea/workflows/
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "[AUTO] Sync workflows from ${{ github.ref_name }} to main"
|
||||
GIT_SSH_COMMAND="ssh -p 3222 -o StrictHostKeyChecking=no" git push origin main
|
||||
fi
|
||||
git checkout ${{ github.ref_name }}
|
||||
|
||||
21
.gitea/workflows/deploy-prod.yaml
Normal file
21
.gitea/workflows/deploy-prod.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
name: star-cloud-deploy-production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
github-server-url: https://gitea.taiwan-star.com.tw
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- name: Step 1 - Push Code to Production
|
||||
run: |
|
||||
echo "Production deployment is currently in preparation..."
|
||||
# 待正式環境資料確定後,再補上 rsync 與 SSH 邏輯
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -17,3 +17,7 @@ yarn-error.log
|
||||
/.fleet
|
||||
/.idea
|
||||
/.vscode
|
||||
/docs/API
|
||||
/docs/*.xlsx
|
||||
|
||||
|
||||
|
||||
216
README.md
216
README.md
@@ -1,112 +1,116 @@
|
||||
# Star Cloud 智能販賣機管理平台
|
||||
|
||||
## 專案簡介 (Project Description)
|
||||
Star Cloud 是一個專為智能販賣機設計的後台管理系統,旨在提供全方位的機台監控、庫存管理、銷售分析與會員管理功能。透過此平台,管理者可以即時掌握機台運營狀態,優化補貨流程,並透過數據分析提升營運效益。
|
||||
> 基於 Docker 的全方位智能販賣機後台管理系統 (Cloud 平台)
|
||||
|
||||
## 技術棧 (Technology Stack)
|
||||
|
||||
### 後端 (Backend)
|
||||
- **Framework**: Laravel 10.x
|
||||
- **Language**: PHP 8.1+
|
||||
- **Database**: MySQL 8.0+
|
||||
- **Authentication**: Laravel Sanctum (API Token Authentication)
|
||||
- **Tools**: Composer
|
||||
|
||||
### 前端 (Frontend)
|
||||
- **Framework**: Blade Templates (Laravel 預設樣板引擎)
|
||||
- **CSS Framework**: Tailwind CSS 3.x
|
||||
- **JavaScript**: Alpine.js 3.x
|
||||
- **Build Tool**: Vite 5.x
|
||||
- **HTTP Client**: Axios
|
||||
|
||||
## 安裝與使用說明 (Installation & Usage)
|
||||
|
||||
請依照以下步驟將專案 Clone 至本地端並開始運行:
|
||||
|
||||
### 0. 前置需求 (Prerequisites)
|
||||
確保您的系統已安裝以下軟體:
|
||||
- PHP 8.1+
|
||||
- Composer
|
||||
- Node.js & npm
|
||||
- MySQL 8.0+
|
||||
|
||||
若您尚未安裝 MySQL,Windows 使用者可至 [MySQL 官網](https://dev.mysql.com/downloads/installer/) 下載 Installer,或使用 XAMPP / Laragon 等整合環境。
|
||||
|
||||
### 1. 下載專案 (Clone Repository)
|
||||
```bash
|
||||
git clone <repository_url>
|
||||
cd star-cloud
|
||||
```
|
||||
|
||||
### 2. 安裝依賴套件 (Install Dependencies)
|
||||
|
||||
安裝後端 PHP 套件:
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
|
||||
安裝前端 Node.js 套件:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. 環境變數設定 (Environment Setup)
|
||||
複製範例環境設定檔:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
請開啟 `.env` 檔案,並依照您的本地環境設定資料庫連線資訊:
|
||||
```dotenv
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=star_cloud
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=your_password
|
||||
```
|
||||
|
||||
產生應用程式金鑰 (Application Key):
|
||||
```bash
|
||||
php artisan key:generate
|
||||
```
|
||||
|
||||
### 4. 資料庫遷移 (Database Migration)
|
||||
執行 Migration 以建立資料庫結構:
|
||||
```bash
|
||||
php artisan migrate
|
||||
```
|
||||
php artisan migrate --seed
|
||||
```
|
||||
|
||||
### 4.1 預設管理員帳號 (Default Admin Account)
|
||||
執行上述指令後,系統會建立一組預設管理員帳號:
|
||||
- **Email**: `admin@star-cloud.com`
|
||||
- **Password**: `password`
|
||||
|
||||
### 5. 編譯前端資源 (Build Frontend Assets)
|
||||
啟動開發模式 (Hot Module Replacement):
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
或編譯生產環境檔案:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 6. 啟動伺服器 (Start Server)
|
||||
啟動 Laravel 開發伺服器:
|
||||
```bash
|
||||
php artisan serve --port=8001
|
||||
```
|
||||
預設網址為:http://localhost:8001
|
||||
|
||||
## 主要功能模組
|
||||
- **儀錶板 (Dashboard)**: 銷售數據概覽、機台狀態監控
|
||||
- **機台管理 (Machine Management)**: 機台列表、遠端控制、日誌查詢
|
||||
- **商品與庫存 (Inventory)**: 商品管理、進銷存、補貨單
|
||||
- **銷售管理 (Sales)**: 交易紀錄、營收報表
|
||||
- **權限設定 (Permissions)**: 角色與權限分配
|
||||
Star Cloud 是一個專為智能販賣機設計的後台管理系統,負責管理機台、商品、銷售數據,並為硬體端點提供專用的 API。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 技術架構
|
||||
|
||||
### 核心架構
|
||||
本專案採用 **傳統單體式架構 (Monolithic Architecture)**,結合 Laravel Blade 引擎進行伺服器端渲染 (SSR)。
|
||||
|
||||
| 服務 | 容器名稱 | 技術 | 用途 | 本地 Port |
|
||||
|------|---------|------|------|--------|
|
||||
| **應用程式** | `star-cloud-laravel` | Laravel 12 + PHP 8.5 | 核心業務與渲染 | 8090 |
|
||||
| **資料庫** | `star-cloud-mysql` | MySQL 8.0 | 數據持久化 | 3306 |
|
||||
| **快取/隊列** | `star-cloud-redis` | Redis Alpine | IoT 高併發隊列 | 6380 |
|
||||
|
||||
### 後端技術棧
|
||||
- **Framework**: Laravel 12.x
|
||||
- **Language**: PHP 8.5
|
||||
- **Redis**: 用於 IoT 高併發隊列 (B010, B600 等)
|
||||
- **Database**: MySQL 8.0
|
||||
|
||||
### 前端技術棧
|
||||
- **View**: Laravel Blade
|
||||
- **CSS**: Tailwind CSS + **Preline UI**
|
||||
- **JS**: Alpine.js (行為控制)
|
||||
- **Build**: Vite
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 開發環境 (Laravel Sail)
|
||||
|
||||
本專案建議使用 **Laravel Sail** 進行開發,避免直接在宿主機執行指令。
|
||||
|
||||
### 前置需求
|
||||
- Docker Desktop (Windows/Mac) 或 Docker Engine (Linux)
|
||||
- Git
|
||||
|
||||
### 快速啟動
|
||||
1. `clone` 專案並進入目錄
|
||||
2. `cp .env.example .env`
|
||||
3. `./vendor/bin/sail up -d`
|
||||
4. `./vendor/bin/sail composer install`
|
||||
5. `./vendor/bin/sail artisan key:generate`
|
||||
6. `./vendor/bin/sail artisan migrate --seed`
|
||||
7. `./vendor/bin/sail npm install`
|
||||
8. `./vendor/bin/sail npm run dev`
|
||||
|
||||
### 常用開發指令
|
||||
| 功能 | 指令 |
|
||||
|------|------|
|
||||
| 啟動環境 | `./vendor/bin/sail up -d` |
|
||||
| 停止環境 | `./vendor/bin/sail down` |
|
||||
| Artisan 指令 | `./vendor/bin/sail artisan <cmd>` |
|
||||
| Composer 指令 | `./vendor/bin/sail composer <cmd>` |
|
||||
| NPM 指令 | `./vendor/bin/sail npm <cmd>` |
|
||||
| 執行測試 | `./vendor/bin/sail test` |
|
||||
|
||||
---
|
||||
|
||||
## 🔐 API 規範
|
||||
|
||||
系統 API 分為兩大類,遵循不同的設計慣例:
|
||||
|
||||
### 1. Admin/Web API (`/api/v1/...`)
|
||||
- **對象**: 後台管理介面、APP UI。
|
||||
- **認證**: Laravel Sanctum (Session/Token)。
|
||||
- **格式**: 標準 RESTful JSON。
|
||||
|
||||
### 2. Machine IoT API (`/api/app/...`)
|
||||
- **對象**: 智能販賣機、計時器等硬體。
|
||||
- **認證**: Header `Authorization: Bearer <api_token>`。
|
||||
- **高併發處理**: 核心日誌 (B010 心跳、B600 交易) **嚴禁直寫 DB**,必須進入 **Redis Queue** 背景異步處理。
|
||||
|
||||
---
|
||||
|
||||
## 🌐 多語系支援 (I18n)
|
||||
|
||||
所有 UI 顯示文字必須支援多語系,禁止 Hard-coded。
|
||||
- **語系檔案**: `lang/zh_TW.json`, `lang/en.json`, `lang/ja.json`。
|
||||
- **呼叫方式**: 使用 `__('Phrases in English')` 或 `@lang('...')`。
|
||||
- **命名規範**: 優先使用「英文原始詞彙」作為 Key 名稱。
|
||||
|
||||
---
|
||||
|
||||
## 📂 目錄結構
|
||||
|
||||
- `app/Http/Controllers/`: 控制器
|
||||
- `app/Models/{Domain}/`: 分領域的模型 (如 `Machine`, `Member`)
|
||||
- `app/Services/{Domain}/`: 封裝商業邏輯與資料異動
|
||||
- `app/Jobs/{Domain}/`: IoT 異步隊列處理任務 (重要)
|
||||
- `resources/views/`: Blade 模板 (按功能分資料夾)
|
||||
- `resources/views/components/`: 可重用的 UI 組件
|
||||
- `routes/`: 路由定義 (`web.php` 與 `api.php`)
|
||||
|
||||
---
|
||||
|
||||
## 🚢 CI/CD 與部署
|
||||
|
||||
- **自動化工具**: Gitea Actions (`.gitea/workflows/`)。
|
||||
- **Demo 環境**: 推送到 `demo` 分支會自動部署至 `demo-cloud.taiwan-star.com.tw`。
|
||||
- **伺服器路徑**: `/var/www/star-cloud-demo` (透過 `ssh gitea_work` 登入)。
|
||||
|
||||
---
|
||||
|
||||
## 授權與版權
|
||||
© Star Cloud. All Rights Reserved.
|
||||
|
||||
---
|
||||
|
||||
## 技術支援
|
||||
|
||||
如有問題或建議,請聯繫開發團隊或提交 Issue。
|
||||
64
app/Console/Commands/Machine/SimulateMachineLogs.php
Normal file
64
app/Console/Commands/Machine/SimulateMachineLogs.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
10
app/Http/Controllers/Admin/AdminController.php
Normal file
10
app/Http/Controllers/Admin/AdminController.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
abstract class AdminController extends Controller
|
||||
{
|
||||
// Admin 相關的共用邏輯可寫於此
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AppConfig;
|
||||
use App\Models\System\AppConfig;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AppConfigController extends Controller
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\BasicSettings;
|
||||
|
||||
use App\Http\Controllers\Admin\AdminController;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineModel;
|
||||
use App\Models\System\PaymentConfig;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MachineSettingController extends AdminController
|
||||
{
|
||||
/**
|
||||
* 顯示機台設定列表
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$per_page = $request->input('per_page', 20);
|
||||
$query = Machine::query()->with(['machineModel', 'paymentConfig', 'company']);
|
||||
|
||||
// 搜尋:名稱或序號
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$machines = $query->latest()
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
|
||||
$models = MachineModel::select('id', 'name')->get();
|
||||
$paymentConfigs = PaymentConfig::select('id', 'name')->get();
|
||||
// 這裡應根據租戶 (Company) 決定可用的選項,暫採簡單模擬或從 Auth 取得
|
||||
$companies = \App\Models\System\Company::select('id', 'name')->get();
|
||||
|
||||
return view('admin.basic-settings.machines.index', compact('machines', 'models', 'paymentConfigs', 'companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存新機台 (僅核心欄位)
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'serial_no' => 'required|string|unique:machines,serial_no',
|
||||
'company_id' => 'required|exists:companies,id',
|
||||
'machine_model_id' => 'required|exists:machine_models,id',
|
||||
'payment_config_id' => 'nullable|exists:payment_configs,id',
|
||||
'images.*' => 'image|mimes:jpeg,png,jpg,gif|max:2048',
|
||||
]);
|
||||
|
||||
$imagePaths = [];
|
||||
if ($request->hasFile('images')) {
|
||||
foreach (array_slice($request->file('images'), 0, 3) as $image) {
|
||||
$imagePaths[] = $this->processAndStoreImage($image);
|
||||
}
|
||||
}
|
||||
|
||||
$machine = Machine::create(array_merge($validated, [
|
||||
'status' => 'offline',
|
||||
'creator_id' => auth()->id(),
|
||||
'updater_id' => auth()->id(),
|
||||
'card_reader_seconds' => 30, // 預設值
|
||||
'card_reader_checkout_time_1' => '22:30:00',
|
||||
'card_reader_checkout_time_2' => '23:45:00',
|
||||
'payment_buffer_seconds' => 5,
|
||||
'images' => $imagePaths,
|
||||
]));
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index')
|
||||
->with('success', __('Machine created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示詳細編輯頁面
|
||||
*/
|
||||
public function edit(Machine $machine): View
|
||||
{
|
||||
$models = MachineModel::select('id', 'name')->get();
|
||||
$paymentConfigs = PaymentConfig::select('id', 'name')->get();
|
||||
$companies = \App\Models\System\Company::select('id', 'name')->get();
|
||||
|
||||
return view('admin.basic-settings.machines.edit', compact('machine', 'models', 'paymentConfigs', 'companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新機台詳細參數
|
||||
*/
|
||||
public function update(Request $request, Machine $machine): RedirectResponse
|
||||
{
|
||||
Log::info('Machine Update Request', ['machine_id' => $machine->id, 'data' => $request->all()]);
|
||||
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'card_reader_seconds' => 'required|integer|min:0',
|
||||
'payment_buffer_seconds' => 'required|integer|min:0',
|
||||
'card_reader_checkout_time_1' => 'nullable|string',
|
||||
'card_reader_checkout_time_2' => 'nullable|string',
|
||||
'heating_start_time' => 'nullable|string',
|
||||
'heating_end_time' => 'nullable|string',
|
||||
'card_reader_no' => 'nullable|string|max:255',
|
||||
'key_no' => 'nullable|string|max:255',
|
||||
'invoice_status' => 'required|integer|in:0,1,2',
|
||||
'welcome_gift_enabled' => 'boolean',
|
||||
'is_spring_slot_1_10' => 'boolean',
|
||||
'is_spring_slot_11_20' => 'boolean',
|
||||
'is_spring_slot_21_30' => 'boolean',
|
||||
'is_spring_slot_31_40' => 'boolean',
|
||||
'is_spring_slot_41_50' => 'boolean',
|
||||
'is_spring_slot_51_60' => 'boolean',
|
||||
'member_system_enabled' => 'boolean',
|
||||
'machine_model_id' => 'required|exists:machine_models,id',
|
||||
'payment_config_id' => 'nullable|exists:payment_configs,id',
|
||||
]);
|
||||
|
||||
Log::info('Machine Update Validated Data', ['data' => $validated]);
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
Log::error('Machine Update Validation Failed', ['errors' => $e->errors()]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$machine->update(array_merge($validated, [
|
||||
'updater_id' => auth()->id(),
|
||||
]));
|
||||
|
||||
// 處理圖片更新 (若有上傳新圖片,則替換或附加,這裡採簡單邏輯:若有傳 images 則全換)
|
||||
if ($request->hasFile('images')) {
|
||||
// 刪除舊圖
|
||||
if (!empty($machine->images)) {
|
||||
foreach ($machine->images as $oldPath) {
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
}
|
||||
}
|
||||
|
||||
$imagePaths = [];
|
||||
foreach (array_slice($request->file('images'), 0, 3) as $image) {
|
||||
$imagePaths[] = $this->processAndStoreImage($image);
|
||||
}
|
||||
$machine->update(['images' => $imagePaths]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index')
|
||||
->with('success', __('Machine settings updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 處理圖片並轉換為 WebP
|
||||
*/
|
||||
private function processAndStoreImage($file): string
|
||||
{
|
||||
$filename = Str::random(40) . '.webp';
|
||||
$path = 'machines/' . $filename;
|
||||
|
||||
// 建立圖資源
|
||||
$image = null;
|
||||
$extension = strtolower($file->getClientOriginalExtension());
|
||||
|
||||
switch ($extension) {
|
||||
case 'jpeg':
|
||||
case 'jpg':
|
||||
$image = imagecreatefromjpeg($file->getRealPath());
|
||||
break;
|
||||
case 'png':
|
||||
$image = imagecreatefrompng($file->getRealPath());
|
||||
break;
|
||||
case 'gif':
|
||||
$image = imagecreatefromgif($file->getRealPath());
|
||||
break;
|
||||
case 'webp':
|
||||
$image = imagecreatefromwebp($file->getRealPath());
|
||||
break;
|
||||
}
|
||||
|
||||
if ($image) {
|
||||
// 確保目錄存在
|
||||
Storage::disk('public')->makeDirectory('machines');
|
||||
$fullPath = Storage::disk('public')->path($path);
|
||||
|
||||
// 轉換並儲存
|
||||
imagewebp($image, $fullPath, 80); // 品質 80
|
||||
imagedestroy($image);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
// Fallback to standard store if GD fails
|
||||
return $file->store('machines', 'public');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\BasicSettings;
|
||||
|
||||
use App\Http\Controllers\Admin\AdminController;
|
||||
use App\Models\System\PaymentConfig;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class PaymentConfigController extends AdminController
|
||||
{
|
||||
/**
|
||||
* 顯示金流配置列表
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$per_page = $request->input('per_page', 20);
|
||||
$configs = PaymentConfig::query()
|
||||
->with(['company', 'creator'])
|
||||
->latest()
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.basic-settings.payment-configs.index', [
|
||||
'paymentConfigs' => $configs
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示新增頁面
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
$companies = \App\Models\System\Company::select('id', 'name')->get();
|
||||
return view('admin.basic-settings.payment-configs.create', compact('companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存金流配置
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'company_id' => 'required|exists:companies,id',
|
||||
'settings' => 'required|array',
|
||||
]);
|
||||
|
||||
PaymentConfig::create([
|
||||
'name' => $request->name,
|
||||
'company_id' => $request->company_id,
|
||||
'settings' => $request->settings,
|
||||
'creator_id' => auth()->id(),
|
||||
'updater_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.basic-settings.payment-configs.index')
|
||||
->with('success', __('Payment Configuration created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示編輯頁面
|
||||
*/
|
||||
public function edit(PaymentConfig $paymentConfig): View
|
||||
{
|
||||
$companies = \App\Models\System\Company::select('id', 'name')->get();
|
||||
return view('admin.basic-settings.payment-configs.edit', compact('paymentConfig', 'companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新金流配置
|
||||
*/
|
||||
public function update(Request $request, PaymentConfig $paymentConfig): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'settings' => 'required|array',
|
||||
]);
|
||||
|
||||
$paymentConfig->update([
|
||||
'name' => $request->name,
|
||||
'settings' => $request->settings,
|
||||
'updater_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.basic-settings.payment-configs.index')
|
||||
->with('success', __('Payment Configuration updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除金流配置
|
||||
*/
|
||||
public function destroy(PaymentConfig $paymentConfig): RedirectResponse
|
||||
{
|
||||
$paymentConfig->delete();
|
||||
return redirect()->route('admin.basic-settings.payment-configs.index')
|
||||
->with('success', __('Payment Configuration deleted successfully.'));
|
||||
}
|
||||
}
|
||||
125
app/Http/Controllers/Admin/CompanyController.php
Normal file
125
app/Http/Controllers/Admin/CompanyController.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\System\Company;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CompanyController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Company::query()->withCount(['users', 'machines']);
|
||||
|
||||
// 搜尋
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 狀態篩選
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$companies = $query->latest()->paginate($per_page)->withQueryString();
|
||||
|
||||
return view('admin.companies.index', compact('companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'required|string|max:50|unique:companies,code',
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'valid_until' => 'nullable|date',
|
||||
'status' => 'required|boolean',
|
||||
'note' => 'nullable|string',
|
||||
// 帳號相關欄位 (可選)
|
||||
'admin_username' => 'nullable|string|max:255|unique:users,username',
|
||||
'admin_password' => 'nullable|string|min:8',
|
||||
'admin_name' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($validated) {
|
||||
$company = Company::create([
|
||||
'name' => $validated['name'],
|
||||
'code' => $validated['code'],
|
||||
'tax_id' => $validated['tax_id'] ?? null,
|
||||
'contact_name' => $validated['contact_name'] ?? null,
|
||||
'contact_phone' => $validated['contact_phone'] ?? null,
|
||||
'contact_email' => $validated['contact_email'] ?? null,
|
||||
'valid_until' => $validated['valid_until'] ?? null,
|
||||
'status' => $validated['status'],
|
||||
'note' => $validated['note'] ?? null,
|
||||
]);
|
||||
|
||||
// 如果有填寫帳號資訊,則建立管理員帳號
|
||||
if (!empty($validated['admin_username']) && !empty($validated['admin_password'])) {
|
||||
$user = \App\Models\System\User::create([
|
||||
'company_id' => $company->id,
|
||||
'username' => $validated['admin_username'],
|
||||
'password' => \Illuminate\Support\Facades\Hash::make($validated['admin_password']),
|
||||
'name' => $validated['admin_name'] ?: ($validated['contact_name'] ?: $validated['name']),
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
// 綁定客戶管理員角色
|
||||
$user->assignRole('tenant-admin');
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', __('Customer created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, Company $company)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'required|string|max:50|unique:companies,code,' . $company->id,
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'valid_until' => 'nullable|date',
|
||||
'status' => 'required|boolean',
|
||||
'note' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$company->update($validated);
|
||||
|
||||
return redirect()->back()->with('success', __('Customer updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Company $company)
|
||||
{
|
||||
if ($company->users()->count() > 0) {
|
||||
return redirect()->back()->with('error', __('Cannot delete company with active accounts.'));
|
||||
}
|
||||
|
||||
$company->delete();
|
||||
|
||||
return redirect()->back()->with('success', __('Customer deleted successfully.'));
|
||||
}
|
||||
}
|
||||
@@ -3,25 +3,40 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Machine;
|
||||
use App\Models\Machine\Machine;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
// 模擬數據或從資料庫獲取
|
||||
// 由於目前沒有數據,我們先傳遞一些預設值或空集合
|
||||
$totalMachines = Machine::count();
|
||||
$onlineMachines = Machine::where('status', 'online')->count();
|
||||
$offlineMachines = Machine::where('status', 'offline')->count();
|
||||
$errorMachines = Machine::where('status', 'error')->count();
|
||||
// 每頁顯示筆數限制 (預設為 10)
|
||||
$perPage = (int) request()->input('per_page', 10);
|
||||
if ($perPage <= 0) $perPage = 10;
|
||||
|
||||
// 從資料庫獲取真實統計數據
|
||||
$totalRevenue = \App\Models\Member\MemberWallet::sum('balance');
|
||||
$activeMachines = Machine::where('status', 'online')->count();
|
||||
$alertsPending = Machine::where('status', 'error')->count();
|
||||
$memberCount = \App\Models\Member\Member::count();
|
||||
|
||||
// 獲取機台列表 (分頁)
|
||||
$machines = Machine::when($request->search, function($query, $search) {
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->latest()
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.dashboard', compact(
|
||||
'totalMachines',
|
||||
'onlineMachines',
|
||||
'offlineMachines',
|
||||
'errorMachines'
|
||||
'totalRevenue',
|
||||
'activeMachines',
|
||||
'alertsPending',
|
||||
'memberCount',
|
||||
'machines'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,19 +29,11 @@ class DataConfigController extends Controller
|
||||
public function adminProducts()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '管理者可賣商品',
|
||||
'title' => '商品狀態',
|
||||
'description' => '管理者商品銷售權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// 帳號管理
|
||||
public function accounts()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '帳號管理',
|
||||
'description' => '主帳號管理',
|
||||
]);
|
||||
}
|
||||
|
||||
// 子帳號管理
|
||||
public function subAccounts()
|
||||
|
||||
56
app/Http/Controllers/Admin/DepositBonusRuleController.php
Normal file
56
app/Http/Controllers/Admin/DepositBonusRuleController.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Member\DepositBonusRule;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DepositBonusRuleController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$rules = DepositBonusRule::orderBy('min_amount')->get();
|
||||
return view('admin.deposit-bonus-rules.index', compact('rules'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'min_amount' => 'required|numeric|min:0',
|
||||
'bonus_type' => 'required|in:fixed,percentage',
|
||||
'bonus_value' => 'required|numeric|min:0',
|
||||
'is_active' => 'boolean',
|
||||
'start_at' => 'nullable|date',
|
||||
'end_at' => 'nullable|date|after:start_at',
|
||||
]);
|
||||
|
||||
DepositBonusRule::create($validated);
|
||||
|
||||
return redirect()->route('admin.deposit-bonus-rules.index')->with('success', '儲值回饋規則已建立');
|
||||
}
|
||||
|
||||
public function update(Request $request, DepositBonusRule $depositBonusRule)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'min_amount' => 'required|numeric|min:0',
|
||||
'bonus_type' => 'required|in:fixed,percentage',
|
||||
'bonus_value' => 'required|numeric|min:0',
|
||||
'is_active' => 'boolean',
|
||||
'start_at' => 'nullable|date',
|
||||
'end_at' => 'nullable|date|after:start_at',
|
||||
]);
|
||||
|
||||
$depositBonusRule->update($validated);
|
||||
|
||||
return redirect()->route('admin.deposit-bonus-rules.index')->with('success', '儲值回饋規則已更新');
|
||||
}
|
||||
|
||||
public function destroy(DepositBonusRule $depositBonusRule)
|
||||
{
|
||||
$depositBonusRule->delete();
|
||||
return redirect()->route('admin.deposit-bonus-rules.index')->with('success', '儲值回饋規則已刪除');
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Admin/GiftDefinitionController.php
Normal file
58
app/Http/Controllers/Admin/GiftDefinitionController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Member\GiftDefinition;
|
||||
use App\Models\Member\MembershipTier;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GiftDefinitionController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$gifts = GiftDefinition::with('tier')->get();
|
||||
$tiers = MembershipTier::orderBy('sort_order')->get();
|
||||
return view('admin.gift-definitions.index', compact('gifts', 'tiers'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|in:points,coupon,product,discount,cash',
|
||||
'value' => 'required|numeric|min:0',
|
||||
'tier_id' => 'nullable|exists:membership_tiers,id',
|
||||
'trigger' => 'required|in:register,birthday,annual,upgrade,manual',
|
||||
'validity_days' => 'required|integer|min:1',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
GiftDefinition::create($validated);
|
||||
|
||||
return redirect()->route('admin.gift-definitions.index')->with('success', '禮品已建立');
|
||||
}
|
||||
|
||||
public function update(Request $request, GiftDefinition $giftDefinition)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|in:points,coupon,product,discount,cash',
|
||||
'value' => 'required|numeric|min:0',
|
||||
'tier_id' => 'nullable|exists:membership_tiers,id',
|
||||
'trigger' => 'required|in:register,birthday,annual,upgrade,manual',
|
||||
'validity_days' => 'required|integer|min:1',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$giftDefinition->update($validated);
|
||||
|
||||
return redirect()->route('admin.gift-definitions.index')->with('success', '禮品已更新');
|
||||
}
|
||||
|
||||
public function destroy(GiftDefinition $giftDefinition)
|
||||
{
|
||||
$giftDefinition->delete();
|
||||
return redirect()->route('admin.gift-definitions.index')->with('success', '禮品已刪除');
|
||||
}
|
||||
}
|
||||
@@ -2,142 +2,100 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Machine;
|
||||
use App\Models\Machine\Machine;
|
||||
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);
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$query = Machine::query();
|
||||
|
||||
// 搜尋:名稱或序號
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$machines = $query->when($request->status, function ($query, $status) {
|
||||
return $query->where('status', $status);
|
||||
})
|
||||
->latest()
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
* 顯示所有機台日誌列表
|
||||
*/
|
||||
public function edit(Machine $machine)
|
||||
public function logs(Request $request): View
|
||||
{
|
||||
return view('admin.machines.edit', compact('machine'));
|
||||
$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);
|
||||
})
|
||||
->when($request->machine_id, function ($query, $machineId) {
|
||||
return $query->where('machine_id', $machineId);
|
||||
})
|
||||
->latest()
|
||||
->paginate($per_page)->withQueryString();
|
||||
|
||||
$machines = Machine::select('id', 'name')->get();
|
||||
|
||||
return view('admin.machines.logs', compact('logs', 'machines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
* 機台權限設定 (開發中)
|
||||
*/
|
||||
public function update(Request $request, Machine $machine)
|
||||
public function permissions(Request $request): View
|
||||
{
|
||||
$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', '機台更新成功');
|
||||
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
* 機台使用率統計 (開發中)
|
||||
*/
|
||||
public function destroy(Machine $machine)
|
||||
public function utilization(Request $request): View
|
||||
{
|
||||
$machine->delete();
|
||||
|
||||
return redirect()->route('admin.machines.index')
|
||||
->with('success', '機台已刪除');
|
||||
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||
}
|
||||
|
||||
// 機台日誌
|
||||
public function logs()
|
||||
/**
|
||||
* 機台到期管理 (開發中)
|
||||
*/
|
||||
public function expiry(Request $request): View
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '機台日誌',
|
||||
'description' => '機台操作歷史紀錄回溯',
|
||||
'features' => [
|
||||
'操作時間戳記',
|
||||
'事件類型分類',
|
||||
'操作人員記錄',
|
||||
'詳細描述查詢',
|
||||
]
|
||||
]);
|
||||
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||
}
|
||||
|
||||
// 機台權限
|
||||
public function permissions()
|
||||
/**
|
||||
* 機台維護紀錄 (開發中)
|
||||
*/
|
||||
public function maintenance(Request $request): View
|
||||
{
|
||||
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' => '機台維修工單系統',
|
||||
]);
|
||||
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
|
||||
}
|
||||
}
|
||||
|
||||
62
app/Http/Controllers/Admin/MembershipTierController.php
Normal file
62
app/Http/Controllers/Admin/MembershipTierController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Member\MembershipTier;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MembershipTierController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$tiers = MembershipTier::orderBy('sort_order')->get();
|
||||
return view('admin.membership-tiers.index', compact('tiers'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'annual_fee' => 'required|numeric|min:0',
|
||||
'discount_rate' => 'required|numeric|min:0|max:1',
|
||||
'point_multiplier' => 'required|numeric|min:0',
|
||||
'description' => 'nullable|string',
|
||||
'is_default' => 'boolean',
|
||||
]);
|
||||
|
||||
if ($request->is_default) {
|
||||
MembershipTier::where('is_default', true)->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
MembershipTier::create($validated);
|
||||
|
||||
return redirect()->route('admin.membership-tiers.index')->with('success', '會員等級已建立');
|
||||
}
|
||||
|
||||
public function update(Request $request, MembershipTier $membershipTier)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'annual_fee' => 'required|numeric|min:0',
|
||||
'discount_rate' => 'required|numeric|min:0|max:1',
|
||||
'point_multiplier' => 'required|numeric|min:0',
|
||||
'description' => 'nullable|string',
|
||||
'is_default' => 'boolean',
|
||||
]);
|
||||
|
||||
if ($request->is_default && !$membershipTier->is_default) {
|
||||
MembershipTier::where('is_default', true)->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
$membershipTier->update($validated);
|
||||
|
||||
return redirect()->route('admin.membership-tiers.index')->with('success', '會員等級已更新');
|
||||
}
|
||||
|
||||
public function destroy(MembershipTier $membershipTier)
|
||||
{
|
||||
$membershipTier->delete();
|
||||
return redirect()->route('admin.membership-tiers.index')->with('success', '會員等級已刪除');
|
||||
}
|
||||
}
|
||||
@@ -7,111 +7,293 @@ use Illuminate\Http\Request;
|
||||
|
||||
class PermissionController extends Controller
|
||||
{
|
||||
// APP功能管理
|
||||
public function appFeatures()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => 'APP功能管理',
|
||||
'description' => 'APP功能權限設定',
|
||||
]);
|
||||
}
|
||||
|
||||
// 資料設定權限
|
||||
public function dataConfig()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '資料設定權限',
|
||||
'description' => '資料設定功能權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// 銷售管理權限
|
||||
public function sales()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '銷售管理權限',
|
||||
'description' => '銷售管理功能權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// 機台管理權限
|
||||
public function machines()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '機台管理權限',
|
||||
'description' => '機台管理功能權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// 倉庫管理權限
|
||||
public function warehouses()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '倉庫管理權限',
|
||||
'description' => '倉庫管理功能權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// 分析管理權限
|
||||
public function analysis()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '分析管理權限',
|
||||
'description' => '分析管理功能權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// 稽核管理權限
|
||||
public function audit()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '稽核管理權限',
|
||||
'description' => '稽核管理功能權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// 遠端管理權限
|
||||
public function remote()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端管理權限',
|
||||
'description' => '遠端管理功能權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// Line管理權限
|
||||
public function line()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => 'Line管理權限',
|
||||
'description' => 'Line管理功能權限',
|
||||
]);
|
||||
}
|
||||
|
||||
// 權限角色設定
|
||||
public function roles()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '權限角色設定',
|
||||
'description' => '角色權限組合設定',
|
||||
]);
|
||||
$per_page = request()->input('per_page', 10);
|
||||
$user = auth()->user();
|
||||
$query = \App\Models\System\Role::query()->with(['permissions', 'users']);
|
||||
|
||||
// 租戶隔離:租戶只能看到自己公司的角色 + 系統角色 (company_id is null)
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$query->where(function($q) use ($user) {
|
||||
$q->where('company_id', $user->company_id)
|
||||
->orWhereNull('company_id');
|
||||
});
|
||||
}
|
||||
|
||||
// 搜尋:角色名稱
|
||||
if ($search = request()->input('search')) {
|
||||
$query->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
$roles = $query->latest()->paginate($per_page)->withQueryString();
|
||||
$all_permissions = \Spatie\Permission\Models\Permission::all()
|
||||
->filter(function($perm) {
|
||||
// 排除子項目的權限,只顯示主選單權限
|
||||
$excluded = [
|
||||
'menu.basic.machines',
|
||||
'menu.basic.payment-configs',
|
||||
'menu.companies',
|
||||
'menu.accounts',
|
||||
'menu.roles',
|
||||
];
|
||||
return !in_array($perm->name, $excluded);
|
||||
})
|
||||
->groupBy(function($perm) {
|
||||
if (str_starts_with($perm->name, 'menu.')) {
|
||||
return 'menu';
|
||||
}
|
||||
return 'other';
|
||||
});
|
||||
|
||||
// 根據路由決定標題
|
||||
$title = request()->routeIs('*.sub-account-roles') ? __('Sub Account Roles') : __('Role Settings');
|
||||
|
||||
return view('admin.permission.roles', compact('roles', 'all_permissions', 'title'));
|
||||
}
|
||||
|
||||
// 其他功能管理
|
||||
public function others()
|
||||
/**
|
||||
* Store a newly created role in storage.
|
||||
*/
|
||||
public function storeRole(Request $request)
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '其他功能管理',
|
||||
'description' => '其他特殊功能權限',
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:roles,name',
|
||||
'permissions' => 'nullable|array',
|
||||
'permissions.*' => 'string|exists:permissions,name',
|
||||
]);
|
||||
|
||||
$is_system = auth()->user()->isSystemAdmin() && $request->boolean('is_system');
|
||||
|
||||
$role = \App\Models\System\Role::create([
|
||||
'name' => $validated['name'],
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $is_system ? null : auth()->user()->company_id,
|
||||
'is_system' => $is_system,
|
||||
]);
|
||||
|
||||
if (!empty($validated['permissions'])) {
|
||||
$perms = $validated['permissions'];
|
||||
// 如果不是系統角色,排除主選單的系統權限
|
||||
if (!$is_system) {
|
||||
$perms = array_diff($perms, ['menu.basic-settings', 'menu.permissions']);
|
||||
}
|
||||
$role->syncPermissions($perms);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Role created successfully.'));
|
||||
}
|
||||
|
||||
// AI智能預測
|
||||
public function aiPrediction()
|
||||
/**
|
||||
* Update the specified role in storage.
|
||||
*/
|
||||
public function updateRole(Request $request, $id)
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => 'AI智能預測',
|
||||
'description' => 'AI功能權限設定',
|
||||
$role = \App\Models\System\Role::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:roles,name,' . $id,
|
||||
'permissions' => 'nullable|array',
|
||||
'permissions.*' => 'string|exists:permissions,name',
|
||||
]);
|
||||
|
||||
if ($role->name === 'super-admin') {
|
||||
return redirect()->back()->with('error', __('The Super Admin role is immutable.'));
|
||||
}
|
||||
|
||||
if (!auth()->user()->isSystemAdmin() && $role->is_system) {
|
||||
return redirect()->back()->with('error', __('System roles cannot be modified by tenant administrators.'));
|
||||
}
|
||||
|
||||
$is_system = auth()->user()->isSystemAdmin() ? $request->boolean('is_system') : $role->is_system;
|
||||
|
||||
$role->update([
|
||||
'name' => $validated['name'],
|
||||
'is_system' => $is_system,
|
||||
'company_id' => $is_system ? null : $role->company_id,
|
||||
]);
|
||||
|
||||
$perms = $validated['permissions'] ?? [];
|
||||
// 如果不是系統角色,排除主選單的系統權限
|
||||
if (!$is_system) {
|
||||
$perms = array_diff($perms, ['menu.basic-settings', 'menu.permissions']);
|
||||
}
|
||||
$role->syncPermissions($perms);
|
||||
|
||||
return redirect()->back()->with('success', __('Role updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified role from storage.
|
||||
*/
|
||||
public function destroyRole($id)
|
||||
{
|
||||
$role = \App\Models\System\Role::findOrFail($id);
|
||||
|
||||
if ($role->name === 'super-admin') {
|
||||
return redirect()->back()->with('error', __('The Super Admin role cannot be deleted.'));
|
||||
}
|
||||
|
||||
if (!auth()->user()->isSystemAdmin() && $role->is_system) {
|
||||
return redirect()->back()->with('error', __('System roles cannot be deleted by tenant administrators.'));
|
||||
}
|
||||
|
||||
if ($role->users()->count() > 0) {
|
||||
return redirect()->back()->with('error', __('Cannot delete role with active users.'));
|
||||
}
|
||||
|
||||
$role->delete();
|
||||
|
||||
return redirect()->back()->with('success', __('Role deleted successfully.'));
|
||||
}
|
||||
|
||||
// 帳號管理
|
||||
public function accounts(Request $request)
|
||||
{
|
||||
$query = \App\Models\System\User::query()->with(['company', 'roles']);
|
||||
|
||||
// 租戶隔離:如果不是系統管理員,則只看自己公司的成員
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$query->where('company_id', auth()->user()->company_id);
|
||||
}
|
||||
|
||||
// 搜尋
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('username', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 公司篩選 (僅限 super-admin)
|
||||
if (auth()->user()->isSystemAdmin() && $request->filled('company_id')) {
|
||||
$query->where('company_id', $request->company_id);
|
||||
}
|
||||
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$users = $query->latest()->paginate($per_page)->withQueryString();
|
||||
$companies = auth()->user()->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
|
||||
$roles_query = \App\Models\System\Role::where('name', '!=', 'super-admin');
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$roles_query->where(function($q) {
|
||||
$q->where('company_id', auth()->user()->company_id)
|
||||
->orWhereNull('company_id');
|
||||
});
|
||||
}
|
||||
$roles = $roles_query->get();
|
||||
|
||||
// 根據路由決定標題
|
||||
$title = request()->routeIs('*.sub-accounts') ? __('Sub Account Management') : __('Account Management');
|
||||
|
||||
return view('admin.data-config.accounts', compact('users', 'companies', 'roles', 'title'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created account in storage.
|
||||
*/
|
||||
public function storeAccount(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'username' => 'required|string|max:255|unique:users,username',
|
||||
'email' => 'nullable|email|max:255|unique:users,email',
|
||||
'password' => 'required|string|min:8',
|
||||
'role' => 'required|string',
|
||||
'status' => 'required|boolean',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
$user = \App\Models\System\User::create([
|
||||
'name' => $validated['name'],
|
||||
'username' => $validated['username'],
|
||||
'email' => $validated['email'],
|
||||
'password' => \Illuminate\Support\Facades\Hash::make($validated['password']),
|
||||
'status' => $validated['status'],
|
||||
'company_id' => auth()->user()->isSystemAdmin() ? $validated['company_id'] : auth()->user()->company_id,
|
||||
'phone' => $validated['phone'],
|
||||
]);
|
||||
|
||||
$user->assignRole($validated['role']);
|
||||
|
||||
return redirect()->back()->with('success', __('Account created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified account in storage.
|
||||
*/
|
||||
public function updateAccount(Request $request, $id)
|
||||
{
|
||||
$user = \App\Models\System\User::findOrFail($id);
|
||||
|
||||
if ($user->hasRole('super-admin')) {
|
||||
return redirect()->back()->with('error', __('System super admin accounts cannot be modified via this interface.'));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'username' => 'required|string|max:255|unique:users,username,' . $id,
|
||||
'email' => 'nullable|email|max:255|unique:users,email,' . $id,
|
||||
'password' => 'nullable|string|min:8',
|
||||
'role' => 'required|string',
|
||||
'status' => 'required|boolean',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
$updateData = [
|
||||
'name' => $validated['name'],
|
||||
'username' => $validated['username'],
|
||||
'email' => $validated['email'],
|
||||
'status' => $validated['status'],
|
||||
'phone' => $validated['phone'],
|
||||
];
|
||||
|
||||
if (auth()->user()->isSystemAdmin()) {
|
||||
// 防止超級管理員不小心把自己綁定到租客公司或降級
|
||||
if ($user->id === auth()->id()) {
|
||||
$updateData['company_id'] = null;
|
||||
$validated['role'] = 'super-admin';
|
||||
} else {
|
||||
$updateData['company_id'] = $validated['company_id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($validated['password'])) {
|
||||
$updateData['password'] = \Illuminate\Support\Facades\Hash::make($validated['password']);
|
||||
}
|
||||
|
||||
$user->update($updateData);
|
||||
|
||||
// 如果是編輯自己且原本是超級管理員,強制保留 super-admin 角色
|
||||
if ($user->id === auth()->id() && auth()->user()->isSystemAdmin()) {
|
||||
$user->syncRoles(['super-admin']);
|
||||
} else {
|
||||
$user->syncRoles([$validated['role']]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Account updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified account from storage.
|
||||
*/
|
||||
public function destroyAccount($id)
|
||||
{
|
||||
$user = \App\Models\System\User::findOrFail($id);
|
||||
|
||||
if ($user->hasRole('super-admin')) {
|
||||
return redirect()->back()->with('error', __('System super admin accounts cannot be deleted.'));
|
||||
}
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
return redirect()->back()->with('error', __('You cannot delete your own account.'));
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
|
||||
return redirect()->back()->with('success', __('Account deleted successfully.'));
|
||||
}
|
||||
}
|
||||
|
||||
54
app/Http/Controllers/Admin/PointRuleController.php
Normal file
54
app/Http/Controllers/Admin/PointRuleController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Member\PointRule;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PointRuleController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$rules = PointRule::all();
|
||||
return view('admin.point-rules.index', compact('rules'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'trigger' => 'required|in:purchase,deposit,register,birthday,referral',
|
||||
'points_per_unit' => 'required|integer|min:1',
|
||||
'unit_amount' => 'required|numeric|min:0',
|
||||
'validity_days' => 'required|integer|min:1',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
PointRule::create($validated);
|
||||
|
||||
return redirect()->route('admin.point-rules.index')->with('success', '點數規則已建立');
|
||||
}
|
||||
|
||||
public function update(Request $request, PointRule $pointRule)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'trigger' => 'required|in:purchase,deposit,register,birthday,referral',
|
||||
'points_per_unit' => 'required|integer|min:1',
|
||||
'unit_amount' => 'required|numeric|min:0',
|
||||
'validity_days' => 'required|integer|min:1',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$pointRule->update($validated);
|
||||
|
||||
return redirect()->route('admin.point-rules.index')->with('success', '點數規則已更新');
|
||||
}
|
||||
|
||||
public function destroy(PointRule $pointRule)
|
||||
{
|
||||
$pointRule->delete();
|
||||
return redirect()->route('admin.point-rules.index')->with('success', '點數規則已刪除');
|
||||
}
|
||||
}
|
||||
260
app/Http/Controllers/Api/MemberController.php
Normal file
260
app/Http/Controllers/Api/MemberController.php
Normal file
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Member\Member;
|
||||
use App\Models\Member\SocialAccount;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class MemberController extends Controller
|
||||
{
|
||||
/**
|
||||
* 會員註冊
|
||||
*/
|
||||
public function register(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['nullable', 'email', 'unique:members,email'],
|
||||
'phone' => ['nullable', 'string', 'unique:members,phone'],
|
||||
'password' => ['required', Password::min(6)],
|
||||
'birthday' => ['nullable', 'date'],
|
||||
'gender' => ['nullable', 'in:male,female,other'],
|
||||
], [
|
||||
'name.required' => '請輸入姓名',
|
||||
'email.unique' => '此 Email 已被註冊',
|
||||
'phone.unique' => '此手機號碼已被註冊',
|
||||
'password.required' => '請輸入密碼',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '驗證失敗',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 必須提供 email 或 phone 其中之一
|
||||
if (empty($request->email) && empty($request->phone)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '請提供 Email 或手機號碼',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$member = Member::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'phone' => $request->phone,
|
||||
'password' => $request->password,
|
||||
'birthday' => $request->birthday,
|
||||
'gender' => $request->gender,
|
||||
]);
|
||||
|
||||
$token = $member->createToken('member-token')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '註冊成功',
|
||||
'data' => [
|
||||
'member' => $member,
|
||||
'token' => $token,
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 會員登入(Email/Phone + Password)
|
||||
*/
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'account' => ['required', 'string'],
|
||||
'password' => ['required', 'string'],
|
||||
], [
|
||||
'account.required' => '請輸入帳號',
|
||||
'password.required' => '請輸入密碼',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '驗證失敗',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 嘗試以 email 或 phone 查詢
|
||||
$member = Member::where('email', $request->account)
|
||||
->orWhere('phone', $request->account)
|
||||
->first();
|
||||
|
||||
if (!$member || !Hash::check($request->password, $member->password)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '帳號或密碼錯誤',
|
||||
], 401);
|
||||
}
|
||||
|
||||
if (!$member->is_active) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '帳號已被停用',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$token = $member->createToken('member-token')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '登入成功',
|
||||
'data' => [
|
||||
'member' => $member,
|
||||
'token' => $token,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 社群登入
|
||||
*/
|
||||
public function socialLogin(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'provider' => ['required', 'in:line,google,facebook'],
|
||||
'provider_id' => ['required', 'string'],
|
||||
'access_token' => ['nullable', 'string'],
|
||||
'name' => ['nullable', 'string'],
|
||||
'email' => ['nullable', 'email'],
|
||||
'avatar' => ['nullable', 'string'],
|
||||
], [
|
||||
'provider.required' => '請指定登入平台',
|
||||
'provider.in' => '不支援的登入平台',
|
||||
'provider_id.required' => '缺少社群用戶 ID',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '驗證失敗',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 查詢是否已綁定
|
||||
$socialAccount = SocialAccount::where('provider', $request->provider)
|
||||
->where('provider_id', $request->provider_id)
|
||||
->first();
|
||||
|
||||
if ($socialAccount) {
|
||||
// 已綁定,直接登入
|
||||
$member = $socialAccount->member;
|
||||
|
||||
// 更新 token
|
||||
$socialAccount->update([
|
||||
'access_token' => $request->access_token,
|
||||
]);
|
||||
} else {
|
||||
// 未綁定,建立新會員
|
||||
$member = Member::create([
|
||||
'name' => $request->name ?? '會員',
|
||||
'email' => $request->email,
|
||||
'avatar' => $request->avatar,
|
||||
'email_verified_at' => $request->email ? now() : null, // 社群登入自動驗證
|
||||
]);
|
||||
|
||||
// 綁定社群帳號
|
||||
$member->socialAccounts()->create([
|
||||
'provider' => $request->provider,
|
||||
'provider_id' => $request->provider_id,
|
||||
'access_token' => $request->access_token,
|
||||
'profile_data' => $request->only(['name', 'email', 'avatar']),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$member->is_active) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '帳號已被停用',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$token = $member->createToken('member-token')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '登入成功',
|
||||
'data' => [
|
||||
'member' => $member,
|
||||
'token' => $token,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得個人資料
|
||||
*/
|
||||
public function profile(Request $request): JsonResponse
|
||||
{
|
||||
$member = $request->user();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'member' => $member->load('socialAccounts'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新個人資料
|
||||
*/
|
||||
public function updateProfile(Request $request): JsonResponse
|
||||
{
|
||||
$member = $request->user();
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'birthday' => ['nullable', 'date'],
|
||||
'gender' => ['nullable', 'in:male,female,other'],
|
||||
'avatar' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '驗證失敗',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$member->update($request->only(['name', 'birthday', 'gender', 'avatar']));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '更新成功',
|
||||
'data' => [
|
||||
'member' => $member,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '登出成功',
|
||||
]);
|
||||
}
|
||||
}
|
||||
11
app/Http/Controllers/Api/V1/ApiController.php
Normal file
11
app/Http/Controllers/Api/V1/ApiController.php
Normal 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;
|
||||
}
|
||||
123
app/Http/Controllers/Api/V1/App/MachineController.php
Normal file
123
app/Http/Controllers/Api/V1/App/MachineController.php
Normal 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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
66
app/Http/Controllers/Api/V1/App/TransactionController.php
Normal file
66
app/Http/Controllers/Api/V1/App/TransactionController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
39
app/Http/Controllers/Api/V1/MachineController.php
Normal file
39
app/Http/Controllers/Api/V1/MachineController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
11
app/Http/Controllers/Api/V1/MemberController.php
Normal file
11
app/Http/Controllers/Api/V1/MemberController.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MemberController extends Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\System\User;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
25
app/Http/Controllers/MemberController.php
Normal file
25
app/Http/Controllers/MemberController.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Member\Member;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class MemberController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the members.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$members = Member::query()
|
||||
->latest()
|
||||
->paginate(10);
|
||||
|
||||
return view('admin.members.index', [
|
||||
'members' => $members,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProfileController extends Controller
|
||||
@@ -16,8 +17,11 @@ class ProfileController extends Controller
|
||||
*/
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return view('profile.edit', [
|
||||
'user' => $request->user(),
|
||||
// 只取最新 10 筆登入紀錄
|
||||
'user' => $user->load(['loginLogs' => fn($q) => $q->latest('login_at')->limit(10)]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -26,35 +30,50 @@ class ProfileController extends Controller
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
$user = $request->user();
|
||||
$user->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
if ($user->isDirty('email')) {
|
||||
$user->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
$user->save();
|
||||
|
||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's account.
|
||||
* Update the user's avatar via AJAX.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
public function updateAvatar(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$request->validateWithBag('userDeletion', [
|
||||
'password' => ['required', 'current_password'],
|
||||
$request->validate([
|
||||
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif', 'max:1024'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
if ($request->hasFile('avatar')) {
|
||||
// Delete old avatar if exists
|
||||
if ($user->avatar) {
|
||||
Storage::disk('public')->delete($user->avatar);
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
$path = $request->file('avatar')->store('avatars', 'public');
|
||||
$user->avatar = $path;
|
||||
$user->save();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'avatar_url' => $user->avatar_url,
|
||||
'message' => __('Avatar updated successfully.'),
|
||||
]);
|
||||
}
|
||||
|
||||
return Redirect::to('/');
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('No file uploaded.'),
|
||||
], 400);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
33
app/Http/Controllers/SocialLoginTestController.php
Normal file
33
app/Http/Controllers/SocialLoginTestController.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class SocialLoginTestController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('test.social-login');
|
||||
}
|
||||
|
||||
public function lineCallback(Request $request)
|
||||
{
|
||||
// 這裡可以實作後端換發 Token 的邏輯
|
||||
// 為了測試方便,我們先直接顯示回傳的 code 與 state
|
||||
// 或者嘗試交換 Token 並取得 User Profile
|
||||
|
||||
$code = $request->input('code');
|
||||
$state = $request->input('state');
|
||||
$error = $request->input('error');
|
||||
|
||||
return view('test.social-login', [
|
||||
'line_data' => [
|
||||
'code' => $code,
|
||||
'state' => $state,
|
||||
'error' => $error
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
25
app/Http/Controllers/System/LanguageController.php
Normal file
25
app/Http/Controllers/System/LanguageController.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\System;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
class LanguageController extends Controller
|
||||
{
|
||||
/**
|
||||
* Switch application language.
|
||||
*
|
||||
* @param string $locale
|
||||
* @return \Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
public function switch($locale)
|
||||
{
|
||||
if (in_array($locale, ['en', 'zh_TW', 'ja'])) {
|
||||
Session::put('locale', $locale);
|
||||
}
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ class Kernel extends HttpKernel
|
||||
\App\Http\Middleware\EncryptCookies::class,
|
||||
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||
\Illuminate\Session\Middleware\StartSession::class,
|
||||
\App\Http\Middleware\SetLocale::class,
|
||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
@@ -64,5 +65,10 @@ class Kernel extends HttpKernel
|
||||
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
||||
'tenant.access' => \App\Http\Middleware\EnsureTenantAccess::class,
|
||||
'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,
|
||||
];
|
||||
}
|
||||
|
||||
37
app/Http/Middleware/EnsureTenantAccess.php
Normal file
37
app/Http/Middleware/EnsureTenantAccess.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureTenantAccess
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
// 如果是租戶帳號,檢查公司狀態
|
||||
if ($user && $user->isTenant()) {
|
||||
$company = $user->company;
|
||||
|
||||
if (!$company || $company->status === 0) {
|
||||
auth()->logout();
|
||||
return redirect()->route('login')->with('error', __('Your account is associated with a deactivated company.'));
|
||||
}
|
||||
|
||||
if ($company->valid_until && $company->valid_until->isPast()) {
|
||||
auth()->logout();
|
||||
return redirect()->route('login')->with('error', __('Your company contract has expired.'));
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
39
app/Http/Middleware/IotAuth.php
Normal file
39
app/Http/Middleware/IotAuth.php
Normal 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);
|
||||
}
|
||||
}
|
||||
27
app/Http/Middleware/SetLocale.php
Normal file
27
app/Http/Middleware/SetLocale.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetLocale
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (session()->has('locale')) {
|
||||
$locale = session()->get('locale');
|
||||
if (in_array($locale, ['zh_TW', 'en', 'ja'])) {
|
||||
app()->setLocale($locale);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class TrustProxies extends Middleware
|
||||
*
|
||||
* @var array<int, string>|string|null
|
||||
*/
|
||||
protected $proxies;
|
||||
protected $proxies = '*';
|
||||
|
||||
/**
|
||||
* The headers that should be used to detect proxies.
|
||||
|
||||
@@ -27,11 +27,22 @@ class LoginRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'username' => ['required', 'string'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得驗證規則的自訂錯誤訊息
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'username.required' => '請輸入帳號',
|
||||
'password.required' => '請輸入密碼',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
@@ -41,11 +52,11 @@ class LoginRequest extends FormRequest
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||
if (! Auth::attempt($this->only('username', 'password'), $this->boolean('remember'))) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.failed'),
|
||||
'username' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -68,7 +79,7 @@ class LoginRequest extends FormRequest
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.throttle', [
|
||||
'username' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
@@ -80,6 +91,6 @@ class LoginRequest extends FormRequest
|
||||
*/
|
||||
public function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||
return Str::transliterate(Str::lower($this->string('username')).'|'.$this->ip());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\System\User;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -19,7 +19,7 @@ class ProfileUpdateRequest extends FormRequest
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)],
|
||||
'phone' => ['nullable', 'string', 'max:20'],
|
||||
'avatar' => ['nullable', 'string', 'max:255'],
|
||||
'avatar' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif', 'max:2048'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
57
app/Jobs/Machine/ProcessCoinInventory.php
Normal file
57
app/Jobs/Machine/ProcessCoinInventory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/Jobs/Machine/ProcessHeartbeat.php
Normal file
41
app/Jobs/Machine/ProcessHeartbeat.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
app/Jobs/Machine/ProcessMachineLog.php
Normal file
46
app/Jobs/Machine/ProcessMachineLog.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
51
app/Jobs/Machine/ProcessTimerStatus.php
Normal file
51
app/Jobs/Machine/ProcessTimerStatus.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/Jobs/Transaction/ProcessDispenseRecord.php
Normal file
39
app/Jobs/Transaction/ProcessDispenseRecord.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
app/Jobs/Transaction/ProcessInvoice.php
Normal file
42
app/Jobs/Transaction/ProcessInvoice.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/Jobs/Transaction/ProcessTransaction.php
Normal file
39
app/Jobs/Transaction/ProcessTransaction.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
app/Listeners/LogSuccessfulLogin.php
Normal file
70
app/Listeners/LogSuccessfulLogin.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Models\System\UserLoginLog;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LogSuccessfulLogin
|
||||
{
|
||||
/**
|
||||
* The request instance.
|
||||
*
|
||||
* @var \Illuminate\Http\Request
|
||||
*/
|
||||
protected $request;
|
||||
|
||||
/**
|
||||
* Create the event listener.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Request $request)
|
||||
{
|
||||
$this->request = $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the event.
|
||||
*
|
||||
* @param \Illuminate\Auth\Events\Login $event
|
||||
* @return void
|
||||
*/
|
||||
public function handle(Login $event)
|
||||
{
|
||||
$ip = $this->request->ip();
|
||||
$userAgent = $this->request->userAgent();
|
||||
|
||||
// 防重覆機制 (Debouncing): 10 秒內同使用者、同 IP 的記錄視為重複
|
||||
$recentLog = UserLoginLog::where('user_id', $event->user->id)
|
||||
->where('ip_address', $ip)
|
||||
->where('login_at', '>=', now()->subSeconds(10))
|
||||
->first();
|
||||
|
||||
if ($recentLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
$agent = new \Jenssegers\Agent\Agent();
|
||||
$agent->setUserAgent($userAgent);
|
||||
|
||||
$deviceType = 'desktop';
|
||||
if ($agent->isTablet()) {
|
||||
$deviceType = 'tablet';
|
||||
} elseif ($agent->isMobile()) {
|
||||
$deviceType = 'mobile';
|
||||
}
|
||||
|
||||
UserLoginLog::create([
|
||||
'user_id' => $event->user->id,
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => $userAgent,
|
||||
'device_type' => $deviceType,
|
||||
'browser' => $agent->browser(),
|
||||
'platform' => $agent->platform(),
|
||||
'login_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Machine extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'location',
|
||||
'status',
|
||||
'temperature',
|
||||
'firmware_version',
|
||||
'last_heartbeat_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_heartbeat_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function logs()
|
||||
{
|
||||
return $this->hasMany(MachineLog::class);
|
||||
}
|
||||
|
||||
}
|
||||
23
app/Models/Machine/CoinInventory.php
Normal file
23
app/Models/Machine/CoinInventory.php
Normal 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);
|
||||
}
|
||||
}
|
||||
102
app/Models/Machine/Machine.php
Normal file
102
app/Models/Machine/Machine.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
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',
|
||||
'card_reader_seconds',
|
||||
'card_reader_checkout_time_1',
|
||||
'card_reader_checkout_time_2',
|
||||
'heating_start_time',
|
||||
'heating_end_time',
|
||||
'payment_buffer_seconds',
|
||||
'card_reader_no',
|
||||
'key_no',
|
||||
'invoice_status',
|
||||
'welcome_gift_enabled',
|
||||
'is_spring_slot_1_10',
|
||||
'is_spring_slot_11_20',
|
||||
'is_spring_slot_21_30',
|
||||
'is_spring_slot_31_40',
|
||||
'is_spring_slot_41_50',
|
||||
'is_spring_slot_51_60',
|
||||
'member_system_enabled',
|
||||
'payment_config_id',
|
||||
'machine_model_id',
|
||||
'images',
|
||||
'creator_id',
|
||||
'updater_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_heartbeat_at' => 'datetime',
|
||||
'welcome_gift_enabled' => 'boolean',
|
||||
'is_spring_slot_1_10' => 'boolean',
|
||||
'is_spring_slot_11_20' => 'boolean',
|
||||
'is_spring_slot_21_30' => 'boolean',
|
||||
'is_spring_slot_31_40' => 'boolean',
|
||||
'is_spring_slot_41_50' => 'boolean',
|
||||
'is_spring_slot_51_60' => 'boolean',
|
||||
'member_system_enabled' => 'boolean',
|
||||
'images' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get machine images absolute URLs
|
||||
*/
|
||||
public function getImageUrlsAttribute(): array
|
||||
{
|
||||
if (empty($this->images)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(fn($path) => \Illuminate\Support\Facades\Storage::disk('public')->url($path), $this->images);
|
||||
}
|
||||
|
||||
public function logs()
|
||||
{
|
||||
return $this->hasMany(MachineLog::class);
|
||||
}
|
||||
|
||||
public function machineModel()
|
||||
{
|
||||
return $this->belongsTo(MachineModel::class);
|
||||
}
|
||||
|
||||
public function paymentConfig()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\PaymentConfig::class);
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\User::class, 'creator_id');
|
||||
}
|
||||
|
||||
public function updater()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\User::class, 'updater_id');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class MachineLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'machine_id',
|
||||
'level',
|
||||
35
app/Models/Machine/MachineModel.php
Normal file
35
app/Models/Machine/MachineModel.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class MachineModel extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'company_id',
|
||||
'creator_id',
|
||||
'updater_id',
|
||||
];
|
||||
|
||||
public function machines()
|
||||
{
|
||||
return $this->hasMany(Machine::class);
|
||||
}
|
||||
|
||||
public function company()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\Company::class);
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\User::class, 'creator_id');
|
||||
}
|
||||
|
||||
public function updater()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\User::class, 'updater_id');
|
||||
}
|
||||
}
|
||||
39
app/Models/Machine/MachineSlot.php
Normal file
39
app/Models/Machine/MachineSlot.php
Normal 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);
|
||||
}
|
||||
}
|
||||
31
app/Models/Machine/RemoteCommand.php
Normal file
31
app/Models/Machine/RemoteCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
28
app/Models/Machine/TimerStatus.php
Normal file
28
app/Models/Machine/TimerStatus.php
Normal 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);
|
||||
}
|
||||
}
|
||||
60
app/Models/Member/DepositBonusRule.php
Normal file
60
app/Models/Member/DepositBonusRule.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DepositBonusRule extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'min_amount',
|
||||
'bonus_type',
|
||||
'bonus_value',
|
||||
'is_active',
|
||||
'start_at',
|
||||
'end_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'min_amount' => 'decimal:2',
|
||||
'bonus_value' => 'decimal:2',
|
||||
'is_active' => 'boolean',
|
||||
'start_at' => 'datetime',
|
||||
'end_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 取得目前有效的規則
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('start_at')->orWhere('start_at', '<=', now());
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('end_at')->orWhere('end_at', '>=', now());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算回饋金額
|
||||
*/
|
||||
public function calculateBonus(float $depositAmount): float
|
||||
{
|
||||
if ($depositAmount < $this->min_amount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($this->bonus_type === 'fixed') {
|
||||
return $this->bonus_value;
|
||||
}
|
||||
|
||||
// percentage
|
||||
return $depositAmount * ($this->bonus_value / 100);
|
||||
}
|
||||
}
|
||||
53
app/Models/Member/GiftDefinition.php
Normal file
53
app/Models/Member/GiftDefinition.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class GiftDefinition extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'type',
|
||||
'value',
|
||||
'tier_id',
|
||||
'trigger',
|
||||
'validity_days',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'value' => 'decimal:2',
|
||||
'validity_days' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* 適用等級
|
||||
*/
|
||||
public function tier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MembershipTier::class, 'tier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 發放紀錄
|
||||
*/
|
||||
public function memberGifts(): HasMany
|
||||
{
|
||||
return $this->hasMany(MemberGift::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 有效禮品
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
}
|
||||
153
app/Models/Member/Member.php
Normal file
153
app/Models/Member/Member.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Member extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* 資料表名稱
|
||||
*/
|
||||
protected $table = 'members';
|
||||
|
||||
/**
|
||||
* 可批量賦值的屬性
|
||||
*/
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'email',
|
||||
'phone',
|
||||
'password',
|
||||
'birthday',
|
||||
'gender',
|
||||
'avatar',
|
||||
'is_active',
|
||||
'email_verified_at',
|
||||
'company_id',
|
||||
'barcode',
|
||||
'points',
|
||||
'wallet_balance',
|
||||
];
|
||||
|
||||
/**
|
||||
* 隱藏的屬性
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* 屬性轉換
|
||||
*/
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'birthday' => 'date',
|
||||
'is_active' => 'boolean',
|
||||
'password' => 'hashed',
|
||||
'points' => 'integer',
|
||||
'wallet_balance' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* 建立時自動產生 UUID
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->uuid)) {
|
||||
$model->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯:社群帳號
|
||||
*/
|
||||
public function socialAccounts()
|
||||
{
|
||||
return $this->hasMany(SocialAccount::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯:錢包
|
||||
*/
|
||||
public function wallet()
|
||||
{
|
||||
return $this->hasOne(MemberWallet::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯:點數帳戶
|
||||
*/
|
||||
public function points()
|
||||
{
|
||||
return $this->hasOne(MemberPoint::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯:會員資格紀錄
|
||||
*/
|
||||
public function memberships()
|
||||
{
|
||||
return $this->hasMany(MemberMembership::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯:禮品紀錄
|
||||
*/
|
||||
public function gifts()
|
||||
{
|
||||
return $this->hasMany(MemberGift::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得目前有效的會員資格
|
||||
*/
|
||||
public function activeMembership()
|
||||
{
|
||||
return $this->hasOne(MemberMembership::class)->active()->latest('starts_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查是否已綁定指定社群
|
||||
*/
|
||||
public function hasSocialAccount(string $provider): bool
|
||||
{
|
||||
return $this->socialAccounts()->where('provider', $provider)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得或建立錢包
|
||||
*/
|
||||
public function getOrCreateWallet(): MemberWallet
|
||||
{
|
||||
return $this->wallet ?? $this->wallet()->create([
|
||||
'balance' => 0,
|
||||
'bonus_balance' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得或建立點數帳戶
|
||||
*/
|
||||
public function getOrCreatePoints(): MemberPoint
|
||||
{
|
||||
return $this->points ?? $this->points()->create([
|
||||
'available_points' => 0,
|
||||
'pending_points' => 0,
|
||||
'expired_points' => 0,
|
||||
'used_points' => 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
56
app/Models/Member/MemberGift.php
Normal file
56
app/Models/Member/MemberGift.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MemberGift extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'gift_definition_id',
|
||||
'status',
|
||||
'claimed_at',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'claimed_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所屬會員
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 禮品定義
|
||||
*/
|
||||
public function giftDefinition(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GiftDefinition::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 待領取的禮品
|
||||
*/
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', 'pending')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
}
|
||||
}
|
||||
65
app/Models/Member/MemberMembership.php
Normal file
65
app/Models/Member/MemberMembership.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MemberMembership extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'tier_id',
|
||||
'starts_at',
|
||||
'expires_at',
|
||||
'payment_id',
|
||||
'auto_renew',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'starts_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'auto_renew' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所屬會員
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 會員等級
|
||||
*/
|
||||
public function tier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MembershipTier::class, 'tier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有效
|
||||
*/
|
||||
public function getIsActiveAttribute(): bool
|
||||
{
|
||||
return $this->status === 'active'
|
||||
&& (!$this->expires_at || $this->expires_at->isFuture());
|
||||
}
|
||||
|
||||
/**
|
||||
* 有效會員資格
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', 'active')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
}
|
||||
}
|
||||
44
app/Models/Member/MemberPoint.php
Normal file
44
app/Models/Member/MemberPoint.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class MemberPoint extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'available_points',
|
||||
'pending_points',
|
||||
'expired_points',
|
||||
'used_points',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'available_points' => 'integer',
|
||||
'pending_points' => 'integer',
|
||||
'expired_points' => 'integer',
|
||||
'used_points' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所屬會員
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 點數異動紀錄
|
||||
*/
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(PointTransaction::class, 'member_id', 'member_id');
|
||||
}
|
||||
}
|
||||
48
app/Models/Member/MemberWallet.php
Normal file
48
app/Models/Member/MemberWallet.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class MemberWallet extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'balance',
|
||||
'bonus_balance',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'balance' => 'decimal:2',
|
||||
'bonus_balance' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所屬會員
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 交易紀錄
|
||||
*/
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(WalletTransaction::class, 'member_id', 'member_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 總餘額 (儲值 + 回饋)
|
||||
*/
|
||||
public function getTotalBalanceAttribute(): float
|
||||
{
|
||||
return $this->balance + $this->bonus_balance;
|
||||
}
|
||||
}
|
||||
62
app/Models/Member/MembershipTier.php
Normal file
62
app/Models/Member/MembershipTier.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class MembershipTier extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'annual_fee',
|
||||
'discount_rate',
|
||||
'point_multiplier',
|
||||
'description',
|
||||
'is_default',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'annual_fee' => 'decimal:2',
|
||||
'discount_rate' => 'decimal:2',
|
||||
'point_multiplier' => 'decimal:2',
|
||||
'is_default' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 此等級的會員紀錄
|
||||
*/
|
||||
public function memberships(): HasMany
|
||||
{
|
||||
return $this->hasMany(MemberMembership::class, 'tier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 此等級的禮品定義
|
||||
*/
|
||||
public function giftDefinitions(): HasMany
|
||||
{
|
||||
return $this->hasMany(GiftDefinition::class, 'tier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得預設等級
|
||||
*/
|
||||
public static function getDefault(): ?self
|
||||
{
|
||||
return static::where('is_default', true)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否為免費等級
|
||||
*/
|
||||
public function getIsFreeAttribute(): bool
|
||||
{
|
||||
return $this->annual_fee <= 0;
|
||||
}
|
||||
}
|
||||
47
app/Models/Member/PointRule.php
Normal file
47
app/Models/Member/PointRule.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PointRule extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'trigger',
|
||||
'points_per_unit',
|
||||
'unit_amount',
|
||||
'validity_days',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'points_per_unit' => 'integer',
|
||||
'unit_amount' => 'decimal:2',
|
||||
'validity_days' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* 取得有效規則
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據金額計算可獲得點數
|
||||
*/
|
||||
public function calculatePoints(float $amount): int
|
||||
{
|
||||
if ($this->unit_amount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) floor($amount / $this->unit_amount) * $this->points_per_unit;
|
||||
}
|
||||
}
|
||||
48
app/Models/Member/PointTransaction.php
Normal file
48
app/Models/Member/PointTransaction.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PointTransaction extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'type',
|
||||
'points',
|
||||
'balance_after',
|
||||
'description',
|
||||
'expires_at',
|
||||
'reference_type',
|
||||
'reference_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'points' => 'integer',
|
||||
'balance_after' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所屬會員
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已過期
|
||||
*/
|
||||
public function getIsExpiredAttribute(): bool
|
||||
{
|
||||
return $this->expires_at && $this->expires_at->isPast();
|
||||
}
|
||||
}
|
||||
53
app/Models/Member/SocialAccount.php
Normal file
53
app/Models/Member/SocialAccount.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SocialAccount extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* 資料表名稱
|
||||
*/
|
||||
protected $table = 'social_accounts';
|
||||
|
||||
/**
|
||||
* 可批量賦值的屬性
|
||||
*/
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'provider',
|
||||
'provider_id',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'profile_data',
|
||||
'token_expires_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* 屬性轉換
|
||||
*/
|
||||
protected $casts = [
|
||||
'profile_data' => 'array',
|
||||
'token_expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 隱藏的屬性
|
||||
*/
|
||||
protected $hidden = [
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* 關聯:會員
|
||||
*/
|
||||
public function member()
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
}
|
||||
38
app/Models/Member/WalletTransaction.php
Normal file
38
app/Models/Member/WalletTransaction.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Member;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WalletTransaction extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'type',
|
||||
'amount',
|
||||
'balance_after',
|
||||
'description',
|
||||
'reference_type',
|
||||
'reference_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'decimal:2',
|
||||
'balance_after' => 'decimal:2',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所屬會員
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
}
|
||||
40
app/Models/Product/Product.php
Normal file
40
app/Models/Product/Product.php
Normal 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');
|
||||
}
|
||||
}
|
||||
24
app/Models/Product/ProductCategory.php
Normal file
24
app/Models/Product/ProductCategory.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
namespace App\Models\System;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
48
app/Models/System/Company.php
Normal file
48
app/Models/System/Company.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\System;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class Company extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'code',
|
||||
'tax_id',
|
||||
'contact_name',
|
||||
'contact_phone',
|
||||
'contact_email',
|
||||
'status',
|
||||
'valid_until',
|
||||
'note',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'valid_until' => 'date',
|
||||
'status' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the users for the company.
|
||||
*/
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the machines for the company.
|
||||
*/
|
||||
public function machines(): HasMany
|
||||
{
|
||||
return $this->hasMany(Machine::class);
|
||||
}
|
||||
}
|
||||
40
app/Models/System/PaymentConfig.php
Normal file
40
app/Models/System/PaymentConfig.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\System;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PaymentConfig extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'name',
|
||||
'settings',
|
||||
'creator_id',
|
||||
'updater_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'settings' => 'array',
|
||||
];
|
||||
|
||||
public function machines()
|
||||
{
|
||||
return $this->hasMany(\App\Models\Machine\Machine::class);
|
||||
}
|
||||
|
||||
public function company()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\Company::class);
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'creator_id');
|
||||
}
|
||||
|
||||
public function updater()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updater_id');
|
||||
}
|
||||
}
|
||||
34
app/Models/System/Role.php
Normal file
34
app/Models/System/Role.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\System;
|
||||
|
||||
use Spatie\Permission\Models\Role as SpatieRole;
|
||||
|
||||
class Role extends SpatieRole
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'guard_name',
|
||||
'company_id',
|
||||
'is_system',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the company that owns the role.
|
||||
*/
|
||||
public function company()
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include roles for a specific company or system roles.
|
||||
*/
|
||||
public function scopeForCompany($query, $company_id)
|
||||
{
|
||||
return $query->where(function($q) use ($company_id) {
|
||||
$q->where('company_id', $company_id)
|
||||
->orWhereNull('company_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
18
app/Models/System/Translation.php
Normal file
18
app/Models/System/Translation.php
Normal 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',
|
||||
];
|
||||
}
|
||||
100
app/Models/System/User.php
Normal file
100
app/Models/System/User.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\System;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
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, TenantScoped, SoftDeletes;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'username',
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'phone',
|
||||
'avatar',
|
||||
'role',
|
||||
'status',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the login logs for the user.
|
||||
*/
|
||||
public function loginLogs()
|
||||
{
|
||||
return $this->hasMany(UserLoginLog::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the company that owns the user.
|
||||
*/
|
||||
public function company()
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is a system administrator.
|
||||
*/
|
||||
public function isSystemAdmin(): bool
|
||||
{
|
||||
return is_null($this->company_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user belongs to a tenant.
|
||||
*/
|
||||
public function isTenant(): bool
|
||||
{
|
||||
return !is_null($this->company_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL for the user's avatar.
|
||||
*/
|
||||
public function getAvatarUrlAttribute(): string
|
||||
{
|
||||
if ($this->avatar) {
|
||||
return \Illuminate\Support\Facades\Storage::disk('public')->url($this->avatar);
|
||||
}
|
||||
|
||||
// Return a default UI Avatar if no avatar is set
|
||||
return "https://ui-avatars.com/api/?name=" . urlencode($this->name) . "&color=7F9CF5&background=EBF4FF";
|
||||
}
|
||||
}
|
||||
30
app/Models/System/UserLoginLog.php
Normal file
30
app/Models/System/UserLoginLog.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\System;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class UserLoginLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'device_type',
|
||||
'browser',
|
||||
'platform',
|
||||
'login_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'login_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
50
app/Models/Transaction/DispenseRecord.php
Normal file
50
app/Models/Transaction/DispenseRecord.php
Normal 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);
|
||||
}
|
||||
}
|
||||
39
app/Models/Transaction/Invoice.php
Normal file
39
app/Models/Transaction/Invoice.php
Normal 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);
|
||||
}
|
||||
}
|
||||
64
app/Models/Transaction/Order.php
Normal file
64
app/Models/Transaction/Order.php
Normal 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);
|
||||
}
|
||||
}
|
||||
39
app/Models/Transaction/OrderItem.php
Normal file
39
app/Models/Transaction/OrderItem.php
Normal 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);
|
||||
}
|
||||
}
|
||||
23
app/Models/Transaction/PaymentType.php
Normal file
23
app/Models/Transaction/PaymentType.php
Normal 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',
|
||||
];
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'phone',
|
||||
'avatar',
|
||||
'role',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\LogSuccessfulLogin;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -19,6 +22,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
if (!$this->app->isLocal()) {
|
||||
\Illuminate\Support\Facades\URL::forceScheme('https');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\LogSuccessfulLogin;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
@@ -18,6 +20,9 @@ class EventServiceProvider extends ServiceProvider
|
||||
Registered::class => [
|
||||
SendEmailVerificationNotification::class,
|
||||
],
|
||||
Login::class => [
|
||||
LogSuccessfulLogin::class,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
72
app/Services/Machine/MachineService.php
Normal file
72
app/Services/Machine/MachineService.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Machine;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineLog;
|
||||
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);
|
||||
|
||||
return $machine->logs()->create([
|
||||
'level' => $data['level'] ?? 'info',
|
||||
'message' => $data['message'],
|
||||
'payload' => $data['context'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
120
app/Services/Transaction/TransactionService.php
Normal file
120
app/Services/Transaction/TransactionService.php
Normal 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(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
49
app/Traits/ApiResponse.php
Normal file
49
app/Traits/ApiResponse.php
Normal 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);
|
||||
}
|
||||
}
|
||||
51
app/Traits/TenantScoped.php
Normal file
51
app/Traits/TenantScoped.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
trait TenantScoped
|
||||
{
|
||||
/**
|
||||
* Boot the trait.
|
||||
*/
|
||||
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();
|
||||
|
||||
// 如果使用者已登入且有綁定公司,則自動注入過濾條件
|
||||
if ($user && $user->company_id) {
|
||||
$query->where((new static)->getTable() . '.company_id', $user->company_id);
|
||||
}
|
||||
});
|
||||
|
||||
// 建立資料時,自動填入當前使用者的 company_id
|
||||
static::creating(function ($model) {
|
||||
if (!$model->company_id) {
|
||||
$user = auth()->user();
|
||||
if ($user && $user->company_id) {
|
||||
$model->company_id = $user->company_id;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the company relationship.
|
||||
*/
|
||||
public function company()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\Company::class);
|
||||
}
|
||||
}
|
||||
49
artisan
49
artisan
@@ -1,53 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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.
|
||||
|
|
||||
*/
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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);
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
|
||||
16
compose.yaml
16
compose.yaml
@@ -6,12 +6,12 @@ services:
|
||||
args:
|
||||
WWWGROUP: '${WWWGROUP}'
|
||||
image: 'sail-8.5/app'
|
||||
container_name: start-cloud-laravel
|
||||
hostname: start-cloud-laravel
|
||||
container_name: star-cloud-laravel
|
||||
hostname: star-cloud-laravel
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
ports:
|
||||
- '${APP_PORT:-80}:80'
|
||||
- '${APP_PORT:-80}:8080'
|
||||
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
|
||||
environment:
|
||||
WWWUSER: '${WWWUSER}'
|
||||
@@ -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:
|
||||
@@ -29,8 +30,8 @@ services:
|
||||
|
||||
mysql:
|
||||
image: 'mysql/mysql-server:8.0'
|
||||
container_name: start-cloud-mysql
|
||||
hostname: start-cloud-mysql
|
||||
container_name: star-cloud-mysql
|
||||
hostname: star-cloud-mysql
|
||||
ports:
|
||||
- '${FORWARD_DB_PORT:-3306}:3306'
|
||||
environment:
|
||||
@@ -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'
|
||||
@@ -56,8 +58,8 @@ services:
|
||||
timeout: 5s
|
||||
redis:
|
||||
image: 'redis:alpine'
|
||||
container_name: start-cloud-redis
|
||||
hostname: start-cloud-redis
|
||||
container_name: star-cloud-redis
|
||||
hostname: star-cloud-redis
|
||||
ports:
|
||||
- '${FORWARD_REDIS_PORT:-6379}:6379'
|
||||
volumes:
|
||||
|
||||
@@ -2,24 +2,29 @@
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework"
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"laravel/framework": "^10.10",
|
||||
"laravel/sanctum": "^3.3",
|
||||
"laravel/tinker": "^2.8"
|
||||
"php": "^8.2",
|
||||
"guzzlehttp/guzzle": "^7.8",
|
||||
"jenssegers/agent": "^2.6",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"spatie/laravel-permission": "^7.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.9.1",
|
||||
"laravel/breeze": "^1.29",
|
||||
"laravel/pint": "^1.0",
|
||||
"laravel/sail": "^1.18",
|
||||
"mockery/mockery": "^1.4.4",
|
||||
"nunomaduro/collision": "^7.0",
|
||||
"phpunit/phpunit": "^10.1",
|
||||
"spatie/laravel-ignition": "^2.0"
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/breeze": "^2.0",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.24",
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^11.5.3"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
2980
composer.lock
generated
2980
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -70,7 +70,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
'timezone' => env('APP_TIMEZONE', 'Asia/Taipei'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -83,7 +83,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => 'en',
|
||||
'locale' => env('APP_LOCALE', 'zh_TW'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -62,7 +62,7 @@ return [
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => App\Models\User::class,
|
||||
'model' => App\Models\System\User::class,
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user