src/data/
準備 skills / projects 資料ref/readonly
管理資料,v-for
產生清單v-model
+ computed
做「分類 + 關鍵字」過濾:key
的重要性(效能與狀態穩定)src/data/
)src/data/skills.ts
export type SkillCategory = 'frontend' | 'backend' | 'tools'
export interface Skill { name: string; category: SkillCategory }
export const skills: Skill[] = [
{ name: 'HTML / CSS / SCSS', category: 'frontend' },
{ name: 'TypeScript', category: 'frontend' },
{ name: 'Vue / Angular / React', category: 'frontend' },
{ name: 'Node.js / Express', category: 'backend' },
{ name: 'Git / GitHub / Docker', category: 'tools' },
{ name: 'Vite / Webpack', category: 'tools' },
]
src/data/projects.ts
export interface Project {
id: number
slug: string
title: string
tech: string
desc: string
tags: string[]
images: string[]
demo: string
repo: string
featured: boolean
}
export const projects: Project[] = [
{
id: 1,
slug: 'maomao-shop',
title: '毛毛購物(寵物電商)',
tech: 'Vue + Node.js|購物車、結帳、RWD',
desc: '主導前端架構,完成商品列表、購物流程與訂單頁。',
tags: ['vue', 'node', 'rwd', 'ecommerce'],
images: ['assets/projects/maomao-1.png'],
demo: '#',
repo: '#',
featured: true
},
{
id: 2,
slug: 'line-bot-reservation',
title: 'LINE Bot 預約系統',
tech: 'Cloud Functions + LINE API|時段預約',
desc: '整合 LINE 聊天介面與雲端排程,完成會員預約流程。',
tags: ['line', 'gcp', 'serverless'],
images: ['assets/projects/line-1.png'],
demo: '#',
repo: '#',
featured: false
}
]
把昨天的靜態 Skills 改成資料驅動。
<!-- src/components/Skills.vue -->
<template>
<section id="skills" class="container section" aria-labelledby="skills-title">
<div class="section-header">
<h2 id="skills-title">技能 Skillset</h2>
<!-- 分類 -->
<div role="tablist" aria-label="技能分類" class="filters">
<button class="chip" role="tab"
:aria-selected="category==='all'"
@click="category='all'">全部</button>
<button class="chip" role="tab"
:aria-selected="category==='frontend'"
@click="category='frontend'">前端</button>
<button class="chip" role="tab"
:aria-selected="category==='backend'"
@click="category='backend'">後端</button>
<button class="chip" role="tab"
:aria-selected="category==='tools'"
@click="category='tools'">工具</button>
</div>
</div>
<!-- 關鍵字搜尋 -->
<div class="field" style="margin:12px 0;">
<label for="skill-search">關鍵字搜尋</label>
<input id="skill-search" type="text" v-model.trim="keyword" placeholder="例如:Vue、Docker…" />
<small class="muted">結果:{{ filtered.length }} 項</small>
</div>
<!-- 清單:用 v-for 渲染;記得 :key -->
<ul class="skill-grid">
<li v-for="s in filtered" :key="s.name">
{{ s.name }}
</li>
</ul>
</section>
</template>
<script setup lang="ts">
import { ref, computed, readonly } from 'vue'
import { skills as seed, type Skill } from '@/data/skills'
type Cat = 'all' | 'frontend' | 'backend' | 'tools'
// 固定資料用 readonly 包起來避免無意修改
const all = readonly<Skill[]>(seed)
const category = ref<Cat>('all')
const keyword = ref('')
const filtered = computed(() => {
const kw = keyword.value.toLowerCase()
return all.filter(s => {
const byCat = category.value === 'all' || s.category === category.value
const byKw = !kw || s.name.toLowerCase().includes(kw)
return byCat && byKw
})
})
</script>
小提醒
v-model.trim
自帶去空白。:key="s.name"
讓 Vue 能穩定追蹤項目(效能&避免重渲染出錯)。- 之後要做 debounce 或把狀態抽到 Pinia 再進階。
v-for
渲染卡片<!-- src/components/Projects.vue -->
<template>
<section id="projects" class="container section" aria-labelledby="projects-title">
<h2 id="projects-title">作品集 Projects</h2>
<!-- 精選切換(可選) -->
<div class="field" style="margin:12px 0;">
<label><input type="checkbox" v-model="onlyFeatured" /> 只看精選</label>
</div>
<div class="project-grid">
<article class="card" v-for="p in view" :key="p.id">
<h3>{{ p.title }}</h3>
<p class="muted">{{ p.tech }}</p>
<p>{{ p.desc }}</p>
<div class="actions" style="display:flex; gap:8px; margin-top:8px;">
<a class="btn small" :href="p.demo" target="_blank" rel="noopener">Live Demo</a>
<a class="btn small btn-outline" :href="p.repo" target="_blank" rel="noopener">GitHub</a>
</div>
</article>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, computed, readonly } from 'vue'
import { projects as seed, type Project } from '@/data/projects'
const all = readonly<Project[]>(seed)
const onlyFeatured = ref(false)
const view = computed(() => onlyFeatured.value
? all.filter(p => p.featured)
: all
)
</script>
後續 Day 23 會導入 vue-router,把每張卡片的「查看詳情」做成 /projects/:slug,並拆出 ProjectDetail.vue。
v-model
+ computed
)v-for
渲染、穩定 :key
:key
<li v-for="s in filtered">
(可能導致狀態錯亂)<li v-for="s in filtered" :key="s.name">
computed
裡改 state
computed(() => { keyword.value = ''; return list })
computed
只能「算資料」,不要「改資料」readonly
的陣列
all.push(...)
readonly
是防呆;要改資料請在來源(service / store)處理v-for="s in skills.filter(...)"
每次渲染都跑computed
(可快取)/
、作品詳情 /projects/:slug
<RouterLink>
導向詳情頁:slug
,顯示單一專案內容(先從本地 projects.ts
查)