Establish baseline for project documentation including BMAD specs, PRD, and system architecture notes.
883 lines
23 KiB
Markdown
883 lines
23 KiB
Markdown
# 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 = '瀏覽恩群數位的網站設計作品集,包含企業官網、電商網站、活動頁面等專案案例。'
|
||
---
|
||
|
||
<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`
|
||
|
||
```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 進行篩選
|
||
|
||
```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
|
||
<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 結構 (`/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) |
|