后端通过 strapi 一波操作下来就搞定了,接下来就进入到 nuxt3 前端部分的开发

创建项目并迁移布局

按文档创建项目

npx nuxi@latest init blog-by-nuxt
cd blog-by-nuxt
yarn install

创建项目后,根据文档,我们开始将布局迁移至nuxt3

<!-- app.ts -->
<template>
  <NuxtLayout> </NuxtLayout>
</template>

创建布局文件layouts/default.vue,components文件夹中的组件将会按规则自动注册。

<!-- layouts/default.vue -->
<template>
	<div>
    <!-- components/layout/Header.vue -->
		<LayoutHeader />
		<div class="container-mask"></div>
		<ClientOnly>
    	<!-- components/layout/Minecreaft.vue -->
			<LayoutMinecreaft />
		</ClientOnly>
		<div class="container-wrap">
			<main class="main-container">
				<NuxtPage />
			</main>
		</div>
    <!-- components/layout/Footer.vue -->
		<LayoutFooter />
    <!-- components/BackTop.vue -->
		<BackTop />
	</div>
</template>
<script lang="ts" setup>
</script>

引入stylus相关依赖,并迁移样式后

yarn add stylus stylus-loader -D
# 引入自己写的stylus-shortcut快捷样式
yarn add stylus-shortcut

在配置中全局引用,并配置部分meta标签

export default defineNuxtConfig({
	// ...
  app: {
    head: {
      title: "超能郭工",
      htmlAttrs: {
        lang: 'zh-CN'
      },
      meta: [
        { name: "viewport", content: "width=device-width,initial-scale=1" }
      ],
      link: [
        { rel: "apple-touch-icon", sizes: "180x180", href: "/apple-touch-icon.png" },
        { rel: "icon", type: "image/png", sizes: "32x32", href: "/favicon-32x32.png" },
        { rel: "icon", type: "image/png", sizes: "16x16", href: "/favicon-16x16.png" },
        { rel: "manifest", href: "/site.webmanifest" },
        { rel: "preconnect", href: "www.kwokronny.com" },
        { rel: "preconnect", href: "cdn.bootcdn.net" },
        { rel: "dns-prefetch", href: "www.kwokronny.com" },
        { rel: "dns-prefetch", href: "cdn.bootcdn.net" },
      ],
      script: [
        { async: true, src: "//hm.baidu.com/hm.js?1762b014e1a457fdc04e1ea7bc1026fa" }
      ]
    }
  },
  css: ["~/styles/index.styl"],
  // ...
})

以上布局的迁移工作就基本完成了。

对接后端

nuxt3Modules列表找到了@nuxtjs/strapi模块,已帮我们写好了基础的接口实例,包括 ts 的声明,非常好用。

安装文档 · Nuxt Strapi (nuxtjs.org)

yarn add -D @nuxtjs/strapi

export default defineNuxtConfig({
  // ...
  modules: [
    '@nuxtjs/strapi',
	],
  strapi: {
    url: 'http://api.example.com',
    prefix: '/api',
    version: 'v4',
  }
  // ...
})

完成以上配置后,我们登录到 strapi内容管理平台创建内容类型。

创建 Tag标签、Category分类、Post文章 三个内容类型

  • Post文章与Tag标签创建 has and belongs to many 关系

  • Post文章与Category分类创建 has many 关系

创建内容类型.png

‼️重要,完成内容类型的创建后,还需要为这个内容放开公共的权限,否则也将无法请求到数据

放开内容权限.png

创建完成后可增加一些测试数据后就可以开始前后端数据的对接了。

// types/index.d.ts

// @nuxtjs/strapi 缺失Media的声明
declare interface StrapiMedia {
	ext: string;
	formats: { thumbnail: StrapiMedia };
	hash: string;
	height: number;
	mime: string;
	name: string;
	previewUrl?: string;
	provider?: string;
	size: number;
	url: string;
	width: number
}

declare interface DataType {
	createdAt: string;
	updatedAt: string;
}

// 内容类型开启了 Draft&publish 功能将会多一个 publishedAt 属性
declare interface PublishedDataType extends DataType {
	publishedAt: string;
}

// SEO组件的数据结构,后续再细说
declare interface SEO {
	ogTitle: string;
	ogDescription: string;
	ogImage: Strapi4ResponseSingle<StrapiMedia>;
	description: string;
}

// 文章数据结构声明
declare interface Post extends PublishedDataType {
	title: string;
	cover: Strapi4ResponseSingle<StrapiMedia>;
	content: string;
	category: Strapi4ResponseSingle<Category>;
	tags: Strapi4ResponseMany<Tag>
	seo: SEO;
	top: number;
}

// 标签数据结构声明
declare interface Tag extends DataType  {
	name: string;
}

// 分类数据结构声明
declare interface Category extends PublishedDataType  {
	name: string;
}


创建composables/strapi.ts文件,给所有接口写为组合式API。

import { Strapi4RequestParams, Strapi4ResponseMany } from "@nuxtjs/strapi/dist/runtime/types"
import { Post, Category, Tag } from "~/types"

