前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >5w字长文带你【从0使用NextJS+SSR开发博客系统】 | 技术创作特训营第五期

5w字长文带你【从0使用NextJS+SSR开发博客系统】 | 技术创作特训营第五期

原创
作者头像
程序员库里
修改2024-02-06 12:22:37
7541
修改2024-02-06 12:22:37
举报
文章被收录于专栏:全栈学习全栈学习

NextJS介绍

Next.js 是一个用于构建 React 应用程序的 React 框架。它的目标是使 React 应用的开发变得更简单、更灵活。下面是一些 Next.js 的关键特性:

服务器渲染 (SSR): Next.js 支持服务器渲染,这意味着页面可以在服务器上生成,然后再发送到浏览器,有助于提高应用程序的性能和搜索引擎优化(SEO)。

静态生成 (Static Generation): 除了服务器渲染外,Next.js 还支持静态生成,可以在构建时预先生成页面,然后将它们作为静态文件提供,这对于构建性能高效的静态网站非常有用。

自动代码拆分 (Automatic Code Splitting): Next.js 会自动将应用程序的代码拆分成小块,只加载当前页面所需的代码,提高加载速度。

热模块替换 (Hot Module Replacement): 在开发模式下,Next.js 支持热模块替换,允许在运行时更新代码,无需重新加载整个页面。

项目介绍&展示

使用Next.js+React,实现一个SSR服务器渲染的博客项目

环境搭建

技术选型

  1. Next.js
  2. Mysql
  3. React
  4. Ant Design
  5. typeorm

创建项目

  1. 首先在github上创建一个项目仓库,比如:nextjs-blog
  2. 将nextjs-blog仓库使用git拉取到本地git clone xxx.nextjs-blog.git
  3. 然后进入项目目录cd nestjs-blog
  4. 接着使用next.js提供的脚手架创建项目,这里我们使用typescript开发,所以使用typescript的模板yarn create next-app --typescript

配置eslint

1.安装lint

代码语言:bash
复制
pnpm i eslint -D -w

2.初始化

代码语言:bash
复制
npx eslint --init

3.手动安装其他包

代码语言:bash
复制
pnpm i -D -w typesript @typescript-eslint/eslint-plugin@latest @typescript-eslint/parser@latest

4.修改eslint 配置

代码语言:json
复制
{
	"env": {
		"browser": true,
		"es2021": true,
		"node": true,
		"jest": true
	},
	"extends": [
		"eslint:recommended",
		"plugin:@typescript-eslint/recommended",
		"prettier",
		"plugin:prettier/recommended"
	],
	"parser": "@typescript-eslint/parser",
	"parserOptions": {
		"ecmaVersion": "latest",
		"sourceType": "module"
	},
	"plugins": ["@typescript-eslint", "prettier"],
	"rules": {
		"prettier/prettier": "error",
		"no-case-declarations": "off",
		"no-constant-condition": "off",
		"@typescript-eslint/ban-ts-comment": "off",
		"@typescript-eslint/no-unused-vars": "off",
		"@typescript-eslint/no-var-requires": "off",
		"no-unused-vars": "off"
	}
}

5.安装ts lint

代码语言:bash
复制
pnpm i -D -w @typescript-eslint/eslint-plugin

配置prettier

1.安装prettier

代码语言:bash
复制
pnpm i -D -w prettier

2.新建.pretterrc.json

代码语言:json
复制
{
  "printWidth": 80,
  "tabWidth": 2,
  "useTabs": true,
  "singleQuote": true,
  "semi": true,
  "trailingComma": "none",
  "bracketSpacing": true
}

3.将pretter集成到eslint中

代码语言:bash
复制
 pnpm i -D -w eslint-config-prettier eslint-plugin-prettier

4.在scripts中增加lint命令

代码语言:bash
复制
"lint": "eslint --ext .ts,.jsx,.tsx --fix --quiet ./packages"

5.安装eslint pretter两个vscode插件

6.在vscode settings中设置format:pretter和 on save

检查commit

1.安装husky

代码语言:bash
复制
pnpm i -D -w husky

2.初始化husky

代码语言:bash
复制
npx husky install

3.将lint增加到husky中

代码语言:bash
复制
npx husky add .husky/pre-commit "pnpm lint "

在commit的时候会执行pnpm lint

检查commit msg

1.安装包

代码语言:bash
复制
pnpm i -D -w commitlint @commitlint/cli @commitlint/config-conventional

2.新建.commitlintrc.js

代码语言:javasript
复制
module.exports = {
	extends: ['@commitlint/config-conventional']
};

3.集成到husky中

在终端执行下面命令

代码语言:bash
复制
npx husky add .husky/commit-msg "npx --no-install commitlint -e $HUSKY_GIT_PARAMS"

TypeScript配置

在根目录新建tsconfig.json

代码语言:json
复制
{
	"compileOnSave": true,
	"include": ["./packages/**/*"],
	"compilerOptions": {
		"target": "ESNext",
		"useDefineForClassFields": true,
		"module": "ESNext",
		"lib": ["ESNext", "DOM"],
		"moduleResolution": "Node",
		"strict": true,
		"sourceMap": true,
		"resolveJsonModule": true,
		"isolatedModules": true,
		"esModuleInterop": true,
		"noEmit": true,
		"noUnusedLocals": false,
		"noUnusedParameters": false,
		"noImplicitReturns": false,
		"skipLibCheck": true,
		"baseUrl": "./packages",
		"paths": {
			"hostConfig": ["./react-dom/src/hostConfig.ts"]
		}
	}
}

这样,我们的项目开发环境就配置好了。

Next.js路由介绍

看下面这张图:

从上图可以看到

在pages目录下来创建文件夹,文件夹的名称就代表路由。俗称约定式路由。现在很多框架都支持约定式路由,比如Umi框架。

普通路由

1.比如pages/index.js,那么这个的路由就是 根路由

2.比如在pages下面新建 blog文件夹,在blog文件夹下面新建index.js,那此时这个文件对应的页面利用就是/blog

嵌套路由

1.在pages目录下新建blog目录,在blog目录下新建first-post.js,注意此时不是index.js,那此时的文件夹是嵌套的,那么对应的路由也是嵌套的,路由也是根据嵌套的文件夹的名称而来,所以这个first-post.js文件页面对应的路由就是/blog/first-post

动态路由

动态路由在实际业务中非常常见,接下来看下next.js中提供的动态路由。

1.在pages目录下新建blog文件夹,在文件夹下 新建 id.js,这个 id 就表示是动态路由,那展现的路由就是这个样子 /blog/:id ,这个里面的 :id 可以换成任意的路由,例如 /blog/1 , /blog/2

2.第二种是动态路由在中间,在pages目录下新建 id 文件夹,在id文件夹下面 创建setting.js, 那此时的动态路由就是 /:id/setting, :id 就是动态,例如 /1/setting, /2/setting

3.第三种动态路由是 任意匹配的路由,在pages目录下新建post文件夹,在post文件夹下面新建...all.js,此时这个 ...all表现的动态路由就是 /post/ ,这个 就代表任意路由,丽日: /post/2020/id/title

实现Layout布局

我们开始实现整体页面的布局。这里来讲解如何实现Layout布局,采用上中下的布局。

上中下的布局就是:上方 就是 导航区域,中间是内容区域,下方是 底部区域。

整个系统使用 Antd Design UI组件库。

我们先安装下 antd design

代码语言:bash
复制
pnpm install antd

Layout

  1. 首先在根目录创建components文件夹,这里来放 各个组件。 在compoents 文件夹 新建layout文件夹,在layout文件夹新建index.tsx。
代码语言:bash
复制
mkdir components
cd components
mkdir layout 
touch index.tsx

2.在compoents 文件夹 新建Navbar文件夹,在Navbar文件夹新建index.tsx,同时创建index.module.scss

代码语言:bash
复制
cd components
mkdir Navbar
cd Navbar
touch index.tsx
touch index.module.scss

3.在compoents 文件夹 新建Footer文件夹,在Footer文件夹新建index.tsx,同时创建index.module.scss

代码语言:bash
复制
cd components
mkdir Footer
cd Footer
touch index.tsx
touch index.module.scss

这样先把Layout,Navbar, Footer的架子 搭建起来。

然后开始写 Layout的布局

在 layout/index.tsx中写入, 中间的内容区域,由 props的children来填充,这样的话 ,就实现了 上中下的布局

代码语言:typescript
复制
import type { NextPage } from 'next';
import Navbar from 'components/Navbar';
import Footer from 'components/Footer';


