Compare commits
10 Commits
24145419b0
...
a3462f8493
| Author | SHA1 | Date | |
|---|---|---|---|
| a3462f8493 | |||
| 05ef8b9316 | |||
| 95bafef52d | |||
| bda6533105 | |||
| efb1f22be3 | |||
| 915e404dfc | |||
| 0bce40a5bf | |||
| ca2ed14de3 | |||
| 8ef293332a | |||
| 88c0c09ccb |
+6
-4
@@ -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,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
@@ -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"]
|
||||||
|
|||||||
@@ -1,4 +1,49 @@
|
|||||||

|
# CFDive Platform
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
|
潛水課程媒合平台 — 連結潛水教練與學員,提供課程瀏覽、線上預約、評價與通知等完整服務。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能概覽
|
||||||
|
|
||||||
|
**會員(Member)**
|
||||||
|
- 註冊 / 登入(Email + Google OAuth)
|
||||||
|
- 瀏覽、搜尋、篩選潛水課程
|
||||||
|
- 查看課程時段並送出預約
|
||||||
|
- 對完成的課程留下評價(支援匿名、有幫助投票)
|
||||||
|
- 站內通知(Email + Polling)
|
||||||
|
|
||||||
|
**教練(Provider)**
|
||||||
|
- 課程 CRUD(含封面 + 相簿圖片上傳)
|
||||||
|
- 課程時段管理
|
||||||
|
- 預約管理(確認 / 拒絕 / 完成 / 取消)
|
||||||
|
|
||||||
|
**管理員(Admin)**
|
||||||
|
- 平台統計數據(會員數、教練數、課程數)
|
||||||
|
- 會員與教練帳號管理(啟用 / 停用 / 審核)
|
||||||
|
- 課程、預約、評價管理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技術棧
|
||||||
|
|
||||||
|
| 層級 | 技術 |
|
||||||
|
|------|------|
|
||||||
|
| 後端 | PHP 8.x / Laravel 11 |
|
||||||
|
| 前端 | Vue 3 |
|
||||||
|
| 資料庫 | MySQL 8.0 |
|
||||||
|
| 快取 | Redis(predis)|
|
||||||
|
| 認證 | Laravel Sanctum + Google OAuth |
|
||||||
|
| 容器 | Docker / Docker Compose |
|
||||||
|
| API 文件 | Swagger UI(l5-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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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()
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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,
|
'total_members' => User::where('role', 'member')->count(),
|
||||||
'data' => [
|
'total_providers' => User::where('role', 'provider')->count(),
|
||||||
'total_members' => User::where('role', 'member')->count(),
|
'total_offers' => DivingOffer::count(),
|
||||||
'total_providers' => User::where('role', 'provider')->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,59 +5,79 @@ 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()));
|
||||||
|
|
||||||
$query = DivingOffer::query();
|
$result = Cache::tags(['diving_offers'])->remember($cacheKey, 180, function () use ($request, $perPage) {
|
||||||
|
$query = DivingOffer::query();
|
||||||
|
|
||||||
if ($q = $request->query('q')) {
|
if ($q = $request->query('q')) {
|
||||||
$query->where(function ($sub) use ($q) {
|
$query->where(function ($sub) use ($q) {
|
||||||
$sub->where('title', 'like', "%{$q}%")
|
$sub->where('title', 'like', "%{$q}%")
|
||||||
->orWhere('location', 'like', "%{$q}%")
|
->orWhere('location', 'like', "%{$q}%")
|
||||||
->orWhere('spot', 'like', "%{$q}%");
|
->orWhere('spot', 'like', "%{$q}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($region = $request->query('region')) {
|
if ($region = $request->query('region')) {
|
||||||
$query->where('region', $region);
|
$query->where('region', $region);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tag = $request->query('tag')) {
|
if ($tag = $request->query('tag')) {
|
||||||
$query->where('tag', 'like', "%{$tag}%");
|
$query->where('tag', 'like', "%{$tag}%");
|
||||||
}
|
}
|
||||||
|
|
||||||
$paginated = $query->paginate($perPage);
|
$paginated = $query->paginate($perPage);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'items' => collect($paginated->items())->map(fn($o) => $this->formatOffer($o, false))->values(),
|
||||||
|
'meta' => [
|
||||||
|
'total' => $paginated->total(),
|
||||||
|
'per_page' => $paginated->perPage(),
|
||||||
|
'current_page' => $paginated->currentPage(),
|
||||||
|
'last_page' => $paginated->lastPage(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => true,
|
'status' => true,
|
||||||
'data' => $paginated->items(),
|
'data' => $result['items'],
|
||||||
'meta' => [
|
'meta' => $result['meta'],
|
||||||
'total' => $paginated->total(),
|
|
||||||
'per_page' => $paginated->perPage(),
|
|
||||||
'current_page' => $paginated->currentPage(),
|
|
||||||
'last_page' => $paginated->lastPage(),
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 2:lockForUpdate 後二次驗證
|
||||||
|
$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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
@@ -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,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;
|
||||||
|
|||||||
@@ -110,5 +110,13 @@ fi
|
|||||||
|
|
||||||
echo "✅ CFDivePlatform 初始化完成!"
|
echo "✅ CFDivePlatform 初始化完成!"
|
||||||
|
|
||||||
|
# 建立 storage symlink
|
||||||
|
echo "🔗 建立 storage symlink..."
|
||||||
|
php artisan storage:link --force || true
|
||||||
|
|
||||||
|
# 啟動 cron daemon(Laravel Scheduler)
|
||||||
|
echo "⏰ 啟動 Laravel Scheduler cron..."
|
||||||
|
service cron start || cron || true
|
||||||
|
|
||||||
# 執行傳入的命令
|
# 執行傳入的命令
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
+4
-14
@@ -1,21 +1,10 @@
|
|||||||
<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 route = useRoute()
|
||||||
const coachAuth = useCoachAuthStore()
|
|
||||||
const adminAuth = useAdminAuthStore()
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -18,12 +19,16 @@ async function handleLogout() {
|
|||||||
<RouterLink to="/coach/dashboard" class="text-lg font-bold tracking-wide hover:text-gray-300 transition">
|
<RouterLink to="/coach/dashboard" class="text-lg font-bold tracking-wide hover:text-gray-300 transition">
|
||||||
🤿 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/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
|
<RouterLink to="/coach/schedules" class="text-sm hover:text-gray-300 transition">時段管理</RouterLink>
|
||||||
|
<RouterLink to="/coach/bookings" class="text-sm hover:text-gray-300 transition">預約管理</RouterLink>
|
||||||
|
<RouterLink to="/coach/reviews" class="text-sm hover:text-gray-300 transition">課程評價</RouterLink>
|
||||||
|
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ const routes = [
|
|||||||
{ path: '/login', component: () => import('../views/LoginView.vue') },
|
{ path: '/login', component: () => import('../views/LoginView.vue') },
|
||||||
{ 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') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,14 +176,167 @@ 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>
|
||||||
立即洽詢
|
|
||||||
</button>
|
<!-- 可用時段 -->
|
||||||
|
<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>
|
||||||
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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`"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
router.push('/coach/dashboard')
|
||||||
} else {
|
} else {
|
||||||
await coachApi.post('/provider/offers', payload)
|
const res = await coachApi.post('/provider/offers', payload)
|
||||||
|
const newId = res.data.data?.id
|
||||||
|
router.push(`/coach/schedules?offer_id=${newId}&new=1`)
|
||||||
}
|
}
|
||||||
router.push('/coach/dashboard')
|
|
||||||
} 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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,不 increment(pending 不佔位)
|
||||||
|
```
|
||||||
|
|
||||||
|
**階段 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 → confirmed(Provider 確認) | +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` 保留為選填欄位供日後使用。
|
||||||
|
- **Q2:Provider 取消時段時,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 Scheduler:pending 超 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** 系統建立 Booking,status 為 `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** 系統建立 CourseSchedule,status 為 `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 確認一筆 Booking(confirmed),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,七個 case(pending / confirmed / completed / rejected / expired / member_cancelled / provider_cancelled)
|
||||||
|
- [x] 2.2 [後端] 建立 `app/Enums/ScheduleStatus.php`:PHP BackedEnum,三個 case(open / 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_participants(pending 本來就未佔位)
|
||||||
|
- `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`(階段 A,pending 不佔位):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_cancelled;completed/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 disk,Docker 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 disk,URL 可直接用於 `<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 volume,Nginx 透過 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
Reference in New Issue
Block a user