fetch
(或 axios)載入 projects.json
(可替代為 API)loading / data / error
。畫面要能清楚表達這三種情況。useProjects()
。/public/projects.json
(或放 CDN / 你的 API):
[
{
"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}
]
放在 public/ 好處是以 相對路徑 fetch('/projects.json') 即可;部署時也能直接隨站上傳。
useProjects()
src/composables/useProjects.ts
import { ref, computed } from 'vue'
export type Project = {
id: number
slug: string
title: string
tech: string
desc: string
tags: string[]
images: string[]
demo: string
repo: string
featured: boolean
}
const cache = ref<Project[] | null>(null) // 簡易快取
const loaded = ref(false)
const loading = ref(false)
const error = ref<Error | null>(null)
const lastFetchedAt = ref<number | null>(null)
export function useProjects() {
async function fetchAll(force = false) {
if (cache.value && !force) return cache.value
loading.value = true
error.value = null
try {
const res = await fetch('/projects.json', { cache: 'no-cache' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = (await res.json()) as Project[]
cache.value = data
loaded.value = true
lastFetchedAt.value = Date.now()
return data
} catch (err: any) {
error.value = err
cache.value = null
throw err
} finally {
loading.value = false
}
}
function getBySlug(slug: string) {
return computed(() => cache.value?.find(p => p.slug === slug))
}
const stats = computed(() => ({
count: cache.value?.length ?? 0,
lastFetchedAt: lastFetchedAt.value
}))
return {
// state
projects: cache,
loaded, loading, error, stats,
// actions
fetchAll, getBySlug
}
}
之後要改成打真 API,只要把 fetch('/projects.json') 改成你的後端 URL 即可;如需權限/錯誤處理,在這層集中處理。
Projects.vue
)改用非同步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>
<input
type="text"
v-model.trim="keyword"
@input="onKeywordInput"
placeholder="搜尋關鍵字(e.g. Vue, Node)"
style="margin-left:12px; max-width:260px;"
/>
<small class="muted" style="margin-left:8px;"
>結果:{{ view.length }} / {{ stats.count }}</small>
</div>
<p v-if="loading">載入中...</p>
<p v-else-if="error" class="error">載入失敗,請稍後再試。</p>
<div v-else 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 style="display:flex; gap:8px; margin-top:8px;">
<RouterLink class="btn small" :to="{ name:'project-detail', params:{ slug: p.slug } }">
查看詳情
</RouterLink>
<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 { onMounted, ref, computed } from 'vue'
import { useProjects } from '@/composables/useProjects'
const { projects, fetchAll, loading, error, stats } = useProjects()
// UI 狀態
const onlyFeatured = ref(false)
const keyword = ref('')
// 簡易 debounce(300ms)
let t: number | undefined
function onKeywordInput() {
window.clearTimeout(t)
t = window.setTimeout(() => {
kw.value = keyword.value.toLowerCase()
}, 300)
}
const kw = ref('')
// 導出給 template 的 view
const view = computed(() => {
const list = projects.value ?? []
return list.filter(p => {
const f1 = !onlyFeatured.value || p.featured
const f2 = !kw.value || p.title.toLowerCase().includes(kw.value) || p.tech.toLowerCase().includes(kw.value) || p.tags.some(t => t.toLowerCase().includes(kw.value))
return f1 && f2
})
})
onMounted(() => { fetchAll().catch(() => {}) })
</script>
src/views/ProjectDetail.vue
<template>
<section class="container section">
<nav style="margin-bottom:12px;">
<RouterLink to="/" class="btn btn-outline">← 返回列表</RouterLink>
</nav>
<p v-if="loading">載入中...</p>
<p v-else-if="error" class="error">讀取失敗,請返回列表。</p>
<template v-else>
<section v-if="project">
<h2>{{ project.title }}</h2>
<p class="muted">{{ project.tech }}</p>
<div class="gallery" v-if="project.images?.length" style="display:flex; gap:12px; flex-wrap:wrap; margin:12px 0;">
<img v-for="src in project.images" :key="src" :src="src" alt="專案截圖" width="360" />
</div>
<p>{{ project.desc }}</p>
<div style="display:flex; gap:8px; margin-top:8px;">
<a class="btn" :href="project.demo" target="_blank" rel="noopener">Live Demo</a>
<a class="btn btn-outline" :href="project.repo" target="_blank" rel="noopener">GitHub</a>
</div>
</section>
<section v-else>
<h2>找不到這個專案</h2>
<p class="muted">請回到列表,或確認網址是否正確。</p>
<RouterLink to="/" class="btn">返回列表</RouterLink>
</section>
</template>
</section>
</template>
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useProjects } from '@/composables/useProjects'
const route = useRoute()
const slug = computed(() => String(route.params.slug || ''))
const { fetchAll, getBySlug, loading, error } = useProjects()
const projectRef = getBySlug(slug.value)
const project = computed(() => projectRef.value)
onMounted(async () => {
// 若還沒載過,就先抓一次
if (!project.value) {
try { await fetchAll() } catch {}
}
})
</script>
如果你偏好 axios(方便設 header / 攔截器 / 重試):
npm i axios
src/lib/http.ts
import axios from 'axios'
export const http = axios.create({
baseURL: '/', // 你的 API base
timeout: 8000
})
http.interceptors.response.use(
res => res,
err => {
// 可在這裡統一處理 401/500、上報、顯示錯誤 toast
return Promise.reject(err)
}
)
在 useProjects()
中改:
// const res = await fetch('/projects.json')
const { data } = await http.get<Project[]>('/projects.json')
cache.value = data
/
首次進入會看到「載入中…」,之後顯示卡片清單/projects/:slug
,若未載入會自動抓;找不到 slug 顯示提示fetch
await fetch()
失敗 → 畫面空白computed(debounce(...))
容易踩怪