Compare commits

...

14 Commits

Author SHA1 Message Date
a620906209 0455c1577e feat:實作 API 效能優化 — Redis 快取、分頁、DB 索引
Tests / PHP 8.2 (push) Failing after 1m35s
Tests / PHP 8.3 (push) Failing after 2s
- 引入 Redis(predis)快取層:Admin Stats(5分鐘)、課程列表(3分鐘,tag-based 失效)、評價分布(10分鐘)
- ReviewController::publicList 改為 paginate + eager load votes,消除 N+1
- AdminReviewController::index 加入分頁(預設 20,最大 100)
- 新增 notifications / diving_offers 效能索引 migration
- 新增 docker-compose.override.yml 本機開發 port mapping 機制(不進 git)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:17:16 +08:00
a620906209 6877ef30b5 fix:修正 Email 設定與規格同步
Tests / PHP 8.2 (push) Failing after 1m56s
Tests / PHP 8.3 (push) Failing after 1m17s
- .env.example:MAIL_MAILER 改為 smtp(對應 Mailpit 本地測試流程)
- notification-email spec:移除 ReviewReceived Email 觸發(僅 database channel)
- notification-email spec:Email CTA 連結改為 /my-bookings(移除 /{id})

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 23:21:16 +08:00
a620906209 03f8caf3e9 feat:實作通知系統 — 站內通知、Email 通知、Polling 機制
後端
- 新增 6 個 Notification class(預約建立/確認/拒絕/取消/完成、收到評價),database + mail 雙 channel
- 新增 NotificationController(list / unread-count / markRead / markAllRead / destroy)
- 整合通知觸發至 MemberBookingController、ProviderBookingController、CompleteFinishedBookings、ReviewController
- 新增 notifications / jobs / failed_jobs migration
- Docker Compose 加入 queue-worker、mailpit service
- DivingOffer 補上 provider() 關聯

前端
- 新增 notificationStore(Polling 30s/60s 自適應 + Page Visibility API)
- 新增 NotificationBell(未讀 Badge)、NotificationDrawer(側邊通知中心)
- main.js:auth store init 前置於 router.use(),修正 beforeEach guard 時序問題
- notificationAxios:依路徑動態選擇 member/coach token
- NotificationDrawer:改用 new URL().pathname 提取 action_url 路徑

OpenSpec
- 歸檔 notification-system change
- 同步 notification-core / notification-email / notification-triggers specs 至主規格
- 更新 booking-lifecycle / review-lifecycle spec(補充通知觸發 requirement)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 22:26:14 +08:00
a620906209 4baa4cb52b feat:實作課程圖片上傳 — 封面 + 相簿管理
後端:
- Migration:diving_offers 新增 cover_image 欄位、新增 course_images 表(含索引)
- CourseImage Model(CREATED_AT、url accessor)
- DivingOffer:cover_image_url accessor、hasMany courseImages、static::deleting() 孤兒清理
- CourseImageController:封面上傳/刪除、相簿上傳(max 3)/刪除,統一 mimes+size 驗證
- DivingOfferController:index/show 回傳加入 cover_image_url 與 images 陣列
- 修正 APP_URL 加入 port(:8080),Storage::url() 才能產生正確圖片連結

前端:
- courseImageApi.js:uploadCover/deleteCover/uploadImage/deleteImage
- CourseCard:有封面顯示 <img>,無封面顯示漸層佔位
- CourseDetailView:封面大圖 + 相簿縮圖橫列(點擊開新分頁)
- OfferFormView(編輯模式):封面預覽/更換/刪除、相簿縮圖管理(達 3 張隱藏上傳按鈕)

基礎設施:
- docker-entrypoint.sh:加入 storage:link --force
- docker-compose.yml:移除 storage-data named volume(改用 bind mount,避免 Nginx 讀不到圖片)

測試:
- CourseImageTest.php:14 個 Feature Test 全部 PASS(Storage::fake)
  涵蓋:上傳成功/格式驗證/大小驗證/所有權、刪除/無封面不報錯、
        相簿上限/sort_order 遞增、孤兒清理

OpenSpec:
- course-images change 歸檔至 archive/2026-05-12-course-images
- 新增 specs/course-image-upload 主規格(含 bind mount 持久化說明)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 03:54:45 +08:00
a620906209 81a9f84b26 feat:實作評價系統 — 匿名評價、有幫助投票、手動完成預約
後端:
- 新增 reviews / review_edits / review_votes migration(含索引)
- Review / ReviewEdit / ReviewVote Model
- ReviewController:評價 CRUD、資格驗證(completed booking)、rating 即時重算
- toggleHelpful:Member 限定、GREATEST 原子防負、DB transaction 同步
- AdminReviewController:全量列表、刪除(含重算)
- AdminBookingController:全量列表、手動標記 completed
- ProviderBookingController 新增 complete 方法(教練手動完成預約)
- DevelopmentSeeder:快速重建測試資料(admin/coach/member + offers + bookings)
- EnsureAdmin middleware 正式納入 bootstrap/app.php
- Nginx server_name 加入 cfdive.local

前端:
- 課程詳情頁加入評價區塊(星等分布、排序切換、撰寫/修改/刪除、有幫助 Toggle)
- Coach Portal 新增「課程評價」頁(只讀,依課程分組)
- Coach 預約管理加入「完成」按鈕
- Admin 新增「預約管理」頁(標記完成)、「評價管理」頁(刪除)
- Admin / Coach Navbar 新增對應連結

OpenSpec:
- review-system change 歸檔至 archive/2026-05-12-review-system
- 新增 specs/review-lifecycle 與 specs/review-voting 主規格
- review-voting spec 補充 Member 限定與 GREATEST 原子更新說明

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 02:46:54 +08:00
a620906209 975b56ca54 feat:實作預約系統 — 時段管理、預約生命週期與前端整合
後端:
- 新增 course_schedules / bookings migration(含索引)
- BookingStatus / ScheduleStatus PHP BackedEnum
- CourseSchedule / Booking Model(七狀態機 VALID_TRANSITIONS)
- ScheduleController、ProviderBookingController、MemberBookingController
- 雙層名額驗證(API 層快速失敗 + DB lockForUpdate 防超賣)
- 24h 取消截止、pending 不佔位設計
- ExpirePendingBookings(每小時)/ CompleteFinishedBookings(每日)Scheduler
- Docker cron 配置、CACHE_STORE 改為 file 修正 502

前端:
- 課程詳情頁加入時段選擇與預約流程
- 我的預約頁(展開式卡片、狀態說明、連結課程詳情)
- Coach 時段管理(上午/下午時間選擇器、新課程引導)
- Coach 預約管理(依課程分組、待確認徽章)
- Navbar 新增「我的預約」與「時段/預約管理」入口
- 密碼格式提示與即時比對

OpenSpec:
- booking-system change 歸檔至 archive/2026-05-12-booking-system
- 新增 specs/course-scheduling 與 specs/booking-lifecycle 主規格

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 00:24:51 +08:00
a620906209 ad2c05779d feat:實作 Admin Panel — 平台管理後台
後端:
- AdminStatsController:總會員/教練/課程數統計 API
- AdminUserController:會員與教練列表、詳情、啟用/停用、教練驗證(toggle 反轉語意)
- AdminOfferController:全平台課程列表與刪除
- routes/api.php:新增 /api/admin/stats、members、providers、offers 等路由