const Layout: NextPage = ({ children }) => {
    return (
        <div>
            <Navbar />
            <main>{children}</main>
            <Footer />
        </div>
    )
}

export default Layout;

写好上面代码以后,需要再入口文件引入 layout

代码语言:typescript
复制
import Layout from 'components/layout'
import { NextPage } from 'next';


return (
    <Layout>
        <Component />
    </Layout>
);

Navbar

接下来 来开发 上部导航区域

先看下要实现的效果图,如下:这里采用 flex 布局

  1. 先把博客系统的名称写下,在Navbar/index.tsx文件下
代码语言:typescript
复制
<div className={styles.navbar}>
      <section className={styles.logoArea}>BLOG</section>
</div>

2.然后开始写标签,这几个标签,采用配置的方式,这里我们再 Navbar文件夹下新建 config.ts 来 存放 这几个导航数据

代码语言:typescript
复制
interfacee NavProps {
    label: string;
    value: string;
}

export const navs: NavProps[] = [
    {
      label: '首页',
      value: '/',
    },
    {
      label: '咨询',
      value: '/info',
    },
    {
      label: '标签',
      value: '/tag',
    },
  ];

3.在Navbar/index.tsx拿到config中的导航数据,然后遍历渲染出来。

同时引入 next提供的link,来进行路由跳转

代码语言:typescript
复制
import Link from 'next/link';
import { navs } from './config';


<section className={styles.linkArea}>
    {navs?.map((nav) => (
        <Link key={nav?.label} href={nav?.value}>
        <a className={pathname === nav?.value ? styles.active : ''}>
            {nav?.label}
        </a>
        </Link>
    ))}
</section>

4.最后再添加两个 写文章 和登录的按钮

代码语言:typescript
复制
<section className={styles.operationArea}>
    <Button onClick={handleGotoEditorPage}>写文章</Button>
    <Button type="primary" onClick={handleLogin}>
        登录
    </Button>
</section>

5.最后整体的样式文件如下:

代码语言:less
复制
.navbar {
    height: 60px;
    background-color: #fff;
    border-bottom: 1px solid #f1f1f1;
    display: flex;
    align-items: center;
    justify-content: center;
  
    .logoArea {
      font-size: 30px;
      font-weight: bolder;
      margin-right: 60px;
    }
  
    .linkArea {
      a {
        font-size: 18px;
        padding: 0 20px;
        color: #515767;
      }
  
      .active {
        color: #1e80ff;
      }
    }
  
    .operationArea {
      margin-left: 150px;
  
      button {
        margin-right: 20px;
      }
    }
  }

这样 导航部分的 初始页面就完成了。

Footer

接下来简单写下Footer部分

在 components/Footer/index.tsx中写入如下代码:

代码语言:typescript
复制
import type { NextPage } from 'next';
import styles from './index.module.scss';

const Footer: NextPage = () => {
  return (
    <div className={styles.footer}>
      <p>博客系统</p>
    </div>
  );
};

export default Footer;

样式文件代码:

代码语言:less
复制
.footer {
    text-align: center;
    color: #72777b;
    padding: 20px;
}

这样简单的footer部分就完成了

最后看下 这样写下来的效果

登录模块

接下来我们要开发登录模块的开发,首先看下效果图:

登录弹窗

1.首先在components创建Login文件夹,在Login文件夹创建index.tsx文件和index.modules.scss

代码语言:bash
复制
cd components
mkdir Login
cd Login
touch index.tsx
touch index.module.scss

2.在Navbar组件中的 登录按钮 添加点击事件

代码语言:typescript
复制
<Button type="primary" onClick={handleLogin}>
    登录
</Button>

3.定义一个state来控制 登录弹窗 是否显示。

代码语言:typescript
复制
const [isShowLogin, setIsShowLogin] = useState(false);

4.将isShowLogin 当做 props 传入 登录组件

代码语言:typescript
复制
<Login isShowLogin={isShowLogin} />

5.接下来开发登录弹窗的布局代码

代码语言:typescript
复制
return isShow ? (
    <div className={styles.loginArea}>
      <div className={styles.loginBox}>
        <div className={styles.loginTitle}>
          <div>手机号登录</div>
          <div className={styles.close} onClick={handleClose}>
            x
          </div>
        </div>
        <input
          name="phone"
          type="text"
          placeholder="请输入手机号"
          value={form.phone}
          onChange={handleFormChange}
        />
        <div className={styles.verifyCodeArea}>
          <input
            name="verify"
            type="text"
            placeholder="请输入验证码"
            value={form.verify}
            onChange={handleFormChange}
          />
          <span className={styles.verifyCode} onClick={handleGetVerifyCode}>
            {isShowVerifyCode ? (
              <CountDown time={10} onEnd={handleCountDownEnd} />
            ) : (
              '获取验证码'
            )}
          </span>
        </div>
        <div className={styles.loginBtn} onClick={handleLogin}>
          登录
        </div>
        <div className={styles.otherLogin} onClick={handleOAuthGithub}>
          使用 Github 登录
        </div>
        <div className={styles.loginPrivacy}>
          注册登录即表示同意
          <a
            href="https://moco.imooc.com/privacy.html"
            target="_blank"
            rel="noreferrer"
          >
            隐私政策
          </a>
        </div>
      </div>
    </div>
  ) : null;

6.对应的样式代码如下:

代码语言:less
复制
.loginArea {
    position: fixed;
    top: 0;
    left: 0;
    z-index: 1000;
    width: 100vw;
    height: 100vh;
    background-color: rgb(0 0 0 / 30%);
  
    .loginBox {
      width: 320px;
      height: 320px;
      background-color: #fff;
      position: relative;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      padding: 20px;
  
      input {
        width: 100%;
        height: 37px;
        margin-bottom: 10px;
        padding: 10px;
        border-radius: 5px;
        border: 1px solid #888;
        outline: none;
      }
  
      input:focus {
        border: 1px solid #1e80ff;
      }
  
      .verifyCodeArea {
        position: relative;
        cursor: pointer;
  
        .verifyCode {
          color: #1e80ff;
          position: absolute;
          right: 20px;
          top: 8px;
          font-size: 14px;
        }
      }
    }
  
    .loginTitle {
      font-size: 20px;
      font-weight: bold;
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 20px;
  
      .close {
        color: #888;
        cursor: pointer;
      }
    }
  
    .loginBtn {
      height: 40px;
      line-height: 40px;
      border-radius: 5px;
      margin-top: 15px;
      background-color: #007fff;
      color: #fff;
      text-align: center;
      cursor: pointer;
    }
  
    .otherLogin {
      margin-top: 15px;
      font-size: 14px;
      color: #1e80ff;
      cursor: pointer;
    }
  
    .loginPrivacy {
      margin-top: 10px;
      color: #333;
      font-size: 14px;
  
      a {
        color: #1e80ff;
      }
    }
  }

接下来 编写 点击逻辑

1.首先 当点击关闭的时候,把弹窗关闭

使用 props 中的 onClose 方法,onClose方法在父组件 Navbar 通过isShowLogin控制隐藏

代码语言:typescript
复制
// Login/index.tsx
const { onClose } = props;
const handleClose = () => {
    onClose && onClose();
};

在入口引入

代码语言:typescript
复制
<Login isShow={isShowLogin} onClose={handleClose} />


  const handleClose = () => {
    setIsShowLogin(false);
  };

接下来开始编写 获取验证码的 逻辑

获取验证码 需要提前编写一个倒计时的组件

接下来开始编写 倒计时组件

代码语言:bash
复制
cd components
mkdir CountDown
cd CountDown
touch index.tsx
touch index.module.scss

在index.tsx中编写如下代码:

思路是: 提供一个 time,表示倒计时的时间。提供一个onEnd回调函数,表示当倒计时结束的时候,进行一些回调处理。

这里需要注意下, 当 time时间为0的时候,需要主动 调 一些 onEnd,表示结束。

代码语言:typescript
复制
import { useState, useEffect } from 'react';
import styles from './index.module.scss';

interface IProps {
  time: number;
  onEnd: Function;
}

const CountDown = (props: IProps) => {
  const { time, onEnd } = props;
  const [count, setCount] = useState(time || 60);

  useEffect(() => {
    const id = setInterval(() => {
      setCount((count) => {
        if (count === 0) {
          clearInterval(id);
          onEnd && onEnd();
          return count;
        }
        return count - 1;
      });
    }, 1000);
    return () => {
      clearInterval(id);
    };
  }, [time, onEnd]);

  return <div className={styles.countDown}>{count}</div>;
};

export default CountDown;

