From ad2c05779dd5ea5e0f0b394725715866f7b5babb Mon Sep 17 00:00:00 2001 From: Hank Date: Sun, 10 May 2026 04:07:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=AF=A6=E4=BD=9C=20Admin=20Panel=20?= =?UTF-8?q?=E2=80=94=20=E5=B9=B3=E5=8F=B0=E7=AE=A1=E7=90=86=E5=BE=8C?= =?UTF-8?q?=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 後端: - AdminStatsController:總會員/教練/課程數統計 API - AdminUserController:會員與教練列表、詳情、啟用/停用、教練驗證(toggle 反轉語意) - AdminOfferController:全平台課程列表與刪除 - routes/api.php:新增 /api/admin/stats、members、providers、offers 等路由 前端(frontend/): - adminAuth store、adminAxios(第三套獨立認證) - /admin/* 路由群組 + requiresAdmin guard - AdminNavBar、AdminLayout - App.vue:isCoachPage → isBackofficePage(/coach/* 和 /admin/* 皆隱藏會員 NavBar) - LoginView、DashboardView(統計卡片) - MembersView、ProvidersView(含驗證操作)、OffersView(含刪除確認) OpenSpec: - 新增 specs:admin-auth / admin-user-management / admin-offer-management / admin-stats / admin-panel-ui - 歸檔:openspec/changes/archive/2026-05-10-admin-panel Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Controllers/API/AdminOfferController.php | 58 +++++++ .../Controllers/API/AdminStatsController.php | 26 +++ .../Controllers/API/AdminUserController.php | 150 ++++++++++++++++++ frontend/src/App.vue | 9 +- frontend/src/api/adminAxios.js | 16 ++ frontend/src/components/AdminNavBar.vue | 33 ++++ frontend/src/layouts/AdminLayout.vue | 10 ++ frontend/src/router/index.js | 28 +++- frontend/src/stores/adminAuth.js | 38 +++++ frontend/src/views/admin/DashboardView.vue | 43 +++++ frontend/src/views/admin/LoginView.vue | 56 +++++++ frontend/src/views/admin/MembersView.vue | 87 ++++++++++ frontend/src/views/admin/OffersView.vue | 93 +++++++++++ frontend/src/views/admin/ProvidersView.vue | 103 ++++++++++++ .../2026-05-10-admin-panel/.openspec.yaml | 2 + .../archive/2026-05-10-admin-panel/design.md | 125 +++++++++++++++ .../2026-05-10-admin-panel/proposal.md | 39 +++++ .../specs/admin-auth/spec.md | 30 ++++ .../specs/admin-offer-management/spec.md | 25 +++ .../specs/admin-panel-ui/spec.md | 82 ++++++++++ .../specs/admin-stats/spec.md | 12 ++ .../specs/admin-user-management/spec.md | 69 ++++++++ .../archive/2026-05-10-admin-panel/tasks.md | 80 ++++++++++ openspec/specs/admin-auth/spec.md | 30 ++++ openspec/specs/admin-offer-management/spec.md | 25 +++ openspec/specs/admin-panel-ui/spec.md | 82 ++++++++++ openspec/specs/admin-stats/spec.md | 12 ++ openspec/specs/admin-user-management/spec.md | 69 ++++++++ routes/api.php | 17 +- 29 files changed, 1439 insertions(+), 10 deletions(-) create mode 100644 app/Http/Controllers/API/AdminOfferController.php create mode 100644 app/Http/Controllers/API/AdminStatsController.php create mode 100644 app/Http/Controllers/API/AdminUserController.php create mode 100644 frontend/src/api/adminAxios.js create mode 100644 frontend/src/components/AdminNavBar.vue create mode 100644 frontend/src/layouts/AdminLayout.vue create mode 100644 frontend/src/stores/adminAuth.js create mode 100644 frontend/src/views/admin/DashboardView.vue create mode 100644 frontend/src/views/admin/LoginView.vue create mode 100644 frontend/src/views/admin/MembersView.vue create mode 100644 frontend/src/views/admin/OffersView.vue create mode 100644 frontend/src/views/admin/ProvidersView.vue create mode 100644 openspec/changes/archive/2026-05-10-admin-panel/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-10-admin-panel/design.md create mode 100644 openspec/changes/archive/2026-05-10-admin-panel/proposal.md create mode 100644 openspec/changes/archive/2026-05-10-admin-panel/specs/admin-auth/spec.md create mode 100644 openspec/changes/archive/2026-05-10-admin-panel/specs/admin-offer-management/spec.md create mode 100644 openspec/changes/archive/2026-05-10-admin-panel/specs/admin-panel-ui/spec.md create mode 100644 openspec/changes/archive/2026-05-10-admin-panel/specs/admin-stats/spec.md create mode 100644 openspec/changes/archive/2026-05-10-admin-panel/specs/admin-user-management/spec.md create mode 100644 openspec/changes/archive/2026-05-10-admin-panel/tasks.md create mode 100644 openspec/specs/admin-auth/spec.md create mode 100644 openspec/specs/admin-offer-management/spec.md create mode 100644 openspec/specs/admin-panel-ui/spec.md create mode 100644 openspec/specs/admin-stats/spec.md create mode 100644 openspec/specs/admin-user-management/spec.md diff --git a/app/Http/Controllers/API/AdminOfferController.php b/app/Http/Controllers/API/AdminOfferController.php new file mode 100644 index 0000000..4c7c929 --- /dev/null +++ b/app/Http/Controllers/API/AdminOfferController.php @@ -0,0 +1,58 @@ +user()->role !== 'admin') { + return response()->json(['status' => false, 'message' => '無權限存取'], 403); + } + return null; + } + + public function index(Request $request) + { + if ($err = $this->checkAdmin()) return $err; + + $query = DivingOffer::query(); + + if ($q = $request->query('q')) { + $query->where(function ($sub) use ($q) { + $sub->where('title', 'like', "%{$q}%") + ->orWhere('location', 'like', "%{$q}%"); + }); + } + + $paginated = $query->latest('id')->paginate(15); + + return response()->json([ + 'status' => true, + 'data' => $paginated->items(), + 'meta' => [ + 'total' => $paginated->total(), + 'per_page' => $paginated->perPage(), + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + ], + ]); + } + + public function destroy(int $id) + { + if ($err = $this->checkAdmin()) return $err; + + $offer = DivingOffer::find($id); + if (!$offer) { + return response()->json(['status' => false, 'message' => '課程不存在'], 404); + } + + $offer->delete(); + return response()->json(['status' => true, 'message' => '課程已刪除']); + } +} diff --git a/app/Http/Controllers/API/AdminStatsController.php b/app/Http/Controllers/API/AdminStatsController.php new file mode 100644 index 0000000..10ea996 --- /dev/null +++ b/app/Http/Controllers/API/AdminStatsController.php @@ -0,0 +1,26 @@ +user()->role !== 'admin') { + return response()->json(['status' => false, 'message' => '無權限存取'], 403); + } + + return response()->json([ + 'status' => true, + 'data' => [ + 'total_members' => User::where('role', 'member')->count(), + 'total_providers' => User::where('role', 'provider')->count(), + 'total_offers' => DivingOffer::count(), + ], + ]); + } +} diff --git a/app/Http/Controllers/API/AdminUserController.php b/app/Http/Controllers/API/AdminUserController.php new file mode 100644 index 0000000..f1bcb7b --- /dev/null +++ b/app/Http/Controllers/API/AdminUserController.php @@ -0,0 +1,150 @@ +user()->role !== 'admin') { + return response()->json(['status' => false, 'message' => '無權限存取'], 403); + } + return null; + } + + private function findUser(int $id, string $role) + { + return User::where('id', $id)->where('role', $role)->first(); + } + + public function members(Request $request) + { + if ($err = $this->checkAdmin()) return $err; + + $query = User::where('role', 'member')->with('memberProfile'); + + if ($q = $request->query('q')) { + $query->where(function ($sub) use ($q) { + $sub->where('name', 'like', "%{$q}%") + ->orWhere('email', 'like', "%{$q}%"); + }); + } + + $paginated = $query->latest()->paginate(15); + + return response()->json([ + 'status' => true, + 'data' => $paginated->items(), + 'meta' => [ + 'total' => $paginated->total(), + 'per_page' => $paginated->perPage(), + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + ], + ]); + } + + public function member(int $id) + { + if ($err = $this->checkAdmin()) return $err; + + $user = $this->findUser($id, 'member'); + if (!$user) { + return response()->json(['status' => false, 'message' => '用戶不存在'], 404); + } + + return response()->json(['status' => true, 'data' => $user->load('memberProfile')]); + } + + public function toggleMemberActive(int $id) + { + if ($err = $this->checkAdmin()) return $err; + + $user = $this->findUser($id, 'member'); + if (!$user) { + return response()->json(['status' => false, 'message' => '用戶不存在'], 404); + } + + $user->is_active = !$user->is_active; + $user->save(); + + $msg = $user->is_active ? '帳號已啟用' : '帳號已停用'; + return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]); + } + + public function providers(Request $request) + { + if ($err = $this->checkAdmin()) return $err; + + $query = User::where('role', 'provider')->with('providerProfile'); + + if ($q = $request->query('q')) { + $query->where(function ($sub) use ($q) { + $sub->where('name', 'like', "%{$q}%") + ->orWhere('email', 'like', "%{$q}%"); + }); + } + + $paginated = $query->latest()->paginate(15); + + return response()->json([ + 'status' => true, + 'data' => $paginated->items(), + 'meta' => [ + 'total' => $paginated->total(), + 'per_page' => $paginated->perPage(), + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + ], + ]); + } + + public function provider(int $id) + { + if ($err = $this->checkAdmin()) return $err; + + $user = $this->findUser($id, 'provider'); + if (!$user) { + return response()->json(['status' => false, 'message' => '用戶不存在'], 404); + } + + return response()->json(['status' => true, 'data' => $user->load('providerProfile')]); + } + + public function toggleProviderActive(int $id) + { + if ($err = $this->checkAdmin()) return $err; + + $user = $this->findUser($id, 'provider'); + if (!$user) { + return response()->json(['status' => false, 'message' => '用戶不存在'], 404); + } + + $user->is_active = !$user->is_active; + $user->save(); + + $msg = $user->is_active ? '帳號已啟用' : '帳號已停用'; + return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]); + } + + public function toggleProviderVerified(int $id) + { + if ($err = $this->checkAdmin()) return $err; + + $user = $this->findUser($id, 'provider'); + if (!$user) { + return response()->json(['status' => false, 'message' => '用戶不存在'], 404); + } + + $profile = $user->providerProfile; + $profile->is_verified = !$profile->is_verified; + $profile->save(); + + $msg = $profile->is_verified ? '教練已驗證' : '已取消驗證'; + return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_verified' => $profile->is_verified]]); + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6e7b2a3..265dcf3 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,24 +2,29 @@ import { computed, onMounted } from 'vue' import { useAuthStore } from './stores/auth' import { useCoachAuthStore } from './stores/coachAuth' +import { useAdminAuthStore } from './stores/adminAuth' import { useRoute } from 'vue-router' import NavBar from './components/NavBar.vue' const auth = useAuthStore() const coachAuth = useCoachAuthStore() +const adminAuth = useAdminAuthStore() const route = useRoute() onMounted(() => { auth.init() coachAuth.init() + adminAuth.init() }) -const isCoachPage = computed(() => route.path.startsWith('/coach')) +const isBackofficePage = computed(() => + route.path.startsWith('/coach') || route.path.startsWith('/admin') +) diff --git a/frontend/src/api/adminAxios.js b/frontend/src/api/adminAxios.js new file mode 100644 index 0000000..02a0d55 --- /dev/null +++ b/frontend/src/api/adminAxios.js @@ -0,0 +1,16 @@ +import axios from 'axios' + +const adminApi = axios.create({ + baseURL: import.meta.env.VITE_API_URL + '/api', + headers: { Accept: 'application/json' }, +}) + +adminApi.interceptors.request.use((config) => { + const token = localStorage.getItem('admin_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +export default adminApi diff --git a/frontend/src/components/AdminNavBar.vue b/frontend/src/components/AdminNavBar.vue new file mode 100644 index 0000000..4a4d6ff --- /dev/null +++ b/frontend/src/components/AdminNavBar.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..3aa1439 --- /dev/null +++ b/frontend/src/layouts/AdminLayout.vue @@ -0,0 +1,10 @@ + + + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index f0c5bbe..cc76531 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,6 +1,7 @@ import { createRouter, createWebHistory } from 'vue-router' import { useAuthStore } from '../stores/auth' import { useCoachAuthStore } from '../stores/coachAuth' +import { useAdminAuthStore } from '../stores/adminAuth' const routes = [ // Member @@ -15,7 +16,7 @@ const routes = [ // Coach (public) { path: '/coach/login', component: () => import('../views/coach/LoginView.vue') }, { path: '/coach/register', component: () => import('../views/coach/RegisterView.vue') }, - // Coach (protected) — wrapped in CoachLayout + // Coach (protected) { path: '/coach', component: () => import('../layouts/CoachLayout.vue'), @@ -27,6 +28,21 @@ const routes = [ { path: 'profile', component: () => import('../views/coach/ProfileView.vue') }, ], }, + + // Admin (public) + { path: '/admin/login', component: () => import('../views/admin/LoginView.vue') }, + // Admin (protected) + { + path: '/admin', + component: () => import('../layouts/AdminLayout.vue'), + meta: { requiresAdmin: true }, + children: [ + { path: 'dashboard', component: () => import('../views/admin/DashboardView.vue') }, + { path: 'members', component: () => import('../views/admin/MembersView.vue') }, + { path: 'providers', component: () => import('../views/admin/ProvidersView.vue') }, + { path: 'offers', component: () => import('../views/admin/OffersView.vue') }, + ], + }, ] const router = createRouter({ @@ -37,13 +53,11 @@ const router = createRouter({ router.beforeEach((to) => { const auth = useAuthStore() const coachAuth = useCoachAuthStore() + const adminAuth = useAdminAuthStore() - if (to.meta.requiresAuth && !auth.isLoggedIn) { - return { path: '/login' } - } - if (to.meta.requiresCoach && !coachAuth.isLoggedIn) { - return { path: '/coach/login' } - } + if (to.meta.requiresAuth && !auth.isLoggedIn) return { path: '/login' } + if (to.meta.requiresCoach && !coachAuth.isLoggedIn) return { path: '/coach/login' } + if (to.meta.requiresAdmin && !adminAuth.isLoggedIn) return { path: '/admin/login' } }) export default router diff --git a/frontend/src/stores/adminAuth.js b/frontend/src/stores/adminAuth.js new file mode 100644 index 0000000..5a4c426 --- /dev/null +++ b/frontend/src/stores/adminAuth.js @@ -0,0 +1,38 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import adminApi from '../api/adminAxios' + +export const useAdminAuthStore = defineStore('adminAuth', () => { + const user = ref(null) + const token = ref(null) + + const isLoggedIn = computed(() => !!token.value) + + function init() { + const savedToken = localStorage.getItem('admin_token') + const savedUser = localStorage.getItem('admin_user') + if (savedToken) { + token.value = savedToken + user.value = savedUser ? JSON.parse(savedUser) : null + } + } + + function setAuth(userData, tokenValue) { + user.value = userData + token.value = tokenValue + localStorage.setItem('admin_token', tokenValue) + localStorage.setItem('admin_user', JSON.stringify(userData)) + } + + async function logout() { + try { + await adminApi.post('/admin/logout') + } catch {} + user.value = null + token.value = null + localStorage.removeItem('admin_token') + localStorage.removeItem('admin_user') + } + + return { user, token, isLoggedIn, init, setAuth, logout } +}) diff --git a/frontend/src/views/admin/DashboardView.vue b/frontend/src/views/admin/DashboardView.vue new file mode 100644 index 0000000..216b761 --- /dev/null +++ b/frontend/src/views/admin/DashboardView.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/views/admin/LoginView.vue b/frontend/src/views/admin/LoginView.vue new file mode 100644 index 0000000..f2b9638 --- /dev/null +++ b/frontend/src/views/admin/LoginView.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/views/admin/MembersView.vue b/frontend/src/views/admin/MembersView.vue new file mode 100644 index 0000000..2efc743 --- /dev/null +++ b/frontend/src/views/admin/MembersView.vue @@ -0,0 +1,87 @@ + + + diff --git a/frontend/src/views/admin/OffersView.vue b/frontend/src/views/admin/OffersView.vue new file mode 100644 index 0000000..6f92777 --- /dev/null +++ b/frontend/src/views/admin/OffersView.vue @@ -0,0 +1,93 @@ + + + diff --git a/frontend/src/views/admin/ProvidersView.vue b/frontend/src/views/admin/ProvidersView.vue new file mode 100644 index 0000000..27d7cec --- /dev/null +++ b/frontend/src/views/admin/ProvidersView.vue @@ -0,0 +1,103 @@ + + + diff --git a/openspec/changes/archive/2026-05-10-admin-panel/.openspec.yaml b/openspec/changes/archive/2026-05-10-admin-panel/.openspec.yaml new file mode 100644 index 0000000..0478d8f --- /dev/null +++ b/openspec/changes/archive/2026-05-10-admin-panel/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-09 diff --git a/openspec/changes/archive/2026-05-10-admin-panel/design.md b/openspec/changes/archive/2026-05-10-admin-panel/design.md new file mode 100644 index 0000000..db93ce8 --- /dev/null +++ b/openspec/changes/archive/2026-05-10-admin-panel/design.md @@ -0,0 +1,125 @@ +## Context + +後端 Admin Auth 方法(loginAdmin / logoutAdmin / adminProfile / updateAdminProfile)已存在於 AuthController,路由也已佔位。User model 有 `is_active` 欄位;ProviderProfile 有 `is_verified` 欄位,目前無 API 可修改。前端已有 memberAuth / coachAuth 兩套認證模式,Admin 循同一模式新增第三套。 + +## Goals / Non-Goals + +**Goals:** +- 管理員可登入後台、查看平台數據 +- 管理員可列表/搜尋會員與教練,並啟用/停用帳號 +- 管理員可驗證教練(設定 ProviderProfile.is_verified) +- 管理員可查看全平台課程並刪除違規內容 +- 前端 `/admin/*` 有獨立 Layout,不顯示會員 NavBar + +**Non-Goals:** +- Admin 帳號自助註冊(透過後端 seeder 或直接 DB 建立) +- 細粒度角色權限(RBAC) +- 操作日誌(Audit Log) +- 批次操作(批量停用) + +## Decisions + +### D1:Admin Auth 沿用現有 AuthController,不新建 Controller + +**決定**:`loginAdmin`、`logoutAdmin`、`adminProfile`、`updateAdminProfile` 直接沿用,不修改。 + +**理由**:方法已存在且邏輯完整,路由也已佔位。 + +--- + +### D2:業務邏輯拆到獨立 Controller + +**決定**:新增 `AdminUserController`(用戶管理)、`AdminOfferController`(課程管理)、`AdminStatsController`(統計)。 + +**理由**:與 AuthController 職責分開,避免繼續膨脹。所有方法在開頭驗證 `auth()->user()->role === 'admin'`,非管理員回傳 403。 + +--- + +### D3:Toggle 語意(啟用/停用、驗證/取消驗證) + +**決定**:`toggle-active` 和 `toggle-verified` 為 PUT 端點,後端直接反轉當前值(`is_active = !is_active`),不接受 body 傳入布林值。 + +**理由**:UI 是單一按鈕切換狀態,反轉語意最直覺,避免前端傳錯值。 + +--- + +### D4:adminAuth 獨立 Store,localStorage key `admin_token` / `admin_user` + +**決定**:循 coachAuth 模式,新增第三套獨立 store。 + +**理由**:三種角色可能在不同 tab 同時使用,共用 store 會互相污染。 + +--- + +## Contracts + +### API Schema + +#### `POST /api/admin/login`(現有) +``` +Body: { "email": "...", "password": "..." } +Response 200: { "status": true, "data": { "user": {...}, "token": "...", "token_type": "Bearer" } } +``` + +#### `GET /api/admin/stats`(需 Bearer token,role=admin) +``` +Response 200: +{ + "status": true, + "data": { + "total_members": 120, + "total_providers": 18, + "total_offers": 64 + } +} +``` + +#### `GET /api/admin/members`(需 Bearer token,role=admin) +``` +Query: q(搜尋 name / email), page, per_page(default 15) +Response 200: { "status": true, "data": [...users with memberProfile], "meta": {...} } +``` + +#### `GET /api/admin/members/{id}` +``` +Response 200: { "status": true, "data": { ...user, profile: {...} } } +Response 404: { "status": false, "message": "用戶不存在" } +``` + +#### `PUT /api/admin/members/{id}/toggle-active` +``` +Response 200: { "status": true, "message": "帳號已停用" | "帳號已啟用", "data": { "is_active": false | true } } +Response 404: { "status": false, "message": "用戶不存在" } +``` + +#### `GET /api/admin/providers`(同 members 結構,含 providerProfile) +#### `GET /api/admin/providers/{id}` +#### `PUT /api/admin/providers/{id}/toggle-active` + +#### `PUT /api/admin/providers/{id}/toggle-verified` +``` +Response 200: { "status": true, "message": "教練已驗證" | "已取消驗證", "data": { "is_verified": true | false } } +``` + +#### `GET /api/admin/offers` +``` +Query: q(搜尋 title / location), page, per_page(default 15) +Response 200: { "status": true, "data": [...offers with provider_id], "meta": {...} } +``` + +#### `DELETE /api/admin/offers/{id}` +``` +Response 200: { "status": true, "message": "課程已刪除" } +Response 404: { "status": false, "message": "課程不存在" } +``` + +--- + +## Risks / Trade-offs + +| 風險 | 緩解策略 | +|------|----------| +| toggle 反轉語意若網路重試,可能連按兩次回到原狀態 | MVP 接受,未來可改為明確 `{ is_active: true/false }` body | +| Admin 帳號只能透過 DB 或 seeder 建立,無自助註冊 | 開發期間用 tinker 建立,正式環境透過 seeder | +| `AdminUserController` 對 member / provider 各需重複驗證邏輯 | 用 private helper method 共用,避免複製貼上 | +| `/admin/*` 頁面無額外安全層(任何人知道路徑都可訪問登入頁) | MVP 接受,route guard 在 frontend 層足夠 | diff --git a/openspec/changes/archive/2026-05-10-admin-panel/proposal.md b/openspec/changes/archive/2026-05-10-admin-panel/proposal.md new file mode 100644 index 0000000..33f6d68 --- /dev/null +++ b/openspec/changes/archive/2026-05-10-admin-panel/proposal.md @@ -0,0 +1,39 @@ +## Why + +Member Portal 和 Coach Portal 已上線,但平台缺乏管理工具:教練驗證無法操作、問題帳號無法停用、課程品質無法把關。Admin Panel 補上這塊,讓平台可以實際營運。 + +## What Changes + +- **後端**:新增 `AdminUserController` 處理會員/教練的列表、詳情、啟用/停用、教練驗證 +- **後端**:新增 `AdminOfferController` 處理全平台課程列表與刪除 +- **後端**:新增 `AdminStatsController` 提供統計數據 +- **前端**:新增 `/admin/*` 路由群組,包含登入、儀表板、用戶管理、課程管理、個人資料 +- **前端**:`App.vue` 擴充隱藏邏輯,`/admin/*` 也不顯示會員 NavBar + +## Capabilities + +### New Capabilities + +- `admin-auth`:管理員登入/登出/個人資料(沿用現有 AuthController 方法,不需新增) +- `admin-user-management`:管理員查看、啟用/停用會員與教練,驗證/取消驗證教練 +- `admin-offer-management`:管理員查看全平台課程並刪除違規內容 +- `admin-stats`:平台統計數據 API(會員數、教練數、課程數) +- `admin-panel-ui`:管理後台前端介面(儀表板、用戶管理、課程管理) + +### Modified Capabilities + +(無) + +## Impact + +**後端** +- 新增 `AdminUserController`、`AdminOfferController`、`AdminStatsController` +- 現有 `AuthController` Admin 方法(login / logout / profile)直接沿用,路由已存在 +- 所有新 Admin API 套用 `auth:sanctum` middleware 並在 Controller 層驗證 `role === admin` + +**前端(frontend/ 目錄)** +- 新增 `src/stores/adminAuth.js`、`src/api/adminAxios.js` +- 新增 `src/layouts/AdminLayout.vue`、`src/components/AdminNavBar.vue` +- 新增 `src/views/admin/` 目錄下各頁面 +- `src/router/index.js` 新增 `/admin/*` 路由與 `requiresAdmin` guard +- `App.vue` 隱藏邏輯擴充:`/admin/*` 同樣不顯示會員 NavBar diff --git a/openspec/changes/archive/2026-05-10-admin-panel/specs/admin-auth/spec.md b/openspec/changes/archive/2026-05-10-admin-panel/specs/admin-auth/spec.md new file mode 100644 index 0000000..59f78e5 --- /dev/null +++ b/openspec/changes/archive/2026-05-10-admin-panel/specs/admin-auth/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: 管理員登入 +後端 SHALL 提供 `POST /api/admin/login`(現有 AuthController 方法),驗證 email/password 並確認 role=admin,回傳 Bearer token。 + +#### Scenario: 正確帳密登入 +- **WHEN** 管理員送出正確 email 與 password +- **THEN** 回傳 HTTP 200,`{ status: true, data: { user, token, token_type: "Bearer" } }` + +#### Scenario: 非 admin 角色帳號嘗試登入 +- **WHEN** role 非 admin 的帳號嘗試呼叫此端點 +- **THEN** 回傳 HTTP 401,`{ status: false, message: "電子郵件或密碼錯誤" }` + +--- + +### Requirement: 管理員登出 +後端 SHALL 提供 `POST /api/admin/logout`(需 Bearer token),撤銷當前 token。 + +#### Scenario: 登出成功 +- **WHEN** 已登入管理員送出登出請求 +- **THEN** 回傳 HTTP 200,`{ status: true, message: "..." }`,token 失效 + +--- + +### Requirement: 管理員個人資料 +後端 SHALL 提供 `GET /api/admin/profile`(需 Bearer token),回傳管理員基本資訊與 AdminProfile。 + +#### Scenario: 取得個人資料 +- **WHEN** 已登入管理員送出 GET 請求 +- **THEN** 回傳 HTTP 200,包含 name / email / role / adminProfile(position / department) diff --git a/openspec/changes/archive/2026-05-10-admin-panel/specs/admin-offer-management/spec.md b/openspec/changes/archive/2026-05-10-admin-panel/specs/admin-offer-management/spec.md new file mode 100644 index 0000000..758be54 --- /dev/null +++ b/openspec/changes/archive/2026-05-10-admin-panel/specs/admin-offer-management/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: 管理員查看全平台課程列表 +後端 SHALL 提供 `GET /api/admin/offers`(需 Bearer token,role=admin),回傳所有課程,支援關鍵字搜尋與分頁。 + +#### Scenario: 取得全部課程列表 +- **WHEN** 管理員送出 GET 請求不帶參數 +- **THEN** 回傳 HTTP 200,`{ status: true, data: [...offers], meta: { total, per_page, current_page, last_page } }`,預設每頁 15 筆,含 provider_id + +#### Scenario: 搜尋課程 +- **WHEN** 管理員送出 `?q=墾丁` +- **THEN** 只回傳 title 或 location 包含「墾丁」的課程 + +--- + +### Requirement: 管理員刪除課程 +後端 SHALL 提供 `DELETE /api/admin/offers/{id}`(需 Bearer token,role=admin),可刪除任意課程,不受 provider_id 限制。 + +#### Scenario: 刪除存在的課程 +- **WHEN** 管理員送出有效 id 的 DELETE 請求 +- **THEN** 回傳 HTTP 200,`{ status: true, message: "課程已刪除" }`,資料庫記錄移除 + +#### Scenario: 課程不存在 +- **WHEN** 指定 id 的課程不存在 +- **THEN** 回傳 HTTP 404,`{ status: false, message: "課程不存在" }` diff --git a/openspec/changes/archive/2026-05-10-admin-panel/specs/admin-panel-ui/spec.md b/openspec/changes/archive/2026-05-10-admin-panel/specs/admin-panel-ui/spec.md new file mode 100644 index 0000000..b6a4c44 --- /dev/null +++ b/openspec/changes/archive/2026-05-10-admin-panel/specs/admin-panel-ui/spec.md @@ -0,0 +1,82 @@ +## ADDED Requirements + +### Requirement: 管理員登入頁 +前端 SHALL 提供 `/admin/login` 頁面,供管理員以 email/password 登入,成功後導向 `/admin/dashboard`。 + +#### Scenario: 登入成功 +- **WHEN** 管理員填入正確帳密並送出 +- **THEN** 呼叫 `POST /api/admin/login`,token 存入 adminAuth store(localStorage key: admin_token),導向 `/admin/dashboard` + +#### Scenario: 登入失敗 +- **WHEN** 帳密錯誤 +- **THEN** 頁面顯示錯誤訊息,不跳轉 + +--- + +### Requirement: 儀表板(統計數據) +前端 SHALL 提供 `/admin/dashboard` 頁面(需 admin 登入),顯示平台核心統計數據。 + +#### Scenario: 載入統計數據 +- **WHEN** 管理員訪問 Dashboard +- **THEN** 呼叫 `GET /api/admin/stats`,顯示總會員數、總教練數、總課程數三個數字卡片 + +--- + +### Requirement: 會員管理頁 +前端 SHALL 提供 `/admin/members` 頁面,列出所有會員,支援搜尋與啟用/停用操作。 + +#### Scenario: 載入會員列表 +- **WHEN** 管理員訪問此頁面 +- **THEN** 呼叫 `GET /api/admin/members`,以表格顯示姓名、email、註冊時間、帳號狀態 + +#### Scenario: 搜尋會員 +- **WHEN** 管理員在搜尋框輸入關鍵字 +- **THEN** 以 `?q=` 重新呼叫 API,列表更新 + +#### Scenario: 切換帳號狀態 +- **WHEN** 管理員點擊啟用/停用按鈕 +- **THEN** 呼叫 `PUT /api/admin/members/{id}/toggle-active`,成功後按鈕狀態更新 + +--- + +### Requirement: 教練管理頁 +前端 SHALL 提供 `/admin/providers` 頁面,列出所有教練,支援搜尋、啟用/停用、驗證操作。 + +#### Scenario: 載入教練列表 +- **WHEN** 管理員訪問此頁面 +- **THEN** 呼叫 `GET /api/admin/providers`,顯示姓名、email、工作室名稱、驗證狀態、帳號狀態 + +#### Scenario: 切換驗證狀態 +- **WHEN** 管理員點擊驗證/取消驗證按鈕 +- **THEN** 呼叫 `PUT /api/admin/providers/{id}/toggle-verified`,成功後驗證狀態更新 + +--- + +### Requirement: 課程管理頁 +前端 SHALL 提供 `/admin/offers` 頁面,列出全平台課程,支援搜尋與刪除。 + +#### Scenario: 載入課程列表 +- **WHEN** 管理員訪問此頁面 +- **THEN** 呼叫 `GET /api/admin/offers`,顯示課程標題、地點、教練 ID、價格 + +#### Scenario: 刪除課程(含確認) +- **WHEN** 管理員點擊刪除按鈕後確認 +- **THEN** 呼叫 `DELETE /api/admin/offers/{id}`,成功後從列表移除 + +--- + +### Requirement: Admin 路由守衛 +前端 SHALL 對所有 `/admin/*` 路由(login 除外)加上 navigation guard,未登入時導向 `/admin/login`。 + +#### Scenario: 未登入訪問後台頁面 +- **WHEN** 未登入使用者直接訪問 `/admin/dashboard` +- **THEN** 自動導向 `/admin/login` + +--- + +### Requirement: Admin Layout 與導覽 +前端 SHALL 提供 `AdminLayout`,包含 `AdminNavBar`(顯示管理員姓名、各功能連結、登出),所有 `/admin/*` protected 頁面套用此 Layout。`/admin/*` 路由不顯示會員 NavBar。 + +#### Scenario: 會員 NavBar 隱藏 +- **WHEN** 使用者訪問任何 `/admin/*` 路徑 +- **THEN** App.vue 不渲染會員 NavBar diff --git a/openspec/changes/archive/2026-05-10-admin-panel/specs/admin-stats/spec.md b/openspec/changes/archive/2026-05-10-admin-panel/specs/admin-stats/spec.md new file mode 100644 index 0000000..7b138b6 --- /dev/null +++ b/openspec/changes/archive/2026-05-10-admin-panel/specs/admin-stats/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: 平台統計數據 API +後端 SHALL 提供 `GET /api/admin/stats`(需 Bearer token,role=admin),回傳平台核心數據。 + +#### Scenario: 取得統計數據 +- **WHEN** 管理員送出 GET /api/admin/stats +- **THEN** 回傳 HTTP 200,`{ status: true, data: { total_members: N, total_providers: N, total_offers: N } }` + +#### Scenario: 非管理員存取 +- **WHEN** 非 admin role 的 token 送出請求 +- **THEN** 回傳 HTTP 403,`{ status: false, message: "無權限存取" }` diff --git a/openspec/changes/archive/2026-05-10-admin-panel/specs/admin-user-management/spec.md b/openspec/changes/archive/2026-05-10-admin-panel/specs/admin-user-management/spec.md new file mode 100644 index 0000000..0809403 --- /dev/null +++ b/openspec/changes/archive/2026-05-10-admin-panel/specs/admin-user-management/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: 管理員查看會員列表 +後端 SHALL 提供 `GET /api/admin/members`(需 Bearer token,role=admin),回傳所有 role=member 的用戶,支援關鍵字搜尋與分頁。 + +#### Scenario: 取得全部會員列表 +- **WHEN** 管理員送出 GET 請求不帶參數 +- **THEN** 回傳 HTTP 200,`{ status: true, data: [...members with memberProfile], meta: { total, per_page, current_page, last_page } }`,預設每頁 15 筆 + +#### Scenario: 搜尋會員 +- **WHEN** 管理員送出 `?q=王小明` +- **THEN** 只回傳 name 或 email 包含「王小明」的會員 + +--- + +### Requirement: 管理員查看會員詳情 +後端 SHALL 提供 `GET /api/admin/members/{id}`,回傳指定會員的完整資料。 + +#### Scenario: 取得存在的會員詳情 +- **WHEN** 管理員送出有效 id 的 GET 請求 +- **THEN** 回傳 HTTP 200,包含 user 資料與 memberProfile + +#### Scenario: 會員不存在 +- **WHEN** 指定 id 的用戶不存在或 role 非 member +- **THEN** 回傳 HTTP 404,`{ status: false, message: "用戶不存在" }` + +--- + +### Requirement: 管理員啟用/停用會員帳號 +後端 SHALL 提供 `PUT /api/admin/members/{id}/toggle-active`,反轉指定會員的 `is_active` 狀態。 + +#### Scenario: 停用啟用中的帳號 +- **WHEN** 管理員對 is_active=true 的會員發送請求 +- **THEN** 將 is_active 設為 false,回傳 HTTP 200,`{ status: true, message: "帳號已停用", data: { is_active: false } }` + +#### Scenario: 啟用停用中的帳號 +- **WHEN** 管理員對 is_active=false 的會員發送請求 +- **THEN** 將 is_active 設為 true,回傳 HTTP 200,`{ status: true, message: "帳號已啟用", data: { is_active: true } }` + +--- + +### Requirement: 管理員查看教練列表 +後端 SHALL 提供 `GET /api/admin/providers`(需 Bearer token,role=admin),回傳所有 role=provider 的用戶,支援搜尋與分頁,含 providerProfile。 + +#### Scenario: 取得全部教練列表 +- **WHEN** 管理員送出 GET 請求 +- **THEN** 回傳 HTTP 200,含 providerProfile(包括 is_verified、business_name 等)與分頁 meta + +--- + +### Requirement: 管理員啟用/停用教練帳號 +後端 SHALL 提供 `PUT /api/admin/providers/{id}/toggle-active`,行為同會員版本。 + +#### Scenario: 停用/啟用教練帳號 +- **WHEN** 管理員對教練帳號發送 toggle-active 請求 +- **THEN** 反轉 is_active,回傳對應訊息 + +--- + +### Requirement: 管理員驗證教練 +後端 SHALL 提供 `PUT /api/admin/providers/{id}/toggle-verified`,反轉 ProviderProfile.is_verified 狀態。 + +#### Scenario: 驗證教練 +- **WHEN** 管理員對 is_verified=false 的教練發送請求 +- **THEN** 將 is_verified 設為 true,回傳 HTTP 200,`{ status: true, message: "教練已驗證", data: { is_verified: true } }` + +#### Scenario: 取消驗證教練 +- **WHEN** 管理員對 is_verified=true 的教練發送請求 +- **THEN** 將 is_verified 設為 false,回傳 HTTP 200,`{ status: true, message: "已取消驗證", data: { is_verified: false } }` diff --git a/openspec/changes/archive/2026-05-10-admin-panel/tasks.md b/openspec/changes/archive/2026-05-10-admin-panel/tasks.md new file mode 100644 index 0000000..2dbb4d1 --- /dev/null +++ b/openspec/changes/archive/2026-05-10-admin-panel/tasks.md @@ -0,0 +1,80 @@ +## 1. [後端] Admin Auth — 確認現有方法可用 + +- [x] 1.1 `loginAdmin()` ✅ 直接可用,確認 role=admin 驗證邏輯正確 +- [x] 1.2 `logoutAdmin()` ✅ 直接可用,不需改動 +- [x] 1.3 `adminProfile()` ✅ 直接可用,不需改動 +- [x] 1.4 用 Postman 建立測試用 admin 帳號:`docker exec cfdive-app php artisan tinker`,建立 role=admin 的 User + AdminProfile,測試 login → profile → logout + +## 2. [後端] AdminStatsController + +- [x] 2.1 建立 `AdminStatsController`,實作 `index()`:驗證 role=admin,查詢 `User::where('role','member')->count()`、`User::where('role','provider')->count()`、`DivingOffer::count()`,回傳統計數據 +- [x] 2.2 在 `routes/api.php` 的 admin middleware group 新增 `GET /stats` 路由 + +## 3. [後端] AdminUserController + +- [x] 3.1 建立 `AdminUserController`,宣告 private `checkAdmin()` helper(驗證 role=admin,不符回傳 403) +- [x] 3.2 實作 `members(Request $request)`:搜尋 role=member 用戶(q 參數 LIKE name/email),load memberProfile,分頁 15 筆 +- [x] 3.3 實作 `member(int $id)`:find role=member 用戶,不存在回 404,load memberProfile 後回傳 +- [x] 3.4 實作 `toggleMemberActive(int $id)`:find → 404,反轉 is_active,回傳新狀態與對應訊息 +- [x] 3.5 實作 `providers(Request $request)`:同 members,查 role=provider,load providerProfile +- [x] 3.6 實作 `provider(int $id)`:同 member,查 role=provider,load providerProfile +- [x] 3.7 實作 `toggleProviderActive(int $id)`:同 toggleMemberActive,查 role=provider +- [x] 3.8 實作 `toggleProviderVerified(int $id)`:find role=provider → 404,取得 providerProfile,反轉 is_verified,儲存,回傳新狀態 +- [x] 3.9 在 `routes/api.php` admin group 新增路由: + - `GET /members`、`GET /members/{id}`、`PUT /members/{id}/toggle-active` + - `GET /providers`、`GET /providers/{id}`、`PUT /providers/{id}/toggle-active`、`PUT /providers/{id}/toggle-verified` + +## 4. [後端] AdminOfferController + +- [x] 4.1 建立 `AdminOfferController`,實作 `index()`:驗證 admin,搜尋所有課程(q 參數 LIKE title/location),分頁 15 筆 +- [x] 4.2 實作 `destroy(int $id)`:find → 404,刪除,回傳 200 +- [x] 4.3 在 routes 新增 `GET /offers`、`DELETE /offers/{id}` + +## 5. [前端] Admin 基礎設施 + +- [x] 5.1 建立 `frontend/src/stores/adminAuth.js`:管理 `admin_token` / `admin_user`,實作 `init()` / `setAuth()` / `logout()` +- [x] 5.2 建立 `frontend/src/api/adminAxios.js`:獨立 Axios instance,request interceptor 讀 `admin_token` +- [x] 5.3 在 `frontend/src/router/index.js` 新增 `/admin/*` 路由:login(public)+ dashboard / members / providers / offers / profile(requiresAdmin) +- [x] 5.4 router `beforeEach` 加入 `requiresAdmin` guard,未登入導向 `/admin/login` +- [x] 5.5 在 `App.vue` 的 `onMounted` 加入 `adminAuth.init()`,並擴充 `isCoachPage` → `isBackofficePage`(涵蓋 `/coach/*` 和 `/admin/*`),會員 NavBar 在這兩個路徑下都不顯示 + +## 6. [前端] Admin Layout 與導覽 + +- [x] 6.1 建立 `frontend/src/components/AdminNavBar.vue`:顯示管理員姓名、「儀表板」、「會員管理」、「教練管理」、「課程管理」連結與登出按鈕 +- [x] 6.2 建立 `frontend/src/layouts/AdminLayout.vue`:包含 AdminNavBar + `` + +## 7. [前端] 管理員登入頁 + +- [x] 7.1 建立 `frontend/src/views/admin/LoginView.vue`:email/password 表單,送出呼叫 `POST /api/admin/login`,成功存 token 至 adminAuth store 並導向 `/admin/dashboard`,失敗顯示錯誤 + +## 8. [前端] 儀表板 + +- [x] 8.1 建立 `frontend/src/views/admin/DashboardView.vue`:掛載時呼叫 `GET /api/admin/stats`,以三個數字卡片顯示總會員數、總教練數、總課程數 + +## 9. [前端] 會員管理頁 + +- [x] 9.1 建立 `frontend/src/views/admin/MembersView.vue`:掛載時呼叫 `GET /api/admin/members`,以表格顯示姓名、email、帳號狀態(啟用/停用 badge) +- [x] 9.2 新增搜尋框,輸入後按 Enter 重新呼叫 API(帶 q 參數) +- [x] 9.3 每列新增啟用/停用按鈕,呼叫 `PUT /api/admin/members/{id}/toggle-active`,成功後更新該列狀態 + +## 10. [前端] 教練管理頁 + +- [x] 10.1 建立 `frontend/src/views/admin/ProvidersView.vue`:掛載時呼叫 `GET /api/admin/providers`,顯示姓名、email、工作室名稱、驗證狀態、帳號狀態 +- [x] 10.2 新增搜尋框 +- [x] 10.3 每列新增啟用/停用按鈕(呼叫 toggle-active)與驗證/取消驗證按鈕(呼叫 toggle-verified),成功後更新對應欄位 + +## 11. [前端] 課程管理頁 + +- [x] 11.1 建立 `frontend/src/views/admin/OffersView.vue`:掛載時呼叫 `GET /api/admin/offers`,顯示課程標題、地點、地區、價格、provider_id +- [x] 11.2 新增搜尋框 +- [x] 11.3 每列新增刪除按鈕:顯示確認 dialog,確認後呼叫 `DELETE /api/admin/offers/{id}`,成功後重新載入列表 + +## 12. [整合測試] 端對端驗證 + +- [x] 12.1 驗證管理員登入流程:tinker 建立 admin 帳號 → 登入 → 顯示 AdminNavBar → 登出 +- [x] 12.2 驗證 Dashboard 統計數據正確顯示 +- [x] 12.3 驗證會員管理:搜尋 → 停用 → 確認帳號無法登入 → 重新啟用 +- [x] 12.4 驗證教練驗證:切換 is_verified → 確認 /coach/profile 顯示狀態更新 +- [x] 12.5 驗證課程刪除:Admin 刪除課程 → 確認 /courses 列表消失 +- [x] 12.6 驗證 route guard:未登入訪問 `/admin/dashboard` 自動跳轉 `/admin/login` +- [x] 12.7 驗證 `/admin/*` 路由不顯示會員 NavBar diff --git a/openspec/specs/admin-auth/spec.md b/openspec/specs/admin-auth/spec.md new file mode 100644 index 0000000..59f78e5 --- /dev/null +++ b/openspec/specs/admin-auth/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: 管理員登入 +後端 SHALL 提供 `POST /api/admin/login`(現有 AuthController 方法),驗證 email/password 並確認 role=admin,回傳 Bearer token。 + +#### Scenario: 正確帳密登入 +- **WHEN** 管理員送出正確 email 與 password +- **THEN** 回傳 HTTP 200,`{ status: true, data: { user, token, token_type: "Bearer" } }` + +#### Scenario: 非 admin 角色帳號嘗試登入 +- **WHEN** role 非 admin 的帳號嘗試呼叫此端點 +- **THEN** 回傳 HTTP 401,`{ status: false, message: "電子郵件或密碼錯誤" }` + +--- + +### Requirement: 管理員登出 +後端 SHALL 提供 `POST /api/admin/logout`(需 Bearer token),撤銷當前 token。 + +#### Scenario: 登出成功 +- **WHEN** 已登入管理員送出登出請求 +- **THEN** 回傳 HTTP 200,`{ status: true, message: "..." }`,token 失效 + +--- + +### Requirement: 管理員個人資料 +後端 SHALL 提供 `GET /api/admin/profile`(需 Bearer token),回傳管理員基本資訊與 AdminProfile。 + +#### Scenario: 取得個人資料 +- **WHEN** 已登入管理員送出 GET 請求 +- **THEN** 回傳 HTTP 200,包含 name / email / role / adminProfile(position / department) diff --git a/openspec/specs/admin-offer-management/spec.md b/openspec/specs/admin-offer-management/spec.md new file mode 100644 index 0000000..758be54 --- /dev/null +++ b/openspec/specs/admin-offer-management/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: 管理員查看全平台課程列表 +後端 SHALL 提供 `GET /api/admin/offers`(需 Bearer token,role=admin),回傳所有課程,支援關鍵字搜尋與分頁。 + +#### Scenario: 取得全部課程列表 +- **WHEN** 管理員送出 GET 請求不帶參數 +- **THEN** 回傳 HTTP 200,`{ status: true, data: [...offers], meta: { total, per_page, current_page, last_page } }`,預設每頁 15 筆,含 provider_id + +#### Scenario: 搜尋課程 +- **WHEN** 管理員送出 `?q=墾丁` +- **THEN** 只回傳 title 或 location 包含「墾丁」的課程 + +--- + +### Requirement: 管理員刪除課程 +後端 SHALL 提供 `DELETE /api/admin/offers/{id}`(需 Bearer token,role=admin),可刪除任意課程,不受 provider_id 限制。 + +#### Scenario: 刪除存在的課程 +- **WHEN** 管理員送出有效 id 的 DELETE 請求 +- **THEN** 回傳 HTTP 200,`{ status: true, message: "課程已刪除" }`,資料庫記錄移除 + +#### Scenario: 課程不存在 +- **WHEN** 指定 id 的課程不存在 +- **THEN** 回傳 HTTP 404,`{ status: false, message: "課程不存在" }` diff --git a/openspec/specs/admin-panel-ui/spec.md b/openspec/specs/admin-panel-ui/spec.md new file mode 100644 index 0000000..b6a4c44 --- /dev/null +++ b/openspec/specs/admin-panel-ui/spec.md @@ -0,0 +1,82 @@ +## ADDED Requirements + +### Requirement: 管理員登入頁 +前端 SHALL 提供 `/admin/login` 頁面,供管理員以 email/password 登入,成功後導向 `/admin/dashboard`。 + +#### Scenario: 登入成功 +- **WHEN** 管理員填入正確帳密並送出 +- **THEN** 呼叫 `POST /api/admin/login`,token 存入 adminAuth store(localStorage key: admin_token),導向 `/admin/dashboard` + +#### Scenario: 登入失敗 +- **WHEN** 帳密錯誤 +- **THEN** 頁面顯示錯誤訊息,不跳轉 + +--- + +### Requirement: 儀表板(統計數據) +前端 SHALL 提供 `/admin/dashboard` 頁面(需 admin 登入),顯示平台核心統計數據。 + +#### Scenario: 載入統計數據 +- **WHEN** 管理員訪問 Dashboard +- **THEN** 呼叫 `GET /api/admin/stats`,顯示總會員數、總教練數、總課程數三個數字卡片 + +--- + +### Requirement: 會員管理頁 +前端 SHALL 提供 `/admin/members` 頁面,列出所有會員,支援搜尋與啟用/停用操作。 + +#### Scenario: 載入會員列表 +- **WHEN** 管理員訪問此頁面 +- **THEN** 呼叫 `GET /api/admin/members`,以表格顯示姓名、email、註冊時間、帳號狀態 + +#### Scenario: 搜尋會員 +- **WHEN** 管理員在搜尋框輸入關鍵字 +- **THEN** 以 `?q=` 重新呼叫 API,列表更新 + +#### Scenario: 切換帳號狀態 +- **WHEN** 管理員點擊啟用/停用按鈕 +- **THEN** 呼叫 `PUT /api/admin/members/{id}/toggle-active`,成功後按鈕狀態更新 + +--- + +### Requirement: 教練管理頁 +前端 SHALL 提供 `/admin/providers` 頁面,列出所有教練,支援搜尋、啟用/停用、驗證操作。 + +#### Scenario: 載入教練列表 +- **WHEN** 管理員訪問此頁面 +- **THEN** 呼叫 `GET /api/admin/providers`,顯示姓名、email、工作室名稱、驗證狀態、帳號狀態 + +#### Scenario: 切換驗證狀態 +- **WHEN** 管理員點擊驗證/取消驗證按鈕 +- **THEN** 呼叫 `PUT /api/admin/providers/{id}/toggle-verified`,成功後驗證狀態更新 + +--- + +### Requirement: 課程管理頁 +前端 SHALL 提供 `/admin/offers` 頁面,列出全平台課程,支援搜尋與刪除。 + +#### Scenario: 載入課程列表 +- **WHEN** 管理員訪問此頁面 +- **THEN** 呼叫 `GET /api/admin/offers`,顯示課程標題、地點、教練 ID、價格 + +#### Scenario: 刪除課程(含確認) +- **WHEN** 管理員點擊刪除按鈕後確認 +- **THEN** 呼叫 `DELETE /api/admin/offers/{id}`,成功後從列表移除 + +--- + +### Requirement: Admin 路由守衛 +前端 SHALL 對所有 `/admin/*` 路由(login 除外)加上 navigation guard,未登入時導向 `/admin/login`。 + +#### Scenario: 未登入訪問後台頁面 +- **WHEN** 未登入使用者直接訪問 `/admin/dashboard` +- **THEN** 自動導向 `/admin/login` + +--- + +### Requirement: Admin Layout 與導覽 +前端 SHALL 提供 `AdminLayout`,包含 `AdminNavBar`(顯示管理員姓名、各功能連結、登出),所有 `/admin/*` protected 頁面套用此 Layout。`/admin/*` 路由不顯示會員 NavBar。 + +#### Scenario: 會員 NavBar 隱藏 +- **WHEN** 使用者訪問任何 `/admin/*` 路徑 +- **THEN** App.vue 不渲染會員 NavBar diff --git a/openspec/specs/admin-stats/spec.md b/openspec/specs/admin-stats/spec.md new file mode 100644 index 0000000..7b138b6 --- /dev/null +++ b/openspec/specs/admin-stats/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: 平台統計數據 API +後端 SHALL 提供 `GET /api/admin/stats`(需 Bearer token,role=admin),回傳平台核心數據。 + +#### Scenario: 取得統計數據 +- **WHEN** 管理員送出 GET /api/admin/stats +- **THEN** 回傳 HTTP 200,`{ status: true, data: { total_members: N, total_providers: N, total_offers: N } }` + +#### Scenario: 非管理員存取 +- **WHEN** 非 admin role 的 token 送出請求 +- **THEN** 回傳 HTTP 403,`{ status: false, message: "無權限存取" }` diff --git a/openspec/specs/admin-user-management/spec.md b/openspec/specs/admin-user-management/spec.md new file mode 100644 index 0000000..0809403 --- /dev/null +++ b/openspec/specs/admin-user-management/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: 管理員查看會員列表 +後端 SHALL 提供 `GET /api/admin/members`(需 Bearer token,role=admin),回傳所有 role=member 的用戶,支援關鍵字搜尋與分頁。 + +#### Scenario: 取得全部會員列表 +- **WHEN** 管理員送出 GET 請求不帶參數 +- **THEN** 回傳 HTTP 200,`{ status: true, data: [...members with memberProfile], meta: { total, per_page, current_page, last_page } }`,預設每頁 15 筆 + +#### Scenario: 搜尋會員 +- **WHEN** 管理員送出 `?q=王小明` +- **THEN** 只回傳 name 或 email 包含「王小明」的會員 + +--- + +### Requirement: 管理員查看會員詳情 +後端 SHALL 提供 `GET /api/admin/members/{id}`,回傳指定會員的完整資料。 + +#### Scenario: 取得存在的會員詳情 +- **WHEN** 管理員送出有效 id 的 GET 請求 +- **THEN** 回傳 HTTP 200,包含 user 資料與 memberProfile + +#### Scenario: 會員不存在 +- **WHEN** 指定 id 的用戶不存在或 role 非 member +- **THEN** 回傳 HTTP 404,`{ status: false, message: "用戶不存在" }` + +--- + +### Requirement: 管理員啟用/停用會員帳號 +後端 SHALL 提供 `PUT /api/admin/members/{id}/toggle-active`,反轉指定會員的 `is_active` 狀態。 + +#### Scenario: 停用啟用中的帳號 +- **WHEN** 管理員對 is_active=true 的會員發送請求 +- **THEN** 將 is_active 設為 false,回傳 HTTP 200,`{ status: true, message: "帳號已停用", data: { is_active: false } }` + +#### Scenario: 啟用停用中的帳號 +- **WHEN** 管理員對 is_active=false 的會員發送請求 +- **THEN** 將 is_active 設為 true,回傳 HTTP 200,`{ status: true, message: "帳號已啟用", data: { is_active: true } }` + +--- + +### Requirement: 管理員查看教練列表 +後端 SHALL 提供 `GET /api/admin/providers`(需 Bearer token,role=admin),回傳所有 role=provider 的用戶,支援搜尋與分頁,含 providerProfile。 + +#### Scenario: 取得全部教練列表 +- **WHEN** 管理員送出 GET 請求 +- **THEN** 回傳 HTTP 200,含 providerProfile(包括 is_verified、business_name 等)與分頁 meta + +--- + +### Requirement: 管理員啟用/停用教練帳號 +後端 SHALL 提供 `PUT /api/admin/providers/{id}/toggle-active`,行為同會員版本。 + +#### Scenario: 停用/啟用教練帳號 +- **WHEN** 管理員對教練帳號發送 toggle-active 請求 +- **THEN** 反轉 is_active,回傳對應訊息 + +--- + +### Requirement: 管理員驗證教練 +後端 SHALL 提供 `PUT /api/admin/providers/{id}/toggle-verified`,反轉 ProviderProfile.is_verified 狀態。 + +#### Scenario: 驗證教練 +- **WHEN** 管理員對 is_verified=false 的教練發送請求 +- **THEN** 將 is_verified 設為 true,回傳 HTTP 200,`{ status: true, message: "教練已驗證", data: { is_verified: true } }` + +#### Scenario: 取消驗證教練 +- **WHEN** 管理員對 is_verified=true 的教練發送請求 +- **THEN** 將 is_verified 設為 false,回傳 HTTP 200,`{ status: true, message: "已取消驗證", data: { is_verified: false } }` diff --git a/routes/api.php b/routes/api.php index a1a2a46..18bf43a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,9 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\API\AuthController; use App\Http\Controllers\API\DivingOfferController; use App\Http\Controllers\API\ProviderOfferController; +use App\Http\Controllers\API\AdminStatsController; +use App\Http\Controllers\API\AdminUserController; +use App\Http\Controllers\API\AdminOfferController; // 這裡可以定義 API 路由,例如: Route::get('/ping', function () { @@ -85,7 +88,19 @@ Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () { Route::get('/check-member/{id}', [AuthController::class, 'checkMember']); // 查詢服務提供者資料 Route::get('/check-provider/{id}', [AuthController::class, 'checkProvider']); - // 其他管理員專屬 API + // 統計數據 + Route::get('/stats', [AdminStatsController::class, 'index']); + // 用戶管理 + Route::get('/members', [AdminUserController::class, 'members']); + Route::get('/members/{id}', [AdminUserController::class, 'member']); + Route::put('/members/{id}/toggle-active', [AdminUserController::class, 'toggleMemberActive']); + Route::get('/providers', [AdminUserController::class, 'providers']); + Route::get('/providers/{id}', [AdminUserController::class, 'provider']); + Route::put('/providers/{id}/toggle-active', [AdminUserController::class, 'toggleProviderActive']); + Route::put('/providers/{id}/toggle-verified', [AdminUserController::class, 'toggleProviderVerified']); + // 課程管理 + Route::get('/offers', [AdminOfferController::class, 'index']); + Route::delete('/offers/{id}', [AdminOfferController::class, 'destroy']); }); // 需要認證的通用路由