feat:實作 Member Portal MVP 前端與後端整合

後端:
- 新增 DivingOffer Model / DivingOfferController(列表+詳情 API,支援搜尋/篩選/分頁)
- 修正 Google OAuth callback 改為 redirect 至前端(SocialAuthController)
- 新增 config/cors.php 允許前端 origin
- .gitignore 新增 frontend/ 排除規則

前端(frontend/):
- Vue 3 + Vite + Tailwind CSS + Pinia + Vue Router
- 頁面:首頁、課程列表、課程詳情、登入、註冊、個人資料、OAuth callback
- 整合至 Docker(multi-stage build,nginx 靜態服務於 port 5173)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 01:41:28 +08:00
parent 725c86f434
commit 550e2fc97a
48 changed files with 5887 additions and 17 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-09
@@ -0,0 +1,349 @@
## Context
後端(Laravel + Sanctum)已有會員認證、個人資料、Google OAuth 等 API,但 `diving_offers` 表雖已建立,尚無對應的公開 API。前端完全從零開始,需建立獨立 repo,透過 HTTP 呼叫後端 API。兩端分離部署,前端在開發期間以 `http://localhost:5173` 運行,後端以 `http://localhost:80`Laragon)運行。
## Goals / Non-Goals
**Goals:**
- 建立可運行的 Vue 3 前端 MVP,讓會員能瀏覽和搜尋潛水課程
- 後端補齊 `diving-offers` 公開 API(列表 + 詳情)
- 整合現有 Auth API(登入、註冊、Google OAuth
- 會員個人資料頁可讀取與更新
**Non-Goals:**
- 預約/訂單系統(本次不做)
- 金流整合
- Provider 端介面
- Admin 後台
- SSR / SEO 優化
- 自動化測試
## Decisions
### D1:前端獨立 repo,不用 Inertia.js
**決定**:前端為獨立 Vue 3 SPA repo,透過 REST API 與後端溝通。
**理由**:前後端分離讓未來可獨立部署、擴展。Inertia.js 雖整合方便,但綁定 Laravel monolith,不符長期架構方向。
**替代方案**Inertia.jsLaravel + Vue 同 repo)→ 捨棄,因為部署彈性不足。
---
### D2Auth 策略 — Sanctum TokenBearer
**決定**:前端登入後儲存 Bearer token 於 `localStorage`,每次請求附加 `Authorization: Bearer <token>` header。
**理由**Sanctum 在 SPA 跨域場景下有兩種模式(cookie-based SPA 和 token-based API)。Token 模式對跨 origin 最簡單,不需設定 session cookie、CSRF。
**替代方案**Sanctum SPA cookie 模式 → 需要同 domain 或複雜 CORS cookie 設定,開發期間繁瑣。
---
### D3:樣式策略 — Tailwind CSS,無 UI 框架
**決定**:純 Tailwind CSS 配合自定義 Vue 組件。
**理由**:設計彈性最高,不被 Element Plus / Naive UI 的元件語言綁定,適合建立品牌識別感強的潛水平台。
---
### D4:狀態管理 — Pinia(只管 Auth 狀態)
**決定**:僅用 Pinia 管理認證狀態(user、token、isLoggedIn)。課程列表等資料用組件本地 `ref` + Axios 取得,不過度使用全域 store。
**理由**:MVP 階段避免過早引入複雜的全域狀態。
---
### D5Google OAuth Callback 改為 Redirect
**決定**`SocialAuthController::handleGoogleCallback()` 現行實作回傳 JSON response,但前後端分離時瀏覽器停在後端 origin(`:80`),前端無法取得 token。**必須改為 redirect 至前端 callback 頁面。**
**OAuth 完整流程契約:**
```
cf-dive-frontend (:5173) CFDivePlatform (:80) Google
| | |
| 點擊「Google 登入」 | |
| window.location = | |
| :80/api/auth/google/ | |
| redirect | |
|──────────────────────────▶| |
| | stateless()->redirect()|
| |──────────────────────▶|
| | 302 → Google 同意頁 |
|◀──────────────────────────| |
| (使用者在 Google 同意) | |
| |◀──────────────────────|
| | callback?code=xxx |
| | |
| | 1. 取得 Google user |
| | 2. 建立/查詢 User |
| | 3. 建立 Sanctum token |
| | |
| [成功] 302 redirect → | |
| :5173/auth/callback | |
| ?token=<sanctum_token> | |
|◀──────────────────────────| |
| | |
| [失敗] 302 redirect → | |
| :5173/login | |
| ?error=oauth_failed | |
|◀──────────────────────────| |
| | |
| /auth/callback 頁面: | |
| 讀取 ?token= | |
| 存入 Pinia + localStorage | |
| 導向 /courses | |
```
**後端改動**`handleGoogleCallback()` 末段改為:
```php
// 成功
return redirect(env('FRONTEND_URL') . '/auth/callback?token=' . $token);
// 失敗(catch 區塊)
return redirect(env('FRONTEND_URL') . '/login?error=oauth_failed');
```
**前端新增**`/auth/callback` 路由對應 `AuthCallbackView.vue`,讀取 `?token=` 後存入 store,再導向 `/courses`
---
## Contracts
### Contract 1 — API Schema
所有 API response 遵循統一結構:
```
成功:{ "status": true, "message": "...", "data": {...} 或 [...] }
失敗:{ "status": false, "message": "錯誤說明" }
```
---
#### `GET /api/diving-offers`(公開,無需 auth
```
Query Parameters:
q : string 搜尋 title / location / spotLIKE 模糊匹配)
region : string 完全匹配 region 欄位
tag : string LIKE 匹配 tag 欄位
per_page : integer default=12, max=50
page : integer default=1
Response 200:
{
"status": true,
"data": [
{
"id": 1,
"title": "墾丁海底探險",
"location": "屏東縣",
"spot": "龍坑生態保護區",
"rating": 4.8,
"reviews": 32,
"price": 2500,
"badges": ["PADI認證", "含裝備"], ← JSON decode 後為陣列
"description": "課程描述文字...",
"tag": "初學者",
"region": "南部",
"created_at": "2025-05-01T00:00:00.000000Z"
}
],
"meta": {
"total": 48,
"per_page": 12,
"current_page": 1,
"last_page": 4
}
}
```
---
#### `GET /api/diving-offers/{id}`(公開,無需 auth
```
Response 200:
{
"status": true,
"data": {
"id": 1,
"title": "墾丁海底探險",
"location": "屏東縣",
"spot": "龍坑生態保護區",
"rating": 4.8,
"reviews": 32,
"price": 2500,
"badges": ["PADI認證", "含裝備"],
"description": "課程描述文字...",
"tag": "初學者",
"region": "南部",
"created_at": "2025-05-01T00:00:00.000000Z"
}
}
Response 404:
{ "status": false, "message": "課程不存在" }
```
---
#### `POST /api/member/login`(現有 API,前端需遵守)
```
Request Body (application/json):
{ "email": "user@example.com", "password": "password123" }
Response 200:
{
"status": true,
"message": "登入成功",
"data": {
"user": {
"id": 1,
"name": "王小明",
"email": "user@example.com",
"role": "member"
},
"token": "1|abcdef...",
"token_type": "Bearer"
}
}
Response 401/422:
{ "status": false, "message": "帳號或密碼錯誤" }
```
---
#### `POST /api/member/register`(現有 API
```
Request Body (application/json):
{ "name": "王小明", "email": "user@example.com", "password": "password123", "password_confirmation": "password123" }
Response 201:
{
"status": true,
"message": "註冊成功",
"data": { "user": { "id": 1, "name": "王小明", "email": "...", "role": "member" } }
}
Response 422:
{ "status": false, "message": "此 Email 已被使用" }
```
---
#### `GET /api/member/profile`(需 Bearer token
```
Request Header: Authorization: Bearer <token>
Response 200:
{
"status": true,
"data": {
"id": 1,
"name": "王小明",
"email": "user@example.com",
"role": "member",
"profile": {
"birthday": "1990-01-01",
"gender": "male",
"address": "台北市信義區...",
"emergency_contact": "王大明",
"emergency_phone": "0987654321"
}
}
}
```
---
#### `PUT /api/member/profile`(需 Bearer token
```
Request Header: Authorization: Bearer <token>
Request Body (application/json):
{
"name": "王小明",
"birthday": "1990-01-01",
"gender": "male",
"address": "台北市信義區...",
"emergency_contact": "王大明",
"emergency_phone": "0987654321"
}
Response 200:
{ "status": true, "message": "資料已更新", "data": { ...同 GET profile } }
```
---
#### `POST /api/member/logout`(需 Bearer token
```
Request Header: Authorization: Bearer <token>
Response 200:
{ "status": true, "message": "已登出" }
```
---
### Contract 2 — 環境變數
#### 後端(`CFDivePlatform/.env`)需新增
```
# Google OAuth
GOOGLE_CLIENT_ID=<從 Google Cloud Console 取得>
GOOGLE_CLIENT_SECRET=<從 Google Cloud Console 取得>
GOOGLE_REDIRECT_URI=http://localhost:80/api/auth/google/callback
# 前端 URLOAuth callback redirect 用)
FRONTEND_URL=http://localhost:5173
```
#### 前端(`cf-dive-frontend/.env`
```
VITE_API_URL=http://localhost:80
```
---
### Contract 3 — CORS 設定
`config/cors.php`(Laravel 11 預設不存在,需執行 `php artisan config:publish cors` 建立後修改):
```php
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')],
'allowed_origins_patterns'=> [],
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => false, // Token 模式不需要 cookiefalse 即可
```
> `supports_credentials: false` 是刻意的決定。若改用 Sanctum cookie 模式才需設為 true,但會帶來 SameSite / CSRF 複雜度。Token 模式維持 false 最簡單。
---
## Risks / Trade-offs
| 風險 | 緩解策略 |
|------|----------|
| `localStorage` 存 token 有 XSS 風險 | MVP 階段接受,未來可改用 httpOnly cookie |
| `diving_offers` 表無 `provider_id`,課程無法關聯教練 | MVP 不處理,僅展示靜態課程資料 |
| Google OAuth callback redirect 帶 token 在 URL 中,有 Referer 洩漏風險 | token 在 query param 僅短暫存在,前端取得後立即存 localStorage 並清除 URL`history.replaceState` |
| 前端 repo 與 Laravel repo 分開,OpenSpec tasks 橫跨兩個位置 | Tasks 以 `[後端]` / `[前端]` 標記,分別在對應 repo 操作 |
## Open Questions
- [x] **Google OAuth callback 邏輯**`SocialAuthController::handleGoogleCallback()` 現行回傳 JSON**確認需改為 redirect 至 `FRONTEND_URL/auth/callback?token=<token>`**。失敗時 redirect 至 `FRONTEND_URL/login?error=oauth_failed`
- [x] **前端 repo 名稱**:確認為 `cf-dive-frontend`
@@ -0,0 +1,32 @@
## Why
CFDivePlatform 後端 API 已具備會員認證與基礎資料管理能力,但目前缺乏任何前端介面,使用者無法透過瀏覽器使用平台。建立獨立的會員端前端 MVP,讓潛水愛好者能瀏覽、搜尋課程,是平台從「有 API」走向「可用產品」的第一步。
## What Changes
- **新建獨立前端 repo**Vue 3 + Vite + Tailwind CSS),與此 Laravel repo 分開部署
- **後端新增 Diving Offers 公開 API**:課程列表(含搜尋/篩選)與課程詳情兩支 endpoint
- 前端實作六個頁面:首頁、課程列表、課程詳情、登入、註冊、會員個人資料
- 前端整合現有 Auth APISanctum token)與 Google OAuth redirect 流程
## Capabilities
### New Capabilities
- `diving-offers-api`:後端提供公開的潛水課程列表與詳情 API,支援關鍵字搜尋、地區與標籤篩選
- `member-portal-ui`:獨立前端應用,包含課程瀏覽、認證流程、會員個人資料等完整使用者介面
### Modified Capabilities
(無)
## Impact
**後端(此 Laravel repo**
- 新增 `DivingOfferController` 與兩條 API 路由
- `diving_offers` 資料表已存在,僅需新增 Model fillable 與 Controller
**前端(新 repo**
- 獨立 Vue 3 repo,需另行建立專案結構
- 依賴後端 API base URL(透過 `.env` 設定)
- CORS 需在 Laravel 端設定允許前端 origin
@@ -0,0 +1,46 @@
## ADDED Requirements
### Requirement: 課程列表 API
後端 SHALL 提供公開的 `GET /api/diving-offers` endpoint,回傳分頁的潛水課程列表,支援關鍵字搜尋與篩選,無需認證即可存取。
#### Scenario: 取得全部課程列表
- **WHEN** 客戶端發送 `GET /api/diving-offers` 且不帶任何參數
- **THEN** 回傳 HTTP 200body 包含 `{ data: [...], meta: { total, per_page, current_page } }`,預設每頁 12 筆
#### 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` 包含正確的分頁資訊
---
### Requirement: 課程詳情 API
後端 SHALL 提供公開的 `GET /api/diving-offers/{id}` endpoint,回傳單一課程完整資訊,無需認證即可存取。
#### Scenario: 取得存在的課程詳情
- **WHEN** 客戶端發送 `GET /api/diving-offers/1`(該 id 存在)
- **THEN** 回傳 HTTP 200body 包含 `{ data: { id, title, location, spot, rating, reviews, price, badges, description, tag, region, created_at } }`
#### Scenario: 課程不存在
- **WHEN** 客戶端發送 `GET /api/diving-offers/99999`(該 id 不存在)
- **THEN** 回傳 HTTP 404body 包含 `{ message: "課程不存在" }`
---
### Requirement: CORS 允許前端 Origin
後端 SHALL 在 `config/cors.php` 中允許來自前端開發 origin`http://localhost:5173`)的跨域請求。
#### Scenario: 前端跨域請求課程列表
- **WHEN** 瀏覽器從 `http://localhost:5173` 發送 `GET /api/diving-offers`
- **THEN** 後端回應包含正確的 CORS header,瀏覽器不阻擋請求
@@ -0,0 +1,119 @@
## ADDED Requirements
### Requirement: 專案基礎建設
前端 SHALL 建立於獨立 repo,使用 Vue 3 + Vite + Tailwind CSS + Vue Router 4 + Pinia + Axios,並設定 `.env` 指定後端 API base URL。
#### Scenario: 開發環境啟動
- **WHEN** 開發者執行 `npm run dev`
- **THEN** 應用在 `http://localhost:5173` 啟動,無編譯錯誤
#### Scenario: API base URL 設定
- **WHEN** `.env` 中設定 `VITE_API_URL=http://localhost:80`
- **THEN** 所有 Axios 請求以此為 base URL
---
### Requirement: 首頁 Landing Page
前端 SHALL 提供靜態首頁,展示平台品牌、簡介,以及引導至課程列表的 CTA(Call to Action)按鈕。
#### Scenario: 訪客瀏覽首頁
- **WHEN** 使用者訪問 `/`
- **THEN** 看到平台名稱、簡介文字、「探索課程」按鈕
#### Scenario: 點擊 CTA 跳轉
- **WHEN** 使用者點擊「探索課程」按鈕
- **THEN** 導航至 `/courses`(課程列表頁)
---
### Requirement: 課程列表頁
前端 SHALL 提供 `/courses` 頁面,顯示從後端取得的潛水課程卡片列表,並支援搜尋與篩選。
#### Scenario: 載入課程列表
- **WHEN** 使用者訪問 `/courses`
- **THEN** 頁面呼叫 `GET /api/diving-offers` 並渲染課程卡片(含標題、地點、價格、評分、標籤)
#### Scenario: 搜尋課程
- **WHEN** 使用者在搜尋框輸入關鍵字後按 Enter 或點搜尋
- **THEN**`?q=<keyword>` 重新呼叫 API,列表更新
#### Scenario: 地區篩選
- **WHEN** 使用者從地區下拉選單選擇某地區
- **THEN**`?region=<region>` 重新呼叫 API,列表更新
#### Scenario: 無結果
- **WHEN** 搜尋/篩選後後端回傳空陣列
- **THEN** 頁面顯示「找不到符合的課程」提示訊息
---
### Requirement: 課程詳情頁
前端 SHALL 提供 `/courses/:id` 頁面,顯示單一課程的完整資訊。
#### Scenario: 載入課程詳情
- **WHEN** 使用者訪問 `/courses/1`
- **THEN** 頁面呼叫 `GET /api/diving-offers/1` 並顯示標題、地點、景點、價格、評分、評論數、描述、徽章、標籤
#### Scenario: 課程不存在
- **WHEN** 使用者訪問不存在的課程 id
- **THEN** 頁面顯示「課程不存在」並提供返回列表按鈕
---
### Requirement: 登入頁
前端 SHALL 提供 `/login` 頁面,供會員以 email/password 登入,以及 Google OAuth 登入入口。
#### Scenario: Email/Password 登入成功
- **WHEN** 使用者填入正確的 email 與 password 並送出
- **THEN** 呼叫 `POST /api/member/login`,儲存回傳的 token 至 localStorage,導航至 `/courses`
#### Scenario: 登入失敗
- **WHEN** 使用者填入錯誤的 email 或 password
- **THEN** 頁面顯示錯誤訊息,不跳轉
#### Scenario: Google OAuth 登入
- **WHEN** 使用者點擊「以 Google 登入」按鈕
- **THEN** 瀏覽器導航至後端 `GET /api/auth/google/redirect`,開始 OAuth 流程
---
### Requirement: 註冊頁
前端 SHALL 提供 `/register` 頁面,供訪客建立會員帳號。
#### Scenario: 註冊成功
- **WHEN** 使用者填入 name、email、password 並送出
- **THEN** 呼叫 `POST /api/member/register`,成功後導航至 `/login`,顯示「註冊成功,請登入」
#### Scenario: Email 已被使用
- **WHEN** 使用者填入已存在的 email 送出
- **THEN** 頁面顯示「此 Email 已被使用」錯誤訊息
---
### Requirement: 會員個人資料頁
前端 SHALL 提供 `/profile` 頁面,已登入會員可查看並更新個人資料。此頁面需登入後才能訪問。
#### Scenario: 已登入會員訪問個人資料
- **WHEN** 已登入使用者訪問 `/profile`
- **THEN** 頁面呼叫 `GET /api/member/profile` 並顯示姓名、email、生日、性別、地址、緊急聯絡人
#### Scenario: 未登入訪問個人資料
- **WHEN** 未登入使用者訪問 `/profile`
- **THEN** 自動導向 `/login`
#### Scenario: 更新個人資料成功
- **WHEN** 已登入使用者修改欄位後點擊儲存
- **THEN** 呼叫 `PUT /api/member/profile`,成功後顯示「資料已更新」提示
---
### Requirement: 認證狀態管理
前端 SHALL 使用 Pinia store 管理認證狀態,token 持久化至 localStorage,並在所有需認證的 API 請求自動附加 Bearer token。
#### Scenario: 頁面刷新後保持登入狀態
- **WHEN** 已登入使用者重新整理頁面
- **THEN** 從 localStorage 還原 token,使用者仍為登入狀態
#### Scenario: 登出
- **WHEN** 使用者點擊登出
- **THEN** 呼叫 `POST /api/member/logout`,清除 localStorage token,導向 `/login`
@@ -0,0 +1,73 @@
## 1. [後端] 環境與 CORS 設定
- [x] 1.1 在 `.env` 新增 `FRONTEND_URL=http://localhost:5173``GOOGLE_CLIENT_ID``GOOGLE_CLIENT_SECRET``GOOGLE_REDIRECT_URI=http://localhost:80/api/auth/google/callback`
- [x] 1.2 執行 `php artisan config:publish cors` 建立 `config/cors.php`,設定 `allowed_origins=[FRONTEND_URL]``allowed_methods``allowed_headers``supports_credentials=false`(參考 design.md Contract 3
- [x] 1.3 確認 `bootstrap/app.php`(或 `app/Http/Kernel.php`)已啟用 `HandleCors` middleware
## 2. [後端] 修正 Google OAuth Callback
- [x] 2.1 修改 `SocialAuthController::handleGoogleCallback()`:成功時改為 `redirect(env('FRONTEND_URL') . '/auth/callback?token=' . $token)`
- [x] 2.2 修改 catch 區塊:失敗時改為 `redirect(env('FRONTEND_URL') . '/login?error=oauth_failed')`
- [x] 2.3 手動測試 OAuth 流程:點擊 Google 登入後確認瀏覽器最終落在 `:5173/auth/callback?token=...`
## 3. [後端] Diving Offers API
- [x] 3.1 更新 `DivingOffer` Model:設定 `$fillable``$table``badges` 欄位加上 `$casts = ['badges' => 'array']` 自動 JSON decode
- [x] 3.2 建立 `DivingOfferController`,實作 `index()` 方法(支援 q / region / tag 篩選,分頁預設 12 筆,max 50)
- [x] 3.3 實作 `show($id)` 方法:找不到時回傳 `{ "status": false, "message": "課程不存在" }`HTTP 404
- [x] 3.4 在 `routes/api.php` 新增公開路由:`GET /diving-offers``GET /diving-offers/{id}`
- [x] 3.5 用 Postman 驗證:列表(含 q / region / tag / 分頁)、詳情、404 情境,確認 response 結構符合 design.md Contract 1
## 4. [前端] 專案初始化
- [x] 4.1 在 Laravel repo 外建立新目錄 `cf-dive-frontend`,執行 `npm create vite@latest . -- --template vue`
- [x] 4.2 安裝依賴:`npm install`,再安裝 `vue-router@4 pinia axios`
- [x] 4.3 安裝並設定 Tailwind CSS`tailwindcss postcss autoprefixer`,初始化 `tailwind.config.js`
- [x] 4.4 建立 `.env` 文件,設定 `VITE_API_URL=http://localhost:80`
- [x] 4.5 建立 `src/api/axios.js`:設定 Axios instancebase URL 讀自 `import.meta.env.VITE_API_URL`request interceptor 讀 localStorage token 並附加 `Authorization: Bearer <token>`
- [x] 4.6 建立 `src/stores/auth.js`Pinia store 管理 `user``token``isLoggedIn``init()` 從 localStorage 還原狀態
- [x] 4.7 設定 Vue Router`src/router/index.js`):定義所有路由(含 `/auth/callback`),`/profile` 加上 beforeEach navigation guard(未登入導向 `/login`
- [x] 4.8 在 `App.vue` 呼叫 `authStore.init()`,並加入 `<RouterView>`
- [x] 4.9 執行 `npm run dev`,確認開發環境正常啟動無錯誤
## 5. [前端] Layout 與共用組件
- [x] 5.1 建立 `src/components/NavBar.vue`:顯示 logo、「探索課程」連結,已登入顯示「個人資料」和「登出」,未登入顯示「登入」和「註冊」
- [x] 5.2 建立 `src/components/CourseCard.vue`:接收 offer 資料,顯示標題、地點、價格、評分、標籤
## 6. [前端] 首頁
- [x] 6.1 建立 `src/views/HomeView.vue`Hero section(平台名稱、簡介)+ 「探索課程」CTA 按鈕,點擊導向 `/courses`
## 7. [前端] 課程列表頁
- [x] 7.1 建立 `src/views/CoursesView.vue`,掛載時呼叫 `GET /api/diving-offers`,渲染 `CourseCard` 列表
- [x] 7.2 新增搜尋框:輸入後按 Enter 或點搜尋重新呼叫 API(帶 `q` 參數)
- [x] 7.3 新增地區下拉選單:選擇後以 `region` 參數重新呼叫 API
- [x] 7.4 處理無結果狀態:顯示「找不到符合的課程」提示
## 8. [前端] 課程詳情頁
- [x] 8.1 建立 `src/views/CourseDetailView.vue`,掛載時呼叫 `GET /api/diving-offers/:id`
- [x] 8.2 顯示課程完整資訊:標題、地點、景點、價格、評分、評論數、描述、徽章(badges 陣列)、標籤
- [x] 8.3 處理 404 情境:顯示「課程不存在」並提供「返回列表」按鈕
## 9. [前端] 認證頁面
- [x] 9.1 建立 `src/views/LoginView.vue`email/password 表單,送出呼叫 `POST /api/member/login`,成功存 token + user 至 Pinia 並導向 `/courses`,失敗顯示錯誤訊息
- [x] 9.2 在 `LoginView.vue` 加入「以 Google 登入」按鈕:點擊執行 `window.location.href = VITE_API_URL + '/api/auth/google/redirect'`
- [x] 9.3 建立 `src/views/AuthCallbackView.vue`(路由 `/auth/callback`):讀取 `?token=` query param → 存入 Pinia + localStorage → 呼叫 `history.replaceState` 清除 URL token → 導向 `/courses`;若 `?error=oauth_failed` 則導向 `/login` 並顯示錯誤提示
- [x] 9.4 建立 `src/views/RegisterView.vue`name / email / password / password_confirmation 表單,送出呼叫 `POST /api/member/register`,成功導向 `/login` 並顯示成功提示,失敗顯示錯誤
## 10. [前端] 會員個人資料頁
- [x] 10.1 建立 `src/views/ProfileView.vue`,掛載時呼叫 `GET /api/member/profile`,顯示姓名、email、生日、性別、地址、緊急聯絡人
- [x] 10.2 實作編輯表單:使用者修改後點擊「儲存」呼叫 `PUT /api/member/profile`,成功顯示「資料已更新」提示
## 11. [整合測試] 端對端驗證
- [x] 11.1 驗證訪客流程:首頁 → 課程列表(搜尋/篩選)→ 課程詳情(無需登入)
- [x] 11.2 驗證 Email 認證流程:註冊 → 登入 → 個人資料 → 登出
- [x] 11.3 驗證 Google OAuth 流程:點擊 Google 登入 → 同意 → 回到前端 `/auth/callback` → 自動存 token → 導向課程列表
- [x] 11.4 驗證 navigation guard:未登入直接訪問 `/profile` 自動跳轉至 `/login`
- [x] 11.5 驗證 CORS:確認 Network tab 無 CORS 錯誤,所有 API 請求正常回應
+20
View File
@@ -0,0 +1,20 @@
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
# 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
+46
View File
@@ -0,0 +1,46 @@
## ADDED Requirements
### Requirement: 課程列表 API
後端 SHALL 提供公開的 `GET /api/diving-offers` endpoint,回傳分頁的潛水課程列表,支援關鍵字搜尋與篩選,無需認證即可存取。
#### Scenario: 取得全部課程列表
- **WHEN** 客戶端發送 `GET /api/diving-offers` 且不帶任何參數
- **THEN** 回傳 HTTP 200body 包含 `{ data: [...], meta: { total, per_page, current_page } }`,預設每頁 12 筆
#### 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` 包含正確的分頁資訊
---
### Requirement: 課程詳情 API
後端 SHALL 提供公開的 `GET /api/diving-offers/{id}` endpoint,回傳單一課程完整資訊,無需認證即可存取。
#### Scenario: 取得存在的課程詳情
- **WHEN** 客戶端發送 `GET /api/diving-offers/1`(該 id 存在)
- **THEN** 回傳 HTTP 200body 包含 `{ data: { id, title, location, spot, rating, reviews, price, badges, description, tag, region, created_at } }`
#### Scenario: 課程不存在
- **WHEN** 客戶端發送 `GET /api/diving-offers/99999`(該 id 不存在)
- **THEN** 回傳 HTTP 404body 包含 `{ message: "課程不存在" }`
---
### Requirement: CORS 允許前端 Origin
後端 SHALL 在 `config/cors.php` 中允許來自前端開發 origin`http://localhost:5173`)的跨域請求。
#### Scenario: 前端跨域請求課程列表
- **WHEN** 瀏覽器從 `http://localhost:5173` 發送 `GET /api/diving-offers`
- **THEN** 後端回應包含正確的 CORS header,瀏覽器不阻擋請求
+119
View File
@@ -0,0 +1,119 @@
## ADDED Requirements
### Requirement: 專案基礎建設
前端 SHALL 建立於獨立 repo,使用 Vue 3 + Vite + Tailwind CSS + Vue Router 4 + Pinia + Axios,並設定 `.env` 指定後端 API base URL。
#### Scenario: 開發環境啟動
- **WHEN** 開發者執行 `npm run dev`
- **THEN** 應用在 `http://localhost:5173` 啟動,無編譯錯誤
#### Scenario: API base URL 設定
- **WHEN** `.env` 中設定 `VITE_API_URL=http://localhost:80`
- **THEN** 所有 Axios 請求以此為 base URL
---
### Requirement: 首頁 Landing Page
前端 SHALL 提供靜態首頁,展示平台品牌、簡介,以及引導至課程列表的 CTA(Call to Action)按鈕。
#### Scenario: 訪客瀏覽首頁
- **WHEN** 使用者訪問 `/`
- **THEN** 看到平台名稱、簡介文字、「探索課程」按鈕
#### Scenario: 點擊 CTA 跳轉
- **WHEN** 使用者點擊「探索課程」按鈕
- **THEN** 導航至 `/courses`(課程列表頁)
---
### Requirement: 課程列表頁
前端 SHALL 提供 `/courses` 頁面,顯示從後端取得的潛水課程卡片列表,並支援搜尋與篩選。
#### Scenario: 載入課程列表
- **WHEN** 使用者訪問 `/courses`
- **THEN** 頁面呼叫 `GET /api/diving-offers` 並渲染課程卡片(含標題、地點、價格、評分、標籤)
#### Scenario: 搜尋課程
- **WHEN** 使用者在搜尋框輸入關鍵字後按 Enter 或點搜尋
- **THEN**`?q=<keyword>` 重新呼叫 API,列表更新
#### Scenario: 地區篩選
- **WHEN** 使用者從地區下拉選單選擇某地區
- **THEN**`?region=<region>` 重新呼叫 API,列表更新
#### Scenario: 無結果
- **WHEN** 搜尋/篩選後後端回傳空陣列
- **THEN** 頁面顯示「找不到符合的課程」提示訊息
---
### Requirement: 課程詳情頁
前端 SHALL 提供 `/courses/:id` 頁面,顯示單一課程的完整資訊。
#### Scenario: 載入課程詳情
- **WHEN** 使用者訪問 `/courses/1`
- **THEN** 頁面呼叫 `GET /api/diving-offers/1` 並顯示標題、地點、景點、價格、評分、評論數、描述、徽章、標籤
#### Scenario: 課程不存在
- **WHEN** 使用者訪問不存在的課程 id
- **THEN** 頁面顯示「課程不存在」並提供返回列表按鈕
---
### Requirement: 登入頁
前端 SHALL 提供 `/login` 頁面,供會員以 email/password 登入,以及 Google OAuth 登入入口。
#### Scenario: Email/Password 登入成功
- **WHEN** 使用者填入正確的 email 與 password 並送出
- **THEN** 呼叫 `POST /api/member/login`,儲存回傳的 token 至 localStorage,導航至 `/courses`
#### Scenario: 登入失敗
- **WHEN** 使用者填入錯誤的 email 或 password
- **THEN** 頁面顯示錯誤訊息,不跳轉
#### Scenario: Google OAuth 登入
- **WHEN** 使用者點擊「以 Google 登入」按鈕
- **THEN** 瀏覽器導航至後端 `GET /api/auth/google/redirect`,開始 OAuth 流程
---
### Requirement: 註冊頁
前端 SHALL 提供 `/register` 頁面,供訪客建立會員帳號。
#### Scenario: 註冊成功
- **WHEN** 使用者填入 name、email、password 並送出
- **THEN** 呼叫 `POST /api/member/register`,成功後導航至 `/login`,顯示「註冊成功,請登入」
#### Scenario: Email 已被使用
- **WHEN** 使用者填入已存在的 email 送出
- **THEN** 頁面顯示「此 Email 已被使用」錯誤訊息
---
### Requirement: 會員個人資料頁
前端 SHALL 提供 `/profile` 頁面,已登入會員可查看並更新個人資料。此頁面需登入後才能訪問。
#### Scenario: 已登入會員訪問個人資料
- **WHEN** 已登入使用者訪問 `/profile`
- **THEN** 頁面呼叫 `GET /api/member/profile` 並顯示姓名、email、生日、性別、地址、緊急聯絡人
#### Scenario: 未登入訪問個人資料
- **WHEN** 未登入使用者訪問 `/profile`
- **THEN** 自動導向 `/login`
#### Scenario: 更新個人資料成功
- **WHEN** 已登入使用者修改欄位後點擊儲存
- **THEN** 呼叫 `PUT /api/member/profile`,成功後顯示「資料已更新」提示
---
### Requirement: 認證狀態管理
前端 SHALL 使用 Pinia store 管理認證狀態,token 持久化至 localStorage,並在所有需認證的 API 請求自動附加 Bearer token。
#### Scenario: 頁面刷新後保持登入狀態
- **WHEN** 已登入使用者重新整理頁面
- **THEN** 從 localStorage 還原 token,使用者仍為登入狀態
#### Scenario: 登出
- **WHEN** 使用者點擊登出
- **THEN** 呼叫 `POST /api/member/logout`,清除 localStorage token,導向 `/login`