这样完成了倒计时组件的开发。接着编写获取验证码的逻辑。

1.首先 通过 isShowVerifyCode 控制 显示 验证码文字 还是倒计时

代码语言:typescript
复制
<span className={styles.verifyCode} onClick={handleGetVerifyCode}>
    {isShowVerifyCode ? (
        <CountDown time={10} onEnd={handleCountDownEnd} />
    ) : (
        '获取验证码'
    )}
</span>

2.接着当点击 获取验证码的时候,校验一下 手机号是否输入, 如果手机号没有输入,提示用户输入手机号

代码语言:typescript
复制
<span className={styles.verifyCode} onClick={handleGetVerifyCode}>获取验证码</span>


const handleGetVerifyCode = () => {
  if (!form?.phone) {
      message.warning('请输入手机号');
      return;
  }
}

3.如果 手机号输入,则开始 调 获取验证码的接口

代码语言:typescript
复制
const handleGetVerifyCode = () => {
    if (!form?.phone) {
      message.warning('请输入手机号');
      return;
    }

    request
      .post('/api/user/sendVerifyCode', {
        to: form?.phone,
        templateId: 1,
      })
      .then((res: any) => {
        if (res?.code === 0) {
          setIsShowVerifyCode(true);
        } else {
          message.error(res?.msg || '未知错误');
        }
      });
  };

获取验证码

接下来开始编辑 获取 验证码 接口的逻辑

这里采用 云 的 验证码接口

1.根据 云的 接入文档,拼成url

代码语言:typescript
复制
 const session: ISession = req.session;
  const { to = '', templateId = '1' } = req.body;
  const AppId = 'xxx'; // 接入自己的AppId
  const AccountId = 'xxx'; // 接入自己的AccountId
  const AuthToken = 'xxx';   // 接入自己的AuthToken
  const NowDate = format(new Date(), 'yyyyMMddHHmmss');
  const SigParameter = md5(`${AccountId}${AuthToken}${NowDate}`);
  const Authorization = encode(`${AccountId}:${NowDate}`);
  const verifyCode = Math.floor(Math.random() * (9999 - 1000)) + 1000;
  const expireMinute = '5';
  const url = `https://xxx.com:8883/2013-12-26/Accounts/${AccountId}/SMS/TemplateSMS?sig=${SigParameter}`;

2.使用request调用接口,参数 to 代表手机号,templateId 代表是 通过手机号进行登录,appId和datas按文档传入

代码语言:typescript
复制
const response = await request.post(
    url,
    {
      to,
      templateId,
      appId: AppId,
      datas: [verifyCode, expireMinute],
    },
    {
      headers: {
        Authorization,
      },
    }
  );

3.获取 response,根据response 进行处理。当接口调用成功的时候,将验证码保存到session中,同时返回200状态码和成功的数据,当失败的时候,返回失败的原因

代码语言:typescript
复制
const { statusCode, templateSMS, statusMsg } = response as any;

  if (statusCode === '000000') {
    session.verifyCode = verifyCode;
    await session.save();
    res.status(200).json({
      code: 0,
      msg: statusMsg,
      data: {
        templateSMS
      }
    });
  } else {
    res.status(200).json({
      code: statusCode,
      msg: statusMsg
    });
  }

4.当验证码调成功的时候,显示 倒计时

代码语言:typescript
复制
request
      .post('/api/user/sendVerifyCode', {
        to: form?.phone,
        templateId: 1,
      })
      .then((res: any) => {
        if (res?.code === 0) {
          setIsShowVerifyCode(true);
        } else {
          message.error(res?.msg || '未知错误');
        }
      });

效果如下:

开始倒计时,并成功收到验证码

登录逻辑

当成功获取验证码,然后开始进行登录

在用户输入手机号和验证码,点击登录按钮的时候,去调用登录的接口

接口为:/api/user/login

传入表单数据,当成功的时候 将 用户的信息 存入到 store中,并且调用 onClose 将弹窗关闭

代码语言:typescript
复制
const handleLogin = () => {
    request
      .post('/api/user/login', {
        ...form,
        identity_type: 'phone',
      })
      .then((res: any) => {
        if (res?.code === 0) {
          // 登录成功
          store.user.setUserInfo(res?.data);
          onClose && onClose();
        } else {
          message.error(res?.msg || '未知错误');
        }
      });
  };

接下来开始编写 登录接口的逻辑

1.首先从session中获取验证码

代码语言:typescript
复制
const session: ISession = req.session;

2.从body中获取传入的验证码

代码语言:typescript
复制
const { phone = '', verify = '', identity_type = 'phone' } = req.body;

3.比较两个验证码是否相等,如果不相等,则返回 验证码错误

4.如果两个验证码相等,则去用户表中查找,判断用户是否存在,如果用户不存在,则表示注册,如果存在,则表示登录。

代码语言:typescript
复制
// 验证码正确,在 user_auths 表中查找 identity_type 是否有记录
    const userAuth = await userAuthRepo.findOne(
      {
        identity_type,
        identifier: phone,
      },
      {
        relations: ['user'],
      }
    );

5.当用户存在的时候,从数据库中读取除用户信息,存入session和cookie中,并将用户信息返回

代码语言:typescript
复制
// 已存在的用户
      const user = userAuth.user;
      const { id, nickname, avatar } = user;

      session.userId = id;
      session.nickname = nickname;
      session.avatar = avatar;

      await session.save();

      setCookie(cookies, { id, nickname, avatar });

      res?.status(200).json({
        code: 0,
        msg: '登录成功',
        data: {
          userId: id,
          nickname,
          avatar,
        },
      });

6.当用户不存在的时候,将输入的信息 存入到数据库,session和 cookie中,表示用户注册,返回用户信息

代码语言:typescript
复制
 // 新用户,自动注册
      const user = new User();
      user.nickname = `用户_${Math.floor(Math.random() * 10000)}`;
      user.avatar = '/images/avatar.jpg';
      user.job = '暂无';
      user.introduce = '暂无';

      const userAuth = new UserAuth();
      userAuth.identifier = phone;
      userAuth.identity_type = identity_type;
      userAuth.credential = session.verifyCode;
      userAuth.user = user;

      const resUserAuth = await userAuthRepo.save(userAuth);
      const {
        user: { id, nickname, avatar },
      } = resUserAuth;

      session.userId = id;
      session.nickname = nickname;
      session.avatar = avatar;

      await session.save();

      setCookie(cookies, { id, nickname, avatar });

      res?.status(200).json({
        code: 0,
        msg: '登录成功',
        data: {
          userId: id,
          nickname,
          avatar,
        },
      });

点击登录,即可登录成功。

数据库操作

我们这里使用typeorm数据库

首先在根目录创建db文件夹,在db文件建创建entity文件夹,entity存放各个模块的表模型

在db文件夹创建index.ts,用来导出各个模块的表模型

新建db/entity/user.ts

1.Entity指定数据库中的哪个数据表,这里指定 users 数据表

