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:
@@ -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.js(Laravel + Vue 同 repo)→ 捨棄,因為部署彈性不足。
|
||||
|
||||
---
|
||||
|
||||
### D2:Auth 策略 — Sanctum Token(Bearer)
|
||||
|
||||
**決定**:前端登入後儲存 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 階段避免過早引入複雜的全域狀態。
|
||||
|
||||
---
|
||||
|
||||
### D5:Google 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 / spot(LIKE 模糊匹配)
|
||||
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
|
||||
|
||||
# 前端 URL(OAuth 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 模式不需要 cookie,false 即可
|
||||
```
|
||||
|
||||
> `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 API(Sanctum 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 200,body 包含 `{ 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 200,body 包含 `{ data: { id, title, location, spot, rating, reviews, price, badges, description, tag, region, created_at } }`
|
||||
|
||||
#### Scenario: 課程不存在
|
||||
- **WHEN** 客戶端發送 `GET /api/diving-offers/99999`(該 id 不存在)
|
||||
- **THEN** 回傳 HTTP 404,body 包含 `{ 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 instance,base 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 請求正常回應
|
||||
Reference in New Issue
Block a user