前言
最近在为某公司搭建网站,需要一个内容管理系统,正好之前了解了 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 的受欢迎程度:
接下来我们需要安装 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:新闻的来源(分类)
添加模型构建器
点击添加集合类型,然后在面板中输入模型名称,并输入 API ID ,这将决定你的 API 的 URL。
完成后,添加上述字段。
在高级设置中可以设置其默认值、是否必填、长度等。
对于文章内容,这里我们选用富文本编辑器类型,这样我们就可以在编辑器中直接编辑文章内容了。
模型添加完毕后,点击右上角的 保存
按钮。打开内容管理,我们可以看到我们刚刚添加的模型。
添加内容
点击右上角的 添加条目
按钮,我们就可以添加新闻了。
添加完成后,点击保存并发布,我们就可以在前端页面中看到我们刚刚添加的新闻了。
这时,来到设置页面,进入角色列表,找到 public
角色,然后点击进入。
找到刚刚创建的新闻模型的 API
,勾选 find
和 findone
,然后点击右上角的 保存
按钮。在权限的设置图标上,点击,可以查看对应操作的 API
地址。点击保存。
接口测试
接着我们打开 http://localhost:1337/api/news
,可以看到我们刚刚添加的新闻。具体内容是一个 JSON 对象。
可以通过 api 测试工具,如 postman 进行测试。这里用的是 apifox 。
列表页面编写
接下来我们就可以开始编写前端页面了。我们首先在 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]);
在这里,我为副作用绑定了 strapiLink
和 selectedArr
两个状态,也就是 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 文档中的这个条目。
可以满足绝大多数的筛选需求。
分页
分页的逻辑比较简单,我们只需要将 newsList
中的数据按照页码进行切割即可。
useEffect(() => {
setNowPage(newsList.slice((pageNo - 1) * 10, pageNo * 10));
}, [newsList, pageNo]);
同样是绑定了 newsList
和 pageNo
两个状态,当这两个状态发生变化时,才会触发副作用。每次切割的长度为 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>
列表页的效果如下:
下面我们会讲到详情页的实现。
详情页的创建
详情页我们要获得指定文章的 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 的服务器,嗯,很香