代码语言:typescript
复制
import { Entity, BaseEntity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity({name: 'users'})
export class User extends BaseEntity {
  @PrimaryGeneratedColumn()
  readonly id!: number;

  @Column()
  nickname!: string;

  @Column()
  avatar!: string;

  @Column()
  job!: string;

  @Column()
  introduce!: string;
}

2.使用typeorm链接mysql

3.从typeorm引入

代码语言:typescript
复制
import { Connection, getConnection, createConnection } from 'typeorm';

4.引入数据表

代码语言:typescript
复制
import { User, UserAuth, Article, Comment, Tag } from './entity/index';

5.链接mysql数据库

代码语言:typescript
复制
import 'reflect-metadata';
import { Connection, getConnection, createConnection } from 'typeorm';
import { User, UserAuth, Article, Comment, Tag } from './entity/index';

const host = process.env.DATABASE_HOST;
const port = Number(process.env.DATABASE_PORT);
const username = process.env.DATABASE_USERNAME;
const password = process.env.DATABASE_PASSWORD;
const database = process.env.DATABASE_NAME;

let connectionReadyPromise: Promise<Connection> | null = null;
console.log('username', username)
export const prepareConnection = () => {
  if (!connectionReadyPromise) {
    connectionReadyPromise = (async () => {
      try {
        const staleConnection = getConnection();
        await staleConnection.close();
      } catch (error) {
        console.log(error);
      }

      const connection = await createConnection({
        type: 'mysql',
        host,
        port,
        username,
        password,
        database,
        entities: [User, UserAuth, Article, Comment, Tag],
        synchronize: false,
        logging: true,
      },6.

  

      return connection;
    })();
  }

  return connectionReadyPromise;
};

6.在接口侧 引入数据库

代码语言:typescript
复制
import { prepareConnection } from 'db/index';

const db = await prepareConnection();

7.引入数据表,使用db获取 指定的数据表,userAuthRepo来操作mysql

代码语言:typescript
复制
import { User, UserAuth } from 'db/entity/index';

const db = await prepareConnection();
const userAuthRepo = db.getRepository(UserAuth);

8.从users表查询数据

代码语言:typescript
复制
const userAuth = await userAuthRepo.findOne(
      {
        identity_type,
        identifier: phone,
      },
      {
        relations: ['user'],
      }
    );

9.如果userAuth 有数据,则表示登录,没有数据则表示注册

10.如果是登录,从user中获取当前用户的信息,将这些信息一方面存入session,一方面存入cookie,最后返回200状态码,同时将用户信息返回

11.如果是注册,将这些输入的用户信息,存入users表中,同时将这些信息存入到session和cookie中,同时返回200状态码和这些用户信息

代码语言:typescript
复制
 if (userAuth) {
      // 已存在的用户
      const user = userAuth.user;
      const { id, nickname, avatar } = user;

      session.userId = id;
      session.nickname = nickname;
      session.avatar = avatar;

      await session.save();

      setCookie(cookies, { id, nickname, avatar });

      res?.status(200).json({
        code: 0,
        msg: '登录成功',
        data: {
          userId: id,
          nickname,
          avatar,
        },
      });
    } else {
      // 新用户,自动注册
      const user = new User();
      user.nickname = `用户_${Math.floor(Math.random() * 10000)}`;
      user.avatar = '/images/avatar.jpg';
      user.job = '暂无';
      user.introduce = '暂无';

      const userAuth = new UserAuth();
      userAuth.identifier = phone;
      userAuth.identity_type = identity_type;
      userAuth.credential = session.verifyCode;
      userAuth.user = user;

      const resUserAuth = await userAuthRepo.save(userAuth);
      const {
        user: { id, nickname, avatar },
      } = resUserAuth;

      session.userId = id;
      session.nickname = nickname;
      session.avatar = avatar;

      await session.save();

      setCookie(cookies, { id, nickname, avatar });

      res?.status(200).json({
        code: 0,
        msg: '登录成功',
        data: {
          userId: id,
          nickname,
          avatar,
        },
      });
    }

发布文章

1.当点击 写文章的时候,先判断用户是否登录,如果没有登录,则提示用户先登录,如果已经登录,则跳到新建文章页面

代码语言:typescript
复制
<Button onClick={handleGotoEditorPage}>写文章</Button>

const handleGotoEditorPage = () => {
    if (userId) {
      push('/editor/new');
    } else {
      message.warning('请先登录');
    }
  };

2.在pages目录下创建 editor/new.tsx,表示 新建文章的页面

3.首先编写 markdown编辑器,这里使用 开源的一款markdown编辑器,@uiw/react-md-editor

安装

代码语言:bash
复制
yarn add @uiw/react-md-editor

4.引入编辑器

代码语言:typescript
复制
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });

import '@uiw/react-md-editor/markdown-editor.css';
import '@uiw/react-markdown-preview/markdown.css';


 <MDEditor />

5.定义state表示编辑器的内容

代码语言:typescript
复制
 const [content, setContent] = useState('');
 <MDEditor value={content} height={1080}  />

6.添加change事件

代码语言:typescript
复制
<MDEditor value={content} height={1080} onChange={handleContentChange} />

  const handleContentChange = (content: any) => {
    setContent(content);
  };

7.添加 输入标题 组件

代码语言:typescript
复制
const [title, setTitle] = useState('');
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
    setTitle(event?.target?.value);
  };
<Input
          className={styles.title}
          placeholder="请输入文章标题"
          value={title}
          onChange={handleTitleChange}
        />

8.添加 标签选择 组件

代码语言:typescript
复制
<Select
          className={styles.tag}
          mode="multiple"
          allowClear
          placeholder="请选择标签"
          onChange={handleSelectTag}
        >{allTags?.map((tag: any) => (
          <Select.Option key={tag?.id} value={tag?.id}>{tag?.title}</Select.Option>
        ))}</Select>

9.新增 state 控制 标签

代码语言:typescript
复制
const [allTags, setAllTags] = useState([]);

10.添加 选择 标签的 事件

代码语言:typescript
复制
const handleSelectTag = (value: []) => {
    setTagIds(value);
  }

11.新建 标签的 数据表

代码语言:typescript
复制
import { Entity, BaseEntity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable } from 'typeorm';
import { User } from './user'
import { Article } from './article'

@Entity({name: 'tags'})
export class Tag extends BaseEntity {
  @PrimaryGeneratedColumn()
  readonly id!: number;

  @Column()
  title!: string;

  @Column()
  icon!: string;

  @Column()
  follow_count!: number;

  @Column()
  article_count!: number;

  @ManyToMany(() => User, {
    cascade: true
  })
  @JoinTable({
    name: 'tags_users_rel',
    joinColumn: {
      name: 'tag_id'
    },
    inverseJoinColumn: {
      name: 'user_id'
    }
  })
  users!: User[]

  @ManyToMany(() => Article, (article) => article.tags)
  @JoinTable({
    name: 'articles_tags_rel',
    joinColumn: {
      name: 'tag_id'
    },
    inverseJoinColumn: {
      name: 'article_id'
    }
  })
  articles!: Article[]
}

新增 获取所有标签的接口,新建 api/tag/get.ts

1.从session中获取用户信息

2.从tag表 查询 所有 标签数据

3.关联users表,根据users表,查询所有标签,返回allTags

4.关联User表,根据当前登录用户的信息,查询该用户 关注的标签,返回followTags

代码语言:typescript
复制
import { NextApiRequest, NextApiResponse } from 'next';
import { withIronSessionApiRoute } from 'iron-session/next';
import { ironOptions } from 'config/index';
import { ISession } from 'pages/api/index';
import { prepareConnection } from 'db/index';
import { Tag } from 'db/entity/index';

export default withIronSessionApiRoute(get, ironOptions);

async function get(req: NextApiRequest, res: NextApiResponse) {
  const session: ISession = req.session;
  const { userId = 0 } = session;
  const db = await prepareConnection();
  const tagRepo = db.getRepository(Tag);

  const followTags = await tagRepo.find({
    relations: ['users'],
    where: (qb: any) => {
      qb.where('user_id = :id', {
        id: Number(userId),
      });
    },
  });

  const allTags = await tagRepo.find({
    relations: ['users'],
  });

  res?.status(200)?.json({
    code: 0,
    msg: '',
    data: {
      followTags,
      allTags,
    },
  });
}

5.在editor/new.tsx中 调 获取 标签的接口拿到标签数据

代码语言:typescript
复制
useEffect(() => {
    request.get('/api/tag/get').then((res: any) => {
      if (res?.code === 0) {
        setAllTags(res?.data?.allTags || [])
      }
    })
  }, []);

最后渲染 所有标签

代码语言:typescript
复制
<Select
          className={styles.tag}
          mode="multiple"
          allowClear
          placeholder="请选择标签"
          onChange={handleSelectTag}
        >{allTags?.map((tag: any) => (
          <Select.Option key={tag?.id} value={tag?.id}>{tag?.title}</Select.Option>
        ))}</Select>

这样页面就出来了,也获取到了markdown,标签,标题的数据

然后开始写发布文章:

1.先判断是否输入标题,如果没有输入标题,就提示用户输入标题

2.然后调 发布文章的接口,参数就是 标题,markdown数据,标签

3.当接口调取成功的时候,提示发布成功,并跳到用户中心 的页面

4.当接口调取失败的时候,提示发布失败

代码语言:typescript
复制
const handlePublish = () => {
    if (!title) {
      message.warning('请输入文章标题');
      return ;
    }
    request.post('/api/article/publish', {
      title,
      content,
      tagIds
    }).then((res: any) => {
      if (res?.code === 0) {
        userId ? push(`/user/${userId}`) : push('/');
        message.success('发布成功');
      } else {
        message.error(res?.msg || '发布失败');
      }
    })
  };

现在写下 发布文章的接口

新建 api/artice/publish.ts

1.引入数据库和user, tag, article三张数据表

代码语言:typescript
复制
import { prepareConnection } from 'db/index';
import { User, Article, Tag } from 'db/entity/index';

2.链接三个数据表