前端(frontend/):
- adminAuth store、adminAxios(第三套獨立認證)
- /admin/* 路由群組 + requiresAdmin guard
- AdminNavBar、AdminLayout
- App.vue:isCoachPage → isBackofficePage(/coach/* 和 /admin/* 皆隱藏會員 NavBar)
- LoginView、DashboardView(統計卡片)
- MembersView、ProvidersView(含驗證操作)、OffersView(含刪除確認)

OpenSpec:
- 新增 specs:admin-auth / admin-user-management / admin-offer-management / admin-stats / admin-panel-ui
- 歸檔:openspec/changes/archive/2026-05-10-admin-panel

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-10 04:07:13 +08:00
a620906209 da48a3652d feat:實作 Coach Portal — 教練後台課程管理
後端:
- Migration:diving_offers 新增 provider_id(nullable FK)
- Migration:users.role ENUM 加入 provider 值
- Migration:diving_offers.spot 改為 nullable
- AuthController:registerProvider business_name 改為選填
- AuthController:updateProviderProfile 補上 certifications / dive_sites / services / facilities / website / social_media
- ProviderOfferController:教練課程 CRUD(index/show/store/update/destroy),實作 provider_id 所有權不變式(404 → 403 兩步驟)

前端(frontend/):
- coachAuth store、coachAxios(獨立於 member auth)
- /coach/* 路由群組 + beforeEach guard
- CoachNavBar、CoachLayout(教練頁隱藏會員 NavBar)
- LoginView、RegisterView、DashboardView(表格 + 刪除確認)
- OfferFormView(新增/編輯共用)、ProfileView

OpenSpec:
- openspec/config.yaml 補入專案 context 與 rules
- 新增 specs:coach-offers-api / coach-portal-ui / provider-auth
- 更新 spec:diving-offers-api 加入 provider_id
- 歸檔:openspec/changes/archive/2026-05-10-coach-portal

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-10 03:34:14 +08:00
a620906209 550e2fc97a feat:實作 Member Portal MVP 前端與後端整合
後端:
- 新增 DivingOffer Model / DivingOfferController(列表+詳情 API,支援搜尋/篩選/分頁)
- 修正 Google OAuth callback 改為 redirect 至前端(SocialAuthController)
- 新增 config/cors.php 允許前端 origin
- .gitignore 新增 frontend/ 排除規則

前端(frontend/):
- Vue 3 + Vite + Tailwind CSS + Pinia + Vue Router
- 頁面:首頁、課程列表、課程詳情、登入、註冊、個人資料、OAuth callback
- 整合至 Docker(multi-stage build,nginx 靜態服務於 port 5173)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-10 01:41:28 +08:00
a620906209 725c86f434 fix:補sessionDB 2025-05-23 21:21:19 +08:00
a620906209 9de698d90e build:添加docker文件 2025-05-23 21:20:54 +08:00
a620906209 b02a18d762 test:編寫Postman API測試文件 2025-05-23 18:31:33 +08:00
a620906209 0a0d335304 docs:移除coach swagger註解 2025-05-12 02:43:37 +08:00
a620906209 6fef9e8c37 Update README.md
Swagger API Doc
2025-05-12 02:37:02 +08:00
215 changed files with 18209 additions and 185 deletions
+5 -4
View File
@@ -4,6 +4,7 @@ APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_TIMEZONE=UTC APP_TIMEZONE=UTC
APP_URL=http://localhost APP_URL=http://localhost
FRONTEND_URL=http://localhost:5173
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@@ -36,17 +37,17 @@ BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=database
CACHE_STORE=database CACHE_STORE=redis
CACHE_PREFIX= CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1 REDIS_HOST=redis
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
MAIL_MAILER=log MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1 MAIL_HOST=127.0.0.1
MAIL_PORT=2525 MAIL_PORT=2525
MAIL_USERNAME=null MAIL_USERNAME=null
+9
View File
@@ -1,4 +1,5 @@
/.phpunit.cache /.phpunit.cache
docker-compose.override.yml
/node_modules /node_modules
/public/build /public/build
/public/hot /public/hot
@@ -17,3 +18,11 @@ yarn-error.log
/.fleet /.fleet
/.idea /.idea
/.vscode /.vscode
/.claude
/.opensp
# Frontend
/frontend/node_modules
/frontend/dist
/frontend/.env
@@ -0,0 +1,501 @@
{
"info": {
"name": "CFDivePlatform API Tests",
"description": "CFDivePlatform API 測試集合 - 包含會員、服務提供者、管理員等完整測試案例",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "基礎測試",
"item": [
{
"name": "Ping Test",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json"
}
],
"url": {
"raw": "http://localhost:8080/api/ping",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "ping"]
}
}
},
{
"name": "Test POST",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"測試用戶\",\n \"value\": \"123\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/testpost",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "testpost"]
}
}
}
]
},
{
"name": "會員認證",
"item": [
{
"name": "會員註冊",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"測試會員\",\n \"email\": \"member@test.com\",\n \"password\": \"password123\",\n \"password_confirmation\": \"password123\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/member/register",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "member", "register"]
}
}
},
{
"name": "會員登入",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"member@test.com\",\n \"password\": \"password123\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/member/login",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "member", "login"]
}
}
},
{
"name": "取得會員資料",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{member_token}}"
}
],
"url": {
"raw": "http://localhost:8080/api/member/profile",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "member", "profile"]
}
}
}
]
},
{
"name": "會員專屬 API",
"item": [
{
"name": "會員登出",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{member_token}}"
}
],
"url": {
"raw": "http://localhost:8080/api/member/logout",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "member", "logout"]
}
}
},
{
"name": "更新會員個人資料",
"request": {
"method": "PUT",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{member_token}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"更新後的會員名稱\",\n \"phone\": \"0912345678\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/member/profile",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "member", "profile"]
}
}
},
{
"name": "修改會員密碼",
"request": {
"method": "PUT",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{member_token}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"current_password\": \"password123\",\n \"new_password\": \"newpassword123\",\n \"new_password_confirmation\": \"newpassword123\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/member/change-password",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "member", "change-password"]
}
}
}
]
},
{
"name": "服務提供者認證",
"item": [
{
"name": "服務提供者註冊",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"測試教練\",\n \"email\": \"{{test_provider_email}}\",\n \"password\": \"{{test_password}}\",\n \"password_confirmation\": \"{{test_password}}\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/provider/register",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "provider", "register"]
}
}
},
{
"name": "服務提供者登入",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"{{test_provider_email}}\",\n \"password\": \"{{test_password}}\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/provider/login",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "provider", "login"]
}
}
}
]
},
{
"name": "服務提供者專屬 API",
"item": [
{
"name": "服務提供者登出",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{provider_token}}"
}
],
"url": {
"raw": "http://localhost:8080/api/provider/logout",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "provider", "logout"]
}
}
},
{
"name": "取得服務提供者資料",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{provider_token}}"
}
],
"url": {
"raw": "http://localhost:8080/api/provider/profile",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "provider", "profile"]
}
}
}
]
},
{
"name": "管理員認證",
"item": [
{
"name": "管理員註冊",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"測試管理員\",\n \"email\": \"{{test_admin_email}}\",\n \"password\": \"{{test_password}}\",\n \"password_confirmation\": \"{{test_password}}\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/admin/register",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "admin", "register"]
}
}
},
{
"name": "管理員登入",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"{{test_admin_email}}\",\n \"password\": \"{{test_password}}\"\n}"
},
"url": {
"raw": "http://localhost:8080/api/admin/login",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "admin", "login"]
}
}
}
]
},
{
"name": "管理員專屬 API",
"item": [
{
"name": "查詢會員資料",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{admin_token}}"
}
],
"url": {
"raw": "http://localhost:8080/api/admin/check-member/1",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "admin", "check-member", "1"],
"variable": [
{
"key": "id",
"value": "1"
}
]
}
}
},
{
"name": "查詢服務提供者資料",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{admin_token}}"
}
],
"url": {
"raw": "http://localhost:8080/api/admin/check-provider/1",
"protocol": "http",
"host": ["localhost"],
"port": "8080",
"path": ["api", "admin", "check-provider", "1"],
"variable": [
{
"key": "id",
"value": "1"
}
]
}
}
}
]
}
],
"variable": [
{
"key": "base_url",
"value": "http://localhost:8080/api"
},
{
"key": "member_token",
"value": "",
"description": "會員登入後設定"
},
{
"key": "provider_token",
"value": "",
"description": "服務提供者登入後設定"
},
{
"key": "admin_token",
"value": "",
"description": "管理員登入後設定"
},
{
"key": "test_member_email",
"value": "member@test.com"
},
{
"key": "test_provider_email",
"value": "provider@test.com"
},
{
"key": "test_admin_email",
"value": "admin@test.com"
},
{
"key": "test_password",
"value": "password123"
}
]
}
+64
View File
@@ -0,0 +1,64 @@
# 使用官方 PHP 8.2 FPM 鏡像作為基礎
FROM php:8.2-fpm
# 安裝系統依賴
# 1. 更新套件列表並安裝必要的套件
RUN apt-get update && apt-get install -y \
git \
curl \
libpng-dev \
libonig-dev \
libxml2-dev \
zip \
unzip \
libzip-dev \
default-mysql-client \
netcat-openbsd \
grep \
cron
# 清理 apt 快取以減小鏡像大小
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
# 安裝 PHP 擴展
# 這些擴展是 Laravel 和一般 PHP 開發所需的
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
# 從官方 Composer 鏡像複製 Composer 執行文件
# 這樣可以確保我們使用最新版本的 Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# 設置工作目錄
WORKDIR /var/www
# 設置目錄權限
# 將 /var/www 目錄的所有權更改為 www-data 用戶和組
RUN chown -R www-data:www-data /var/www
# 創建必要的目錄並設置權限
# Laravel 需要這些目錄來存儲日誌、緩存等
RUN mkdir -p /var/www/storage /var/www/bootstrap/cache
RUN chmod -R 775 /var/www/storage /var/www/bootstrap/cache
# 複製自定義的 PHP 配置文件
# 這個文件包含 PHP 運行時的配置選項
COPY docker/php/local.ini /usr/local/etc/php/conf.d/local.ini
# 複製並設置入口點腳本
# 這個腳本將在容器啟動時執行
COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# 加入 Laravel Scheduler cron job
RUN echo "* * * * * www-data php /var/www/artisan schedule:run >> /var/log/laravel-scheduler.log 2>&1" \
> /etc/cron.d/laravel-scheduler \
&& chmod 0644 /etc/cron.d/laravel-scheduler \
&& crontab /etc/cron.d/laravel-scheduler
# 設置容器啟動時執行的入口點
# 這將在 CMD 指令之前執行
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
# 設置默認的容器命令
# 這將在 ENTRYPOINT 執行後運行
CMD ["php-fpm"]
+3 -65
View File
@@ -1,66 +1,4 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p> ![image](https://github.com/user-attachments/assets/332bf381-8faa-441f-8843-bff2c1fbde7a)
![image](https://github.com/user-attachments/assets/f0a765f5-5386-467e-80b8-4a5ef8ef0bcf)
![image](https://github.com/user-attachments/assets/5d6ad6ee-f100-4d45-9200-d61ce76ca7c4)
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com/)**
- **[Tighten Co.](https://tighten.co)**
- **[WebReinvent](https://webreinvent.com/)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel/)**
- **[Cyber-Duck](https://cyber-duck.co.uk)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Jump24](https://jump24.co.uk)**
- **[Redberry](https://redberry.international/laravel/)**
- **[Active Logic](https://activelogic.com)**
- **[byte5](https://byte5.de)**
- **[OP.GG](https://op.gg)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
@@ -0,0 +1,38 @@
<?php
namespace App\Console\Commands;
use App\Enums\BookingStatus;
use App\Models\Booking;
use App\Notifications\BookingCompletedNotification;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class CompleteFinishedBookings extends Command
{
protected $signature = 'app:complete-finished-bookings';
protected $description = '將課程日期已過的 confirmed 預約標記為 completed';
public function handle(): void
{
$bookings = Booking::with(['member', 'schedule.divingOffer'])
->where('status', BookingStatus::Confirmed->value)
->whereHas('schedule', fn($q) => $q->whereDate('scheduled_date', '<', now()->toDateString()))
->get();
$count = 0;
foreach ($bookings as $booking) {
$booking->update(['status' => BookingStatus::Completed]);
$count++;
try {
$booking->member->notify(new BookingCompletedNotification($booking));
} catch (\Throwable $e) {
Log::error("BookingCompletedNotification failed for booking #{$booking->id}: " . $e->getMessage());
}
}
Log::info("CompleteFinishedBookings: {$count} completed");
$this->info("CompleteFinishedBookings: {$count} bookings completed.");
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Console\Commands;
use App\Enums\BookingStatus;
use App\Models\Booking;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ExpirePendingBookings extends Command
{
protected $signature = 'app:expire-pending-bookings';
protected $description = '將超過 48 小時未確認的 pending 預約標記為 expired';
public function handle(): void
{
$count = Booking::where('status', BookingStatus::Pending->value)
->where('created_at', '<=', now()->subHours(48))
->update(['status' => BookingStatus::Expired->value]);
Log::info("ExpirePendingBookings: {$count} expired");
$this->info("ExpirePendingBookings: {$count} bookings expired.");
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Enums;
enum BookingStatus: string
{
case Pending = 'pending';
case Confirmed = 'confirmed';
case Completed = 'completed';
case Rejected = 'rejected';
case Expired = 'expired';
case MemberCancelled = 'member_cancelled';
case ProviderCancelled = 'provider_cancelled';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum ScheduleStatus: string
{
case Open = 'open';
case Full = 'full';
case Cancelled = 'cancelled';
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
use App\Http\Controllers\Controller;
use App\Models\Booking;
class AdminBookingController extends Controller
{
public function index()
{
$bookings = Booking::with(['member', 'schedule.divingOffer'])
->orderByDesc('created_at')
->get()
->map(fn($b) => [
'id' => $b->id,
'member_name' => $b->member?->name,
'member_email' => $b->member?->email,
'offer_title' => $b->schedule?->divingOffer?->title,
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
'start_time' => $b->schedule?->start_time,
'participants' => $b->participants,
'total_price' => $b->total_price,
'status' => $b->status->value,
'created_at' => $b->created_at?->toISOString(),
]);
return response()->json(['status' => true, 'data' => $bookings]);
}
public function complete(int $id)
{
$booking = Booking::findOrFail($id);
if (!$booking->canTransitionTo(BookingStatus::Completed)) {
return response()->json(['status' => false, 'message' => '只有已確認的預約才能標記完成'], 422);
}
$booking->update(['status' => BookingStatus::Completed]);
return response()->json(['status' => true, 'message' => '預約已標記為完成']);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
class AdminOfferController extends Controller
{
private function checkAdmin()
{
if (auth()->user()->role !== 'admin') {
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
}
return null;
}
public function index(Request $request)
{
if ($err = $this->checkAdmin()) return $err;
$query = DivingOffer::query();
if ($q = $request->query('q')) {
$query->where(function ($sub) use ($q) {
$sub->where('title', 'like', "%{$q}%")
->orWhere('location', 'like', "%{$q}%");
});
}
$paginated = $query->latest('id')->paginate(15);
return response()->json([
'status' => true,
'data' => $paginated->items(),
'meta' => [
'total' => $paginated->total(),
'per_page' => $paginated->perPage(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
],
]);
}
public function destroy(int $id)
{
if ($err = $this->checkAdmin()) return $err;
$offer = DivingOffer::find($id);
if (!$offer) {
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
}
$offer->delete();
return response()->json(['status' => true, 'message' => '課程已刪除']);
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use App\Models\Review;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AdminReviewController extends Controller
{
public function index(Request $request)
{
$perPage = min((int) $request->query('per_page', 20), 100);
$paginator = Review::with(['divingOffer', 'member'])
->orderByDesc('created_at')
->paginate($perPage);
$reviews = $paginator->getCollection()->map(fn($r) => [
'id' => $r->id,
'offer_title' => $r->divingOffer?->title,
'member_email' => $r->member?->email,
'rating' => $r->rating,
'comment' => mb_strimwidth($r->comment, 0, 50, '...'),
'is_edited' => $r->is_edited,
'helpful_count'=> $r->helpful_count,
'created_at' => $r->created_at?->toISOString(),
]);
return response()->json([
'status' => true,
'data' => $reviews,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]);
}
public function destroy(int $id)
{
$review = Review::findOrFail($id);
$offerId = $review->diving_offer_id;
DB::transaction(function () use ($review, $offerId) {
$review->delete();
$this->recalculateOfferRating($offerId);
});
return response()->json(['status' => true, 'message' => '評價已刪除']);
}
private function recalculateOfferRating(int $offerId): void
{
$stats = Review::where('diving_offer_id', $offerId)
->selectRaw('ROUND(AVG(rating), 1) as avg_rating, COUNT(*) as total')
->first();
DivingOffer::where('id', $offerId)->update([
'rating' => $stats->total > 0 ? $stats->avg_rating : 0,
'reviews' => $stats->total,
]);
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
class AdminStatsController extends Controller
{
public function index()
{
if (auth()->user()->role !== 'admin') {
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
}
$stats = Cache::remember('admin_stats', 300, fn() => [
'total_members' => User::where('role', 'member')->count(),
'total_providers' => User::where('role', 'provider')->count(),
'total_offers' => DivingOffer::count(),
]);
return response()->json(['status' => true, 'data' => $stats]);
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
class AdminUserController extends Controller
{
private function checkAdmin()
{
if (auth()->user()->role !== 'admin') {
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
}
return null;
}
private function findUser(int $id, string $role)
{
return User::where('id', $id)->where('role', $role)->first();
}
public function members(Request $request)
{
if ($err = $this->checkAdmin()) return $err;
$query = User::where('role', 'member')->with('memberProfile');
if ($q = $request->query('q')) {
$query->where(function ($sub) use ($q) {
$sub->where('name', 'like', "%{$q}%")
->orWhere('email', 'like', "%{$q}%");
});
}
$paginated = $query->latest()->paginate(15);
return response()->json([
'status' => true,
'data' => $paginated->items(),
'meta' => [
'total' => $paginated->total(),
'per_page' => $paginated->perPage(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
],
]);
}
public function member(int $id)
{
if ($err = $this->checkAdmin()) return $err;
$user = $this->findUser($id, 'member');
if (!$user) {
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
}
return response()->json(['status' => true, 'data' => $user->load('memberProfile')]);
}
public function toggleMemberActive(int $id)
{
if ($err = $this->checkAdmin()) return $err;
$user = $this->findUser($id, 'member');
if (!$user) {
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
}
$user->is_active = !$user->is_active;
$user->save();
$msg = $user->is_active ? '帳號已啟用' : '帳號已停用';
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
}
public function providers(Request $request)
{
if ($err = $this->checkAdmin()) return $err;
$query = User::where('role', 'provider')->with('providerProfile');
if ($q = $request->query('q')) {
$query->where(function ($sub) use ($q) {
$sub->where('name', 'like', "%{$q}%")
->orWhere('email', 'like', "%{$q}%");
});
}
$paginated = $query->latest()->paginate(15);
return response()->json([
'status' => true,
'data' => $paginated->items(),
'meta' => [
'total' => $paginated->total(),
'per_page' => $paginated->perPage(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
],
]);
}
public function provider(int $id)
{
if ($err = $this->checkAdmin()) return $err;
$user = $this->findUser($id, 'provider');
if (!$user) {
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
}
return response()->json(['status' => true, 'data' => $user->load('providerProfile')]);
}
public function toggleProviderActive(int $id)
{
if ($err = $this->checkAdmin()) return $err;
$user = $this->findUser($id, 'provider');
if (!$user) {
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
}
$user->is_active = !$user->is_active;
$user->save();
$msg = $user->is_active ? '帳號已啟用' : '帳號已停用';
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
}
public function toggleProviderVerified(int $id)
{
if ($err = $this->checkAdmin()) return $err;
$user = $this->findUser($id, 'provider');
if (!$user) {
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
}
$profile = $user->providerProfile;
$profile->is_verified = !$profile->is_verified;
$profile->save();
$msg = $profile->is_verified ? '教練已驗證' : '已取消驗證';
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_verified' => $profile->is_verified]]);
}
}
+25 -1
View File
@@ -474,7 +474,7 @@ class AuthController extends Controller
'email' => 'required|string|email|max:255|unique:users', 'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:6|confirmed', 'password' => 'required|string|min:6|confirmed',
'phone' => 'nullable|string|max:20', 'phone' => 'nullable|string|max:20',
'business_name' => 'required|string|max:255', 'business_name' => 'nullable|string|max:255',
'description' => 'nullable|string', 'description' => 'nullable|string',
'contact_person' => 'nullable|string|max:100', 'contact_person' => 'nullable|string|max:100',
'contact_phone' => 'nullable|string|max:20', 'contact_phone' => 'nullable|string|max:20',
@@ -655,6 +655,12 @@ class AuthController extends Controller
'contact_email' => 'nullable|string|email|max:255', 'contact_email' => 'nullable|string|email|max:255',
'address' => 'nullable|string|max:255', 'address' => 'nullable|string|max:255',
'business_hours' => 'nullable|string|max:100', 'business_hours' => 'nullable|string|max:100',
'certifications' => 'nullable|string',
'dive_sites' => 'nullable|string',
'services' => 'nullable|string',
'facilities' => 'nullable|string',
'website' => 'nullable|string|max:255',
'social_media' => 'nullable|string|max:255',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@@ -701,6 +707,24 @@ class AuthController extends Controller
if ($request->has('business_hours')) { if ($request->has('business_hours')) {
$providerProfile->business_hours = $request->business_hours; $providerProfile->business_hours = $request->business_hours;
} }
if ($request->has('certifications')) {
$providerProfile->certifications = $request->certifications;
}
if ($request->has('dive_sites')) {
$providerProfile->dive_sites = $request->dive_sites;
}
if ($request->has('services')) {
$providerProfile->services = $request->services;
}
if ($request->has('facilities')) {
$providerProfile->facilities = $request->facilities;
}
if ($request->has('website')) {
$providerProfile->website = $request->website;
}
if ($request->has('social_media')) {
$providerProfile->social_media = $request->social_media;
}
$providerProfile->save(); $providerProfile->save();
@@ -0,0 +1,99 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\CourseImage;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class CourseImageController extends Controller
{
private function validateImage(Request $request): void
{
$request->validate([
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048',
]);
}
private function authorizeOffer(Request $request, DivingOffer $offer): void
{
if ($offer->provider_id !== $request->user()->id) {
abort(403, '無權操作此課程');
}
}
public function uploadCover(Request $request, int $offerId)
{
$offer = DivingOffer::findOrFail($offerId);
$this->authorizeOffer($request, $offer);
$this->validateImage($request);
if ($offer->cover_image) {
Storage::disk('public')->delete($offer->cover_image);
}
$path = $request->file('image')->store("offers/{$offerId}/cover", 'public');
$offer->update(['cover_image' => $path]);
return response()->json([
'status' => true,
'message' => '封面已上傳',
'cover_image_url' => $offer->cover_image_url,
]);
}
public function deleteCover(Request $request, int $offerId)
{
$offer = DivingOffer::findOrFail($offerId);
$this->authorizeOffer($request, $offer);
if ($offer->cover_image) {
Storage::disk('public')->delete($offer->cover_image);
$offer->update(['cover_image' => null]);
}
return response()->json(['status' => true, 'message' => '封面已刪除']);
}
public function uploadImage(Request $request, int $offerId)
{
$offer = DivingOffer::findOrFail($offerId);
$this->authorizeOffer($request, $offer);
$this->validateImage($request);
if ($offer->courseImages()->count() >= 3) {
return response()->json(['status' => false, 'message' => '相簿最多 3 張圖片'], 422);
}
$path = $request->file('image')->store("offers/{$offerId}/gallery", 'public');
$sortOrder = ($offer->courseImages()->max('sort_order') ?? 0) + 1;
$image = CourseImage::create([
'diving_offer_id' => $offerId,
'image_path' => $path,
'sort_order' => $sortOrder,
]);
return response()->json([
'status' => true,
'message' => '圖片已上傳',
'data' => ['id' => $image->id, 'url' => $image->url, 'sort_order' => $image->sort_order],
], 201);
}
public function deleteImage(Request $request, int $imageId)
{
$image = CourseImage::with('divingOffer')->findOrFail($imageId);
if ($image->divingOffer->provider_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權刪除此圖片'], 403);
}
Storage::disk('public')->delete($image->image_path);
$image->delete();
return response()->json(['status' => true, 'message' => '圖片已刪除']);
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class DivingOfferController extends Controller
{
public function index(Request $request)
{
$perPage = min((int) $request->query('per_page', 12), 50);
$cacheKey = 'diving_offers_' . md5(serialize($request->all()));
$result = Cache::tags(['diving_offers'])->remember($cacheKey, 180, function () use ($request, $perPage) {
$query = DivingOffer::query();
if ($q = $request->query('q')) {
$query->where(function ($sub) use ($q) {
$sub->where('title', 'like', "%{$q}%")
->orWhere('location', 'like', "%{$q}%")
->orWhere('spot', 'like', "%{$q}%");
});
}
if ($region = $request->query('region')) {
$query->where('region', $region);
}
if ($tag = $request->query('tag')) {
$query->where('tag', 'like', "%{$tag}%");
}
$paginated = $query->paginate($perPage);
return [
'items' => collect($paginated->items())->map(fn($o) => $this->formatOffer($o, false))->values(),
'meta' => [
'total' => $paginated->total(),
'per_page' => $paginated->perPage(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
],
];
});
return response()->json([
'status' => true,
'data' => $result['items'],
'meta' => $result['meta'],
]);
}
public function show(int $id)
{
$offer = DivingOffer::with('courseImages')->find($id);
if (!$offer) {
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
}
return response()->json(['status' => true, 'data' => $this->formatOffer($offer, true)]);
}
private function formatOffer(DivingOffer $offer, bool $withImages): array
{
$data = array_merge($offer->toArray(), [
'cover_image_url' => $offer->cover_image_url,
]);
if ($withImages) {
$data['images'] = $offer->courseImages->map(fn($img) => [
'id' => $img->id,
'url' => $img->url,
'sort_order' => $img->sort_order,
])->values();
}
return $data;
}
}
@@ -0,0 +1,170 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\CourseSchedule;
use App\Notifications\BookingCreatedNotification;
use App\Notifications\BookingCancelledNotification;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class MemberBookingController extends Controller
{
public function index(Request $request)
{
$bookings = Booking::with(['schedule.divingOffer'])
->where('member_id', $request->user()->id)
->orderByDesc('created_at')
->get()
->map(fn($b) => $this->formatBooking($b));
return response()->json(['status' => true, 'data' => $bookings]);
}
public function show(Request $request, int $id)
{
$booking = Booking::with(['schedule.divingOffer'])->findOrFail($id);
if ($booking->member_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權查看此預約'], 403);
}
return response()->json(['status' => true, 'data' => $this->formatBooking($booking)]);
}
public function store(Request $request)
{
$data = $request->validate([
'schedule_id' => 'required|integer|exists:course_schedules,id',
'participants' => 'required|integer|min:1',
'notes' => 'nullable|string|max:500',
]);
$schedule = CourseSchedule::with('divingOffer')->findOrFail($data['schedule_id']);
// Layer 1:快速失敗
if ($schedule->status !== ScheduleStatus::Open) {
return response()->json(['status' => false, 'message' => '此時段不開放預約'], 422);
}
if ($data['participants'] > $schedule->remainingSpots()) {
return response()->json(['status' => false, 'message' => '人數超過剩餘名額'], 422);
}
$memberId = $request->user()->id;
try {
$booking = DB::transaction(function () use ($data, $schedule, $memberId) {
// Layer 2lockForUpdate 後二次驗證
$schedule = CourseSchedule::lockForUpdate()->find($schedule->id);
if ($data['participants'] > $schedule->remainingSpots()) {
throw new \RuntimeException('名額不足,請重新選擇');
}
// 重複預約檢查
$duplicate = Booking::where('member_id', $memberId)
->where('schedule_id', $schedule->id)
->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value])
->exists();
if ($duplicate) {
throw new \RuntimeException('您已預約此時段');
}
return Booking::create([
'schedule_id' => $schedule->id,
'member_id' => $memberId,
'participants' => $data['participants'],
'total_price' => $schedule->divingOffer->price * $data['participants'],
'status' => BookingStatus::Pending,
'notes' => $data['notes'] ?? null,
]);
});
} catch (\RuntimeException $e) {
return response()->json(['status' => false, 'message' => $e->getMessage()], 422);
}
try {
$booking->load('schedule.divingOffer.provider');
$provider = $booking->schedule->divingOffer->provider;
$provider->notify(new BookingCreatedNotification($booking));
} catch (\Throwable $e) {
Log::error('BookingCreatedNotification failed: ' . $e->getMessage());
}
return response()->json([
'status' => true,
'message' => '預約已送出,等待教練確認',
'data' => $this->formatBooking($booking->fresh(['schedule.divingOffer'])),
], 201);
}
public function destroy(Request $request, int $id)
{
$booking = Booking::with('schedule')->findOrFail($id);
if ($booking->member_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權操作此預約'], 403);
}
$canCancelFrom = [BookingStatus::Pending, BookingStatus::Confirmed];
if (!in_array($booking->status, $canCancelFrom)) {
return response()->json(['status' => false, 'message' => '此預約狀態無法取消'], 422);
}
// 24h 截止驗證
$schedule = $booking->schedule;
$courseStart = Carbon::parse($schedule->scheduled_date->toDateString() . ' ' . $schedule->start_time);
if (now()->diffInHours($courseStart, false) < 24) {
return response()->json(['status' => false, 'message' => '距課程開始不足 24 小時,無法取消,請聯繫教練'], 422);
}
DB::transaction(function () use ($booking, $schedule) {
$wasConfirmed = $booking->status === BookingStatus::Confirmed;
$booking->update(['status' => BookingStatus::MemberCancelled]);
if ($wasConfirmed) {
$schedule = $booking->schedule()->lockForUpdate()->first();
$schedule->decrement('current_participants', $booking->participants);
$schedule->refresh();
if ($schedule->current_participants < $schedule->max_participants
&& $schedule->status === ScheduleStatus::Full) {
$schedule->update(['status' => ScheduleStatus::Open]);
}
}
});
try {
$booking->load('schedule.divingOffer.provider');
$provider = $booking->schedule->divingOffer->provider;
$provider->notify(new BookingCancelledNotification($booking, cancelledBy: 'member'));
} catch (\Throwable $e) {
Log::error('BookingCancelledNotification(member) failed: ' . $e->getMessage());
}
return response()->json(['status' => true, 'message' => '預約已取消']);
}
private function formatBooking(Booking $b): array
{
$offer = $b->schedule?->divingOffer;
return [
'id' => $b->id,
'offer_id' => $offer?->id,
'offer_title' => $offer?->title,
'offer_location' => $offer?->location,
'offer_region' => $offer?->region,
'offer_price' => $offer?->price,
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
'start_time' => $b->schedule?->start_time,
'participants' => $b->participants,
'total_price' => $b->total_price,
'status' => $b->status->value,
'notes' => $b->notes,
'created_at' => $b->created_at?->toISOString(),
];
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
public function index(Request $request)
{
try {
$user = $request->user();
$unreadCount = $user->unreadNotifications()->count();
$notifications = $user->notifications()
->orderByDesc('created_at')
->paginate(20);
$items = $notifications->map(fn($n) => array_merge($n->data, [
'id' => $n->id,
'read_at' => $n->read_at?->toISOString(),
'created_at' => $n->created_at->toISOString(),
]));
return response()->json([
'status' => true,
'data' => $items,
'unread_count' => $unreadCount,
'meta' => [
'current_page' => $notifications->currentPage(),
'last_page' => $notifications->lastPage(),
'total' => $notifications->total(),
],
]);
} catch (\Throwable) {
return response()->json(['status' => true, 'data' => [], 'unread_count' => 0, 'meta' => ['current_page' => 1, 'last_page' => 1, 'total' => 0]]);
}
}
public function unreadCount(Request $request)
{
try {
return response()->json([
'status' => true,
'data' => ['count' => $request->user()->unreadNotifications()->count()],
]);
} catch (\Throwable) {
return response()->json(['status' => true, 'data' => ['count' => 0]]);
}
}
public function markRead(Request $request, string $id)
{
$notification = $request->user()->notifications()->findOrFail($id);
$notification->markAsRead();
return response()->json(['status' => true]);
}
public function markAllRead(Request $request)
{
$request->user()->unreadNotifications->markAsRead();
return response()->json(['status' => true]);
}
public function destroy(Request $request, string $id)
{
$notification = $request->user()->notifications()->findOrFail($id);
$notification->delete();
return response()->json(null, 204);
}
}
@@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Notifications\BookingCancelledNotification;
use App\Notifications\BookingCompletedNotification;
use App\Notifications\BookingConfirmedNotification;
use App\Notifications\BookingRejectedNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ProviderBookingController extends Controller
{
public function index(Request $request)
{
$provider = $request->user();
$bookings = Booking::with(['member', 'schedule.divingOffer'])
->whereHas('schedule', fn($q) => $q->where('provider_id', $provider->id))
->orderByDesc('created_at')
->get()
->map(fn($b) => $this->formatBooking($b));
return response()->json(['status' => true, 'data' => $bookings]);
}
public function confirm(Request $request, int $id)
{
$booking = Booking::with('schedule')->findOrFail($id);
$this->authorizeProvider($request, $booking);
if (!$booking->canTransitionTo(BookingStatus::Confirmed)) {
return response()->json(['status' => false, 'message' => '當前狀態無法確認'], 422);
}
try {
DB::transaction(function () use ($booking) {
$schedule = $booking->schedule()->lockForUpdate()->first();
$remaining = $schedule->max_participants - $schedule->current_participants;
if ($booking->participants > $remaining) {
throw new \RuntimeException('名額不足,無法確認此預約');
}
$booking->update(['status' => BookingStatus::Confirmed]);
$schedule->increment('current_participants', $booking->participants);
$schedule->refresh();
if ($schedule->current_participants >= $schedule->max_participants) {
$schedule->update(['status' => ScheduleStatus::Full]);
}
});
} catch (\RuntimeException $e) {
return response()->json(['status' => false, 'message' => $e->getMessage()], 422);
}
try {
$booking->load('member');
$booking->member->notify(new BookingConfirmedNotification($booking));
} catch (\Throwable $e) {
Log::error('BookingConfirmedNotification failed: ' . $e->getMessage());
}
return response()->json(['status' => true, 'message' => '預約已確認', 'data' => $this->formatBooking($booking->fresh(['member', 'schedule.divingOffer']))]);
}
public function reject(Request $request, int $id)
{
$booking = Booking::with('schedule')->findOrFail($id);
$this->authorizeProvider($request, $booking);
if (!$booking->canTransitionTo(BookingStatus::Rejected)) {
return response()->json(['status' => false, 'message' => '當前狀態無法拒絕'], 422);
}
$booking->update(['status' => BookingStatus::Rejected]);
try {
$booking->load('member');
$booking->member->notify(new BookingRejectedNotification($booking));
} catch (\Throwable $e) {
Log::error('BookingRejectedNotification failed: ' . $e->getMessage());
}
return response()->json(['status' => true, 'message' => '預約已拒絕']);
}
public function cancel(Request $request, int $id)
{
$booking = Booking::with('schedule')->findOrFail($id);
$this->authorizeProvider($request, $booking);
if (!$booking->canTransitionTo(BookingStatus::ProviderCancelled)) {
return response()->json(['status' => false, 'message' => '當前狀態無法取消'], 422);
}
DB::transaction(function () use ($booking) {
$schedule = $booking->schedule()->lockForUpdate()->first();
$booking->update(['status' => BookingStatus::ProviderCancelled]);
$schedule->decrement('current_participants', $booking->participants);
$schedule->refresh();
if ($schedule->current_participants < $schedule->max_participants
&& $schedule->status === ScheduleStatus::Full) {
$schedule->update(['status' => ScheduleStatus::Open]);
}
});
try {
$booking->load('member');
$booking->member->notify(new BookingCancelledNotification($booking, cancelledBy: 'provider'));
} catch (\Throwable $e) {
Log::error('BookingCancelledNotification(provider) failed: ' . $e->getMessage());
}
return response()->json(['status' => true, 'message' => '預約已取消']);
}
public function complete(Request $request, int $id)
{
$booking = Booking::with('schedule')->findOrFail($id);
$this->authorizeProvider($request, $booking);
if (!$booking->canTransitionTo(BookingStatus::Completed)) {
return response()->json(['status' => false, 'message' => '只有已確認的預約才能標記完成'], 422);
}
$booking->update(['status' => BookingStatus::Completed]);
try {
$booking->load('member');
$booking->member->notify(new BookingCompletedNotification($booking));
} catch (\Throwable $e) {
Log::error('BookingCompletedNotification failed: ' . $e->getMessage());
}
return response()->json(['status' => true, 'message' => '預約已標記為完成']);
}
private function authorizeProvider(Request $request, Booking $booking): void
{
if ($booking->schedule->provider_id !== $request->user()->id) {
abort(403, '無權操作此預約');
}
}
private function formatBooking(Booking $b): array
{
return [
'id' => $b->id,
'member_name' => $b->member?->name,
'member_email' => $b->member?->email,
'offer_title' => $b->schedule?->divingOffer?->title,
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
'start_time' => $b->schedule?->start_time,
'participants' => $b->participants,
'total_price' => $b->total_price,
'status' => $b->status->value,
'notes' => $b->notes,
'created_at' => $b->created_at?->toISOString(),
];
}
}
@@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class ProviderOfferController extends Controller
{
public function index()
{
$offers = DivingOffer::where('provider_id', auth()->id())
->paginate(12);
return response()->json([
'status' => true,
'data' => $offers->items(),
'meta' => [
'total' => $offers->total(),
'per_page' => $offers->perPage(),
'current_page' => $offers->currentPage(),
'last_page' => $offers->lastPage(),
],
]);
}
public function show(int $id)
{
$offer = DivingOffer::find($id);
if (!$offer) {
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
}
if ($offer->provider_id !== auth()->id()) {
return response()->json(['status' => false, 'message' => '無權限查看此課程'], 403);
}
return response()->json(['status' => true, 'data' => $offer]);
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'location' => 'required|string|max:255',
'spot' => 'nullable|string|max:255',
'price' => 'required|integer|min:0',
'region' => 'required|string|max:100',
'tag' => 'nullable|string|max:100',
'badges' => 'nullable|array',
'badges.*' => 'string|max:50',
'description' => 'nullable|string',
]);
$validated['provider_id'] = auth()->id();
$validated['rating'] = 0;
$validated['reviews'] = 0;
$offer = DivingOffer::create($validated);
Cache::tags(['diving_offers'])->flush();
return response()->json(['status' => true, 'data' => $offer], 201);
}
public function update(Request $request, int $id)
{
$offer = DivingOffer::find($id);
if (!$offer) {
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
}
if ($offer->provider_id !== auth()->id()) {
return response()->json(['status' => false, 'message' => '無權限修改此課程'], 403);
}
$validated = $request->validate([
'title' => 'nullable|string|max:255',
'location' => 'nullable|string|max:255',
'spot' => 'nullable|string|max:255',
'price' => 'nullable|integer|min:0',
'region' => 'nullable|string|max:100',
'tag' => 'nullable|string|max:100',
'badges' => 'nullable|array',
'badges.*' => 'string|max:50',
'description' => 'nullable|string',
]);
$offer->fill($validated)->save();
Cache::tags(['diving_offers'])->flush();
return response()->json(['status' => true, 'data' => $offer]);
}
public function destroy(int $id)
{
$offer = DivingOffer::find($id);
if (!$offer) {
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
}
if ($offer->provider_id !== auth()->id()) {
return response()->json(['status' => false, 'message' => '無權限刪除此課程'], 403);
}
$offer->delete();
Cache::tags(['diving_offers'])->flush();
return response()->json(['status' => true, 'message' => '課程已刪除']);
}
}
@@ -0,0 +1,257 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\DivingOffer;
use App\Models\Review;
use App\Models\ReviewEdit;
use App\Models\ReviewVote;
use App\Notifications\ReviewReceivedNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ReviewController extends Controller
{
// ── 公開列表 ──────────────────────────────────────────────
public function publicList(Request $request, int $offerId)
{
$offer = DivingOffer::findOrFail($offerId);
$user = $request->user();
$perPage = min((int) $request->query('per_page', 20), 50);
$sort = $request->query('sort', 'helpful');
$query = Review::with('votes')->where('diving_offer_id', $offer->id);
match ($sort) {
'rating' => $query->orderByDesc('rating')->orderByDesc('created_at'),
'newest' => $query->orderByDesc('created_at'),
default => $query->orderByDesc('helpful_count')->orderByDesc('created_at'),
};
$paginator = $query->paginate($perPage);
$reviews = $paginator->getCollection();
$memberId = $user?->id;
$distribution = Cache::remember("offer_review_distribution_{$offerId}", 600, function () use ($offerId) {
$distRaw = Review::where('diving_offer_id', $offerId)
->selectRaw('rating, COUNT(*) as cnt')
->groupBy('rating')
->pluck('cnt', 'rating');
return collect([1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0])->merge($distRaw);
});
$total = $paginator->total();
$average = $total > 0 ? round(
Review::where('diving_offer_id', $offerId)->avg('rating'), 1
) : 0;
$formatted = $reviews->map(function ($r) use ($user, $memberId) {
$item = [
'id' => $r->id,
'reviewer_name' => '匿名潛水者',
'rating' => $r->rating,
'comment' => $r->comment,
'helpful_count' => $r->helpful_count,
'is_edited' => $r->is_edited,
'created_at' => $r->created_at?->toISOString(),
'has_voted' => $memberId
? $r->votes->contains('member_id', $memberId)
: false,
];
if ($user) {
$item['is_mine'] = $r->member_id === $memberId;
}
return $item;
});
return response()->json([
'status' => true,
'data' => [
'summary' => [
'average' => $average,
'total' => $total,
'distribution' => $distribution,
],
'reviews' => $formatted,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
],
]);
}
// ── Member CRUD ───────────────────────────────────────────
public function store(Request $request)
{
$data = $request->validate([
'diving_offer_id' => 'required|integer|exists:diving_offers,id',
'rating' => 'required|integer|min:1|max:5',
'comment' => 'required|string|min:1',
]);
$memberId = $request->user()->id;
$offerId = $data['diving_offer_id'];
// 資格驗證:有 completed booking
$eligible = Booking::where('member_id', $memberId)
->whereHas('schedule', fn($q) => $q->where('diving_offer_id', $offerId))
->where('status', BookingStatus::Completed->value)
->exists();
if (!$eligible) {
return response()->json(['status' => false, 'message' => '須完成此課程後才能評價'], 403);
}
// 重複評價檢查
if (Review::where('member_id', $memberId)->where('diving_offer_id', $offerId)->exists()) {
return response()->json(['status' => false, 'message' => '已評價,如需修改請使用編輯功能'], 422);
}
$review = DB::transaction(function () use ($data, $memberId, $offerId) {
$review = Review::create([
'diving_offer_id' => $offerId,
'member_id' => $memberId,
'rating' => $data['rating'],
'comment' => $data['comment'],
]);
$this->recalculateOfferRating($offerId);
return $review;
});
Cache::forget("offer_review_distribution_{$offerId}");
try {
$offer = DivingOffer::with('provider')->findOrFail($offerId);
$provider = $offer->provider;
if ($provider) {
$provider->notify(new ReviewReceivedNotification($review));
}
} catch (\Throwable $e) {
Log::error('ReviewReceivedNotification failed: ' . $e->getMessage());
}
return response()->json(['status' => true, 'message' => '評價已送出', 'data' => $this->formatReview($review)], 201);
}
public function update(Request $request, int $id)
{
$review = Review::findOrFail($id);
if ($review->member_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權修改此評價'], 403);
}
$data = $request->validate([
'rating' => 'sometimes|integer|min:1|max:5',
'comment' => 'sometimes|string|min:1',
]);
$offerId = $review->diving_offer_id;
DB::transaction(function () use ($review, $data) {
ReviewEdit::updateOrCreate(
['review_id' => $review->id],
['old_rating' => $review->rating, 'old_comment' => $review->comment, 'edited_at' => now()]
);
$review->update(array_merge($data, ['is_edited' => true]));
$this->recalculateOfferRating($review->diving_offer_id);
});
Cache::forget("offer_review_distribution_{$offerId}");
return response()->json(['status' => true, 'message' => '評價已更新', 'data' => $this->formatReview($review->fresh())]);
}
public function destroy(Request $request, int $id)
{
$review = Review::findOrFail($id);
if ($review->member_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權刪除此評價'], 403);
}
$offerId = $review->diving_offer_id;
DB::transaction(function () use ($review, $offerId) {
$review->delete();
$this->recalculateOfferRating($offerId);
});
Cache::forget("offer_review_distribution_{$offerId}");
return response()->json(['status' => true, 'message' => '評價已刪除']);
}
// ── 有幫助投票 ────────────────────────────────────────────
public function toggleHelpful(Request $request, int $id)
{
if (!$request->user()->isMember()) {
return response()->json(['status' => false, 'message' => '只有會員可以投票'], 403);
}
$review = Review::findOrFail($id);
$memberId = $request->user()->id;
if ($review->member_id === $memberId) {
return response()->json(['status' => false, 'message' => '不可對自己的評價投票'], 422);
}
DB::transaction(function () use ($review, $memberId) {
$vote = ReviewVote::where('review_id', $review->id)
->where('member_id', $memberId)
->first();
if ($vote) {
$vote->delete();
DB::table('reviews')
->where('id', $review->id)
->where('helpful_count', '>', 0)
->decrement('helpful_count');
} else {
ReviewVote::create(['review_id' => $review->id, 'member_id' => $memberId, 'created_at' => now()]);
$review->increment('helpful_count');
}
});
$review->refresh();
$hasVoted = ReviewVote::where('review_id', $review->id)->where('member_id', $memberId)->exists();
return response()->json(['status' => true, 'data' => ['helpful_count' => $review->helpful_count, 'has_voted' => $hasVoted]]);
}
// ── 私有方法 ──────────────────────────────────────────────
private function recalculateOfferRating(int $offerId): void
{
$stats = Review::where('diving_offer_id', $offerId)
->selectRaw('ROUND(AVG(rating), 1) as avg_rating, COUNT(*) as total')
->first();
DivingOffer::where('id', $offerId)->update([
'rating' => $stats->total > 0 ? $stats->avg_rating : 0,
'reviews' => $stats->total,
]);
}
private function formatReview(Review $r): array
{
return [
'id' => $r->id,
'reviewer_name' => '匿名潛水者',
'rating' => $r->rating,
'comment' => $r->comment,
'helpful_count' => $r->helpful_count,
'is_edited' => $r->is_edited,
'created_at' => $r->created_at?->toISOString(),
];
}
}
@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\API;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Http\Controllers\Controller;
use App\Models\CourseSchedule;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ScheduleController extends Controller
{
public function index(Request $request)
{
$provider = $request->user();
$schedules = CourseSchedule::with('divingOffer')
->where('provider_id', $provider->id)
->orderBy('scheduled_date')
->orderBy('start_time')
->get()
->map(fn($s) => $this->formatSchedule($s));
return response()->json(['status' => true, 'data' => $schedules]);
}
public function store(Request $request)
{
$data = $request->validate([
'diving_offer_id' => 'required|integer|exists:diving_offers,id',
'scheduled_date' => 'required|date|after_or_equal:today',
'start_time' => 'required|date_format:H:i',
'max_participants' => 'required|integer|min:1',
]);
$offer = DivingOffer::findOrFail($data['diving_offer_id']);
if ($offer->provider_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權操作此課程'], 403);
}
$schedule = CourseSchedule::create([
'diving_offer_id' => $data['diving_offer_id'],
'provider_id' => $request->user()->id,
'scheduled_date' => $data['scheduled_date'],
'start_time' => $data['start_time'],
'max_participants' => $data['max_participants'],
'current_participants'=> 0,
'status' => ScheduleStatus::Open,
]);
return response()->json(['status' => true, 'data' => $this->formatSchedule($schedule)], 201);
}
public function update(Request $request, int $id)
{
$schedule = CourseSchedule::findOrFail($id);
if ($schedule->provider_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權操作此時段'], 403);
}
$data = $request->validate([
'start_time' => 'sometimes|date_format:H:i',
'max_participants' => 'sometimes|integer|min:1',
]);
if (isset($data['max_participants']) && $data['max_participants'] < $schedule->current_participants) {
return response()->json([
'status' => false,
'message' => '人數上限不可低於目前已確認人數(' . $schedule->current_participants . '',
], 422);
}
$schedule->update($data);
return response()->json(['status' => true, 'data' => $this->formatSchedule($schedule->fresh())]);
}
public function destroy(Request $request, int $id)
{
$schedule = CourseSchedule::findOrFail($id);
if ($schedule->provider_id !== $request->user()->id) {
return response()->json(['status' => false, 'message' => '無權操作此時段'], 403);
}
DB::transaction(function () use ($schedule) {
$schedule->update(['status' => ScheduleStatus::Cancelled]);
$schedule->bookings()
->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value])
->update(['status' => BookingStatus::ProviderCancelled->value]);
});
return response()->json(['status' => true, 'message' => '時段已取消']);
}
public function publicList(int $offerId)
{
$offer = DivingOffer::findOrFail($offerId);
$schedules = CourseSchedule::where('diving_offer_id', $offer->id)
->where('status', ScheduleStatus::Open->value)
->whereDate('scheduled_date', '>=', now()->toDateString())
->orderBy('scheduled_date')
->orderBy('start_time')
->get()
->map(fn($s) => [
'id' => $s->id,
'scheduled_date' => $s->scheduled_date->toDateString(),
'start_time' => $s->start_time,
'max_participants' => $s->max_participants,
'remaining_spots' => $s->remainingSpots(),
'status' => $s->status->value,
]);
return response()->json(['status' => true, 'data' => $schedules]);
}
private function formatSchedule(CourseSchedule $s): array
{
return [
'id' => $s->id,
'diving_offer_id' => $s->diving_offer_id,
'offer_title' => $s->divingOffer?->title,
'scheduled_date' => $s->scheduled_date?->toDateString(),
'start_time' => $s->start_time,
'max_participants' => $s->max_participants,
'current_participants' => $s->current_participants,
'remaining_spots' => $s->remainingSpots(),
'status' => $s->status->value,
];
}
}
@@ -106,23 +106,9 @@ class SocialAuthController extends Controller
// 生成 Sanctum token // 生成 Sanctum token
$token = $user->createToken('google-auth')->plainTextToken; $token = $user->createToken('google-auth')->plainTextToken;
// 載入會員資料 return redirect(env('FRONTEND_URL') . '/auth/callback?token=' . $token);
$user->load('memberProfile');
return response()->json([
'status' => true,
'message' => 'Google 登入成功',
'data' => [
'user' => $user,
'token' => $token,
'token_type' => 'Bearer',
]
]);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return redirect(env('FRONTEND_URL') . '/login?error=oauth_failed');
'status' => false,
'message' => 'Google 登入失敗:' . $e->getMessage()
], 500);
} }
} }
} }
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->user() || !$request->user()->isAdmin()) {
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
}
return $next($request);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use App\Enums\BookingStatus;
use Illuminate\Database\Eloquent\Model;
class Booking extends Model
{
protected $fillable = [
'schedule_id',
'member_id',
'participants',
'total_price',
'status',
'notes',
];
protected $casts = [
'participants' => 'integer',
'total_price' => 'integer',
'status' => BookingStatus::class,
];
const VALID_TRANSITIONS = [
'pending' => ['confirmed', 'rejected', 'expired', 'member_cancelled'],
'confirmed' => ['completed', 'member_cancelled', 'provider_cancelled'],
'completed' => [],
'rejected' => [],
'expired' => [],
'member_cancelled' => [],
'provider_cancelled' => [],
];
public function canTransitionTo(BookingStatus $newStatus): bool
{
$current = $this->status->value;
$allowed = self::VALID_TRANSITIONS[$current] ?? [];
return in_array($newStatus->value, $allowed);
}
public function schedule()
{
return $this->belongsTo(CourseSchedule::class, 'schedule_id');
}
public function member()
{
return $this->belongsTo(User::class, 'member_id');
}
}
+1 -17
View File
@@ -4,23 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** // 該模型已棄用,請使用ProviderProfile模型
* @OA\Schema(
* schema="CoachProfile",
* title="教練個人資料",
* description="教練的詳細個人資料",
* @OA\Property(property="id", type="integer", format="int64", example=1, description="資料ID"),
* @OA\Property(property="user_id", type="integer", format="int64", example=1, description="關聯的使用者ID"),
* @OA\Property(property="bio", type="string", example="專業潛水教練,擁有10年教學經驗", description="個人簡介"),
* @OA\Property(property="expertise", type="string", example="自由潛水,水肺潛水", description="專長領域"),
* @OA\Property(property="certification", type="string", example="PADI專業潛水教練", description="證照資訊"),
* @OA\Property(property="experience", type="string", example="10年教學經驗,帶領超過500名學員", description="教學經驗"),
* @OA\Property(property="rating", type="number", format="float", example=4.8, description="評分"),
* @OA\Property(property="availability", type="string", example="週一至週五 09:00-18:00", description="可授課時間"),
* @OA\Property(property="created_at", type="string", format="date-time", example="2023-01-01T00:00:00.000000Z", description="創建時間"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2023-01-01T00:00:00.000000Z", description="更新時間")
* )
*/
class CoachProfile extends Model class CoachProfile extends Model
{ {
/** /**
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class CourseImage extends Model
{
public $timestamps = false;
const CREATED_AT = 'created_at';
protected $fillable = [
'diving_offer_id',
'image_path',
'sort_order',
];
protected $casts = [
'sort_order' => 'integer',
'created_at' => 'datetime',
];
public function divingOffer()
{
return $this->belongsTo(DivingOffer::class);
}
public function getUrlAttribute(): string
{
return Storage::disk('public')->url($this->image_path);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use App\Enums\ScheduleStatus;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class CourseSchedule extends Model
{
protected $fillable = [
'diving_offer_id',
'provider_id',
'scheduled_date',
'start_time',
'max_participants',
'current_participants',
'status',
];
protected $casts = [
'scheduled_date' => 'date',
'max_participants' => 'integer',
'current_participants' => 'integer',
'status' => ScheduleStatus::class,
];
public function divingOffer()
{
return $this->belongsTo(DivingOffer::class);
}
public function provider()
{
return $this->belongsTo(User::class, 'provider_id');
}
public function bookings()
{
return $this->hasMany(Booking::class, 'schedule_id');
}
public function remainingSpots(): int
{
return max(0, $this->max_participants - $this->current_participants);
}
protected function startTime(): Attribute
{
return Attribute::make(
get: fn($value) => $value ? substr($value, 0, 5) : $value,
);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class DivingOffer extends Model
{
public $timestamps = false;
protected $table = 'diving_offers';
protected $fillable = [
'provider_id',
'title',
'location',
'spot',
'rating',
'reviews',
'price',
'badges',
'description',
'tag',
'region',
'cover_image',
];
protected $casts = [
'badges' => 'array',
'rating' => 'float',
'price' => 'integer',
'reviews'=> 'integer',
];
protected static function booted(): void
{
static::deleting(function ($offer) {
Storage::disk('public')->deleteDirectory("offers/{$offer->id}");
});
}
public function getCoverImageUrlAttribute(): ?string
{
return $this->cover_image
? Storage::disk('public')->url($this->cover_image)
: null;
}
public function provider(): BelongsTo
{
return $this->belongsTo(User::class, 'provider_id');
}
public function schedules()
{
return $this->hasMany(CourseSchedule::class, 'diving_offer_id');
}
public function courseImages()
{
return $this->hasMany(CourseImage::class)->orderBy('sort_order');
}
public function reviews()
{
return $this->hasMany(Review::class);
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ use Illuminate\Database\Eloquent\Model;
/** /**
* @OA\Schema( * @OA\Schema(
* schema="ProviderProfile", * schema="ProviderProfile",
* title="潛水業者資料", * title="服務提供者資料",
* description="潛水業者的詳細資料", * description="服務提供者的詳細資料",
* @OA\Property(property="id", type="integer", format="int64", example=1, description="資料ID"), * @OA\Property(property="id", type="integer", format="int64", example=1, description="資料ID"),
* @OA\Property(property="user_id", type="integer", format="int64", example=1, description="關聯的使用者ID"), * @OA\Property(property="user_id", type="integer", format="int64", example=1, description="關聯的使用者ID"),
* @OA\Property(property="business_name", type="string", example="藍海潛水中心", description="業者名稱"), * @OA\Property(property="business_name", type="string", example="藍海潛水中心", description="業者名稱"),
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Review extends Model
{
protected $fillable = [
'diving_offer_id',
'member_id',
'rating',
'comment',
'helpful_count',
'is_edited',
];
protected $casts = [
'rating' => 'integer',
'helpful_count' => 'integer',
'is_edited' => 'boolean',
];
public function divingOffer()
{
return $this->belongsTo(DivingOffer::class);
}
public function member()
{
return $this->belongsTo(User::class, 'member_id');
}
public function edit()
{
return $this->hasOne(ReviewEdit::class);
}
public function votes()
{
return $this->hasMany(ReviewVote::class);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ReviewEdit extends Model
{
public $timestamps = false;
protected $fillable = [
'review_id',
'old_rating',
'old_comment',
'edited_at',
];
protected $casts = [
'old_rating' => 'integer',
'edited_at' => 'datetime',
];
public function review()
{
return $this->belongsTo(Review::class);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ReviewVote extends Model
{
public $timestamps = false;
protected $fillable = [
'review_id',
'member_id',
'created_at',
];
public function review()
{
return $this->belongsTo(Review::class);
}
public function member()
{
return $this->belongsTo(User::class, 'member_id');
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingCancelledNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(
public readonly Booking $booking,
public readonly string $cancelledBy,
) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
if ($this->cancelledBy === 'member') {
return [
'type' => 'booking_cancelled',
'title' => '學員取消了預約',
'body' => "學員已取消《{$offer->title}》的預約(時段:{$date}",
'action_url' => config('app.frontend_url') . '/coach/bookings',
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
return [
'type' => 'booking_cancelled',
'title' => '教練取消了你的預約',
'body' => "教練已取消你的《{$offer->title}》預約(時段:{$date}),如有疑問請聯繫教練",
'action_url' => config('app.frontend_url') . '/my-bookings',
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
public function toMail(object $notifiable): MailMessage
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
if ($this->cancelledBy === 'member') {
return (new MailMessage)
->subject('預約已取消 — CFDivePlatform')
->greeting('通知')
->line("學員已取消《{$offer->title}》的預約(時段:{$date})。")
->action('查看所有預約', config('app.frontend_url') . '/coach/bookings')
->salutation('CFDivePlatform 團隊');
}
return (new MailMessage)
->subject('你的預約已取消 — CFDivePlatform')
->greeting('通知')
->line("教練已取消你的《{$offer->title}》預約(時段:{$date})。如有疑問,請聯繫課程教練。")
->action('查看預約', config('app.frontend_url') . '/my-bookings')
->salutation('CFDivePlatform 團隊');
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingCompletedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Booking $booking) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->booking->schedule->divingOffer;
return [
'type' => 'booking_completed',
'title' => '課程已完成,歡迎留下評價',
'body' => "你的《{$offer->title}》課程已完成,歡迎分享你的學習心得!",
'action_url' => config('app.frontend_url') . '/courses/' . $offer->id,
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
public function toMail(object $notifiable): MailMessage
{
$offer = $this->booking->schedule->divingOffer;
$url = config('app.frontend_url') . '/courses/' . $offer->id;
return (new MailMessage)
->subject('課程完成,歡迎留下評價 — CFDivePlatform')
->greeting('恭喜完成課程!')
->line("你的《{$offer->title}》課程已完成。")
->action('前往評價', $url)
->line('你的評價對其他學員非常有幫助,感謝你的分享!')
->salutation('CFDivePlatform 團隊');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingConfirmedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Booking $booking) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
return [
'type' => 'booking_confirmed',
'title' => '預約已確認',
'body' => "你的《{$offer->title}》課程預約已由教練確認(時段:{$date}",
'action_url' => config('app.frontend_url') . '/my-bookings',
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
public function toMail(object $notifiable): MailMessage
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
$url = config('app.frontend_url') . '/my-bookings';
return (new MailMessage)
->subject('你的預約已確認 — CFDivePlatform')
->greeting('好消息!')
->line("你的《{$offer->title}》課程預約已由教練確認(時段:{$date})。")
->action('查看預約', $url)
->line('請準時出席,如需取消請至少提前 24 小時操作。')
->salutation('CFDivePlatform 團隊');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingCreatedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Booking $booking) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
return [
'type' => 'booking_created',
'title' => '你有新的預約申請',
'body' => "學員申請了《{$offer->title}》的預約(時段:{$date}",
'action_url' => config('app.frontend_url') . '/coach/bookings',
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
public function toMail(object $notifiable): MailMessage
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
$url = config('app.frontend_url') . '/coach/bookings';
return (new MailMessage)
->subject('你有新的預約申請 — CFDivePlatform')
->greeting('你好!')
->line("學員申請了《{$offer->title}》的預約(時段:{$date})。")
->action('查看預約', $url)
->line('請盡快確認或拒絕此預約申請。')
->salutation('CFDivePlatform 團隊');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BookingRejectedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Booking $booking) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
return [
'type' => 'booking_rejected',
'title' => '預約申請未通過',
'body' => "你的《{$offer->title}》預約申請(時段:{$date})未通過,請選擇其他時段",
'action_url' => config('app.frontend_url') . '/my-bookings',
'related_id' => $this->booking->id,
'related_type' => 'booking',
];
}
public function toMail(object $notifiable): MailMessage
{
$offer = $this->booking->schedule->divingOffer;
$date = $this->booking->schedule->scheduled_date->toDateString();
$url = config('app.frontend_url') . '/courses';
return (new MailMessage)
->subject('你的預約申請未通過 — CFDivePlatform')
->greeting('通知')
->line("很抱歉,你的《{$offer->title}》預約申請(時段:{$date})未通過審核。")
->action('瀏覽其他課程', $url)
->line('如有疑問,請聯繫課程教練。')
->salutation('CFDivePlatform 團隊');
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Notifications;
use App\Models\Review;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class ReviewReceivedNotification extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public readonly Review $review) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
$offer = $this->review->divingOffer;
return [
'type' => 'review_received',
'title' => '你收到了一則新評價',
'body' => "{$offer->title}》收到了 {$this->review->rating} 星評價",
'action_url' => config('app.frontend_url') . '/coach/reviews',
'related_id' => $this->review->id,
'related_type' => 'review',
];
}
}
+3 -1
View File
@@ -12,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
// $middleware->alias([
'admin' => \App\Http\Middleware\EnsureAdmin::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {
// //
+2 -1
View File
@@ -10,7 +10,8 @@
"laravel/framework": "^11.0", "laravel/framework": "^11.0",
"laravel/sanctum": "^4.1", "laravel/sanctum": "^4.1",
"laravel/socialite": "^5.20", "laravel/socialite": "^5.20",
"laravel/tinker": "^2.9" "laravel/tinker": "^2.9",
"predis/predis": "^3.4"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
Generated
+67 -4
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "cd7975455a8ac2a316ac819eaed700e8", "content-hash": "c7d653b10f8ee748e5662242029651ce",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -3242,6 +3242,69 @@
], ],
"time": "2024-12-14T21:12:59+00:00" "time": "2024-12-14T21:12:59+00:00"
}, },
{
"name": "predis/predis",
"version": "v3.4.2",
"source": {
"type": "git",
"url": "https://github.com/predis/predis.git",
"reference": "2033429520d8997a7815a2485f56abe6d2d0e075"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/predis/predis/zipball/2033429520d8997a7815a2485f56abe6d2d0e075",
"reference": "2033429520d8997a7815a2485f56abe6d2d0e075",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0",
"psr/http-message": "^1.0|^2.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.3",
"phpstan/phpstan": "^1.9",
"phpunit/phpcov": "^6.0 || ^8.0",
"phpunit/phpunit": "^8.0 || ~9.4.4"
},
"suggest": {
"ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
},
"type": "library",
"autoload": {
"psr-4": {
"Predis\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Till Krüss",
"homepage": "https://till.im",
"role": "Maintainer"
}
],
"description": "A flexible and feature-complete Redis/Valkey client for PHP.",
"homepage": "http://github.com/predis/predis",
"keywords": [
"nosql",
"predis",
"redis"
],
"support": {
"issues": "https://github.com/predis/predis/issues",
"source": "https://github.com/predis/predis/tree/v3.4.2"
},
"funding": [
{
"url": "https://github.com/sponsors/tillkruss",
"type": "github"
}
],
"time": "2026-03-09T20:33:04+00:00"
},
{ {
"name": "psr/cache", "name": "psr/cache",
"version": "3.0.0", "version": "3.0.0",
@@ -9166,12 +9229,12 @@
], ],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": [], "stability-flags": {},
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^8.2" "php": "^8.2"
}, },
"platform-dev": [], "platform-dev": {},
"plugin-api-version": "2.3.0" "plugin-api-version": "2.6.0"
} }
+2
View File
@@ -15,6 +15,8 @@ return [
'name' => env('APP_NAME', 'Laravel'), 'name' => env('APP_NAME', 'Laravel'),
'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Environment | Application Environment
+38
View File
@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cross-Origin Resource Sharing (CORS) Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your settings for cross-origin resource sharing
| or "CORS". This determines what cross-origin operations may execute
| in web browsers. You are free to adjust these settings as needed.
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
*/
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_origins' => [
env('FRONTEND_URL', 'http://localhost:5173'),
'http://127.0.0.1:5173',
'http://localhost:5173',
],
'allowed_origins_patterns' => [],
'allowed_headers' => ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => false,
];
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sessions');
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->unsignedBigInteger('provider_id')->nullable()->after('id');
$table->foreign('provider_id')->references('id')->on('users')->onDelete('set null');
});
}
public function down(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->dropForeign(['provider_id']);
$table->dropColumn('provider_id');
});
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member', 'provider') NOT NULL DEFAULT 'member'");
}
public function down(): void
{
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member') NOT NULL DEFAULT 'member'");
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->string('spot')->nullable()->change();
});
}
public function down(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->string('spot')->nullable(false)->change();
});
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('course_schedules', function (Blueprint $table) {
$table->id();
$table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
$table->foreignId('provider_id')->constrained('users')->cascadeOnDelete();
$table->date('scheduled_date');
$table->time('start_time');
$table->unsignedInteger('max_participants');
$table->unsignedInteger('current_participants')->default(0);
$table->string('status')->default('open');
$table->timestamps();
$table->index(['diving_offer_id', 'status', 'scheduled_date'], 'idx_offer_status_date');
$table->index('provider_id', 'idx_provider_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('course_schedules');
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bookings', function (Blueprint $table) {
$table->id();
$table->foreignId('schedule_id')->constrained('course_schedules')->cascadeOnDelete();
$table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
$table->unsignedInteger('participants')->default(1);
$table->unsignedInteger('total_price');
$table->string('status')->default('pending');
$table->text('notes')->nullable();
$table->timestamps();
$table->index(['member_id', 'status'], 'idx_member_status');
$table->index(['schedule_id', 'status'], 'idx_schedule_status');
$table->index(['status', 'created_at'], 'idx_status_created_at');
$table->index(['status', 'schedule_id'], 'idx_status_sched');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bookings');
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('reviews', function (Blueprint $table) {
$table->id();
$table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
$table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
$table->tinyInteger('rating')->unsigned();
$table->text('comment');
$table->unsignedInteger('helpful_count')->default(0);
$table->boolean('is_edited')->default(false);
$table->timestamps();
$table->unique(['member_id', 'diving_offer_id']);
$table->index(['diving_offer_id', 'helpful_count'], 'idx_reviews_helpful');
$table->index(['diving_offer_id', 'rating'], 'idx_reviews_rating');
$table->index(['diving_offer_id', 'created_at'], 'idx_reviews_newest');
$table->index(['member_id', 'diving_offer_id'], 'idx_reviews_member_offer');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('reviews');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('review_edits', function (Blueprint $table) {
$table->id();
$table->foreignId('review_id')->unique()->constrained('reviews')->cascadeOnDelete();
$table->tinyInteger('old_rating')->unsigned();
$table->text('old_comment');
$table->timestamp('edited_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('review_edits');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('review_votes', function (Blueprint $table) {
$table->id();
$table->foreignId('review_id')->constrained('reviews')->cascadeOnDelete();
$table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->unique(['review_id', 'member_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('review_votes');
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->string('cover_image', 500)->nullable()->after('description');
});
}
public function down(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->dropColumn('cover_image');
});
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('course_images', function (Blueprint $table) {
$table->id();
$table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
$table->string('image_path', 500);
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamp('created_at')->useCurrent();
$table->index(['diving_offer_id', 'sort_order'], 'idx_course_images_offer_sort');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('course_images');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('notifications');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('failed_jobs');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('notifications', function (Blueprint $table) {
$table->index(['notifiable_type', 'notifiable_id', 'read_at'], 'notifications_notifiable_read_at_index');
});
Schema::table('diving_offers', function (Blueprint $table) {
$table->index('provider_id', 'diving_offers_provider_id_index');
});
}
public function down(): void
{
Schema::table('notifications', function (Blueprint $table) {
$table->dropIndex('notifications_notifiable_read_at_index');
});
Schema::table('diving_offers', function (Blueprint $table) {
$table->dropIndex('diving_offers_provider_id_index');
});
}
};
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace Database\Seeders;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Models\AdminProfile;
use App\Models\Booking;
use App\Models\CourseSchedule;
use App\Models\DivingOffer;
use App\Models\MemberProfile;
use App\Models\ProviderProfile;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DevelopmentSeeder extends Seeder
{
public function run(): void
{
// Admin
$admin = User::firstOrCreate(['email' => 'admin@cfdive.com'], [
'name' => '平台管理員', 'password' => Hash::make('password123'),
'role' => 'admin', 'is_active' => true,
]);
AdminProfile::firstOrCreate(['user_id' => $admin->id], ['department' => '營運']);
// Coach
$coach = User::firstOrCreate(['email' => 'coach@cfdive.com'], [
'name' => '蔡教練', 'password' => Hash::make('password123'),
'role' => 'provider', 'is_active' => true,
]);
ProviderProfile::firstOrCreate(['user_id' => $coach->id], [
'business_name' => '藍海潛水工作室',
'description' => '專業 PADI 認證教練,10 年教學經驗',
'contact_phone' => '0912345678',
'contact_email' => 'coach@cfdive.com',
'is_verified' => true,
]);
// Member
$member = User::firstOrCreate(['email' => 'member@cfdive.com'], [
'name' => '測試會員', 'password' => Hash::make('password123'),
'role' => 'member', 'is_active' => true,
]);
MemberProfile::firstOrCreate(['user_id' => $member->id], ['gender' => 'male']);
// Offers
$offer = DivingOffer::firstOrCreate(
['title' => '潛入海底 — 入門體驗', 'provider_id' => $coach->id],
[
'location' => '墾丁', 'spot' => '南灣', 'price' => 6000,
'region' => '南部', 'tag' => '初學者',
'badges' => ['PADI認證', '含裝備'],
'description' => '適合零基礎的水肺潛水入門課程,由專業教練全程陪同。',
'rating' => 0, 'reviews' => 0,
]
);
DivingOffer::firstOrCreate(
['title' => '進階深潛探索', 'provider_id' => $coach->id],
[
'location' => '小琉球', 'spot' => '美人洞', 'price' => 9800,
'region' => '南部', 'tag' => '進階',
'badges' => ['AOW認證', '含住宿'],
'description' => '探索 30 米深海,適合已有 OW 認證的潛水愛好者。',
'rating' => 0, 'reviews' => 0,
]
);
// 未來時段(開放預約)
$futureSchedule = CourseSchedule::firstOrCreate(
['diving_offer_id' => $offer->id, 'scheduled_date' => now()->addDays(14)->toDateString()],
[
'provider_id' => $coach->id, 'start_time' => '09:00',
'max_participants' => 5, 'current_participants' => 0,
'status' => ScheduleStatus::Open,
]
);
// 過去時段(供測試 completed booking
$pastSchedule = CourseSchedule::firstOrCreate(
['diving_offer_id' => $offer->id, 'scheduled_date' => now()->subDays(7)->toDateString()],
[
'provider_id' => $coach->id, 'start_time' => '09:00',
'max_participants' => 5, 'current_participants' => 1,
'status' => ScheduleStatus::Open,
]
);
// Pending booking(未來)
Booking::firstOrCreate(
['schedule_id' => $futureSchedule->id, 'member_id' => $member->id],
['participants' => 1, 'total_price' => $offer->price, 'status' => BookingStatus::Pending]
);
// Completed booking(可評價)
Booking::firstOrCreate(
['schedule_id' => $pastSchedule->id, 'member_id' => $member->id],
['participants' => 1, 'total_price' => $offer->price, 'status' => BookingStatus::Completed]
);
$this->command->info('✅ Seed 完成');
$this->command->info(' Admin: admin@cfdive.com / password123');
$this->command->info(' Coach: coach@cfdive.com / password123');
$this->command->info(' Member: member@cfdive.com / password123');
}
}
+165
View File
@@ -0,0 +1,165 @@
services:
# PHP-FPM 服務
app:
# 構建配置
build:
context: . # 使用當前目錄作為構建上下文
dockerfile: Dockerfile # 指定 Dockerfile 路徑
image: cfdive-platform # 構建的鏡像名稱
container_name: cfdive-app # 容器名稱
restart: unless-stopped # 自動重啟策略:除非手動停止,否則自動重啟
working_dir: /var/www/ # 工作目錄
# 卷掛載:將本地代碼掛載到容器中
volumes:
- ./:/var/www # 本地代碼目錄掛載到容器中的 /var/www
# 環境變數:設置 Laravel 數據庫連接
environment:
- DB_CONNECTION=mysql # 數據庫類型
- DB_HOST=db # 數據庫主機名
- DB_PORT=3306 # 數據庫端口
- DB_DATABASE=CFDivePlatform # 數據庫名稱
- DB_USERNAME=cfdiveuser # 數據庫用戶名
- DB_PASSWORD=cfdivepass # 數據庫密碼
# 網絡配置
networks:
- cfdive-network # 連接到自定義網絡
- proxy_net
# 依賴關係:確保 db 和 redis 服務先啟動
depends_on:
db: # 依賴 db 服務
condition: service_healthy # 等待數據庫健康檢查通過
redis: # 依賴 redis 服務
condition: service_started # 等待服務啟動
# 健康檢查配置
healthcheck:
test: ["CMD", "php", "-v"] # 檢查 PHP 版本確認服務正常
interval: 30s # 每30秒檢查一次
timeout: 10s # 超時時間10秒
retries: 3 # 重試3次
start_period: 40s # 容器啟動後40秒開始檢查
nginx:
image: nginx:alpine
container_name: cfdive-nginx
restart: unless-stopped
volumes:
- ./:/var/www
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/
networks:
- cfdive-network
- proxy_net
depends_on:
app:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-https://api.hank-spack.com}
image: cfdive-frontend
container_name: cfdive-frontend
restart: unless-stopped
networks:
- cfdive-network
- proxy_net
db:
image: mysql:8.0
container_name: cfdive-db
restart: unless-stopped
environment:
MYSQL_DATABASE: CFDivePlatform
MYSQL_ROOT_PASSWORD: root
MYSQL_USER: cfdiveuser
MYSQL_PASSWORD: cfdivepass
SERVICE_TAGS: dev
SERVICE_NAME: mysql
volumes:
- mysql-data:/var/lib/mysql
networks:
- cfdive-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "cfdiveuser", "-pcfdivepass"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
phpmyadmin:
image: phpmyadmin/phpmyadmin
container_name: cfdive-phpmyadmin
restart: unless-stopped
environment:
PMA_HOST: db
PMA_PORT: 3306
PMA_USER: cfdiveuser
PMA_PASSWORD: cfdivepass
MYSQL_ROOT_PASSWORD: root
networks:
- cfdive-network
depends_on:
db:
condition: service_healthy
queue-worker:
image: cfdive-platform
container_name: cfdive-queue
restart: unless-stopped
working_dir: /var/www/
command: php artisan queue:work --sleep=3 --tries=3 --timeout=60
volumes:
- ./:/var/www
environment:
- DB_CONNECTION=mysql
- DB_HOST=db
- DB_PORT=3306
- DB_DATABASE=CFDivePlatform
- DB_USERNAME=cfdiveuser
- DB_PASSWORD=cfdivepass
networks:
- cfdive-network
depends_on:
app:
condition: service_healthy
mailpit:
image: axllent/mailpit
container_name: cfdive-mailpit
restart: unless-stopped
networks:
- cfdive-network
redis:
image: redis:alpine
container_name: cfdive-redis
restart: unless-stopped
networks:
- cfdive-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
networks:
cfdive-network:
driver: bridge
proxy_net:
external: true
volumes:
mysql-data:
driver: local
+58
View File
@@ -0,0 +1,58 @@
# 定義一個 HTTP 服務器塊
server {
listen 80;
server_name cfdive.local localhost;
# 默認索引文件,按順序嘗試
index index.php index.html;
# 錯誤日誌和訪問日誌的路徑
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log;
# 網站根目錄,指向 Laravel 的 public 目錄
root /var/www/public;
# 客戶端上傳文件大小限制
client_max_body_size 100M;
# 處理所有請求
location / {
# 嘗試以 URI 作為文件查找,然後作為目錄,最後轉發到 index.php
try_files $uri $uri/ /index.php?$query_string;
# 啟用靜態 gzip 文件服務(如果存在 .gz 文件)
gzip_static on;
}
# 處理 PHP 文件請求
location ~ \.php$ {
# 如果文件不存在,返回 404
try_files $uri =404;
# 分割路徑信息,用於處理 PATH_INFO
fastcgi_split_path_info ^(.+\.php)(/.+)$;
# 轉發 PHP 請求到 PHP-FPM 服務
fastcgi_pass app:9000;
# 設置 FastCGI 緩衝區大小
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
# 默認索引文件
fastcgi_index index.php;
# 設置 FastCGI 讀取超時時間(秒)
fastcgi_read_timeout 600;
# 包含 FastCGI 參數
include fastcgi_params;
# 設置 SCRIPT_FILENAME 參數,告訴 PHP-FPM 要執行的文件
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 設置 PATH_INFO 參數,用於獲取路徑信息
fastcgi_param PATH_INFO $fastcgi_path_info;
}
}
+122
View File
@@ -0,0 +1,122 @@
#!/bin/bash
set -e
echo "=== CFDivePlatform 容器初始化開始 ==="
# 檢查目錄結構
if [ ! -d "/var/www/storage" ]; then
echo "創建 storage 目錄..."
mkdir -p /var/www/storage
fi
if [ ! -d "/var/www/bootstrap/cache" ]; then
echo "創建 bootstrap/cache 目錄..."
mkdir -p /var/www/bootstrap/cache
fi
# 設置權限
echo "設置目錄權限..."
chown -R www-data:www-data /var/www
chmod -R 775 /var/www/storage /var/www/bootstrap/cache
# 等待 MySQL 服務啟動
echo "等待 MySQL 服務啟動..."
# 使用更穩定的方法檢查 MySQL 連接
MAX_TRIES=60
COUNT=0
wait_for_mysql() {
while [ $COUNT -lt $MAX_TRIES ]; do
if mysqladmin ping -h"db" -u"cfdiveuser" -p"cfdivepass" --silent 2>/dev/null; then
echo "✅ MySQL 服務已準備就緒"
return 0
fi
# 備用檢查方法
if php -r "
try {
\$pdo = new PDO('mysql:host=db;port=3306', 'cfdiveuser', 'cfdivepass');
echo 'PHP-PDO-OK';
exit(0);
} catch(Exception \$e) {
exit(1);
}
" 2>/dev/null; then
echo "✅ MySQL 連接成功 (通過 PHP PDO)"
break
fi
echo "⏳ 等待 MySQL... ($((COUNT+1))/$MAX_TRIES)"
sleep 2
COUNT=$((COUNT+1))
done
if [ $COUNT -eq $MAX_TRIES ]; then
echo "⚠️ 無法連接到 MySQL,但將繼續啟動服務"
fi
}
wait_for_mysql
# 檢查並安裝 Composer 依賴
echo "📦 檢查 Composer 依賴..."
if [ -f "composer.json" ]; then
if [ ! -d "vendor" ] || [ "composer.json" -nt "vendor/autoload.php" ]; then
echo "安裝 Composer 依賴..."
composer install --no-scripts --no-autoloader --optimize-autoloader
composer dump-autoload --optimize
else
echo "✅ Composer 依賴已是最新"
fi
fi
# 設置 Laravel 環境
if [ ! -f .env ]; then
echo "🔧 創建 .env 檔案..."
cp .env.example .env
php artisan key:generate
else
echo "✅ .env 檔案已存在"
fi
# 更新環境變數以確保正確配置
echo "🔧 更新資料庫配置..."
sed -i "s/DB_HOST=.*/DB_HOST=db/g" .env
sed -i "s/DB_PASSWORD=.*/DB_PASSWORD=cfdivepass/g" .env
sed -i "s/DB_USERNAME=.*/DB_USERNAME=cfdiveuser/g" .env
sed -i "s/DB_DATABASE=.*/DB_DATABASE=CFDivePlatform/g" .env
# 執行遷移(如果數據庫已準備好)
echo "🗄️ 執行數據庫遷移..."
if php artisan migrate:status 2>/dev/null; then
php artisan migrate --force || echo "⚠️ 遷移執行遇到問題,但繼續執行"
else
echo "⚠️ 無法檢查遷移狀態,跳過遷移"
fi
# 清除與優化 Laravel 緩存
echo "🧹 清除 Laravel 緩存..."
php artisan config:clear || true
php artisan cache:clear || true
php artisan route:clear || true
php artisan view:clear || true
# 生成 Swagger 文檔(如果可能)
if php -r "echo class_exists('L5Swagger\\L5SwaggerServiceProvider') ? 'yes' : 'no';" 2>/dev/null | grep -q 'yes'; then
echo "📖 生成 API 文檔..."
php artisan l5-swagger:generate || echo "⚠️ API 文檔生成失敗"
fi
echo "✅ CFDivePlatform 初始化完成!"
# 建立 storage symlink
echo "🔗 建立 storage symlink..."
php artisan storage:link --force || true
# 啟動 cron daemonLaravel Scheduler
echo "⏰ 啟動 Laravel Scheduler cron..."
service cron start || cron || true
# 執行傳入的命令
exec "$@"
+5
View File
@@ -0,0 +1,5 @@
upload_max_filesize=40M
post_max_size=40M
memory_limit=512M
max_execution_time=600
max_input_vars=10000
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+20
View File
@@ -0,0 +1,20 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
ARG VITE_API_URL=http://localhost:8080
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>cf-dive-frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
}
+3855
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "cf-dive-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.16.0",
"pinia": "^3.0.4",
"vue": "^3.5.32",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.6",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.14",
"tailwindcss": "^3.4.19",
"vite": "^8.0.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+20
View File
@@ -0,0 +1,20 @@
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import NavBar from './components/NavBar.vue'
import NotificationDrawer from './components/NotificationDrawer.vue'
const route = useRoute()
const isBackofficePage = computed(() =>
route.path.startsWith('/coach') || route.path.startsWith('/admin')
)
</script>
<template>
<div class="min-h-screen bg-gray-50">
<NavBar v-if="!isBackofficePage" />
<RouterView />
<NotificationDrawer />
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
import axios from 'axios'
const adminApi = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
adminApi.interceptors.request.use((config) => {
const token = localStorage.getItem('admin_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default adminApi
+16
View File
@@ -0,0 +1,16 @@
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default api
+17
View File
@@ -0,0 +1,17 @@
import api from './axios'
export function getMyBookings() {
return api.get('/member/bookings')
}
export function getBooking(id) {
return api.get(`/member/bookings/${id}`)
}
export function createBooking(payload) {
return api.post('/member/bookings', payload)
}
export function cancelBooking(id) {
return api.delete(`/member/bookings/${id}`)
}
+16
View File
@@ -0,0 +1,16 @@
import axios from 'axios'
const coachApi = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
coachApi.interceptors.request.use((config) => {
const token = localStorage.getItem('coach_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default coachApi
+21
View File
@@ -0,0 +1,21 @@
import coachApi from './coachAxios'
export function getProviderBookings() {
return coachApi.get('/provider/bookings')
}
export function confirmBooking(id) {
return coachApi.put(`/provider/bookings/${id}/confirm`)
}
export function rejectBooking(id) {
return coachApi.put(`/provider/bookings/${id}/reject`)
}
export function cancelBooking(id) {
return coachApi.put(`/provider/bookings/${id}/cancel`)
}
export function completeBooking(id) {
return coachApi.put(`/provider/bookings/${id}/complete`)
}
+17
View File
@@ -0,0 +1,17 @@
import coachApi from './coachAxios'
export function getSchedules() {
return coachApi.get('/provider/schedules')
}
export function createSchedule(payload) {
return coachApi.post('/provider/schedules', payload)
}
export function updateSchedule(id, payload) {
return coachApi.put(`/provider/schedules/${id}`, payload)
}
export function deleteSchedule(id) {
return coachApi.delete(`/provider/schedules/${id}`)
}
+27
View File
@@ -0,0 +1,27 @@
import coachApi from './coachAxios'
function toFormData(file) {
const fd = new FormData()
fd.append('image', file)
return fd
}
export function uploadCover(offerId, file) {
return coachApi.post(`/provider/offers/${offerId}/cover`, toFormData(file), {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
export function deleteCover(offerId) {
return coachApi.delete(`/provider/offers/${offerId}/cover`)
}
export function uploadImage(offerId, file) {
return coachApi.post(`/provider/offers/${offerId}/images`, toFormData(file), {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
export function deleteImage(imageId) {
return coachApi.delete(`/provider/images/${imageId}`)
}
+21
View File
@@ -0,0 +1,21 @@
import axios from 'axios'
const notificationApi = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
notificationApi.interceptors.request.use((config) => {
// 優先用 coach_token,因為 coach 身份通知優先;member 也可用自己的 token
// 兩者都存在時(測試情境),以當前頁面路徑決定:/coach 開頭用 coach_token,其餘用 token
const isCoachPage = window.location.pathname.startsWith('/coach')
const token = isCoachPage
? (localStorage.getItem('coach_token') || localStorage.getItem('token'))
: (localStorage.getItem('token') || localStorage.getItem('coach_token'))
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default notificationApi
+27
View File
@@ -0,0 +1,27 @@
import api from './axios'
import axios from 'axios'
const publicApi = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
export function getReviews(offerId, sort = 'helpful') {
return publicApi.get(`/diving-offers/${offerId}/reviews`, { params: { sort } })
}
export function createReview(payload) {
return api.post('/member/reviews', payload)
}
export function updateReview(id, payload) {
return api.put(`/member/reviews/${id}`, payload)
}
export function deleteReview(id) {
return api.delete(`/member/reviews/${id}`)
}
export function toggleHelpful(reviewId) {
return api.post(`/reviews/${reviewId}/helpful`)
}
+10
View File
@@ -0,0 +1,10 @@
import axios from 'axios'
const publicApi = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
export function getSchedulesByOffer(offerId) {
return publicApi.get(`/diving-offers/${offerId}/schedules`)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+35
View File
@@ -0,0 +1,35 @@
<script setup>
import { useAdminAuthStore } from '../stores/adminAuth'
import { useRouter } from 'vue-router'
const adminAuth = useAdminAuthStore()
const router = useRouter()
async function handleLogout() {
await adminAuth.logout()
router.push('/admin/login')
}
</script>
<template>
<nav class="bg-slate-800 text-white shadow-md">
<div class="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
<div class="flex items-center gap-6">
<span class="text-lg font-bold tracking-wide"> Admin Panel</span>
<RouterLink to="/admin/dashboard" class="text-sm hover:text-slate-300 transition">儀表板</RouterLink>
<RouterLink to="/admin/members" class="text-sm hover:text-slate-300 transition">會員管理</RouterLink>
<RouterLink to="/admin/providers" class="text-sm hover:text-slate-300 transition">教練管理</RouterLink>
<RouterLink to="/admin/offers" class="text-sm hover:text-slate-300 transition">課程管理</RouterLink>
<RouterLink to="/admin/bookings" class="text-sm hover:text-slate-300 transition">預約管理</RouterLink>
<RouterLink to="/admin/reviews" class="text-sm hover:text-slate-300 transition">評價管理</RouterLink>
</div>
<div class="flex items-center gap-4 text-sm">
<span class="text-slate-400">{{ adminAuth.user?.name }}</span>
<button @click="handleLogout"
class="bg-slate-600 hover:bg-slate-500 px-4 py-1.5 rounded-full transition">
登出
</button>
</div>
</div>
</nav>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup>
import { useCoachAuthStore } from '../stores/coachAuth'
import { useRouter } from 'vue-router'
import NotificationBell from './NotificationBell.vue'
const coachAuth = useCoachAuthStore()
const router = useRouter()
async function handleLogout() {
await coachAuth.logout()
router.push('/coach/login')
}
</script>
<template>
<nav class="bg-gray-900 text-white shadow-md">
<div class="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
<div class="flex items-center gap-6">
<RouterLink to="/coach/dashboard" class="text-lg font-bold tracking-wide hover:text-gray-300 transition">
🤿 Coach Portal
</RouterLink>
<RouterLink to="/coach/dashboard" class="text-sm hover:text-gray-300 transition">我的課程</RouterLink>
<RouterLink to="/coach/schedules" class="text-sm hover:text-gray-300 transition">時段管理</RouterLink>
<RouterLink to="/coach/bookings" class="text-sm hover:text-gray-300 transition">預約管理</RouterLink>
<RouterLink to="/coach/reviews" class="text-sm hover:text-gray-300 transition">課程評價</RouterLink>
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
</div>
<div class="flex items-center gap-4 text-sm">
<span class="text-gray-400">{{ coachAuth.user?.name }}</span>
<NotificationBell />
<button
@click="handleLogout"
class="bg-gray-700 hover:bg-gray-600 px-4 py-1.5 rounded-full transition"
>
登出
</button>
</div>
</div>
</nav>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup>
defineProps({
offer: { type: Object, required: true },
})
</script>
<template>
<RouterLink
:to="`/courses/${offer.id}`"
class="bg-white rounded-2xl shadow hover:shadow-lg transition overflow-hidden flex flex-col"
>
<div class="h-40 overflow-hidden">
<img
v-if="offer.cover_image_url"
:src="offer.cover_image_url"
:alt="offer.title"
class="w-full h-full object-cover"
/>
<div v-else class="bg-gradient-to-br from-ocean-700 to-ocean-500 h-full flex items-center justify-center text-white text-5xl">
🤿
</div>
</div>
<div class="p-4 flex flex-col gap-2 flex-1">
<div class="flex gap-2 flex-wrap">
<span
v-for="badge in (offer.badges || [])"
:key="badge"
class="text-xs bg-ocean-100 text-ocean-700 px-2 py-0.5 rounded-full"
>
{{ badge }}
</span>
<span v-if="offer.tag" class="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
{{ offer.tag }}
</span>
</div>
<h3 class="font-semibold text-gray-800 line-clamp-2 leading-snug">{{ offer.title }}</h3>
<p class="text-sm text-gray-500 flex items-center gap-1">
📍 {{ offer.location }}
</p>
<div class="mt-auto flex items-center justify-between pt-2 border-t border-gray-100">
<span class="text-sm text-amber-500 font-medium">
{{ offer.rating }} <span class="text-gray-400">({{ offer.reviews }})</span>
</span>
<span class="text-ocean-700 font-bold">NT$ {{ offer.price.toLocaleString() }}</span>
</div>
</div>
</RouterLink>
</template>
+95
View File
@@ -0,0 +1,95 @@
<script setup>
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup>
import { useAuthStore } from '../stores/auth'
import { useRouter } from 'vue-router'
import NotificationBell from './NotificationBell.vue'
const auth = useAuthStore()
const router = useRouter()
async function handleLogout() {
await auth.logout()
router.push('/login')
}
</script>
<template>
<nav class="bg-ocean-800 text-white shadow-md">
<div class="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
<RouterLink to="/" class="text-xl font-bold tracking-wide hover:text-ocean-100 transition">
🤿 CFDive
</RouterLink>
<div class="flex items-center gap-6 text-sm font-medium">
<RouterLink to="/courses" class="hover:text-ocean-100 transition">探索課程</RouterLink>
<template v-if="auth.isLoggedIn">
<span class="text-ocean-200 hidden sm:inline">
👤 {{ auth.user?.name }}
</span>
<RouterLink to="/my-bookings" class="hover:text-ocean-100 transition">我的預約</RouterLink>
<RouterLink to="/profile" class="hover:text-ocean-100 transition">個人資料</RouterLink>
<NotificationBell />
<button
@click="handleLogout"
class="bg-ocean-600 hover:bg-ocean-500 px-4 py-1.5 rounded-full transition"
>
登出
</button>
</template>
<template v-else>
<RouterLink to="/login" class="hover:text-ocean-100 transition">登入</RouterLink>
<RouterLink
to="/register"
class="bg-ocean-600 hover:bg-ocean-500 px-4 py-1.5 rounded-full transition"
>
註冊
</RouterLink>
</template>
</div>
</div>
</nav>
</template>
@@ -0,0 +1,32 @@
<script setup>
import { useNotificationStore } from '../stores/notifications'
const store = useNotificationStore()
function toggle() {
if (!store.isOpen) {
store.fetchNotifications()
}
store.isOpen = !store.isOpen
}
</script>
<template>
<button
@click="toggle"
class="relative p-2 rounded-full hover:bg-white/10 transition"
aria-label="通知"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 10-12 0v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0a3 3 0 11-6 0m6 0H9" />
</svg>
<span
v-if="store.unreadCount > 0"
class="absolute -top-0.5 -right-0.5 min-w-[1.1rem] h-[1.1rem] flex items-center justify-center
bg-red-500 text-white text-[10px] font-bold rounded-full px-0.5 leading-none"
>
{{ store.unreadCount > 99 ? '99+' : store.unreadCount }}
</span>
</button>
</template>
@@ -0,0 +1,115 @@
<script setup>
import { useNotificationStore } from '../stores/notifications'
import { useRouter } from 'vue-router'
const store = useNotificationStore()
const router = useRouter()
function formatTime(iso) {
if (!iso) return ''
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60000)
if (m < 1) return '剛剛'
if (m < 60) return `${m} 分鐘前`
const h = Math.floor(m / 60)
if (h < 24) return `${h} 小時前`
return `${Math.floor(h / 24)} 天前`
}
function truncate(text, max = 80) {
return text && text.length > max ? text.slice(0, max) + '…' : text
}
async function clickItem(item) {
await store.markRead(item.id)
store.isOpen = false
if (item.action_url) {
try {
const path = new URL(item.action_url).pathname
await router.push(path)
} catch (e) {
console.error('[NotificationDrawer] navigation failed:', item.action_url, e)
}
}
}
</script>
<template>
<Teleport to="body">
<Transition name="drawer">
<div v-if="store.isOpen" class="fixed inset-0 z-50 flex justify-end">
<div class="absolute inset-0 bg-black/30" @click="store.isOpen = false" />
<div class="relative w-80 sm:w-96 h-full bg-white shadow-2xl flex flex-col">
<div class="flex items-center justify-between px-4 py-3 border-b">
<h2 class="font-semibold text-gray-800">通知</h2>
<div class="flex items-center gap-2">
<button
v-if="store.unreadCount > 0"
@click="store.markAllRead()"
class="text-xs text-blue-600 hover:underline"
>
全部標為已讀
</button>
<button @click="store.isOpen = false" class="text-gray-400 hover:text-gray-600 p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<p v-if="store.notifications.length === 0" class="text-center text-gray-400 text-sm py-12">
目前沒有通知
</p>
<ul v-else>
<li
v-for="item in store.notifications"
:key="item.id"
class="flex items-start gap-3 px-4 py-3 border-b hover:bg-gray-50 transition cursor-pointer"
:class="{ 'bg-blue-50': !item.read_at }"
@click="clickItem(item)"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800 truncate">{{ item.title }}</p>
<p class="text-xs text-gray-500 mt-0.5 line-clamp-2">{{ truncate(item.body) }}</p>
<p class="text-[10px] text-gray-400 mt-1">{{ formatTime(item.created_at) }}</p>
</div>
<button
@click.stop="store.remove(item.id)"
class="shrink-0 text-gray-300 hover:text-gray-500 p-1 mt-0.5"
title="刪除"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
</ul>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.drawer-enter-active,
.drawer-leave-active {
transition: opacity 0.2s ease;
}
.drawer-enter-active > div:last-child,
.drawer-leave-active > div:last-child {
transition: transform 0.2s ease;
}
.drawer-enter-from,
.drawer-leave-to {
opacity: 0;
}
.drawer-enter-from > div:last-child,
.drawer-leave-to > div:last-child {
transform: translateX(100%);
}
</style>
+10
View File
@@ -0,0 +1,10 @@
<script setup>
import AdminNavBar from '../components/AdminNavBar.vue'
</script>
<template>
<div class="min-h-screen bg-slate-50">
<AdminNavBar />
<RouterView />
</div>
</template>
+10
View File
@@ -0,0 +1,10 @@
<script setup>
import CoachNavBar from '../components/CoachNavBar.vue'
</script>
<template>
<div class="min-h-screen bg-gray-50">
<CoachNavBar />
<RouterView />
</div>
</template>
+22
View File
@@ -0,0 +1,22 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import router from './router'
import { useAuthStore } from './stores/auth'
import { useCoachAuthStore } from './stores/coachAuth'
import { useAdminAuthStore } from './stores/adminAuth'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
// 在 router 安裝前同步初始化所有 auth store,
// 確保 beforeEach guard 跑時 isLoggedIn 已反映 localStorage 的實際狀態
useAuthStore().init()
useCoachAuthStore().init()
useAdminAuthStore().init()
app.use(router)
app.mount('#app')

Some files were not shown because too many files have changed in this diff Show More