业务功能实现
官网作为一个品牌形象的载体,肯定需要大量的文章或信息,来进行文化价值观的传输,文章的内容一多,自然需要为它实现对应的分页。
文章页分页
样式实现
分页的组件使用 semi-design (其它 UI 框架方法类似) 来实现。
npm install @douyinfe/semi-ui --save
给首页文章块下面加一个分页。
pages/index.tsx
import { Pagination } from "@douyinfe/semi-ui";
// ...
<div className={styles.paginationArea}>
<Pagination total={articles?.total} pageSize={6} />
</div>
Nextjs 希望可以自主导入依赖中的样式,而不是随着依赖直接导入样式,避免对全局样式造成影响。
Semi 的依赖默认是在入口文件统一导入的,针对这种情况,Semi 提供了 semi-next 插件来对入口文件样式进行去除。
npm i @douyinfe/semi-next
安装好 semi-next 后,到 nextjs 的配置文件,用 semi-next 包裹一层配置文件,进行默认导入样式的去除。
next.config.js
/** @type {import('next').NextConfig} */
const path = require('path');
const semi = require('@douyinfe/semi-next').default({});
const nextConfig = semi({
reactStrictMode: true,
swcMinify: true,
images: {
domains: ['127.0.0.1'],
},
webpack: (config) => {
config.resolve.alias = {
...config.resolve.alias,
'@': path.resolve(__dirname),
};
return config;
}
});
module.exports = nextConfig
在全局样式中手动导入 Semi 的样式。
styles/global.css
@import "~@douyinfe/semi-ui/dist/css/semi.min.css";
针对分页组件覆盖一下主题化的样式,样式覆盖是通过 global 样式去做。
styles/Home.module.scss
@import "./pages/media.scss";
@mixin initStatus {
transform: translate3d(0, 2.5rem, 0);
opacity: 0;
}
@mixin finalStatus {
-webkit-transform: none;
transform: none;
opacity: 1;
}
.container {
padding: 0 2rem;
color: var(--primary-color);
.main {
min-height: 100vh;
padding: 4rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.header {
background-image: var(--home-background-icon);
background-size: 18.75rem 18.75rem;
background-repeat: no-repeat;
width: 18.75rem;
height: 18.75rem;
}
.headerWebp {
background-image: var(--home-background-icon-webp);
}
.top {
display: flex;
}
.title a {
color: var(--link-color);
text-decoration: none;
}
.title a:hover,
.title a:focus,
.title a:active {
text-decoration: underline;
}
.title {
margin: 0;
line-height: 1.15;
font-size: 4rem;
}
.title,
.description {
text-align: center;
}
.description {
margin: 4rem 0;
line-height: 1.5;
font-size: 1.5rem;
}
.grid {
display: flex;
align-items: flex-start;
justify-content: flex-start;
flex-wrap: wrap;
max-width: 62.5rem;
transition: 2s;
min-height: 36.25rem;
.card {
margin: 1rem;
padding: 1.5rem;
text-align: left;
color: inherit;
text-decoration: none;
border: 0.0625rem solid var(--footer-background-color);
border-radius: 0.625rem;
transition: color 0.15s ease, border-color 0.15s ease;
max-width: 18.75rem;
cursor: pointer;
width: 18.75rem;
height: 13.875rem;
}
.card:hover,
.card:focus,
.card:active {
color: var(--link-color);
border-color: var(--link-color);
}
.card h2 {
margin: 0 0 1rem 0;
font-size: 1.5rem;
}
.card p {
margin: 0;
font-size: 1.25rem;
line-height: 1.5;
}
}
.paginationArea {
width: 62.5rem;
display: flex;
justify-content: flex-end;
padding: 20px 0;
:global {
.semi-page-item {
color: var(--primary-color);
opacity: 0.7;
}
.semi-page-item:hover {
background-color: var(--semi-page-hover-background-color);
}
.semi-page-item-active {
color: var(--semi-page-active-color);
background-color: var(--semi-page-active-background-color);
}
.semi-page-item-active:hover {
color: var(--semi-page-active-color);
background-color: var(--semi-page-active-background-color);
}
}
}
}
.withAnimation {
.title {
animation: fadeInDown1 1s;
}
.description {
animation: fadeInDown2 1s;
}
.card:nth-of-type(1) {
animation: fadeInDown3 1s;
}
.card:nth-of-type(2) {
animation: fadeInDown4 1s;
}
.card:nth-of-type(3) {
animation: fadeInDown5 1s;
}
.card:nth-of-type(4) {
animation: fadeInDown6 1s;
}
.card:nth-of-type(5) {
animation: fadeInDown7 1s;
}
.card:nth-of-type(6) {
animation: fadeInDown8 1s;
}
}
@keyframes fadeInDown1 {
0% {
@include initStatus;
}
11% {
@include initStatus;
}
100% {
@include finalStatus;
}
}
@keyframes fadeInDown2 {
0% {
@include initStatus;
}
22% {
@include initStatus;
}
100% {
@include finalStatus;
}
}
@keyframes fadeInDown3 {
0% {
@include initStatus;
}
33% {
@include initStatus;
}
100% {
@include finalStatus;
}
}
@keyframes fadeInDown4 {
0% {
@include initStatus;
}
44% {
@include initStatus;
}
100% {
@include finalStatus;
}
}
@keyframes fadeInDown5 {
0% {
@include initStatus;
}
55% {
@include initStatus;
}
100% {
@include finalStatus;
}
}
@keyframes fadeInDown6 {
0% {
@include initStatus;
}
66% {
@include initStatus;
}
100% {
@include finalStatus;
}
}
@keyframes fadeInDown7 {
0% {
@include initStatus;
}
77% {
@include initStatus;
}
100% {
@include finalStatus;
}
}
@keyframes fadeInDown8 {
0% {
@include initStatus;
}
88% {
@include initStatus;
}
100% {
@include finalStatus;
}
}
}
@include media-ipad {
.container {
.main {
.grid {
width: 95%;
margin: auto;
justify-content: center;
}
}
}
}
@include media-mobile {
.container {
.main {
.title {
font-size: 1.75rem;
line-height: 2.4375rem;
}
.description {
font-size: 0.875rem;
line-height: 1.5rem;
margin: 2rem 0;
}
.grid {
width: 95%;
margin: auto;
justify-content: center;
.card {
height: 10rem;
h2 {
font-size: 1.125rem;
line-height: 1.5625rem;
}
p {
font-size: 0.75rem;
line-height: 1.625rem;
}
}
}
}
}
}
实现效果:
接口层实现
设计三个结构体,ArticleInfo、ArticleIntroduction 和 Home,其中 Home 就是首页那两个基础文案,ArticleIntroduction 是文章相关的简介,link 指向 ArticleInfo 对应元素的 id 即可。
这里文章内容单独放在 ArticleInfo,之所以这么做,是因为考虑到文章内容往往很多,如果放在 ArticleIntroduction 中进行分页,cdn 拉取的时间随着文章的增多,可能会越来越长。
启动一下 CMS 的项目,配置对应的结构体,填上数据。
Home:
ArticleIntroduction:
ArticleInfo:
其中富文本区域的配置需要着重关注一下,其中包含了文本、标题和图片,这个其实和平常用的一些文本编辑器还是很像的,点击 preview mode 处可以看到效果,按照平时写笔记的习惯,用 markdown 语言去配置文章就可以了。
按照之前的配置,给这些结构体开一下 find、findone 等配置。
随便开一个模块看看。
好像有 time 等相关数据,参照上次,把对应用不上的数据清掉。
home controller
'use strict';
/**
* home controller
*/
const { removeTime, removeAttrsAndId } = require('../../../utils/index');
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::home.home', ({ strapi }) => ({
async find(ctx) {
ctx.query = {
...ctx.query,
populate: 'deep',
};
const { data } = await super.find(ctx);
return removeAttrsAndId(removeTime(data[0]));
}
}));
需要对 ArticleIntroduce 做一个分页的操作,Strapi 中针对分页的操作提供了 pagination[page] 和 pagination[pageSize] 两个参数,类似下面的效果。
/api/articles?pagination[page]=1&pagination[pageSize]=10 // 按 10 个/页分页,返回第一页的数据
这两个参数太长了,定义两个自己的参数,pageNo, pageSize,然后在它的基础上魔改一下就可以,具体代码如下:
article-introduction controller
"use strict";
const { removeTime, removeAttrsAndId } = require("../../../utils/index.js");
/**
* article-introduction controller
*/
const { createCoreController } = require("@strapi/strapi").factories;
module.exports = createCoreController("api::article-introduction.article-introduction", ({ strapi }) => ({
async find(ctx) {
const { pageNo, pageSize, ...params } = ctx.query;
if (pageNo && pageSize) {
ctx.query = {
...params,
"pagination[page]": Number(pageNo),
"pagination[pageSize]": Number(pageSize),
};
}
const { data, meta } = await super.find(ctx);
return { data: removeAttrsAndId(removeTime(data)), meta };
},
})
);
article-info controller
"use strict";
const { removeTime, removeAttrsAndId } = require("../../../utils/index.js");
/**
* article-info controller
*/
const { createCoreController } = require("@strapi/strapi").factories;
module.exports = createCoreController("api::article-info.article-info", ({ strapi }) => ({
async find(ctx) {
const { data, meta } = await super.find(ctx);
return { data: removeAttrsAndId(removeTime(data)), meta };
},
async findOne(ctx) {
const { data, meta } = await super.findOne(ctx);
return removeAttrsAndId(removeTime(data));
},
})
);
接下来开始编写 BFF 层的代码,三个结构体分别对应三个接口层。
pages/api/home.ts
import axios from 'axios';
import type { NextApiRequest, NextApiResponse } from 'next';
import nextConnect from 'next-connect';
import { CMSDOMAIN } from '@/utils';
export interface IHomeProps {
title: string;
description: string;
}
const getHomeData = nextConnect()
.get((req: NextApiRequest, res: NextApiResponse<IHomeProps>) => {
axios.get(`${CMSDOMAIN}/api/homes`).then(result => {
const { title, description } = result.data || {};
res.status(200).json({
title,
description,
})
})
})
export default getHomeData;
接下来是文章简介的接口,它可以接受分页的两个入参进行对应的分页。
pages/api/articleIntroduction.ts
import axios from 'axios';
import type { NextApiRequest, NextApiResponse } from 'next';
import nextConnect from 'next-connect';
import { CMSDOMAIN } from '@/utils';
export interface IArticleIntroduction {
label: string;
info: string;
articleId: number;
}
export interface IArticleIntroductionProps {
list: IArticleIntroduction[];
total: number;
}
const ArticleIntroductionData = nextConnect()
.post((req: NextApiRequest, res: NextApiResponse<IArticleIntroductionProps>) => {
const { pageNo, pageSize } = req.body;
axios.get(`${CMSDOMAIN}/api/article-introductions`, {
params: {
pageNo,
pageSize,
}
}).then(result => {
const { data, meta } = result.data || {};
res.status(200).json({
list: Object.values(data),
total: meta.pagination.total,
})
})
})
export default ArticleIntroductionData;
list 需要用 Object.values 包一层 data,因为针对没有 relation 的多个元素,Strapi 是通过 object 类型返回,所以需要处理一层转成需要的数组格式。
最后一个接口是文章详情接口,接口包含一个 id 的入参,可以支持对数据进行单查,直接调用 Strapi 的 findOne 接口实现就好。
pages/api/articleInfo.ts
import axios from 'axios';
import nextConnect from 'next-connect';
import type { NextApiRequest, NextApiResponse } from 'next';
import { CMSDOMAIN } from '@/utils';
import { IArticleProps } from '@/pages/article/[articleId]';
const getArticleInfoData = nextConnect()
.get((req: NextApiRequest, res: NextApiResponse<IArticleProps>) => {
const { articleId } = req.query;
axios.get(`${CMSDOMAIN}/api/article-infos/${articleId}`).then(result => {
const data = result.data || {};
res.status(200).json(data);
})
})
export default getArticleInfoData;
到这里 BFF 层就定义好了,接下来改造一下首页,接入一下接口替换原先的静态数据。
pages/index.tsx
// ...
Home.getInitialProps = async (context) => {
const { data: homeData } = await axios.get(`${LOCALDOMAIN}/api/home`);
const { data: articleData } = await axios.post(
`${LOCALDOMAIN}/api/articleIntro`,
{
pageNo: 1,
pageSize: 6,
}
);
return {
title: homeData.title,
description: homeData.description,
articles: {
list: articleData.list.map((item: IArticleIntro) => {
return {
label: item.label,
info: item.info,
link: `${LOCALDOMAIN}/article/${item.articleId}`,
};
}),
total: articleData.total,
},
};
};
然后看看效果,数据已经注入进去了。
把客户端的分页事件绑定一下。
pages/index.tsx
import {useContext, useEffect, useRef, useState} from 'react';
import axios from 'axios';
import type {NextPage} from 'next';
import {Pagination} from '@douyinfe/semi-ui';
import classNames from 'classnames';
import {ThemeContext} from '@/stores/theme';
import styles from '@/styles/Home.module.scss';
import {LOCALDOMAIN} from "@/utils";
import {IArticleIntroduction} from "@/pages/api/articleIntroduction";
interface IHomeProps {
title: string;
description: string;
articles: {
list: {
label: string;
info: string;
link: string;
}[];
total: number;
};
}
const Home: NextPage<IHomeProps> = ({ title, description, articles }) => {
const [content, setContent] = useState(articles);
const mainRef = useRef<HTMLDivElement>(null);
const { theme } = useContext(ThemeContext);
useEffect(() => {
mainRef.current?.classList.remove(styles.withAnimation);
window.requestAnimationFrame(() => {
mainRef.current?.classList.add(styles.withAnimation);
});
}, [theme]);
return (
<div className={styles.container}>
<main
className={classNames([styles.main, styles.withAnimation])}
ref={mainRef}
>
<h1 className={styles.title}>{title}</h1>
<p className={styles.description}>{description}</p>
<div className={styles.grid}>
{content?.list?.map((item, index) => {
return (
<div
key={index}
className={styles.card}
onClick={(): void => {
window.open(
item.link,
"blank",
"noopener=yes,noreferrer=yes"
);
}}
>
<h2>{item.label}</h2>
<p>{item.info}</p>
</div>
);
})}
<div className={styles.paginationArea}>
<Pagination
total={content?.total}
pageSize={6}
onPageChange={(pageNo) => {
axios
.post(`${LOCALDOMAIN}/api/articleIntro`, {
pageNo,
pageSize: 6,
})
.then(({ data }) => {
setContent({
list: data.list.map((item: IArticleIntro) => {
return {
label: item.label,
info: item.info,
link: `${LOCALDOMAIN}/article/${item.articleId}`,
};
}),
total: data.total,
});
});
}}
/>
</div>
</div>
</main>
</div>
);
};
/// ...
`
接下来给对应的文章页面绑定一下接口数据。
pages/article/[articleId].tsx
Article.getInitialProps = async (context) => {
const { articleId } = context.query;
const { data } = await axios.get(`${LOCALDOMAIN}/api/articleInfo`, {
params: {
articleId,
}
})
return data;
}
export default Article;
这里有个问题需要注意下,内容是 Markdown,Markdown 转 HTML 可以使用 showdown,这是一个免费的开源转换 markdown 为 HTML 的库,安装依赖。
npm install showdown --save
然后对页面的 content 进行一下转换。
import React from 'react';
import axios from 'axios';
import type { NextPage } from 'next';
import {LOCALDOMAIN} from '@/utils';
import styles from './index.module.scss';
const showdown = require('showdown');
export interface IArticleProps {
title: string;
author: string;
description: string;
createTime: string;
content: string;
}
const Article: NextPage<IArticleProps> = ({ title, author, description, createTime, content }) => {
const converter = new showdown.Converter();
return (
<div className={styles.article}>
<h1 className={styles.title}>{title}</h1>
<div className={styles.info}>
作者:{author} | 创建时间: {createTime}
</div>
<div className={styles.description}>{description}</div>
<div className={styles.content} dangerouslySetInnerHTML={{__html: converter.makeHtml(content)}} />
</div>
);
};
Article.getInitialProps = async (context) => {
const { articleId } = context.query;
const { data } = await axios.get(`${LOCALDOMAIN}/api/articleInfo`, {
params: {
articleId,
}
})
return data;
}
export default Article;
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论