iT邦幫忙

2023 iThome 鐵人賽

DAY 25
0
影片教學

Nuxt 3 快速入門系列 第 25

[影片教學] Nuxt 3 Server API 權限判斷與串接 - 實戰部落格系列 Part 3

  • 分享至 

  • xImage
  •  

Yes

👆建議你可以使用影片子母畫面功能或全螢幕播放來獲得最佳的觀賞體驗,👇下方是本篇教學的相關筆記。


建立伺服器中間件

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

建立文章 API 添加權限判斷

調整 ./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
})

刪除文章 API 添加權限判斷

調整 ./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 的夥伴。

範例程式碼


上一篇
[影片教學] Nuxt 3 建立部落格文章相關 API - 實戰部落格系列 Part 2
下一篇
[影片教學] Nuxt 3 路由頁面的權限判斷 - 實戰部落格系列 Part 4
系列文
Nuxt 3 快速入門30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言