# Story 1.10: Portfolio Implementation
**Status:** ready-for-dev
**Epic:** Epic 1 - Webflow to Payload CMS + Astro Migration
**Priority:** P1 (High)
**Estimated Time:** 8 hours
## Story
**作為**潛在客戶,
**我想要**看到 Enchun 過去的網站設計作品,
**以便**我能夠評估他們的設計能力並決定是否合作。
## Context
這是 Sprint 1 的核心前端故事之一。Portfolio 頁面展示了 Enchun 數位的設計能力和過往項目經驗,是潛在客戶評估公司能力的重要參考。
**Story Source:**
- `docs/prd/05-epic-stories.md` - Story 1.10
- `sprint-status.yaml` - Story 1-10-portfolio
**原始 HTML 參考:**
- [Source: research/www.enchun.tw/website-portfolio.html](../../research/www.enchun.tw/website-portfolio.html) - Portfolio 列表頁 (Webflow 原始)
**Dependencies:**
- Story 1-2-collections-definition (DONE) - Portfolio collection 已完成
- Story 1-4-global-layout - Header/Footer 組件需先完成
**原 Webflow URL 對應:**
- 舊: `/webdesign-profolio` → 新: `/portfolio` (作品列表)
- 舊: `/webdesign-profolio/[slug]` → 新: `/portfolio/[slug]` (作品詳情)
## Acceptance Criteria
### Part 1: Portfolio Listing Page (`/portfolio`)
1. **AC1 - 2-Column Grid Layout**: 作品以 2 欄式網格顯示
2. **AC2 - Card Information**: 每張卡片顯示:
- 專案預覽圖 (image)
- 專案標題 (title)
- 專案描述 (description)
- 標籤 (tags - 如 "一頁式銷售", "客戶預約")
3. **AC3 - Visual Fidelity**: 視覺還原度達 95%+ (對比 Webflow)
4. **AC4 - Responsive**: 手機版單欄顯示,平板/桌面 2 欄顯示
### Part 2: Portfolio Detail Page (`/portfolio/[slug]`)
5. **AC5 - Project Display**: 顯示完整專案資訊
6. **AC6 - Live Website Link**: 連結到實際網站 (url 欄位)
7. **AC7 - Additional Images**: 顯示額外專案圖片/輪播 (如可用)
8. **AC8 - Case Study Content**: 案例研究內容展示
9. **AC9 - Back to List**: 返回列表頁的連結
### Part 3: Content Integration
10. **AC10 - CMS Integration**: 從 Payload CMS Portfolio collection 獲取資料
11. **AC11 - SEO Metadata**: 每頁都有適當的 meta tags
12. **AC12 - 301 Redirect**: 舊 URL 正確重定向到新 URL
## Dev Technical Guidance
### Architecture Overview
Portfolio 頁面採用 Astro SSR 模式,在建置時從 Payload CMS 獲取所有 portfolio items 並生成靜態頁面。
### Portfolio Collection Schema (已完成)
```typescript
// apps/backend/src/collections/Portfolio/index.ts
{
slug: 'portfolio',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text' },
{ name: 'url', type: 'text' }, // Website URL
{ name: 'image', type: 'upload', relationTo: 'media' },
{ name: 'description', type: 'textarea' },
{ name: 'websiteType', type: 'select' }, // corporate/ecommerce/landing/brand/other
{ name: 'tags', type: 'array' }, // Array of { tag: string }
]
}
```
### Task 1.10.1: Design Portfolio Architecture (1h)
**目標**: 規劃 Portfolio 頁面的組件結構和資料流
**設計考量**:
1. **組件拆分**:
- `PortfolioCard.astro` - 單一作品卡片組件
- `PortfolioGrid.astro` - 作品網格容器
- `PortfolioDetail.astro` - 作品詳情頁主組件
2. **資料獲取策略**:
- 使用 Astro 的 `getStaticPaths()` 預生成所有作品頁面
- 從 Payload CMS API 獲取完整資料
3. **URL 結構**:
- 列表頁: `/portfolio` (或保留原有 `/website-portfolio`)
- 詳情頁: `/portfolio/[slug]` (或 `/webdesign-profolio/[slug]`)
### Task 1.10.2: Implement Portfolio Listing Page (2h)
**檔案**: `apps/frontend/src/pages/portfolio.astro`
```astro
---
import Layout from '../layouts/Layout.astro'
import { Payload } from '@payload-types'
// Payload CMS API 配置
const PAYLOAD_API_URL = import.meta.env.PAYLOAD_CMS_URL || 'http://localhost:3000/api'
// 獲取所有 portfolio items
async function getPortfolios() {
try {
const res = await fetch(`${PAYLOAD_API_URL}/portfolio?depth=1&limit=100`)
if (!res.ok) throw new Error('Failed to fetch portfolios')
const data = await res.json()
return data.docs || []
} catch (error) {
console.error('Error fetching portfolios:', error)
return []
}
}
const portfolios = await getPortfolios()
// SEO 配置
const title = '網站設計作品 | 恩群數位行銷'
const description = '瀏覽恩群數位的網站設計作品集,包含企業官網、電商網站、活動頁面等專案案例。'
---
{portfolios.length === 0 && (
)}
---
// Helper functions
function getWebsiteTypeLabel(type: string): string {
const labels = {
corporate: '企業官網',
ecommerce: '電商網站',
landing: '活動頁面',
brand: '品牌網站',
other: '其他'
}
return labels[type] || type
}
---
```
### Task 1.10.3: Implement Portfolio Detail Page (2h)
**檔案**: `apps/frontend/src/pages/portfolio/[slug].astro`
```astro
---
import Layout from '../../layouts/Layout.astro'
const PAYLOAD_API_URL = import.meta.env.PAYLOAD_CMS_URL || 'http://localhost:3000/api'
// 獲取單一 portfolio item
async function getPortfolio(slug: string) {
try {
const res = await fetch(`${PAYLOAD_API_URL}/portfolio?where[slug][equals]=${slug}&depth=1`)
if (!res.ok) throw new Error('Failed to fetch portfolio')
const data = await res.json()
return data.docs?.[0] || null
} catch (error) {
console.error('Error fetching portfolio:', error)
return null
}
}
// 生成所有靜態路徑
export async function getStaticPaths() {
try {
const res = await fetch(`${PAYLOAD_API_URL}/portfolio?limit=100&depth=0`)
if (!res.ok) return []
const data = await res.json()
const portfolios = data.docs || []
return portfolios.map((item) => ({
params: { slug: item.slug },
props: { slug: item.slug }
}))
} catch (error) {
console.error('Error in getStaticPaths:', error)
return []
}
}
const { slug } = Astro.props
const portfolio = await getPortfolio(slug)
// 404 處理
if (!portfolio) {
return Astro.redirect('/404')
}
// SEO 配置
const title = `${portfolio.title} | 恩群數位網站設計作品`
const description = portfolio.description || `瀏覽 ${portfolio.title} 專案詳情`
---
← 返回作品列表
{portfolio.image?.url ? (

) : (
暫無圖片
)}
專案介紹
此專案展示了我們在 {getWebsiteTypeLabel(portfolio.websiteType)} 領域的專業能力。
專案連結
{portfolio.url ? (
前往網站 →
) : (
此專案暫無公開連結
)}
---
// Helper function
function getWebsiteTypeLabel(type: string): string {
const labels = {
corporate: '企業官網',
ecommerce: '電商網站',
landing: '活動頁面',
brand: '品牌網站',
other: '其他'
}
return labels[type] || type
}
---
```
### Task 1.10.4: Implement Portfolio Filter (Optional, 1h)
**可選功能**: 依照 websiteType 或 tags 進行篩選
```typescript
// 在 portfolio.astro 中加入篩選功能
---
const websiteTypes = [
{ value: 'all', label: '全部' },
{ value: 'corporate', label: '企業官網' },
{ value: 'ecommerce', label: '電商網站' },
{ value: 'landing', label: '活動頁面' },
{ value: 'brand', label: '品牌網站' },
{ value: 'other', label: '其他' },
]
// 這裡可以加入客戶端 JavaScript 進行篩選
// 或使用 Astro 的 actions 進行伺服器端篩選
---
```
**篩選 UI 範例**:
```html
{websiteTypes.map((type) => (
))}
```
### Task 1.10.5: Performance and Visual Testing (1h)
**Lighthouse 測試項目**:
- [ ] Performance 分數 >= 95
- [ ] Accessibility 分數 >= 95
- [ ] Best Practices 分數 >= 95
- [ ] SEO 分數 >= 95
**視覺還原測試**:
- [ ] 對比 Webflow 原站,視覺相似度 >= 95%
- [ ] 圖片尺寸和裁切方式一致
- [ ] 字體大小和行高一致
- [ ] 間距和布局一致
**響應式測試**:
- [ ] 手機版 (< 768px) 單欄顯示
- [ ] 平板版 (768px - 1024px) 2 欄顯示
- [ ] 桌面版 (> 1024px) 2 欄顯示,最大寬度限制
## File Structure
```
apps/frontend/src/
├── pages/
│ ├── portfolio.astro ← CREATE (或修改 website-portfolio.astro)
│ └── portfolio/
│ └── [slug].astro ← CREATE (或修改 webdesign-profolio/[slug].astro)
├── components/
│ ├── portfolio/
│ │ ├── PortfolioCard.astro ← CREATE (可選,用於組件化)
│ │ ├── PortfolioGrid.astro ← CREATE (可選)
│ │ └── PortfolioDetail.astro ← CREATE (可選)
│ ├── Header.astro ← EXISTS (依賴)
│ └── Footer.astro ← EXISTS (依賴)
└── styles/
└── theme.css ← EXISTS (共用樣式)
```
## Tasks / Subtasks
### Part 1: 設計與架構
- [ ] **Task 1.1**: 設計 Portfolio 頁面架構
- [ ] 確認 URL 結構 (`/portfolio` vs `/website-portfolio`)
- [ ] 規劃組件拆分策略
- [ ] 設計資料獲取流程
### Part 2: Portfolio Listing Page
- [ ] **Task 2.1**: 實作 Portfolio 列表頁
- [ ] 建立 `/portfolio.astro` (或修改現有檔案)
- [ ] 從 Payload CMS 獲取資料
- [ ] 實作 2 欄網格布局
- [ ] 加入卡片 hover 效果
- [ ] **Task 2.2**: Portfolio 卡片組件
- [ ] 顯示專案圖片
- [ ] 顯示標題、描述
- [ ] 顯示標籤和類型
- [ ] 加入連結到詳情頁
- [ ] **Task 2.3**: 響應式設計
- [ ] 手機版單欄
- [ ] 平板/桌面 2 欄
- [ ] 測試各裝置尺寸
### Part 3: Portfolio Detail Page
- [ ] **Task 3.1**: 實作 Portfolio 詳情頁
- [ ] 建立 `/portfolio/[slug].astro`
- [ ] 實作 `getStaticPaths()`
- [ ] 獲取單一專案資料
- [ ] **Task 3.2**: 詳情頁內容
- [ ] 專案標題和描述
- [ ] 主要圖片展示
- [ ] 標籤顯示
- [ ] 外部網站連結
- [ ] **Task 3.3**: 導航功能
- [ ] 返回列表連結
- [ ] 404 處理 (無效 slug)
### Part 4: 篩選功能 (可選)
- [ ] **Task 4.1**: 實作篩選器
- [ ] 按 websiteType 篩選
- [ ] 按 tags 篩選
- [ ] 客戶端狀態管理
### Part 5: 測試與優化
- [ ] **Task 5.1**: Lighthouse 測試
- [ ] Performance >= 95
- [ ] Accessibility >= 95
- [ ] SEO >= 95
- [ ] **Task 5.2**: 視覺還原測試
- [ ] 對比 Webflow 原站
- [ ] 相似度 >= 95%
- [ ] 修正視覺差異
- [ ] **Task 5.3**: SEO 設定
- [ ] Meta title 和 description
- [ ] Open Graph tags
- [ ] 結構化資料 (可選)
## Testing Requirements
### Unit Tests
```typescript
// apps/frontend/src/components/portfolio/__tests__/PortfolioCard.spec.ts
import { describe, it, expect } from 'vitest'
describe('PortfolioCard', () => {
const mockPortfolio = {
title: '測試專案',
slug: 'test-project',
description: '這是一個測試專案',
image: { url: '/test.jpg', alt: '測試圖片' },
websiteType: 'corporate',
tags: [{ tag: '企業官網' }, { tag: '響應式' }]
}
it('should render portfolio card with all required fields', () => {
// 測試卡片正確渲染所有欄位
})
it('should link to correct detail page', () => {
// 測試連結正確指向 /portfolio/[slug]
})
it('should display all tags', () => {
// 測試所有標籤正確顯示
})
})
```
### Integration Tests
```typescript
// apps/frontend/tests/e2e/portfolio.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Portfolio Pages', () => {
test('should display portfolio listing page', async ({ page }) => {
await page.goto('/portfolio')
await expect(page.locator('h1')).toContainText('網站設計作品')
await expect(page.locator('.portfolio-card')).toHaveCount(expect.any(Number))
})
test('should navigate to portfolio detail page', async ({ page }) => {
await page.goto('/portfolio')
const firstCard = page.locator('.portfolio-card').first()
await firstCard.click()
await expect(page).toHaveURL(/\/portfolio\/[^/]+$/)
})
test('should display portfolio detail with all information', async ({ page }) => {
await page.goto('/portfolio/test-project')
await expect(page.locator('.project-title')).toBeVisible()
await expect(page.locator('.project-description')).toBeVisible()
await expect(page.locator('.project-hero-image')).toBeVisible()
})
test('should return to listing from detail page', async ({ page }) => {
await page.goto('/portfolio/test-project')
await page.click('.back-link')
await expect(page).toHaveURL('/portfolio')
})
test('should handle 404 for invalid slug', async ({ page }) => {
await page.goto('/portfolio/invalid-slug')
await expect(page).toHaveURL('/404')
})
})
```
### Visual Regression Tests
```bash
# 使用 Playwright 截圖進行視覺測試
npx playwright test --project=chromium --update-snapshots
```
### Manual Testing Checklist
**Portfolio Listing Page**:
- [ ] 頁面正常載入,無 console 錯誤
- [ ] 所有 portfolio cards 正確顯示
- [ ] 圖片正確載入(使用 R2 URL)
- [ ] 標籤正確顯示
- [ ] 卡片 hover 效果正常
- [ ] 點擊卡片跳轉到正確的詳情頁
- [ ] 手機版顯示單欄
- [ ] 平板/桌面顯示 2 欄
**Portfolio Detail Page**:
- [ ] 頁面正常載入,無 console 錯誤
- [ ] 專案標題、描述正確顯示
- [ ] 主要圖片正確顯示
- [ ] 外部連結正確運作
- [ ] 返回列表連結正常運作
- [ ] 無效 slug 正確導向 404
- [ ] SEO meta tags 正確設置
## Risk Assessment
| Risk | Probability | Impact | Mitigation |
|------|------------|--------|------------|
| CMS API 無回應 | Low | Medium | 加入錯誤處理和 fallback |
| 圖片載入緩慢 | Medium | Low | 使用 loading="lazy",優化圖片尺寸 |
| 視覺還原度不足 | Medium | Medium | 對比 Webflow 原站進行像素級測試 |
| 大量作品導致頁面過長 | Low | Low | 加入分頁或虛擬滾動 (未來優化) |
| 301 redirect 遺失 | Low | Medium | 確保 redirect map 包含所有舊 URL |
## Definition of Done
- [ ] `/portfolio` 列表頁實作完成
- [ ] `/portfolio/[slug]` 詳情頁實作完成
- [ ] 從 Payload CMS 正確獲取資料
- [ ] 2 欄網格布局正確顯示
- [ ] 響應式設計通過 (手機/平板/桌面)
- [ ] 所有連結正常運作
- [ ] SEO meta tags 正確設置
- [ ] Lighthouse 分數 >= 95 (Performance, Accessibility, SEO)
- [ ] 視覺還原度 >= 95%
- [ ] 單元測試通過
- [ ] E2E 測試通過
- [ ] 無 console 錯誤或警告
- [ ] 301 redirect 配置完成
- [ ] sprint-status.yaml 更新為 "done"
## Dev Agent Record
### Agent Model Used
*To be filled by Dev Agent*
### Debug Log References
*To be filled by Dev Agent*
### Completion Notes
*To be filled by Dev Agent*
### File List
*To be filled by Dev Agent*
## Change Log
| Date | Action | Author |
|------|--------|--------|
| 2026-01-31 | Story created (ready-for-dev) | SM Agent (Bob) |