Next.js+Strapi+Ubuntu 从 0 到 1 搭建 CMS 内容管理系统(含域名及证书申请教程)

lxf2023-05-09 03:04:02

前言

最近在为某公司搭建网站,需要一个内容管理系统,正好之前了解了 Strapi CMS,于是决定用它来搭建这个系统。这里涉及的技术栈有:

  • Next.js:搭建前端页面
  • Tailwind CSS:编写前端样式
  • Strapi CMS:搭建后端内容管理系统,生成 API
  • Ubuntu:服务器
  • Azure:云服务器
  • PM2:Node.js 进程管理
  • Nginx:反向代理
  • ZeroSSL:免费 SSL 证书

什么?你说你不知道这些东西是干什么的?别急,往下看,这是一篇从 0 到 1 的教程。

博客地址:Next.js + Strapi CMS + Ubuntu 搭建内容管理系统 | Jetzihan

Next.js 搭建

首先,我们需要安装 Next.js,这里我们使用 create-next-app 来创建一个 Next.js 项目。在这里我把 Next.js 的文档丢给你,你可以看看 Next.js 官方文档。

npx create-next-app@latest my-site --typescript

等待安装完成后,我们就可以进入项目目录了。

cd my-site

接着可以运行 npm run dev 来启动项目,然后在浏览器中打开 http://localhost:3000,就可以看到 Next.js 的欢迎页面了。

Tailwind CSS 安装

Why Tailwind? Because it's awesome!

Tailwind 是一个 CSS 框架,它的特点是:

  • 无需编写 CSS,只需添加类名
  • 简单的响应式设计
  • 优化的 CSS 输出

从 Github 趋势 上我们可以看出 Tailwind CSS 的受欢迎程度:

Next.js+Strapi+Ubuntu 从 0 到 1 搭建 CMS 内容管理系统(含域名及证书申请教程)

接下来我们需要安装 Tailwind CSS,这里我们使用 create-tailwind-app 来创建一个 Tailwind CSS 项目。在这里我也把 Tailwind CSS 的文档丢给你, Next.js 安装 Tailwind CSS。

在我们项目的根目录下运行以下命令:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

这时,脚手架会自动在项目根目录下创建一个 tailwind.config.js 文件,我们可以在这里配置 Tailwind CSS。我们需就要在 tailwind.config.js 文件中添加以下内容,主要是 content 字段中的内容:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./app/**/*.{js,ts,jsx,tsx}",
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
    // Or if using `src` directory:
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Strapi CMS 安装

新建一个目录,然后进入这个目录,运行以下命令:

npx create-strapi-app@latest my-project --quickstart

官方文档在此 Strapi 官方文档。

待安装完毕之后,我们就可以进入项目目录了。

cd my-project

接着可以运行 npm run develop 来启动项目,然后在浏览器中打开 http://localhost:1337/admin,就可以看到 Strapi CMS 的欢迎页面了。初始界面时,会提示你创建一个管理员账号,这里我们就不多说了。请妥善保存你的管理员账号和密码。

Strapi CMS 配置

以上工作做完之后,我们就可以开始了。

汉化 Strapi CMS 面板

首先你可能想要一个中文面板的 Strapi ,那么我们可以在 src/admin/ 中创建一个 app.js 文件,然后在这个文件中添加以下内容:

export default {
  config: {
    locales: ["en", "zh-Hans"],
    tutorials: false,
  },
  bootstrap() {},
};

重启项目,然后在浏览器中打开 http://localhost:1337/admin。之后你可以点击左下角头像,然后选择 Settings,在 Language 中选择 简体中文,然后点击 Save,这时你就可以看到中文的 Strapi CMS 面板了。

接口分析

试想我们有如下的需求:即创建一个新闻中心应用,它包含了新闻列表和新闻详情两个页面。新闻列表页面需要展示新闻的标题、发布时间、新闻类别的信息,新闻详情页面需要展示新闻的主要内容。

一条新闻应该包含以下字段:

  • slug:新闻的唯一标识符
  • title:新闻的标题
  • desc:新闻的主要内容
  • date:新闻的发布时间
  • from:新闻的来源(分类)

添加模型构建器

Next.js+Strapi+Ubuntu 从 0 到 1 搭建 CMS 内容管理系统(含域名及证书申请教程)

点击添加集合类型,然后在面板中输入模型名称,并输入 API ID ,这将决定你的 API 的 URL。

Next.js+Strapi+Ubuntu 从 0 到 1 搭建 CMS 内容管理系统(含域名及证书申请教程)

完成后,添加上述字段。

Next.js+Strapi+Ubuntu 从 0 到 1 搭建 CMS 内容管理系统(含域名及证书申请教程)

在高级设置中可以设置其默认值、是否必填、长度等。

Next.js+Strapi+Ubuntu 从 0 到 1 搭建 CMS 内容管理系统(含域名及证书申请教程)

对于文章内容,这里我们选用富文本编辑器类型,这样我们就可以在编辑器中直接编辑文章内容了。

