返回介绍

业务功能实现

发布于 2024-09-11 01:01:38 字数 19931 浏览 0 评论 0 收藏 0

官网作为一个品牌形象的载体,肯定需要大量的文章或信息,来进行文化价值观的传输,文章的内容一多,自然需要为它实现对应的分页。

文章页分页

样式实现

分页的组件使用 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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文