feat:實作 Admin Panel — 平台管理後台

後端:
- 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 04:07:13 +08:00
parent da48a3652d
commit ad2c05779d
29 changed files with 1439 additions and 10 deletions
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
class AdminOfferController extends Controller
{
private function checkAdmin()
{
if (auth()->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' => '課程已刪除']);
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use App\Models\User;
class AdminStatsController extends Controller
{
public function index()
{
if (auth()->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(),
],
]);
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
class AdminUserController extends Controller
{
private function checkAdmin()
{
if (auth()->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]]);
}
}
+7 -2
View File
@@ -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')
)
</script>
<template>
<div class="min-h-screen bg-gray-50">
<NavBar v-if="!isCoachPage" />
<NavBar v-if="!isBackofficePage" />
<RouterView />
</div>
</template>
+16
View File
@@ -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
+33
View File
@@ -0,0 +1,33 @@
<script setup>
import { useAdminAuthStore } from '../stores/adminAuth'
import { useRouter } from 'vue-router'
const adminAuth = useAdminAuthStore()
const router = useRouter()
async function handleLogout() {
await adminAuth.logout()
router.push('/admin/login')
}
</script>
<template>
<nav class="bg-slate-800 text-white shadow-md">
<div class="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
<div class="flex items-center gap-6">
<span class="text-lg font-bold tracking-wide"> Admin Panel</span>
<RouterLink to="/admin/dashboard" class="text-sm hover:text-slate-300 transition">儀表板</RouterLink>
<RouterLink to="/admin/members" class="text-sm hover:text-slate-300 transition">會員管理</RouterLink>
<RouterLink to="/admin/providers" class="text-sm hover:text-slate-300 transition">教練管理</RouterLink>
<RouterLink to="/admin/offers" class="text-sm hover:text-slate-300 transition">課程管理</RouterLink>
</div>
<div class="flex items-center gap-4 text-sm">
<span class="text-slate-400">{{ adminAuth.user?.name }}</span>
<button @click="handleLogout"
class="bg-slate-600 hover:bg-slate-500 px-4 py-1.5 rounded-full transition">
登出
</button>
</div>
</div>
</nav>
</template>
+10
View File
@@ -0,0 +1,10 @@
<script setup>
import AdminNavBar from '../components/AdminNavBar.vue'
</script>
<template>
<div class="min-h-screen bg-slate-50">
<AdminNavBar />
<RouterView />
</div>
</template>
+21 -7
View File
@@ -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
+38
View File
@@ -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 }
})
@@ -0,0 +1,43 @@
<script setup>
import { ref, onMounted } from 'vue'
import adminApi from '../../api/adminAxios'
const stats = ref(null)
const loading = ref(true)
onMounted(async () => {
try {
const res = await adminApi.get('/admin/stats')
stats.value = res.data.data
} finally {
loading.value = false
}
})
const cards = [
{ key: 'total_members', label: '總會員數', icon: '👤', color: 'bg-blue-50 text-blue-700' },
{ key: 'total_providers', label: '總教練數', icon: '🤿', color: 'bg-teal-50 text-teal-700' },
{ key: 'total_offers', label: '總課程數', icon: '📋', color: 'bg-purple-50 text-purple-700' },
]
</script>
<template>
<main class="max-w-5xl mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-slate-800 mb-8">平台總覽</h1>
<div v-if="loading" class="text-center text-slate-400 py-20">載入中...</div>
<div v-else class="grid sm:grid-cols-3 gap-6">
<div v-for="card in cards" :key="card.key"
class="bg-white rounded-2xl shadow p-6 flex items-center gap-4">
<div :class="['text-3xl w-14 h-14 rounded-xl flex items-center justify-center', card.color]">
{{ card.icon }}
</div>
<div>
<p class="text-sm text-slate-500">{{ card.label }}</p>
<p class="text-3xl font-bold text-slate-800">{{ stats?.[card.key] ?? '-' }}</p>
</div>
</div>
</div>
</main>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import adminApi from '../../api/adminAxios'
import { useAdminAuthStore } from '../../stores/adminAuth'
const router = useRouter()
const adminAuth = useAdminAuthStore()
const email = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function submit() {
error.value = ''
loading.value = true
try {
const res = await adminApi.post('/admin/login', { email: email.value, password: password.value })
adminAuth.setAuth(res.data.data.user, res.data.data.token)
router.push('/admin/dashboard')
} catch (e) {
error.value = e.response?.data?.message || '帳號或密碼錯誤'
} finally {
loading.value = false
}
}
</script>
<template>
<main class="min-h-screen bg-slate-100 flex items-center justify-center px-4">
<div class="bg-white rounded-2xl shadow-lg w-full max-w-sm p-8">
<div class="text-center mb-8">
<p class="text-slate-400 text-sm mb-1">CFDive</p>
<h1 class="text-2xl font-bold text-slate-800">管理員登入</h1>
</div>
<div v-if="error" class="bg-red-50 text-red-600 text-sm rounded-lg px-4 py-3 mb-4">{{ error }}</div>
<form @submit.prevent="submit" class="flex flex-col gap-4">
<div>
<label class="block text-sm text-slate-600 mb-1">Email</label>
<input v-model="email" type="email" required
class="w-full border border-slate-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-slate-400" />
</div>
<div>
<label class="block text-sm text-slate-600 mb-1">密碼</label>
<input v-model="password" type="password" required
class="w-full border border-slate-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-slate-400" />
</div>
<button type="submit" :disabled="loading"
class="bg-slate-800 hover:bg-slate-700 text-white font-semibold py-2.5 rounded-lg transition disabled:opacity-60">
{{ loading ? '登入中...' : '登入' }}
</button>
</form>
</div>
</main>
</template>
+87
View File
@@ -0,0 +1,87 @@
<script setup>
import { ref, onMounted } from 'vue'
import adminApi from '../../api/adminAxios'
const members = ref([])
const loading = ref(true)
const search = ref('')
async function fetchMembers() {
loading.value = true
try {
const params = {}
if (search.value) params.q = search.value
const res = await adminApi.get('/admin/members', { params })
members.value = res.data.data
} finally {
loading.value = false
}
}
async function toggleActive(member) {
try {
const res = await adminApi.put(`/admin/members/${member.id}/toggle-active`)
member.is_active = res.data.data.is_active
} catch (e) {
alert(e.response?.data?.message || '操作失敗')
}
}
onMounted(fetchMembers)
</script>
<template>
<main class="max-w-6xl mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-slate-800 mb-6">會員管理</h1>
<div class="flex gap-3 mb-6">
<input v-model="search" @keyup.enter="fetchMembers" type="text" placeholder="搜尋姓名或 Email..."
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" />
<button @click="fetchMembers"
class="bg-slate-800 text-white px-5 py-2 rounded-lg text-sm hover:bg-slate-700 transition">搜尋</button>
</div>
<div v-if="loading" class="text-center text-slate-400 py-20">載入中...</div>
<div v-else class="bg-white rounded-2xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
<tr>
<th class="px-6 py-3 text-left">姓名</th>
<th class="px-6 py-3 text-left">Email</th>
<th class="px-6 py-3 text-left">註冊時間</th>
<th class="px-6 py-3 text-center">狀態</th>
<th class="px-6 py-3 text-center">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="m in members" :key="m.id" class="hover:bg-slate-50">
<td class="px-6 py-4 font-medium text-slate-800">{{ m.name }}</td>
<td class="px-6 py-4 text-slate-500">{{ m.email }}</td>
<td class="px-6 py-4 text-slate-400 text-xs">{{ m.created_at?.slice(0,10) }}</td>
<td class="px-6 py-4 text-center">
<span :class="m.is_active
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-600'"
class="text-xs px-2 py-1 rounded-full font-medium">
{{ m.is_active ? '啟用' : '停用' }}
</span>
</td>
<td class="px-6 py-4 text-center">
<button @click="toggleActive(m)"
:class="m.is_active
? 'bg-red-50 text-red-600 hover:bg-red-100'
: 'bg-green-50 text-green-700 hover:bg-green-100'"
class="text-xs px-3 py-1 rounded-lg transition">
{{ m.is_active ? '停用' : '啟用' }}
</button>
</td>
</tr>
<tr v-if="members.length === 0">
<td colspan="5" class="px-6 py-10 text-center text-slate-400">無符合的會員</td>
</tr>
</tbody>
</table>
</div>
</main>
</template>
+93
View File
@@ -0,0 +1,93 @@
<script setup>
import { ref, onMounted } from 'vue'
import adminApi from '../../api/adminAxios'
const offers = ref([])
const loading = ref(true)
const search = ref('')
const confirmId = ref(null)
async function fetchOffers() {
loading.value = true
try {
const params = {}
if (search.value) params.q = search.value
const res = await adminApi.get('/admin/offers', { params })
offers.value = res.data.data
} finally {
loading.value = false
}
}
async function deleteOffer(id) {
try {
await adminApi.delete(`/admin/offers/${id}`)
confirmId.value = null
await fetchOffers()
} catch (e) { alert(e.response?.data?.message || '刪除失敗') }
}
onMounted(fetchOffers)
</script>
<template>
<main class="max-w-6xl mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-slate-800 mb-6">課程管理</h1>
<div class="flex gap-3 mb-6">
<input v-model="search" @keyup.enter="fetchOffers" type="text" placeholder="搜尋課程名稱或地點..."
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" />
<button @click="fetchOffers"
class="bg-slate-800 text-white px-5 py-2 rounded-lg text-sm hover:bg-slate-700 transition">搜尋</button>
</div>
<div v-if="loading" class="text-center text-slate-400 py-20">載入中...</div>
<div v-else class="bg-white rounded-2xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
<tr>
<th class="px-6 py-3 text-left">課程名稱</th>
<th class="px-6 py-3 text-left">地點</th>
<th class="px-6 py-3 text-left">地區</th>
<th class="px-6 py-3 text-right">價格</th>
<th class="px-6 py-3 text-center">教練 ID</th>
<th class="px-6 py-3 text-center">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="offer in offers" :key="offer.id" class="hover:bg-slate-50">
<td class="px-6 py-4 font-medium text-slate-800">{{ offer.title }}</td>
<td class="px-6 py-4 text-slate-500">{{ offer.location }}</td>
<td class="px-6 py-4 text-slate-500">{{ offer.region }}</td>
<td class="px-6 py-4 text-right">NT$ {{ offer.price?.toLocaleString() }}</td>
<td class="px-6 py-4 text-center text-slate-400">{{ offer.provider_id ?? '-' }}</td>
<td class="px-6 py-4 text-center">
<button @click="confirmId = offer.id"
class="text-xs bg-red-50 hover:bg-red-100 text-red-600 px-3 py-1 rounded-lg transition">
刪除
</button>
</td>
</tr>
<tr v-if="offers.length === 0">
<td colspan="6" class="px-6 py-10 text-center text-slate-400">無符合的課程</td>
</tr>
</tbody>
</table>
</div>
<!-- 刪除確認 dialog -->
<div v-if="confirmId" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div class="bg-white rounded-2xl shadow-xl p-6 w-80">
<p class="font-semibold text-slate-800 mb-2">確定要刪除此課程</p>
<p class="text-sm text-slate-500 mb-6">此操作無法復原</p>
<div class="flex gap-3 justify-end">
<button @click="confirmId = null"
class="px-4 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 transition">取消</button>
<button @click="deleteOffer(confirmId)"
class="px-4 py-2 text-sm bg-red-600 hover:bg-red-500 text-white rounded-lg transition">確定刪除</button>
</div>
</div>
</div>
</main>
</template>
+103
View File
@@ -0,0 +1,103 @@
<script setup>
import { ref, onMounted } from 'vue'
import adminApi from '../../api/adminAxios'
const providers = ref([])
const loading = ref(true)
const search = ref('')
async function fetchProviders() {
loading.value = true
try {
const params = {}
if (search.value) params.q = search.value
const res = await adminApi.get('/admin/providers', { params })
providers.value = res.data.data
} finally {
loading.value = false
}
}
async function toggleActive(p) {
try {
const res = await adminApi.put(`/admin/providers/${p.id}/toggle-active`)
p.is_active = res.data.data.is_active
} catch (e) { alert(e.response?.data?.message || '操作失敗') }
}
async function toggleVerified(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
} catch (e) { alert(e.response?.data?.message || '操作失敗') }
}
onMounted(fetchProviders)
</script>
<template>
<main class="max-w-6xl mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-slate-800 mb-6">教練管理</h1>
<div class="flex gap-3 mb-6">
<input v-model="search" @keyup.enter="fetchProviders" type="text" placeholder="搜尋姓名或 Email..."
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" />
<button @click="fetchProviders"
class="bg-slate-800 text-white px-5 py-2 rounded-lg text-sm hover:bg-slate-700 transition">搜尋</button>
</div>
<div v-if="loading" class="text-center text-slate-400 py-20">載入中...</div>
<div v-else class="bg-white rounded-2xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
<tr>
<th class="px-6 py-3 text-left">姓名</th>
<th class="px-6 py-3 text-left">工作室</th>
<th class="px-6 py-3 text-center">驗證</th>
<th class="px-6 py-3 text-center">狀態</th>
<th class="px-6 py-3 text-center">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="p in providers" :key="p.id" class="hover:bg-slate-50">
<td class="px-6 py-4">
<p class="font-medium text-slate-800">{{ p.name }}</p>
<p class="text-xs text-slate-400">{{ p.email }}</p>
</td>
<td class="px-6 py-4 text-slate-500">{{ p.provider_profile?.business_name || '-' }}</td>
<td class="px-6 py-4 text-center">
<span :class="p.provider_profile?.is_verified ? 'bg-teal-100 text-teal-700' : 'bg-slate-100 text-slate-500'"
class="text-xs px-2 py-1 rounded-full font-medium">
{{ p.provider_profile?.is_verified ? '已驗證' : '未驗證' }}
</span>
</td>
<td class="px-6 py-4 text-center">
<span :class="p.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'"
class="text-xs px-2 py-1 rounded-full font-medium">
{{ p.is_active ? '啟用' : '停用' }}
</span>
</td>
<td class="px-6 py-4 text-center">
<div class="flex justify-center gap-2">
<button @click="toggleVerified(p)"
:class="p.provider_profile?.is_verified ? 'bg-slate-100 text-slate-600' : 'bg-teal-50 text-teal-700'"
class="text-xs px-3 py-1 rounded-lg hover:opacity-80 transition">
{{ p.provider_profile?.is_verified ? '取消驗證' : '驗證' }}
</button>
<button @click="toggleActive(p)"
:class="p.is_active ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-700'"
class="text-xs px-3 py-1 rounded-lg hover:opacity-80 transition">
{{ p.is_active ? '停用' : '啟用' }}
</button>
</div>
</td>
</tr>
<tr v-if="providers.length === 0">
<td colspan="5" class="px-6 py-10 text-center text-slate-400">無符合的教練</td>
</tr>
</tbody>
</table>
</div>
</main>
</template>
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-09
@@ -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
### D1Admin Auth 沿用現有 AuthController,不新建 Controller
**決定**`loginAdmin``logoutAdmin``adminProfile``updateAdminProfile` 直接沿用,不修改。
**理由**:方法已存在且邏輯完整,路由也已佔位。
---
### D2:業務邏輯拆到獨立 Controller
**決定**:新增 `AdminUserController`(用戶管理)、`AdminOfferController`(課程管理)、`AdminStatsController`(統計)。
**理由**:與 AuthController 職責分開,避免繼續膨脹。所有方法在開頭驗證 `auth()->user()->role === 'admin'`,非管理員回傳 403。
---
### D3Toggle 語意(啟用/停用、驗證/取消驗證)
**決定**`toggle-active``toggle-verified` 為 PUT 端點,後端直接反轉當前值(`is_active = !is_active`),不接受 body 傳入布林值。
**理由**:UI 是單一按鈕切換狀態,反轉語意最直覺,避免前端傳錯值。
---
### D4adminAuth 獨立 StorelocalStorage 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 tokenrole=admin
```
Response 200:
{
"status": true,
"data": {
"total_members": 120,
"total_providers": 18,
"total_offers": 64
}
}
```
#### `GET /api/admin/members`(需 Bearer tokenrole=admin
```
Query: q(搜尋 name / email, page, per_pagedefault 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_pagedefault 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 層足夠 |
@@ -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
@@ -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 / adminProfileposition / department
@@ -0,0 +1,25 @@
## ADDED Requirements
### Requirement: 管理員查看全平台課程列表
後端 SHALL 提供 `GET /api/admin/offers`(需 Bearer tokenrole=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 tokenrole=admin),可刪除任意課程,不受 provider_id 限制。
#### Scenario: 刪除存在的課程
- **WHEN** 管理員送出有效 id 的 DELETE 請求
- **THEN** 回傳 HTTP 200`{ status: true, message: "課程已刪除" }`,資料庫記錄移除
#### Scenario: 課程不存在
- **WHEN** 指定 id 的課程不存在
- **THEN** 回傳 HTTP 404`{ status: false, message: "課程不存在" }`
@@ -0,0 +1,82 @@
## ADDED Requirements
### Requirement: 管理員登入頁
前端 SHALL 提供 `/admin/login` 頁面,供管理員以 email/password 登入,成功後導向 `/admin/dashboard`
#### Scenario: 登入成功
- **WHEN** 管理員填入正確帳密並送出
- **THEN** 呼叫 `POST /api/admin/login`token 存入 adminAuth storelocalStorage 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
@@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: 平台統計數據 API
後端 SHALL 提供 `GET /api/admin/stats`(需 Bearer tokenrole=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: "無權限存取" }`
@@ -0,0 +1,69 @@
## ADDED Requirements
### Requirement: 管理員查看會員列表
後端 SHALL 提供 `GET /api/admin/members`(需 Bearer tokenrole=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 tokenrole=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 } }`
@@ -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 用戶,不存在回 404load memberProfile 後回傳
- [x] 3.4 實作 `toggleMemberActive(int $id)`find → 404,反轉 is_active,回傳新狀態與對應訊息
- [x] 3.5 實作 `providers(Request $request)`:同 members,查 role=providerload providerProfile
- [x] 3.6 實作 `provider(int $id)`:同 member,查 role=providerload 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 instancerequest interceptor 讀 `admin_token`
- [x] 5.3 在 `frontend/src/router/index.js` 新增 `/admin/*` 路由:loginpublic+ dashboard / members / providers / offers / profilerequiresAdmin
- [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 + `<RouterView>`
## 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
+30
View File
@@ -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 / adminProfileposition / department
@@ -0,0 +1,25 @@
## ADDED Requirements
### Requirement: 管理員查看全平台課程列表
後端 SHALL 提供 `GET /api/admin/offers`(需 Bearer tokenrole=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 tokenrole=admin),可刪除任意課程,不受 provider_id 限制。
#### Scenario: 刪除存在的課程
- **WHEN** 管理員送出有效 id 的 DELETE 請求
- **THEN** 回傳 HTTP 200`{ status: true, message: "課程已刪除" }`,資料庫記錄移除
#### Scenario: 課程不存在
- **WHEN** 指定 id 的課程不存在
- **THEN** 回傳 HTTP 404`{ status: false, message: "課程不存在" }`
+82
View File
@@ -0,0 +1,82 @@
## ADDED Requirements
### Requirement: 管理員登入頁
前端 SHALL 提供 `/admin/login` 頁面,供管理員以 email/password 登入,成功後導向 `/admin/dashboard`
#### Scenario: 登入成功
- **WHEN** 管理員填入正確帳密並送出
- **THEN** 呼叫 `POST /api/admin/login`token 存入 adminAuth storelocalStorage 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
+12
View File
@@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: 平台統計數據 API
後端 SHALL 提供 `GET /api/admin/stats`(需 Bearer tokenrole=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: "無權限存取" }`
@@ -0,0 +1,69 @@
## ADDED Requirements
### Requirement: 管理員查看會員列表
後端 SHALL 提供 `GET /api/admin/members`(需 Bearer tokenrole=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 tokenrole=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 } }`
+16 -1
View File
@@ -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']);
});
// 需要認證的通用路由