From da48a3652d4db2f05c5c38ebac4f6c4c7b2e9e02 Mon Sep 17 00:00:00 2001 From: Hank Date: Sun, 10 May 2026 03:34:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=AF=A6=E4=BD=9C=20Coach=20Portal=20?= =?UTF-8?q?=E2=80=94=20=E6=95=99=E7=B7=B4=E5=BE=8C=E5=8F=B0=E8=AA=B2?= =?UTF-8?q?=E7=A8=8B=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 後端: - 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) --- app/Http/Controllers/API/AuthController.php | 28 ++- .../API/ProviderOfferController.php | 111 +++++++++++ app/Models/DivingOffer.php | 1 + ...add_provider_id_to_diving_offers_table.php | 27 +++ ...185046_add_provider_to_users_role_enum.php | 22 +++ ...40_make_spot_nullable_in_diving_offers.php | 25 +++ frontend/src/App.vue | 18 +- frontend/src/api/coachAxios.js | 16 ++ frontend/src/components/CoachNavBar.vue | 36 ++++ frontend/src/layouts/CoachLayout.vue | 10 + frontend/src/router/index.js | 25 ++- frontend/src/stores/coachAuth.js | 38 ++++ frontend/src/views/coach/DashboardView.vue | 113 +++++++++++ frontend/src/views/coach/LoginView.vue | 76 ++++++++ frontend/src/views/coach/OfferFormView.vue | 174 +++++++++++++++++ frontend/src/views/coach/ProfileView.vue | 175 ++++++++++++++++++ frontend/src/views/coach/RegisterView.vue | 145 +++++++++++++++ .../2026-05-10-coach-portal/.openspec.yaml | 2 + .../archive/2026-05-10-coach-portal/design.md | 152 +++++++++++++++ .../2026-05-10-coach-portal/proposal.md | 38 ++++ .../specs/coach-offers-api/spec.md | 89 +++++++++ .../specs/coach-portal-ui/spec.md | 102 ++++++++++ .../specs/diving-offers-api/spec.md | 24 +++ .../specs/provider-auth/spec.md | 56 ++++++ .../archive/2026-05-10-coach-portal/tasks.md | 84 +++++++++ openspec/config.yaml | 71 +++++-- openspec/specs/coach-offers-api/spec.md | 89 +++++++++ openspec/specs/coach-portal-ui/spec.md | 102 ++++++++++ openspec/specs/diving-offers-api/spec.md | 4 +- openspec/specs/provider-auth/spec.md | 56 ++++++ routes/api.php | 8 +- 31 files changed, 1890 insertions(+), 27 deletions(-) create mode 100644 app/Http/Controllers/API/ProviderOfferController.php create mode 100644 database/migrations/2026_05_09_184151_add_provider_id_to_diving_offers_table.php create mode 100644 database/migrations/2026_05_09_185046_add_provider_to_users_role_enum.php create mode 100644 database/migrations/2026_05_09_185440_make_spot_nullable_in_diving_offers.php create mode 100644 frontend/src/api/coachAxios.js create mode 100644 frontend/src/components/CoachNavBar.vue create mode 100644 frontend/src/layouts/CoachLayout.vue create mode 100644 frontend/src/stores/coachAuth.js create mode 100644 frontend/src/views/coach/DashboardView.vue create mode 100644 frontend/src/views/coach/LoginView.vue create mode 100644 frontend/src/views/coach/OfferFormView.vue create mode 100644 frontend/src/views/coach/ProfileView.vue create mode 100644 frontend/src/views/coach/RegisterView.vue create mode 100644 openspec/changes/archive/2026-05-10-coach-portal/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-10-coach-portal/design.md create mode 100644 openspec/changes/archive/2026-05-10-coach-portal/proposal.md create mode 100644 openspec/changes/archive/2026-05-10-coach-portal/specs/coach-offers-api/spec.md create mode 100644 openspec/changes/archive/2026-05-10-coach-portal/specs/coach-portal-ui/spec.md create mode 100644 openspec/changes/archive/2026-05-10-coach-portal/specs/diving-offers-api/spec.md create mode 100644 openspec/changes/archive/2026-05-10-coach-portal/specs/provider-auth/spec.md create mode 100644 openspec/changes/archive/2026-05-10-coach-portal/tasks.md create mode 100644 openspec/specs/coach-offers-api/spec.md create mode 100644 openspec/specs/coach-portal-ui/spec.md create mode 100644 openspec/specs/provider-auth/spec.md diff --git a/app/Http/Controllers/API/AuthController.php b/app/Http/Controllers/API/AuthController.php index 6ac6f48..e1f8180 100644 --- a/app/Http/Controllers/API/AuthController.php +++ b/app/Http/Controllers/API/AuthController.php @@ -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(); // 加載服務提供者資料 diff --git a/app/Http/Controllers/API/ProviderOfferController.php b/app/Http/Controllers/API/ProviderOfferController.php new file mode 100644 index 0000000..b086349 --- /dev/null +++ b/app/Http/Controllers/API/ProviderOfferController.php @@ -0,0 +1,111 @@ +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' => '課程已刪除']); + } +} diff --git a/app/Models/DivingOffer.php b/app/Models/DivingOffer.php index 95a102e..d5fbe6d 100644 --- a/app/Models/DivingOffer.php +++ b/app/Models/DivingOffer.php @@ -11,6 +11,7 @@ class DivingOffer extends Model protected $table = 'diving_offers'; protected $fillable = [ + 'provider_id', 'title', 'location', 'spot', diff --git a/database/migrations/2026_05_09_184151_add_provider_id_to_diving_offers_table.php b/database/migrations/2026_05_09_184151_add_provider_id_to_diving_offers_table.php new file mode 100644 index 0000000..6f52c65 --- /dev/null +++ b/database/migrations/2026_05_09_184151_add_provider_id_to_diving_offers_table.php @@ -0,0 +1,27 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_05_09_185046_add_provider_to_users_role_enum.php b/database/migrations/2026_05_09_185046_add_provider_to_users_role_enum.php new file mode 100644 index 0000000..b3783ac --- /dev/null +++ b/database/migrations/2026_05_09_185046_add_provider_to_users_role_enum.php @@ -0,0 +1,22 @@ +string('spot')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('diving_offers', function (Blueprint $table) { + $table->string('spot')->nullable(false)->change(); + }); + } +}; diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 118e9d0..6e7b2a3 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,15 +1,25 @@ diff --git a/frontend/src/api/coachAxios.js b/frontend/src/api/coachAxios.js new file mode 100644 index 0000000..b534f2d --- /dev/null +++ b/frontend/src/api/coachAxios.js @@ -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 diff --git a/frontend/src/components/CoachNavBar.vue b/frontend/src/components/CoachNavBar.vue new file mode 100644 index 0000000..6dc72c6 --- /dev/null +++ b/frontend/src/components/CoachNavBar.vue @@ -0,0 +1,36 @@ + + + diff --git a/frontend/src/layouts/CoachLayout.vue b/frontend/src/layouts/CoachLayout.vue new file mode 100644 index 0000000..f21fd52 --- /dev/null +++ b/frontend/src/layouts/CoachLayout.vue @@ -0,0 +1,10 @@ + + + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index bafe606..f0c5bbe 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -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 diff --git a/frontend/src/stores/coachAuth.js b/frontend/src/stores/coachAuth.js new file mode 100644 index 0000000..769d18b --- /dev/null +++ b/frontend/src/stores/coachAuth.js @@ -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 } +}) diff --git a/frontend/src/views/coach/DashboardView.vue b/frontend/src/views/coach/DashboardView.vue new file mode 100644 index 0000000..4bb4521 --- /dev/null +++ b/frontend/src/views/coach/DashboardView.vue @@ -0,0 +1,113 @@ + + + diff --git a/frontend/src/views/coach/LoginView.vue b/frontend/src/views/coach/LoginView.vue new file mode 100644 index 0000000..696413c --- /dev/null +++ b/frontend/src/views/coach/LoginView.vue @@ -0,0 +1,76 @@ + + + diff --git a/frontend/src/views/coach/OfferFormView.vue b/frontend/src/views/coach/OfferFormView.vue new file mode 100644 index 0000000..a27bc6a --- /dev/null +++ b/frontend/src/views/coach/OfferFormView.vue @@ -0,0 +1,174 @@ + + +