👆建議你可以使用影片子母畫面功能或全螢幕播放來獲得最佳的觀賞體驗,👇下方是本篇教學的相關筆記。
在 server/middleware 目錄下建立一個檔案 ./server/middleware/auth.js:
import jwt from 'jsonwebtoken'
const urls = [
{
path: '/api/articles',
method: 'POST'
},
{
path: /^\/api\/articles\/(.*)($|\?.*|#.*)/,
method: 'DELETE'
}
]
export default defineEventHandler((event) => {
const requireVerify = urls.some((apiUrl) => {
if (event.method === apiUrl.method) {
if (apiUrl.path instanceof RegExp) {
return apiUrl.path.test(event.path)
}
return event.path === apiUrl.path
}
return false
})
if (!requireVerify) {
return
}
const jwtToken = getCookie(event, 'access_token')
if (jwtToken) {
try {
const { data: user } = jwt.verify(jwtToken, 'JWT_SIGN_SECRET_PLEASE_REPLACE_WITH_YOUR_KEY')
event.context.auth = {
user
}
} catch (error) {
console.error(error)
}
}
})
調整 ./server/api/articles.post.js 檔案:
import { pool } from '@/server/utils/db'
export default defineEventHandler(async (event) => {
if (event.context?.auth?.user?.id !== 1) {
throw createError({
statusCode: 401,
message: '沒有權限'
})
}
const body = await readBody(event)
const articleRecord = await pool
.query('INSERT INTO "article" ("title", "content", "cover") VALUES ($1, $2, $3) RETURNING *;', [
body.title,
body.content,
body.cover
])
.then((result) => {
if (result.rowCount === 1) {
return result.rows?.[0]
}
})
.catch((error) => {
console.error(error)
throw createError({
statusCode: 500,
message: '無法建立文章,請稍候再試'
})
})
if (!articleRecord) {
throw createError({
statusCode: 400,
message: '建立文章失敗,請稍候再試'
})
}
return articleRecord
})
調整 ./server/api/articles/[id].delete.js 檔案:
import { pool } from '@/server/utils/db'
export default defineEventHandler(async (event) => {
if (event.context?.auth?.user?.id !== 1) {
throw createError({
statusCode: 401,
message: '沒有權限'
})
}
const articleId = getRouterParam(event, 'id')
const result = await pool
.query('DELETE FROM "article" WHERE "id" = $1;', [articleId])
.catch((error) => {
console.error(error)
throw createError({
statusCode: 500,
message: '無法刪除文章,請稍候再試'
})
})
if (result.rowCount !== 1) {
throw createError({
statusCode: 400,
message: '刪除文章失敗,請稍候再試'
})
}
return {
message: '刪除文章成功'
}
})
在 pages/articles 目錄下建立一個檔案 ./pages/articles/[id].vue:
<template>
<div class="flex w-full justify-center px-6 lg:px-0">
<div v-if="pending">
<Icon class="h-6 w-6 text-gray-500" name="eos-icons:loading" />
</div>
<template v-else>
<div v-if="error" class="my-4">
<span class="text-gray-500">發生了一點錯誤,請稍後再嘗試</span>
<p class="my-2 text-rose-500">{{ error }}</p>
</div>
<div v-else-if="article" class="mb-8 flex w-full flex-col justify-center md:max-w-3xl">
<div class="mt-4 flex justify-center">
<img :src="article.cover" />
</div>
<time class="my-2 text-sm text-gray-400">
{{ new Date(article.updated_at).toLocaleString() }}
</time>
<h1 class="break-words text-4xl font-semibold text-gray-700">{{ article.title }}</h1>
<div class="mt-6 break-words">
{{ article.content }}
</div>
</div>
</template>
</div>
</template>
<script setup>
const route = useRoute()
const { data: article, error } = await useFetch(`/api/articles/${route.params.id}`)
</script>
在 pages/articles 目錄下建立一個檔案 ./pages/articles/create.vue:
<template>
<div class="flex w-full justify-center px-6 lg:px-0">
<div class="mb-8 flex w-full max-w-3xl justify-center">
<form class="w-full space-y-8 divide-y divide-gray-200" @submit.prevent="handleSubmit">
<div class="space-y-8 divide-y divide-gray-200">
<div>
<div class="mt-6">
<h3 class="text-xl font-medium leading-6 text-gray-900">新增文章</h3>
</div>
<div class="mt-6 grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-6">
<div class="col-span-12">
<label for="title" class="block text-sm font-medium text-gray-700">
文章標題
</label>
<div class="mt-1">
<input
id="title"
v-model="articleData.title"
placeholder="請撰輸入文章標題"
name="title"
type="text"
autocomplete="title"
class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-emerald-500 sm:text-sm"
/>
</div>
</div>
<div class="col-span-12">
<label for="cover" class="block text-sm font-medium text-gray-700">
代表性圖片連結
</label>
<div class="mt-1">
<input
id="cover"
v-model="articleData.cover"
placeholder="請撰輸入網址連結"
name="cover"
type="text"
autocomplete="cover"
class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-emerald-500 sm:text-sm"
/>
</div>
</div>
<div class="col-span-12">
<label for="about" class="block text-sm font-medium text-gray-700">文章內容</label>
<div class="mt-1">
<textarea
id="content"
v-model="articleData.content"
name="content"
rows="4"
placeholder="請撰寫文章內容..."
class="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-emerald-500 sm:text-sm"
/>
</div>
</div>
</div>
</div>
</div>
<div class="pt-5">
<div class="flex justify-end">
<NuxtLink
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
to="/"
>
取消
</NuxtLink>
<button
type="submit"
class="ml-3 inline-flex justify-center rounded-md border border-transparent bg-emerald-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
發布
</button>
</div>
</div>
</form>
</div>
</div>
</template>
<script setup>
const articleData = reactive({
title: '',
content: '',
cover: ''
})
const handleSubmit = async () => {
await $fetch('/api/articles', {
method: 'POST',
body: {
title: articleData.title,
content: articleData.content,
cover: articleData.cover
}
})
.then((response) => {
navigateTo({
name: 'articles-id',
params: {
id: response.id
}
})
})
.catch((error) => alert(error.value))
}
</script>
調整 ./components/LayoutHeader.vue 檔案,在使用者頭像選單的撰寫文章選項添加路由連結:
<NuxtLink
class="flex w-full items-center rounded-md px-2 py-2 text-sm group-hover/menu-item:bg-emerald-500 group-hover/menu-item:text-white"
to="/articles/create"
>
<Icon
class="mr-2 h-5 w-5 text-emerald-400 group-hover/menu-item:text-white"
name="ri:pencil-line"
/>
撰寫文章
</NuxtLink>
調整 ./pages/index.vue 檔案:
<template>
<div class="flex w-full flex-col items-center">
<div class="item-center mt-8 flex w-full max-w-4xl flex-col px-6 md:items-start lg:px-0">
<h1 class="text-3xl font-semibold text-gray-800">最新文章</h1>
</div>
<div class="my-8 flex w-full max-w-4xl flex-col">
<div v-if="pending">
<Icon class="h-6 w-6 text-gray-500" name="eos-icons:loading" />
</div>
<template v-else>
<div v-if="error">
<span class="text-gray-500">發生了一點錯誤,請稍後再嘗試</span>
<p class="my-2 text-rose-500">{{ error }}</p>
</div>
<div v-else-if="!articlesResponse || articlesResponse.items.length === 0">
<span class="text-gray-500">目前尚無最新文章</span>
</div>
<div v-else class="md:border-l md:border-gray-100">
<div class="flex flex-col space-y-4 md:pl-6">
<article
v-for="article in articlesResponse.items"
:key="article.id"
class="md:grid md:grid-cols-4 md:items-baseline"
>
<NuxtLink
class="group mx-4 flex cursor-pointer flex-col items-start px-4 py-6 transition hover:bg-gray-50 sm:rounded-2xl md:col-span-3 md:mx-0"
:to="{
name: 'articles-id',
params: {
id: article.id
}
}"
>
<h2 class="text-base font-semibold tracking-tight text-gray-700">
<span class="">{{ article.title }}</span>
</h2>
<time class="order-first mb-3 flex items-center text-sm text-gray-400 md:hidden">
{{ date2LocaleString(article.updated_at) }}
</time>
<p class="mt-2 text-sm text-gray-500">
{{ article.content.replace(/\n/g, ' ').substring(0, 300) }}
</p>
<span
aria-hidden="true"
class="mt-4 flex items-center text-sm font-medium text-emerald-500"
>
繼續閱讀
<Icon name="ri:arrow-right-s-line" />
</span>
</NuxtLink>
<time class="order-first mb-3 mt-1 hidden items-center text-sm text-gray-400 md:flex">
{{ date2LocaleString(article.updated_at) }}
</time>
</article>
</div>
</div>
</template>
<nav
v-if="articlesResponse"
class="mt-12 flex items-center justify-between px-4 py-3 sm:px-6"
>
<div class="flex flex-1 justify-center sm:justify-end">
<NuxtLink
v-if="currentPage > 1"
class="flex items-center text-xl font-medium text-gray-600 hover:text-emerald-500"
:to="{
name: 'index',
query: {
page: currentPage - 1
}
}"
>
<Icon name="ri:arrow-left-s-line" />
</NuxtLink>
<label class="mx-2 text-sm text-gray-600">第 {{ articlesResponse.page }} 頁</label>
<NuxtLink
class="flex items-center text-xl font-medium text-gray-600 hover:text-emerald-500"
:to="{
name: 'index',
query: {
page: currentPage + 1
}
}"
>
<Icon name="ri:arrow-right-s-line" />
</NuxtLink>
</div>
</nav>
</div>
</div>
</template>
<script setup>
const route = useRoute()
const currentPage = computed(() => parseInt(route?.query?.page) || 1)
const {
pending,
data: articlesResponse,
error
} = await useFetch('/api/articles', {
query: {
page: currentPage,
pageSize: 10
}
})
const date2LocaleString = (date) => {
return new Date(date).toLocaleString()
}
</script>
感謝大家的閱讀,歡迎大家給予建議與討論,也請各位大大鞭小力一些:)
如果對這個 Nuxt 3 系列感興趣,可以訂閱
接收通知,也歡迎分享給喜歡或正在學習 Nuxt 3 的夥伴。
範例程式碼