diff --git a/app/Docs/AdminApiDoc.php b/app/Docs/AdminApiDoc.php
index 69ceeb4..944d58a 100644
--- a/app/Docs/AdminApiDoc.php
+++ b/app/Docs/AdminApiDoc.php
@@ -283,30 +283,85 @@ class AdminApiDoc
}
/**
- * 切換教練審核狀態
+ * 教練審核佇列
*
- * @OA\Put(
- * path="/admin/providers/{id}/toggle-verified",
- * summary="切換教練審核狀態",
- * description="通過或撤銷教練審核,回傳新的 is_verified 狀態",
- * operationId="toggleProviderVerified",
+ * @OA\Get(
+ * path="/admin/verifications",
+ * summary="教練審核佇列",
+ * description="查詢教練驗證申請(預設僅 pending;status=all 可查全部),含證照圖片 URL",
+ * operationId="adminVerificationIndex",
* tags={"Admin 教練管理"},
* security={{"bearerAuth": {}}},
- * @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
+ * @OA\Parameter(name="status", in="query", required=false, description="unsubmitted / pending / approved / rejected / all", @OA\Schema(type="string", default="pending")),
* @OA\Response(
* response=200,
- * description="切換成功",
+ * 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\Property(property="data", type="array", @OA\Items(
+ * @OA\Property(property="user_id", type="integer", example=5),
+ * @OA\Property(property="name", type="string", example="王教練"),
+ * @OA\Property(property="email", type="string", example="coach@example.com"),
+ * @OA\Property(property="business_name", type="string", example="藍海潛水"),
+ * @OA\Property(property="verification_status", type="string", example="pending"),
+ * @OA\Property(property="rejection_reason", type="string", nullable=true, example=null),
+ * @OA\Property(property="certifications", type="array", @OA\Items(
+ * @OA\Property(property="id", type="integer", example=1),
+ * @OA\Property(property="url", type="string", example="http://localhost:8080/storage/providers/5/certifications/uuid.jpg")
+ * ))
+ * ))
* )
* ),
- * @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
- * @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
+ * @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* )
*/
- public function toggleProviderVerified()
+ public function adminVerificationIndex()
+ {
+ }
+
+ /**
+ * 通過教練審核
+ *
+ * @OA\Put(
+ * path="/admin/verifications/{userId}/approve",
+ * summary="通過教練審核",
+ * description="將 pending 教練轉為 approved,課程恢復公開曝光並通知教練",
+ * operationId="adminVerificationApprove",
+ * tags={"Admin 教練管理"},
+ * security={{"bearerAuth": {}}},
+ * @OA\Parameter(name="userId", in="path", required=true, description="教練使用者 ID", @OA\Schema(type="integer")),
+ * @OA\Response(response=200, description="已通過審核"),
+ * @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
+ * @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
+ * @OA\Response(response=422, description="當前狀態無法通過審核", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
+ * )
+ */
+ public function adminVerificationApprove()
+ {
+ }
+
+ /**
+ * 駁回教練審核
+ *
+ * @OA\Put(
+ * path="/admin/verifications/{userId}/reject",
+ * summary="駁回教練審核",
+ * description="駁回 pending 教練或撤銷 approved 教練,原因必填並通知教練",
+ * operationId="adminVerificationReject",
+ * tags={"Admin 教練管理"},
+ * security={{"bearerAuth": {}}},
+ * @OA\Parameter(name="userId", in="path", required=true, description="教練使用者 ID", @OA\Schema(type="integer")),
+ * @OA\RequestBody(required=true, @OA\JsonContent(
+ * required={"reason"},
+ * @OA\Property(property="reason", type="string", maxLength=500, example="證照影像不清晰,請重新拍攝上傳")
+ * )),
+ * @OA\Response(response=200, description="已駁回"),
+ * @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
+ * @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
+ * @OA\Response(response=422, description="原因未填或狀態不可駁回", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
+ * )
+ */
+ public function adminVerificationReject()
{
}
diff --git a/app/Enums/VerificationStatus.php b/app/Enums/VerificationStatus.php
new file mode 100644
index 0000000..cc2480f
--- /dev/null
+++ b/app/Enums/VerificationStatus.php
@@ -0,0 +1,23 @@
+ ['pending'],
+ 'pending' => ['approved', 'rejected'],
+ 'approved' => ['rejected'], // 撤銷驗證,原因必填
+ 'rejected' => ['pending'], // 重新送審
+ ];
+
+ public function canTransitionTo(self $newStatus): bool
+ {
+ return in_array($newStatus->value, self::VALID_TRANSITIONS[$this->value] ?? []);
+ }
+}
diff --git a/app/Http/Controllers/API/AdminUserController.php b/app/Http/Controllers/API/AdminUserController.php
index 9814760..2ab18e5 100644
--- a/app/Http/Controllers/API/AdminUserController.php
+++ b/app/Http/Controllers/API/AdminUserController.php
@@ -5,7 +5,6 @@ namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Cache;
class AdminUserController extends Controller
{
@@ -132,23 +131,5 @@ class AdminUserController extends Controller
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
}
- public function toggleProviderVerified(int $id)
- {
- if ($err = $this->checkAdmin()) return $err;
-
- $user = $this->findUser($id, 'provider');
- if (!$user) {
- return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
- }
-
- $profile = $user->providerProfile;
- $profile->is_verified = !$profile->is_verified;
- $profile->save();
-
- // 驗證狀態影響公開課程列表的可見性,需立即讓快取失效
- Cache::tags(['diving_offers'])->flush();
-
- $msg = $profile->is_verified ? '教練已驗證' : '已取消驗證';
- return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_verified' => $profile->is_verified]]);
- }
+ // toggleProviderVerified 已移除:驗證狀態變更一律走 AdminVerificationController 的審核狀態機
}
diff --git a/app/Http/Controllers/API/AdminVerificationController.php b/app/Http/Controllers/API/AdminVerificationController.php
new file mode 100644
index 0000000..5a2a82a
--- /dev/null
+++ b/app/Http/Controllers/API/AdminVerificationController.php
@@ -0,0 +1,94 @@
+query('status', VerificationStatus::Pending->value);
+
+ $query = User::where('role', 'provider')
+ ->with(['providerProfile', 'providerCertifications'])
+ ->whereHas('providerProfile');
+
+ if ($status !== 'all') {
+ $query->whereHas('providerProfile', fn($q) => $q->where('verification_status', $status));
+ }
+
+ $providers = $query->orderByDesc('updated_at')->get()->map(fn($u) => [
+ 'user_id' => $u->id,
+ 'name' => $u->name,
+ 'email' => $u->email,
+ 'business_name' => $u->providerProfile->business_name,
+ 'verification_status' => $u->providerProfile->verification_status->value,
+ 'rejection_reason' => $u->providerProfile->rejection_reason,
+ 'certifications' => $u->providerCertifications->map(fn($c) => ['id' => $c->id, 'url' => $c->url])->values(),
+ ]);
+
+ return response()->json(['status' => true, 'data' => $providers]);
+ }
+
+ public function approve(Request $request, int $userId)
+ {
+ $profile = $this->findProviderProfile($userId);
+
+ if (!$profile->verification_status->canTransitionTo(VerificationStatus::Approved)) {
+ return response()->json(['status' => false, 'message' => '當前狀態無法通過審核'], 422);
+ }
+
+ $profile->update(['verification_status' => VerificationStatus::Approved, 'rejection_reason' => null]);
+
+ // 驗證狀態影響公開課程可見性,需立即讓快取失效
+ Cache::tags(['diving_offers'])->flush();
+
+ $this->notify($profile->user, new ProviderVerificationApprovedNotification());
+
+ return response()->json(['status' => true, 'message' => '教練已通過審核']);
+ }
+
+ public function reject(Request $request, int $userId)
+ {
+ $data = $request->validate(['reason' => 'required|string|max:500']);
+
+ $profile = $this->findProviderProfile($userId);
+
+ if (!$profile->verification_status->canTransitionTo(VerificationStatus::Rejected)) {
+ return response()->json(['status' => false, 'message' => '當前狀態無法駁回'], 422);
+ }
+
+ $profile->update([
+ 'verification_status' => VerificationStatus::Rejected,
+ 'rejection_reason' => $data['reason'],
+ ]);
+
+ Cache::tags(['diving_offers'])->flush();
+
+ $this->notify($profile->user, new ProviderVerificationRejectedNotification($data['reason']));
+
+ return response()->json(['status' => true, 'message' => '已駁回,教練將收到原因通知']);
+ }
+
+ private function findProviderProfile(int $userId)
+ {
+ return User::where('role', 'provider')->findOrFail($userId)->providerProfile()->firstOrFail();
+ }
+
+ private function notify(User $provider, object $notification): void
+ {
+ try {
+ $provider->notify($notification);
+ } catch (\Throwable $e) {
+ Log::error('ProviderVerification notification failed: ' . $e->getMessage());
+ }
+ }
+}
diff --git a/app/Http/Controllers/API/MemberBookingController.php b/app/Http/Controllers/API/MemberBookingController.php
index 7276f72..5f5fab2 100644
--- a/app/Http/Controllers/API/MemberBookingController.php
+++ b/app/Http/Controllers/API/MemberBookingController.php
@@ -48,9 +48,10 @@ class MemberBookingController extends Controller
$schedule = CourseSchedule::with('divingOffer.provider.providerProfile')->findOrFail($data['schedule_id']);
// Layer 1:快速失敗
- // 可見性繞過防護:課程屬未驗證教練時不可建立新預約(既有預約不受影響,見 provider-verification 規格)
+ // 可見性繞過防護:課程教練未通過審核時不可建立新預約(既有預約不受影響,見 provider-verification 規格)
$offer = $schedule->divingOffer;
if ($offer->provider_id !== null && !($offer->provider?->providerProfile?->is_verified)) {
+ // is_verified 為 accessor(= verification_status === approved)
return response()->json(['status' => false, 'message' => '此課程目前不開放預約'], 422);
}
if ($schedule->status !== ScheduleStatus::Open) {
diff --git a/app/Http/Controllers/API/ProviderVerificationController.php b/app/Http/Controllers/API/ProviderVerificationController.php
new file mode 100644
index 0000000..a77774b
--- /dev/null
+++ b/app/Http/Controllers/API/ProviderVerificationController.php
@@ -0,0 +1,113 @@
+user()->providerProfile;
+
+ return response()->json([
+ 'status' => true,
+ 'data' => [
+ 'verification_status' => $profile->verification_status->value,
+ 'rejection_reason' => $profile->rejection_reason,
+ 'certifications' => $request->user()->providerCertifications
+ ->map(fn($c) => ['id' => $c->id, 'url' => $c->url])->values(),
+ ],
+ ]);
+ }
+
+ public function uploadCertification(Request $request)
+ {
+ $request->validate([
+ 'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:10240',
+ ]);
+
+ if ($err = $this->ensureCertificationsEditable($request)) {
+ return $err;
+ }
+
+ $user = $request->user();
+
+ if ($user->providerCertifications()->count() >= self::MAX_CERTIFICATIONS) {
+ return response()->json(['status' => false, 'message' => '證照最多 3 張'], 422);
+ }
+
+ $path = $this->compressToJpeg($request->file('image'), "providers/{$user->id}/certifications");
+
+ $certification = ProviderCertification::create([
+ 'user_id' => $user->id,
+ 'image_path' => $path,
+ ]);
+
+ return response()->json([
+ 'status' => true,
+ 'message' => '證照已上傳',
+ 'data' => ['id' => $certification->id, 'url' => $certification->url],
+ ], 201);
+ }
+
+ public function deleteCertification(Request $request, int $id)
+ {
+ $certification = ProviderCertification::findOrFail($id);
+
+ if ($certification->user_id !== $request->user()->id) {
+ return response()->json(['status' => false, 'message' => '無權刪除此證照'], 403);
+ }
+
+ if ($err = $this->ensureCertificationsEditable($request)) {
+ return $err;
+ }
+
+ $certification->delete();
+
+ return response()->json(['status' => true, 'message' => '證照已刪除']);
+ }
+
+ public function submit(Request $request)
+ {
+ $user = $request->user();
+ $profile = $user->providerProfile;
+
+ if (!$profile->verification_status->canTransitionTo(VerificationStatus::Pending)) {
+ return response()->json(['status' => false, 'message' => '當前狀態無法送審'], 422);
+ }
+
+ if ($user->providerCertifications()->count() < 1) {
+ return response()->json(['status' => false, 'message' => '請先上傳至少 1 張證照'], 422);
+ }
+
+ $profile->update([
+ 'verification_status' => VerificationStatus::Pending,
+ 'rejection_reason' => null,
+ ]);
+
+ return response()->json(['status' => true, 'message' => '已送出審核,請等待平台審核結果']);
+ }
+
+ /**
+ * 證照僅於 unsubmitted / rejected 可增刪:pending / approved 是審核依據,不可變動
+ */
+ private function ensureCertificationsEditable(Request $request)
+ {
+ $status = $request->user()->providerProfile->verification_status;
+
+ if (!in_array($status, [VerificationStatus::Unsubmitted, VerificationStatus::Rejected])) {
+ return response()->json(['status' => false, 'message' => '審核期間或通過後不可變更證照'], 422);
+ }
+
+ return null;
+ }
+}
diff --git a/app/Models/DivingOffer.php b/app/Models/DivingOffer.php
index b552b52..7791ad8 100644
--- a/app/Models/DivingOffer.php
+++ b/app/Models/DivingOffer.php
@@ -55,14 +55,14 @@ class DivingOffer extends Model
}
/**
- * 公開端點可見性:未驗證教練的課程不對外曝光。
+ * 公開端點可見性:未通過審核(approved)教練的課程不對外曝光。
* provider_id 為 null 的課程(平台自有資料)不受此限制。
*/
public function scopeVisibleToPublic(Builder $query): Builder
{
return $query->where(function (Builder $visible) {
$visible->whereNull('provider_id')
- ->orWhereHas('provider.providerProfile', fn (Builder $profile) => $profile->where('is_verified', true));
+ ->orWhereHas('provider.providerProfile', fn (Builder $profile) => $profile->where('verification_status', \App\Enums\VerificationStatus::Approved->value));
});
}
diff --git a/app/Models/ProviderCertification.php b/app/Models/ProviderCertification.php
new file mode 100644
index 0000000..59fb0aa
--- /dev/null
+++ b/app/Models/ProviderCertification.php
@@ -0,0 +1,31 @@
+delete($certification->image_path);
+ });
+ }
+
+ public function getUrlAttribute(): string
+ {
+ return Storage::disk('public')->url($this->image_path);
+ }
+
+ public function user()
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/app/Models/ProviderProfile.php b/app/Models/ProviderProfile.php
index 79250dd..ce14e97 100644
--- a/app/Models/ProviderProfile.php
+++ b/app/Models/ProviderProfile.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Enums\VerificationStatus;
use Illuminate\Database\Eloquent\Model;
/**
@@ -23,7 +24,9 @@ use Illuminate\Database\Eloquent\Model;
* @OA\Property(property="certifications", type="string", example="PADI五星級潛水中心,SSI認證中心", description="業者相關認證"),
* @OA\Property(property="facilities", type="string", example="空氣填充站,沖洗區,更衣室,休息區", description="設施"),
* @OA\Property(property="business_hours", type="string", example="週一至週五 09:00-18:00,週六日 08:00-19:00", description="營業時間"),
- * @OA\Property(property="is_verified", type="boolean", example=true, description="是否通過平台驗證"),
+ * @OA\Property(property="verification_status", type="string", example="approved", description="審核狀態:unsubmitted / pending / approved / rejected"),
+ * @OA\Property(property="rejection_reason", type="string", nullable=true, example=null, description="駁回原因(rejected 時有值)"),
+ * @OA\Property(property="is_verified", type="boolean", example=true, description="是否通過平台驗證(相容欄位,= verification_status 為 approved)"),
* @OA\Property(property="rating", type="number", format="float", example=4.8, description="評分"),
* @OA\Property(property="website", type="string", example="https://www.bluedive.com", description="官方網站"),
* @OA\Property(property="social_media", type="string", example="https://www.facebook.com/bluedive", description="社群媒體連結"),
@@ -55,7 +58,8 @@ class ProviderProfile extends Model
'certifications',
'facilities',
'business_hours',
- 'is_verified',
+ 'verification_status',
+ 'rejection_reason',
'rating',
'website',
'social_media',
@@ -64,6 +68,21 @@ class ProviderProfile extends Model
'is_active'
];
+ protected $casts = [
+ 'verification_status' => VerificationStatus::class,
+ ];
+
+ /**
+ * API 相容層:既有前端與 Swagger 讀取 is_verified boolean,
+ * 欄位移除後以 accessor 維持輸出(= 審核通過)
+ */
+ protected $appends = ['is_verified'];
+
+ public function getIsVerifiedAttribute(): bool
+ {
+ return $this->verification_status === VerificationStatus::Approved;
+ }
+
/**
* 與用戶的關聯
*/
diff --git a/app/Models/User.php b/app/Models/User.php
index 971ee8e..de38cb9 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -140,6 +140,14 @@ class User extends Authenticatable
return $this->hasOne(ProviderProfile::class);
}
+ /**
+ * 教練驗證證照圖片(送審用)
+ */
+ public function providerCertifications()
+ {
+ return $this->hasMany(ProviderCertification::class);
+ }
+
/**
* 獲取用戶的會員資料
*/
diff --git a/app/Notifications/ProviderVerificationApprovedNotification.php b/app/Notifications/ProviderVerificationApprovedNotification.php
new file mode 100644
index 0000000..df5d798
--- /dev/null
+++ b/app/Notifications/ProviderVerificationApprovedNotification.php
@@ -0,0 +1,42 @@
+ 'verification_approved',
+ 'title' => '審核通過',
+ 'body' => '恭喜!你的教練資格已通過平台審核,課程現在會公開曝光並可接受預約。',
+ 'action_url' => config('app.frontend_url') . '/coach/dashboard',
+ 'related_id' => $notifiable->id,
+ 'related_type' => 'provider_verification',
+ ];
+ }
+
+ public function toMail(object $notifiable): MailMessage
+ {
+ return (new MailMessage)
+ ->subject('教練資格審核通過 — CFDivePlatform')
+ ->greeting('恭喜!')
+ ->line('你的教練資格已通過平台審核。')
+ ->line('你的課程現在會公開曝光,並可開始接受會員預約。')
+ ->action('前往教練後台', config('app.frontend_url') . '/coach/dashboard');
+ }
+}
diff --git a/app/Notifications/ProviderVerificationRejectedNotification.php b/app/Notifications/ProviderVerificationRejectedNotification.php
new file mode 100644
index 0000000..a6452fa
--- /dev/null
+++ b/app/Notifications/ProviderVerificationRejectedNotification.php
@@ -0,0 +1,45 @@
+ 'verification_rejected',
+ 'title' => '審核未通過',
+ 'body' => "你的教練資格審核未通過。原因:{$this->reason}",
+ 'action_url' => config('app.frontend_url') . '/coach/verification',
+ 'related_id' => $notifiable->id,
+ 'related_type' => 'provider_verification',
+ ];
+ }
+
+ public function toMail(object $notifiable): MailMessage
+ {
+ return (new MailMessage)
+ ->subject('教練資格審核結果 — CFDivePlatform')
+ ->greeting('審核結果通知')
+ ->line('很抱歉,你的教練資格審核未通過。')
+ ->line("原因:{$this->reason}")
+ ->line('你可以補正資料與證照後重新送審。')
+ ->action('重新送審', config('app.frontend_url') . '/coach/verification');
+ }
+}
diff --git a/database/migrations/2026_06_12_000001_upgrade_provider_profiles_verification_status.php b/database/migrations/2026_06_12_000001_upgrade_provider_profiles_verification_status.php
new file mode 100644
index 0000000..8968a6a
--- /dev/null
+++ b/database/migrations/2026_06_12_000001_upgrade_provider_profiles_verification_status.php
@@ -0,0 +1,39 @@
+string('verification_status', 20)->default('unsubmitted')->after('is_verified')
+ ->comment('教練驗證狀態:unsubmitted / pending / approved / rejected');
+ $table->text('rejection_reason')->nullable()->after('verification_status');
+ });
+
+ // 既有資料轉換:已驗證視為審核通過,未驗證回到未送審
+ DB::table('provider_profiles')->where('is_verified', true)->update(['verification_status' => 'approved']);
+ DB::table('provider_profiles')->where('is_verified', false)->update(['verification_status' => 'unsubmitted']);
+
+ Schema::table('provider_profiles', function (Blueprint $table) {
+ $table->dropColumn('is_verified');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('provider_profiles', function (Blueprint $table) {
+ $table->boolean('is_verified')->default(false);
+ });
+
+ DB::table('provider_profiles')->where('verification_status', 'approved')->update(['is_verified' => true]);
+
+ Schema::table('provider_profiles', function (Blueprint $table) {
+ $table->dropColumn(['verification_status', 'rejection_reason']);
+ });
+ }
+};
diff --git a/database/migrations/2026_06_12_000002_create_provider_certifications_table.php b/database/migrations/2026_06_12_000002_create_provider_certifications_table.php
new file mode 100644
index 0000000..667099f
--- /dev/null
+++ b/database/migrations/2026_06_12_000002_create_provider_certifications_table.php
@@ -0,0 +1,23 @@
+id();
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('教練 User id');
+ $table->string('image_path')->comment('證照圖片相對路徑(public disk)');
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('provider_certifications');
+ }
+};
diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php
index f5a97de..55f57ca 100644
--- a/database/seeders/DemoSeeder.php
+++ b/database/seeders/DemoSeeder.php
@@ -65,7 +65,7 @@ class DemoSeeder extends Seeder
'services' => '體驗潛水,OW認證課程,AOW認證課程,夜潛,水下攝影',
'certifications' => 'PADI 五星級潛水中心,PADI Course Director',
'facilities' => '空氣填充站,氧氣填充站,裝備租借,沖洗區,更衣室,停車場',
- 'business_hours' => '每日 07:30–18:00', 'is_verified' => true,
+ 'business_hours' => '每日 07:30–18:00', 'verification_status' => 'approved',
],
[
'email' => 'greendive@cfdive.com', 'name' => '陳美玲',
@@ -77,7 +77,7 @@ class DemoSeeder extends Seeder
'services' => '體驗潛水,OW認證課程,AOW認證課程,水下生態導覽,浮潛',
'certifications' => 'PADI 潛水中心,SSI 認證教練',
'facilities' => '空氣填充站,裝備租借,防寒衣洗滌區,水下攝影記錄',
- 'business_hours' => '每日 07:00–17:30', 'is_verified' => true,
+ 'business_hours' => '每日 07:00–17:30', 'verification_status' => 'approved',
],
[
'email' => 'islet@cfdive.com', 'name' => '張大偉',
@@ -89,7 +89,7 @@ class DemoSeeder extends Seeder
'services' => '浮潛,體驗潛水,OW認證課程,海龜觀察導覽',
'certifications' => 'PADI 授權潛水中心',
'facilities' => '裝備租借,沖洗區,更衣室,代訂民宿服務',
- 'business_hours' => '每日 08:00–17:00', 'is_verified' => true,
+ 'business_hours' => '每日 08:00–17:00', 'verification_status' => 'approved',
],
[
'email' => 'northdive@cfdive.com', 'name' => '王建國',
@@ -101,7 +101,7 @@ class DemoSeeder extends Seeder
'services' => '體驗潛水,OW認證課程,進階課程,Tec潛水,洞穴入門',
'certifications' => 'PADI 課程總監,PADI TecRec 教練',
'facilities' => '空氣填充站,混氣填充站,裝備租借,技術潛水裝備維修',
- 'business_hours' => '週一公休,週二至週日 08:00–18:00', 'is_verified' => false,
+ 'business_hours' => '週一公休,週二至週日 08:00–18:00', 'verification_status' => 'pending',
],
];
@@ -123,7 +123,7 @@ class DemoSeeder extends Seeder
'certifications' => $data['certifications'],
'facilities' => $data['facilities'],
'business_hours' => $data['business_hours'],
- 'is_verified' => $data['is_verified'],
+ 'verification_status' => $data['verification_status'],
'rating' => 0,
]);
$providers[] = $user;
diff --git a/database/seeders/DevelopmentSeeder.php b/database/seeders/DevelopmentSeeder.php
index 56cd06c..75f5f0a 100644
--- a/database/seeders/DevelopmentSeeder.php
+++ b/database/seeders/DevelopmentSeeder.php
@@ -35,7 +35,7 @@ class DevelopmentSeeder extends Seeder
'description' => '專業 PADI 認證教練,10 年教學經驗',
'contact_phone' => '0912345678',
'contact_email' => 'coach@cfdive.com',
- 'is_verified' => true,
+ 'verification_status' => 'approved',
]);
// Member
diff --git a/frontend/src/components/CoachNavBar.vue b/frontend/src/components/CoachNavBar.vue
index 8cee3f4..2e61540 100644
--- a/frontend/src/components/CoachNavBar.vue
+++ b/frontend/src/components/CoachNavBar.vue
@@ -23,6 +23,7 @@ async function handleLogout() {
時段管理
預約管理
課程評價
+ 驗證申請
個人資料
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js
index d3080c6..fac7b8a 100644
--- a/frontend/src/router/index.js
+++ b/frontend/src/router/index.js
@@ -30,6 +30,7 @@ const routes = [
{ path: 'schedules', component: () => import('../views/coach/ScheduleManagerView.vue') },
{ path: 'bookings', component: () => import('../views/coach/BookingManagerView.vue') },
{ path: 'reviews', component: () => import('../views/coach/ReviewsView.vue') },
+ { path: 'verification', component: () => import('../views/coach/VerificationView.vue') },
],
},
diff --git a/frontend/src/views/admin/ProvidersView.vue b/frontend/src/views/admin/ProvidersView.vue
index 27d7cec..026dd87 100644
--- a/frontend/src/views/admin/ProvidersView.vue
+++ b/frontend/src/views/admin/ProvidersView.vue
@@ -2,9 +2,24 @@
import { ref, onMounted } from 'vue'
import adminApi from '../../api/adminAxios'
-const providers = ref([])
-const loading = ref(true)
-const search = ref('')
+const providers = ref([])
+const loading = ref(true)
+const search = ref('')
+const pendingOnly = ref(false)
+const certsOf = ref(null) // { name, certifications } 查看證照面板
+const rejectTarget = ref(null) // 駁回原因輸入面板的對象
+const rejectReason = ref('')
+
+const STATUS_META = {
+ unsubmitted: { label: '未送審', cls: 'bg-slate-100 text-slate-500' },
+ pending: { label: '待審核', cls: 'bg-amber-100 text-amber-700' },
+ approved: { label: '已通過', cls: 'bg-teal-100 text-teal-700' },
+ rejected: { label: '已駁回', cls: 'bg-rose-100 text-rose-600' },
+}
+
+function statusMeta(p) {
+ return STATUS_META[p.provider_profile?.verification_status] ?? STATUS_META.unsubmitted
+}
async function fetchProviders() {
loading.value = true
@@ -12,12 +27,19 @@ async function fetchProviders() {
const params = {}
if (search.value) params.q = search.value
const res = await adminApi.get('/admin/providers', { params })
- providers.value = res.data.data
+ providers.value = pendingOnly.value
+ ? res.data.data.filter(p => p.provider_profile?.verification_status === 'pending')
+ : res.data.data
} finally {
loading.value = false
}
}
+function togglePendingFilter() {
+ pendingOnly.value = !pendingOnly.value
+ fetchProviders()
+}
+
async function toggleActive(p) {
try {
const res = await adminApi.put(`/admin/providers/${p.id}/toggle-active`)
@@ -25,10 +47,32 @@ async function toggleActive(p) {
} catch (e) { alert(e.response?.data?.message || '操作失敗') }
}
-async function toggleVerified(p) {
+async function viewCertifications(p) {
try {
- const res = await adminApi.put(`/admin/providers/${p.id}/toggle-verified`)
- if (p.provider_profile) p.provider_profile.is_verified = res.data.data.is_verified
+ const res = await adminApi.get('/admin/verifications', { params: { status: 'all' } })
+ const row = res.data.data.find(v => v.user_id === p.id)
+ certsOf.value = { name: p.name, certifications: row?.certifications ?? [] }
+ } catch (e) { alert(e.response?.data?.message || '讀取失敗') }
+}
+
+async function approve(p) {
+ try {
+ await adminApi.put(`/admin/verifications/${p.id}/approve`)
+ await fetchProviders()
+ } catch (e) { alert(e.response?.data?.message || '操作失敗') }
+}
+
+function openReject(p) {
+ rejectTarget.value = p
+ rejectReason.value = ''
+}
+
+async function confirmReject() {
+ if (!rejectReason.value.trim()) { alert('請填寫駁回原因'); return }
+ try {
+ await adminApi.put(`/admin/verifications/${rejectTarget.value.id}/reject`, { reason: rejectReason.value.trim() })
+ rejectTarget.value = null
+ await fetchProviders()
} catch (e) { alert(e.response?.data?.message || '操作失敗') }
}
@@ -44,6 +88,9 @@ onMounted(fetchProviders)
class="flex-1 border border-slate-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400" />
+
載入中...
@@ -67,9 +114,8 @@ onMounted(fetchProviders)
{{ p.provider_profile?.business_name || '-' }} |
-
- {{ p.provider_profile?.is_verified ? '已驗證' : '未驗證' }}
+
+ {{ statusMeta(p).label }}
|
@@ -79,11 +125,18 @@ onMounted(fetchProviders)
|
-
- |