Compare commits

...

56 Commits

Author SHA1 Message Date
99243d4206 [STYLE] 統一全站次要資訊字體規範並更新 UI Skill 文件
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m9s
2026-03-17 17:44:09 +08:00
fc79148879 [FEAT] 實作角色權限分類、租戶角控管理與介面多語系優化
1. [FEAT] 權限劃分為「系統層級」與「客戶層級」,並在後端強制過濾跨權限分配。
2. [FEAT] 整合選單權限至主選單層級 (基本設定、權限設定),簡化角色管理 UI。
3. [STYLE] 側邊欄優化:補齊多語系翻譯,並為基本設定子選單增加視覺圖示。
4. [REFACTOR] 更新 RoleSeeder,將 tenant-admin 重新分類為客戶層級角色。
2026-03-17 16:53:28 +08:00
3ce88ed342 [FEAT] 重構機台日誌 UI 與增加多語系支援,並整合 IoT API 核心架構
- 機台日誌:對齊 Luxury UI 規範,實作整合式佈局與分頁組件。
- 多語系:完成機台日誌繁、英、日三語系翻譯與動態處理。
- UI 規範:更新 SKILL.md 定義「標準列表 Bible」。
- 後端:完善 TenantScoped 隔離邏輯,修復儀表板死循環與 User Model 缺失。
- IoT:擴展機台、會員 Model 並建立交易、商品、狀態等核心表結構。
- 基礎設施:設置台北時區與 Docker 環境變數同步。
2026-03-16 17:29:15 +08:00
1851e91c86 [REFACTOR] 簡化權限管理介面,整合權限設定至角色管理,並完成多語系支援 2026-03-16 13:47:16 +08:00
09e1d0dc48 [FIX] 修復個人檔案與導覽列頭像不顯示問題,並實作即時更新邏輯
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 47s
2026-03-13 17:50:31 +08:00
42f96d54c3 [FIX] 修正部署流程:錨定 rsync 排除路徑,確保 resources/views/vendor 資料夾能正確同步
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 42s
2026-03-13 17:40:31 +08:00
56daf8940b [FEAT] 優化部署流程:加入 RoleSeeder 與 AdminUserSeeder,並實作權限系統基礎架構與多租戶隔離機制
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 48s
2026-03-13 17:35:22 +08:00
39d25ed1d4 [REFACTOR] 移除 framework.md 中重複的環境與時區規範
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 52s
2026-03-13 10:50:33 +08:00
78597f1c68 [FIX] 移除個人檔案姓名輸入框的 autofocus 並恢復開發規範文件內容
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 49s
2026-03-13 10:47:39 +08:00
588704642b [DOC] 更新 framework.md 加入 IP 偵測特性說明 2026-03-13 10:43:38 +08:00
e5516193b0 [CLEANUP] 移除根目錄下錯誤位置的規範文件
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 56s
2026-03-13 10:41:06 +08:00
7f9f76111c [DOC] 新增 GEMINI.md 與 開發.md 規範手冊,統一時區與開發慣例
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 49s
2026-03-13 10:38:05 +08:00
3fbb7bc286 [FIX] 修正系統時區為 Asia/Taipei 並同步語系設定
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 42s
2026-03-13 10:34:30 +08:00
bb5d212569 [FIX] 解決手機重複登入日誌問題並新增裝置詳細資訊偵測
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 59s
2026-03-13 10:21:43 +08:00
6fab048461 [FEAT] 完善個人檔案功能:新增頭像即時上傳、麵包屑導覽、版面寬度優化與日期格式統一
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 58s
2026-03-13 10:08:30 +08:00
ea460cf6d9 [FEAT] 完善個人資料頭像上傳功能與導覽列介面優化 2026-03-13 09:30:07 +08:00
773396fc90 [REFACTOR] 實作側邊欄與儀表板多語系化,修復 UI 位移與樣式優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 52s
2026-03-12 17:42:57 +08:00
8ee14eaa29 [DOCS] 新增並更新 Phase 1 核心機台通訊 API 技術規範文件 (B010~B710)
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 48s
2026-03-11 17:37:05 +08:00
5708c4f12a [DOCS] 更新 .gitignore 排除 API 文件與 Excel 檔案
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m12s
2026-03-10 11:15:14 +08:00
d679c0df17 [FIX] 更新技能觸發路徑:修正因技能更名導致的 broken links 並指向新的全域規範路徑。 2026-03-09 15:04:17 +08:00
03dfcc9c7e [REFACTOR] 更新技能觸發規則:將 Eloquent 優化規範提升為全域技能並同步至觸發清單。 2026-03-09 14:59:38 +08:00
75a70a256d [FEAT] 新增機台系統日誌列表與極簡奢華風 UI
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 44s
2026-03-09 13:09:50 +08:00
682a9e7ac3 [FEAT] 儀表板 UI 大改造、中文化與項目開發規範同步 (含深色模式修復)
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 58s
2026-03-09 11:30:06 +08:00
02918ce0e1 [FEAT] 儀表板中文化、數據動態化及 UI 細節優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 43s
2026-03-09 10:57:22 +08:00
a38387f2ad style: 完成全域極簡奢華風 (Minimal Luxury) CSS 實作
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m10s
2026-03-09 10:21:28 +08:00
80436caee7 style: 切換為極簡奢華風 (Minimal Luxury) - 移除復古風,導入 Outfit/Jakarta 與軟陰影設計
Some checks failed
star-cloud-deploy-demo / deploy-demo (push) Has been cancelled
2026-03-09 10:20:58 +08:00
c1b40185eb style: 改造 Dashboard 為復古工業風 - 導入擬物化面板與 CRT 數據監控區域
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 42s
2026-03-09 10:17:25 +08:00
f15038fcbd style: 實作復古工業風 (Retro Industrial) - 字體, 鑲嵌面板, CRT 螢幕與琥珀色發亮效果
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 39s
2026-03-09 10:16:38 +08:00
1d035d2766 style: 移除使用者不喜歡的雜訊紋理與磨砂玻璃效果
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 39s
2026-03-09 10:09:10 +08:00
1c9edf8e46 style: 依據 frontend-design 規範進行視覺重構 (字體, 紋理, 玻璃質感, 動態)
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 40s
2026-03-09 10:08:14 +08:00
fba4f26575 style: 自定義全域與深色模式滾動條樣式
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 50s
2026-03-09 09:59:34 +08:00
4b64fede2e fix: 修正模型重構後遺漏的命名空間引用 (PointRule, Member, etc.)
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 56s
2026-03-09 09:57:41 +08:00
d905501a77 fix: 修正 Factory 中的 Faker 引用方式以解決 Seeder 失敗問題
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 45s
2026-03-09 09:46:30 +08:00
56c9a55944 fix: 將 MachineSeeder 加入主 Seeder 以支援自動部署填充
Some checks failed
star-cloud-deploy-demo / deploy-demo (push) Has been cancelled
2026-03-09 09:45:50 +08:00
c30c3a399d feat: 實作機台日誌核心功能與 IoT 高併發處理架構
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 36s
2026-03-09 09:43:51 +08:00
21e064ff91 [FIX] 強制 HTTPS 與信任代理以修正 Mixed Content 問題,並加入 CI/CD 自動同步 main 邏輯
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 44s
2026-03-06 17:16:00 +08:00
adea7feb7b [STYLE] 新增 deploy-prod.yaml 佔位檔案以觸發 Gitea 側邊欄分類
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 35s
2026-03-06 17:09:16 +08:00
2a6170b4ce [STYLE] 重構 CI/CD 文件名為 deploy-demo.yaml 以對齊 ERP UI 規範
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 34s
2026-03-06 17:07:02 +08:00
4a1fa2ad1b [DOCS] 更新 README.md 技術支援說明
All checks were successful
Star-Cloud-Deploy-Demo / deploy-demo (push) Successful in 41s
2026-03-06 17:04:11 +08:00
acc81b2156 [FEAT] 重構 CI/CD 工作流並修正容器端口配置以對齊 Demo 環境
All checks were successful
Star-Cloud-Deploy-Demo / deploy-demo (push) Successful in 50s
2026-03-06 16:49:13 +08:00
74b6c71c95 feat: Preline UI 改版與深色模式修復
All checks were successful
Star-Cloud-Deploy-System / deploy-demo (push) Successful in 35s
Star-Cloud-Deploy-System / deploy-production (push) Has been skipped
2026-01-13 13:28:58 +08:00
88c3678a4d chore: 加入 Docker 8.5 Dockerfile 並恢復 deploy.yaml 原始版本
All checks were successful
Star-Cloud-Deploy-System / deploy-demo (push) Successful in 1m22s
Star-Cloud-Deploy-System / deploy-production (push) Has been skipped
- 加入 docker/8.5 目錄(從 Laravel Sail 8.4 複製)
- 恢復 deploy.yaml 到原始版本
2026-01-13 11:04:58 +08:00
649cbaab02 fix: 修正部署腳本用戶權限問題
Some checks failed
Star-Cloud-Deploy-System / deploy-demo (push) Failing after 38s
Star-Cloud-Deploy-System / deploy-production (push) Has been skipped
- 將 docker exec 用戶從 -u 1000:1000 改為 -u sail(容器內 sail UID 是 1337)
- 簡化權限修正為 chown -R sail:sail /var/www/html
- 同步更新 Demo 與正式環境的部署腳本