模型添加完毕后,点击右上角的 保存 按钮。打开内容管理,我们可以看到我们刚刚添加的模型。

Next.js+Strapi+Ubuntu 从 0 到 1 搭建 CMS 内容管理系统(含域名及证书申请教程)

添加内容

点击右上角的 添加条目 按钮,我们就可以添加新闻了。

Next.js+Strapi+Ubuntu 从 0 到 1 搭建 CMS 内容管理系统(含域名及证书申请教程)

添加完成后,点击保存并发布,我们就可以在前端页面中看到我们刚刚添加的新闻了。

这时,来到设置页面,进入角色列表,找到 public 角色,然后点击进入。

Next.js+Strapi+Ubuntu 从 0 到 1 搭建 CMS 内容管理系统(含域名及证书申请教程)

找到刚刚创建的新闻模型的 API ,勾选 findfindone ,然后点击右上角的 保存 按钮。在权限的设置图标上,点击,可以查看对应操作的 API 地址。点击保存。

Next.js+Strapi+Ubuntu 从 0 到 1 搭建 CMS 内容管理系统(含域名及证书申请教程)

接口测试

接着我们打开 http://localhost:1337/api/news ,可以看到我们刚刚添加的新闻。具体内容是一个 JSON 对象

Next.js+Strapi+Ubuntu 从 0 到 1 搭建 CMS 内容管理系统(含域名及证书申请教程)

可以通过 api 测试工具,如 postman 进行测试。这里用的是 apifox 。

Next.js+Strapi+Ubuntu 从 0 到 1 搭建 CMS 内容管理系统(含域名及证书申请教程)

列表页面编写

接下来我们就可以开始编写前端页面了。我们首先在 Next.js 中创建一个页面,命名为 NewsList.tsx,用于展示新闻列表。

类型抽象

根据上面的接口测试,查看返回的结果,我们可以抽象出下面的类型:

type propType = {
    id: number,
    attributes: {
        slug: string,
        title: string,
        desc: string,
        date: string,
        from: string,
        createdAt: string,
        updatedAt: string,
        publishedAt: string,
    }
}

状态管理

创建组件,定义一些状态用来存储新闻列表、分页页码、筛选条件等。

const [newsList, setNewsList] = React.useState<propType[]>([]); // 新闻列表
const [pageNo, setPageNo] = React.useState(1);  // 当前页码
const [nowPage, setNowPage] = React.useState<propType[]>([]);   // 当前页的新闻列表
const [pageCount, setPageCount] = React.useState(2);    // 总页数
const [selectedArr, setSelectedArr] = React.useState<string[]>([]); // 筛选条件

接口"软编码"

接口地址不应该写死,我们应该采用软编码的方式,将接口地址前缀管理在文件中,方便后期维护。可以使用 next.config.js 来管理。

module.exports = {
    env: {
        API_URL: 'http://localhost:1337/api'
    }
}

调用时,可以使用 process.env.API_URL 来获取。

但是我这里使用了一个自定义 Hook 来管理接口地址。创建一个 Hooks 文件夹,创建 useStrapiLink.ts 文件,用于管理接口地址。

import React from "react";
import {useState} from "react";

const useStrapiLink = () => {
    const [strapiLink, setStrapiLink] = useState<string>("http://localhost:1337/api");
    return strapiLink;
}

export default useStrapiLink;

使用时,可以直接引入 useStrapiLink,然后调用即可。

import useStrapiLink from "../Hooks/useStrapiLink";

const strapiLink = useStrapiLink();

接口请求

useEffect 中,我们可以使用 fetch 来请求接口。

useEffect(() => {
    if (selectedArr.length == 0) {
        fetch(strapiLink + "/api/news")
            .then(res => res.json())
            .then(data => {
                setNewsList(data.data); // 将请求到的数据存储在 newsList 中
                setPageCount(Math.ceil(data.data.length));  // 计算总页数
            })
    } else {
        fetch(strapiLink + "/api/news?filters[from][$in][0]=" + selectedArr[0] + '&filters[from][$in][1]=' + selectedArr[1] + '&filters[from][$in][2]=' + selectedArr[2])
            .then(res => res.json())
            .then(data => {
                setNewsList(data.data);
                setPageCount(Math.ceil(data.data.length));
            })
    }
}, [strapiLink, selectedArr]);

在这里,我为副作用绑定了 strapiLinkselectedArr 两个状态,也就是 strapi 的接口地址和筛选条件。当这两个状态发生变化时,才会触发副作用。

我们可以看到这里使用了 strapi 的 RESTful API,这里我使用了筛选条件,筛选条件的格式filters[from][$in][0]=,其中 from 是字段名,$in 是筛选条件,[0] 是数组下标,下标代表筛选条件的位次。这里我有三个条件可供筛选,并且它们之间满足的是或的关系,所以我使用了数组。

