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:
2026-02-11 11:49:49 +08:00
parent 8c87d71aa2
commit e9897388dc
34 changed files with 11920 additions and 8777 deletions

View 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) |