feat:實作 Coach Portal — 教練後台課程管理

後端:
- Migration:diving_offers 新增 provider_id(nullable FK)
- Migration:users.role ENUM 加入 provider 值
- Migration:diving_offers.spot 改為 nullable
- AuthController:registerProvider business_name 改為選填
- AuthController:updateProviderProfile 補上 certifications / dive_sites / services / facilities / website / social_media
- ProviderOfferController:教練課程 CRUD(index/show/store/update/destroy),實作 provider_id 所有權不變式(404 → 403 兩步驟)

前端(frontend/):
- coachAuth store、coachAxios(獨立於 member auth)
- /coach/* 路由群組 + beforeEach guard
- CoachNavBar、CoachLayout(教練頁隱藏會員 NavBar)
- LoginView、RegisterView、DashboardView(表格 + 刪除確認)
- OfferFormView(新增/編輯共用)、ProfileView

OpenSpec:
- openspec/config.yaml 補入專案 context 與 rules
- 新增 specs:coach-offers-api / coach-portal-ui / provider-auth
- 更新 spec:diving-offers-api 加入 provider_id
- 歸檔:openspec/changes/archive/2026-05-10-coach-portal

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 03:34:14 +08:00
parent 550e2fc97a
commit da48a3652d
31 changed files with 1890 additions and 27 deletions
+26 -2
View File
@@ -474,7 +474,7 @@ class AuthController extends Controller
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:6|confirmed',
'phone' => 'nullable|string|max:20',
'business_name' => 'required|string|max:255',
'business_name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'contact_person' => 'nullable|string|max:100',
'contact_phone' => 'nullable|string|max:20',
@@ -655,6 +655,12 @@ class AuthController extends Controller
'contact_email' => 'nullable|string|email|max:255',
'address' => 'nullable|string|max:255',
'business_hours' => 'nullable|string|max:100',
'certifications' => 'nullable|string',
'dive_sites' => 'nullable|string',
'services' => 'nullable|string',
'facilities' => 'nullable|string',
'website' => 'nullable|string|max:255',
'social_media' => 'nullable|string|max:255',
]);
if ($validator->fails()) {
@@ -701,7 +707,25 @@ class AuthController extends Controller
if ($request->has('business_hours')) {
$providerProfile->business_hours = $request->business_hours;
}
if ($request->has('certifications')) {
$providerProfile->certifications = $request->certifications;
}
if ($request->has('dive_sites')) {
$providerProfile->dive_sites = $request->dive_sites;
}
if ($request->has('services')) {
$providerProfile->services = $request->services;
}
if ($request->has('facilities')) {
$providerProfile->facilities = $request->facilities;
}
if ($request->has('website')) {
$providerProfile->website = $request->website;
}
if ($request->has('social_media')) {
$providerProfile->social_media = $request->social_media;
}
$providerProfile->save();
// 加載服務提供者資料
@@ -0,0 +1,111 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DivingOffer;
use Illuminate\Http\Request;
class ProviderOfferController extends Controller
{
public function index()
{
$offers = DivingOffer::where('provider_id', auth()->id())
->paginate(12);
return response()->json([
'status' => true,
'data' => $offers->items(),
'meta' => [
'total' => $offers->total(),
'per_page' => $offers->perPage(),
'current_page' => $offers->currentPage(),
'last_page' => $offers->lastPage(),
],
]);
}
public function show(int $id)
{
$offer = DivingOffer::find($id);
if (!$offer) {
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
}
if ($offer->provider_id !== auth()->id()) {
return response()->json(['status' => false, 'message' => '無權限查看此課程'], 403);
}
return response()->json(['status' => true, 'data' => $offer]);
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'location' => 'required|string|max:255',
'spot' => 'nullable|string|max:255',
'price' => 'required|integer|min:0',
'region' => 'required|string|max:100',
'tag' => 'nullable|string|max:100',
'badges' => 'nullable|array',
'badges.*' => 'string|max:50',
'description' => 'nullable|string',
]);
$validated['provider_id'] = auth()->id();
$validated['rating'] = 0;
$validated['reviews'] = 0;
$offer = DivingOffer::create($validated);
return response()->json(['status' => true, 'data' => $offer], 201);
}
public function update(Request $request, int $id)
{
$offer = DivingOffer::find($id);
if (!$offer) {
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
}
if ($offer->provider_id !== auth()->id()) {
return response()->json(['status' => false, 'message' => '無權限修改此課程'], 403);
}
$validated = $request->validate([
'title' => 'nullable|string|max:255',
'location' => 'nullable|string|max:255',
'spot' => 'nullable|string|max:255',
'price' => 'nullable|integer|min:0',
'region' => 'nullable|string|max:100',
'tag' => 'nullable|string|max:100',
'badges' => 'nullable|array',
'badges.*' => 'string|max:50',
'description' => 'nullable|string',
]);
$offer->fill($validated)->save();
return response()->json(['status' => true, 'data' => $offer]);
}
public function destroy(int $id)
{
$offer = DivingOffer::find($id);
if (!$offer) {
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
}
if ($offer->provider_id !== auth()->id()) {
return response()->json(['status' => false, 'message' => '無權限刪除此課程'], 403);
}
$offer->delete();
return response()->json(['status' => true, 'message' => '課程已刪除']);
}
}
+1
View File
@@ -11,6 +11,7 @@ class DivingOffer extends Model
protected $table = 'diving_offers';
protected $fillable = [
'provider_id',
'title',
'location',
'spot',
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->unsignedBigInteger('provider_id')->nullable()->after('id');
$table->foreign('provider_id')->references('id')->on('users')->onDelete('set null');
});
}
public function down(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->dropForeign(['provider_id']);
$table->dropColumn('provider_id');
});
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member', 'provider') NOT NULL DEFAULT 'member'");
}
public function down(): void
{
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member') NOT NULL DEFAULT 'member'");
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->string('spot')->nullable()->change();
});
}
public function down(): void
{
Schema::table('diving_offers', function (Blueprint $table) {
$table->string('spot')->nullable(false)->change();
});
}
};
+14 -4
View File
@@ -1,15 +1,25 @@
<script setup>
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { useAuthStore } from './stores/auth'
import { useCoachAuthStore } from './stores/coachAuth'
import { useRoute } from 'vue-router'
import NavBar from './components/NavBar.vue'
const auth = useAuthStore()
onMounted(() => auth.init())
const auth = useAuthStore()
const coachAuth = useCoachAuthStore()
const route = useRoute()
onMounted(() => {
auth.init()
coachAuth.init()
})
const isCoachPage = computed(() => route.path.startsWith('/coach'))
</script>
<template>
<div class="min-h-screen bg-gray-50">
<NavBar />
<NavBar v-if="!isCoachPage" />
<RouterView />
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
import axios from 'axios'
const coachApi = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
coachApi.interceptors.request.use((config) => {
const token = localStorage.getItem('coach_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default coachApi
+36
View File
@@ -0,0 +1,36 @@
<script setup>
import { useCoachAuthStore } from '../stores/coachAuth'
import { useRouter } from 'vue-router'
const coachAuth = useCoachAuthStore()
const router = useRouter()
async function handleLogout() {
await coachAuth.logout()
router.push('/coach/login')
}
</script>
<template>
<nav class="bg-gray-900 text-white shadow-md">
<div class="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
<div class="flex items-center gap-6">
<RouterLink to="/coach/dashboard" class="text-lg font-bold tracking-wide hover:text-gray-300 transition">
🤿 Coach Portal
</RouterLink>
<RouterLink to="/coach/dashboard" class="text-sm hover:text-gray-300 transition">我的課程</RouterLink>
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
</div>
<div class="flex items-center gap-4 text-sm">
<span class="text-gray-400">{{ coachAuth.user?.name }}</span>
<button
@click="handleLogout"
class="bg-gray-700 hover:bg-gray-600 px-4 py-1.5 rounded-full transition"
>
登出
</button>
</div>
</div>
</nav>
</template>
+10
View File
@@ -0,0 +1,10 @@
<script setup>
import CoachNavBar from '../components/CoachNavBar.vue'
</script>
<template>
<div class="min-h-screen bg-gray-50">
<CoachNavBar />
<RouterView />
</div>
</template>
+24 -1
View File
@@ -1,7 +1,9 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useCoachAuthStore } from '../stores/coachAuth'
const routes = [
// Member
{ path: '/', component: () => import('../views/HomeView.vue') },
{ path: '/courses', component: () => import('../views/CoursesView.vue') },
{ path: '/courses/:id', component: () => import('../views/CourseDetailView.vue') },
@@ -9,6 +11,22 @@ const routes = [
{ path: '/register', component: () => import('../views/RegisterView.vue') },
{ path: '/auth/callback', component: () => import('../views/AuthCallbackView.vue') },
{ path: '/profile', component: () => import('../views/ProfileView.vue'), meta: { requiresAuth: true } },
// 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
{
path: '/coach',
component: () => import('../layouts/CoachLayout.vue'),
meta: { requiresCoach: true },
children: [
{ path: 'dashboard', component: () => import('../views/coach/DashboardView.vue') },
{ path: 'offers/new', component: () => import('../views/coach/OfferFormView.vue') },
{ path: 'offers/:id/edit', component: () => import('../views/coach/OfferFormView.vue') },
{ path: 'profile', component: () => import('../views/coach/ProfileView.vue') },
],
},
]
const router = createRouter({
@@ -17,10 +35,15 @@ const router = createRouter({
})
router.beforeEach((to) => {
const auth = useAuthStore()
const auth = useAuthStore()
const coachAuth = useCoachAuthStore()
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return { path: '/login' }
}
if (to.meta.requiresCoach && !coachAuth.isLoggedIn) {
return { path: '/coach/login' }
}
})
export default router
+38
View File
@@ -0,0 +1,38 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import coachApi from '../api/coachAxios'
export const useCoachAuthStore = defineStore('coachAuth', () => {
const user = ref(null)
const token = ref(null)
const isLoggedIn = computed(() => !!token.value)
function init() {
const savedToken = localStorage.getItem('coach_token')
const savedUser = localStorage.getItem('coach_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('coach_token', tokenValue)
localStorage.setItem('coach_user', JSON.stringify(userData))
}
async function logout() {
try {
await coachApi.post('/provider/logout')
} catch {}
user.value = null
token.value = null
localStorage.removeItem('coach_token')
localStorage.removeItem('coach_user')
}
return { user, token, isLoggedIn, init, setAuth, logout }
})
+113
View File
@@ -0,0 +1,113 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import coachApi from '../../api/coachAxios'
const router = useRouter()
const offers = ref([])
const loading = ref(true)
const error = ref('')
const confirmId = ref(null)
async function fetchOffers() {
loading.value = true
try {
const res = await coachApi.get('/provider/offers')
offers.value = res.data.data
} catch {
error.value = '無法載入課程列表'
} finally {
loading.value = false
}
}
async function deleteOffer(id) {
try {
await coachApi.delete(`/provider/offers/${id}`)
confirmId.value = null
await fetchOffers()
} catch (e) {
alert(e.response?.data?.message || '刪除失敗')
}
}
onMounted(fetchOffers)
</script>
<template>
<main class="max-w-5xl mx-auto px-4 py-10">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-800">我的課程</h1>
<RouterLink
to="/coach/offers/new"
class="bg-gray-900 hover:bg-gray-700 text-white text-sm font-medium px-5 py-2 rounded-lg transition"
>
+ 新增課程
</RouterLink>
</div>
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
<div v-else-if="error" class="text-center text-red-500 py-20">{{ error }}</div>
<div v-else-if="offers.length === 0" class="text-center py-20">
<p class="text-5xl mb-4">🌊</p>
<p class="text-gray-500 mb-4">尚無課程立即新增第一堂課</p>
<RouterLink to="/coach/offers/new"
class="bg-gray-900 text-white px-6 py-2 rounded-lg hover:bg-gray-700 transition text-sm">
新增課程
</RouterLink>
</div>
<div v-else class="bg-white rounded-2xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-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">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="offer in offers" :key="offer.id" class="hover:bg-gray-50">
<td class="px-6 py-4 font-medium text-gray-800">{{ offer.title }}</td>
<td class="px-6 py-4 text-gray-500">{{ offer.location }}</td>
<td class="px-6 py-4 text-gray-500">{{ offer.region }}</td>
<td class="px-6 py-4 text-right font-medium">NT$ {{ offer.price?.toLocaleString() }}</td>
<td class="px-6 py-4 text-center">
<div class="flex justify-center gap-2">
<RouterLink :to="`/coach/offers/${offer.id}/edit`"
class="text-xs bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-lg transition">
編輯
</RouterLink>
<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>
</div>
</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-gray-800 mb-2">確定要刪除這堂課程</p>
<p class="text-sm text-gray-500 mb-6">此操作無法復原</p>
<div class="flex gap-3 justify-end">
<button @click="confirmId = null"
class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-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>
+76
View File
@@ -0,0 +1,76 @@
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import coachApi from '../../api/coachAxios'
import { useCoachAuthStore } from '../../stores/coachAuth'
const router = useRouter()
const route = useRoute()
const coachAuth = useCoachAuthStore()
const email = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
const registeredMsg = route.query.registered ? '註冊成功,請登入。' : ''
async function submit() {
error.value = ''
loading.value = true
try {
const res = await coachApi.post('/provider/login', {
email: email.value,
password: password.value,
})
const { user, token } = res.data.data
coachAuth.setAuth(user, token)
router.push('/coach/dashboard')
} catch (e) {
error.value = e.response?.data?.message || '帳號或密碼錯誤'
} finally {
loading.value = false
}
}
</script>
<template>
<main class="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div class="bg-white rounded-2xl shadow-lg w-full max-w-md p-8">
<div class="text-center mb-8">
<p class="text-gray-500 text-sm mb-1">CFDive 教練後台</p>
<h1 class="text-2xl font-bold text-gray-800">教練登入</h1>
</div>
<div v-if="registeredMsg" class="bg-green-50 text-green-700 text-sm rounded-lg px-4 py-3 mb-4">
{{ registeredMsg }}
</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-gray-600 mb-1">Email</label>
<input v-model="email" type="email" required
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">密碼</label>
<input v-model="password" type="password" required
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
<button type="submit" :disabled="loading"
class="bg-gray-900 hover:bg-gray-700 text-white font-semibold py-2.5 rounded-lg transition disabled:opacity-60">
{{ loading ? '登入中...' : '登入' }}
</button>
</form>
<p class="text-center text-sm text-gray-500 mt-6">
還沒有帳號
<RouterLink to="/coach/register" class="text-gray-700 hover:underline font-medium">申請教練帳號</RouterLink>
</p>
</div>
</main>
</template>
+174
View File
@@ -0,0 +1,174 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import coachApi from '../../api/coachAxios'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const errors = ref({})
const REGIONS = ['北部', '中部', '南部', '東部', '離島']
const form = ref({
title: '',
location: '',
spot: '',
price: '',
region: '',
tag: '',
badges: '',
description: '',
})
onMounted(async () => {
if (!isEdit.value) return
loading.value = true
try {
const res = await coachApi.get(`/provider/offers/${route.params.id}`)
const o = res.data.data
form.value = {
title: o.title || '',
location: o.location || '',
spot: o.spot || '',
price: o.price ?? '',
region: o.region || '',
tag: o.tag || '',
badges: Array.isArray(o.badges) ? o.badges.join(', ') : (o.badges || ''),
description: o.description || '',
}
} catch (e) {
error.value = e.response?.data?.message || '無法載入課程資料'
} finally {
loading.value = false
}
})
async function submit() {
errors.value = {}
error.value = ''
if (!form.value.title) { errors.value.title = '課程名稱為必填'; }
if (!form.value.location) { errors.value.location = '地點為必填'; }
if (!form.value.price) { errors.value.price = '價格為必填'; }
if (!form.value.region) { errors.value.region = '地區為必填'; }
if (Object.keys(errors.value).length) return
const payload = {
...form.value,
price: Number(form.value.price),
badges: form.value.badges
? form.value.badges.split(',').map(b => b.trim()).filter(Boolean)
: [],
}
saving.value = true
try {
if (isEdit.value) {
await coachApi.put(`/provider/offers/${route.params.id}`, payload)
} else {
await coachApi.post('/provider/offers', payload)
}
router.push('/coach/dashboard')
} catch (e) {
const data = e.response?.data
error.value = data?.message || '儲存失敗'
errors.value = data?.errors || {}
} finally {
saving.value = false
}
}
</script>
<template>
<main class="max-w-2xl mx-auto px-4 py-10">
<div class="flex items-center gap-3 mb-6">
<RouterLink to="/coach/dashboard" class="text-gray-400 hover:text-gray-600 text-sm"> 返回</RouterLink>
<h1 class="text-2xl font-bold text-gray-800">{{ isEdit ? '編輯課程' : '新增課程' }}</h1>
</div>
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
<form v-else @submit.prevent="submit" class="bg-white rounded-2xl shadow p-6 space-y-5">
<div v-if="error" class="bg-red-50 text-red-600 text-sm rounded-lg px-4 py-3">{{ error }}</div>
<div>
<label class="block text-sm text-gray-600 mb-1">課程名稱 <span class="text-red-400">*</span></label>
<input v-model="form.title" type="text"
class="w-full border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
:class="errors.title ? 'border-red-400' : 'border-gray-300'" />
<p v-if="errors.title" class="text-red-500 text-xs mt-1">{{ errors.title }}</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-600 mb-1">地點 <span class="text-red-400">*</span></label>
<input v-model="form.location" type="text"
class="w-full border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
:class="errors.location ? 'border-red-400' : 'border-gray-300'" />
<p v-if="errors.location" class="text-red-500 text-xs mt-1">{{ errors.location }}</p>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">潛點</label>
<input v-model="form.spot" type="text"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-600 mb-1">地區 <span class="text-red-400">*</span></label>
<select v-model="form.region"
class="w-full border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
:class="errors.region ? 'border-red-400' : 'border-gray-300'">
<option value="">請選擇</option>
<option v-for="r in REGIONS" :key="r" :value="r">{{ r }}</option>
</select>
<p v-if="errors.region" class="text-red-500 text-xs mt-1">{{ errors.region }}</p>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">價格NT$<span class="text-red-400">*</span></label>
<input v-model="form.price" type="number" min="0"
class="w-full border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
:class="errors.price ? 'border-red-400' : 'border-gray-300'" />
<p v-if="errors.price" class="text-red-500 text-xs mt-1">{{ errors.price }}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-600 mb-1">標籤</label>
<input v-model="form.tag" type="text" placeholder="例:初學者"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">徽章逗號分隔</label>
<input v-model="form.badges" type="text" placeholder="例:PADI認證, 含裝備"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">課程說明</label>
<textarea v-model="form.description" rows="4"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none" />
</div>
<div class="flex gap-3 justify-end pt-2">
<RouterLink to="/coach/dashboard"
class="px-5 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition">
取消
</RouterLink>
<button type="submit" :disabled="saving"
class="px-5 py-2 text-sm bg-gray-900 hover:bg-gray-700 text-white rounded-lg transition disabled:opacity-60">
{{ saving ? '儲存中...' : (isEdit ? '更新課程' : '新增課程') }}
</button>
</div>
</form>
</main>
</template>
+175
View File
@@ -0,0 +1,175 @@
<script setup>
import { ref, onMounted } from 'vue'
import coachApi from '../../api/coachAxios'
const loading = ref(true)
const saving = ref(false)
const success = ref(false)
const error = ref('')
const profile = ref(null)
const form = ref({
name: '', phone: '',
business_name: '', description: '',
certifications: '', dive_sites: '', services: '', facilities: '',
contact_person: '', contact_phone: '', contact_email: '',
address: '', business_hours: '',
website: '', social_media: '',
})
onMounted(async () => {
try {
const res = await coachApi.get('/provider/profile')
const d = res.data.data
profile.value = d
const p = d.provider_profile || {}
form.value = {
name: d.name || '',
phone: d.phone || '',
business_name: p.business_name || '',
description: p.description || '',
certifications: p.certifications || '',
dive_sites: p.dive_sites || '',
services: p.services || '',
facilities: p.facilities || '',
contact_person: p.contact_person || '',
contact_phone: p.contact_phone || '',
contact_email: p.contact_email || '',
address: p.address || '',
business_hours: p.business_hours || '',
website: p.website || '',
social_media: p.social_media || '',
}
} catch {
error.value = '無法載入個人資料'
} finally {
loading.value = false
}
})
async function save() {
saving.value = true
success.value = false
error.value = ''
try {
await coachApi.put('/provider/profile', form.value)
success.value = true
setTimeout(() => (success.value = false), 3000)
} catch (e) {
error.value = e.response?.data?.message || '儲存失敗'
} finally {
saving.value = false
}
}
</script>
<template>
<main class="max-w-2xl mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-gray-800 mb-6">教練個人資料</h1>
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
<form v-else @submit.prevent="save" class="space-y-6">
<div v-if="success" class="bg-green-50 text-green-700 text-sm rounded-lg px-4 py-3"> 資料已更新</div>
<div v-if="error" class="bg-red-50 text-red-600 text-sm rounded-lg px-4 py-3">{{ error }}</div>
<!-- 唯讀資訊 -->
<div class="bg-white rounded-2xl shadow p-6 space-y-3">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">帳號資訊</h2>
<p class="text-sm text-gray-500">Email<span class="text-gray-800">{{ profile?.email }}</span></p>
<p class="text-sm text-gray-500">
驗證狀態
<span :class="profile?.provider_profile?.is_verified ? 'text-green-600' : 'text-yellow-600'">
{{ profile?.provider_profile?.is_verified ? '✅ 已驗證' : '⏳ 審核中' }}
</span>
</p>
<p class="text-sm text-gray-500">評分<span class="text-gray-800">{{ profile?.provider_profile?.rating ?? '-' }}</span></p>
</div>
<!-- 可編輯表單 -->
<div class="bg-white rounded-2xl shadow p-6 space-y-4">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">基本資料</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-600 mb-1">姓名</label>
<input v-model="form.name" type="text" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">手機</label>
<input v-model="form.phone" type="tel" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">工作室 / 教練名稱</label>
<input v-model="form.business_name" type="text" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">自我介紹</label>
<textarea v-model="form.description" rows="3" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none" />
</div>
</div>
<div class="bg-white rounded-2xl shadow p-6 space-y-4">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">專業資訊</h2>
<div>
<label class="block text-sm text-gray-600 mb-1">認證PADI / SSI </label>
<input v-model="form.certifications" type="text" placeholder="例:PADI OWSI, SSI Instructor" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">常駐潛點</label>
<input v-model="form.dive_sites" type="text" placeholder="例:墾丁,小琉球,綠島" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">服務項目</label>
<input v-model="form.services" type="text" placeholder="例:體驗潛水,初級課程,進階課程" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">設施</label>
<input v-model="form.facilities" type="text" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
</div>
<div class="bg-white rounded-2xl shadow p-6 space-y-4">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">聯絡資訊</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-600 mb-1">聯絡人</label>
<input v-model="form.contact_person" type="text" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">聯絡電話</label>
<input v-model="form.contact_phone" type="tel" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">聯絡信箱</label>
<input v-model="form.contact_email" type="email" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">地址</label>
<input v-model="form.address" type="text" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">營業時間</label>
<input v-model="form.business_hours" type="text" placeholder="例:週一至週五 09:00-18:00" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-600 mb-1">官網</label>
<input v-model="form.website" type="url" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">社群媒體</label>
<input v-model="form.social_media" type="text" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" />
</div>
</div>
</div>
<button type="submit" :disabled="saving"
class="w-full bg-gray-900 hover:bg-gray-700 text-white font-semibold py-3 rounded-lg transition disabled:opacity-60">
{{ saving ? '儲存中...' : '儲存變更' }}
</button>
</form>
</main>
</template>
+145
View File
@@ -0,0 +1,145 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import coachApi from '../../api/coachAxios'
const router = useRouter()
const form = ref({
name: '',
email: '',
password: '',
password_confirmation: '',
phone: '',
business_name: '',
description: '',
contact_phone: '',
contact_email: '',
address: '',
})
const error = ref('')
const errors = ref({})
const loading = ref(false)
async function submit() {
error.value = ''
errors.value = {}
loading.value = true
try {
await coachApi.post('/provider/register', form.value)
router.push('/coach/login?registered=1')
} catch (e) {
const data = e.response?.data
error.value = data?.message || '註冊失敗,請稍後再試'
errors.value = data?.errors || {}
} finally {
loading.value = false
}
}
</script>
<template>
<main class="min-h-screen bg-gray-50 flex items-center justify-center px-4 py-12">
<div class="bg-white rounded-2xl shadow-lg w-full max-w-lg p-8">
<div class="text-center mb-8">
<p class="text-ocean-600 text-sm font-medium mb-1">CFDive 教練後台</p>
<h1 class="text-2xl font-bold text-gray-800">申請成為教練</h1>
</div>
<div v-if="error" class="bg-red-50 text-red-600 text-sm rounded-lg px-4 py-3 mb-6">
{{ error }}
</div>
<form @submit.prevent="submit" class="space-y-5">
<fieldset class="space-y-4">
<legend class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">帳號資訊</legend>
<div>
<label class="block text-sm text-gray-600 mb-1">姓名 <span class="text-red-400">*</span></label>
<input v-model="form.name" type="text" required
class="w-full border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400"
:class="errors.name ? 'border-red-400' : 'border-gray-300'" />
<p v-if="errors.name" class="text-red-500 text-xs mt-1">{{ errors.name[0] }}</p>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">Email <span class="text-red-400">*</span></label>
<input v-model="form.email" type="email" required
class="w-full border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400"
:class="errors.email ? 'border-red-400' : 'border-gray-300'" />
<p v-if="errors.email" class="text-red-500 text-xs mt-1">{{ errors.email[0] }}</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-600 mb-1">密碼 <span class="text-red-400">*</span></label>
<input v-model="form.password" type="password" required minlength="6"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400" />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">確認密碼 <span class="text-red-400">*</span></label>
<input v-model="form.password_confirmation" type="password" required
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400" />
</div>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">手機號碼</label>
<input v-model="form.phone" type="tel"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400" />
</div>
</fieldset>
<hr class="border-gray-100" />
<fieldset class="space-y-4">
<legend class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">教練 / 業者資訊</legend>
<div>
<label class="block text-sm text-gray-600 mb-1">工作室 / 個人教練名稱</label>
<input v-model="form.business_name" type="text" placeholder="例:藍海潛水工作室(選填)"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400" />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">自我介紹</label>
<textarea v-model="form.description" rows="3" placeholder="簡短介紹你的教學風格與專長..."
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400 resize-none" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-600 mb-1">聯絡電話</label>
<input v-model="form.contact_phone" type="tel"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400" />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">聯絡信箱</label>
<input v-model="form.contact_email" type="email"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400" />
</div>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">地址</label>
<input v-model="form.address" type="text"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400" />
</div>
</fieldset>
<button type="submit" :disabled="loading"
class="w-full bg-ocean-700 hover:bg-ocean-600 text-white font-semibold py-3 rounded-lg transition disabled:opacity-60 mt-2">
{{ loading ? '送出中...' : '申請教練帳號' }}
</button>
</form>
<p class="text-center text-sm text-gray-500 mt-6">
已有帳號
<RouterLink to="/coach/login" class="text-ocean-600 hover:underline">返回登入</RouterLink>
</p>
</div>
</main>
</template>
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-09
@@ -0,0 +1,152 @@
## Context
後端 `AuthController` 已有完整的 Provider Auth 方法實作(login / logout / profile / register / update),經審查後大部分可直接沿用,僅需對 `registerProvider``updateProviderProfile` 做小幅欄位調整以對齊 Coach Portal 使用情境。`diving_offers` 表無 `provider_id`,課程與教練無法關聯。前端(`frontend/`)已有 Member Portal 的架構(Pinia、Vue Router、Axios、Tailwind),Coach Portal 將整合進同一個 SPA,以 `/coach/*` 路由群組區分。
## Goals / Non-Goals
**Goals:**
- 教練可以申請註冊帳號(含工作室/個人資訊)
- 教練可以用獨立帳號登入後台
- 教練可以 CRUD 管理自己上架的潛水課程
- 教練可以讀取與更新個人資料
- 課程與建立者(provider)綁定,不同教練只能看/改自己的課程
**Non-Goals:**
- 訂單 / 預約系統
- 課程圖片上傳(MVP 用 emoji 佔位)
- 教練與會員的配對管理
- 課程審核流程(Admin 功能)
## Decisions
### D1Coach Portal 整合進現有 SPA,不另開 repo
**決定**`/coach/*` 路由加進 `frontend/src/router/index.js`,與 Member Portal 共用同一個 Vue app。
**理由**frontend/ 已在同一個 repo,共用 Tailwind、Axios instance、router 基礎設施。分開 repo 收益不大,反而增加維護成本。
---
### D2:獨立的 coachAuth Pinia Store
**決定**:新增 `src/stores/coachAuth.js`,與現有 `auth.js`member)完全分開。localStorage key 用 `coach_token` / `coach_user` 區分。
**理由**:同一個瀏覽器可能同時開著會員頁和教練後台(不同 tab)。共用 store 會互相污染登入狀態。
---
### D3Provider Auth / Profile 沿用現有 AuthController,只做必要調整
**決定**:沿用現有 `AuthController``registerProvider``loginProvider``logoutProvider``providerProfile``updateProviderProfile` 方法,僅針對教練情境補充欄位與調整驗證規則。
**理由**:現有路由與主要邏輯已存在,本次以最小修改滿足 Coach Portal 需求,避免重複開發。
---
### D4:課程 CRUD — 新增 ProviderOfferController
**決定**:新增獨立的 `ProviderOfferController`,處理教練的課程 CRUD。`index()` 只返回當前 provider 的課程;`store()` 強制將 `provider_id` 設為 `auth()->id()``show()``update()``destroy()` 則驗證課程擁有權。
**理由**:與公開的 `DivingOfferController` 職責分開,避免授權邏輯混雜。
**Invariant — provider_id 所有權(實用優先)**
單一課程操作端點(show / update / destroy)依序執行兩步驟,不可合併:
1. `DivingOffer::find($id)` → null 時回傳 **404**
2. `offer->provider_id !== auth()->id()` → 回傳 **403**
此設計會洩漏資源存在性,為刻意取捨:`diving_offers` 使用自增整數 ID,資源存在性本可枚舉,安全遮蔽收益有限;而對教練而言,明確區分「課程不存在」與「無權限」有實際操作價值。
`store()` 補充規則:強制將 `provider_id` 設為 `auth()->id()`,忽略 request body 中任何傳入值。
---
### D5diving_offers.provider_id — Nullable Migration
**決定**`provider_id``nullable` 外鍵,現有測試資料不受影響。
**理由**:現有 6 筆手動塞入的課程 `provider_id` 為 null,保留以免資料遺失。之後可用 seeder 補上。
---
## Contracts
### API Schema
#### `POST /api/provider/register`
```
Body: { name, email, password, password_confirmation, phone?,
business_name?, description?, contact_phone?, contact_email?, address? }
Response 201: { "status": true, "message": "服務提供者註冊成功", "data": { "user": {...}, "token": "...", "token_type": "Bearer" } }
Response 422: { "status": false, "message": "驗證失敗", "errors": {...} }
```
#### `POST /api/provider/login`
```
Body: { "email": "...", "password": "..." }
Response 200: { "status": true, "data": { "user": {...}, "token": "...", "token_type": "Bearer" } }
Response 401: { "status": false, "message": "帳號或密碼錯誤" }
```
#### `GET /api/provider/offers`(需 Bearer tokenrole=provider
```
Response 200: { "status": true, "data": [...offers], "meta": { total, per_page, current_page, last_page } }
```
#### `GET /api/provider/offers/{id}`(需 Bearer tokenrole=provider
```
Response 200: { "status": true, "data": { ...offer } }
Response 403: { "status": false, "message": "無權限查看此課程" }
Response 404: { "status": false, "message": "課程不存在" }
```
#### `POST /api/provider/offers`
```
Body: { title, location, spot, price, region, tag, badges (array), description }
Response 201: { "status": true, "data": { ...offer } }
Response 422: { "status": false, "message": "...", "errors": {...} }
```
#### `PUT /api/provider/offers/{id}`
```
Body: 同 POST(部分欄位可選)
Response 200: { "status": true, "data": { ...offer } }
Response 403: { "status": false, "message": "無權限修改此課程" }
Response 404: { "status": false, "message": "課程不存在" }
```
#### `DELETE /api/provider/offers/{id}`
```
Response 200: { "status": true, "message": "課程已刪除" }
Response 403: { "status": false, "message": "無權限刪除此課程" }
Response 404: { "status": false, "message": "課程不存在" }
```
---
## Risks / Trade-offs
| 風險 | 緩解策略 |
|------|----------|
| `AuthController` 已很龐大,繼續加方法會更難維護 | MVP 接受,下一個 change 可拆分成 `ProviderAuthController` |
| 同一 SPA 混合 Member 和 Coach 路由,bundle 變大 | 所有頁面已用動態 import(`() => import(...)`),不影響首次載入 |
| `provider_id` nullable 導致公開課程列表混有無主課程 | 公開 API 不過濾 null,視為平台示範課程;Coach 的列表 API 只返回自己的 |
## Open Questions
- [x] `ProviderProfile``CoachProfile` 兩個 model 並存,目前 provider login 應該用哪個 profile?→ 決定統一使用 `ProviderProfile``CoachProfile` 暫時忽略(legacy
## 現有方法審查結果(實作前確認)
| 方法 | 狀態 | 說明 |
|------|------|------|
| `loginProvider` | ✅ 直接可用 | role 驗證、token、is_active 檢查皆完整 |
| `logoutProvider` | ✅ 直接可用 | role 檢查 + token 撤銷正確 |
| `providerProfile` (GET) | ✅ 直接可用 | 回傳 user + providerProfile |
| `registerProvider` | ⚠️ 小調整 | `business_name` 改為 nullable(單人教練不一定有業者名稱)|
| `updateProviderProfile` | ⚠️ 補欄位 | 補上 certifications / dive_sites / services / facilities / website / social_media |
**ProviderProfile 欄位使用策略:**
- 教練可自行編輯:business_name、description、certifications、dive_sites、services、facilities、contact_person、contact_phone、contact_email、address、business_hours、website、social_media
- 系統/Admin 管理(前端唯讀顯示):is_verified、rating、is_active、logo_url、banner_url
@@ -0,0 +1,38 @@
## Why
會員端課程列表目前依賴手動塞入的測試資料,平台無法規模化運作。需要 Coach Portal 讓教練能自行上架、編輯、下架課程,使平台內容自給自足。
## What Changes
- **後端**`diving_offers` 表新增 `provider_id` 欄位,綁定課程與教練
- **後端**:補完 Provider Auth APIregister / login / logout / profile CRUD
- **後端**:新增 Coach 課程管理 APICRUD,需 provider 角色驗證)
- **前端**:在現有 Vue 3 SPA 新增 `/coach/*` 路由群組,整合教練登入與課程管理介面
- **修改** `diving-offers-api`:公開課程列表 API 新增 `provider_id` 欄位於 response,供未來關聯展示使用
## Capabilities
### New Capabilities
- `provider-auth`:教練帳號的註冊、登入、登出、個人資料讀取與更新 API
- `coach-offers-api`:教練專屬課程管理 API(列出自己課程、新增、更新、刪除)
- `coach-portal-ui`:教練後台前端介面(登入、課程 Dashboard、新增/編輯表單、個人資料頁)
### Modified Capabilities
- `diving-offers-api``diving_offers` 資料表新增 `provider_id` 欄位,response 加入此欄位(向後相容,nullable)
## Impact
**後端**
- 新增 migration`diving_offers.provider_id`
- 補完 `AuthController` 中 Provider 相關方法(現有路由佔位但方法未實作)
- 新增 `ProviderOfferController`
**前端(frontend/ 目錄)**
- 新增 `src/stores/coachAuth.js`
- 新增 `src/views/coach/` 目錄下各頁面
- `src/router/index.js` 新增 `/coach/*` 路由與 guard
**資料庫**
- `diving_offers` 表結構變更(新增 nullable 欄位,不影響現有資料)
@@ -0,0 +1,89 @@
## ADDED Requirements
### Requirement: provider_id 所有權不變式
對單一課程操作端點(show / update / destroy),系統 MUST 依序執行:先以 id 查找課程(不存在回 404),再比對 provider_id(不符回 403)。兩步驟不可合併為單一 WHERE 查詢。`store()` MUST 強制將 `provider_id` 設為 `auth()->id()`,忽略 request body 傳入值。
#### Scenario: 課程不存在回傳 404
- **WHEN** 指定 id 的課程不存在於資料庫
- **THEN** 回傳 HTTP 404`{ status: false, message: "課程不存在" }`
#### Scenario: 課程存在但非本人回傳 403
- **WHEN** 課程存在(id 有效)但 `offer.provider_id !== auth()->id()`
- **THEN** 回傳 HTTP 403`{ status: false, message: "無權限…" }`
#### Scenario: store 強制設定 provider_id
- **WHEN** 教練送出新增課程請求,body 中包含任意 provider_id 值
- **THEN** 系統忽略該值,`offer.provider_id` 固定為 `auth()->id()`
---
### Requirement: 教練課程列表
後端 SHALL 提供 `GET /api/provider/offers`(需 Bearer tokenrole=provider),回傳當前登入教練自己建立的課程,支援分頁。
#### Scenario: 取得自己的課程列表
- **WHEN** 已登入教練送出 GET 請求
- **THEN** 回傳 HTTP 200,只包含 `provider_id = auth()->id()` 的課程,含分頁 meta
#### Scenario: 無課程時回傳空陣列
- **WHEN** 教練尚未建立任何課程
- **THEN** 回傳 HTTP 200`{ status: true, data: [], meta: { total: 0, ... } }`
---
### Requirement: 教練課程詳情
後端 SHALL 提供 `GET /api/provider/offers/{id}`(需 Bearer tokenrole=provider),回傳單一課程完整資料,只允許查看自己建立的課程。
#### Scenario: 取得自己的課程詳情
- **WHEN** 已登入教練送出 `GET /api/provider/offers/1`,且該課程 `provider_id = auth()->id()`
- **THEN** 回傳 HTTP 200`{ status: true, data: { ...offer } }`
#### Scenario: 查看他人課程
- **WHEN** 課程存在但 `provider_id !== auth()->id()`
- **THEN** 回傳 HTTP 403`{ status: false, message: "無權限查看此課程" }`
#### Scenario: 課程不存在
- **WHEN** 指定 id 的課程不存在
- **THEN** 回傳 HTTP 404`{ status: false, message: "課程不存在" }`
---
### Requirement: 教練新增課程
後端 SHALL 提供 `POST /api/provider/offers`(需 Bearer token),建立新課程並自動設定 `provider_id` 為當前登入教練。
#### Scenario: 新增課程成功
- **WHEN** 教練送出包含 title / location / spot / price / region 的合法資料
- **THEN** 回傳 HTTP 201`{ status: true, data: { ...offer, provider_id: <coach_id> } }`
#### Scenario: 缺少必填欄位
- **WHEN** 教練送出缺少 title 或 price 的資料
- **THEN** 回傳 HTTP 422`{ status: false, message: "...", errors: { field: [...] } }`
---
### Requirement: 教練更新課程
後端 SHALL 提供 `PUT /api/provider/offers/{id}`(需 Bearer token),更新指定課程,只允許修改自己建立的課程。
#### Scenario: 更新自己的課程
- **WHEN** 教練送出合法更新資料且 offer.provider_id === auth()->id()
- **THEN** 回傳 HTTP 200`{ status: true, data: { ...updated_offer } }`
#### Scenario: 嘗試更新他人課程
- **WHEN** offer.provider_id !== auth()->id()
- **THEN** 回傳 HTTP 403`{ status: false, message: "無權限修改此課程" }`
#### Scenario: 課程不存在
- **WHEN** 指定 id 的課程不存在
- **THEN** 回傳 HTTP 404`{ status: false, message: "課程不存在" }`
---
### Requirement: 教練刪除課程
後端 SHALL 提供 `DELETE /api/provider/offers/{id}`(需 Bearer token),刪除指定課程,只允許刪除自己建立的課程。
#### Scenario: 刪除自己的課程
- **WHEN** offer.provider_id === auth()->id()
- **THEN** 回傳 HTTP 200`{ status: true, message: "課程已刪除" }`,資料庫記錄移除
#### Scenario: 嘗試刪除他人課程
- **WHEN** offer.provider_id !== auth()->id()
- **THEN** 回傳 HTTP 403`{ status: false, message: "無權限刪除此課程" }`
@@ -0,0 +1,102 @@
## ADDED Requirements
### Requirement: 教練註冊頁
前端 SHALL 提供 `/coach/register` 頁面,供教練填寫帳號資訊與業者資料後申請帳號,成功後導向 `/coach/login`
#### Scenario: 註冊成功
- **WHEN** 教練填入必填欄位(name / email / password / password_confirmation)並送出
- **THEN** 呼叫 `POST /api/provider/register`,成功後導向 `/coach/login?registered=1`,顯示「註冊成功,請登入」提示
#### Scenario: Email 重複
- **WHEN** 送出已存在的 email
- **THEN** 頁面顯示後端回傳的錯誤訊息,不跳轉
#### Scenario: 密碼不一致
- **WHEN** password 與 password_confirmation 不一致
- **THEN** 後端回傳 422,頁面顯示欄位錯誤提示
#### Scenario: business_name 為選填
- **WHEN** 教練不填寫工作室名稱直接送出
- **THEN** 正常完成註冊,business_name 存為 null
---
### Requirement: 教練登入頁
前端 SHALL 提供 `/coach/login` 頁面,供教練以 email/password 登入,成功後導向 `/coach/dashboard`
#### Scenario: 登入成功
- **WHEN** 教練填入正確帳密並送出
- **THEN** 呼叫 `POST /api/provider/login`token 存入 coachAuth storelocalStorage key: coach_token),導向 `/coach/dashboard`
#### Scenario: 登入失敗
- **WHEN** 帳密錯誤
- **THEN** 頁面顯示錯誤訊息,不跳轉
#### Scenario: 已登入教練訪問登入頁
- **WHEN** coachAuth.isLoggedIn 為 true 時訪問 `/coach/login`
- **THEN** 自動導向 `/coach/dashboard`
---
### Requirement: 課程 Dashboard
前端 SHALL 提供 `/coach/dashboard` 頁面(需教練登入),顯示自己的課程列表,並提供新增、編輯、刪除操作入口。
#### Scenario: 載入課程列表
- **WHEN** 已登入教練訪問 Dashboard
- **THEN** 呼叫 `GET /api/provider/offers`,以表格或卡片渲染課程(標題、地點、價格、狀態)
#### Scenario: 無課程時顯示空狀態
- **WHEN** 教練尚無課程
- **THEN** 顯示「尚無課程,立即新增第一堂課」提示與新增按鈕
#### Scenario: 刪除課程確認
- **WHEN** 教練點擊刪除按鈕
- **THEN** 顯示確認提示,確認後呼叫 `DELETE /api/provider/offers/{id}`,成功後更新列表
---
### Requirement: 新增課程表單
前端 SHALL 提供 `/coach/offers/new` 頁面,教練填寫課程資訊後送出新增。
#### Scenario: 新增課程成功
- **WHEN** 教練填入所有必填欄位並送出
- **THEN** 呼叫 `POST /api/provider/offers`,成功後導向 `/coach/dashboard` 並顯示成功提示
#### Scenario: 表單驗證失敗
- **WHEN** 必填欄位(title / location / price)為空
- **THEN** 前端顯示欄位錯誤提示,不送出 API
---
### Requirement: 編輯課程表單
前端 SHALL 提供 `/coach/offers/:id/edit` 頁面,預填現有課程資料供教練修改。
#### Scenario: 載入課程資料並編輯
- **WHEN** 教練訪問編輯頁
- **THEN** 從 Dashboard 傳入或呼叫 API 取得課程資料,預填表單,送出後呼叫 `PUT /api/provider/offers/{id}`,成功後返回 Dashboard
#### Scenario: 無權限編輯
- **WHEN** API 回傳 403
- **THEN** 頁面顯示「無權限修改此課程」並返回 Dashboard
---
### Requirement: 教練個人資料頁
前端 SHALL 提供 `/coach/profile` 頁面(需教練登入),顯示並允許更新教練基本資訊與專業資料。
#### Scenario: 讀取並更新資料
- **WHEN** 教練訪問個人資料頁
- **THEN** 呼叫 `GET /api/provider/profile`,顯示 name / email / bio / expertise / certification,儲存時呼叫 `PUT /api/provider/profile`
---
### Requirement: Coach 路由守衛
前端 SHALL 對所有 `/coach/*` 路由(login 除外)加上 navigation guard,未登入時導向 `/coach/login`
#### Scenario: 未登入訪問 Dashboard
- **WHEN** 未登入使用者直接訪問 `/coach/dashboard`
- **THEN** 自動導向 `/coach/login`
#### Scenario: 登出
- **WHEN** 教練點擊登出
- **THEN** 呼叫 `POST /api/provider/logout`,清除 coach_token / coach_user,導向 `/coach/login`
@@ -0,0 +1,24 @@
## MODIFIED Requirements
### Requirement: 課程列表 API
後端 SHALL 提供公開的 `GET /api/diving-offers` endpoint,回傳分頁的潛水課程列表,支援關鍵字搜尋與篩選,無需認證即可存取。response 中每筆課程包含 `provider_id` 欄位(可為 null)。
#### Scenario: 取得全部課程列表
- **WHEN** 客戶端發送 `GET /api/diving-offers` 且不帶任何參數
- **THEN** 回傳 HTTP 200body 包含 `{ data: [...], meta: { total, per_page, current_page } }`,預設每頁 12 筆,每筆資料含 `provider_id`
#### Scenario: 依關鍵字搜尋課程
- **WHEN** 客戶端發送 `GET /api/diving-offers?q=墾丁`
- **THEN** 回傳 `title``location` 包含「墾丁」的課程列表
#### Scenario: 依地區篩選課程
- **WHEN** 客戶端發送 `GET /api/diving-offers?region=南部`
- **THEN** 只回傳 `region` 欄位等於「南部」的課程
#### Scenario: 依標籤篩選課程
- **WHEN** 客戶端發送 `GET /api/diving-offers?tag=初學者`
- **THEN** 只回傳 `tag` 欄位包含「初學者」的課程
#### Scenario: 分頁參數
- **WHEN** 客戶端發送 `GET /api/diving-offers?page=2&per_page=6`
- **THEN** 回傳第 2 頁資料,每頁 6 筆,`meta` 包含正確的分頁資訊
@@ -0,0 +1,56 @@
## ADDED Requirements
### Requirement: 教練帳號登入
後端 SHALL 提供 `POST /api/provider/login`,驗證 email/password 並回傳 Sanctum Bearer token,僅限 role=provider 帳號。
#### Scenario: 正確帳密登入成功
- **WHEN** 教練送出正確的 email 與 password
- **THEN** 回傳 HTTP 200,包含 `{ status: true, data: { user, token, token_type: "Bearer" } }`
#### Scenario: 錯誤帳密登入失敗
- **WHEN** 教練送出錯誤的 email 或 password
- **THEN** 回傳 HTTP 401`{ status: false, message: "帳號或密碼錯誤" }`
#### Scenario: 會員帳號無法用教練登入
- **WHEN** role=member 的帳號嘗試呼叫 `/api/provider/login`
- **THEN** 回傳 HTTP 403`{ status: false, message: "此帳號非教練角色" }`
---
### Requirement: 教練帳號註冊
後端 SHALL 提供 `POST /api/provider/register`,建立 role=provider 的 User 與對應 ProviderProfile。
#### Scenario: 新帳號註冊成功
- **WHEN** 送出有效的 name / email / password / password_confirmation
- **THEN** 回傳 HTTP 201`{ status: true, data: { user } }`
#### Scenario: Email 重複
- **WHEN** 送出已存在的 email
- **THEN** 回傳 HTTP 422`{ status: false, message: "此 Email 已被使用" }`
---
### Requirement: 教練登出
後端 SHALL 提供 `POST /api/provider/logout`(需 Bearer token),撤銷當前 token。
#### Scenario: 登出成功
- **WHEN** 已登入教練送出登出請求
- **THEN** 回傳 HTTP 200`{ status: true, message: "已登出" }`token 失效
---
### Requirement: 教練個人資料讀取
後端 SHALL 提供 `GET /api/provider/profile`(需 Bearer token),回傳教練基本資訊與 ProviderProfile。
#### Scenario: 取得個人資料
- **WHEN** 已登入教練送出 GET 請求
- **THEN** 回傳 HTTP 200,包含 `{ id, name, email, role, profile: { bio, expertise, certification, avatar } }`
---
### Requirement: 教練個人資料更新
後端 SHALL 提供 `PUT /api/provider/profile`(需 Bearer token),更新教練基本資訊與 ProviderProfile。
#### Scenario: 更新成功
- **WHEN** 教練送出合法的更新資料
- **THEN** 回傳 HTTP 200`{ status: true, message: "資料已更新", data: { ...profile } }`
@@ -0,0 +1,84 @@
## 1. [後端] 資料庫 Migration
- [x] 1.1 建立 migration`diving_offers` 新增 `provider_id` 欄位(`unsignedBigInteger` nullable,外鍵關聯 `users.id`onDelete set null
- [x] 1.2 執行 `docker exec cfdive-app php artisan migrate`,確認欄位新增成功
- [x] 1.3 更新 `DivingOffer` Model`$fillable` 加入 `provider_id`
## 2. [後端] Provider Auth API 調整
- [x] 2.1 修改 `registerProvider()`:將 `business_name` 改為 nullable(單人教練不一定有業者名稱),驗證規則從 `required` 改為 `nullable|string|max:255`
- [x] 2.2 `loginProvider()` ✅ 直接可用,不需改動(role 驗證、token、load profile 皆正確)
- [x] 2.3 `logoutProvider()` ✅ 直接可用,不需改動
- [x] 2.4 `providerProfile()` ✅ 直接可用,不需改動
- [x] 2.5 補完 `updateProviderProfile()`:在現有更新邏輯後補上以下欄位的更新處理:
- `certifications`PADI / SSI 等認證資訊)
- `dive_sites`(常駐潛點,逗號分隔字串)
- `services`(提供服務類型)
- `facilities`(設施說明)
- `website`(官網連結)
- `social_media`(社群媒體連結)
- 同時在 Validator 規則加入這六個欄位(皆為 `nullable|string`
- [x] 2.6 用 Postman 驗證:registerbusiness_name 選填)→ login → GET profile → PUT profile(含新增欄位)→ logout
## 3. [後端] Coach 課程管理 API
- [x] 3.1 建立 `ProviderOfferController`,實作 `index()`:只回傳 `provider_id = auth()->id()` 的課程,含分頁
- [x] 3.2 實作 `show($id)``find()` null → 404`provider_id !== auth()->id()` → 403;否則回傳課程資料
- [x] 3.3 實作 `store()`:驗證必填欄位(title / location / spot / price / region),強制將 `provider_id` 設為 `auth()->id()`(忽略 body 傳入值),回傳 201
- [x] 3.4 實作 `update($id)``find()` null → 404`provider_id !== auth()->id()` → 403;更新欄位回傳 200
- [x] 3.5 實作 `destroy($id)``find()` null → 404`provider_id !== auth()->id()` → 403;刪除回傳 200
- [x] 3.6 在 `routes/api.php``provider` middleware group 新增課程路由:GET(index) / GET(show) / POST / PUT / DELETE
- [x] 3.7 用 Postman 驗證:新增 → 列表 → 單筆詳情 → 更新 → 刪除;另測試跨教練操作:存在課程回 403、不存在 ID 回 404
## 4. [前端] coachAuth Store 與基礎設施
- [x] 4.1 建立 `frontend/src/stores/coachAuth.js`:管理 `coach_token` / `coach_user`,實作 `init()` / `setAuth()` / `logout()`
- [x] 4.2 建立 `frontend/src/api/coachAxios.js`:獨立 Axios instancerequest interceptor 讀 `coach_token`
- [x] 4.3 在 `frontend/src/router/index.js` 新增 `/coach/*` 路由群組:login / dashboard / offers/new / offers/:id/edit / profile
- [x] 4.4 `/coach/*`login 除外)加上 beforeEach guard,未登入導向 `/coach/login`
- [x] 4.5 在 `App.vue``onMounted` 加入 `coachAuth.init()`
## 5. [前端] Coach Layout 與導覽
- [x] 5.1 建立 `frontend/src/components/CoachNavBar.vue`:顯示教練姓名、「我的課程」、「個人資料」連結與登出按鈕
- [x] 5.2 建立 `frontend/src/layouts/CoachLayout.vue`:包含 CoachNavBar + `<RouterView>`,供所有 `/coach/*` 頁面使用
## 6. [前端] 教練認證頁面
- [x] 6.1 建立 `frontend/src/views/coach/RegisterView.vue`:帳號資訊 + 業者資訊兩段表單,送出呼叫 `POST /api/provider/register`,成功導向 `/coach/login?registered=1`,失敗顯示欄位錯誤;business_name 選填
- [x] 6.2 建立 `frontend/src/views/coach/LoginView.vue`email/password 表單,送出呼叫 `POST /api/provider/login`,成功存 token 並導向 `/coach/dashboard`,失敗顯示錯誤;若 query 有 `?registered=1` 顯示「註冊成功,請登入」
## 7. [前端] 課程 Dashboard
- [x] 7.1 建立 `frontend/src/views/coach/DashboardView.vue`:掛載時呼叫 `GET /api/provider/offers`,以表格列出課程(標題、地點、價格)
- [x] 7.2 新增「新增課程」按鈕,點擊導向 `/coach/offers/new`
- [x] 7.3 每列新增「編輯」按鈕,點擊導向 `/coach/offers/:id/edit`
- [x] 7.4 每列新增「刪除」按鈕:顯示確認 dialog,確認後呼叫 `DELETE /api/provider/offers/{id}`,成功後重新載入列表
- [x] 7.5 無課程時顯示空狀態提示
## 8. [前端] 課程表單(新增 / 編輯)
- [x] 8.1 建立 `frontend/src/views/coach/OfferFormView.vue`(新增與編輯共用同一個組件,以 route param 判斷模式)
- [x] 8.2 欄位:title(必填)、location(必填)、spot、price(必填)、region、tag、badges(多選或逗號分隔輸入)、description
- [x] 8.3 新增模式:送出呼叫 `POST /api/provider/offers`,成功後導向 Dashboard
- [x] 8.4 編輯模式:掛載時取得課程資料預填,送出呼叫 `PUT /api/provider/offers/{id}`,成功後導向 Dashboard
- [x] 8.5 前端必填欄位驗證(title / location / price 為空時不送出)
## 9. [前端] 教練個人資料頁
- [x] 9.1 建立 `frontend/src/views/coach/ProfileView.vue`:掛載時呼叫 `GET /api/provider/profile`,顯示以下欄位:
- 基本:name、email、phone
- 業者:business_name(工作室/個人教練名稱)、description(自我介紹)
- 專業:certifications(認證)、dive_sites(常駐潛點)、services(服務類型)
- 聯絡:contact_person、contact_phone、contact_email、address、business_hours
- 網路:website、social_media
- 唯讀顯示(不可自改):is_verified、rating
- [x] 9.2 實作編輯表單,送出呼叫 `PUT /api/provider/profile`(包含 task 2.5 補完的新欄位),成功顯示「資料已更新」提示
## 10. [整合測試] 端對端驗證
- [x] 10.1 驗證教練完整認證流程:註冊 → 登入 → 登出 → 重新登入
- [x] 10.2 驗證課程 CRUD:新增 → Dashboard 出現 → 編輯 → 刪除
- [x] 10.3 驗證 route guard:未登入訪問 `/coach/dashboard` 自動跳轉 `/coach/login`
- [x] 10.4 驗證權限隔離:教練 A 無法編輯/刪除教練 B 的課程(API 層回傳 403)
- [x] 10.5 驗證公開課程列表(`/courses`)能看到教練新增的課程
+54 -17
View File
@@ -1,20 +1,57 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
context: |
## 專案:CFDivePlatform
潛水課程媒合平台,連結潛水教練(Provider)與學員(Member)。
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
## 架構
- 後端:Laravel 11 + SanctumBearer token+ MySQL,跑在 Dockercfdive-app / cfdive-nginx:8080
- 前端:Vue 3 + Vite + Tailwind CSS + Pinia + Vue Router 4 + Axios
- 原始碼在 `frontend/`(同一個 repo
- Docker image: cfdive-frontend,對外 port 5173
- DBMySQL 8Docker),ORM 使用 Eloquent
- 部署:docker-compose`docker-compose up --build` 即可啟動全部服務
## 使用者角色
- `member`:一般會員,瀏覽/搜尋課程,Google OAuth 登入
- `provider`:教練/業者,管理自己的課程(CRUD),使用 ProviderProfile
- `admin`:平台管理員,尚未實作
## 已完成模組
- Member Portal`/`、`/courses`、`/login`、`/register`、`/profile`
- Coach Portal`/coach/*`):登入、註冊、課程 CRUD Dashboard、個人資料
- Diving Offers 公開 API`GET /api/diving-offers`、`GET /api/diving-offers/{id}`
- Provider Auth API`/api/provider/login|register|logout|profile`
- Provider Offers API`/api/provider/offers` CRUD,含 provider_id 所有權驗證)
## 關鍵資料模型
- `users`role enummember / provider / admin),`is_active`
- `provider_profiles`:業者資料(business_name、certifications、dive_sites 等)
- `member_profiles`:會員資料(birthday、gender、emergency_contact 等)
- `diving_offers`:課程(title、location、spot、price、region、tag、badges JSON、provider_id nullable FK
- `subscriptions` / `plans`:訂閱方案(尚未實作 API)
## 前端慣例
- Member 認證:`src/stores/auth.js`localStorage key `token` / `user`
- Coach 認證:`src/stores/coachAuth.js`localStorage key `coach_token` / `coach_user`
- Member Axios`src/api/axios.js`
- Coach Axios`src/api/coachAxios.js`
- Coach 頁面包在 `src/layouts/CoachLayout.vue`(含 CoachNavBar
- 所有 `/coach/*` protected 路由:`meta: { requiresCoach: true }`
## API 回應格式
成功:`{ status: true, message?: "...", data: {...} 或 [...] }`
失敗:`{ status: false, message: "...", errors?: {...} }`
## 尚未實作
- Admin Panel
- 預約/訂單系統
- 金流整合
- 課程圖片上傳
- 教練審核流程
rules:
tasks:
- 後端任務標記 [後端],前端任務標記 [前端],整合測試標記 [整合測試]
- 每個 task 描述應包含具體的檔案路徑或方法名稱
- 手動驗證類 task 放在最後一個 group
+89
View File
@@ -0,0 +1,89 @@
## ADDED Requirements
### Requirement: provider_id 所有權不變式
對單一課程操作端點(show / update / destroy),系統 MUST 依序執行:先以 id 查找課程(不存在回 404),再比對 provider_id(不符回 403)。兩步驟不可合併為單一 WHERE 查詢。`store()` MUST 強制將 `provider_id` 設為 `auth()->id()`,忽略 request body 傳入值。
#### Scenario: 課程不存在回傳 404
- **WHEN** 指定 id 的課程不存在於資料庫
- **THEN** 回傳 HTTP 404`{ status: false, message: "課程不存在" }`
#### Scenario: 課程存在但非本人回傳 403
- **WHEN** 課程存在(id 有效)但 `offer.provider_id !== auth()->id()`
- **THEN** 回傳 HTTP 403`{ status: false, message: "無權限…" }`
#### Scenario: store 強制設定 provider_id
- **WHEN** 教練送出新增課程請求,body 中包含任意 provider_id 值
- **THEN** 系統忽略該值,`offer.provider_id` 固定為 `auth()->id()`
---
### Requirement: 教練課程列表
後端 SHALL 提供 `GET /api/provider/offers`(需 Bearer tokenrole=provider),回傳當前登入教練自己建立的課程,支援分頁。
#### Scenario: 取得自己的課程列表
- **WHEN** 已登入教練送出 GET 請求
- **THEN** 回傳 HTTP 200,只包含 `provider_id = auth()->id()` 的課程,含分頁 meta
#### Scenario: 無課程時回傳空陣列
- **WHEN** 教練尚未建立任何課程
- **THEN** 回傳 HTTP 200`{ status: true, data: [], meta: { total: 0, ... } }`
---
### Requirement: 教練課程詳情
後端 SHALL 提供 `GET /api/provider/offers/{id}`(需 Bearer tokenrole=provider),回傳單一課程完整資料,只允許查看自己建立的課程。
#### Scenario: 取得自己的課程詳情
- **WHEN** 已登入教練送出 `GET /api/provider/offers/1`,且該課程 `provider_id = auth()->id()`
- **THEN** 回傳 HTTP 200`{ status: true, data: { ...offer } }`
#### Scenario: 查看他人課程
- **WHEN** 課程存在但 `provider_id !== auth()->id()`
- **THEN** 回傳 HTTP 403`{ status: false, message: "無權限查看此課程" }`
#### Scenario: 課程不存在
- **WHEN** 指定 id 的課程不存在
- **THEN** 回傳 HTTP 404`{ status: false, message: "課程不存在" }`
---
### Requirement: 教練新增課程
後端 SHALL 提供 `POST /api/provider/offers`(需 Bearer token),建立新課程並自動設定 `provider_id` 為當前登入教練。
#### Scenario: 新增課程成功
- **WHEN** 教練送出包含 title / location / spot / price / region 的合法資料
- **THEN** 回傳 HTTP 201`{ status: true, data: { ...offer, provider_id: <coach_id> } }`
#### Scenario: 缺少必填欄位
- **WHEN** 教練送出缺少 title 或 price 的資料
- **THEN** 回傳 HTTP 422`{ status: false, message: "...", errors: { field: [...] } }`
---
### Requirement: 教練更新課程
後端 SHALL 提供 `PUT /api/provider/offers/{id}`(需 Bearer token),更新指定課程,只允許修改自己建立的課程。
#### Scenario: 更新自己的課程
- **WHEN** 教練送出合法更新資料且 offer.provider_id === auth()->id()
- **THEN** 回傳 HTTP 200`{ status: true, data: { ...updated_offer } }`
#### Scenario: 嘗試更新他人課程
- **WHEN** offer.provider_id !== auth()->id()
- **THEN** 回傳 HTTP 403`{ status: false, message: "無權限修改此課程" }`
#### Scenario: 課程不存在
- **WHEN** 指定 id 的課程不存在
- **THEN** 回傳 HTTP 404`{ status: false, message: "課程不存在" }`
---
### Requirement: 教練刪除課程
後端 SHALL 提供 `DELETE /api/provider/offers/{id}`(需 Bearer token),刪除指定課程,只允許刪除自己建立的課程。
#### Scenario: 刪除自己的課程
- **WHEN** offer.provider_id === auth()->id()
- **THEN** 回傳 HTTP 200`{ status: true, message: "課程已刪除" }`,資料庫記錄移除
#### Scenario: 嘗試刪除他人課程
- **WHEN** offer.provider_id !== auth()->id()
- **THEN** 回傳 HTTP 403`{ status: false, message: "無權限刪除此課程" }`
+102
View File
@@ -0,0 +1,102 @@
## ADDED Requirements
### Requirement: 教練註冊頁
前端 SHALL 提供 `/coach/register` 頁面,供教練填寫帳號資訊與業者資料後申請帳號,成功後導向 `/coach/login`
#### Scenario: 註冊成功
- **WHEN** 教練填入必填欄位(name / email / password / password_confirmation)並送出
- **THEN** 呼叫 `POST /api/provider/register`,成功後導向 `/coach/login?registered=1`,顯示「註冊成功,請登入」提示
#### Scenario: Email 重複
- **WHEN** 送出已存在的 email
- **THEN** 頁面顯示後端回傳的錯誤訊息,不跳轉
#### Scenario: 密碼不一致
- **WHEN** password 與 password_confirmation 不一致
- **THEN** 後端回傳 422,頁面顯示欄位錯誤提示
#### Scenario: business_name 為選填
- **WHEN** 教練不填寫工作室名稱直接送出
- **THEN** 正常完成註冊,business_name 存為 null
---
### Requirement: 教練登入頁
前端 SHALL 提供 `/coach/login` 頁面,供教練以 email/password 登入,成功後導向 `/coach/dashboard`
#### Scenario: 登入成功
- **WHEN** 教練填入正確帳密並送出
- **THEN** 呼叫 `POST /api/provider/login`token 存入 coachAuth storelocalStorage key: coach_token),導向 `/coach/dashboard`
#### Scenario: 登入失敗
- **WHEN** 帳密錯誤
- **THEN** 頁面顯示錯誤訊息,不跳轉
#### Scenario: 已登入教練訪問登入頁
- **WHEN** coachAuth.isLoggedIn 為 true 時訪問 `/coach/login`
- **THEN** 自動導向 `/coach/dashboard`
---
### Requirement: 課程 Dashboard
前端 SHALL 提供 `/coach/dashboard` 頁面(需教練登入),顯示自己的課程列表,並提供新增、編輯、刪除操作入口。
#### Scenario: 載入課程列表
- **WHEN** 已登入教練訪問 Dashboard
- **THEN** 呼叫 `GET /api/provider/offers`,以表格或卡片渲染課程(標題、地點、價格、狀態)
#### Scenario: 無課程時顯示空狀態
- **WHEN** 教練尚無課程
- **THEN** 顯示「尚無課程,立即新增第一堂課」提示與新增按鈕
#### Scenario: 刪除課程確認
- **WHEN** 教練點擊刪除按鈕
- **THEN** 顯示確認提示,確認後呼叫 `DELETE /api/provider/offers/{id}`,成功後更新列表
---
### Requirement: 新增課程表單
前端 SHALL 提供 `/coach/offers/new` 頁面,教練填寫課程資訊後送出新增。
#### Scenario: 新增課程成功
- **WHEN** 教練填入所有必填欄位並送出
- **THEN** 呼叫 `POST /api/provider/offers`,成功後導向 `/coach/dashboard` 並顯示成功提示
#### Scenario: 表單驗證失敗
- **WHEN** 必填欄位(title / location / price)為空
- **THEN** 前端顯示欄位錯誤提示,不送出 API
---
### Requirement: 編輯課程表單
前端 SHALL 提供 `/coach/offers/:id/edit` 頁面,預填現有課程資料供教練修改。
#### Scenario: 載入課程資料並編輯
- **WHEN** 教練訪問編輯頁
- **THEN** 從 Dashboard 傳入或呼叫 API 取得課程資料,預填表單,送出後呼叫 `PUT /api/provider/offers/{id}`,成功後返回 Dashboard
#### Scenario: 無權限編輯
- **WHEN** API 回傳 403
- **THEN** 頁面顯示「無權限修改此課程」並返回 Dashboard
---
### Requirement: 教練個人資料頁
前端 SHALL 提供 `/coach/profile` 頁面(需教練登入),顯示並允許更新教練基本資訊與專業資料。
#### Scenario: 讀取並更新資料
- **WHEN** 教練訪問個人資料頁
- **THEN** 呼叫 `GET /api/provider/profile`,顯示 name / email / bio / expertise / certification,儲存時呼叫 `PUT /api/provider/profile`
---
### Requirement: Coach 路由守衛
前端 SHALL 對所有 `/coach/*` 路由(login 除外)加上 navigation guard,未登入時導向 `/coach/login`
#### Scenario: 未登入訪問 Dashboard
- **WHEN** 未登入使用者直接訪問 `/coach/dashboard`
- **THEN** 自動導向 `/coach/login`
#### Scenario: 登出
- **WHEN** 教練點擊登出
- **THEN** 呼叫 `POST /api/provider/logout`,清除 coach_token / coach_user,導向 `/coach/login`
+2 -2
View File
@@ -1,11 +1,11 @@
## ADDED Requirements
### Requirement: 課程列表 API
後端 SHALL 提供公開的 `GET /api/diving-offers` endpoint,回傳分頁的潛水課程列表,支援關鍵字搜尋與篩選,無需認證即可存取。
後端 SHALL 提供公開的 `GET /api/diving-offers` endpoint,回傳分頁的潛水課程列表,支援關鍵字搜尋與篩選,無需認證即可存取。response 中每筆課程包含 `provider_id` 欄位(可為 null)。
#### Scenario: 取得全部課程列表
- **WHEN** 客戶端發送 `GET /api/diving-offers` 且不帶任何參數
- **THEN** 回傳 HTTP 200body 包含 `{ data: [...], meta: { total, per_page, current_page } }`,預設每頁 12 筆
- **THEN** 回傳 HTTP 200body 包含 `{ data: [...], meta: { total, per_page, current_page } }`,預設每頁 12 筆,每筆資料含 `provider_id`
#### Scenario: 依關鍵字搜尋課程
- **WHEN** 客戶端發送 `GET /api/diving-offers?q=墾丁`
+56
View File
@@ -0,0 +1,56 @@
## ADDED Requirements
### Requirement: 教練帳號登入
後端 SHALL 提供 `POST /api/provider/login`,驗證 email/password 並回傳 Sanctum Bearer token,僅限 role=provider 帳號。
#### Scenario: 正確帳密登入成功
- **WHEN** 教練送出正確的 email 與 password
- **THEN** 回傳 HTTP 200,包含 `{ status: true, data: { user, token, token_type: "Bearer" } }`
#### Scenario: 錯誤帳密登入失敗
- **WHEN** 教練送出錯誤的 email 或 password
- **THEN** 回傳 HTTP 401`{ status: false, message: "帳號或密碼錯誤" }`
#### Scenario: 會員帳號無法用教練登入
- **WHEN** role=member 的帳號嘗試呼叫 `/api/provider/login`
- **THEN** 回傳 HTTP 403`{ status: false, message: "此帳號非教練角色" }`
---
### Requirement: 教練帳號註冊
後端 SHALL 提供 `POST /api/provider/register`,建立 role=provider 的 User 與對應 ProviderProfile。
#### Scenario: 新帳號註冊成功
- **WHEN** 送出有效的 name / email / password / password_confirmation
- **THEN** 回傳 HTTP 201`{ status: true, data: { user } }`
#### Scenario: Email 重複
- **WHEN** 送出已存在的 email
- **THEN** 回傳 HTTP 422`{ status: false, message: "此 Email 已被使用" }`
---
### Requirement: 教練登出
後端 SHALL 提供 `POST /api/provider/logout`(需 Bearer token),撤銷當前 token。
#### Scenario: 登出成功
- **WHEN** 已登入教練送出登出請求
- **THEN** 回傳 HTTP 200`{ status: true, message: "已登出" }`token 失效
---
### Requirement: 教練個人資料讀取
後端 SHALL 提供 `GET /api/provider/profile`(需 Bearer token),回傳教練基本資訊與 ProviderProfile。
#### Scenario: 取得個人資料
- **WHEN** 已登入教練送出 GET 請求
- **THEN** 回傳 HTTP 200,包含 `{ id, name, email, role, profile: { bio, expertise, certification, avatar } }`
---
### Requirement: 教練個人資料更新
後端 SHALL 提供 `PUT /api/provider/profile`(需 Bearer token),更新教練基本資訊與 ProviderProfile。
#### Scenario: 更新成功
- **WHEN** 教練送出合法的更新資料
- **THEN** 回傳 HTTP 200`{ status: true, message: "資料已更新", data: { ...profile } }`
+7 -1
View File
@@ -3,6 +3,7 @@
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\API\AuthController;
use App\Http\Controllers\API\DivingOfferController;
use App\Http\Controllers\API\ProviderOfferController;
// 這裡可以定義 API 路由,例如:
Route::get('/ping', function () {
@@ -58,7 +59,12 @@ Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () {
Route::put('/profile', [AuthController::class, 'updateProviderProfile']);
// 修改密碼
Route::put('/change-password', [AuthController::class, 'changeProviderPassword']);
// 其他服務提供者專屬 API
// 教練課程管理
Route::get('/offers', [ProviderOfferController::class, 'index']);
Route::post('/offers', [ProviderOfferController::class, 'store']);
Route::get('/offers/{id}', [ProviderOfferController::class, 'show']);
Route::put('/offers/{id}', [ProviderOfferController::class, 'update']);
Route::delete('/offers/{id}', [ProviderOfferController::class, 'destroy']);
});
// 管理員註冊/登入