代码语言:typescript
复制
const db = await prepareConnection();
  const userRepo = db.getRepository(User);
  const articleRepo = db.getRepository(Article);
  const tagRepo = db.getRepository(Tag);

3.从req.body中获取传入的参数

代码语言:typescript
复制
const { title = '', content = '', tagIds = [] } = req.body;

4.从session中获取用户信息

代码语言:typescript
复制
const session: ISession = req.session;

5.根据session从user表中查询当前用户信息

代码语言:typescript
复制
const user = await userRepo.findOne({
    id: session.userId,
  });

6.根据传入的标签,获取所有的标签

代码语言:typescript
复制
const tags = await tagRepo.find({
    where: tagIds?.map((tagId: number) => ({ id: tagId })),
  });

7.将传入的数据 存入到 article表中, 如果有用户信息,将用户信息也存入表,并且标签数量增加

代码语言:typescript
复制
const article = new Article();
  article.title = title;
  article.content = content;
  article.create_time = new Date();
  article.update_time = new Date();
  article.is_delete = 0;
  article.views = 0;

if (user) {
    article.user = user;
  }

  if (tags) {
    const newTags = tags?.map((tag) => {
      tag.article_count = tag?.article_count + 1;
      return tag;
    });
    article.tags = newTags;
  }

  const resArticle = await articleRepo.save(article);

  if (resArticle) {
    res.status(200).json({ data: resArticle, code: 0, msg: '发布成功' });
  } else {
    res.status(200).json({ ...EXCEPTION_ARTICLE.PUBLISH_FAILED });
  }

这样就完成了文章发布

ssr渲染首页文章列表

nextjs 提供 getServerSideProps 来获取数据,返回到props中,然后在react组件中通过props获取数据进行渲染,达到ssr效果。

1.引入数据库和tag,article两张表

代码语言:typescript
复制
import { prepareConnection } from 'db/index';
import { Article, Tag } from 'db/entity';

2.链接数据库

代码语言:typescript
复制
const db = await prepareConnection();

3.根据 关联的 user和tag查询出 所有 文章

代码语言:typescript
复制
const articles = await db.getRepository(Article).find({
    relations: ['user', 'tags'],
  });

4.根据 关联的 user 查询出 标签

代码语言:typescript
复制
 const tags = await db.getRepository(Tag).find({
    relations: ['users'],
  });

5.最后将 文章和标签通过props返回

代码语言:typescript
复制
return {
    props: {
      articles: JSON.parse(JSON.stringify(articles)) || [],
      tags: JSON.parse(JSON.stringify(tags)) || [],
    },
  };

6.在react组件中 通过props获取 文章和标签

代码语言:typescript
复制
const { articles = [], tags = [] } = props;

7.默认将 获取的 文章,存放到所有文章的state中

代码语言:typescript
复制
const [showAricles, setShowAricles] = useState([...articles]);

8.然后渲染当前所有的文章

代码语言:typescript
复制
<div className="content-layout">
        {showAricles?.map((article) => (
          <>
            <DynamicComponent article={article} />
            <Divider />
          </>
        ))}
      </div>

9.上面的文章列表通过 异步加载的方式加载

代码语言:typescript
复制
const DynamicComponent = dynamic(() => import('components/ListItem'));

10.新建 components/ListItem/index.tsx components/ListItem/index.module.scss

通过 props 可以获取到 从 父组件传过来的 article和 user信息

拿到这两个信息后,将这两个字段里面的内容 渲染处理即可

需要注意的是,需要点击谋篇文章的时候,跳转到该文章的详情页面,所以需要使用 Link

另外一个需要注意的地方是,渲染文章的时候,文章是markdown格式

所以使用 markdown-to-txt 第三方包 来加载 markdown格式的数据

所以代码是这样的

代码语言:typescript
复制
import Link from 'next/link';
import { formatDistanceToNow } from 'date-fns';
import { IArticle } from 'pages/api/index';
import { Avatar } from 'antd';
import { EyeOutlined } from '@ant-design/icons';
import { markdownToTxt } from 'markdown-to-txt';
import styles from './index.module.scss';

interface IProps {
  article: IArticle;
}

const ListItem = (props: IProps) => {
  const { article } = props;
  const { user } = article;

  return (
    // eslint-disable-next-line @next/next/link-passhref
    <Link href={`/article/${article.id}`}>
      <div className={styles.container}>
        <div className={styles.article}>
          <div className={styles.userInfo}>
            <span className={styles.name}>{user?.nickname}</span>
            <span className={styles.date}>
              {formatDistanceToNow(new Date(article?.update_time))}
            </span>
          </div>
          <h4 className={styles.title}>{article?.title}</h4>
          <p className={styles.content}>{markdownToTxt(article?.content)}</p>
          <div className={styles.statistics}>
            <EyeOutlined />
            <span className={styles.item}>{article?.views}</span>
          </div>
        </div>
        <Avatar src={user?.avatar} size={48} />
      </div>
    </Link>
  );
};

export default ListItem;

11.css代码

