docs: separate documentation and specs into initial commit
Establish baseline for project documentation including BMAD specs, PRD, and system architecture notes.
This commit is contained in:
882
_bmad-output/implementation-artifacts/1-10-portfolio.story.md
Normal file
882
_bmad-output/implementation-artifacts/1-10-portfolio.story.md
Normal file
@@ -0,0 +1,882 @@
|
||||
# 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) |
|
||||
Reference in New Issue
Block a user