問題根因:容器內 sail 用戶 UID 是 1337,非預期的 1000,導致 npm install 無權寫入
2026-01-13 10:54:29 +08:00
9c2ef60463 fix: 更新部署腳本,完全清除 node_modules 後重建以解決權限問題
All checks were successful
Star-Cloud-Deploy-System / deploy-demo (push) Successful in 47s
Star-Cloud-Deploy-System / deploy-production (push) Has been skipped
- 在 npm install 前先 rm -rf node_modules 再重建
- 確保 /.npm 和 node_modules 都有正確的 sail 擁有者
- 同步更新 Demo 與正式環境的部署腳本
2026-01-13 10:48:20 +08:00
f67a1dc11e style: 調整 deploy.yaml 格式
All checks were successful
Star-Cloud-Deploy-System / deploy-demo (push) Successful in 33s
Star-Cloud-Deploy-System / deploy-production (push) Has been skipped
2026-01-13 10:45:13 +08:00
a578c7f261 fix: 修正部署流程中的 npm 權限問題
All checks were successful
Star-Cloud-Deploy-System / deploy-demo (push) Successful in 40s
Star-Cloud-Deploy-System / deploy-production (push) Has been skipped
- 在 npm install 前先用 root 身份修正 /.npm 和 node_modules 權限
- 加入 npm cache clean --force 避免快取權限衝突
- 同時修正 Demo 與正式環境的部署腳本
- 解決 EACCES 與 ENOTEMPTY 錯誤
2026-01-13 10:39:05 +08:00
84ef0c24e2 feat: 整合 Preline UI 3.x 與重寫 README 為 Docker 架構
All checks were successful
Star-Cloud-Deploy-System / deploy-demo (push) Successful in 44s
Star-Cloud-Deploy-System / deploy-production (push) Has been skipped
- 新增 Preline UI 3.2.3 作為 UI 組件庫
- 更新 tailwind.config.js 整合 Preline
- 更新 app.js 初始化 Preline
- 完全重寫 README.md 以 Docker 容器化架構為核心
- 新增 Docker 常用指令大全
- 新增故障排除與生產部署指南
- 新增會員系統相關功能(會員、錢包、點數、會籍、禮物)
- 新增社交登入測試功能
2026-01-13 10:17:37 +08:00
55ba08c88f chore: 更新 deploy.yaml 部署流程
All checks were successful
Star-Cloud-Deploy-System / deploy-demo (push) Successful in 39s
Star-Cloud-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 13:21:07 +08:00
11491e07aa fix: 修正空白的 add_username_to_users_table migration 檔案
All checks were successful
Star-Cloud-Deploy-System / deploy-demo (push) Successful in 47s
Star-Cloud-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 13:11:01 +08:00
d3684385b2 fix: 修正 AdminUserSeeder 欄位結構與資料庫一致
All checks were successful
Star-Cloud-Deploy-System / deploy-demo (push) Successful in 44s
Star-Cloud-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 13:08:00 +08:00
7db3ee3a05 feat: 分離 AdminUserSeeder 並重構 DatabaseSeeder 支援單獨執行
All checks were successful
Star-Cloud-Deploy-System / deploy-demo (push) Successful in 39s
Star-Cloud-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 13:04:59 +08:00
a0d107ca79 feat: 更新 DatabaseSeeder 添加 admin 帳號的 username 和 role 欄位
All checks were successful
Star-Cloud-Deploy-System / deploy-demo (push) Successful in 56s
Star-Cloud-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 13:02:34 +08:00
96b22cd577 fix: 修正 Step 4 部署錯誤,改用 SSH action 在 demo 伺服器執行 docker exec
All checks were successful
Star-Cloud-Deploy-System / deploy-demo (push) Successful in 50s
Star-Cloud-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 11:46:56 +08:00
3ed8b00cab chore: 移除 p.bat 檔案
Some checks failed
Star-Cloud-Deploy-System / deploy-demo (push) Failing after 2m18s
Star-Cloud-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 11:39:59 +08:00
2aff99fc76 add deploy
Some checks failed
Star-Cloud-Deploy-System / deploy-demo (push) Failing after 28s
Star-Cloud-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 11:27:34 +08:00
2ed0ee272e 登入修正 2026-01-12 11:23:41 +08:00
231 changed files with 18261 additions and 3559 deletions