代码语言:less
复制
.container {
    margin: 0 atuo;
    background-color: #fff;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 10px;
    cursor: pointer;
  
    .article {
      width: 90%;
  
      .userInfo {
        margin-bottom: 10px;
        display: flex;
        align-items: center;
  
        span {
          padding: 0 10px;
          border-right: 1px solid #e5e6eb;
        }
  
        span:first-of-type {
          padding-left: 0;
        }
  
        span:last-of-type {
          border-right: 0;
        }
  
        .name {
          color: #4e5969;
        }
  
        .name:hover {
          text-decoration: underline;
          color: #1e80ff;
        }
  
        .date {
          color: #86909c;
        }
      }
  
      .title {
        font-size: 20px;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
  
      .content {
        font-size: 16px;
        color: #86909c;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
  
      .statistics {
        display: flex;
        align-items: center;
  
        .item {
          margin-left: 5px;
        }
      }
    }
  }
  

看下效果:

ssr渲染文章详情页

这里需要使用 nextjs中的动态路由

1.在pages/article 新建 id.tsx,表示 文章详情页的入口文件

同时新建 pages/article/index.module.scss

2.通过 url 获取 文章的 id字段

3.然后根据通过ssr获取文章详情数据

4.根据id 去数据表中查询当前文章的详情

5.这里增加一个功能,就是浏览次数,当前查询的时候,浏览次数增加 1 次

整体代码如下:

代码语言:typescript
复制
export async function getServerSideProps({ params }: any) {
  const articleId = params?.id;
  const db = await prepareConnection();
  const articleRepo = db.getRepository(Article);
  const article = await articleRepo.findOne({
    where: {
      id: articleId,
    },
    relations: ['user', 'comments', 'comments.user'],
  });

  if (article) {
    // 阅读次数 +1
    article.views = article?.views + 1;
    await articleRepo.save(article);
  }

  return {
    props: {
      article: JSON.parse(JSON.stringify(article)),
    },
  };
}

通过以上 ssr 代码就拿到了 当前文章的数据

然后渲染这些基本信息

这里 markdown的内容 使用 markdown-to-jsx 第三方库 来加载

代码语言:typescript
复制
<div className="content-layout">
        <h2 className={styles.title}>{article?.title}</h2>
        <div className={styles.user}>
          <Avatar src={avatar} size={50} />
          <div className={styles.info}>
            <div className={styles.name}>{nickname}</div>
            <div className={styles.date}>
              <div>
                {format(new Date(article?.update_time), 'yyyy-MM-dd hh:mm:ss')}
              </div>
              <div>阅读 {article?.views}</div>
            </div>
          </div>
        </div>
        <MarkDown className={styles.markdown}>{article?.content}</MarkDown>
      </div>

接着增加 是否显示编辑的逻辑

通过store拿到 当前登录的用户信息

代码语言:typescript
复制
const store = useStore();
  const loginUserInfo = store?.user?.userInfo;

当 用户登录的时候,显示编辑按钮

并且 点击 编辑 按钮 跳转到 文章 编辑页面

代码语言:typescript
复制
{Number(loginUserInfo?.userId) === Number(id) && (
                <Link href={`/editor/${article?.id}`}>编辑</Link>
              )}

编辑文章

文章渲染

因为 编辑文章是编辑不同的文章,所以这里需要 使用动态 路由

1.首先新建 pages/editor/id.tsx 和 index.module.scss

2.编辑文章 首先 需要 把 当前的文章详情 回显到页面上

这里通过 url 获取 当前 文章的 id,然后通过ssr渲染的方式进行渲染

3.根据 文章 id 和 关联的 用户表,链接 文章的 数据表,查询出来 属于 当前用户发布的这篇文章

最后将 查询出来的 文章详情返回

代码语言:typescript
复制
export async function getServerSideProps({ params }: any) {
  const articleId = params?.id;
  const db = await prepareConnection();
  const articleRepo = db.getRepository(Article);
  const article = await articleRepo.findOne({
    where: {
      id: articleId,
    },
    relations: ['user'],
  });

  return {
    props: {
      article: JSON.parse(JSON.stringify(article)),
    },
  };
}

在react客户端组件中,通过props获取article数据

1.将 文章标题,文章内容通过state来控制,初始值是props获取的数据

代码语言:typescript
复制
const [title, setTitle] = useState(article?.title || '');
const [content, setContent] = useState(article?.content || '');

2.通过 useRouter hooks 获取 文章Id

代码语言:typescript
复制
const { push, query } = useRouter();
const articleId = Number(query?.id)

3.将获取的文章数据渲染出来

代码语言:typescript
复制
return (
    <div className={styles.container}>
      <div className={styles.operation}>
        <Input
          className={styles.title}
          placeholder="请输入文章标题"
          value={title}
          onChange={handleTitleChange}
        />
        <Select
          className={styles.tag}
          mode="multiple"
          allowClear
          placeholder="请选择标签"
          onChange={handleSelectTag}
        >{allTags?.map((tag: any) => (
          <Select.Option key={tag?.id} value={tag?.id}>{tag?.title}</Select.Option>
        ))}</Select>
        <Button
          className={styles.button}
          type="primary"
          onClick={handlePublish}
        >
          发布
        </Button>
      </div>
      <MDEditor value={content} height={1080} onChange={handleContentChange} />
    </div>
  );

4.修改标题,通过state控制

代码语言:typescript
复制
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
    setTitle(event?.target?.value);
  };

5.修改 文章内容的时候,也是通过state控制

代码语言:typescript
复制
 const handleContentChange = (content: any) => {
    setContent(content);
  };

6.这里 新增一个 获取所有标签的接口

首先 调用 标签接口,将标签数据存到state中

代码语言:typescript
复制
useEffect(() => {
    request.get('/api/tag/get').then((res: any) => {
      if (res?.code === 0) {
        setAllTags(res?.data?.allTags || [])
      }
    })
  }, []);

接下来编写下 获取标签的接口

新建 pages/api/tag/get.ts

1.首先通过session获取当前用户信息

代码语言:typescript
复制
const session: ISession = req.session;
  const { userId = 0 } = session;

2.链接 标签的数据表

代码语言:typescript
复制
const db = await prepareConnection();
  const tagRepo = db.getRepository(Tag);

3.根据当前关联的用户表,查询出来所有标签

代码语言:typescript
复制
const allTags = await tagRepo.find({
    relations: ['users'],
  });

4.根据用户id查询出来 当前用户关注的标签

代码语言:typescript
复制
const followTags = await tagRepo.find({
    relations: ['users'],
    where: (qb: any) => {
      qb.where('user_id = :id', {
        id: Number(userId),
      });
    },
  });

5.最后将所有的标签 和 当前用户 关注的 标签 返回

代码语言:typescript
复制
res?.status(200)?.json({
    code: 0,
    msg: '',
    data: {
      followTags,
      allTags,
    },
  });

6.在客户端 拿到 所有标签数据后渲染出来

代码语言:typescript
复制
<Select
          className={styles.tag}
          mode="multiple"
          allowClear
          placeholder="请选择标签"
          onChange={handleSelectTag}
        >{allTags?.map((tag: any) => (
          <Select.Option key={tag?.id} value={tag?.id}>{tag?.title}</Select.Option>
        ))}</Select>

更新文章

1、当点击更新的时候,首先判断一下 是否 输入了标题,如果没有输入标题,则提示用户输入标题

代码语言:typescript
复制
if (!title) {
      message.warning('请输入文章标题');
      return ;
    }

2、然后传参数调用更新文章的接口

3、传的参数包括 文章id、标题、内容、标签

4、当调用更新文章接口成功的时候提示更新文章成功并跳到当前文章

5、如果失败,则提示发布失败

代码语言:typescript
复制
request.post('/api/article/update', {
      id: articleId,
      title,
      content,
      tagIds
    }).then((res: any) => {
      if (res?.code === 0) {
        articleId ? push(`/article/${articleId}`) : push('/');
        message.success('更新成功');
      } else {
        message.error(res?.msg || '发布失败');
      }
    })

6、接着编写 更新文章的接口,新建 pages/api/article/update.ts

7、通过body获取 前端传过来的数据

代码语言:typescript
复制
const { title = '', content = '', id = 0, tagIds = [] } = req.body;

8、链接文章和标签的数据库

代码语言:typescript
复制
const articleRepo = db.getRepository(Article);
  const tagRepo = db.getRepository(Tag);

9、根据文章的id,关联用户表和标签表,查询出来当前文章

代码语言:typescript
复制
const article = await articleRepo.findOne({
    where: {
      id,
    },
    relations: ['user', 'tags'],
  });

10、判断查询出来的article是否存在,如果不存在,则提示文章不存在

代码语言:typescript
复制
res.status(200).json({ ...EXCEPTION_ARTICLE.NOT_FOUND });

11、如果存在,则将传过来的文章数据 覆盖之前的数据,如果保存成功,则提示成功,否则提示失败

代码语言:typescript
复制
if (article) {
    article.title = title;
    article.content = content;
    article.update_time = new Date();
    article.tags = newTags;

    const resArticle = await articleRepo.save(article);

    if (resArticle) {
      res.status(200).json({ data: resArticle, code: 0, msg: '更新成功' });
    } else {
      res.status(200).json({ ...EXCEPTION_ARTICLE.UPDATE_FAILED });
    }
  }

12、这里需要根据传过来的标签id,查询出来所有标签,然后将标签数量加1

代码语言:typescript
复制
const tags = await tagRepo.find({
    where: tagIds?.map((tagId: number) => ({ id: tagId })),
  });

  const newTags = tags?.map((tag) => {
    tag.article_count = tag.article_count + 1;
    return tag;
  });

13、最后记得将 需要的 第三方库引入进来

代码语言:typescript
复制
import { NextApiRequest, NextApiResponse } from 'next';
import { withIronSessionApiRoute } from 'iron-session/next';
import { ironOptions } from 'config/index';
import { prepareConnection } from 'db/index';
import { Article, Tag } from 'db/entity/index';
import { EXCEPTION_ARTICLE } from 'pages/api/config/codes';

这样就完成了编辑文章的前后端开发。

发布评论

评论渲染

1.首先 先编写 发布评论 和评论列表的页面,只有登录的用户才能发布评论,所以这里有个判断,判断只有获取到用户的信息,才显示 发布评论的 按钮

代码语言:typescript
复制
const store = useStore();
const loginUserInfo = store?.user?.userInfo;



{loginUserInfo?.userId && (
            <div className={styles.enter}>
              <Avatar src={avatar} size={40} />
              <div className={styles.content}>
                <Input.TextArea
                  placeholder="请输入评论"
                  rows={4}
                  value={inputVal}
                  onChange={(event) => setInputVal(event?.target?.value)}
                />
                <Button type="primary" onClick={handleComment}>
                  发表评论
                </Button>
              </div>
            </div>
          )}

2.然后 获取 所有的 评论 列表,渲染到页面上

代码语言:typescript
复制
<div className={styles.display}>
            {comments?.map((comment: any) => (
              <div className={styles.wrapper} key={comment?.id}>
                <Avatar src={comment?.user?.avatar} size={40} />
                <div className={styles.info}>
                  <div className={styles.name}>
                    <div>{comment?.user?.nickname}</div>
                    <div className={styles.date}>
                      {format(
                        new Date(comment?.update_time),
                        'yyyy-MM-dd hh:mm:ss'
                      )}
                    </div>
                  </div>
                  <div className={styles.content}>{comment?.content}</div>
                </div>
              </div>
            ))}
          </div>

评论发布接口

这里 有 两个逻辑接口,一个是 发布评论的接口,一个是 获取所有评论数据的接口

首先 编写 发布评论的接口

1.首先获取 参数,一个参数是文章的id,一个是评论的内容

2.将这两个参数 传给 发布评论的接口

代码语言:typescript
复制
post('/api/comment/publish', {
        articleId: article?.id,
        content: inputVal,
      })

3.接下来 看下 发布评论的接口

4.新建 pages/api/comment/publish.ts

5.引入 数据库 和 session的配置

代码语言:typescript
复制
import { NextApiRequest, NextApiResponse } from 'next';
import { withIronSessionApiRoute } from 'iron-session/next';
import { ironOptions } from 'config/index';
import { ISession } from 'pages/api/index';
import { prepareConnection } from 'db/index';
import { User, Article, Comment } from 'db/entity/index';
import { EXCEPTION_COMMENT } from 'pages/api/config/codes';

6.通过 传过来的参数 获取 文章id 和 评论的内容

代码语言:typescript
复制
const { articleId = 0, content = '' } = req.body;

7.链接 评论接口的 数据库

代码语言:typescript
复制
 const db = await prepareConnection();
  const commentRepo = db.getRepository(Comment);

  const comment = new Comment();

8.实例化 Comment类,根据 session信息,从users表中查询 当前用户,根据文章id,查询文章信息,将这些信息全部添加到 comment实例中,保存到 comment表中

代码语言:typescript
复制
  const comment = new Comment();
  comment.content = content;
  comment.create_time = new Date();
  comment.update_time = new Date();

  const user = await db.getRepository(User).findOne({
    id: session?.userId,
  });

  const article = await db.getRepository(Article).findOne({
    id: articleId,
  });

  if (user) {
    comment.user = user;
  }
  if (article) {
    comment.article = article;
  }

  const resComment = await commentRepo.save(comment);

9.如果保存成功,则提示发布成功,否则提示发布失败

代码语言:typescript
复制
if (resComment) {
    res.status(200).json({
      code: 0,
      msg: '发表成功',
      data: resComment,
    });
  } else {
    res.status(200).json({
      ...EXCEPTION_COMMENT.PUBLISH_FAILED,
    });
  }

10.当调用发布接口成功的时候,提示发布成功,并且将新发布的评论 添加到 评论列表中,显示在评论中。同时把评论框的内容清空。注意这个将 新发布的评论 添加到 评论列表的时候,使用react的不可变原则,使用concat方法。

代码语言:typescript
复制
request
      .post('/api/comment/publish', {
        articleId: article?.id,
        content: inputVal,
      })
      .then((res: any) => {
        if (res?.code === 0) {
          message.success('发表成功');
          const newComments = [
            {
              id: Math.random(),
              create_time: new Date(),
              update_time: new Date(),
              content: inputVal,
              user: {
                avatar: loginUserInfo?.avatar,
                nickname: loginUserInfo?.nickname,
              },
            },
          ].concat([...(comments as any)]);
          setComments(newComments);
          setInputVal('');
        } else {
          message.error('发表失败');
        }
      });

11.最后拿到最新的 评论列表,将评论列表 遍历 渲染到页面上

代码语言:typescript
复制
<div className={styles.display}>
            {comments?.map((comment: any) => (
              <div className={styles.wrapper} key={comment?.id}>
                <Avatar src={comment?.user?.avatar} size={40} />
                <div className={styles.info}>
                  <div className={styles.name}>
                    <div>{comment?.user?.nickname}</div>
                    <div className={styles.date}>
                      {format(
                        new Date(comment?.update_time),
                        'yyyy-MM-dd hh:mm:ss'
                      )}
                    </div>
                  </div>
                  <div className={styles.content}>{comment?.content}</div>
                </div>
              </div>
            ))}
          </div>

标签管理

首先 新建 pages/tag/index.tsx和 pages/tag/index.module.scss分别 存放 标签的 页面和样式

这个页面 我们采用 csr的方式来渲染页面,看看和ssr渲染页面的方式有何不同

在这个页面 我们设计成 全部标签 和关注的标签,页面效果如下:

首先 我们 先 编写接口, 来获取 全部标签和已关注的标签

新建 pages/api/tag/get.ts

1.首先 引入 数据库等的配置

代码语言:typescript
复制
import { NextApiRequest, NextApiResponse } from 'next';
import { withIronSessionApiRoute } from 'iron-session/next';
import { ironOptions } from 'config/index';
import { ISession } from 'pages/api/index';
import { prepareConnection } from 'db/index';
import { Tag } from 'db/entity/index';

2.通过 session 获取 当前用户的id,因为我们需要根据用户id获取该用户的标签数据

代码语言:typescript
复制
const { userId = 0 } = session;

3.链接 标签 数据库的 配置

代码语言:typescript
复制
const db = await prepareConnection();
  const tagRepo = db.getRepository(Tag);

4.首先 获取 全部标签的数据,这个我们只需要 根据 关联 的用户表去 标签的 数据表 查询即可

代码语言:typescript
复制
const allTags = await tagRepo.find({
    relations: ['users'],
  });

5.接下来 获取 关注的标签,关注的标签逻辑是,根据当前用户的id去查询标签数据,这样获取的数据就是该用户关注的标签数据

代码语言:typescript
复制
const followTags = await tagRepo.find({
    relations: ['users'],
    where: (qb: any) => {
      qb.where('user_id = :id', {
        id: Number(userId),
      });
    },
  });

6.最后将 获取的 所有标签数据 和 关注的标签数据 返回

代码语言:typescript
复制
res?.status(200)?.json({
    code: 0,
    msg: '',
    data: {
      followTags,
      allTags,
    },
  });

7.接下来 我们在客户端 使用 csr的方式 来获取 全部标签和已关注的标签数据。同followTags和allTags来分别存储全部标签数据和已关注的标签数据

代码语言:typescript
复制
const [followTags, setFollowTags] = useState<ITag[]>();
  const [allTags, setAllTags] = useState<ITag[]>();

useEffect(() => {
    request('/api/tag/get').then((res: any) => {
      if (res?.code === 0) {
        const { followTags = [], allTags = [] } = res?.data || {};
        setFollowTags(followTags);
        setAllTags(allTags);
      }
    })
  }, [needRefresh]);

8.接下来 来渲染 全部标签的数据,这里有个逻辑,就是 显示 关注 还是已关注。当 当前用户id 能够在 接口返回的users中返回的id中能够找打,则表明 当前用户 已关注了 这个标签,则页面上显示 已关注,否则显示关注。当显示已关注的时候,按钮事件则是 取消关注的逻辑,否则则是 关注的逻辑。

代码语言:typescript
复制
<TabPane tab="全部标签" key="all" className={styles.tags}>
        {
            allTags?.map(tag => (
              <div key={tag?.title} className={styles.tagWrapper}>
                <div>{(ANTD_ICONS as any)[tag?.icon]?.render()}</div>
                <div className={styles.title}>{tag?.title}</div>
                <div>{tag?.follow_count} 关注 {tag?.article_count} 文章</div>
                {
                  tag?.users?.find((user) => Number(user?.id) === Number(userId)) ? (
                    <Button type='primary' onClick={() => handleUnFollow(tag?.id)}>已关注</Button>
                  ) : (
                    <Button onClick={() => handleFollow(tag?.id)}>关注</Button>
                  )
                }
              </div>
            ))
          }
        </TabPane>

9.首先 编写 关注 标签的逻辑,新建 pages/api/tag/follow.ts

10.首先引入数据库配置

代码语言:typescript
复制
import { NextApiRequest, NextApiResponse } from 'next';
import { withIronSessionApiRoute } from 'iron-session/next';
import { ironOptions } from 'config/index';
import { ISession } from 'pages/api/index';
import { prepareConnection } from 'db/index';
import { Tag, User } from 'db/entity/index';
import { EXCEPTION_USER, EXCEPTION_TAG } from 'pages/api/config/codes';

export default withIronSessionApiRoute(follow, ironOptions);

11.从session获取用户的id

代码语言:typescript
复制
 const session: ISession = req.session;
  const { userId = 0 } = session;

12.从 body中获取 前端传过来的参数,一共两个参数,一个type,值分别是follow和unfollow,表示是取消关注还是关注,另外一个参数数标签的id

代码语言:typescript
复制
const { tagId, type } = req?.body || {};

13.链接 标签和用户的数据库

代码语言:typescript
复制
 const db = await prepareConnection();
  const tagRepo = db.getRepository(Tag);
  const userRepo = db.getRepository(User);

14.根据用户id去用户表中查询该用户信息,如果没找到,则提示当前用户不存在

代码语言:typescript
复制
const user = await userRepo.findOne({
    where: {
      id: userId,
    },
  });

if (!user) {
    res?.status(200).json({
      ...EXCEPTION_USER?.NOT_LOGIN,
    });
    return;
  }

15.根据标签id从标签的数据表中查询所有标签

代码语言:typescript
复制
const tag = await tagRepo.findOne({
    relations: ['users'],
    where: {
      id: tagId,
    },
  });

16.如果从标签表中查询出有用户,如果类型是follow,则表示是关注操作,则将当前用户添加到 关注该标签的用户数据中,并且将关注该标签的数据增加1,如果类型是unfollow,则表示取消关注操作,则将当前用户从 关注该标签的用户数据中剔除,并且将关注该标签的数据减1.

代码语言:typescript
复制
if (tag?.users) {
    if (type === 'follow') {
      tag.users = tag?.users?.concat([user]);
      tag.follow_count = tag?.follow_count + 1;
    } else if (type === 'unfollow') {
      tag.users = tag?.users?.filter((user) => user.id !== userId);
      tag.follow_count = tag?.follow_count - 1;
    }
  }

17.最后将 标签的数据存入 标签的数据表中,如果成功,则返回200,否则提示失败

代码语言:typescript
复制
if (tag) {
    const resTag = await tagRepo?.save(tag);
    res?.status(200)?.json({
      code: 0,
      msg: '',
      data: resTag,
    });
  } else {
    res?.status(200)?.json({
      ...EXCEPTION_TAG?.FOLLOW_FAILED,
    });
  }

18.在前端点击关注的时候,传入两个参数,一个参数是type,值为follw,另外一个参数是标签id,如果接口成功,在前端提示关注成功,并且重新调标签的数据,刷新页面

代码语言:typescript
复制
request.post('/api/tag/follow', {
      type: 'follow',
      tagId
    }).then((res: any) => {
      if (res?.code === 0) {
        message.success('关注成功');
        setNeedRefresh(!needRefresh);
      } else {
        message.error(res?.msg || '关注失败');
      }
    })

19.取消关注,则是将type参数的值改成 unfollow。

这样完成了标签管理功能。

个人中心页面

首先看下页面的效果

接下来 就按照 这个设计 来编写代码

个人中心页面,我们使用ssr的方式来渲染

1.首先引入数据库等的配置

代码语言:typescript
复制
/* eslint-disable @next/next/link-passhref */
import React from 'react';
import Link from 'next/link';
import { observer } from 'mobx-react-lite';
import { Button, Avatar, Divider } from 'antd';
import {
  CodeOutlined,
  FireOutlined,
  FundViewOutlined,
} from '@ant-design/icons';
import ListItem from 'components/ListItem';
import { prepareConnection } from 'db/index';
import { User, Article } from 'db/entity';

2.通过ssr的方式获取用户信息和文章相关的数据

3.根据url获取当前用户的id

代码语言:typescript
复制
const userId = params?.id;

4.根据当前用户的id查询 从用户表中查询当前用户的信息

代码语言:typescript
复制
const user = await db.getRepository(User).findOne({
    where: {
      id: Number(userId),
    },
  });

5.根据用户id以及关联的用户表和标签表查询相关联的文章

代码语言:typescript
复制
const articles = await db.getRepository(Article).find({
    where: {
      user: {
        id: Number(userId),
      },
    },
    relations: ['user', 'tags'],
  });

6.最后将上面两个数据返回

代码语言:typescript
复制
return {
    props: {
      userInfo: JSON.parse(JSON.stringify(user)),
      articles: JSON.parse(JSON.stringify(articles)),
    },
  };

7.在前端 通过 props 拿到 数据

代码语言:typescript
复制
const { userInfo = {}, articles = [] } = props;

8.获取 全部文章的 总浏览数

代码语言:typescript
复制
const viewsCount = articles?.reduce(
    (prev: any, next: any) => prev + next?.views,
    0
  );

9.最后将 所有的数据渲染出来

代码语言:typescript
复制
<div className={styles.userDetail}>
      <div className={styles.left}>
        <div className={styles.userInfo}>
          <Avatar className={styles.avatar} src={userInfo?.avatar} size={90} />
          <div>
            <div className={styles.nickname}>{userInfo?.nickname}</div>
            <div className={styles.desc}>
              <CodeOutlined /> {userInfo?.job}
            </div>
            <div className={styles.desc}>
              <FireOutlined /> {userInfo?.introduce}
            </div>
          </div>
          <Link href="/user/profile">
            <Button>编辑个人资料</Button>
          </Link>
        </div>
        <Divider />
        <div className={styles.article}>
          {articles?.map((article: any) => (
            <div key={article?.id}>
              <ListItem article={article} />
              <Divider />
            </div>
          ))}
        </div>
      </div>
      <div className={styles.right}>
        <div className={styles.achievement}>
          <div className={styles.header}>个人成就</div>
          <div className={styles.number}>
            <div className={styles.wrapper}>
              <FundViewOutlined />
              <span>共创作 {articles?.length} 篇文章</span>
            </div>
            <div className={styles.wrapper}>
              <FundViewOutlined />
              <span>文章被阅读 {viewsCount} 次</span>
            </div>
          </div>
        </div>
      </div>
    </div>

10.这里有个地方是 编辑 个人资料的 入口,点击 跳转到 编辑个人资料的页面

代码语言:typescript
复制
<Link href="/user/profile">
            <Button>编辑个人资料</Button>
          </Link>

首先看下 编辑个人资料的页面

这里的逻辑就是 首先 从接口 获取当前用户的信息,然后修改个人信息,最后 保存修改。

1.首先通过接口获取用户信息

代码语言:typescript
复制
useEffect(() => {
    request.get('/api/user/detail').then((res: any) => {
      if (res?.code === 0) {
        console.log(333333);
        console.log(res?.data?.userInfo);
        form.setFieldsValue(res?.data?.userInfo);
      }
    });
  }, [form]);

2.接着将用户信息渲染到表单中

代码语言:typescript
复制
return (
    <div className="content-layout">
      <div className={styles.userProfile}>
        <h2>个人资料</h2>
        <div>
          <Form
            {...layout}
            form={form}
            className={styles.form}
            onFinish={handleSubmit}
          >
            <Form.Item label="用户名" name="nickname">
              <Input placeholder="请输入用户名" />
            </Form.Item>
            <Form.Item label="职位" name="job">
              <Input placeholder="请输入职位" />
            </Form.Item>
            <Form.Item label="个人介绍" name="introduce">
              <Input placeholder="请输入个人介绍" />
            </Form.Item>
            <Form.Item {...tailLayout}>
              <Button type="primary" htmlType="submit">
                保存修改
              </Button>
            </Form.Item>
          </Form>
        </div>
      </div>
    </div>
  );

3.最后调用保存修改的接口 将 修改后的数据 更新到 数据表中

代码语言:typescript
复制
const handleSubmit = (values: any) => {
    console.log(99999);
    console.log(values);
    request.post('/api/user/update', { ...values }).then((res: any) => {
      if (res?.code === 0) {
        message.success('修改成功');
      } else {
        message.error(res?.msg || '修改失败');
      }
    });
  };

部署

最后我们使用vercel进行部署,体验地址:博客系统

总结

通过这篇文章,我们实操了全栈博客系统开发。

我们应用了前后端技术栈:

· Next.js+React

· Typescript

· Antd

· Node

· MySQL

提高了全栈开发能力:

· 掌握数据表设计基本思想

· 掌握Next.js框架的使用

理解并应用SSR同构原理:

· 前端注水及页面接管

· 服务端渲染及数据预取

希望这篇文章能够带你进入全栈开发。

我正在参与2024腾讯技术创作特训营第五期有奖征文,快来和我瓜分大奖!

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • NextJS介绍
  • 项目介绍&展示
  • 环境搭建
    • 技术选型
      • 创建项目
        • 配置eslint
          • 配置prettier
            • 检查commit
              • 检查commit msg
                • TypeScript配置
                • Next.js路由介绍
                  • 普通路由
                    • 嵌套路由
                      • 动态路由
                      • 实现Layout布局
                        • Layout
                          • Navbar
                            • Footer
                            • 登录模块
                              • 登录弹窗
                                • 获取验证码
                                  • 登录逻辑
                                  • 数据库操作
                                  • 发布文章
                                  • ssr渲染首页文章列表
                                  • ssr渲染文章详情页
                                  • 编辑文章
                                    • 文章渲染
                                      • 更新文章
                                      • 发布评论
                                        • 评论渲染
                                          • 评论发布接口
                                          • 标签管理
                                          • 个人中心页面
                                          • 部署
                                          • 总结
                                          相关产品与服务
                                          验证码
                                          腾讯云新一代行为验证码(Captcha),基于十道安全栅栏, 为网页、App、小程序开发者打造立体、全面的人机验证。最大程度保护注册登录、活动秒杀、点赞发帖、数据保护等各大场景下业务安全的同时,提供更精细化的用户体验。
                                          领券
                                          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档