From 81a9f84b26759804f9af2680001c89c2bbe059bb Mon Sep 17 00:00:00 2001 From: Hank Date: Tue, 12 May 2026 02:46:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=AF=A6=E4=BD=9C=E8=A9=95=E5=83=B9?= =?UTF-8?q?=E7=B3=BB=E7=B5=B1=20=E2=80=94=20=E5=8C=BF=E5=90=8D=E8=A9=95?= =?UTF-8?q?=E5=83=B9=E3=80=81=E6=9C=89=E5=B9=AB=E5=8A=A9=E6=8A=95=E7=A5=A8?= =?UTF-8?q?=E3=80=81=E6=89=8B=E5=8B=95=E5=AE=8C=E6=88=90=E9=A0=90=E7=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 後端: - 新增 reviews / review_edits / review_votes migration(含索引) - Review / ReviewEdit / ReviewVote Model - ReviewController:評價 CRUD、資格驗證(completed booking)、rating 即時重算 - toggleHelpful:Member 限定、GREATEST 原子防負、DB transaction 同步 - AdminReviewController:全量列表、刪除(含重算) - AdminBookingController:全量列表、手動標記 completed - ProviderBookingController 新增 complete 方法(教練手動完成預約) - DevelopmentSeeder:快速重建測試資料(admin/coach/member + offers + bookings) - EnsureAdmin middleware 正式納入 bootstrap/app.php - Nginx server_name 加入 cfdive.local 前端: - 課程詳情頁加入評價區塊(星等分布、排序切換、撰寫/修改/刪除、有幫助 Toggle) - Coach Portal 新增「課程評價」頁(只讀,依課程分組) - Coach 預約管理加入「完成」按鈕 - Admin 新增「預約管理」頁(標記完成)、「評價管理」頁(刪除) - Admin / Coach Navbar 新增對應連結 OpenSpec: - review-system change 歸檔至 archive/2026-05-12-review-system - 新增 specs/review-lifecycle 與 specs/review-voting 主規格 - review-voting spec 補充 Member 限定與 GREATEST 原子更新說明 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../API/AdminBookingController.php | 44 +++ .../Controllers/API/AdminReviewController.php | 56 ++++ .../API/ProviderBookingController.php | 14 + app/Http/Controllers/API/ReviewController.php | 229 ++++++++++++++ app/Http/Middleware/EnsureAdmin.php | 19 ++ app/Models/DivingOffer.php | 5 + app/Models/Review.php | 43 +++ app/Models/ReviewEdit.php | 27 ++ app/Models/ReviewVote.php | 26 ++ bootstrap/app.php | 4 +- ...2026_05_11_175856_create_reviews_table.php | 39 +++ ...05_11_175857_create_review_edits_table.php | 30 ++ ...05_11_175858_create_review_votes_table.php | 31 ++ database/seeders/DevelopmentSeeder.php | 108 +++++++ docker/nginx/conf.d/app.conf | 2 +- frontend/src/api/coachBookingApi.js | 4 + frontend/src/api/reviewApi.js | 27 ++ frontend/src/components/AdminNavBar.vue | 2 + frontend/src/components/CoachNavBar.vue | 1 + frontend/src/router/index.js | 3 + frontend/src/views/CourseDetailView.vue | 162 +++++++++- frontend/src/views/admin/BookingsView.vue | 85 +++++ frontend/src/views/admin/ReviewsView.vue | 67 ++++ .../src/views/coach/BookingManagerView.vue | 13 +- frontend/src/views/coach/ReviewsView.vue | 101 ++++++ .../2026-05-12-review-system/.openspec.yaml | 2 + .../2026-05-12-review-system/README.md | 3 + .../2026-05-12-review-system/design.md | 290 ++++++++++++++++++ .../2026-05-12-review-system/proposal.md | 42 +++ .../specs/review-lifecycle/spec.md | 72 +++++ .../specs/review-voting/spec.md | 31 ++ .../archive/2026-05-12-review-system/tasks.md | 73 +++++ openspec/specs/review-lifecycle/spec.md | 81 +++++ openspec/specs/review-voting/spec.md | 33 ++ routes/api.php | 20 +- 35 files changed, 1781 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/API/AdminBookingController.php create mode 100644 app/Http/Controllers/API/AdminReviewController.php create mode 100644 app/Http/Controllers/API/ReviewController.php create mode 100644 app/Http/Middleware/EnsureAdmin.php create mode 100644 app/Models/Review.php create mode 100644 app/Models/ReviewEdit.php create mode 100644 app/Models/ReviewVote.php create mode 100644 database/migrations/2026_05_11_175856_create_reviews_table.php create mode 100644 database/migrations/2026_05_11_175857_create_review_edits_table.php create mode 100644 database/migrations/2026_05_11_175858_create_review_votes_table.php create mode 100644 database/seeders/DevelopmentSeeder.php create mode 100644 frontend/src/api/reviewApi.js create mode 100644 frontend/src/views/admin/BookingsView.vue create mode 100644 frontend/src/views/admin/ReviewsView.vue create mode 100644 frontend/src/views/coach/ReviewsView.vue create mode 100644 openspec/changes/archive/2026-05-12-review-system/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-12-review-system/README.md create mode 100644 openspec/changes/archive/2026-05-12-review-system/design.md create mode 100644 openspec/changes/archive/2026-05-12-review-system/proposal.md create mode 100644 openspec/changes/archive/2026-05-12-review-system/specs/review-lifecycle/spec.md create mode 100644 openspec/changes/archive/2026-05-12-review-system/specs/review-voting/spec.md create mode 100644 openspec/changes/archive/2026-05-12-review-system/tasks.md create mode 100644 openspec/specs/review-lifecycle/spec.md create mode 100644 openspec/specs/review-voting/spec.md diff --git a/app/Http/Controllers/API/AdminBookingController.php b/app/Http/Controllers/API/AdminBookingController.php new file mode 100644 index 0000000..4a9df1f --- /dev/null +++ b/app/Http/Controllers/API/AdminBookingController.php @@ -0,0 +1,44 @@ +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' => '預約已標記為完成']); + } +} diff --git a/app/Http/Controllers/API/AdminReviewController.php b/app/Http/Controllers/API/AdminReviewController.php new file mode 100644 index 0000000..c963aaa --- /dev/null +++ b/app/Http/Controllers/API/AdminReviewController.php @@ -0,0 +1,56 @@ +orderByDesc('created_at') + ->get() + ->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]); + } + + 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, + ]); + } +} diff --git a/app/Http/Controllers/API/ProviderBookingController.php b/app/Http/Controllers/API/ProviderBookingController.php index 1adef81..7252543 100644 --- a/app/Http/Controllers/API/ProviderBookingController.php +++ b/app/Http/Controllers/API/ProviderBookingController.php @@ -94,6 +94,20 @@ class ProviderBookingController extends Controller 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]); + + return response()->json(['status' => true, 'message' => '預約已標記為完成']); + } + private function authorizeProvider(Request $request, Booking $booking): void { if ($booking->schedule->provider_id !== $request->user()->id) { diff --git a/app/Http/Controllers/API/ReviewController.php b/app/Http/Controllers/API/ReviewController.php new file mode 100644 index 0000000..0ffa251 --- /dev/null +++ b/app/Http/Controllers/API/ReviewController.php @@ -0,0 +1,229 @@ +user(); + + $sort = $request->query('sort', 'helpful'); + $query = Review::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'), + }; + + $reviews = $query->get(); + + // 批次查詢 has_voted + $votedIds = $user + ? ReviewVote::where('member_id', $user->id) + ->whereIn('review_id', $reviews->pluck('id')) + ->pluck('review_id') + ->flip() + : collect(); + + // summary + $distRaw = Review::where('diving_offer_id', $offer->id) + ->selectRaw('rating, COUNT(*) as cnt') + ->groupBy('rating') + ->pluck('cnt', 'rating'); + $distribution = collect([1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0])->merge($distRaw); + + $total = $reviews->count(); + $average = $total > 0 ? round($reviews->avg('rating'), 1) : 0; + + $formatted = $reviews->map(function ($r) use ($user, $votedIds) { + $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' => $votedIds->has($r->id), + ]; + if ($user) { + $item['is_mine'] = $r->member_id === $user->id; + } + return $item; + }); + + return response()->json([ + 'status' => true, + 'data' => [ + 'summary' => [ + 'average' => $average, + 'total' => $total, + 'distribution' => $distribution, + ], + 'reviews' => $formatted, + ], + ]); + } + + // ── 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; + }); + + 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', + ]); + + 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); + }); + + 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); + }); + + 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(), + ]; + } +} diff --git a/app/Http/Middleware/EnsureAdmin.php b/app/Http/Middleware/EnsureAdmin.php new file mode 100644 index 0000000..8c38917 --- /dev/null +++ b/app/Http/Middleware/EnsureAdmin.php @@ -0,0 +1,19 @@ +user() || !$request->user()->isAdmin()) { + return response()->json(['status' => false, 'message' => '無權限存取'], 403); + } + + return $next($request); + } +} diff --git a/app/Models/DivingOffer.php b/app/Models/DivingOffer.php index e10047f..edb0489 100644 --- a/app/Models/DivingOffer.php +++ b/app/Models/DivingOffer.php @@ -35,4 +35,9 @@ class DivingOffer extends Model { return $this->hasMany(CourseSchedule::class, 'diving_offer_id'); } + + public function reviews() + { + return $this->hasMany(Review::class); + } } diff --git a/app/Models/Review.php b/app/Models/Review.php new file mode 100644 index 0000000..24f82e5 --- /dev/null +++ b/app/Models/Review.php @@ -0,0 +1,43 @@ + '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); + } +} diff --git a/app/Models/ReviewEdit.php b/app/Models/ReviewEdit.php new file mode 100644 index 0000000..3a6f73c --- /dev/null +++ b/app/Models/ReviewEdit.php @@ -0,0 +1,27 @@ + 'integer', + 'edited_at' => 'datetime', + ]; + + public function review() + { + return $this->belongsTo(Review::class); + } +} diff --git a/app/Models/ReviewVote.php b/app/Models/ReviewVote.php new file mode 100644 index 0000000..27300df --- /dev/null +++ b/app/Models/ReviewVote.php @@ -0,0 +1,26 @@ +belongsTo(Review::class); + } + + public function member() + { + return $this->belongsTo(User::class, 'member_id'); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index d654276..a462725 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware) { - // + $middleware->alias([ + 'admin' => \App\Http\Middleware\EnsureAdmin::class, + ]); }) ->withExceptions(function (Exceptions $exceptions) { // diff --git a/database/migrations/2026_05_11_175856_create_reviews_table.php b/database/migrations/2026_05_11_175856_create_reviews_table.php new file mode 100644 index 0000000..b6d683b --- /dev/null +++ b/database/migrations/2026_05_11_175856_create_reviews_table.php @@ -0,0 +1,39 @@ +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'); + } +}; diff --git a/database/migrations/2026_05_11_175857_create_review_edits_table.php b/database/migrations/2026_05_11_175857_create_review_edits_table.php new file mode 100644 index 0000000..7dd04bc --- /dev/null +++ b/database/migrations/2026_05_11_175857_create_review_edits_table.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/database/migrations/2026_05_11_175858_create_review_votes_table.php b/database/migrations/2026_05_11_175858_create_review_votes_table.php new file mode 100644 index 0000000..7db8722 --- /dev/null +++ b/database/migrations/2026_05_11_175858_create_review_votes_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/database/seeders/DevelopmentSeeder.php b/database/seeders/DevelopmentSeeder.php new file mode 100644 index 0000000..56cd06c --- /dev/null +++ b/database/seeders/DevelopmentSeeder.php @@ -0,0 +1,108 @@ + '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'); + } +} diff --git a/docker/nginx/conf.d/app.conf b/docker/nginx/conf.d/app.conf index 7360631..c0c4690 100644 --- a/docker/nginx/conf.d/app.conf +++ b/docker/nginx/conf.d/app.conf @@ -1,7 +1,7 @@ # 定義一個 HTTP 服務器塊 server { - # 監聽 80 端口(HTTP) listen 80; + server_name cfdive.local localhost; # 默認索引文件,按順序嘗試 index index.php index.html; diff --git a/frontend/src/api/coachBookingApi.js b/frontend/src/api/coachBookingApi.js index 39a2ab4..52a7747 100644 --- a/frontend/src/api/coachBookingApi.js +++ b/frontend/src/api/coachBookingApi.js @@ -15,3 +15,7 @@ export function rejectBooking(id) { export function cancelBooking(id) { return coachApi.put(`/provider/bookings/${id}/cancel`) } + +export function completeBooking(id) { + return coachApi.put(`/provider/bookings/${id}/complete`) +} diff --git a/frontend/src/api/reviewApi.js b/frontend/src/api/reviewApi.js new file mode 100644 index 0000000..c02899c --- /dev/null +++ b/frontend/src/api/reviewApi.js @@ -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`) +} diff --git a/frontend/src/components/AdminNavBar.vue b/frontend/src/components/AdminNavBar.vue index 4a4d6ff..fc8e145 100644 --- a/frontend/src/components/AdminNavBar.vue +++ b/frontend/src/components/AdminNavBar.vue @@ -20,6 +20,8 @@ async function handleLogout() { 會員管理 教練管理 課程管理 + 預約管理 + 評價管理
{{ adminAuth.user?.name }} diff --git a/frontend/src/components/CoachNavBar.vue b/frontend/src/components/CoachNavBar.vue index 1b866a4..5c9fef8 100644 --- a/frontend/src/components/CoachNavBar.vue +++ b/frontend/src/components/CoachNavBar.vue @@ -21,6 +21,7 @@ async function handleLogout() { 我的課程 時段管理 預約管理 + 課程評價 個人資料
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 91f0b28..d3080c6 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -29,6 +29,7 @@ const routes = [ { 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') }, ], }, @@ -44,6 +45,8 @@ const routes = [ { path: 'members', component: () => import('../views/admin/MembersView.vue') }, { path: 'providers', component: () => import('../views/admin/ProvidersView.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') }, ], }, ] diff --git a/frontend/src/views/CourseDetailView.vue b/frontend/src/views/CourseDetailView.vue index 836386d..0c0e3f0 100644 --- a/frontend/src/views/CourseDetailView.vue +++ b/frontend/src/views/CourseDetailView.vue @@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router' 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() @@ -18,12 +19,24 @@ 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 () => { try { const res = await api.get(`/diving-offers/${route.params.id}`) offer.value = res.data.data - const sRes = await getSchedulesByOffer(route.params.id) + 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) { notFound.value = true } finally { @@ -31,6 +44,57 @@ onMounted(async () => { } }) +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: '' } @@ -154,6 +218,102 @@ async function submitBooking() { + + +
+ +
+
+

課程評價

+

+ ★ {{ reviewSummary.average }} · {{ reviewSummary.total }} 則評價 +

+
+
+ +
+
+ + +
+
+ {{ star }}★ +
+
+
+
+ {{ reviewSummary.distribution[star] }} +
+
+ + +
+
+ +
+
+

{{ editTarget ? '修改評價' : '撰寫評價' }}

+ +
+ +
+