View File

@@ -1,222 +0,0 @@
---
trigger: always_on
---
# Backend API Specification (backend.md)
---
## 目標範圍
* 使用 Laravel (RESTful API)
* 資料庫MySQLmigration + 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 testsLaravel Feature tests for API
* Contract tests與 Android 團隊一同建立 contract tests或使用 Postman tests
---
## 部署與運營
* 建議使用 queue 與 cacheRedis處理非同步任務
* 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 清單。*

View File

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

View File

@@ -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。

View 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`,以避免連線至錯誤的服務環境。

View File

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

View File

@@ -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` 隔離邏輯正確套用

View File

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

View File

@@ -0,0 +1,348 @@
---
name: 璆萇陛憟虾憸<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>
---
# 璆萇陛憟虾憸<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><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><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> | 摰<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>

View File

@@ -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

View 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 }}

View 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
View File

@@ -17,3 +17,7 @@ yarn-error.log
/.fleet
/.idea
/.vscode
/docs/API
/docs/*.xlsx

216
README.md
View File

@@ -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+
若您尚未安裝 MySQLWindows 使用者可至 [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。

View File

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

View File

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

View File

@@ -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

View File

@@ -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');
}
}

View File

@@ -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.'));
}
}

View 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.'));
}
}

View File

@@ -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'
));
}
}

View File

@@ -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()

View 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', '儲值回饋規則已刪除');
}
}

View 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', '禮品已刪除');
}
}

View File

@@ -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
}
}

View 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', '會員等級已刪除');
}
}

View File

@@ -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.'));
}
}

View 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', '點數規則已刪除');
}
}

View 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' => '登出成功',
]);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class MemberController extends Controller
{
//
}

View File

@@ -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;

View 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,
]);
}
}

View File

@@ -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);
}
}

View 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
]
]);
}
}

View 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();
}
}

View File

@@ -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,
];
}

View 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);
}
}

View File

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

View File

@@ -0,0 +1,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);
}
}

View File

@@ -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.

View File

@@ -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());
}
}

View File

@@ -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'],
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use App\Models\Machine\CoinInventory;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessCoinInventory implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $serialNo;
protected $data;
/**
* Create a new job instance.
*/
public function __construct(string $serialNo, array $data)
{
$this->serialNo = $serialNo;
$this->data = $data;
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
$machine = Machine::where('serial_no', $this->serialNo)->firstOrFail();
// Sync inventory: typically the IoT device sends the full state
// If it sends partial, logic would differ. For now, we assume simple updateOrCreate per denomination.
if (isset($this->data['inventories']) && is_array($this->data['inventories'])) {
foreach ($this->data['inventories'] as $inv) {
CoinInventory::updateOrCreate(
[
'machine_id' => $machine->id,
'denomination' => $inv['denomination'],
'type' => $inv['type'] ?? 'coin'
],
['count' => $inv['count']]
);
}
}
} catch (\Exception $e) {
Log::error("Failed to process coin inventory for machine {$this->serialNo}: " . $e->getMessage());
throw $e;
}
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Jobs\Machine;
use App\Services\Machine\MachineService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessHeartbeat implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $serialNo;
protected $data;
/**
* Create a new job instance.
*/
public function __construct(string $serialNo, array $data)
{
$this->serialNo = $serialNo;
$this->data = $data;
}
/**
* Execute the job.
*/
public function handle(MachineService $machineService): void
{
try {
$machineService->updateHeartbeat($this->serialNo, $this->data);
} catch (\Exception $e) {
Log::error("Failed to process heartbeat for machine {$this->serialNo}: " . $e->getMessage());
throw $e;
}
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Jobs\Machine;
use App\Services\Machine\MachineService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessMachineLog implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* @var int
*/
protected $machineId;
/**
* @var array
*/
protected $logData;
public function __construct(int $machineId, array $logData)
{
$this->machineId = $machineId;
$this->logData = $logData;
}
public function getMachineId(): int
{
return $this->machineId;
}
public function handle(MachineService $service): void
{
try {
$service->recordLog($this->machineId, $this->logData);
} catch (\Exception $e) {
Log::error("Failed to process machine log for machine {$this->machineId}: " . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use App\Models\Machine\TimerStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessTimerStatus implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $serialNo;
protected $data;
/**
* Create a new job instance.
*/
public function __construct(string $serialNo, array $data)
{
$this->serialNo = $serialNo;
$this->data = $data;
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
$machine = Machine::where('serial_no', $this->serialNo)->firstOrFail();
TimerStatus::updateOrCreate(
['machine_id' => $machine->id, 'slot_no' => $this->data['slot_no']],
[
'status' => $this->data['status'],
'remaining_seconds' => $this->data['remaining_seconds'],
'end_at' => isset($this->data['end_at']) ? \Carbon\Carbon::parse($this->data['end_at']) : null,
]
);
} catch (\Exception $e) {
Log::error("Failed to process timer status for machine {$this->serialNo}: " . $e->getMessage());
throw $e;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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(),
]);
}
}

View File

@@ -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);
}
}

View File

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

View File

@@ -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');
}
}

View File

@@ -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',

View 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');
}
}

View File

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

View File

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

View File

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

View File

@@ -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);
}
}

View 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);
}
}

View 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,
]);
}
}

View 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());
});
}
}

View 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());
});
}
}

View 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');
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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();
}
}

View 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);
}
}

View 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);
}
}

View File

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

View File

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

View File

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

View 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);
}
}

View 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');
}
}

View 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');
});
}
}

View File

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

100
app/Models/System/User.php Normal file
View 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";
}
}

View 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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
];
}

View File

@@ -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');
}
}
}

View File

@@ -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,
],
];
/**

View 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,
]);
}
}

View File

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

View File

@@ -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);
}
}

View 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
View File

@@ -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);

View File

@@ -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:

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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'),
/*
|--------------------------------------------------------------------------

View File

@@ -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