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:
@@ -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' => '課程已刪除']);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
### D1:Coach 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 會互相污染登入狀態。
|
||||
|
||||
---
|
||||
|
||||
### D3:Provider 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 中任何傳入值。
|
||||
|
||||
---
|
||||
|
||||
### D5:diving_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 token,role=provider)
|
||||
```
|
||||
Response 200: { "status": true, "data": [...offers], "meta": { total, per_page, current_page, last_page } }
|
||||
```
|
||||
|
||||
#### `GET /api/provider/offers/{id}`(需 Bearer token,role=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 API(register / login / logout / profile CRUD)
|
||||
- **後端**:新增 Coach 課程管理 API(CRUD,需 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 token,role=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 token,role=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 store(localStorage 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 200,body 包含 `{ 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 驗證:register(business_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 instance,request 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
@@ -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 + Sanctum(Bearer token)+ MySQL,跑在 Docker(cfdive-app / cfdive-nginx:8080)
|
||||
- 前端:Vue 3 + Vite + Tailwind CSS + Pinia + Vue Router 4 + Axios
|
||||
- 原始碼在 `frontend/`(同一個 repo)
|
||||
- Docker image: cfdive-frontend,對外 port 5173
|
||||
- DB:MySQL 8(Docker),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 enum(member / 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
|
||||
|
||||
@@ -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 token,role=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 token,role=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 store(localStorage 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`
|
||||
@@ -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 200,body 包含 `{ data: [...], meta: { total, per_page, current_page } }`,預設每頁 12 筆
|
||||
- **THEN** 回傳 HTTP 200,body 包含 `{ data: [...], meta: { total, per_page, current_page } }`,預設每頁 12 筆,每筆資料含 `provider_id`
|
||||
|
||||
#### Scenario: 依關鍵字搜尋課程
|
||||
- **WHEN** 客戶端發送 `GET /api/diving-offers?q=墾丁`
|
||||
|
||||
@@ -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
@@ -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']);
|
||||
});
|
||||
|
||||
// 管理員註冊/登入
|
||||
|
||||
Reference in New Issue
Block a user