(从零到一)快速搭建博客网站:使用Next.js、Strapi和Shadcn-UI构建CMS管理后台和SEO友好的前端界面
开发背景:
最近在群里看到有人说如何快速开发一个博客网站,那我们先拆解一下开发需求。
- 博客的管理就是需要个CMS的管理后台。
- 展示就是需要一个对SEO友好的界面。
框架选择
SEO友好的前端框架-NextJS
CMS管理后台-Strapi(Open source Node.js Headless CMS)
最近很火的UI集合-shadcn-ui
家喻户晓的CSS框架-Tailwind CSS
包管理工具-pnpm
保姆级开发步骤
创建项目文件夹,创建workspace环境
mkdir blog-project
# 创建目录 /Users/luke/Desktop/course/blog-project
cd blog-project
pnpm init
打开自己习惯用的IDE,执行命令 code . 或者 webstorm .
创建创建文件夹apps和文件pnpm-workspace.yaml
packages:
- "apps/*"
在apps的目录执行命令创建NextJS的web项目
cd web
pnpm run dev
# 打开链接http://127.0.0.1:3000/,这个时候就可以打开我们启动的页面了
添加CMS管理后台
切换到apps的目录执行安装strapi命令,演示作用我就没展示mysql的链接了,大家有兴趣我可以再下一个文章去写一下,或者去strapi官网看一下如何使用别的数据。
pnpx create-strapi-app@latest cms --quickstart --ts
进入apps/cms的目录,拷贝一下src/admin/app.example.tsx文件为app.tsx,然后再配置那里把中文简体的配置打开注释,你的文件就像下面一样
把cms的develop命令改成dev,然后启动看一下,pnpm run dev
这个时候我们会看到启动报错,遇到困难别怕,看一下报错提示,File '@strapi/typescript-utils/tsconfigs/server' not found.很简单,自己在安装一下这个包就行了
你可以在项目根目录执行pnpm install --filter也可以在cms目录直接执行pnpm install.
我是在根目录执行 pnpm install @strapi/typescript-utils --filter -D
重新启动后你还是会发现一个报错,因为typescript的报错。没有找到node模块
还需要安装一下pnpm install @types/node --filter -D
这个时候重新启动一下,我们就会成功进到一个注册的超级管理员的页面,我们根据提示填写自己的账号密码就可以了。
登录成功后我们会进到这里的一个管理后台,至此我们的工作已经完成一半了。
简单对cms的后端面板写一下解释,大家可以看这个图,后面会有别的机会给大家讲解的
点击市场找到CKeditor5插件,我们现在来安装一下,执行以下命令
pnpm install @_sh/strapi-plugin-ckeditor -S --filter=cms
# 然后重新启动一下
进去后我们点击Content-type Builder这个左侧导航,然后点击COLLECTION TYPES下面那个创建,我们来创建文章实体
创建以下基本字段title,desc,cover,content。由于strapi可以用草稿发布模式,我们文章就使用这个模式,你点击创建实体的时候会有让你选择的,默认是选择上的。新增完之后会重启服务,帮我们创建好实体
我们继续创建标签实体,定义这个实体跟我们的文章是多对多关系,下面我们先创建标签实体,这个我们不需要用发布模式,然后只需要一个短文本的name字段。
后面我们去创建内容,然后把这两个实体关联,我们可以随便创建一点内容,点击内容管理器
创建完之后我们需要做下一步,把它们关联起来,关系如何
文章可以有多个标签,标签也属于多个文章,我们得出个关系,就是多对多。
好了我们去添加关系,这个时候添加完之后还是会重启服务。我们点击Contenty-type builder 去给article添加一个新的字段。也就是引用字段,添加完之后去article添加一下标签
这个时候我们最简单的博客管理后端已经做好了。我们现在需要把这个服务变成api接口访问就行。这对于strapi来说也是超级简单的。下面我们来设置api访问。
添加api访问
strapi是一个集成api访问和后台管理的headLess CMS开源框架。我们只需要配置一下实体的权限就可以实现api访问控制,默认情况下strapi的接口入口是/api开始,我们刚才创建了article的实体(要加复数),那么我们可以访问http://127.0.0.1:1337/api/arciels,第一次访问的时候会返回403,这个时候是 因为我们没打开我们的公共访问。
我们现在去打开公共访问
再次看一下我们的接口请求
到这里strapi搭建的cms管理后台已经接近完成,我们可以整合前端项目去做我们的前端展示了。
整合项目
现在我们两个项目都是用typescript,我们需要使用paths去定义一下项目的引用别名,以备之后会用上
我们在项目的根目录创建tsconfig.json
{
"compilerOptions": {
"baseUrl": "./",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"paths":{
"web/*":["./apps/web/*"],
"cms/*":["./apps/cms/*"]
}
}
}
然后我们可以把nextjs的项目的tsconfig.json文件修改一下
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
现在我们内容已经有了,我们可以去做前端网站的开发了,上面我们说到我们在nextjs中需要引入shadcn/ui,这个是最近势头很猛的一个组件集合。它不称自己为组件库,而是叫集合。全部的代码开源,也可以直接拷贝进去进行使用。现在我们就去我们的next14那里去集成一下这个ui。
首先我们去到web目录里执行命令
pnpx shadcn-ui@latest init
按上图所示先选择类型,然后我们引入一下button组件试一下,我们使用pmpm dlx 命令可以在web的项目目录下载button组件到web/src/components
然后修改一下app/page.tsx文件,我们直接使用button组件看看
import {Button} from "web/src/components/ui/button";
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<Button>测试一波</Button>
</main>
)
}
看到这个页面证明我们的shadcn/ui组件已经引入成功,现在我们尝试一下修改主题,打开官网,点击theme这个选项卡。这里我就不去操作了,直接贴一个主题吧,把下面的代码拷贝到app/globals.css文件上
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 346.8 77.2% 49.8%;
--radius: 0.3rem;
}
.dark {
--background: 20 14.3% 4.1%;
--foreground: 0 0% 95%;
--card: 24 9.8% 10%;
--card-foreground: 0 0% 95%;
--popover: 0 0% 9%;
--popover-foreground: 0 0% 95%;
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 15%;
--muted-foreground: 240 5% 64.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 85.7% 97.3%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 346.8 77.2% 49.8%;
}
}
我们这个时候看到页面的按钮变成红色,则意味着主题已经应用上了,下面我们可以开发页面了
博客默认布局以及暗黑模式开发
代补匆页面
我们先把真个页面布局弄出来。然后第一步我们完成头部导航。我们第一步需要安装next-theme,然后创建layout组件,创建header组件,创建暗黑模式切换组件(modeToogle),切换组件我们使用点击切换,所以需要使用到了shadcn/ui 里面的dropdown-menu组件。
# 切换到web目录,首先添加next-theme包
pnpm install next-themes -S
# 然后添加shadcn/ui 的 dropdown-menu
pnpm dlx shadcn-ui@latest add dropdown-menu
// web/src/components/layout/default.tsx
import React, { CSSProperties, PropsWithChildren } from 'react';
import { clsx } from 'clsx';
import { Header } from 'web/src/components/ui/header';
export interface IDefaultLayoutProps extends PropsWithChildren {
className?: string;
headerPosition?: CSSProperties['position'];
}
export const DefaultLayout: React.FC<IDefaultLayoutProps> = ({
headerPosition = 'relative',
className,
children,
}) => {
return (
<div
className={clsx(
'full flex flex-col min-h-screen relative',
className,
'default-layout',
)}
>
<div style={{ position: headerPosition }} className="z-10 w-full">
<Header></Header>
</div>
<main className="flex flex-col flex-1 container z-20 min-h-0 min-w-0 basis-auto w-full h-full">
{children}
</main>
<footer className="flex-shrink-0 z-10 ">
<div className="container flex justify-center items-center py-4 text-base">
footer
</div>
</footer>
</div>
);
};
// web/src/components/ui/header.tsx
'use client';
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from 'web/src/lib/utils';
import Link from 'next/link';
import { ModeToggle } from 'web/src/components/ui/modeToggle';
const headerVariants = cva('flex w-full', {
variants: {
variant: {
default: 'text-2xl',
},
},
defaultVariants: {
variant: 'default',
},
});
export interface HeaderProps
extends React.BaseHTMLAttributes<HTMLDivElement>,
VariantProps<typeof headerVariants> {}
const Header: React.FC<HeaderProps> = ({ className, variant, ...props }) => {
return (
<div className={cn(headerVariants({ variant, className }))} {...props}>
<div className="container mx-auto h-14 flex items-center justify-between">
<div>
My First <span className="text-primary dark:text-pink-300">BLOG</span>
</div>
<div className="flex text-base gap-6 items-center">
<Link className="text-primary hover:text-primary/90" href="/">
首页
</Link>
<Link className="text-primary hover:text-primary/90" href="/posts">
博客
</Link>
<ModeToggle />
</div>
</div>
</div>
);
};
export { Header, headerVariants };
'use client';
import * as React from 'react';
import { MoonIcon, SunIcon } from '@radix-ui/react-icons';
import { useTheme } from 'next-themes';
import { Button } from 'web/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'web/components/ui/dropdown-menu';
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
在src/app/layout.tsx上添加我们的layout
点击主题切换的时候我们会发现没有作用,这个时候我们要往layout.tsx添加一个theme-provier
// web/src/components/provider/theme-provider.tsx
'use client';
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ThemeProviderProps } from 'next-themes/dist/types';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
// web/src/web/layuot.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { DefaultLayout } from 'web/src/components/layout/default';
import { ThemeProvider } from 'web/src/components/provider/theme-provider';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<DefaultLayout>{children}</DefaultLayout>
</ThemeProvider>
</body>
</html>
);
}
现在我们再点击一下切换dark主题就可以切换成暗黑模式了,至此ui组件以及主题切换也完成了。下面我们可以开始完成主要页面的开发了,因为nextjs已经集成了路由模式,创建文件就能创建页面
下面我们先看一下我们的首页设置成什么样子,然后分析一下页面需要什么组件
从图中可以看出,我们需要新增一个article-long-item.tsx组件,并且安装dayjs来显示发布时间到现在的时间。那我们下面看一下详细代码。
// 安装dayjs
pnpm install dayjs -S
创建overlayLink组件
# overlayLink组件,因为MDN规范里,不推荐使用<a><a><a/><a/>模式,nextjs直接就会报错了,我们需要添加一个overlayLink组件,让这个卡片全局可以点击跳转到文章详情
import Link from 'next/link';
import { LinkProps } from 'next/dist/client/link';
import * as React from 'react';
import { cn } from 'web/src/lib/utils';
type OverlayLinkProps = LinkProps & {
className?: string;
children: React.ReactNode;
};
export const OverlayLink: React.FC<OverlayLinkProps> = ({
children,
className,
...rest
}) => {
return (
<Link {...rest} className={cn('plumjs-linkbox__overlay', className)}>
{children}
</Link>
);
};
export const LinkBox: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
children,
className,
...rest
}) => {
return (
<div {...rest} className={cn('plumjs-linkbox', className)}>
{children}
</div>
);
};
在components/ui创建article-long-item.tsx组件
'use client';
import { LinkBox, OverlayLink } from 'web/src/components/ui/overlayLink';
import { AspectRatio } from 'web/src/components/ui/aspect-ratio';
import Image from 'next/image';
import { addImageDomain } from 'web/src/lib/utils';
import Link from 'next/link';
import React from 'react';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
import relativeTime from 'dayjs/plugin/relativeTime';
import { IArticleDatum } from 'web/types';
dayjs.extend(relativeTime);
export const ArticleLongItem: React.FC<IArticleDatum> = (props) => {
return (
<LinkBox
key={props.id}
className="group shadow dark:shadow-primary text-md"
>
<div className="flex justify-items-stretch p-0 cursor-pointer">
<div className="flex-shrink-0 basis-[300px] 2xl:basis-[400px]">
<AspectRatio ratio={16 / 9} className="overflow-hidden ">
<Image
className="object-cover w-full h-full transition ease-in-out duration-500 group-hover:scale-[1.2]"
width={props.attributes.cover.data.attributes.width}
height={props.attributes.cover.data.attributes.height}
src={addImageDomain(props.attributes.cover.data.attributes.url)}
alt={props.attributes.cover.data.attributes.name}
/>
</AspectRatio>
</div>
<div className="ml-4 flex-1 py-4 flex flex-col justify-between">
<div className="flex flex-col gap-2 ">
<OverlayLink href={`/posts/${props.id}`}>
<div className="line-clamp-2 group-hover:text-primary text-2xl font-medium">
{props.attributes.title}
</div>
</OverlayLink>
<div className="line-clamp-2 text-[#999]">
{props.attributes.desc}
</div>
</div>
<div className="flex justify-between pr-4 items-center text-[#999] text-xs">
<div>{dayjs(props.attributes.createdAt).fromNow()}</div>
{props.attributes?.tags?.data &&
props.attributes?.tags?.data.length ? (
<div className="flex gap-4">
{props.attributes.tags.data.map((tagItem: any) => {
return (
<div
className="px-2 shadow py-1 bg-primary rounded-2xl text-secondary transition hover:bg-primary/90 hover:text-secondary/50"
key={tagItem.id}
>
<Link href={`/posts/tag/${tagItem.id}`}>
{tagItem.attributes.name}
</Link>
</div>
);
})}
</div>
) : null}
</div>
</div>
</div>
</LinkBox>
);
};
现在我们开始对接strapi的接口,我们开始完成首页,我们的接口域名跟图片域名都是可以配置的,因此我们创建一个.env文件,重启服务
APIBASEURL=http://127.0.0.1:1337
IMAGEDOMAIN=http://127.0.0.1:1337
修改next.config.js文件,创建global.d.ts
/** @type {import('next').NextConfig} */
const nextConfig = {
env: {
IMAGEDOMAIN: process.env.IMAGEDOMAIN,
APIBASEURL: process.env.APIBASEURL,
},
eslint: {
ignoreDuringBuilds: true,
},
images: {
remotePatterns: [
{
protocol: 'http',
hostname: '127.0.0.1',
port: '1337',
},
],
},
};
module.exports = nextConfig;
declare namespace NodeJS {
export interface ProcessOptions {
browser: boolean;
}
export interface Global {
PORT: any;
}
export interface ProcessEnv {
APIBASEURL: string;
IMAGEDOMAIN: string;
}
}
然后修改page.tsx文件,我们的首页就制作完成
接下来我们继续创建博客列表页和博客详情页,博客列表
// web/src/components/ui/article-short-item.tsx
import { LinkBox, OverlayLink } from 'web/src/components/ui/overlayLink';
import { AspectRatio } from 'web/src/components/ui/aspect-ratio';
import Image from 'next/image';
import { addImageDomain } from 'web/src/lib/utils';
import Link from 'next/link';
import React from 'react';
import { IArticleDatum } from 'web/types';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
export const ArticleShortItem: React.FC<IArticleDatum> = (props) => {
return (
<LinkBox
key={props.id}
className="group shadow dark:shadow-primary rounded-2xl overflow-hidden text-md"
>
<div className="flex justify-items-stretch p-0 cursor-pointer flex-col">
<AspectRatio ratio={16 / 9} className="overflow-hidden rounded-2xl">
<Image
className="object-cover w-full h-full transition ease-in-out duration-500 group-hover:scale-[1.2] "
width={props.attributes.cover.data.attributes.width}
height={props.attributes.cover.data.attributes.height}
src={addImageDomain(props.attributes.cover.data.attributes.url)}
alt={props.attributes.cover.data.attributes.name}
/>
</AspectRatio>
<div className="ml-4 flex-1 py-4 flex flex-col justify-between">
<div className="flex flex-col gap-2 ">
<OverlayLink className="flex-shrink-0" href={`/posts/${props.id}`}>
<div className="line-clamp-2 group-hover:text-primary text-2xl font-medium">
{props.attributes.title}
</div>
</OverlayLink>
<div className="line-clamp-2 text-[#999] group-hover:text-[#999/90]">
{props.attributes.desc}
</div>
</div>
<div className="flex justify-between pr-4 items-center text-[#999] text-xs">
<div>{dayjs(props.attributes.createdAt).fromNow()}</div>
{props.attributes?.tags?.data &&
props.attributes?.tags?.data.length ? (
<div className="flex gap-4">
{props.attributes.tags.data.map((tagItem) => {
return (
<div
className="px-2 shadow py-1 bg-primary rounded-2xl text-secondary transition hover:bg-primary/90 hover:text-secondary/50"
key={tagItem.id}
>
<Link href={`/posts/tag/${tagItem.id}`}>
{tagItem.attributes.name}
</Link>
</div>
);
})}
</div>
) : null}
</div>
</div>
</div>
</LinkBox>
);
};
// web/src/app/posts/page.tsx
import { addApiDomain } from 'web/src/lib/utils';
import { ArticleShortItem } from 'web/src/components/ui/article-short-item';
import { Metadata } from 'next';
import { IArticleData } from 'web/types';
export const metadata: Metadata = {
title: '博客列表',
description: '博客列表',
};
const getData: () => Promise<IArticleData> = async () => {
const result = await fetch(
addApiDomain('/api/articles?populate=*&sort[0]=publishedAt:desc'),
);
return await result.json();
};
const Home = async () => {
const result = await getData();
return (
<>
<div className="h-20 flex justify-center items-center">
<div className="px-24 text-[40px] bg-gradient-to-r from-primary to-secondary text-transparent bg-clip-text">
My First BLOG
</div>
</div>
<div className="mt-8 grid grid-cols-1 gap-8 md:grid-cols-2 xl:grid-cols-3">
{result.data.map((item) => {
return <ArticleShortItem key={item.id} {...item} />;
})}
</div>
</>
);
};
export default Home;
// 博客详情页
// web/src/app/posts/[id]/page.tsx
import { IArticleItemData } from 'web/types';
import { Metadata } from 'next';
import { addApiDomain } from 'web/src/lib/utils';
import { redirect } from 'next/navigation';
export interface IPageParams {
params: {
id: string;
};
}
export const generateMetadata = async ({ params }: IPageParams) => {
const resp = await getData(params?.id || '');
const {
data: { attributes },
} = resp;
const metadata: Metadata = {
title: attributes.title,
description: attributes.desc,
};
return metadata;
};
const getData: (id: string) => Promise<IArticleItemData> = async (
id: string,
) => {
const result = await fetch(addApiDomain(`/api/articles/${id}?populate=*`));
if (result.status !== 200) {
return redirect('/404');
}
return await result.json();
};
const PostPage = async ({ params }: IPageParams) => {
let result = await getData(params.id);
return (
<>
<div className="h-20 flex justify-center items-center">
<div className="px-24 text-[40px] bg-gradient-to-r from-primary to-secondary text-transparent bg-clip-text">
{result.data.attributes.title}
</div>
</div>
<div
className="w-full max-w-full prose lg:prose-lg h-full prose-img:rounded-xl prose-img:w-full prose-img:object-cover"
dangerouslySetInnerHTML={{ __html: result.data.attributes.content }}
></div>
</>
);
};
export default PostPage;
// web/src/app/posts/tag/[id]/page.tsx
// 页面是此标签下的所有文章
import { Metadata } from 'next';
import { addApiDomain } from 'web/src/lib/utils';
import { ArticleShortItem } from 'web/src/components/ui/article-short-item';
import { ITagArticleItemData } from 'web/types';
export interface IPageParams {
params: {
id: string;
};
}
export const generateMetadata = async ({ params }: IPageParams) => {
const resp = await getData(params?.id || '');
const {
data: { attributes },
} = resp;
const metadata: Metadata = {
title: attributes.name,
description: '标签列表',
};
return metadata;
};
const getData: (id: string) => Promise<ITagArticleItemData> = async (id) => {
const result = await fetch(
addApiDomain(
`/api/tags/${id}?populate[articles][populate][0]=cover&populate[articles][populate][1]=tags`,
),
);
return await result.json();
};
const TagPostPage = async ({ params }: IPageParams) => {
let result = await getData(params.id);
return (
<>
<div className="h-20 flex justify-center items-center">
<div className="px-24 text-[40px] bg-gradient-to-r from-primary to-secondary text-transparent bg-clip-text">
{result.data.attributes.name}
</div>
</div>
<div className="mt-8 grid grid-cols-1 gap-8 md:grid-cols-2 xl:grid-cols-3">
{result.data.attributes.articles.data.map((item) => {
return <ArticleShortItem key={item.id} {...item} />;
})}
</div>
</>
);
};
export default TagPostPage;
至此我们的博客网站已经开发完成,可以愉快的添加文章了
项目链接