Compare commits

...

10 Commits

Author SHA1 Message Date
a620906209 a3462f8493 security:移除 docker-compose.yml 硬編碼密碼,改用環境變數
Tests / PHP 8.2 (pull_request) Failing after 2s
Tests / PHP 8.3 (pull_request) Failing after 2s
pull requests / uneditable (pull_request_target) Failing after 0s
- DB_PASSWORD、MYSQL_ROOT_PASSWORD 改由 .env 注入
- MySQL healthcheck 移除密碼參數(改用無認證 ping)
- .env.example 補上 MYSQL_ROOT_PASSWORD 說明
- 已用 git filter-repo 清除歷史中的硬編碼密碼

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 01:30:54 +08:00
a620906209 05ef8b9316 docs:補全所有 API 端點 Swagger 文件(73 個端點)
- 修正 AuthApiDoc.php 路徑錯位(/register/member → /member/register 等)
  及 change-password 方法(Post→Put);補上 POST /logout、GET /user
- 新增 AuthSupplementDoc.php(Google OAuth 2 端點)
- 新增 PublicApiDoc.php(共用 Schema + 公開課程 4 端點)
- 新增 MemberApiDoc.php(預約、評價、通知共 13 端點)
- 新增 ProviderApiDoc.php(課程、圖片、時段、預約管理共 18 端點)
- 新增 AdminApiDoc.php(統計、會員/教練/課程管理共 16 端點)
- 更新 README.md
- 歸檔 swagger-api-docs-completion change,同步主規格

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 01:17:25 +08:00
a620906209 95bafef52d chore:新增 swagger-api-docs-completion change 規劃
proposal / design / specs / tasks 全部就緒,21 tasks 待實作

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:45:15 +08:00
a620906209 bda6533105 chore:歸檔 api-performance-optimization,同步主規格
- 移至 openspec/changes/archive/2026-05-25-api-performance-optimization/
- 新增主規格:api-cache-layer、db-index-optimization
- 更新 review-lifecycle 規格(補分頁、eager load)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:45:08 +08:00
a620906209 efb1f22be3 feat:實作 API 效能優化 — Redis 快取、分頁、DB 索引
- 引入 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 915e404dfc fix:修正 Email 設定與規格同步
- .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 0bce40a5bf 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 ca2ed14de3 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 8ef293332a 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 88c0c09ccb 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
155 changed files with 15104 additions and 156 deletions
+6 -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
@@ -25,6 +26,7 @@ DB_CONNECTION=sqlite
# DB_DATABASE=laravel # DB_DATABASE=laravel
# DB_USERNAME=root # DB_USERNAME=root
# DB_PASSWORD= # DB_PASSWORD=
# MYSQL_ROOT_PASSWORD=
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
@@ -36,17 +38,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
+1
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
+8 -1
View File
@@ -14,7 +14,8 @@ RUN apt-get update && apt-get install -y \
libzip-dev \ libzip-dev \
default-mysql-client \ default-mysql-client \
netcat-openbsd \ netcat-openbsd \
grep grep \
cron
# 清理 apt 快取以減小鏡像大小 # 清理 apt 快取以減小鏡像大小
RUN apt-get clean && rm -rf /var/lib/apt/lists/* RUN apt-get clean && rm -rf /var/lib/apt/lists/*
@@ -48,6 +49,12 @@ 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 COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /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 指令之前執行 # 這將在 CMD 指令之前執行
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+48 -3
View File
@@ -1,4 +1,49 @@
![image](https://github.com/user-attachments/assets/332bf381-8faa-441f-8843-bff2c1fbde7a) # CFDive Platform
![image](https://github.com/user-attachments/assets/f0a765f5-5386-467e-80b8-4a5ef8ef0bcf)
![image](https://github.com/user-attachments/assets/5d6ad6ee-f100-4d45-9200-d61ce76ca7c4)
潛水課程媒合平台 — 連結潛水教練與學員,提供課程瀏覽、線上預約、評價與通知等完整服務。
---
## 功能概覽
**會員(Member**
- 註冊 / 登入(Email + Google OAuth
- 瀏覽、搜尋、篩選潛水課程
- 查看課程時段並送出預約
- 對完成的課程留下評價(支援匿名、有幫助投票)
- 站內通知(Email + Polling
**教練(Provider**
- 課程 CRUD(含封面 + 相簿圖片上傳)
- 課程時段管理
- 預約管理(確認 / 拒絕 / 完成 / 取消)
**管理員(Admin**
- 平台統計數據(會員數、教練數、課程數)
- 會員與教練帳號管理(啟用 / 停用 / 審核)
- 課程、預約、評價管理
---
## 技術棧
| 層級 | 技術 |
|------|------|
| 後端 | PHP 8.x / Laravel 11 |
| 前端 | Vue 3 |
| 資料庫 | MySQL 8.0 |
| 快取 | Redispredis|
| 認證 | Laravel Sanctum + Google OAuth |
| 容器 | Docker / Docker Compose |
| API 文件 | Swagger UIl5-swagger|
---
## API 文件
共 73 個端點,涵蓋:
- 認證(Email + Google OAuth
- 公開課程查詢
- 會員預約 / 評價 / 通知
- 教練課程 / 時段 / 預約管理
- 管理員後台
@@ -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.");
}
}
+504
View File
@@ -0,0 +1,504 @@
<?php
namespace App\Docs;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(
* name="Admin 統計",
* description="管理員平台統計"
* )
* @OA\Tag(
* name="Admin 會員管理",
* description="管理員的會員帳號管理"
* )
* @OA\Tag(
* name="Admin 教練管理",
* description="管理員的服務提供者帳號管理"
* )
* @OA\Tag(
* name="Admin 課程管理",
* description="管理員的課程、預約、評價管理"
* )
*/
class AdminApiDoc
{
// -----------------------------------------------------------------------
// Admin Stats
// -----------------------------------------------------------------------
/**
* 取得平台統計數據
*
* @OA\Get(
* path="/admin/stats",
* summary="取得平台統計數據",
* description="回傳會員總數、服務提供者總數、課程總數;非 admin 角色回傳 403",
* operationId="getAdminStats",
* tags={"Admin 統計"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="total_members", type="integer", example=128),
* @OA\Property(property="total_providers", type="integer", example=34),
* @OA\Property(property="total_offers", type="integer", example=87)
* )
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getAdminStats()
{
}
// -----------------------------------------------------------------------
// Admin Member Management
// -----------------------------------------------------------------------
/**
* 取得會員列表
*
* @OA\Get(
* path="/admin/members",
* summary="取得會員列表",
* description="分頁回傳所有會員帳號,含 member_profile",
* operationId="listAdminMembers",
* tags={"Admin 會員管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="王小明"),
* @OA\Property(property="email", type="string", example="member@example.com"),
* @OA\Property(property="role", type="string", example="member"),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
* @OA\Property(property="member_profile", type="object", nullable=true)
* )
* ),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listAdminMembers()
{
}
/**
* 取得單一會員
*
* @OA\Get(
* path="/admin/members/{id}",
* summary="取得單一會員",
* operationId="getAdminMember",
* tags={"Admin 會員管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="object")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="會員不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getAdminMember()
{
}
/**
* 切換會員啟用狀態
*
* @OA\Put(
* path="/admin/members/{id}/toggle-active",
* summary="切換會員啟用狀態",
* description="啟用或停用指定會員帳號",
* operationId="toggleMemberActive",
* tags={"Admin 會員管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="切換成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="帳號已停用"),
* @OA\Property(property="is_active", type="boolean", example=false)
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="會員不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function toggleMemberActive()
{
}
/**
* 確認會員存在
*
* @OA\Get(
* path="/admin/check-member/{id}",
* summary="確認會員存在",
* description="快速確認指定 ID 的會員是否存在(角色為 member)",
* operationId="checkMember",
* tags={"Admin 會員管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="會員存在",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="exists", type="boolean", example=true)
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function checkMember()
{
}
// -----------------------------------------------------------------------
// Admin Provider Management
// -----------------------------------------------------------------------
/**
* 取得教練列表
*
* @OA\Get(
* path="/admin/providers",
* summary="取得教練列表",
* description="分頁回傳所有服務提供者,含 provider_profile",
* operationId="listAdminProviders",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(
* type="object",
* @OA\Property(property="id", type="integer", example=2),
* @OA\Property(property="name", type="string", example="林教練"),
* @OA\Property(property="email", type="string", example="coach@example.com"),
* @OA\Property(property="role", type="string", example="provider"),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="is_verified", type="boolean", example=false),
* @OA\Property(property="provider_profile", type="object", nullable=true)
* )
* ),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listAdminProviders()
{
}
/**
* 取得單一教練
*
* @OA\Get(
* path="/admin/providers/{id}",
* summary="取得單一教練",
* operationId="getAdminProvider",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="object")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getAdminProvider()
{
}
/**
* 切換教練啟用狀態
*
* @OA\Put(
* path="/admin/providers/{id}/toggle-active",
* summary="切換教練啟用狀態",
* operationId="toggleProviderActive",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="切換成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="帳號已停用"),
* @OA\Property(property="is_active", type="boolean", example=false)
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function toggleProviderActive()
{
}
/**
* 切換教練審核狀態
*
* @OA\Put(
* path="/admin/providers/{id}/toggle-verified",
* summary="切換教練審核狀態",
* description="通過或撤銷教練審核,回傳新的 is_verified 狀態",
* operationId="toggleProviderVerified",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="切換成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="教練已通過審核"),
* @OA\Property(property="is_verified", type="boolean", example=true)
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function toggleProviderVerified()
{
}
/**
* 確認教練存在
*
* @OA\Get(
* path="/admin/check-provider/{id}",
* summary="確認教練存在",
* operationId="checkProvider",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="查詢成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="exists", type="boolean", example=true)
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function checkProvider()
{
}
// -----------------------------------------------------------------------
// Admin Offers / Bookings / Reviews
// -----------------------------------------------------------------------
/**
* 取得所有課程(Admin
*
* @OA\Get(
* path="/admin/offers",
* summary="取得所有課程(Admin",
* description="分頁回傳全平台課程列表",
* operationId="listAdminOffers",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DivingOffer")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listAdminOffers()
{
}
/**
* 刪除課程(Admin
*
* @OA\Delete(
* path="/admin/offers/{id}",
* summary="刪除課程(Admin",
* operationId="deleteAdminOffer",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="課程已刪除")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteAdminOffer()
{
}
/**
* 取得所有預約(Admin
*
* @OA\Get(
* path="/admin/bookings",
* summary="取得所有預約(Admin",
* description="分頁回傳全平台預約列表",
* operationId="listAdminBookings",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listAdminBookings()
{
}
/**
* 標記預約完成(Admin
*
* @OA\Put(
* path="/admin/bookings/{id}/complete",
* summary="標記預約完成(Admin",
* operationId="completeBookingByAdmin",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="標記成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已完成"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許完成", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function completeBookingByAdmin()
{
}
/**
* 取得所有評價(Admin
*
* @OA\Get(
* path="/admin/reviews",
* summary="取得所有評價(Admin",
* description="分頁回傳全平台評價列表,per_page 最大 100",
* operationId="listAdminReviews",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數(最大 100", @OA\Schema(type="integer", default=20, maximum=100)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Review")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function listAdminReviews()
{
}
/**
* 刪除評價(Admin
*
* @OA\Delete(
* path="/admin/reviews/{id}",
* summary="刪除評價(Admin",
* operationId="deleteAdminReview",
* tags={"Admin 課程管理"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="評價已刪除")
* )
* ),
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteAdminReview()
{
}
}
+92 -21
View File
@@ -35,6 +35,10 @@ use OpenApi\Annotations as OA;
* name="管理員", * name="管理員",
* description="管理員相關操作" * description="管理員相關操作"
* ) * )
* @OA\Tag(
* name="認證",
* description="通用認證操作(登出、取得當前使用者)"
* )
*/ */
class AuthApiDoc class AuthApiDoc
{ {
@@ -42,7 +46,7 @@ class AuthApiDoc
* 會員註冊 * 會員註冊
* *
* @OA\Post( * @OA\Post(
* path="/register/member", * path="/member/register",
* summary="會員註冊", * summary="會員註冊",
* description="建立新的會員帳號", * description="建立新的會員帳號",
* operationId="registerMember", * operationId="registerMember",
@@ -119,7 +123,7 @@ class AuthApiDoc
* 會員登入 * 會員登入
* *
* @OA\Post( * @OA\Post(
* path="/login/member", * path="/member/login",
* summary="會員登入", * summary="會員登入",
* description="會員帳號登入系統", * description="會員帳號登入系統",
* operationId="loginMember", * operationId="loginMember",
@@ -207,7 +211,7 @@ class AuthApiDoc
* 會員登出 * 會員登出
* *
* @OA\Post( * @OA\Post(
* path="/logout/member", * path="/member/logout",
* summary="會員登出", * summary="會員登出",
* description="會員登出系統並撤銷當前令牌", * description="會員登出系統並撤銷當前令牌",
* operationId="logoutMember", * operationId="logoutMember",
@@ -241,7 +245,7 @@ class AuthApiDoc
* 取得會員個人資料 * 取得會員個人資料
* *
* @OA\Get( * @OA\Get(
* path="/profile/member", * path="/member/profile",
* summary="取得會員個人資料", * summary="取得會員個人資料",
* description="取得當前登入會員的個人資料", * description="取得當前登入會員的個人資料",
* operationId="memberProfile", * operationId="memberProfile",
@@ -303,7 +307,7 @@ class AuthApiDoc
* 更新會員個人資料 * 更新會員個人資料
* *
* @OA\Put( * @OA\Put(
* path="/profile/member", * path="/member/profile",
* summary="更新會員個人資料", * summary="更新會員個人資料",
* description="更新當前登入會員的個人資料", * description="更新當前登入會員的個人資料",
* operationId="updateMemberProfile", * operationId="updateMemberProfile",
@@ -383,8 +387,8 @@ class AuthApiDoc
/** /**
* 修改會員密碼 * 修改會員密碼
* *
* @OA\Post( * @OA\Put(
* path="/password/member", * path="/member/change-password",
* summary="修改會員密碼", * summary="修改會員密碼",
* description="修改當前登入會員的密碼", * description="修改當前登入會員的密碼",
* operationId="changeMemberPassword", * operationId="changeMemberPassword",
@@ -444,7 +448,7 @@ class AuthApiDoc
* 服務提供者註冊 * 服務提供者註冊
* *
* @OA\Post( * @OA\Post(
* path="/register/provider", * path="/provider/register",
* summary="服務提供者註冊", * summary="服務提供者註冊",
* description="建立新的服務提供者帳號", * description="建立新的服務提供者帳號",
* operationId="registerProvider", * operationId="registerProvider",
@@ -498,7 +502,7 @@ class AuthApiDoc
* 服務提供者登入 * 服務提供者登入
* *
* @OA\Post( * @OA\Post(
* path="/login/provider", * path="/provider/login",
* summary="服務提供者登入", * summary="服務提供者登入",
* description="服務提供者帳號登入系統", * description="服務提供者帳號登入系統",
* operationId="loginProvider", * operationId="loginProvider",
@@ -580,7 +584,7 @@ class AuthApiDoc
* 服務提供者登出 * 服務提供者登出
* *
* @OA\Post( * @OA\Post(
* path="/logout/provider", * path="/provider/logout",
* summary="服務提供者登出", * summary="服務提供者登出",
* description="服務提供者登出系統並撤銷當前令牌", * description="服務提供者登出系統並撤銷當前令牌",
* operationId="logoutProvider", * operationId="logoutProvider",
@@ -614,7 +618,7 @@ class AuthApiDoc
* 取得服務提供者資料 * 取得服務提供者資料
* *
* @OA\Get( * @OA\Get(
* path="/profile/provider", * path="/provider/profile",
* summary="取得服務提供者資料", * summary="取得服務提供者資料",
* description="取得當前登入服務提供者的資料", * description="取得當前登入服務提供者的資料",
* operationId="providerProfile", * operationId="providerProfile",
@@ -678,7 +682,7 @@ class AuthApiDoc
* 更新服務提供者資料 * 更新服務提供者資料
* *
* @OA\Put( * @OA\Put(
* path="/profile/provider", * path="/provider/profile",
* summary="更新服務提供者資料", * summary="更新服務提供者資料",
* description="更新當前登入服務提供者的資料", * description="更新當前登入服務提供者的資料",
* operationId="updateProviderProfile", * operationId="updateProviderProfile",
@@ -764,8 +768,8 @@ class AuthApiDoc
/** /**
* 修改服務提供者密碼 * 修改服務提供者密碼
* *
* @OA\Post( * @OA\Put(
* path="/password/provider", * path="/provider/change-password",
* summary="修改服務提供者密碼", * summary="修改服務提供者密碼",
* description="修改當前登入服務提供者的密碼", * description="修改當前登入服務提供者的密碼",
* operationId="changeProviderPassword", * operationId="changeProviderPassword",
@@ -825,7 +829,7 @@ class AuthApiDoc
* 管理員註冊 * 管理員註冊
* *
* @OA\Post( * @OA\Post(
* path="/register/admin", * path="/admin/register",
* summary="管理員註冊", * summary="管理員註冊",
* description="建立新的管理員帳號", * description="建立新的管理員帳號",
* operationId="registerAdmin", * operationId="registerAdmin",
@@ -877,7 +881,7 @@ class AuthApiDoc
* 管理員登入 * 管理員登入
* *
* @OA\Post( * @OA\Post(
* path="/login/admin", * path="/admin/login",
* summary="管理員登入", * summary="管理員登入",
* description="管理員帳號登入系統", * description="管理員帳號登入系統",
* operationId="loginAdmin", * operationId="loginAdmin",
@@ -958,7 +962,7 @@ class AuthApiDoc
* 管理員登出 * 管理員登出
* *
* @OA\Post( * @OA\Post(
* path="/logout/admin", * path="/admin/logout",
* summary="管理員登出", * summary="管理員登出",
* description="管理員登出系統並撤銷當前令牌", * description="管理員登出系統並撤銷當前令牌",
* operationId="logoutAdmin", * operationId="logoutAdmin",
@@ -992,7 +996,7 @@ class AuthApiDoc
* 取得管理員個人資料 * 取得管理員個人資料
* *
* @OA\Get( * @OA\Get(
* path="/profile/admin", * path="/admin/profile",
* summary="取得管理員個人資料", * summary="取得管理員個人資料",
* description="取得當前登入管理員的個人資料", * description="取得當前登入管理員的個人資料",
* operationId="adminProfile", * operationId="adminProfile",
@@ -1030,7 +1034,7 @@ class AuthApiDoc
* 更新管理員個人資料 * 更新管理員個人資料
* *
* @OA\Put( * @OA\Put(
* path="/profile/admin", * path="/admin/profile",
* summary="更新管理員個人資料", * summary="更新管理員個人資料",
* description="更新當前登入管理員的個人資料", * description="更新當前登入管理員的個人資料",
* operationId="updateAdminProfile", * operationId="updateAdminProfile",
@@ -1087,8 +1091,8 @@ class AuthApiDoc
/** /**
* 修改管理員密碼 * 修改管理員密碼
* *
* @OA\Post( * @OA\Put(
* path="/password/admin", * path="/admin/change-password",
* summary="修改管理員密碼", * summary="修改管理員密碼",
* description="修改當前登入管理員的密碼", * description="修改當前登入管理員的密碼",
* operationId="changeAdminPassword", * operationId="changeAdminPassword",
@@ -1143,4 +1147,71 @@ class AuthApiDoc
public function changeAdminPassword() public function changeAdminPassword()
{ {
} }
/**
* 通用登出
*
* @OA\Post(
* path="/logout",
* summary="通用登出",
* description="撤銷當前 Bearer token,適用所有角色",
* operationId="logout",
* tags={"認證"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="登出成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="登出成功")
* )
* ),
* @OA\Response(
* response=401,
* description="未認證",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="Unauthenticated.")
* )
* )
* )
*/
public function logout()
{
}
/**
* 取得當前使用者
*
* @OA\Get(
* path="/user",
* summary="取得當前使用者",
* description="回傳當前 Bearer token 所屬的使用者資訊",
* operationId="currentUser",
* tags={"認證"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="王小明"),
* @OA\Property(property="email", type="string", example="user@example.com"),
* @OA\Property(property="role", type="string", enum={"member","provider","admin"}, example="member"),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
* @OA\Property(property="updated_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
* ),
* @OA\Response(
* response=401,
* description="未認證",
* @OA\JsonContent(
* @OA\Property(property="message", type="string", example="Unauthenticated.")
* )
* )
* )
*/
public function currentUser()
{
}
} }
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace App\Docs;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(
* name="Google OAuth",
* description="Google OAuth 2.0 社群登入"
* )
*/
class AuthSupplementDoc
{
/**
* 取得 Google OAuth 重定向 URL
*
* @OA\Get(
* path="/auth/google/redirect",
* summary="取得 Google OAuth 重定向 URL",
* description="回傳 Google OAuth 授權頁面的 redirect_url,前端應將使用者導向此 URL 以啟動 OAuth 流程",
* operationId="googleRedirect",
* tags={"Google OAuth"},
* @OA\Response(
* response=200,
* description="取得 redirect_url 成功",
* @OA\JsonContent(
* @OA\Property(property="redirect_url", type="string", format="uri", example="https://accounts.google.com/o/oauth2/auth?client_id=...")
* )
* )
* )
*/
public function googleRedirect()
{
}
/**
* Google OAuth 回調
*
* @OA\Get(
* path="/auth/google/callback",
* summary="Google OAuth 回調",
* description="Google OAuth 授權完成後的回調端點,通常由瀏覽器自動呼叫。成功後回傳 Bearer token 與使用者資訊",
* operationId="googleCallback",
* tags={"Google OAuth"},
* @OA\Parameter(
* name="code",
* in="query",
* required=true,
* description="Google 授權碼",
* @OA\Schema(type="string")
* ),
* @OA\Parameter(
* name="state",
* in="query",
* required=false,
* description="OAuth state 參數",
* @OA\Schema(type="string")
* ),
* @OA\Response(
* response=200,
* description="OAuth 登入成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="登入成功"),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="token", type="string", example="1|abcdef1234567890"),
* @OA\Property(property="token_type", type="string", example="Bearer"),
* @OA\Property(
* property="user",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="王小明"),
* @OA\Property(property="email", type="string", example="user@gmail.com"),
* @OA\Property(property="role", type="string", example="member"),
* @OA\Property(property="is_active", type="boolean", example=true)
* )
* )
* )
* ),
* @OA\Response(
* response=422,
* description="OAuth 驗證失敗",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="OAuth 驗證失敗")
* )
* )
* )
*/
public function googleCallback()
{
}
}
+418
View File
@@ -0,0 +1,418 @@
<?php
namespace App\Docs;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(
* name="會員預約",
* description="會員的預約管理"
* )
* @OA\Tag(
* name="會員評價",
* description="會員的評價管理"
* )
* @OA\Tag(
* name="通知",
* description="站內通知管理(Member / Provider 共用)"
* )
*/
class MemberApiDoc
{
// -----------------------------------------------------------------------
// Member Bookings
// -----------------------------------------------------------------------
/**
* 建立預約
*
* @OA\Post(
* path="/member/bookings",
* summary="建立預約",
* description="會員建立新的課程預約",
* operationId="createBooking",
* tags={"會員預約"},
* security={{"bearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"offer_id","schedule_id","participants"},
* @OA\Property(property="offer_id", type="integer", example=1, description="課程 ID"),
* @OA\Property(property="schedule_id", type="integer", example=2, description="時段 ID"),
* @OA\Property(property="participants", type="integer", example=2, description="參加人數"),
* @OA\Property(property="note", type="string", nullable=true, example="需要器材租借")
* )
* ),
* @OA\Response(
* response=201,
* description="預約建立成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約成功"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(
* response=422,
* description="驗證失敗或時段已滿",
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
* ),
* @OA\Response(
* response=403,
* description="無權限(非 member 角色)",
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
* )
* )
*/
public function createBooking()
{
}
/**
* 取得我的預約列表
*
* @OA\Get(
* path="/member/bookings",
* summary="取得我的預約列表",
* description="分頁回傳當前會員的所有預約",
* operationId="listMemberBookings",
* tags={"會員預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* )
*/
public function listMemberBookings()
{
}
/**
* 取得單一預約
*
* @OA\Get(
* path="/member/bookings/{id}",
* summary="取得單一預約",
* operationId="getMemberBooking",
* tags={"會員預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="無權限存取", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getMemberBooking()
{
}
/**
* 取消預約
*
* @OA\Delete(
* path="/member/bookings/{id}",
* summary="取消預約",
* description="會員取消自己的預約",
* operationId="cancelBooking",
* tags={"會員預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取消成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已取消")
* )
* ),
* @OA\Response(response=403, description="無權限或狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function cancelBooking()
{
}
// -----------------------------------------------------------------------
// Member Reviews
// -----------------------------------------------------------------------
/**
* 建立評價
*
* @OA\Post(
* path="/member/reviews",
* summary="建立評價",
* description="會員對已完成的預約課程提交評價",
* operationId="createReview",
* tags={"會員評價"},
* security={{"bearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"booking_id","rating"},
* @OA\Property(property="booking_id", type="integer", example=5),
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=4),
* @OA\Property(property="comment", type="string", nullable=true, example="課程非常棒!"),
* @OA\Property(property="is_anonymous", type="boolean", example=false)
* )
* ),
* @OA\Response(
* response=201,
* description="評價建立成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="評價已提交"),
* @OA\Property(property="data", ref="#/components/schemas/Review")
* )
* ),
* @OA\Response(response=403, description="預約未完成或非本人預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function createReview()
{
}
/**
* 更新評價
*
* @OA\Put(
* path="/member/reviews/{id}",
* summary="更新評價",
* operationId="updateReview",
* tags={"會員評價"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=5),
* @OA\Property(property="comment", type="string", nullable=true, example="更新後的評語"),
* @OA\Property(property="is_anonymous", type="boolean", example=true)
* )
* ),
* @OA\Response(
* response=200,
* description="更新成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/Review")
* )
* ),
* @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function updateReview()
{
}
/**
* 刪除評價
*
* @OA\Delete(
* path="/member/reviews/{id}",
* summary="刪除評價",
* operationId="deleteReview",
* tags={"會員評價"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="評價已刪除")
* )
* ),
* @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteReview()
{
}
/**
* 標記評價為有幫助
*
* @OA\Post(
* path="/reviews/{id}/helpful",
* summary="標記評價為有幫助",
* description="切換投票狀態(已投票則撤回)",
* operationId="markReviewHelpful",
* tags={"會員評價"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="評價 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="操作成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="helpful_count", type="integer", example=4),
* @OA\Property(property="has_voted", type="boolean", example=true)
* )
* ),
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function markReviewHelpful()
{
}
// -----------------------------------------------------------------------
// Notifications (Member + Provider 共用)
// -----------------------------------------------------------------------
/**
* 取得通知列表
*
* @OA\Get(
* path="/notifications",
* summary="取得通知列表",
* description="分頁回傳當前使用者的所有通知(Member / Provider 共用)",
* operationId="listNotifications",
* tags={"通知"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="type", type="string", example="booking_confirmed"),
* @OA\Property(property="title", type="string", example="預約已確認"),
* @OA\Property(property="message", type="string", example="您的預約已由教練確認"),
* @OA\Property(property="read_at", type="string", nullable=true, example=null),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
* ),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* )
*/
public function listNotifications()
{
}
/**
* 取得未讀通知數量
*
* @OA\Get(
* path="/notifications/unread-count",
* summary="取得未讀通知數量",
* operationId="getUnreadNotificationCount",
* tags={"通知"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="unread_count", type="integer", example=3)
* )
* )
* )
*/
public function getUnreadNotificationCount()
{
}
/**
* 標記單一通知為已讀
*
* @OA\Patch(
* path="/notifications/{id}/read",
* summary="標記單一通知為已讀",
* operationId="markNotificationRead",
* tags={"通知"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="通知 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="標記成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="通知已標記為已讀")
* )
* ),
* @OA\Response(response=404, description="通知不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function markNotificationRead()
{
}
/**
* 全部通知標記為已讀
*
* @OA\Patch(
* path="/notifications/read-all",
* summary="全部通知標記為已讀",
* operationId="markAllNotificationsRead",
* tags={"通知"},
* security={{"bearerAuth": {}}},
* @OA\Response(
* response=200,
* description="標記成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="所有通知已標記為已讀")
* )
* )
* )
*/
public function markAllNotificationsRead()
{
}
/**
* 刪除通知
*
* @OA\Delete(
* path="/notifications/{id}",
* summary="刪除通知",
* operationId="deleteNotification",
* tags={"通知"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="通知 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="通知已刪除")
* )
* ),
* @OA\Response(response=404, description="通知不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteNotification()
{
}
}
+585
View File
@@ -0,0 +1,585 @@
<?php
namespace App\Docs;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(
* name="教練課程",
* description="服務提供者的課程管理"
* )
* @OA\Tag(
* name="教練圖片",
* description="服務提供者的課程圖片管理"
* )
* @OA\Tag(
* name="教練時段",
* description="服務提供者的課程時段管理"
* )
* @OA\Tag(
* name="教練預約",
* description="服務提供者的預約管理"
* )
*/
class ProviderApiDoc
{
// -----------------------------------------------------------------------
// Provider Offers CRUD
// -----------------------------------------------------------------------
/**
* 取得自己的課程列表
*
* @OA\Get(
* path="/provider/offers",
* summary="取得自己的課程列表",
* description="回傳當前服務提供者的所有課程(分頁)",
* operationId="listProviderOffers",
* tags={"教練課程"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DivingOffer")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* )
*/
public function listProviderOffers()
{
}
/**
* 建立課程
*
* @OA\Post(
* path="/provider/offers",
* summary="建立課程",
* description="服務提供者建立新的潛水課程",
* operationId="createProviderOffer",
* tags={"教練課程"},
* security={{"bearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"title","description","region","price","max_participants"},
* @OA\Property(property="title", type="string", example="基礎開放水域課程"),
* @OA\Property(property="description", type="string", example="適合初學者的 OWD 課程"),
* @OA\Property(property="region", type="string", example="墾丁"),
* @OA\Property(property="price", type="number", format="float", example=8500),
* @OA\Property(property="max_participants", type="integer", example=6),
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"初學","OWD"})
* )
* ),
* @OA\Response(
* response=201,
* description="課程建立成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="課程建立成功"),
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
* )
* ),
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function createProviderOffer()
{
}
/**
* 取得單一課程(Provider 視角)
*
* @OA\Get(
* path="/provider/offers/{id}",
* summary="取得單一課程(Provider 視角)",
* description="取得自己的指定課程,非本人課程回傳 403",
* operationId="getProviderOffer",
* tags={"教練課程"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
* )
* ),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function getProviderOffer()
{
}
/**
* 更新課程
*
* @OA\Put(
* path="/provider/offers/{id}",
* summary="更新課程",
* operationId="updateProviderOffer",
* tags={"教練課程"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* @OA\Property(property="title", type="string", example="進階開放水域課程"),
* @OA\Property(property="description", type="string", example="AOWD 課程"),
* @OA\Property(property="region", type="string", example="小琉球"),
* @OA\Property(property="price", type="number", format="float", example=12000),
* @OA\Property(property="max_participants", type="integer", example=4),
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"進階","AOWD"}),
* @OA\Property(property="is_active", type="boolean", example=true)
* )
* ),
* @OA\Response(
* response=200,
* description="更新成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
* )
* ),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function updateProviderOffer()
{
}
/**
* 刪除課程
*
* @OA\Delete(
* path="/provider/offers/{id}",
* summary="刪除課程",
* operationId="deleteProviderOffer",
* tags={"教練課程"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="課程已刪除")
* )
* ),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteProviderOffer()
{
}
// -----------------------------------------------------------------------
// Provider Offer Images
// -----------------------------------------------------------------------
/**
* 上傳封面圖片
*
* @OA\Post(
* path="/provider/offers/{id}/cover",
* summary="上傳封面圖片",
* description="上傳課程封面圖片(multipart/form-data",
* operationId="uploadOfferCover",
* tags={"教練圖片"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\MediaType(
* mediaType="multipart/form-data",
* @OA\Schema(
* required={"cover_image"},
* @OA\Property(property="cover_image", type="string", format="binary", description="封面圖片(jpg/png,最大 5MB")
* )
* )
* ),
* @OA\Response(
* response=200,
* description="上傳成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="cover_image_url", type="string", example="https://example.com/covers/1.jpg")
* )
* ),
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function uploadOfferCover()
{
}
/**
* 刪除封面圖片
*
* @OA\Delete(
* path="/provider/offers/{id}/cover",
* summary="刪除封面圖片",
* operationId="deleteOfferCover",
* tags={"教練圖片"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="封面圖片已刪除")
* )
* ),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteOfferCover()
{
}
/**
* 上傳相簿圖片
*
* @OA\Post(
* path="/provider/offers/{id}/images",
* summary="上傳相簿圖片",
* description="新增課程相簿圖片(multipart/form-data,可多張)",
* operationId="uploadOfferImages",
* tags={"教練圖片"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\MediaType(
* mediaType="multipart/form-data",
* @OA\Schema(
* required={"images[]"},
* @OA\Property(
* property="images[]",
* type="array",
* @OA\Items(type="string", format="binary"),
* description="相簿圖片(每張最大 5MB"
* )
* )
* )
* ),
* @OA\Response(
* response=200,
* description="上傳成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="images", type="array", @OA\Items(type="string"), example={"https://example.com/img/1.jpg"})
* )
* ),
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function uploadOfferImages()
{
}
/**
* 刪除相簿圖片
*
* @OA\Delete(
* path="/provider/images/{id}",
* summary="刪除相簿圖片",
* operationId="deleteOfferImage",
* tags={"教練圖片"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="圖片 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="圖片已刪除")
* )
* ),
* @OA\Response(response=403, description="非本人圖片", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="圖片不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteOfferImage()
{
}
// -----------------------------------------------------------------------
// Provider Schedules
// -----------------------------------------------------------------------
/**
* 取得時段列表
*
* @OA\Get(
* path="/provider/schedules",
* summary="取得時段列表",
* description="回傳服務提供者的所有時段,可依 offer_id 篩選",
* operationId="listProviderSchedules",
* tags={"教練時段"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="offer_id", in="query", required=false, description="課程 ID 篩選", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/CourseSchedule"))
* )
* )
* )
*/
public function listProviderSchedules()
{
}
/**
* 建立時段
*
* @OA\Post(
* path="/provider/schedules",
* summary="建立時段",
* operationId="createProviderSchedule",
* tags={"教練時段"},
* security={{"bearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"offer_id","start_date","end_date","available_slots"},
* @OA\Property(property="offer_id", type="integer", example=1),
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-01"),
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-03"),
* @OA\Property(property="available_slots", type="integer", example=4)
* )
* ),
* @OA\Response(
* response=201,
* description="時段建立成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/CourseSchedule")
* )
* ),
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function createProviderSchedule()
{
}
/**
* 更新時段
*
* @OA\Put(
* path="/provider/schedules/{id}",
* summary="更新時段",
* operationId="updateProviderSchedule",
* tags={"教練時段"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="時段 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-05"),
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-07"),
* @OA\Property(property="available_slots", type="integer", example=6),
* @OA\Property(property="is_active", type="boolean", example=true)
* )
* ),
* @OA\Response(
* response=200,
* description="更新成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/CourseSchedule")
* )
* ),
* @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function updateProviderSchedule()
{
}
/**
* 刪除時段
*
* @OA\Delete(
* path="/provider/schedules/{id}",
* summary="刪除時段",
* operationId="deleteProviderSchedule",
* tags={"教練時段"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="時段 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="刪除成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="時段已刪除")
* )
* ),
* @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function deleteProviderSchedule()
{
}
// -----------------------------------------------------------------------
// Provider Bookings Management
// -----------------------------------------------------------------------
/**
* 取得收到的預約列表
*
* @OA\Get(
* path="/provider/bookings",
* summary="取得收到的預約列表",
* description="分頁回傳服務提供者收到的所有預約",
* operationId="listProviderBookings",
* tags={"教練預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數", @OA\Schema(type="integer", default=15)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* )
*/
public function listProviderBookings()
{
}
/**
* 確認預約
*
* @OA\Put(
* path="/provider/bookings/{id}/confirm",
* summary="確認預約",
* operationId="confirmBooking",
* tags={"教練預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="確認成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已確認"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許確認", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function confirmBooking()
{
}
/**
* 拒絕預約
*
* @OA\Put(
* path="/provider/bookings/{id}/reject",
* summary="拒絕預約",
* operationId="rejectBooking",
* tags={"教練預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="拒絕成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已拒絕"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許拒絕", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function rejectBooking()
{
}
/**
* 標記預約完成
*
* @OA\Put(
* path="/provider/bookings/{id}/complete",
* summary="標記預約完成",
* operationId="completeBookingByProvider",
* tags={"教練預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="標記成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已完成"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許完成", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function completeBookingByProvider()
{
}
/**
* 取消預約(Provider
*
* @OA\Put(
* path="/provider/bookings/{id}/cancel",
* summary="取消預約(Provider",
* operationId="cancelBookingByProvider",
* tags={"教練預約"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="預約 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取消成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="預約已取消"),
* @OA\Property(property="data", ref="#/components/schemas/Booking")
* )
* ),
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
public function cancelBookingByProvider()
{
}
}
+247
View File
@@ -0,0 +1,247 @@
<?php
namespace App\Docs;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(
* name="公開課程",
* description="無需認證的公開潛水課程查詢端點"
* )
*
* -----------------------------------------------------------------------
* 共用 Schema 定義
* -----------------------------------------------------------------------
*
* @OA\Schema(
* schema="PaginationMeta",
* type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=5),
* @OA\Property(property="per_page", type="integer", example=15),
* @OA\Property(property="total", type="integer", example=72)
* )
*
* @OA\Schema(
* schema="ApiErrorResponse",
* type="object",
* @OA\Property(property="status", type="boolean", example=false),
* @OA\Property(property="message", type="string", example="操作失敗"),
* @OA\Property(property="errors", type="object", nullable=true)
* )
*
* @OA\Schema(
* schema="DivingOffer",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="provider_id", type="integer", example=3),
* @OA\Property(property="title", type="string", example="基礎開放水域課程"),
* @OA\Property(property="description", type="string", example="適合初學者的 OWD 課程"),
* @OA\Property(property="region", type="string", example="墾丁"),
* @OA\Property(property="price", type="number", format="float", example=8500),
* @OA\Property(property="max_participants", type="integer", example=6),
* @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"初學","OWD"}),
* @OA\Property(property="cover_image_url", type="string", nullable=true, example="https://example.com/image.jpg"),
* @OA\Property(property="images", type="array", @OA\Items(type="string"), example={}),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
* @OA\Property(property="updated_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
*
* @OA\Schema(
* schema="Review",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="offer_id", type="integer", example=1),
* @OA\Property(property="rating", type="integer", minimum=1, maximum=5, example=4),
* @OA\Property(property="comment", type="string", nullable=true, example="課程非常棒!"),
* @OA\Property(property="is_anonymous", type="boolean", example=false),
* @OA\Property(property="helpful_count", type="integer", example=3),
* @OA\Property(property="has_voted", type="boolean", example=false),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
*
* @OA\Schema(
* schema="CourseSchedule",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="offer_id", type="integer", example=1),
* @OA\Property(property="start_date", type="string", format="date", example="2025-07-01"),
* @OA\Property(property="end_date", type="string", format="date", example="2025-07-03"),
* @OA\Property(property="available_slots", type="integer", example=4),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
*
* @OA\Schema(
* schema="Booking",
* type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="member_id", type="integer", example=5),
* @OA\Property(property="offer_id", type="integer", example=1),
* @OA\Property(property="schedule_id", type="integer", example=2),
* @OA\Property(property="status", type="string", enum={"pending","confirmed","rejected","completed","cancelled"}, example="pending"),
* @OA\Property(property="participants", type="integer", example=2),
* @OA\Property(property="note", type="string", nullable=true, example=""),
* @OA\Property(property="created_at", type="string", example="2025-01-01T00:00:00.000000Z"),
* @OA\Property(property="updated_at", type="string", example="2025-01-01T00:00:00.000000Z")
* )
*/
class PublicApiDoc
{
/**
* 查詢潛水課程列表
*
* @OA\Get(
* path="/diving-offers",
* summary="查詢潛水課程列表",
* description="支援關鍵字、地區、標籤篩選,回傳分頁結果",
* operationId="listDivingOffers",
* tags={"公開課程"},
* @OA\Parameter(name="q", in="query", description="關鍵字搜尋", @OA\Schema(type="string")),
* @OA\Parameter(name="region", in="query", description="地區篩選", @OA\Schema(type="string", example="墾丁")),
* @OA\Parameter(name="tag", in="query", description="標籤篩選", @OA\Schema(type="string", example="OWD")),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數(預設 15,最大 50", @OA\Schema(type="integer", default=15, maximum=50)),
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Response(
* response=200,
* description="查詢成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(ref="#/components/schemas/DivingOffer")
* ),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* )
*/
public function listDivingOffers()
{
}
/**
* 取得單一潛水課程
*
* @OA\Get(
* path="/diving-offers/{id}",
* summary="取得單一潛水課程",
* description="回傳課程詳情,包含封面圖片與相簿",
* operationId="getDivingOffer",
* tags={"公開課程"},
* @OA\Parameter(
* name="id",
* in="path",
* required=true,
* description="課程 ID",
* @OA\Schema(type="integer")
* ),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
* )
* ),
* @OA\Response(
* response=404,
* description="課程不存在",
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
* )
* )
*/
public function getDivingOffer()
{
}
/**
* 取得課程評價列表
*
* @OA\Get(
* path="/diving-offers/{id}/reviews",
* summary="取得課程評價列表",
* description="回傳評分摘要、分頁評價列表與分頁 meta",
* operationId="listOfferReviews",
* tags={"公開課程"},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Parameter(name="sort", in="query", description="排序方式(newest / helpful", @OA\Schema(type="string", enum={"newest","helpful"}, default="newest")),
* @OA\Parameter(name="page", in="query", description="頁碼", @OA\Schema(type="integer", default=1)),
* @OA\Parameter(name="per_page", in="query", description="每頁筆數(預設 15,最大 50", @OA\Schema(type="integer", default=15, maximum=50)),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(
* property="summary",
* type="object",
* @OA\Property(property="average_rating", type="number", format="float", example=4.2),
* @OA\Property(property="total_reviews", type="integer", example=24),
* @OA\Property(property="distribution", type="object",
* @OA\Property(property="1", type="integer", example=1),
* @OA\Property(property="2", type="integer", example=2),
* @OA\Property(property="3", type="integer", example=3),
* @OA\Property(property="4", type="integer", example=8),
* @OA\Property(property="5", type="integer", example=10)
* )
* ),
* @OA\Property(
* property="reviews",
* type="array",
* @OA\Items(ref="#/components/schemas/Review")
* ),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* )
* )
* ),
* @OA\Response(
* response=404,
* description="課程不存在",
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
* )
* )
*/
public function listOfferReviews()
{
}
/**
* 取得課程時段列表
*
* @OA\Get(
* path="/diving-offers/{id}/schedules",
* summary="取得課程時段列表",
* description="回傳指定課程的所有可用時段",
* operationId="listOfferSchedules",
* tags={"公開課程"},
* @OA\Parameter(name="id", in="path", required=true, description="課程 ID", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="取得成功",
* @OA\JsonContent(
* @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(ref="#/components/schemas/CourseSchedule")
* )
* )
* ),
* @OA\Response(
* response=404,
* description="課程不存在",
* @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")
* )
* )
*/
public function listOfferSchedules()
{
}
}
+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,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,
]);
}
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\DivingOffer; use App\Models\DivingOffer;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Cache;
class AdminStatsController extends Controller class AdminStatsController extends Controller
{ {
@@ -14,13 +15,12 @@ class AdminStatsController extends Controller
return response()->json(['status' => false, 'message' => '無權限存取'], 403); return response()->json(['status' => false, 'message' => '無權限存取'], 403);
} }
return response()->json([ $stats = Cache::remember('admin_stats', 300, fn() => [
'status' => true,
'data' => [
'total_members' => User::where('role', 'member')->count(), 'total_members' => User::where('role', 'member')->count(),
'total_providers' => User::where('role', 'provider')->count(), 'total_providers' => User::where('role', 'provider')->count(),
'total_offers' => DivingOffer::count(), 'total_offers' => DivingOffer::count(),
],
]); ]);
return response()->json(['status' => true, 'data' => $stats]);
} }
} }
@@ -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' => '圖片已刪除']);
}
}
@@ -5,13 +5,16 @@ namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\DivingOffer; use App\Models\DivingOffer;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class DivingOfferController extends Controller class DivingOfferController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
$perPage = min((int) $request->query('per_page', 12), 50); $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(); $query = DivingOffer::query();
if ($q = $request->query('q')) { if ($q = $request->query('q')) {
@@ -32,32 +35,49 @@ class DivingOfferController extends Controller
$paginated = $query->paginate($perPage); $paginated = $query->paginate($perPage);
return response()->json([ return [
'status' => true, 'items' => collect($paginated->items())->map(fn($o) => $this->formatOffer($o, false))->values(),
'data' => $paginated->items(),
'meta' => [ 'meta' => [
'total' => $paginated->total(), 'total' => $paginated->total(),
'per_page' => $paginated->perPage(), 'per_page' => $paginated->perPage(),
'current_page' => $paginated->currentPage(), 'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(), 'last_page' => $paginated->lastPage(),
], ],
];
});
return response()->json([
'status' => true,
'data' => $result['items'],
'meta' => $result['meta'],
]); ]);
} }
public function show(int $id) public function show(int $id)
{ {
$offer = DivingOffer::find($id); $offer = DivingOffer::with('courseImages')->find($id);
if (!$offer) { if (!$offer) {
return response()->json([ return response()->json(['status' => false, 'message' => '課程不存在'], 404);
'status' => false,
'message' => '課程不存在',
], 404);
} }
return response()->json([ return response()->json(['status' => true, 'data' => $this->formatOffer($offer, true)]);
'status' => true, }
'data' => $offer,
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(),
];
}
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\DivingOffer; use App\Models\DivingOffer;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class ProviderOfferController extends Controller class ProviderOfferController extends Controller
{ {
@@ -60,6 +61,8 @@ class ProviderOfferController extends Controller
$offer = DivingOffer::create($validated); $offer = DivingOffer::create($validated);
Cache::tags(['diving_offers'])->flush();
return response()->json(['status' => true, 'data' => $offer], 201); return response()->json(['status' => true, 'data' => $offer], 201);
} }
@@ -89,6 +92,8 @@ class ProviderOfferController extends Controller
$offer->fill($validated)->save(); $offer->fill($validated)->save();
Cache::tags(['diving_offers'])->flush();
return response()->json(['status' => true, 'data' => $offer]); return response()->json(['status' => true, 'data' => $offer]);
} }
@@ -106,6 +111,8 @@ class ProviderOfferController extends Controller
$offer->delete(); $offer->delete();
Cache::tags(['diving_offers'])->flush();
return response()->json(['status' => true, 'message' => '課程已刪除']); 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,
];
}
}
+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');
}
}
+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,
);
}
}
+37
View File
@@ -3,6 +3,8 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class DivingOffer extends Model class DivingOffer extends Model
{ {
@@ -22,6 +24,7 @@ class DivingOffer extends Model
'description', 'description',
'tag', 'tag',
'region', 'region',
'cover_image',
]; ];
protected $casts = [ protected $casts = [
@@ -30,4 +33,38 @@ class DivingOffer extends Model
'price' => 'integer', 'price' => 'integer',
'reviews'=> '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);
}
} }
+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
+6 -2
View File
@@ -17,9 +17,13 @@ return [
'paths' => ['api/*', 'sanctum/csrf-cookie'], 'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], 'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')], 'allowed_origins' => [
env('FRONTEND_URL', 'http://localhost:5173'),
'http://127.0.0.1:5173',
'http://localhost:5173',
],
'allowed_origins_patterns' => [], 'allowed_origins_patterns' => [],
@@ -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');
}
}
+45 -25
View File
@@ -1,7 +1,4 @@
# Docker Compose 版本
version: '3.8'
# 定義所有服務
services: services:
# PHP-FPM 服務 # PHP-FPM 服務
app: app:
@@ -23,13 +20,14 @@ services:
- DB_CONNECTION=mysql # 數據庫類型 - DB_CONNECTION=mysql # 數據庫類型
- DB_HOST=db # 數據庫主機名 - DB_HOST=db # 數據庫主機名
- DB_PORT=3306 # 數據庫端口 - DB_PORT=3306 # 數據庫端口
- DB_DATABASE=CFDivePlatform # 數據庫名稱 - DB_DATABASE=${DB_DATABASE:-CFDivePlatform} # 數據庫名稱
- DB_USERNAME=cfdiveuser # 數據庫用戶名 - DB_USERNAME=${DB_USERNAME:-cfdiveuser} # 數據庫用戶名
- DB_PASSWORD=**REMOVED** # 數據庫密碼 - DB_PASSWORD=${DB_PASSWORD} # 數據庫密碼
# 網絡配置 # 網絡配置
networks: networks:
- cfdive-network # 連接到自定義網絡 - cfdive-network # 連接到自定義網絡
- proxy_net
# 依賴關係:確保 db 和 redis 服務先啟動 # 依賴關係:確保 db 和 redis 服務先啟動
depends_on: depends_on:
@@ -50,13 +48,12 @@ services:
image: nginx:alpine image: nginx:alpine
container_name: cfdive-nginx container_name: cfdive-nginx
restart: unless-stopped restart: unless-stopped
ports:
- 8080:80
volumes: volumes:
- ./:/var/www - ./:/var/www
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/ - ./docker/nginx/conf.d/:/etc/nginx/conf.d/
networks: networks:
- cfdive-network - cfdive-network
- proxy_net
depends_on: depends_on:
app: app:
condition: service_healthy condition: service_healthy
@@ -71,34 +68,31 @@ services:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
VITE_API_URL: http://localhost:8080 VITE_API_URL: ${VITE_API_URL:-https://api.hank-spack.com}
image: cfdive-frontend image: cfdive-frontend
container_name: cfdive-frontend container_name: cfdive-frontend
restart: unless-stopped restart: unless-stopped
ports:
- "5173:80"
networks: networks:
- cfdive-network - cfdive-network
- proxy_net
db: db:
image: mysql:8.0 image: mysql:8.0
container_name: cfdive-db container_name: cfdive-db
restart: unless-stopped restart: unless-stopped
environment: environment:
MYSQL_DATABASE: CFDivePlatform MYSQL_DATABASE: ${DB_DATABASE:-CFDivePlatform}
MYSQL_ROOT_PASSWORD: **REMOVED** MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_USER: cfdiveuser MYSQL_USER: ${DB_USERNAME:-cfdiveuser}
MYSQL_PASSWORD: **REMOVED** MYSQL_PASSWORD: ${DB_PASSWORD}
SERVICE_TAGS: dev SERVICE_TAGS: dev
SERVICE_NAME: mysql SERVICE_NAME: mysql
volumes: volumes:
- mysql-data:/var/lib/mysql - mysql-data:/var/lib/mysql
ports:
- "3306:3306"
networks: networks:
- cfdive-network - cfdive-network
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "cfdiveuser", "-p**REMOVED**"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
@@ -108,26 +102,50 @@ services:
image: phpmyadmin/phpmyadmin image: phpmyadmin/phpmyadmin
container_name: cfdive-phpmyadmin container_name: cfdive-phpmyadmin
restart: unless-stopped restart: unless-stopped
ports:
- "8081:80"
environment: environment:
PMA_HOST: db PMA_HOST: db
PMA_PORT: 3306 PMA_PORT: 3306
PMA_USER: cfdiveuser PMA_USER: ${DB_USERNAME:-cfdiveuser}
PMA_PASSWORD: **REMOVED** PMA_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: **REMOVED** MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
networks: networks:
- cfdive-network - cfdive-network
depends_on: depends_on:
db: db:
condition: service_healthy 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=${DB_DATABASE:-CFDivePlatform}
- DB_USERNAME=${DB_USERNAME:-cfdiveuser}
- DB_PASSWORD=${DB_PASSWORD}
networks:
- cfdive-network
depends_on:
app:
condition: service_healthy
mailpit:
image: axllent/mailpit
container_name: cfdive-mailpit
restart: unless-stopped
networks:
- cfdive-network
redis: redis:
image: redis:alpine image: redis:alpine
container_name: cfdive-redis container_name: cfdive-redis
restart: unless-stopped restart: unless-stopped
ports:
- "6379:6379"
networks: networks:
- cfdive-network - cfdive-network
healthcheck: healthcheck:
@@ -139,6 +157,8 @@ services:
networks: networks:
cfdive-network: cfdive-network:
driver: bridge driver: bridge
proxy_net:
external: true
volumes: volumes:
mysql-data: mysql-data:
+1 -1
View File
@@ -1,7 +1,7 @@
# 定義一個 HTTP 服務器塊 # 定義一個 HTTP 服務器塊
server { server {
# 監聽 80 端口(HTTP
listen 80; listen 80;
server_name cfdive.local localhost;
# 默認索引文件,按順序嘗試 # 默認索引文件,按順序嘗試
index index.php index.html; index index.php index.html;
+8
View File
@@ -110,5 +110,13 @@ fi
echo "✅ CFDivePlatform 初始化完成!" 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 "$@" exec "$@"
+3 -13
View File
@@ -1,22 +1,11 @@
<script setup> <script setup>
import { computed, onMounted } from 'vue' import { computed } from 'vue'
import { useAuthStore } from './stores/auth'
import { useCoachAuthStore } from './stores/coachAuth'
import { useAdminAuthStore } from './stores/adminAuth'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import NavBar from './components/NavBar.vue' import NavBar from './components/NavBar.vue'
import NotificationDrawer from './components/NotificationDrawer.vue'
const auth = useAuthStore()
const coachAuth = useCoachAuthStore()
const adminAuth = useAdminAuthStore()
const route = useRoute() const route = useRoute()
onMounted(() => {
auth.init()
coachAuth.init()
adminAuth.init()
})
const isBackofficePage = computed(() => const isBackofficePage = computed(() =>
route.path.startsWith('/coach') || route.path.startsWith('/admin') route.path.startsWith('/coach') || route.path.startsWith('/admin')
) )
@@ -26,5 +15,6 @@ const isBackofficePage = computed(() =>
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
<NavBar v-if="!isBackofficePage" /> <NavBar v-if="!isBackofficePage" />
<RouterView /> <RouterView />
<NotificationDrawer />
</div> </div>
</template> </template>
+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}`)
}
+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`)
}
+2
View File
@@ -20,6 +20,8 @@ async function handleLogout() {
<RouterLink to="/admin/members" 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/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/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>
<div class="flex items-center gap-4 text-sm"> <div class="flex items-center gap-4 text-sm">
<span class="text-slate-400">{{ adminAuth.user?.name }}</span> <span class="text-slate-400">{{ adminAuth.user?.name }}</span>
+5
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { useCoachAuthStore } from '../stores/coachAuth' import { useCoachAuthStore } from '../stores/coachAuth'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import NotificationBell from './NotificationBell.vue'
const coachAuth = useCoachAuthStore() const coachAuth = useCoachAuthStore()
const router = useRouter() const router = useRouter()
@@ -19,11 +20,15 @@ async function handleLogout() {
🤿 Coach Portal 🤿 Coach Portal
</RouterLink> </RouterLink>
<RouterLink to="/coach/dashboard" class="text-sm hover:text-gray-300 transition">我的課程</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> <RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
</div> </div>
<div class="flex items-center gap-4 text-sm"> <div class="flex items-center gap-4 text-sm">
<span class="text-gray-400">{{ coachAuth.user?.name }}</span> <span class="text-gray-400">{{ coachAuth.user?.name }}</span>
<NotificationBell />
<button <button
@click="handleLogout" @click="handleLogout"
class="bg-gray-700 hover:bg-gray-600 px-4 py-1.5 rounded-full transition" class="bg-gray-700 hover:bg-gray-600 px-4 py-1.5 rounded-full transition"
+11 -1
View File
@@ -9,7 +9,17 @@ defineProps({
:to="`/courses/${offer.id}`" :to="`/courses/${offer.id}`"
class="bg-white rounded-2xl shadow hover:shadow-lg transition overflow-hidden flex flex-col" class="bg-white rounded-2xl shadow hover:shadow-lg transition overflow-hidden flex flex-col"
> >
<div class="bg-ocean-700 h-40 flex items-center justify-center text-white text-5xl">🤿</div> <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="p-4 flex flex-col gap-2 flex-1">
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
+3
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import NotificationBell from './NotificationBell.vue'
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter() const router = useRouter()
@@ -25,7 +26,9 @@ async function handleLogout() {
<span class="text-ocean-200 hidden sm:inline"> <span class="text-ocean-200 hidden sm:inline">
👤 {{ auth.user?.name }} 👤 {{ auth.user?.name }}
</span> </span>
<RouterLink to="/my-bookings" class="hover:text-ocean-100 transition">我的預約</RouterLink>
<RouterLink to="/profile" class="hover:text-ocean-100 transition">個人資料</RouterLink> <RouterLink to="/profile" class="hover:text-ocean-100 transition">個人資料</RouterLink>
<NotificationBell />
<button <button
@click="handleLogout" @click="handleLogout"
class="bg-ocean-600 hover:bg-ocean-500 px-4 py-1.5 rounded-full transition" class="bg-ocean-600 hover:bg-ocean-500 px-4 py-1.5 rounded-full transition"
@@ -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>
+13 -1
View File
@@ -3,8 +3,20 @@ import { createPinia } from 'pinia'
import './style.css' import './style.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { useAuthStore } from './stores/auth'
import { useCoachAuthStore } from './stores/coachAuth'
import { useAdminAuthStore } from './stores/adminAuth'
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) const pinia = createPinia()
app.use(pinia)
// 在 router 安裝前同步初始化所有 auth store,
// 確保 beforeEach guard 跑時 isLoggedIn 已反映 localStorage 的實際狀態
useAuthStore().init()
useCoachAuthStore().init()
useAdminAuthStore().init()
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')
+6
View File
@@ -12,6 +12,7 @@ const routes = [
{ path: '/register', component: () => import('../views/RegisterView.vue') }, { path: '/register', component: () => import('../views/RegisterView.vue') },
{ path: '/auth/callback', component: () => import('../views/AuthCallbackView.vue') }, { path: '/auth/callback', component: () => import('../views/AuthCallbackView.vue') },
{ path: '/profile', component: () => import('../views/ProfileView.vue'), meta: { requiresAuth: true } }, { path: '/profile', component: () => import('../views/ProfileView.vue'), meta: { requiresAuth: true } },
{ path: '/my-bookings', component: () => import('../views/MyBookingsView.vue'), meta: { requiresAuth: true } },
// Coach (public) // Coach (public)
{ path: '/coach/login', component: () => import('../views/coach/LoginView.vue') }, { path: '/coach/login', component: () => import('../views/coach/LoginView.vue') },
@@ -26,6 +27,9 @@ const routes = [
{ path: 'offers/new', component: () => import('../views/coach/OfferFormView.vue') }, { path: 'offers/new', component: () => import('../views/coach/OfferFormView.vue') },
{ path: 'offers/:id/edit', component: () => import('../views/coach/OfferFormView.vue') }, { path: 'offers/:id/edit', component: () => import('../views/coach/OfferFormView.vue') },
{ path: 'profile', component: () => import('../views/coach/ProfileView.vue') }, { path: 'profile', component: () => import('../views/coach/ProfileView.vue') },
{ path: 'schedules', component: () => import('../views/coach/ScheduleManagerView.vue') },
{ path: 'bookings', component: () => import('../views/coach/BookingManagerView.vue') },
{ path: 'reviews', component: () => import('../views/coach/ReviewsView.vue') },
], ],
}, },
@@ -41,6 +45,8 @@ const routes = [
{ path: 'members', component: () => import('../views/admin/MembersView.vue') }, { path: 'members', component: () => import('../views/admin/MembersView.vue') },
{ path: 'providers', component: () => import('../views/admin/ProvidersView.vue') }, { path: 'providers', component: () => import('../views/admin/ProvidersView.vue') },
{ path: 'offers', component: () => import('../views/admin/OffersView.vue') }, { path: 'offers', component: () => import('../views/admin/OffersView.vue') },
{ path: 'bookings', component: () => import('../views/admin/BookingsView.vue') },
{ path: 'reviews', component: () => import('../views/admin/ReviewsView.vue') },
], ],
}, },
] ]
+4
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import api from '../api/axios' import api from '../api/axios'
import { useNotificationStore } from './notifications'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const user = ref(null) const user = ref(null)
@@ -14,6 +15,7 @@ export const useAuthStore = defineStore('auth', () => {
if (saved) { if (saved) {
token.value = saved token.value = saved
user.value = savedUser ? JSON.parse(savedUser) : null user.value = savedUser ? JSON.parse(savedUser) : null
useNotificationStore().startPolling()
} }
} }
@@ -22,12 +24,14 @@ export const useAuthStore = defineStore('auth', () => {
token.value = tokenValue token.value = tokenValue
localStorage.setItem('token', tokenValue) localStorage.setItem('token', tokenValue)
localStorage.setItem('user', JSON.stringify(userData)) localStorage.setItem('user', JSON.stringify(userData))
useNotificationStore().startPolling()
} }
async function logout() { async function logout() {
try { try {
await api.post('/member/logout') await api.post('/member/logout')
} catch {} } catch {}
useNotificationStore().stopPolling()
user.value = null user.value = null
token.value = null token.value = null
localStorage.removeItem('token') localStorage.removeItem('token')
+4
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import coachApi from '../api/coachAxios' import coachApi from '../api/coachAxios'
import { useNotificationStore } from './notifications'
export const useCoachAuthStore = defineStore('coachAuth', () => { export const useCoachAuthStore = defineStore('coachAuth', () => {
const user = ref(null) const user = ref(null)
@@ -14,6 +15,7 @@ export const useCoachAuthStore = defineStore('coachAuth', () => {
if (savedToken) { if (savedToken) {
token.value = savedToken token.value = savedToken
user.value = savedUser ? JSON.parse(savedUser) : null user.value = savedUser ? JSON.parse(savedUser) : null
useNotificationStore().startPolling()
} }
} }
@@ -22,12 +24,14 @@ export const useCoachAuthStore = defineStore('coachAuth', () => {
token.value = tokenValue token.value = tokenValue
localStorage.setItem('coach_token', tokenValue) localStorage.setItem('coach_token', tokenValue)
localStorage.setItem('coach_user', JSON.stringify(userData)) localStorage.setItem('coach_user', JSON.stringify(userData))
useNotificationStore().startPolling()
} }
async function logout() { async function logout() {
try { try {
await coachApi.post('/provider/logout') await coachApi.post('/provider/logout')
} catch {} } catch {}
useNotificationStore().stopPolling()
user.value = null user.value = null
token.value = null token.value = null
localStorage.removeItem('coach_token') localStorage.removeItem('coach_token')
+115
View File
@@ -0,0 +1,115 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '../api/notificationAxios'
export const useNotificationStore = defineStore('notifications', () => {
const unreadCount = ref(0)
const notifications = ref([])
const isOpen = ref(false)
let intervalId = null
let currentInterval = null
let visibilityHandler = null
async function fetchUnreadCount() {
try {
const res = await api.get('/notifications/unread-count')
const newCount = res.data?.data?.count ?? 0
if (newCount !== unreadCount.value) {
const wasZero = unreadCount.value === 0
unreadCount.value = newCount
if ((wasZero && newCount > 0) || (!wasZero && newCount === 0)) {
restartInterval()
}
}
} catch (e) {
console.error('[NotificationStore] fetchUnreadCount failed:', e?.response?.status, e?.message)
}
}
async function fetchNotifications() {
try {
const res = await api.get('/notifications')
notifications.value = res.data.data
unreadCount.value = res.data.unread_count
} catch (e) {
console.error('[NotificationStore] fetchNotifications failed:', e?.response?.status, e?.message)
}
}
function getInterval() {
return unreadCount.value > 0 ? 30000 : 60000
}
function restartInterval() {
if (intervalId) clearInterval(intervalId)
const ms = getInterval()
currentInterval = ms
intervalId = setInterval(fetchUnreadCount, ms)
}
function startPolling() {
fetchUnreadCount()
restartInterval()
visibilityHandler = () => {
if (document.visibilityState === 'hidden') {
if (intervalId) clearInterval(intervalId)
intervalId = null
} else {
fetchUnreadCount()
restartInterval()
}
}
document.addEventListener('visibilitychange', visibilityHandler)
}
function stopPolling() {
if (intervalId) clearInterval(intervalId)
intervalId = null
if (visibilityHandler) {
document.removeEventListener('visibilitychange', visibilityHandler)
visibilityHandler = null
}
unreadCount.value = 0
notifications.value = []
isOpen.value = false
}
async function markRead(id) {
const n = notifications.value.find(n => n.id === id)
if (n && !n.read_at) {
n.read_at = new Date().toISOString()
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
try {
await api.patch(`/notifications/${id}/read`)
} catch {}
}
async function markAllRead() {
notifications.value.forEach(n => {
if (!n.read_at) n.read_at = new Date().toISOString()
})
unreadCount.value = 0
try {
await api.patch('/notifications/read-all')
} catch {}
}
async function remove(id) {
const n = notifications.value.find(n => n.id === id)
if (n && !n.read_at) unreadCount.value = Math.max(0, unreadCount.value - 1)
notifications.value = notifications.value.filter(n => n.id !== id)
try {
await api.delete(`/notifications/${id}`)
} catch {}
}
return {
unreadCount, notifications, isOpen,
fetchNotifications, fetchUnreadCount,
startPolling, stopPolling,
markRead, markAllRead, remove,
}
})
+268 -4
View File
@@ -2,24 +2,111 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import api from '../api/axios' import api from '../api/axios'
import { getSchedulesByOffer } from '../api/scheduleApi'
import { createBooking } from '../api/bookingApi'
import { getReviews, createReview, updateReview, deleteReview, toggleHelpful } from '../api/reviewApi'
import { useAuthStore } from '../stores/auth'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const auth = useAuthStore()
const offer = ref(null) const offer = ref(null)
const loading = ref(true) const loading = ref(true)
const notFound = ref(false) const notFound = ref(false)
const schedules = ref([])
const selected = ref(null)
const participants = ref(1)
const booking = ref({ loading: false, success: false, error: '' })
// 評價相關
const reviewSort = ref('helpful')
const reviewSummary = ref(null)
const reviews = ref([])
const myReview = ref(null)
const reviewForm = ref({ show: false, rating: 5, comment: '', saving: false, error: '' })
const editTarget = ref(null)
onMounted(async () => { onMounted(async () => {
try { try {
const res = await api.get(`/diving-offers/${route.params.id}`) const res = await api.get(`/diving-offers/${route.params.id}`)
offer.value = res.data.data offer.value = res.data.data
const [sRes, rRes] = await Promise.all([
getSchedulesByOffer(route.params.id),
getReviews(route.params.id, reviewSort.value),
])
schedules.value = sRes.data.data
applyReviewData(rRes.data.data)
} catch (e) { } catch (e) {
notFound.value = true notFound.value = true
} finally { } finally {
loading.value = false loading.value = false
} }
}) })
function applyReviewData(data) {
reviewSummary.value = data.summary
reviews.value = data.reviews
myReview.value = data.reviews.find(r => r.is_mine) || null
}
async function switchSort(sort) {
reviewSort.value = sort
const res = await getReviews(route.params.id, sort)
applyReviewData(res.data.data)
}
async function submitReview() {
reviewForm.value.saving = true
reviewForm.value.error = ''
try {
if (editTarget.value) {
await updateReview(editTarget.value.id, { rating: reviewForm.value.rating, comment: reviewForm.value.comment })
} else {
await createReview({ diving_offer_id: offer.value.id, rating: reviewForm.value.rating, comment: reviewForm.value.comment })
}
reviewForm.value.show = false
editTarget.value = null
const res = await getReviews(route.params.id, reviewSort.value)
applyReviewData(res.data.data)
} catch (e) {
reviewForm.value.error = e.response?.data?.message || '送出失敗'
} finally {
reviewForm.value.saving = false
}
}
function openEdit(review) {
editTarget.value = review
reviewForm.value = { show: true, rating: review.rating, comment: review.comment, saving: false, error: '' }
}
async function doDeleteReview(review) {
if (!confirm('確定要刪除此評價?')) return
await deleteReview(review.id)
const res = await getReviews(route.params.id, reviewSort.value)
applyReviewData(res.data.data)
}
async function doToggleHelpful(review) {
if (!auth.isLoggedIn) return
const res = await toggleHelpful(review.id)
review.helpful_count = res.data.data.helpful_count
review.has_voted = res.data.data.has_voted
}
async function submitBooking() {
if (!selected.value) return
booking.value = { loading: true, success: false, error: '' }
try {
await createBooking({ schedule_id: selected.value.id, participants: participants.value })
booking.value.success = true
} catch (e) {
booking.value.error = e.response?.data?.message || '預約失敗,請稍後再試'
} finally {
booking.value.loading = false
}
}
</script> </script>
<template> <template>
@@ -38,7 +125,31 @@ onMounted(async () => {
返回課程列表 返回課程列表
</RouterLink> </RouterLink>
<div class="bg-ocean-700 rounded-2xl h-56 flex items-center justify-center text-white text-7xl mb-6">🤿</div> <!-- 封面大圖 -->
<div class="rounded-2xl h-64 overflow-hidden mb-4">
<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-7xl">
🤿
</div>
</div>
<!-- 相簿縮圖列 -->
<div v-if="offer.images && offer.images.length > 0" class="flex gap-2 mb-6">
<a
v-for="img in offer.images"
:key="img.id"
:href="img.url"
target="_blank"
class="w-24 h-20 rounded-xl overflow-hidden shrink-0 border-2 border-transparent hover:border-ocean-400 transition"
>
<img :src="img.url" class="w-full h-full object-cover" />
</a>
</div>
<div class="flex flex-wrap gap-2 mb-3"> <div class="flex flex-wrap gap-2 mb-3">
<span <span
@@ -65,15 +176,168 @@ onMounted(async () => {
<p class="text-gray-700 leading-relaxed whitespace-pre-wrap">{{ offer.description || '暫無課程說明。' }}</p> <p class="text-gray-700 leading-relaxed whitespace-pre-wrap">{{ offer.description || '暫無課程說明。' }}</p>
</div> </div>
<div class="flex items-center justify-between bg-ocean-50 rounded-2xl p-6"> <div class="flex items-center justify-between bg-ocean-50 rounded-2xl p-6 mb-6">
<div> <div>
<p class="text-sm text-gray-500">課程費用</p> <p class="text-sm text-gray-500">課程費用</p>
<p class="text-3xl font-bold text-ocean-800">NT$ {{ offer.price.toLocaleString() }}</p> <p class="text-3xl font-bold text-ocean-800">NT$ {{ offer.price.toLocaleString() }}</p>
</div> </div>
<button class="bg-ocean-700 hover:bg-ocean-600 text-white font-semibold px-8 py-3 rounded-full transition"> </div>
立即洽詢
<!-- 可用時段 -->
<div class="bg-white rounded-2xl shadow p-6 mb-6">
<div class="flex items-start gap-2 mb-4">
<h2 class="text-lg font-semibold text-gray-800">可預約時段</h2>
</div>
<div class="flex items-center gap-2 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-4 text-sm text-amber-700">
<span></span>
<span>送出預約後需等待教練確認確認後才算預約成功</span>
</div>
<div v-if="schedules.length === 0" class="text-gray-400 text-sm">目前沒有開放時段</div>
<div v-else class="space-y-3">
<label
v-for="s in schedules"
:key="s.id"
class="flex items-center justify-between border rounded-xl px-4 py-3 cursor-pointer transition"
:class="selected?.id === s.id ? 'border-ocean-600 bg-ocean-50' : 'border-gray-200 hover:border-ocean-400'"
>
<div class="flex items-center gap-3">
<input type="radio" :value="s" v-model="selected" class="accent-ocean-600" />
<div>
<p class="font-medium text-gray-800">{{ s.scheduled_date }} {{ s.start_time }}</p>
<p class="text-sm text-gray-500">剩餘名額{{ s.remaining_spots }} </p>
</div>
</div>
<p class="text-ocean-700 font-semibold">NT$ {{ (offer.price * participants).toLocaleString() }}</p>
</label>
</div>
<!-- 人數選擇與預約按鈕 -->
<div v-if="selected" class="mt-5 border-t pt-4">
<div class="flex items-center gap-4 mb-4">
<label class="text-sm text-gray-600">預約人數</label>
<input
v-model.number="participants"
type="number"
min="1"
:max="selected.remaining_spots"
class="border rounded-lg px-3 py-1 w-20 text-center"
/>
</div>
<div v-if="booking.success" class="text-green-600 text-sm mb-3">✓ 預約已送出!請等待教練確認。前往 <RouterLink to="/my-bookings" class="underline">我的預約</RouterLink> 查看</div>
<div v-if="booking.error" class="text-red-500 text-sm mb-3">{{ booking.error }}</div>
<div v-if="!auth.isLoggedIn" class="text-sm text-gray-500">
請先 <RouterLink to="/login" class="text-ocean-600 underline">登入</RouterLink> 才能預約
</div>
<button
v-else
@click="submitBooking"
:disabled="booking.loading || booking.success"
class="w-full bg-ocean-700 hover:bg-ocean-600 disabled:opacity-50 text-white font-semibold py-3 rounded-full transition"
>
{{ booking.loading ? '送出中...' : booking.success ? '已送出預約' : '立即預約' }}
</button> </button>
</div> </div>
</div>
<!-- 評價區塊 -->
<div class="bg-white rounded-2xl shadow p-6 mb-6">
<!-- 標題 + 排序 -->
<div class="flex items-center justify-between mb-5 flex-wrap gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-800">課程評價</h2>
<p v-if="reviewSummary" class="text-sm text-gray-500 mt-0.5">
{{ reviewSummary.average }} · {{ reviewSummary.total }} 則評價
</p>
</div>
<div class="flex gap-2 text-sm">
<button v-for="s in [['helpful','最多幫助'],['rating','最高分'],['newest','最新']]" :key="s[0]"
@click="switchSort(s[0])"
:class="reviewSort === s[0]
? 'bg-ocean-700 text-white px-3 py-1 rounded-full'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 px-3 py-1 rounded-full transition'">
{{ s[1] }}
</button>
</div>
</div>
<!-- 星等分布條 -->
<div v-if="reviewSummary?.total > 0" class="space-y-1 mb-6">
<div v-for="star in [5,4,3,2,1]" :key="star" class="flex items-center gap-2 text-sm">
<span class="w-8 text-right text-gray-500">{{ star }}</span>
<div class="flex-1 bg-gray-100 rounded-full h-2">
<div class="bg-yellow-400 h-2 rounded-full transition-all"
:style="`width:${reviewSummary.total > 0 ? (reviewSummary.distribution[star] / reviewSummary.total * 100) : 0}%`">
</div>
</div>
<span class="w-6 text-gray-400 text-xs">{{ reviewSummary.distribution[star] }}</span>
</div>
</div>
<!-- 我的評價 / 新增表單 -->
<div v-if="auth.isLoggedIn" class="mb-5">
<div v-if="!myReview && !reviewForm.show">
<button @click="reviewForm = { show: true, rating: 5, comment: '', saving: false, error: '' }; editTarget = null"
class="text-sm text-ocean-600 hover:underline">
+ 撰寫評價
</button>
</div>
<div v-if="reviewForm.show" class="border border-ocean-200 rounded-xl p-4 bg-ocean-50">
<p class="text-sm font-medium text-gray-700 mb-3">{{ editTarget ? '修改評價' : '撰寫評價' }}</p>
<!-- 星等選擇 -->
<div class="flex gap-1 mb-3">
<button v-for="n in [1,2,3,4,5]" :key="n" @click="reviewForm.rating = n"
:class="n <= reviewForm.rating ? 'text-yellow-400' : 'text-gray-300'"
class="text-2xl transition"></button>
</div>
<textarea v-model="reviewForm.comment" rows="3" placeholder="分享你的課程體驗..."
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ocean-400" />
<p v-if="reviewForm.error" class="text-red-500 text-xs mt-1">{{ reviewForm.error }}</p>
<div class="flex gap-2 mt-3">
<button @click="submitReview" :disabled="reviewForm.saving"
class="bg-ocean-700 text-white text-sm px-4 py-1.5 rounded-full hover:bg-ocean-600 transition disabled:opacity-60">
{{ reviewForm.saving ? '送出中...' : '送出' }}
</button>
<button @click="reviewForm.show = false; editTarget = null"
class="text-sm text-gray-500 hover:text-gray-700 px-4 py-1.5">取消</button>
</div>
</div>
</div>
<!-- 評價列表 -->
<div v-if="reviews.length === 0" class="text-gray-400 text-sm py-4 text-center">尚無評價</div>
<div v-else class="space-y-4">
<div v-for="r in reviews" :key="r.id"
class="pb-4 border-b border-gray-100 last:border-0">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-2">
<span class="text-yellow-400 text-sm">{{ '★'.repeat(r.rating) }}{{ '☆'.repeat(5 - r.rating) }}</span>
<span class="text-xs text-gray-400">{{ r.reviewer_name }}</span>
<span v-if="r.is_edited" class="text-xs text-gray-400">已修改</span>
</div>
<p class="text-sm text-gray-700 mt-1 leading-relaxed">{{ r.comment }}</p>
<p class="text-xs text-gray-400 mt-1">{{ new Date(r.created_at).toLocaleDateString('zh-TW') }}</p>
</div>
<!-- 本人操作 -->
<div v-if="r.is_mine" class="flex gap-2 text-xs ml-3 shrink-0">
<button @click="openEdit(r)" class="text-ocean-600 hover:underline">修改</button>
<button @click="doDeleteReview(r)" class="text-red-400 hover:underline">刪除</button>
</div>
</div>
<!-- 有幫助 -->
<button @click="doToggleHelpful(r)"
:class="r.has_voted ? 'text-ocean-600' : 'text-gray-400 hover:text-gray-600'"
class="mt-2 text-xs flex items-center gap-1 transition"
:disabled="!auth.isLoggedIn">
👍 有幫助 {{ r.helpful_count > 0 ? `(${r.helpful_count})` : '' }}
</button>
</div>
</div>
</div>
</template> </template>
</main> </main>
+166
View File
@@ -0,0 +1,166 @@
<script setup>
import { ref, onMounted } from 'vue'
import { getMyBookings, cancelBooking } from '../api/bookingApi'
const bookings = ref([])
const loading = ref(true)
const error = ref('')
const expanded = ref(new Set())
const STATUS_LABEL = {
pending: { text: '待教練確認', color: 'bg-yellow-100 text-yellow-700', hint: '等待教練確認中,確認後才完成預約' },
confirmed: { text: '預約成功', color: 'bg-green-100 text-green-700', hint: '教練已確認,請準時出席' },
completed: { text: '已完成', color: 'bg-gray-100 text-gray-600', hint: '' },
rejected: { text: '已拒絕', color: 'bg-red-100 text-red-600', hint: '教練無法接受此預約' },
expired: { text: '已過期', color: 'bg-gray-100 text-gray-400', hint: '超過 48 小時未獲確認,預約自動取消' },
member_cancelled: { text: '已取消', color: 'bg-gray-100 text-gray-500', hint: '' },
provider_cancelled: { text: '教練取消', color: 'bg-orange-100 text-orange-600', hint: '教練因故取消此預約' },
}
onMounted(async () => {
try {
const res = await getMyBookings()
bookings.value = res.data.data
} catch {
error.value = '無法載入預約記錄'
} finally {
loading.value = false
}
})
function toggle(id) {
if (expanded.value.has(id)) expanded.value.delete(id)
else expanded.value.add(id)
}
async function doCancel(booking) {
if (!confirm('確定要取消此預約?')) return
try {
await cancelBooking(booking.id)
booking.status = 'member_cancelled'
} catch (e) {
alert(e.response?.data?.message || '取消失敗')
}
}
function canCancel(status) {
return status === 'pending' || status === 'confirmed'
}
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
return `${d.getFullYear()}/${String(d.getMonth()+1).padStart(2,'0')}/${String(d.getDate()).padStart(2,'0')}`
}
</script>
<template>
<main class="max-w-3xl mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-gray-800 mb-6">我的預約</h1>
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
<div v-else-if="error" class="text-center text-red-500 py-10">{{ error }}</div>
<div v-else-if="bookings.length === 0" class="text-center text-gray-400 py-20">
目前沒有預約記錄<RouterLink to="/courses" class="text-ocean-600 underline">瀏覽課程</RouterLink>
</div>
<div v-else class="space-y-3">
<div
v-for="b in bookings"
:key="b.id"
class="bg-white rounded-2xl shadow border border-gray-100 overflow-hidden"
>
<!-- 摘要列點擊展開 -->
<button
class="w-full text-left px-5 py-4 flex items-center justify-between gap-4 hover:bg-gray-50 transition"
@click="toggle(b.id)"
>
<div class="flex-1 min-w-0">
<p class="font-semibold text-gray-800 truncate">{{ b.offer_title }}</p>
<p class="text-sm text-gray-500 mt-0.5">
{{ b.scheduled_date }} {{ b.start_time }}
{{ b.participants }}
NT$ {{ b.total_price?.toLocaleString() }}
</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
{{ STATUS_LABEL[b.status]?.text || b.status }}
</span>
<span class="text-gray-400 text-sm">{{ expanded.has(b.id) ? '▲' : '▼' }}</span>
</div>
</button>
<!-- 展開詳情 -->
<div v-if="expanded.has(b.id)" class="border-t border-gray-100 px-5 py-4 space-y-4 bg-gray-50">
<!-- 狀態說明 -->
<div v-if="STATUS_LABEL[b.status]?.hint"
class="flex items-center gap-2 text-sm rounded-lg px-3 py-2"
:class="b.status === 'pending' ? 'bg-yellow-50 text-yellow-700' : b.status === 'confirmed' ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'"
>
<span>{{ b.status === 'pending' ? '⏳' : b.status === 'confirmed' ? '✅' : '️' }}</span>
<span>{{ STATUS_LABEL[b.status].hint }}</span>
</div>
<!-- 課程與時段資訊 -->
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div>
<p class="text-gray-400 text-xs mb-0.5">課程名稱</p>
<p class="text-gray-700 font-medium">{{ b.offer_title }}</p>
</div>
<div>
<p class="text-gray-400 text-xs mb-0.5">地點</p>
<p class="text-gray-700">{{ b.offer_location || '—' }}
<span v-if="b.offer_region" class="text-gray-400">{{ b.offer_region }}</span>
</p>
</div>
<div>
<p class="text-gray-400 text-xs mb-0.5">上課日期</p>
<p class="text-gray-700">{{ b.scheduled_date }} {{ b.start_time }}</p>
</div>
<div>
<p class="text-gray-400 text-xs mb-0.5">預約人數</p>
<p class="text-gray-700">{{ b.participants }} </p>
</div>
<div>
<p class="text-gray-400 text-xs mb-0.5">課程單價</p>
<p class="text-gray-700">NT$ {{ b.offer_price?.toLocaleString() }}</p>
</div>
<div>
<p class="text-gray-400 text-xs mb-0.5">總金額</p>
<p class="text-gray-800 font-semibold">NT$ {{ b.total_price?.toLocaleString() }}</p>
</div>
<div v-if="b.notes" class="col-span-2">
<p class="text-gray-400 text-xs mb-0.5">備注</p>
<p class="text-gray-600">{{ b.notes }}</p>
</div>
<div class="col-span-2">
<p class="text-gray-400 text-xs mb-0.5">預約時間</p>
<p class="text-gray-500 text-xs">{{ b.created_at ? new Date(b.created_at).toLocaleString('zh-TW') : '—' }}</p>
</div>
</div>
<!-- 操作按鈕列 -->
<div class="flex items-center justify-between pt-1">
<RouterLink
v-if="b.offer_id"
:to="`/courses/${b.offer_id}`"
class="text-sm text-ocean-600 hover:text-ocean-800 hover:underline"
>
查看課程介紹
</RouterLink>
<span v-else></span>
<button
v-if="canCancel(b.status)"
@click="doCancel(b)"
class="text-sm text-red-500 hover:text-red-700 underline"
>
取消預約
</button>
</div>
</div>
</div>
</div>
</main>
</template>
+3
View File
@@ -72,6 +72,7 @@ async function submit() {
minlength="8" minlength="8"
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-ocean-400" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-ocean-400"
/> />
<p class="text-xs text-gray-400 mt-1">至少 8 個字元</p>
</div> </div>
<div> <div>
<label class="block text-sm text-gray-600 mb-1">確認密碼</label> <label class="block text-sm text-gray-600 mb-1">確認密碼</label>
@@ -80,7 +81,9 @@ async function submit() {
type="password" type="password"
required required
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-ocean-400" class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-ocean-400"
:class="confirm && confirm !== password ? 'border-red-400' : ''"
/> />
<p v-if="confirm && confirm !== password" class="text-xs text-red-500 mt-1">密碼不一致</p>
</div> </div>
<button <button
+85
View File
@@ -0,0 +1,85 @@
<script setup>
import { ref, onMounted } from 'vue'
import adminApi from '../../api/adminAxios'
const bookings = ref([])
const loading = ref(true)
const STATUS_LABEL = {
pending: { text: '待確認', color: 'bg-yellow-100 text-yellow-700' },
confirmed: { text: '已確認', color: 'bg-green-100 text-green-700' },
completed: { text: '已完成', color: 'bg-gray-100 text-gray-600' },
rejected: { text: '已拒絕', color: 'bg-red-100 text-red-600' },
expired: { text: '已過期', color: 'bg-gray-100 text-gray-400' },
member_cancelled: { text: '學員取消', color: 'bg-gray-100 text-gray-500' },
provider_cancelled: { text: '教練取消', color: 'bg-orange-100 text-orange-600' },
}
onMounted(async () => {
try {
const res = await adminApi.get('/admin/bookings')
bookings.value = res.data.data
} finally {
loading.value = false
}
})
async function doComplete(booking) {
if (!confirm(`確定要將「${booking.member_name}」的預約標記為完成?`)) return
try {
await adminApi.put(`/admin/bookings/${booking.id}/complete`)
booking.status = 'completed'
} catch (e) {
alert(e.response?.data?.message || '操作失敗')
}
}
</script>
<template>
<div class="p-6 max-w-6xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 mb-6">預約管理</h1>
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
<div v-else-if="bookings.length === 0" class="text-center text-gray-400 py-20">目前沒有預約</div>
<div v-else class="bg-white rounded-2xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500 text-xs uppercase tracking-wide">
<tr>
<th class="px-5 py-3 text-left">課程</th>
<th class="px-5 py-3 text-left">學員</th>
<th class="px-5 py-3 text-left">日期</th>
<th class="px-5 py-3 text-center">人數</th>
<th class="px-5 py-3 text-right">金額</th>
<th class="px-5 py-3 text-center">狀態</th>
<th class="px-5 py-3 text-center">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="b in bookings" :key="b.id" class="hover:bg-gray-50">
<td class="px-5 py-3 font-medium text-gray-800 max-w-[140px] truncate">{{ b.offer_title }}</td>
<td class="px-5 py-3 text-gray-500 text-xs">
<p>{{ b.member_name }}</p>
<p class="text-gray-400">{{ b.member_email }}</p>
</td>
<td class="px-5 py-3 text-gray-500 text-xs">{{ b.scheduled_date }} {{ b.start_time }}</td>
<td class="px-5 py-3 text-center text-gray-600">{{ b.participants }}</td>
<td class="px-5 py-3 text-right text-gray-700">NT$ {{ b.total_price?.toLocaleString() }}</td>
<td class="px-5 py-3 text-center">
<span class="text-xs px-2 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
{{ STATUS_LABEL[b.status]?.text || b.status }}
</span>
</td>
<td class="px-5 py-3 text-center">
<button v-if="b.status === 'confirmed'" @click="doComplete(b)"
class="text-xs bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded-full transition">
標記完成
</button>
<span v-else class="text-gray-300 text-xs"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+67
View File
@@ -0,0 +1,67 @@
<script setup>
import { ref, onMounted } from 'vue'
import adminApi from '../../api/adminAxios'
const reviews = ref([])
const loading = ref(true)
onMounted(fetchReviews)
async function fetchReviews() {
loading.value = true
try {
const res = await adminApi.get('/admin/reviews')
reviews.value = res.data.data
} finally {
loading.value = false
}
}
async function doDelete(review) {
if (!confirm(`確定要刪除「${review.offer_title}」的這則評價?`)) return
await adminApi.delete(`/admin/reviews/${review.id}`)
reviews.value = reviews.value.filter(r => r.id !== review.id)
}
</script>
<template>
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 mb-6">評價管理</h1>
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
<div v-else-if="reviews.length === 0" class="text-center text-gray-400 py-20">目前沒有評價</div>
<div v-else class="bg-white rounded-2xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-500 text-xs uppercase tracking-wide">
<tr>
<th class="px-5 py-3 text-left">課程</th>
<th class="px-5 py-3 text-left">會員</th>
<th class="px-5 py-3 text-center">星等</th>
<th class="px-5 py-3 text-left">內容</th>
<th class="px-5 py-3 text-center">幫助</th>
<th class="px-5 py-3 text-center">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="r in reviews" :key="r.id" class="hover:bg-gray-50">
<td class="px-5 py-3 font-medium text-gray-800 max-w-[140px] truncate">{{ r.offer_title }}</td>
<td class="px-5 py-3 text-gray-500 text-xs">{{ r.member_email }}</td>
<td class="px-5 py-3 text-center">
<span class="text-yellow-400">{{ '★'.repeat(r.rating) }}</span>
<span v-if="r.is_edited" class="text-gray-400 text-xs ml-1"></span>
</td>
<td class="px-5 py-3 text-gray-600 max-w-[240px] truncate">{{ r.comment }}</td>
<td class="px-5 py-3 text-center text-gray-400 text-xs">{{ r.helpful_count }}</td>
<td class="px-5 py-3 text-center">
<button @click="doDelete(r)"
class="text-xs bg-red-50 hover:bg-red-100 text-red-600 px-3 py-1 rounded-lg transition">
刪除
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@@ -0,0 +1,134 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { getProviderBookings, confirmBooking, rejectBooking, cancelBooking, completeBooking } from '../../api/coachBookingApi'
const bookings = ref([])
const loading = ref(true)
const STATUS_LABEL = {
pending: { text: '待確認', color: 'bg-yellow-100 text-yellow-700' },
confirmed: { text: '已確認', color: 'bg-green-100 text-green-700' },
completed: { text: '已完成', color: 'bg-gray-100 text-gray-600' },
rejected: { text: '已拒絕', color: 'bg-red-100 text-red-600' },
expired: { text: '已過期', color: 'bg-gray-100 text-gray-400' },
member_cancelled: { text: '學員取消', color: 'bg-gray-100 text-gray-500' },
provider_cancelled: { text: '教練取消', color: 'bg-orange-100 text-orange-600' },
}
// 依課程名稱分組,同課程再依時段日期排序
const groupedByOffer = computed(() => {
const map = {}
for (const b of bookings.value) {
const key = b.offer_title || '未知課程'
if (!map[key]) map[key] = []
map[key].push(b)
}
// 每組內依日期排序
for (const key of Object.keys(map)) {
map[key].sort((a, b) => (a.scheduled_date + a.start_time).localeCompare(b.scheduled_date + b.start_time))
}
return map
})
const pendingCount = computed(() => bookings.value.filter(b => b.status === 'pending').length)
onMounted(fetchBookings)
async function fetchBookings() {
loading.value = true
try {
const res = await getProviderBookings()
bookings.value = res.data.data
} finally {
loading.value = false
}
}
async function doAction(booking, action) {
const labels = { confirm: '確認', reject: '拒絕', cancel: '取消' }
if (!confirm(`確定要${labels[action]}此預約?`)) return
try {
if (action === 'confirm') await confirmBooking(booking.id)
if (action === 'reject') await rejectBooking(booking.id)
if (action === 'cancel') await cancelBooking(booking.id)
if (action === 'complete') await completeBooking(booking.id)
await fetchBookings()
} catch (e) {
alert(e.response?.data?.message || '操作失敗')
}
}
</script>
<template>
<div class="p-6 max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-800">預約管理</h1>
<span v-if="pendingCount > 0"
class="bg-yellow-100 text-yellow-700 text-sm font-medium px-3 py-1 rounded-full">
{{ pendingCount }} 筆待確認
</span>
</div>
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
<div v-else-if="bookings.length === 0" class="text-center text-gray-400 py-20">目前沒有任何預約</div>
<div v-else class="space-y-8">
<!-- 依課程分組 -->
<div v-for="(group, offerTitle) in groupedByOffer" :key="offerTitle">
<!-- 課程標題列 -->
<div class="flex items-center gap-3 mb-3">
<div class="h-px flex-1 bg-gray-200"></div>
<h2 class="text-sm font-semibold text-gray-500 whitespace-nowrap px-1">🤿 {{ offerTitle }}</h2>
<div class="h-px flex-1 bg-gray-200"></div>
</div>
<!-- 同課程的預約列表 -->
<div class="space-y-2">
<div
v-for="b in group"
:key="b.id"
class="bg-white rounded-xl border px-5 py-4 flex items-start justify-between flex-wrap gap-3"
:class="b.status === 'pending' ? 'border-yellow-200 shadow-sm' : 'border-gray-100'"
>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-700">
{{ b.scheduled_date }} {{ b.start_time }}
</p>
<p class="text-sm text-gray-500 mt-0.5">
{{ b.member_name }}
<span class="text-gray-400">{{ b.member_email }}</span>
{{ b.participants }} NT$ {{ b.total_price?.toLocaleString() }}
</p>
<p v-if="b.notes" class="text-xs text-gray-400 mt-1">備注{{ b.notes }}</p>
</div>
<div class="flex flex-col items-end gap-2 shrink-0">
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
{{ STATUS_LABEL[b.status]?.text || b.status }}
</span>
<div class="flex gap-2">
<button v-if="b.status === 'pending'" @click="doAction(b, 'confirm')"
class="text-xs bg-green-600 hover:bg-green-500 text-white px-3 py-1 rounded-full transition">
確認
</button>
<button v-if="b.status === 'pending'" @click="doAction(b, 'reject')"
class="text-xs bg-red-500 hover:bg-red-400 text-white px-3 py-1 rounded-full transition">
拒絕
</button>
<button v-if="b.status === 'confirmed'" @click="doAction(b, 'complete')"
class="text-xs bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded-full transition">
完成
</button>
<button v-if="b.status === 'confirmed'" @click="doAction(b, 'cancel')"
class="text-xs text-orange-500 hover:text-orange-700 underline">
取消
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -66,6 +66,7 @@ onMounted(fetchOffers)
<th class="px-6 py-3 text-left">地點</th> <th class="px-6 py-3 text-left">地點</th>
<th class="px-6 py-3 text-left">地區</th> <th class="px-6 py-3 text-left">地區</th>
<th class="px-6 py-3 text-right">價格</th> <th class="px-6 py-3 text-right">價格</th>
<th class="px-6 py-3 text-center">時段</th>
<th class="px-6 py-3 text-center">操作</th> <th class="px-6 py-3 text-center">操作</th>
</tr> </tr>
</thead> </thead>
@@ -75,6 +76,12 @@ onMounted(fetchOffers)
<td class="px-6 py-4 text-gray-500">{{ offer.location }}</td> <td class="px-6 py-4 text-gray-500">{{ offer.location }}</td>
<td class="px-6 py-4 text-gray-500">{{ offer.region }}</td> <td class="px-6 py-4 text-gray-500">{{ offer.region }}</td>
<td class="px-6 py-4 text-right font-medium">NT$ {{ offer.price?.toLocaleString() }}</td> <td class="px-6 py-4 text-right font-medium">NT$ {{ offer.price?.toLocaleString() }}</td>
<td class="px-6 py-4 text-center">
<RouterLink :to="`/coach/schedules?offer_id=${offer.id}`"
class="text-xs bg-ocean-50 hover:bg-ocean-100 text-ocean-700 px-3 py-1 rounded-lg transition font-medium">
管理時段
</RouterLink>
</td>
<td class="px-6 py-4 text-center"> <td class="px-6 py-4 text-center">
<div class="flex justify-center gap-2"> <div class="flex justify-center gap-2">
<RouterLink :to="`/coach/offers/${offer.id}/edit`" <RouterLink :to="`/coach/offers/${offer.id}/edit`"
+102 -3
View File
@@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import coachApi from '../../api/coachAxios' import coachApi from '../../api/coachAxios'
import { uploadCover, deleteCover, uploadImage, deleteImage } from '../../api/courseImageApi'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -25,6 +26,12 @@ const form = ref({
description: '', description: '',
}) })
// 圖片管理
const coverUrl = ref(null)
const galleryImgs = ref([])
const imgUploading = ref(false)
const imgError = ref('')
onMounted(async () => { onMounted(async () => {
if (!isEdit.value) return if (!isEdit.value) return
loading.value = true loading.value = true
@@ -41,6 +48,8 @@ onMounted(async () => {
badges: Array.isArray(o.badges) ? o.badges.join(', ') : (o.badges || ''), badges: Array.isArray(o.badges) ? o.badges.join(', ') : (o.badges || ''),
description: o.description || '', description: o.description || '',
} }
coverUrl.value = o.cover_image_url || null
galleryImgs.value = o.images || []
} catch (e) { } catch (e) {
error.value = e.response?.data?.message || '無法載入課程資料' error.value = e.response?.data?.message || '無法載入課程資料'
} finally { } finally {
@@ -48,6 +57,49 @@ onMounted(async () => {
} }
}) })
async function onCoverChange(e) {
const file = e.target.files[0]
if (!file) return
imgUploading.value = true
imgError.value = ''
try {
const res = await uploadCover(route.params.id, file)
coverUrl.value = res.data.cover_image_url
} catch (e) {
imgError.value = e.response?.data?.message || '封面上傳失敗'
} finally {
imgUploading.value = false
e.target.value = ''
}
}
async function onDeleteCover() {
if (!confirm('確定刪除封面?')) return
await deleteCover(route.params.id)
coverUrl.value = null
}
async function onGalleryChange(e) {
const file = e.target.files[0]
if (!file) return
imgUploading.value = true
imgError.value = ''
try {
const res = await uploadImage(route.params.id, file)
galleryImgs.value.push(res.data.data)
} catch (e) {
imgError.value = e.response?.data?.message || '圖片上傳失敗'
} finally {
imgUploading.value = false
e.target.value = ''
}
}
async function onDeleteImage(img) {
await deleteImage(img.id)
galleryImgs.value = galleryImgs.value.filter(i => i.id !== img.id)
}
async function submit() { async function submit() {
errors.value = {} errors.value = {}
error.value = '' error.value = ''
@@ -70,10 +122,12 @@ async function submit() {
try { try {
if (isEdit.value) { if (isEdit.value) {
await coachApi.put(`/provider/offers/${route.params.id}`, payload) await coachApi.put(`/provider/offers/${route.params.id}`, payload)
} else {
await coachApi.post('/provider/offers', payload)
}
router.push('/coach/dashboard') router.push('/coach/dashboard')
} else {
const res = await coachApi.post('/provider/offers', payload)
const newId = res.data.data?.id
router.push(`/coach/schedules?offer_id=${newId}&new=1`)
}
} catch (e) { } catch (e) {
const data = e.response?.data const data = e.response?.data
error.value = data?.message || '儲存失敗' error.value = data?.message || '儲存失敗'
@@ -159,6 +213,51 @@ async function submit() {
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none" /> class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none" />
</div> </div>
<!-- 圖片管理僅編輯模式 -->
<div v-if="isEdit" class="border border-gray-200 rounded-xl p-5 space-y-5">
<h3 class="text-sm font-semibold text-gray-700">課程圖片</h3>
<p v-if="imgError" class="text-red-500 text-xs">{{ imgError }}</p>
<!-- 封面 -->
<div>
<label class="block text-xs text-gray-500 mb-2">封面圖片1 </label>
<div class="flex items-center gap-4">
<div class="w-32 h-24 rounded-lg overflow-hidden bg-gray-100 shrink-0">
<img v-if="coverUrl" :src="coverUrl" class="w-full h-full object-cover" />
<div v-else class="w-full h-full flex items-center justify-center text-gray-400 text-2xl">🤿</div>
</div>
<div class="flex flex-col gap-2">
<label class="cursor-pointer text-xs bg-gray-900 text-white px-3 py-1.5 rounded-lg hover:bg-gray-700 transition text-center">
{{ coverUrl ? '更換封面' : '上傳封面' }}
<input type="file" accept="image/jpeg,image/png,image/webp" class="hidden" @change="onCoverChange" :disabled="imgUploading" />
</label>
<button v-if="coverUrl" @click="onDeleteCover" type="button"
class="text-xs text-red-500 hover:text-red-700 underline">刪除封面</button>
</div>
</div>
</div>
<!-- 相簿 -->
<div>
<label class="block text-xs text-gray-500 mb-2">相簿圖片最多 3 </label>
<div class="flex gap-3 flex-wrap">
<div v-for="img in galleryImgs" :key="img.id" class="relative w-24 h-20 rounded-lg overflow-hidden">
<img :src="img.url" class="w-full h-full object-cover" />
<button @click="onDeleteImage(img)" type="button"
class="absolute top-1 right-1 bg-black/60 text-white rounded-full w-5 h-5 text-xs flex items-center justify-center hover:bg-black/80">
</button>
</div>
<label v-if="galleryImgs.length < 3"
class="w-24 h-20 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center cursor-pointer hover:border-ocean-400 transition text-gray-400 text-xl">
+
<input type="file" accept="image/jpeg,image/png,image/webp" class="hidden" @change="onGalleryChange" :disabled="imgUploading" />
</label>
</div>
<p v-if="imgUploading" class="text-xs text-gray-400 mt-1">上傳中...</p>
</div>
</div>
<div class="flex gap-3 justify-end pt-2"> <div class="flex gap-3 justify-end pt-2">
<RouterLink to="/coach/dashboard" <RouterLink to="/coach/dashboard"
class="px-5 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition"> class="px-5 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition">
+5 -2
View File
@@ -76,13 +76,16 @@ async function submit() {
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm text-gray-600 mb-1">密碼 <span class="text-red-400">*</span></label> <label class="block text-sm text-gray-600 mb-1">密碼 <span class="text-red-400">*</span></label>
<input v-model="form.password" type="password" required minlength="6" <input v-model="form.password" type="password" required minlength="8"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400" /> class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400" />
<p class="text-xs text-gray-400 mt-1">至少 8 個字元</p>
</div> </div>
<div> <div>
<label class="block text-sm text-gray-600 mb-1">確認密碼 <span class="text-red-400">*</span></label> <label class="block text-sm text-gray-600 mb-1">確認密碼 <span class="text-red-400">*</span></label>
<input v-model="form.password_confirmation" type="password" required <input v-model="form.password_confirmation" type="password" required
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400" /> class="w-full border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400"
:class="form.password_confirmation && form.password_confirmation !== form.password ? 'border-red-400' : 'border-gray-300'" />
<p v-if="form.password_confirmation && form.password_confirmation !== form.password" class="text-xs text-red-500 mt-1">密碼不一致</p>
</div> </div>
</div> </div>
+101
View File
@@ -0,0 +1,101 @@
<script setup>
import { ref, onMounted } from 'vue'
import coachApi from '../../api/coachAxios'
import axios from 'axios'
const publicApi = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
const offers = ref([])
const reviews = ref([]) // [{ offer, reviews, summary }]
const loading = ref(true)
onMounted(async () => {
try {
const offersRes = await coachApi.get('/provider/offers')
offers.value = offersRes.data.data
const results = await Promise.all(
offers.value.map(async (offer) => {
const res = await publicApi.get(`/diving-offers/${offer.id}/reviews`)
return { offer, ...res.data.data }
})
)
// 只顯示有評價的課程
reviews.value = results.filter(r => r.summary.total > 0)
} finally {
loading.value = false
}
})
function stars(n) {
return '★'.repeat(n) + '☆'.repeat(5 - n)
}
</script>
<template>
<div class="p-6 max-w-4xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 mb-2">課程評價</h1>
<p class="text-sm text-gray-500 mb-6">學員對你課程的回饋評價人已匿名</p>
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
<div v-else-if="reviews.length === 0" class="text-center text-gray-400 py-20">
目前沒有學員評價
</div>
<div v-else class="space-y-8">
<div v-for="group in reviews" :key="group.offer.id" class="bg-white rounded-2xl shadow p-6">
<!-- 課程標題與統計 -->
<div class="flex items-start justify-between mb-4 flex-wrap gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-800">{{ group.offer.title }}</h2>
<p class="text-sm text-gray-500 mt-0.5">
{{ group.summary.average }} · {{ group.summary.total }} 則評價
</p>
</div>
<!-- 評分分布 -->
<div class="space-y-0.5 min-w-[160px]">
<div v-for="star in [5,4,3,2,1]" :key="star" class="flex items-center gap-1.5 text-xs">
<span class="text-gray-400 w-4">{{ star }}</span>
<div class="flex-1 bg-gray-100 rounded-full h-1.5">
<div class="bg-yellow-400 h-1.5 rounded-full"
:style="`width:${group.summary.total > 0 ? (group.summary.distribution[star] / group.summary.total * 100) : 0}%`">
</div>
</div>
<span class="text-gray-400 w-3 text-right">{{ group.summary.distribution[star] }}</span>
</div>
</div>
</div>
<!-- 評價列表 -->
<div class="divide-y divide-gray-100">
<div v-for="r in group.reviews" :key="r.id" class="py-4 first:pt-0">
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-full bg-ocean-100 flex items-center justify-center text-ocean-600 text-sm font-bold shrink-0">
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-yellow-400 text-sm">{{ stars(r.rating) }}</span>
<span class="text-xs text-gray-400">{{ r.reviewer_name }}</span>
<span v-if="r.is_edited" class="text-xs text-gray-400">已修改</span>
<span class="text-xs text-gray-400 ml-auto">
{{ new Date(r.created_at).toLocaleDateString('zh-TW') }}
</span>
</div>
<p class="text-sm text-gray-700 leading-relaxed">{{ r.comment }}</p>
<p class="text-xs text-gray-400 mt-1.5">
👍 {{ r.helpful_count }} 人覺得有幫助
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,180 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { getSchedules, createSchedule, deleteSchedule } from '../../api/coachScheduleApi'
import coachApi from '../../api/coachAxios'
const route = useRoute()
const schedules = ref([])
const offers = ref([])
const loading = ref(true)
const showForm = ref(false)
const formError = ref('')
const isNewCourse = ref(route.query.new === '1')
const form = ref({
diving_offer_id: route.query.offer_id ? Number(route.query.offer_id) : '',
scheduled_date: '',
start_time: '',
max_participants: 1,
})
// 時間選擇器
const timePeriod = ref('AM')
const timeHour = ref('08')
const timeMinute = ref('00')
const HOURS_AM = ['06','07','08','09','10','11']
const HOURS_PM = ['12','13','14','15','16','17','18']
const MINUTES = ['00','30']
const hourOptions = computed(() => timePeriod.value === 'AM' ? HOURS_AM : HOURS_PM)
function syncTime() {
if (timePeriod.value === 'AM' && !HOURS_AM.includes(timeHour.value)) timeHour.value = '08'
if (timePeriod.value === 'PM' && !HOURS_PM.includes(timeHour.value)) timeHour.value = '13'
form.value.start_time = `${timeHour.value}:${timeMinute.value}`
}
watch([timePeriod, timeHour, timeMinute], syncTime, { immediate: true })
const today = computed(() => new Date().toISOString().split('T')[0])
onMounted(async () => {
try {
const [sRes, oRes] = await Promise.all([
getSchedules(),
coachApi.get('/provider/offers'),
])
schedules.value = sRes.data.data
offers.value = oRes.data.data
if (isNewCourse.value) showForm.value = true
} finally {
loading.value = false
}
})
async function submitForm() {
formError.value = ''
try {
const res = await createSchedule(form.value)
schedules.value.unshift(res.data.data)
showForm.value = false
form.value = { diving_offer_id: '', scheduled_date: '', start_time: '', max_participants: 1 }
} catch (e) {
formError.value = e.response?.data?.message || '建立失敗'
}
}
async function doDelete(schedule) {
if (!confirm(`確定取消「${schedule.offer_title} ${schedule.scheduled_date}」這個時段?\n該時段下的預約將自動取消。`)) return
try {
await deleteSchedule(schedule.id)
schedule.status = 'cancelled'
} catch (e) {
alert(e.response?.data?.message || '操作失敗')
}
}
const STATUS_COLOR = {
open: 'bg-green-100 text-green-700',
full: 'bg-yellow-100 text-yellow-700',
cancelled: 'bg-gray-100 text-gray-400',
}
</script>
<template>
<div class="p-6 max-w-4xl mx-auto">
<!-- 新建課程引導提示 -->
<div v-if="isNewCourse" class="bg-ocean-50 border border-ocean-200 rounded-xl px-5 py-4 mb-6 flex items-start gap-3">
<span class="text-2xl">🎉</span>
<div>
<p class="font-semibold text-ocean-800">課程建立成功</p>
<p class="text-sm text-ocean-700 mt-0.5">請為課程新增開課時段學員才能看到可預約的日期</p>
</div>
</div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-800">時段管理</h1>
<button
@click="showForm = !showForm"
class="bg-ocean-700 hover:bg-ocean-600 text-white px-5 py-2 rounded-full text-sm font-medium transition"
>
{{ showForm ? '取消' : '+ 新增時段' }}
</button>
</div>
<!-- 新增表單 -->
<div v-if="showForm" class="bg-ocean-50 rounded-2xl p-6 mb-6 border border-ocean-200">
<h2 class="font-semibold text-gray-700 mb-4">新增開課時段</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-600 mb-1">課程</label>
<select v-model="form.diving_offer_id" class="w-full border rounded-lg px-3 py-2 text-sm">
<option value="" disabled>請選擇課程</option>
<option v-for="o in offers" :key="o.id" :value="o.id">{{ o.title }}</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">日期</label>
<input v-model="form.scheduled_date" type="date" :min="today" class="w-full border rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">開始時間</label>
<div class="flex gap-2">
<select v-model="timePeriod" @change="syncTime"
class="border rounded-lg px-3 py-2 text-sm w-24 bg-white">
<option value="AM">上午</option>
<option value="PM">下午</option>
</select>
<select v-model="timeHour" @change="syncTime"
class="border rounded-lg px-3 py-2 text-sm flex-1 bg-white">
<option v-for="h in hourOptions" :key="h" :value="h">{{ h }} </option>
</select>
<select v-model="timeMinute" @change="syncTime"
class="border rounded-lg px-3 py-2 text-sm w-24 bg-white">
<option value="00">00 </option>
<option value="30">30 </option>
</select>
</div>
<p class="text-xs text-gray-400 mt-1">已選{{ form.start_time }}</p>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">人數上限</label>
<input v-model.number="form.max_participants" type="number" min="1" class="w-full border rounded-lg px-3 py-2 text-sm" />
</div>
</div>
<p v-if="formError" class="text-red-500 text-sm mt-3">{{ formError }}</p>
<button @click="submitForm" class="mt-4 bg-ocean-700 hover:bg-ocean-600 text-white px-6 py-2 rounded-full text-sm font-medium transition">
建立時段
</button>
</div>
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
<div v-else-if="schedules.length === 0" class="text-center text-gray-400 py-20">尚未建立任何時段</div>
<div v-else class="space-y-3">
<div
v-for="s in schedules"
:key="s.id"
class="bg-white rounded-xl shadow px-5 py-4 flex items-center justify-between"
>
<div>
<p class="font-medium text-gray-800">{{ s.offer_title }}</p>
<p class="text-sm text-gray-500 mt-0.5">{{ s.scheduled_date }} {{ s.start_time }}剩餘 {{ s.remaining_spots }}/{{ s.max_participants }} </p>
</div>
<div class="flex items-center gap-3">
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_COLOR[s.status]">
{{ { open: '開放', full: '已滿', cancelled: '已取消' }[s.status] || s.status }}
</span>
<button
v-if="s.status !== 'cancelled'"
@click="doDelete(s)"
class="text-sm text-red-400 hover:text-red-600 underline"
>
取消時段
</button>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-11
@@ -0,0 +1,3 @@
# booking-system
預約系統:時段制課程預約、狀態機管理、教練排程
@@ -0,0 +1,385 @@
## Context
CFDivePlatform 是 Laravel 11 + Vue 3 的潛水課程媒合平台。目前 Member 只能瀏覽課程,無法完成預約。需要在現有 Sanctum 認證體系上新增預約流程,並引入 Laravel Scheduler 處理自動狀態轉換。
現有關鍵資料模型:`users`(三角色)、`diving_offers`(含 `price``provider_id`)。本次不修改任何現有資料表。
## Goals / Non-Goals
**Goals:**
- Provider 可建立/管理開課時段(`course_schedules`
- Member 可對時段送出預約(`bookings`
- 七狀態狀態機,含自動過期(48h)與自動完成(課程後)
- 前後端完整串接
**Non-Goals:**
- 金流整合(payment 欄位預留但不串接)
- 推播通知(Email/SMS
- 管理員預約管理介面(Admin Panel 待後續)
- 退款流程(取消後僅改狀態,不觸發退款)
## Decisions
### 決策一:狀態機實作方式 — PHP BackedEnum + string 欄位
**選擇**DB 欄位用 `string`(非 MySQL ENUM),應用層用 PHP `BackedEnum` 管理合法值。
```php
// app/Enums/BookingStatus.php
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';
}
// app/Enums/ScheduleStatus.php
enum ScheduleStatus: string {
case Open = 'open';
case Full = 'full';
case Cancelled = 'cancelled';
}
```
Migration 使用 `$table->string('status')->default('pending')`Model 用 `$casts = ['status' => BookingStatus::class]`
`Booking` Model 定義 `VALID_TRANSITIONS` 常數,transition 前統一驗證合法性:
```php
const VALID_TRANSITIONS = [
'pending' => ['confirmed', 'rejected', 'expired', 'member_cancelled'],
'confirmed' => ['completed', 'member_cancelled', 'provider_cancelled'],
'completed' => [],
'rejected' => [],
'expired' => [],
'member_cancelled' => [],
'provider_cancelled' => [],
];
```
**理由**MySQL ENUM 加欄位值需要 `ALTER TABLE`(鎖表),在大資料量下有停機風險。用 string 欄位,未來加狀態只需改 PHP Enum,零 Migration。PHP 8.1 BackedEnum 提供 IDE 自動補全與型別安全,兼顧可維護性。
**放棄**:DB ENUM → 維護成本高,每次加狀態都要 Migration;引入狀態機套件 → 過度設計,transition 數量不值得。
---
### 決策二:人數計數 — DB 欄位 + 悲觀鎖
**選擇**`course_schedules.current_participants` 實體欄位,更新時用 `lockForUpdate()`
```
DB::transaction(function () use ($booking, $schedule) {
$schedule = CourseSchedule::lockForUpdate()->find($schedule->id);
// 驗證剩餘名額...
$schedule->increment('current_participants', $booking->participants);
});
```
**理由**:比每次 COUNT(bookings) 查詢效率高;悲觀鎖防止超賣 race condition。
**放棄**:樂觀鎖(version column)→ 需要 retry 邏輯,複雜度不值得。
---
### 決策三:Scheduler 頻率
| Job | 頻率 | 原因 |
|-----|------|------|
| `ExpirePendingBookings` | 每小時 | 過期精確度到小時即可 |
| `CompleteFinishedBookings` | 每日凌晨 | 課程完成以「日」為單位 |
---
### 決策四:價格快照
**選擇**:建立 Booking 時將 `diving_offer.price × participants` 存入 `bookings.total_price`
**理由**:Provider 日後調整課程價格不應影響已建立的預約;金流整合時直接使用此欄位。
---
### 決策五:取消時段的 Cascade 實作
**選擇**:在 `ScheduleController::destroy()` 內用單一 DB transaction 同時更新時段與相關 Booking。
```
DB::transaction(function () use ($schedule) {
$schedule->update(['status' => ScheduleStatus::Cancelled]);
$schedule->bookings()
->whereIn('status', [BookingStatus::Pending, BookingStatus::Confirmed])
->update(['status' => BookingStatus::ProviderCancelled]);
});
```
**理由**Booking cascade 必須與時段取消原子性完成,避免時段已取消但 Booking 仍掛 `confirmed` 的髒狀態。批次 `update` 不逐筆觸發 Model Event,效率優先(MVP 規模不需要逐筆通知)。
**放棄**:逐筆呼叫 `$booking->transitionTo(ProviderCancelled)` → 在大量預約時效率差,且 Model Event 觸發通知屬於未來功能。
---
### 決策六:Member 取消截止時間
**選擇**:課程開始前 24 小時為截止點,計算方式為 `$schedule->scheduled_date + $schedule->start_time`Carbon datetime)。
```php
$courseStart = Carbon::parse($schedule->scheduled_date . ' ' . $schedule->start_time);
if (now()->diffInHours($courseStart, false) < 24) {
return response()->json(['status' => false, 'message' => '距課程開始不足 24 小時,無法取消'], 422);
}
```
**理由**:潛水課程有實際的人力與設備準備成本,24h 截止是業界常見標準。`pending` 狀態同樣受此限制,避免 Member 在課程即將開始時仍送出再取消的操作。
**放棄**:只限制 `confirmed` 取消、`pending` 不限 → 業務上應一致處理,24h 截止對兩種狀態同樣適用。
---
### 決策七:Participants 驗證時機
**選擇**:分兩個階段各做一次名額驗證。
**階段 A — 建立 pending 時(早期拒絕,`current_participants` = 已確認人數)**
```
Layer 1 (Controller,進 transaction 前):
$remaining = $schedule->max_participants - $schedule->current_participants;
if ($participants > $remaining) return 422; // 連確認的名額都滿了,早期拒絕
Layer 2 (transaction 內,lockForUpdate 後):
$schedule = CourseSchedule::lockForUpdate()->find($id);
$remaining = $schedule->max_participants - $schedule->current_participants;
if ($participants > $remaining) throw new InsufficientSlotsException();
// 通過後只建立 Booking,不 incrementpending 不佔位)
```
**階段 B — Provider 確認時(真正佔位,lockForUpdate + increment**
```
DB::transaction(function () use ($booking) {
$schedule = CourseSchedule::lockForUpdate()->find($booking->schedule_id);
$remaining = $schedule->max_participants - $schedule->current_participants;
if ($booking->participants > $remaining) throw new InsufficientSlotsException();
$booking->update(['status' => 'confirmed']);
$schedule->increment('current_participants', $booking->participants);
$schedule->refresh();
if ($schedule->current_participants >= $schedule->max_participants) {
$schedule->update(['status' => 'full']);
}
});
```
**理由**`current_participants` 只計算 confirmed 人數。pending 是「申請」不是「保留」,Provider 確認時才真正佔位。階段 A 的早期拒絕防止在所有 confirmed 額度滿後仍接受新 pending;階段 B 的 lockForUpdate 是真正防超賣機制。
---
### 決策八:重複預約防護 — 應用層 transaction 內檢查
**選擇**:不使用 DB UNIQUE constraint,改在建立 Booking 的 DB transaction 內執行重複性檢查。
```php
DB::transaction(function () use ($memberId, $scheduleId, ...) {
// 在 lockForUpdate 取得 schedule 的同一 transaction 內檢查
$duplicate = Booking::where('member_id', $memberId)
->where('schedule_id', $scheduleId)
->whereIn('status', [BookingStatus::Pending, BookingStatus::Confirmed])
->exists();
if ($duplicate) {
throw new DuplicateBookingException();
}
// ... 建立 Booking
});
```
**理由**DB UNIQUE(member_id, schedule_id) 會阻止 Member 在取消後重新預約同一時段(如原 pending 取消後想改約),業務上不合理。應用層只檢查活躍狀態(pending/confirmed),允許對同一時段在取消後再次預約。將檢查放在 transaction 內確保與建立操作原子性,避免 TOCTOU race condition。
**放棄**DB UNIQUE constraint → 語意過強,阻擋合法的取消後重訂;Controller 層(transaction 外)檢查 → 有 TOCTOU 風險。
---
### 決策九:公開時段 API 的明確過濾條件
**選擇**`GET /api/diving-offers/{id}/schedules` 回傳條件為:
```sql
WHERE diving_offer_id = :id
AND status = 'open'
AND scheduled_date >= CURDATE()
ORDER BY scheduled_date ASC, start_time ASC
```
`full``cancelled` 時段不回傳;過去日期時段不回傳。
**理由**:只回傳 `open` 確保 Member 看到的全是可預約的時段,前端不需要再做客戶端過濾。`full` 不顯示(MVP 不做候補名單功能),`scheduled_date < today` 的時段對 Member 無意義。
**放棄**:回傳 `open` + `full`(前端再過濾)→ 增加前端複雜度,且 full 時段對無候補功能的 MVP 無用。
---
### 決策十:Coach / Provider 命名慣例
**現況**:codebase 存在兩套命名:前端用 `coach`,後端 API 和 DB 用 `provider`。這是前期開發的歷史遺留,本次不重構。
**決策**
- **前端路由**:維持 `/coach/*`(已存在,不破壞現有 URL
- **後端 API 路由**:統一用 `/provider/*`(與 DB role 欄位值一致)
- **DB `users.role` 欄位值**`'provider'`PHP 端 `isProvider()` 方法判斷)
- **本次新增程式碼**:後端一律用 `provider` 命名(Controller、Policy、Middleware);前端新增頁面放在 `/coach/*` 路由下
新加入開發者應知道:前端 `/coach/*` = 後端 `/api/provider/*` = DB `role = 'provider'`,三者指同一群用戶。
---
### 決策十一:審計追蹤(已知限制)
**現況**`bookings` 表只有 `created_at`/`updated_at`,無法從 DB 層直接查「何時確認」「何時取消」。
**MVP 決策**:接受此限制。`updated_at` 可作為最後一次狀態變更的時間戳,精確度足夠 MVP 使用。
**未來(金流整合前必須處理)**:加入 `booking_status_logs` 歷史表,記錄每次 status transition 的 `from_status``to_status``changed_by`user_id)、`changed_at`。屆時新增一個 Migration 即可,不影響現有 `bookings` 結構。
---
### 決策十三:名額回收與時段狀態自動轉換
**`current_participants` 增減規則**(只計算 confirmed 人數):
| 觸發動作 | current_participants | 說明 |
| ------- | ------------------- | ---- |
| pending → confirmedProvider 確認) | +participants | 確認時才佔位 |
| confirmed → member_cancelled | -participants | Member 取消,釋放名額 |
| confirmed → provider_cancelled | -participants | Provider 取消時段或單筆取消,釋放名額 |
| confirmed → completed | 不變 | 課程已完成,名額已消耗 |
| pending → rejected | 不變 | pending 從未佔位,無需釋放 |
| pending → expired | 不變 | pending 從未佔位,無需釋放 |
| pending → member_cancelled | 不變 | pending 從未佔位,無需釋放 |
**`course_schedules.status` 自動轉換規則**(在 confirm/cancel 的同一 DB transaction 內執行):
```php
// confirm 後 increment,檢查是否 full
if ($schedule->current_participants >= $schedule->max_participants) {
$schedule->update(['status' => ScheduleStatus::Full]);
}
// cancel 後 decrement,檢查是否回 open
if ($schedule->current_participants < $schedule->max_participants
&& $schedule->status === ScheduleStatus::Full) {
$schedule->update(['status' => ScheduleStatus::Open]);
}
```
`cancelled` 是終態,不受上述規則影響。
**理由**:與 `specs/course-scheduling/spec.md` 一致,`current_participants` 反映已確認(實際佔用)的人數。pending 是申請,不保留名額;Provider 確認時才真正佔位。
---
### 決策十四:`ExpirePendingBookings` 過期條件
**選擇**:過期條件為 `status = 'pending'``created_at <= now() - 48 hours`。批次 update 即可,無需碰 `current_participants`pending 從未佔位)。
```php
$count = Booking::where('status', BookingStatus::Pending)
->where('created_at', '<=', now()->subHours(48))
->update(['status' => BookingStatus::Expired]);
Log::info("ExpirePendingBookings: {$count} expired");
```
**理由**:pending 確認時才佔位(決策十三),因此過期只需改狀態,`current_participants` 與時段 `status` 均不受影響。批次 `update` 效率優於逐筆 transaction。
---
### 決策十二:前端路由新增
```
Member 新路由:
/courses/:id → 課程詳情(新增時段選擇區塊)
/my-bookings → 我的預約列表
Coach 新路由(在現有 /coach/* 下):
/coach/schedules → 時段管理
/coach/bookings → 預約管理
```
## 資料表設計
### course_schedules
```
id bigint PK
diving_offer_id bigint FK → diving_offers.id
provider_id bigint FK → users.id
scheduled_date date NOT NULL
start_time time NOT NULL
max_participants int NOT NULL (≥1)
current_participants int DEFAULT 0
status string DEFAULT 'open' ← PHP ScheduleStatus BackedEnum
created_at timestamp
updated_at timestamp
索引:
idx_offer_status_date (diving_offer_id, status, scheduled_date) ← 公開 API 查詢
idx_provider_id (provider_id) ← Provider 管理頁
```
### bookings
```
id bigint PK
schedule_id bigint FK → course_schedules.id
member_id bigint FK → users.id
participants int NOT NULL DEFAULT 1
total_price int NOT NULL (快照,單位:元)
status string DEFAULT 'pending' ← PHP BookingStatus BackedEnum
notes text nullable
created_at timestamp
updated_at timestamp
索引:
idx_member_status (member_id, status) ← Member 預約列表
idx_schedule_status (schedule_id, status) ← 重複預約檢查、人數統計
idx_status_created_at (status, created_at) ← ExpirePendingBookings Scheduler
idx_status_sched (status, schedule_id) ← CompleteFinishedBookings Scheduler
```
## API 路由總覽
```
公開
GET /api/diving-offers/{id}/schedules → 取得課程可用時段
Member (auth:sanctum)
GET /api/member/bookings → 我的預約列表
POST /api/member/bookings → 建立預約
GET /api/member/bookings/{id} → 預約詳情
DELETE /api/member/bookings/{id} → 取消預約
Provider (auth:sanctum)
GET /api/provider/schedules → 我的時段列表
POST /api/provider/schedules → 建立時段
PUT /api/provider/schedules/{id} → 更新時段
DELETE /api/provider/schedules/{id} → 取消時段
GET /api/provider/bookings → 課程預約列表
PUT /api/provider/bookings/{id}/confirm → 確認預約
PUT /api/provider/bookings/{id}/reject → 拒絕預約
PUT /api/provider/bookings/{id}/cancel → 取消預約
```
## Risks / Trade-offs
- **Race condition on 最後一個名額** → 已用 `lockForUpdate()` 在 DB transaction 內處理
- **Scheduler 停擺(高風險)**
Scheduler 若未啟動,`pending` 預約永遠不過期、`confirmed` 課程永遠不完成,資料持續累積髒狀態。
Mitigation 三層:
1. **日誌**:每次 Job 執行結尾記錄 `Log::info("ExpirePendingBookings: {$count} expired")`,可在 Laravel log 中查驗
2. **可觀測性**:開發環境啟用 Laravel Telescope 監控 Schedule 執行;生產環境至少保留 `storage/logs/laravel.log` 並定期 rotate
3. **手動補跑**:兩支 Artisan Command 須可獨立執行(`php artisan app:expire-pending-bookings`),維運人員可在 Scheduler 異常時手動補跑,不依賴 cron
- **取消後無退款** → 目前僅狀態標記,金流整合時需補充退款邏輯
- **`completed` 自動轉換** → 課程當天仍進行中的預約到凌晨才會轉 completed,業務上可接受
- **string status 未加 DB CHECK constraint** → 合法值由應用層 BackedEnum 控制,若繞過 ORM 直接寫 DB 可能插入非法值;可接受此 trade-off,未來有需要可加 DB constraint
## Closed Questions
- **Q1`notes` 欄位是否強制填寫?****已決定:nullable**。Member 預約時不強制填寫,`notes` 保留為選填欄位供日後使用。
- **Q2Provider 取消時段時,confirmed Booking 的通知方式?****已決定:只改狀態**。取消後 Booking 狀態變為 `provider_cancelled`,本次不觸發任何通知。Email/推播通知留給未來 Email 模組實作。
@@ -0,0 +1,42 @@
## Why
CFDivePlatform 目前只有課程瀏覽,Member 無法預約課程,Provider 無法管理開課時段,平台缺少核心商業閉環。預約系統是金流整合與平台商業化的前置條件,必須優先實作。
## What Changes
- 新增 `course_schedules` 資料表:Provider 建立開課時段(日期、時間、人數上限)
- 新增 `bookings` 資料表:記錄 Member 預約紀錄,含價格快照
- 新增 Member API:查詢可用時段、送出預約、取消預約
- 新增 Provider API:管理開課時段 CRUD、接受/拒絕/取消預約
- 新增 Laravel Schedulerpending 超 48 小時自動 expired;課程日期過後自動 completed
- 新增前端頁面:Member 課程詳情頁加入時段選擇與預約流程;Provider Dashboard 加入時段管理與預約管理
## Capabilities
### New Capabilities
- `course-scheduling`:Provider 建立與管理開課時段,含日期、時間、人數上限、狀態(open/full/cancelled
- `booking-lifecycle`:Member 送出預約、取消預約;Provider 確認/拒絕/取消預約;系統自動過期與完成;七狀態狀態機(pending / confirmed / completed / rejected / expired / member_cancelled / provider_cancelled
### Modified Capabilities
(無既有 spec 受影響)
## Impact
**後端**
- 新增 Migration`course_schedules``bookings`
- 新增 Model`CourseSchedule``Booking`
- 新增 Controller`ScheduleController`Provider)、`BookingController`Member/Provider
- 新增 Laravel Scheduler`ExpirePendingBookings``CompleteFinishedBookings`
- 更新 `api.php`:新增 `/member/bookings``/member/schedules``/provider/schedules``/provider/bookings` 路由群組
**前端**
- 更新 `CourseDetail.vue`(或新建):加入時段列表與預約按鈕
- 新增 `src/pages/member/MyBookings.vue`:我的預約列表
- 新增 Coach Dashboard 子頁面:`ScheduleManager.vue``BookingManager.vue`
- 新增 `src/api/bookingApi.js`:封裝預約相關 API 呼叫
**資料庫**
- 兩張新資料表,無現有資料表結構變更
- `diving_offers.price` 作為預約時的價格快照來源
@@ -0,0 +1,104 @@
## ADDED Requirements
### Requirement: Member 送出預約
Member SHALL 能選擇一個開放時段送出預約,系統記錄價格快照。
#### Scenario: 成功建立預約
- **WHEN** 已登入 Member 送出 `POST /api/member/bookings`,指定 `schedule_id``participants`(≥1
- **THEN** 系統建立 Bookingstatus 為 `pending``total_price` 快照為 `diving_offer.price × participants`,回傳 201
#### Scenario: 時段已滿無法預約
- **WHEN** 指定時段 status 為 `full``cancelled`
- **THEN** 系統回傳 422,告知時段不可用
#### Scenario: 超過剩餘名額(API 層快速驗證)
- **WHEN** `participants` 大於時段當前剩餘名額(`max_participants - current_participants`),在進入 DB transaction 前
- **THEN** 系統回傳 422,告知人數超過上限,不進入 lockForUpdate 流程
#### Scenario: 超過剩餘名額(DB 層二次驗證)
- **WHEN** API 層通過但 lockForUpdate 後重新計算剩餘名額仍不足(race condition 情境)
- **THEN** 系統 rollback transaction,回傳 422,告知名額不足
#### Scenario: 不可重複預約同一時段
- **WHEN** Member 對同一 `schedule_id` 已有 `pending``confirmed` 狀態的 Booking
- **THEN** 系統回傳 422,告知已有預約
### Requirement: 預約狀態機
系統 SHALL 維護七個合法狀態,且只允許以下轉換:
- `pending``confirmed`Provider 確認)
- `pending``rejected`Provider 拒絕)
- `pending``member_cancelled`Member 取消)
- `pending``expired`Scheduler 超時)
- `confirmed``completed`Scheduler 課程後自動)
- `confirmed``member_cancelled`Member 取消)
- `confirmed``provider_cancelled`Provider 取消)
#### Scenario: 非法狀態轉換被拒絕
- **WHEN** 任何角色嘗試執行上述以外的狀態轉換
- **THEN** 系統回傳 422,說明當前狀態不允許此操作
### Requirement: Provider 確認或拒絕預約
Provider SHALL 能對自己課程的 `pending` 預約執行確認或拒絕。
#### Scenario: 確認預約
- **WHEN** Provider 送出 `PUT /api/provider/bookings/{id}/confirm`
- **THEN** Booking status 改為 `confirmed`,時段 `current_participants` 更新
#### Scenario: 拒絕預約
- **WHEN** Provider 送出 `PUT /api/provider/bookings/{id}/reject`
- **THEN** Booking status 改為 `rejected`
#### Scenario: 只能操作自己課程的預約
- **WHEN** Provider 嘗試操作不屬於自己課程的 Booking
- **THEN** 系統回傳 403 Forbidden
### Requirement: Provider 取消已確認預約
Provider SHALL 能取消 `confirmed` 狀態的預約(例如天氣因素)。
#### Scenario: Provider 取消確認中預約
- **WHEN** Provider 送出 `PUT /api/provider/bookings/{id}/cancel`
- **THEN** Booking status 改為 `provider_cancelled`,時段名額釋放
### Requirement: Member 取消預約
Member SHALL 能取消自己的 `pending``confirmed` 預約,但須在課程開始前 24 小時之前提出。
#### Scenario: 取消 pending 預約(期限內)
- **WHEN** Member 送出 `DELETE /api/member/bookings/{id}`Booking status 為 `pending`,且當前時間早於 `scheduled_date + start_time - 24h`
- **THEN** Booking status 改為 `member_cancelled`
#### Scenario: 取消 confirmed 預約(期限內)
- **WHEN** Member 送出 `DELETE /api/member/bookings/{id}`Booking status 為 `confirmed`,且當前時間早於 `scheduled_date + start_time - 24h`
- **THEN** Booking status 改為 `member_cancelled`,時段名額釋放
#### Scenario: 課程開始前 24h 內不可取消
- **WHEN** Member 送出 `DELETE /api/member/bookings/{id}`,但當前時間距 `scheduled_date + start_time` 不足 24 小時
- **THEN** 系統回傳 422,告知「距課程開始不足 24 小時,無法取消,請聯繫教練」;Booking 狀態不變
#### Scenario: 不可取消已終態預約
- **WHEN** Booking status 為 `completed``rejected``expired``provider_cancelled`
- **THEN** 系統回傳 422,告知無法取消
### Requirement: 系統自動過期 pending 預約
Scheduler SHALL 每小時掃描 `pending` 超過 48 小時的 Booking 並標記為 `expired`
#### Scenario: 過期觸發
- **WHEN** Booking status 為 `pending``created_at` 早於 48 小時前
- **THEN** Scheduler 將 status 改為 `expired`
### Requirement: 系統自動完成 confirmed 預約
Scheduler SHALL 每日掃描課程日期已過的 `confirmed` Booking 並標記為 `completed`
#### Scenario: 自動完成
- **WHEN** Booking status 為 `confirmed`,對應 `course_schedule.scheduled_date` 早於今天
- **THEN** Scheduler 將 status 改為 `completed`
### Requirement: Member 查看自己的預約列表
Member SHALL 能查詢自己所有預約的列表及詳情。
#### Scenario: 取得預約列表
- **WHEN** 已登入 Member 送出 `GET /api/member/bookings`
- **THEN** 系統回傳該 Member 所有 Booking,含課程名稱、時段日期、狀態、金額
#### Scenario: 取得單一預約詳情
- **WHEN** 已登入 Member 送出 `GET /api/member/bookings/{id}`
- **THEN** 系統回傳該 Booking 詳情;若非本人預約則回傳 403
@@ -0,0 +1,72 @@
## ADDED Requirements
### Requirement: Provider 建立開課時段
Provider SHALL 能為自己擁有的 DivingOffer 建立開課時段,指定日期、開始時間、人數上限。
#### Scenario: 成功建立時段
- **WHEN** Provider 送出 `POST /api/provider/schedules`,包含合法的 `diving_offer_id``scheduled_date`(未來日期)、`start_time``max_participants`(≥1
- **THEN** 系統建立 CourseSchedulestatus 為 `open`,回傳 201 與新時段資料
#### Scenario: 不可為他人課程建立時段
- **WHEN** Provider 送出的 `diving_offer_id` 屬於其他 Provider
- **THEN** 系統回傳 403 Forbidden
#### Scenario: 日期不可為過去
- **WHEN** `scheduled_date` 早於今天
- **THEN** 系統回傳 422,錯誤訊息指出日期無效
### Requirement: Provider 管理既有時段
Provider SHALL 能更新或取消自己的開課時段。
#### Scenario: 更新時段資訊
- **WHEN** Provider 送出 `PUT /api/provider/schedules/{id}`,修改 `start_time``max_participants`
- **THEN** 系統更新時段資料,回傳更新後內容
#### Scenario: 取消時段
- **WHEN** Provider 送出 `DELETE /api/provider/schedules/{id}`
- **THEN** 系統將時段 status 改為 `cancelled`,不實體刪除;cascade 處理所有相關 Booking(詳見下方 Requirement
#### Scenario: 不可修改他人時段
- **WHEN** Provider 嘗試修改不屬於自己的時段
- **THEN** 系統回傳 403 Forbidden
### Requirement: 取消時段的 Booking Cascade 處理
Provider 取消時段時,系統 SHALL 在同一 DB transaction 內處理該時段下所有活躍 Booking,並明確定義各狀態的 cascade 規則。
#### Scenario: pending Booking cascade 為 provider_cancelled
- **WHEN** Provider 取消時段,時段下存在 status 為 `pending` 的 Booking
- **THEN** 這些 Booking status 全部改為 `provider_cancelled`
#### Scenario: confirmed Booking cascade 為 provider_cancelled
- **WHEN** Provider 取消時段,時段下存在 status 為 `confirmed` 的 Booking
- **THEN** 這些 Booking status 全部改為 `provider_cancelled``current_participants` 不需調整(時段已取消)
#### Scenario: 終態 Booking 不受 cascade 影響
- **WHEN** Provider 取消時段,時段下存在 status 為 `completed``rejected``expired``member_cancelled``provider_cancelled` 的 Booking
- **THEN** 這些 Booking status 維持不變
#### Scenario: cascade 在同一 transaction 內完成
- **WHEN** Provider 取消時段
- **THEN** 時段狀態更新與所有 Booking cascade 更新在同一 DB transaction 內完成;任一失敗則全部 rollback,API 回傳 500
### Requirement: 時段人數自動管理
系統 SHALL 在預約確認時自動累計 `current_participants`,並於額滿時將時段 status 改為 `full`
#### Scenario: 預約確認後人數更新
- **WHEN** Provider 確認一筆 Bookingconfirmed),booking 的 `participants` 為 N
- **THEN** `course_schedules.current_participants` 增加 N;若達到 `max_participants` 則 status 改為 `full`
#### Scenario: 預約取消後人數釋放
- **WHEN** 一筆 `confirmed` 狀態的 Booking 被取消(member_cancelled 或 provider_cancelled
- **THEN** `current_participants` 減少對應人數;若原本為 `full` 則 status 改回 `open`
### Requirement: Member 查詢可用時段
Member SHALL 能查詢指定課程的可用開課時段列表。
#### Scenario: 取得開放時段
- **WHEN** 任何人(含未登入)送出 `GET /api/diving-offers/{id}/schedules`
- **THEN** 系統回傳該課程 status 為 `open`、日期未過的時段列表(含剩餘名額)
#### Scenario: 已滿時段不顯示
- **WHEN** 時段 status 為 `full`
- **THEN** 不包含在上述列表中
@@ -0,0 +1,77 @@
## 1. 資料庫層
- [x] 1.1 [後端] 建立 Migration `create_course_schedules_table`:欄位含 `diving_offer_id``provider_id``scheduled_date``start_time``max_participants``current_participants``status` string(非 enum);加索引 `(diving_offer_id, status, scheduled_date)``(provider_id)`
- [x] 1.2 [後端] 建立 Migration `create_bookings_table`:欄位含 `schedule_id``member_id``participants``total_price``status` string(非 enum)、`notes`;加索引 `(member_id, status)``(schedule_id, status)``(status, created_at)`
- [x] 1.3 [後端] 執行 Migration,確認 DB schema 與索引正確(`SHOW INDEX FROM course_schedules`
## 2. Enum 與 Model 層
- [x] 2.1 [後端] 建立 `app/Enums/BookingStatus.php`PHP BackedEnum,七個 casepending / confirmed / completed / rejected / expired / member_cancelled / provider_cancelled
- [x] 2.2 [後端] 建立 `app/Enums/ScheduleStatus.php`PHP BackedEnum,三個 caseopen / full / cancelled
- [x] 2.3 [後端] 建立 `app/Models/CourseSchedule.php`fillable、`casts = ['status' => ScheduleStatus::class]`、關聯(belongsTo DivingOffer / belongsTo User as provider、hasMany Booking
- [x] 2.4 [後端] 建立 `app/Models/Booking.php`fillable、`casts = ['status' => BookingStatus::class]``VALID_TRANSITIONS` 常數(pending→{confirmed,rejected,expired,member_cancelled}confirmed→{completed,member_cancelled,provider_cancelled};其餘終態→[])、`canTransitionTo()` 驗證方法、關聯(belongsTo CourseSchedule / belongsTo User as member
- [x] 2.5 [後端] 在 `DivingOffer` Model 新增 `hasMany CourseSchedule` 關聯
## 3. Provider 時段管理 API
- [x] 3.1 [後端] 建立 `app/Http/Controllers/API/ScheduleController.php``index``store`(含所有權驗證、日期驗證)、`update``destroy`DB transaction:時段 → cancelled + 批次 cascade pending/confirmed Booking → provider_cancelled
- [x] 3.2 [後端] 在 `routes/api.php` 新增 `/provider/schedules` 路由群組(CRUD
- [x] 3.3 [後端] 在 `routes/api.php` 新增公開路由 `GET /diving-offers/{id}/schedules`Controller 查詢條件:`status = 'open' AND scheduled_date >= CURDATE()` ORDER BY `scheduled_date ASC, start_time ASC`;回傳每筆含 `remaining_spots = max_participants - current_participants`
## 4. Provider 預約管理 API
- [x] 4.1 [後端] 建立 `app/Http/Controllers/API/ProviderBookingController.php`
- `confirm`(階段 B 佔位):DB transaction + lockForUpdate 取得 schedule → 重新計算剩餘名額(`max - current_participants`)→ 不足則 422 → 更新 Booking status=confirmed + `increment('current_participants')` → 若達滿則 schedule status=full
- `reject`Booking status → rejected,不動 current_participantspending 本來就未佔位)
- `cancel`Booking status → provider_cancelled + `decrement('current_participants')` + 若原為 full 則 schedule status 改回 open(僅 confirmed 才需釋放)
- `index`:列出自己課程的預約
- [x] 4.2 [後端] 在 `routes/api.php` 新增 `/provider/bookings` 路由群組(index / confirm / reject / cancel
## 5. Member 預約 API
- [x] 5.1 [後端] 建立 `app/Http/Controllers/API/MemberBookingController.php`
- `store`(階段 Apending 不佔位):Layer 1 快速名額檢查(`max - current_participants`422 early return)→ DB transaction 內:lockForUpdate 取得 schedule + Layer 2 名額再次驗證 + 重複預約檢查(同一 member_id + schedule_id 是否已有 pending/confirmed)→ 建立 Booking + 價格快照;**不 increment `current_participants`**
- `destroy`24h 截止驗證(Carbon datetime 比較)→ 合法則改 member_cancelled**僅 confirmed 狀態需 decrement `current_participants` + 若原為 full 改回 open**pending 取消不動人數
- `index``show`:一般查詢
- [x] 5.2 [後端] 在 `routes/api.php` 新增 `/member/bookings` 路由群組(CRUD
## 6. Scheduler 自動任務
- [x] 6.1 [後端] 建立 `app/Console/Commands/ExpirePendingBookings.php`:查詢 `status=pending``created_at < now()-48h`(利用索引 `status, created_at`),批次更新為 `expired`;執行結尾 `Log::info("ExpirePendingBookings: {$count} expired")`
- [x] 6.2 [後端] 建立 `app/Console/Commands/CompleteFinishedBookings.php`:查詢 `status=confirmed` 且 join schedule 的 `scheduled_date < today()`,批次更新為 `completed`;執行結尾 `Log::info("CompleteFinishedBookings: {$count} completed")`
- [x] 6.3 [後端] 在 `routes/console.php` 註冊:`ExpirePendingBookings` 每小時(`->hourly()`)、`CompleteFinishedBookings` 每日凌晨(`->dailyAt('00:05')`
- [x] 6.4 [後端] 在 Docker `cfdive-app` 容器的 Dockerfile / entrypoint 加入 cron job`* * * * * php /var/www/html/artisan schedule:run >> /dev/null 2>&1`;確認 cron daemon 已啟動
- [x] 6.5 [後端] 手動驗證兩支 Command 可獨立執行:`php artisan app:expire-pending-bookings``php artisan app:complete-finished-bookings` 不報錯,且 `storage/logs/laravel.log` 有對應紀錄
## 7. 前端 API 封裝
- [x] 7.1 [前端] 建立 `frontend/src/api/scheduleApi.js`:封裝 `getSchedulesByOffer(offerId)`
- [x] 7.2 [前端] 建立 `frontend/src/api/bookingApi.js`member):`getMyBookings()``getBooking(id)``createBooking(payload)``cancelBooking(id)`
- [x] 7.3 [前端] 建立 `frontend/src/api/coachBookingApi.js`provider):`getProviderBookings()``confirmBooking(id)``rejectBooking(id)``cancelBooking(id)`
- [x] 7.4 [前端] 建立 `frontend/src/api/coachScheduleApi.js``getSchedules()``createSchedule(payload)``updateSchedule(id, payload)``deleteSchedule(id)`
## 8. Member 前端頁面
- [x] 8.1 [前端] 更新課程詳情頁(`frontend/src/views/CourseDetailView.vue`):新增「可用時段」區塊,顯示日期、時間、剩餘名額,含「立即預約」按鈕(呼叫 createBooking
- [x] 8.2 [前端] 新增 `frontend/src/views/MyBookingsView.vue`:列出 Member 所有預約,顯示狀態 Badge(七種狀態對應不同顏色),含取消按鈕(pending/confirmed
- [x] 8.3 [前端] 在 Member Navbar 加入「我的預約」連結,路由 `/my-bookings`
- [x] 8.4 [前端] 在 `frontend/src/router/index.js` 新增 `/my-bookings` 路由(requiresAuth
## 9. Coach 前端頁面
- [x] 9.1 [前端] 新增 `frontend/src/views/coach/ScheduleManagerView.vue`:時段列表(含狀態、剩餘名額)、建立時段表單(日期選擇、時間、人數上限)、取消時段按鈕
- [x] 9.2 [前端] 新增 `frontend/src/views/coach/BookingManagerView.vue`:預約列表(依課程分組或全部列出)、顯示 Member 姓名、人數、金額、狀態、確認/拒絕/取消按鈕
- [x] 9.3 [前端] 在 Coach Navbar`CoachNavBar.vue`)加入「時段管理」與「預約管理」連結
- [x] 9.4 [前端] 在 `frontend/src/router/index.js` 新增 `/coach/schedules``/coach/bookings` 路由(requiresCoach
## 10. 整合驗證
- [x] 10.1 [整合測試] 完整流程測試:Provider 建立時段 → Member 預約 → Provider 確認 → 驗證人數扣減
- [x] 10.2 [整合測試] 超賣防護測試:最後一個名額同時送出兩筆預約,驗證只有一筆成功(Layer 2 lockForUpdate 生效)
- [x] 10.3 [整合測試] 取消流程測試:①Member 取消 confirmed 預約 → current_participants 減少、schedule 若原為 full 改回 open;②Member 取消 pending 預約 → current_participants **不變**pending 本來就未佔位)
- [x] 10.4 [整合測試] Scheduler 測試:手動執行 `php artisan app:expire-pending-bookings``php artisan app:complete-finished-bookings`,驗證狀態正確更新
- [x] 10.5 [整合測試] Cascade 測試:Provider 取消時段後驗證 pending/confirmed Booking 全部變 provider_cancelledcompleted/rejected Booking 狀態不變
- [x] 10.6 [整合測試] 取消截止測試:建立一筆課程開始前 12h 的 confirmed 預約,Member 嘗試取消應回傳 422;課程前 36h 的預約應可取消
- [x] 10.7 [整合測試] Participants 雙層驗證測試:超過剩餘名額的預約在 Layer 1 被攔截,回傳 422 且不進入 DB transaction
- [x] 10.8 [整合測試] 重複預約防護測試:Member 對同一時段送出第二筆 pending 預約應回傳 422;第一筆取消後再送出第三筆應成功
- [x] 10.9 [整合測試] 公開 API 過濾測試:`GET /api/diving-offers/{id}/schedules` 不回傳 status=full、cancelled 及過去日期時段;回傳時段含正確的 remaining_spots
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-11
@@ -0,0 +1,3 @@
# course-images
課程圖片上傳:封面圖 + 相簿最多 3 張,本地 public diskDocker volume 持久化
@@ -0,0 +1,147 @@
## Context
CFDivePlatform 課程目前無圖片欄位。Laravel 的 `public` disk`storage/app/public`)透過 `storage:link` symlink 至 `public/storage`,讓外部可直接存取。Docker 部署需要 named volume 掛載 storage 目錄,確保圖片跨 build 持久化。
## Goals / Non-Goals
**Goals:**
- Provider 可上傳課程封面(1 張)與相簿(最多 3 張)
- 圖片存於本地 public diskURL 可直接用於 `<img src>`
- Docker volume 持久化,重建容器不丟圖
- `FILESYSTEM_DISK=public` 從 env 控制,未來切換 S3 只改設定
**Non-Goals:**
- 圖片後端處理(resize、WebP 轉換)
- S3 / CDN 整合(留給未來)
- 管理員上傳課程圖片(僅 Provider 可上傳自己的課程)
- 圖片排序拖拉(sort_order 按上傳順序自動遞增)
## Decisions
### 決策一:資料表設計
**`diving_offers` 新增欄位:**
```
cover_image varchar(500) nullable ← 儲存相對路徑,如 offers/7/cover/abc.jpg
```
**新增 `course_images` 資料表:**
```
id bigint PK
diving_offer_id bigint FK → diving_offers (cascade)
image_path varchar(500) ← 相對路徑
sort_order unsignedSmallInt DEFAULT 0
created_at timestamp
索引:(diving_offer_id, sort_order)
```
### 決策二:URL 產生方式
`cover_image_url` accessor 與相簿 `url` 統一透過 `Storage::url($path)` 產生:
```php
// DivingOffer Model accessor
public function getCoverImageUrlAttribute(): ?string
{
return $this->cover_image
? Storage::disk('public')->url($this->cover_image)
: null;
}
```
**理由**`Storage::url()` 自動對應 `APP_URL`,未來切換 S3 disk 後 URL 自動改為 S3 endpoint,零修改。
### 決策三:檔案儲存路徑
```
storage/app/public/
offers/
{offer_id}/
cover/
{uuid}.jpg ← 封面(只會有一個)
gallery/
{uuid}.jpg ← 相簿圖片(最多 3 個)
```
UUID 檔名防止猜測,覆蓋封面時先刪舊 UUID 再存新檔。
### 決策四:封面覆蓋策略
```php
if ($offer->cover_image) {
Storage::disk('public')->delete($offer->cover_image);
}
$path = $request->file('image')->store("offers/{$offer->id}/cover", 'public');
$offer->update(['cover_image' => $path]);
```
不保留歷史封面,節省磁碟空間。
### 決策五:Docker 持久化(Bind Mount
`app``nginx` 容器都已掛載 `./:/var/www` bind mount,圖片存至 `./storage/app/public/`,兩個容器透過同一份 host 目錄共享。
**不使用 named volume** 的原因:`nginx` 容器需要能直接讀取圖片(Laravel 只寫檔,Nginx 才是實際提供靜態檔的服務)。若只在 `app` 掛 named volumeNginx 透過 bind mount 找不到該 volume 的檔案,導致圖片 404。Bind mount 對兩個容器一致,是最簡單的解法。
`docker-entrypoint.sh` 加入 `php artisan storage:link --force`,每次啟動確保 symlink 正確指向 `./storage/app/public/`
### 決策六:課程刪除時的孤兒檔案清理
**選擇**:在 `DivingOffer` Model 加 `static::deleting()` observer,刪除整個課程目錄:
```php
static::deleting(function ($offer) {
Storage::disk('public')->deleteDirectory("offers/{$offer->id}");
});
```
**理由**`course_images` cascade 只清 DB 紀錄,實體檔案殘留。若課程反覆建刪,磁碟累積孤兒目錄。一行程式碼解決,不算 over-engineering。`deleteDirectory` 目錄不存在時不報錯,安全。
**放棄**:排程清理孤兒檔 → 有時間差,且需額外邏輯比對 DB 與 storage。
---
### 決策七:sort_order 不連續為預期行為
刪除中間相簿圖片後再新增,`sort_order = MAX(sort_order) + 1`,序號會不連續(如:1, 3 而非 1, 2)。
**決定**:MVP 接受不連續。前端依 `sort_order ASC` 排序顯示即可,不需重新排號。
**理由**:重新排號需要 UPDATE 多筆紀錄,複雜度不值得(相簿最多 3 張,視覺影響極小)。
---
### 決策八:course_images 的 created_at 自動填入
Migration 使用 `$table->timestamp('created_at')->useCurrent()`,讓 DB 自動填入,不依賴 PHP 層。
Model 設定:
```php
public $timestamps = false;
const CREATED_AT = 'created_at'; // 告知 Eloquent 此欄位存在(用於排序)
```
**理由**`useCurrent()``review_votes` 的做法一致,無需手動傳入 `created_at`
---
### 決策九:`FILESYSTEM_DISK` 設定
`.env``FILESYSTEM_DISK=local` 改為 `public`,確保 `Storage::disk()` 預設指向公開路徑。`CourseImageController` 明確指定 `disk('public')`,不依賴預設值,避免混淆。
## API 路由總覽
```
Provider (auth:sanctum)
POST /api/provider/offers/{id}/cover ← 上傳/覆蓋封面
DELETE /api/provider/offers/{id}/cover ← 刪除封面
POST /api/provider/offers/{id}/images ← 上傳相簿圖片(最多 3 張)
DELETE /api/provider/images/{imageId} ← 刪除特定相簿圖片
```
## Risks / Trade-offs
- **本地 disk 無法多機部署**:目前單機 Docker 可接受;未來水平擴展必須改 S3,設計已預留切換彈性(`Storage::disk('public')`
- **檔案孤兒**:若刪除課程(`DivingOffer`)時,`course_images` cascade 刪除 DB 紀錄,但實體檔案不會自動清除。MVP 可接受(磁碟空間有限時手動清理)
- **storage:link 在 Docker 重啟時**:每次 entrypoint 執行 `storage:link --force` 覆蓋重建,確保 symlink 正確
@@ -0,0 +1,41 @@
## Why
目前課程頁面以 🤿 emoji 作為佔位,缺乏視覺吸引力。課程圖片是潛水平台的核心體驗,直接影響會員瀏覽意願與教練品牌形象。
## What Changes
- 新增 `diving_offers.cover_image` 欄位:課程封面,顯示於課程卡與詳情頁頂部
- 新增 `course_images` 資料表:相簿最多 3 張,顯示於課程詳情頁
- 新增 Provider API:上傳/刪除封面、上傳/刪除相簿圖片
- 更新前端:CourseCard 顯示封面、CourseDetailView 顯示封面 + 相簿、OfferFormView 加入圖片管理 UI
- Docker volume 掛載 `storage/app/public`,圖片跨 build 持久化
- `php artisan storage:link` 整合至 entrypoint,確保 symlink 正確
## Capabilities
### New Capabilities
- `course-image-upload`:Provider 上傳課程封面與相簿圖片(本地 public disk、max 2MB、支援 jpg/jpeg/png/webp)、相簿上限 3 張、刪除圖片同步移除實體檔案
### Modified Capabilities
- `coach-offers-api`:新增圖片上傳/刪除端點,`DivingOffer` 回應加入 `cover_image_url``images`
## Impact
**後端**
- Migration`diving_offers``cover_image`nullable string
- Migration:新增 `course_images`id、diving_offer_id、image_path、sort_order
- Model`CourseImage``DivingOffer``hasMany CourseImage``cover_image_url` accessor
- Controller`CourseImageController`(上傳封面、刪除封面、上傳相簿、刪除相簿)
- Route`/provider/offers/{id}/cover`POST/DELETE)、`/provider/offers/{id}/images`POST)、`/provider/images/{id}`DELETE
**前端**
- `frontend/src/api/courseImageApi.js`
- `CourseCard.vue`:有封面顯示圖片,無封面顯示漸層佔位
- `CourseDetailView.vue`:頂部大圖(封面)+ 相簿縮圖列
- `OfferFormView.vue`(或新建 `OfferImageManager.vue`):封面上傳預覽、相簿管理
**Docker**
- `docker-compose.yml` 新增 named volume `storage-data` 掛載 `/var/www/storage/app/public`
- `docker-entrypoint.sh` 加入 `php artisan storage:link --force`

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