export const usePosts = async (params: Strapi4RequestParams = {}): Promise<Strapi4ResponseMany<Post>> => {
	const { find } = useStrapi<Post>()
	return await find("posts", Object.assign({
		populate: ["cover", "category"],
	}, params))
}

export const usePost = async (id: number, preview: boolean = false) => {
	const { findOne } = useStrapi<Post>()
	return await findOne("posts", id, {
		populate: ["cover", "category", "tags", "seo", "seo.ogImage"],
		publicationState: preview ? 'preview' : 'live' //预览未发布的文章
	})
}

export const useCategories = async (id: number) => {
	const { findOne } = useStrapi<Category>()
	let categories = await find("categories", {
		populate: "pId"
	})
	return categories
}

export const useTags = async () => {
	const { find } = useStrapi<Tag>()
	return await find("tags")
}

创建components/PostList.vue文件实现文章展示页

<!-- components/PostList.vue -->
<template>
	<div class="post-list row gutter-s spac-mh_gs">
    <div v-for="(item, idx) in  posts" :key="idx" class="post-item" :data-idx="idx"
      :class="props.postItemClass || `col-d-12 col-dm-8 col-6`">
      <NuxtLink :to="`/post/${item.id}`" :aria-label="item.attributes.title">
        <AspectRatio ratio="4/3">
          <img class="cover" style="object-fit: cover;" :src="mediaUrl(item.attributes.cover.data?.attributes.url)"
            :alt="`封面Cover: ${item.attributes.title}`" />
        </AspectRatio>
      </NuxtLink>
      <div class="info">
        <NuxtLink :to="`/post/${item.id}/${item.attributes.title}`">
          <strong class="title">
            {{ item.attributes.title }}
          </strong>
        </NuxtLink>
        <div class="mark">
          <span>
            <Icon name="material-symbols:tag-rounded"></Icon>
            {{ item.attributes.category.data?.attributes.name || "未分类" }}
          </span>
          <span>
            <Icon class="spac-mr_n" name="material-symbols:calendar-month-sharp"></Icon>
            {{ dateUtil.now(item.attributes.createdAt) }}
          </span>
        </div>
      </div>
    </div>
	</div>
  <!-- 数据为空提示 -->
	<InnerTip v-if="!posts.length && !loading" type="empty"></InnerTip>
	<div v-if="!props.top">
    <!-- 用于滚动加载的观察对象 -->
		<div class="load-observer" ref="LoadObserver"></div>
  	<!-- 全部加载完成提示 -->
		<InnerTip v-if="loaded && posts.length > 0" type="loaded"></InnerTip>
  	<!-- 加载中提示 -->
		<div class="text-a_c spac-mt_s5" v-if="loading">
			<Icon name="svg-spinners:pulse-rings-multiple" size="50px"></Icon>
		</div>
	</div>
</template>
<script lang="ts" setup>
import { Strapi4RequestParams, Strapi4ResponseData } from '@nuxtjs/strapi/dist/runtime/types';
import { Post } from '~/types';

interface IProp {
	params?: Strapi4RequestParams // 自定义post请求参数
	top?: boolean,
	postItemClass?: string // 自定义postItem样式

}
const props = defineProps<IProp>()

const posts = ref<Strapi4ResponseData<Post>[]>([]);
const loading = ref(false);
const loaded = ref(false);
const page = ref(1);
const LoadObserver = ref(null);

useAsyncData(async () => {
	await loadNextPage();
})

onMounted(() => {
	if (LoadObserver.value) {
    // 观察者实现滚动加载
		const observer = new IntersectionObserver((entries) => !loaded.value && !loading.value && entries[0].isIntersecting && loadNextPage(), {
			rootMargin: '0px',
			threshold: 1.0
		})
		observer.observe(LoadObserver.value)
	}
})

async function loadNextPage() {
	if (loading.value || loaded.value) return;
	loading.value = true;
	let response = await usePosts(Object.assign(props.top ? {
		sort: ["top:desc", "createdAt:desc"],
		pagination: {
			limit: 4,
			start: 0
		}
	} : {
		sort: ["top:desc", "createdAt:desc"],
		pagination: {
			page: page.value,
			pageSize: 12,
		}
	}, props.params) || {})
	loading.value = false;
	if (!response.data.length) {
		loaded.value = true;
		return false;
	}
	posts.value = posts.value.concat(response.data)
	page.value += 1;
}
</script>

创建pages/index.vue 实现首页

<template>
	<div class="index-page">
		<h2>最新文章 Latest Articles</h2>
		<div class="spac-mb_s2">
			<NuxtLink class="link-btn spac-mr_s1 spac-mb_s1" :to="`/category/${item.id}`" v-for="item in categories?.data"
				:key="item.id">
				<Icon name="material-symbols:tag-rounded" size="14px"></Icon>{{ item.attributes.name }}
			</NuxtLink>
		</div>
		<PostList />
	</div>
</template>
<script lang="ts" setup>
const  categories = await useCategories()
</script>

以上就基本完成了关于项目的配置与布局的迁移啦,搞完收工

2f26b8182835fcff8812da50683fdcd0.gif