筛选使用的是 Acro Design 的 Select 组件。

 <Select style={{width: 270}} mode='multiple' placeholder="全部"
        onChange={(value) => {
            setSelectedArr(value);
        }}
>
    {options.map((option, index) => (
        <Option key={index} value={option.option}>
            {option.option}
        </Option>
    ))}
</Select>

对于更多的 strapi 筛选条件,可以参考 Strapi 文档中的这个条目。

可以满足绝大多数的筛选需求。

Next.js+Strapi+Ubuntu 从 0 到 1 搭建 CMS 内容管理系统(含域名及证书申请教程)

分页

分页的逻辑比较简单,我们只需要将 newsList 中的数据按照页码进行切割即可。

useEffect(() => {
    setNowPage(newsList.slice((pageNo - 1) * 10, pageNo * 10));
}, [newsList, pageNo]);

同样是绑定了 newsListpageNo 两个状态,当这两个状态发生变化时,才会触发副作用。每次切割的长度为 10,也就是每页显示 10 条数据。

这里使用了 Acro Design 的 Pagination 组件,它的使用方法很简单,如下:

 <Pagination 
    pageSize={10}  // 每页显示的条数
    total={pageCount}  // 总条数
    onChange={(pageNumber) => {setPageNo(pageNumber);}} // 页码改变时的回调函数
/>

列表渲染

由于我们动态改变了当前页面的数据,所以我们需要在 nowPage 发生变化时,重新渲染列表。

{nowPage && nowPage.map((item, index) => {
    return (
        <div
            key={index}
            className="w-full transition-all ease-in-out duration-300 grid grid-cols-9 border-b-2 border-sky-300/20  py-4 justify-start items-center"
        >
            <div className={`col-span-3 lg:col-span-2 flex justify-start text-gray-400`}>
                {item.attributes.date}
            </div>
            <Link href={{pathname: '/NewsPage', query: {id: item.id}}}
                    className="col-span-6 flex items-center hover:underline">
                <div className={`truncate text-black font-normal text-md`}>
                    {item.attributes.title}
                </div>
            </Link>
            <div className={`hidden lg:block lg:col-span-1 flex justify-end text-gray-400`}>
                {item.attributes.from}
            </div>
        </div>
    )
})}

跳转到详情页

值得注意的是,这里使用了 Link 组件,它的作用是跳转到指定的页面,我们通过 query 属性传递了 id 参数,这个参数将会在详情页中使用,用来渲染指定数据的详情页的数据。

<Link href={{pathname: '/NewsPage', query: {id: item.id}}}>
    // ...
</Link>

列表页的效果如下:

Next.js+Strapi+Ubuntu 从 0 到 1 搭建 CMS 内容管理系统(含域名及证书申请教程)

下面我们会讲到详情页的实现。

详情页的创建

详情页我们要获得指定文章的 id ,然后通过 id 去请求数据,最后渲染出来。这里创建一个 NewsPage.tsx 文件。

跳转参数 id 的获取

我们需要获取到 query 中的 id 参数,然后将其赋值给 id 状态。获取 id 参数的方法很简单,就是使用 useRouter 钩子函数,然后获取 query 中的 id 参数。

const {id} = useRouter().query;

新闻请求

我们需要通过 id 去请求数据,这里我们使用 useEffect 副作用,当 id 发生变化时,才会触发副作用。

useEffect(() => {
    fetch(strapiLink + "/api/news/"+ id ).then(
        res => res.json()
    ).then(
        data => {
            setArticle(data.data);
        }
    )
}, [strapiLink]);

渲染 Markdown

Strapi 的富文本编辑器是使用 Markdown 格式的,所以我们需要将 Markdown 转换为 HTML 格式,然后渲染出来。这里使用一个插件 react-markdown

首先安装插件:

npm install react-markdown

接着只需要传入参数即可:

<ReactMarkdown
    children={article.attributes.desc
        // 将 ](/uploads 替换为 ](strapiLink+/uploads
        .replace(/\]\(\//g, `](${strapiLink}/`)}
    remarkPlugins={[remarkGfm]}
    className={`${styles.markdown} lg:pr-12`}
/>

这里也有个要点,就是 Strapi 富文本中图片的地址是 /uploads 开头的,并不会携带 URL 头,所以我们需要将其替换为 strapiLink+/uploads,这样才能正确的请求到图片。这里使用了正则表达式

remarkPlugins 插件是用来解析 Markdown 的,这里我们使用了 remark-gfm 插件,能够更好的解析 Markdown 一些高级语法。更多关于 react-markdown 可以参考官方文档

配置服务器

配置和连接

这里肯定需要一台服务器来部署 strapi,当然,也有一些一键部署的服务,如 Render,但是这里我们还是使用自己的服务器来部署。

笔者这里使用的是 Github Student Pack 提供的 Azure 服务器,这里就不多说了,建议是学生的话,可以去申请一下。除了 Azure 服务器以外,学生包中还赠送了三个域名及 SSL 证书,还有一台 DigitalOcean 的服务器,嗯,很香