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:
@@ -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]]);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
### 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 層足夠 |
|
||||
@@ -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 / adminProfile(position / department)
|
||||
@@ -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: "課程不存在" }`
|
||||
@@ -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
|
||||
@@ -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: "無權限存取" }`
|
||||
@@ -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 } }`
|
||||
@@ -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 + `<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
|
||||
@@ -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)
|
||||
@@ -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: "課程不存在" }`
|
||||
@@ -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
|
||||
@@ -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: "無權限存取" }`
|
||||
@@ -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 } }`
|
||||
+16
-1
@@ -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']);
|
||||
});
|
||||
|
||||
// 需要認證的通用路由
|
||||
|
||||
Reference in New Issue
Block a user