Establish baseline for project documentation including BMAD specs, PRD, and system architecture notes.
23 KiB
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.10sprint-status.yaml- Story 1-10-portfolio
原始 HTML 參考:
- Source: 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)
- AC1 - 2-Column Grid Layout: 作品以 2 欄式網格顯示
- AC2 - Card Information: 每張卡片顯示:
- 專案預覽圖 (image)
- 專案標題 (title)
- 專案描述 (description)
- 標籤 (tags - 如 "一頁式銷售", "客戶預約")
- AC3 - Visual Fidelity: 視覺還原度達 95%+ (對比 Webflow)
- AC4 - Responsive: 手機版單欄顯示,平板/桌面 2 欄顯示
Part 2: Portfolio Detail Page (/portfolio/[slug])
- AC5 - Project Display: 顯示完整專案資訊
- AC6 - Live Website Link: 連結到實際網站 (url 欄位)
- AC7 - Additional Images: 顯示額外專案圖片/輪播 (如可用)
- AC8 - Case Study Content: 案例研究內容展示
- AC9 - Back to List: 返回列表頁的連結
Part 3: Content Integration
- AC10 - CMS Integration: 從 Payload CMS Portfolio collection 獲取資料
- AC11 - SEO Metadata: 每頁都有適當的 meta tags
- AC12 - 301 Redirect: 舊 URL 正確重定向到新 URL
Dev Technical Guidance
Architecture Overview
Portfolio 頁面採用 Astro SSR 模式,在建置時從 Payload CMS 獲取所有 portfolio items 並生成靜態頁面。
Portfolio Collection Schema (已完成)
// 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 頁面的組件結構和資料流
設計考量:
-
組件拆分:
PortfolioCard.astro- 單一作品卡片組件PortfolioGrid.astro- 作品網格容器PortfolioDetail.astro- 作品詳情頁主組件
-
資料獲取策略:
- 使用 Astro 的
getStaticPaths()預生成所有作品頁面 - 從 Payload CMS API 獲取完整資料
- 使用 Astro 的
-
URL 結構:
- 列表頁:
/portfolio(或保留原有/website-portfolio) - 詳情頁:
/portfolio/[slug](或/webdesign-profolio/[slug])
- 列表頁:
Task 1.10.2: Implement Portfolio Listing Page (2h)
檔案: apps/frontend/src/pages/portfolio.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 = '瀏覽恩群數位的網站設計作品集,包含企業官網、電商網站、活動頁面等專案案例。'
---
<Layout title={title} metaDescription={description}>
<section class="portfolio-section">
<div class="container">
<header class="page-header">
<h1 class="page-title">網站設計作品</h1>
<p class="page-subtitle">精選案例,展現專業設計能力</p>
</header>
<div class="portfolio-grid">
{portfolios.map((item) => (
<a href={`/portfolio/${item.slug}`} class="portfolio-card">
<div class="card-image">
{item.image?.url ? (
<img
src={item.image.url}
alt={item.image.alt || item.title}
loading="lazy"
/>
) : (
<div class="image-placeholder">暫無圖片</div>
)}
</div>
<div class="card-content">
<h2 class="card-title">{item.title}</h2>
<p class="card-description">{item.description}</p>
<div class="card-tags">
{item.tags?.map((tagItem) => (
<span class="tag">{tagItem.tag}</span>
))}
</div>
<span class="card-type">{getWebsiteTypeLabel(item.websiteType)}</span>
</div>
</a>
))}
</div>
{portfolios.length === 0 && (
<div class="empty-state">
<p>暫無作品資料</p>
</div>
)}
</div>
</section>
</Layout>
<style>
.portfolio-section {
padding: var(--spacing-3xl) 0;
}
.container {
max-width: var(--container-max-width);
margin: 0 auto;
padding: 0 var(--container-padding);
}
.page-header {
text-align: center;
margin-bottom: var(--spacing-3xl);
}
.page-title {
font-size: 2.5rem;
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: var(--spacing-md);
}
.page-subtitle {
font-size: 1.125rem;
color: var(--color-text-muted);
}
.portfolio-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-xl);
}
.portfolio-card {
display: block;
background: var(--color-surface);
border-radius: var(--radius-lg);
overflow: hidden;
transition: all var(--transition-base);
text-decoration: none;
box-shadow: var(--shadow);
}
.portfolio-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.card-image {
aspect-ratio: 16 / 10;
overflow: hidden;
background: var(--color-gray-200);
}
.card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--transition-slow);
}
.portfolio-card:hover .card-image img {
transform: scale(1.05);
}
.image-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--color-text-muted);
}
.card-content {
padding: var(--spacing-lg);
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--spacing-sm);
}
.card-description {
color: var(--color-text-secondary);
line-height: 1.6;
margin-bottom: var(--spacing-md);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-sm);
}
.tag {
background: var(--color-primary-light);
color: white;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 500;
}
.card-type {
display: inline-block;
font-size: 0.875rem;
color: var(--color-primary);
font-weight: 500;
}
.empty-state {
text-align: center;
padding: var(--spacing-3xl);
color: var(--color-text-muted);
}
@media (max-width: 768px) {
.portfolio-grid {
grid-template-columns: 1fr;
}
.page-title {
font-size: 1.875rem;
}
}
</style>
---
// 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
---
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} 專案詳情`
---
<Layout title={title} metaDescription={description}>
<article class="portfolio-detail">
<div class="container">
<!-- 返回連結 -->
<a href="/portfolio" class="back-link">← 返回作品列表</a>
<!-- 專案標題 -->
<header class="project-header">
<div class="project-meta">
<span class="project-type">{getWebsiteTypeLabel(portfolio.websiteType)}</span>
</div>
<h1 class="project-title">{portfolio.title}</h1>
<p class="project-description">{portfolio.description}</p>
<!-- 標籤 -->
{portfolio.tags && portfolio.tags.length > 0 && (
<div class="project-tags">
{portfolio.tags.map((tagItem) => (
<span class="tag">{tagItem.tag}</span>
))}
</div>
)}
</header>
<!-- 主要圖片 -->
<div class="project-hero-image">
{portfolio.image?.url ? (
<img
src={portfolio.image.url}
alt={portfolio.image.alt || portfolio.title}
loading="eager"
/>
) : (
<div class="image-placeholder">暫無圖片</div>
)}
</div>
<!-- 案例詳情內容 -->
<div class="project-content">
<div class="content-section">
<h2>專案介紹</h2>
<p>此專案展示了我們在 {getWebsiteTypeLabel(portfolio.websiteType)} 領域的專業能力。</p>
</div>
<div class="content-section">
<h2>專案連結</h2>
{portfolio.url ? (
<a href={portfolio.url} target="_blank" rel="noopener noreferrer" class="btn-primary">
前往網站 →
</a>
) : (
<p class="text-muted">此專案暫無公開連結</p>
)}
</div>
</div>
</div>
</article>
</Layout>
<style>
.portfolio-detail {
padding: var(--spacing-3xl) 0;
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 0 var(--container-padding);
}
.back-link {
display: inline-block;
margin-bottom: var(--spacing-xl);
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
transition: color var(--transition-fast);
}
.back-link:hover {
color: var(--color-primary-hover);
}
.project-header {
margin-bottom: var(--spacing-3xl);
text-align: center;
}
.project-meta {
margin-bottom: var(--spacing-md);
}
.project-type {
display: inline-block;
background: var(--color-primary-light);
color: white;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-full);
font-size: 0.875rem;
font-weight: 500;
}
.project-title {
font-size: 2.5rem;
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: var(--spacing-md);
}
.project-description {
font-size: 1.125rem;
color: var(--color-text-secondary);
max-width: 700px;
margin: 0 auto var(--spacing-lg);
line-height: 1.7;
}
.project-tags {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--spacing-sm);
}
.project-tags .tag {
background: var(--color-surface2);
color: var(--color-text-primary);
padding: 0.375rem 0.75rem;
border-radius: var(--radius-full);
font-size: 0.875rem;
}
.project-hero-image {
margin-bottom: var(--spacing-3xl);
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: var(--shadow-lg);
}
.project-hero-image img {
width: 100%;
height: auto;
}
.image-placeholder {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 16 / 10;
background: var(--color-gray-200);
color: var(--color-text-muted);
}
.project-content {
display: grid;
gap: var(--spacing-2xl);
}
.content-section h2 {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--spacing-md);
}
.content-section p {
color: var(--color-text-secondary);
line-height: 1.7;
}
.btn-primary {
display: inline-block;
background: var(--color-primary);
color: white;
padding: 0.75rem 1.5rem;
border-radius: var(--radius-md);
text-decoration: none;
font-weight: 500;
transition: all var(--transition-fast);
}
.btn-primary:hover {
background: var(--color-primary-hover);
transform: translateY(-2px);
}
.text-muted {
color: var(--color-text-muted);
}
@media (max-width: 768px) {
.project-title {
font-size: 1.875rem;
}
.project-description {
font-size: 1rem;
}
}
</style>
---
// 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 進行篩選
// 在 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 範例:
<div class="filter-bar">
{websiteTypes.map((type) => (
<button
class={`filter-btn ${selectedType === type.value ? 'active' : ''}`}
data-type={type.value}
>
{type.label}
</button>
))}
</div>
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 結構 (
/portfoliovs/website-portfolio) - 規劃組件拆分策略
- 設計資料獲取流程
- 確認 URL 結構 (
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
// 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
// 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
# 使用 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) |