# 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} 專案詳情` ---
← 返回作品列表
{getWebsiteTypeLabel(portfolio.websiteType)}

{portfolio.title}

{portfolio.description}

{portfolio.tags && portfolio.tags.length > 0 && (
{portfolio.tags.map((tagItem) => ( {tagItem.tag} ))}
)}
{portfolio.image?.url ? ( {portfolio.image.alt ) : (
暫無圖片
)}

專案介紹

此專案展示了我們在 {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) |