【持续更新】手把手入门Web全干开发(Node + React18 + Midway + Ant-Design5 + MySQL)

284 阅读3分钟

本项目已经上传至github,感兴趣可以下载学习 experience-monitor

1. 概要

【技术选型】

  • UI框架:React + Ant-Design5
  • 数据库:MySQL + TypeORM
  • 服务端:Node + Midway
  • 语言:TypeScript
  • 日志:Redis + Ioredis
  • 任务调度:Bull

【业务介绍】

  • 功能:前端性能监控平台
  • 模块:
    • 登录注册
    • 权限管理
      • 用户管理
      • 菜单管理
      • 角色管理
      • 账号密码配置
    • 系统运维
      • 服务监控
      • 任务调度
    • 体验监控
      • 错误上报
      • 日志明细
      • 监控看板

2. 准备工作

2.1 node

安装配置node 略

2.2 mysql

2.2.1 下载安装

dev.mysql.com/downloads/m…

cdn.mysql.com//Downloads/… 查看电脑是x86还是arm,以选择mysql的版本。

uname -a 

image.png

下载后双击安装按步骤即可。

2.2.2 配置

sudo vim ~/.zshrc

输入

export PATH=$PATH:/usr/local/mysql/bin

生效

source ~/.zshrc

验证

mysql --version

image.png

使用

mysql -uroot -p 
  • -u:mysql用户名,这里直接将用户名 root 挨着写在后边
  • -p:mysql密码,这里没有写会提示让输入,也可以直接挨着p写在后边 然后输入密码就可以使用了

image.png

这时从系统偏好中也可以看出服务已经启动了。 image.png

2.2.3 安装可视化工具

cdn.mysql.com//Downloads/…

image.png

输入密码即可(我的是开机密码)

  • 创建数据库

image.png

也可以执行命令创建:

CREATE SCHEMA `test` DEFAULT CHARACTER SET utf8 ;
  • 创建表

image.png

也可以执行命令创建:

CREATE TABLE `test`.`table1` (
  `id` INT NOT NULL,
  `name` VARCHAR(45) NULL,
  `password` VARCHAR(45) NULL,
  `age` INT NULL,
  PRIMARY KEY (`id`));
  • 插入和查询数据
INSERT INTO `test`.`table1` (`id`, `name`, `password`, `age`) VALUES ('1', '小张', 'xiao_zhang', '100');
INSERT INTO `test`.`table1` (`id`, `name`, `password`, `age`) VALUES ('2', '小王', 'xiao_wang', '21');

image.png

2.3 redis

  • 下载安装
brew install redis

redis服务默认是安装在 /usr/local/Cellar目录下的,(Cellar字面意思是酒窖,地下室) 其配置文件redis.conf是在 /usr/local/etc目录下的。

  • 启动服务
brew services start redis

image.png

关闭、重启可以这样:

brew services stop redis 
brew services restart redis
  • 打开客户端界面
redis-cli

远程连接

redis-cli -h host -p 6379
  • 操作下

image.png

2.4 midway

2.4.1 工程初始化

参考官方文档,直接执行初始化web项目:

npm init midway

image.png

启动项目

npm run dev

image.png

2.4.2 编写测试应用

简单看一个接口的示例:

controller/wheather.ts

import { Controller, Get, Query } from '@midwayjs/decorator';

@Controller('/')
export class WeatherController {
  @Get('/weather')
  async getWeatherInfo(@Query('city') city: string): Promise<string> {
    return `It's raining in ${city}!`;
  }
}

image.png

2.4.3 midway最小完整demo

先看下业务流程: image.png

会实现几个部分包括:Controller、Service、View、Filter、Error等。

接口路由:src/controller/weather.ts

import { Controller, Get, Inject, Query } from '@midwayjs/decorator';
import { WeatherService } from '../service/weather';
import { Context } from '@midwayjs/koa';

@Controller('/')
export class WeatherController {
  @Inject()
  weatherService: WeatherService;

  @Inject()
  ctx: Context;

  @Get('/weather')
  async getWeatherInfo(@Query('cityId') cityId: string): Promise<void> {
    const result = await this.weatherService.getWeather(cityId);
    if (result) {
      await this.ctx.render('info', result.weatherinfo);
    }
  }
}

这里会用到一个天气数据,所以定义一个接口:

src/interface.ts

/**
 * @description 天气信息
 */
export interface WeatherInfo {
  weatherinfo: {
    city: string;
    cityid: string;
    temp: string;
    WD: string;
    WS: string;
    SD: string;
    AP: string;
    njd: string;
    WSE: string;
    time: string;
    sm: string;
    isRadar: string;
    Radar: string;
  };
}

路由后会调用具体的业务处理服务。

业务处理:src/service/weather.ts

import { Provide } from '@midwayjs/decorator';
import { makeHttpRequest } from '@midwayjs/core';
import { WeatherInfo } from '../interface';
import { WeatherEmptyDataError } from '../error/weather';

@Provide()
export class WeatherService {
  async getWeather(cityId: string): Promise<WeatherInfo> {
    if (!cityId) {
      throw new WeatherEmptyDataError();
    }

    try {
      const result = await makeHttpRequest(
        `http://www.weather.com.cn/data/cityinfo/${cityId}.html`,
        {
          dataType: 'json',
        }
      );

      if (result.status === 200) {
        return result.data;
      }
    } catch (error) {
      throw new WeatherEmptyDataError(error);
    }
  }
}

这里会抛出错误,所以定义一个错误类

错误类:src/error/weather.ts

import { MidwayError } from '@midwayjs/core';

export class WeatherEmptyDataError extends MidwayError {
  constructor(err?: Error) {
    super('weather data is empty', {
      cause: err,
    });

    if (err?.stack) {
      this.stack = err.stack;
    }
  }
}

错误拦截:src/filter/weather.ts

import { Catch } from '@midwayjs/decorator';
import { Context } from '@midwayjs/koa';
import { WeatherEmptyDataError } from '../error/weather';

@Catch(WeatherEmptyDataError)
export class WeatherErrorFilter {
  async catch(err: WeatherEmptyDataError, ctx: Context) {
    ctx.logger.error(err);
    return '<html><body><h1>weather data is empty</h1></body></html>';
  }
}

请求成功后会组装view

view/info.html

<!DOCTYPE html>
<html>
  <head>
    <title>天气预报</title>
    <style>
      .weather_bg {
        background-color: #0d68bc; 
        height: 150px;
        color: #fff;
        font-size: 12px;
        line-height: 1em;
        text-align: center;
        padding: 10px;
      }

      .weather_bg label {
        line-height: 1.5em;
        text-align: center;
        text-shadow: 1px 1px 1px #555;
        background: #afdb00;
        width: 100px;
        display: inline-block;
        margin-left: 10px;
      }

      .weather_bg .temp {
        font-size: 32px;
        margin-top: 5px;
        padding-left: 14px;
      }
      .weather_bg sup {
        font-size: 0.5em;
      }
    </style>
  </head>
  <body>
    <div class="weather_bg">
      <div>
        <p>
          {{city}}({{ptime}})
        </p>
        <p class="temp"></p>
        <p>
          气温<label>{{temp1}} ~ {{temp2}}</label>
        </p>
        <p>
          天气<label>{{weather}}</label>
        </p>
      </div>
    </div>
  </body>
</html>

正常渲染的view以及错误拦截都需要依赖一些配置

npm i @midwayjs/koa --save
npm i @midwayjs/view-nunjucks --save
import { App, Configuration } from '@midwayjs/decorator';
import { ILifeCycle } from '@midwayjs/core';
import * as koa from '@midwayjs/koa';
import { join } from 'path';
import * as egg from '@midwayjs/web';
import * as view from '@midwayjs/view-nunjucks';

import { WeatherErrorFilter } from './filter/weather';

@Configuration({
  imports: [egg, view],
  importConfigs: [join(__dirname, './config')],
})
export class ContainerLifeCycle implements ILifeCycle {
  @App()
  app: koa.Application;

  async onReady() {
    this.app.useFilter([WeatherErrorFilter]);
  }
}

可以写测试来验证 test/weather.ts

import { createApp, close, createHttpRequest } from '@midwayjs/mock';
import { Framework, Application } from '@midwayjs/koa';

describe('test/controller/weather.test.ts', () => {
  let app: Application;
  beforeAll(async () => {
    // create app
    app = await createApp<Framework>();
  });

  afterAll(async () => {
    // close app
    await close(app);
  });

  it('should test /weather with success request', async () => {
    // make request
    const result = await createHttpRequest(app)
      .get('/weather')
      .query({ cityId: 101010100 });

    expect(result.status).toBe(200);
    expect(result.text).toMatch(/北京/);
  });

  it('should test /weather with fail request', async () => {
    const result = await createHttpRequest(app).get('/weather');

    expect(result.status).toBe(200);
    expect(result.text).toMatch(/weather data is empty/);
  });
});
npm run test

image.png

也可以运行浏览器看看,正常和错误的效果:

image.png

image.png

2.5 react/semi-design

2.5.1 初始化react项目

新建experience-monitor-client项目。

  • 初始化

参考脚手架教程进行配置。

3. 项目开发

3.1 登录注册

3.1.1 设计(业务逻辑/UI)

image.png

3.1.2 页面开发

src/pages/login/index.tsx

import React from 'react';
import Footer from '@/components/Footer';
import LoginForm from './form';
import LoginBanner from './banner';
import { Layout } from 'antd';
import './index.less';

const { Content, Sider } = Layout;

function Login() {
  return (
    <Layout className='container'>
      <Sider width='550' className='slider'>
        <div className='logo' />
        <div className='logo-text'>体验监控管理平台</div>
        <LoginBanner />
      </Sider>
      <Content className='content'>
        <Layout>
          <Content className='inner-content'>
            <LoginForm />
          </Content>
          <Footer />
        </Layout>
      </Content>
    </Layout>
  );
}

Login.displayName = 'LoginPage';

export default Login;

src/pages/login/form.tsx

import { Form, Checkbox, Input, Button } from 'antd';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import React, { useState } from 'react';
import axios from 'axios';
import useStorage from '@/utils/storage';
import { useNavigate } from 'react-router-dom';

type LoginParams = {
  name: string;
  password: string;
  agree: boolean;
};

const Component: React.FC = () => {
  const navigate = useNavigate();
  const [errorMessage, setErrorMessage] = useState('');
  const [loading, setLoading] = useState(false);
  const [loginParams, setLoginParams, removeLoginParams] = useStorage('loginParams');

  const [rememberPassword, setRememberPassword] = useState(!!loginParams);

  function afterLoginSuccess(params: LoginParams) {
    // 记住密码
    if (rememberPassword) {
      setLoginParams(JSON.stringify(params));
    } else {
      removeLoginParams();
    }
    // 记录登录状态
    localStorage.setItem('userStatus', 'login');
    // 跳转首页
    navigate('/home');
  }

  function login(params: LoginParams) {
    setErrorMessage('');
    setLoading(true);
    axios
      .post('/api/user/login', params)
      .then(res => {
        const { status, msg } = res.data;
        if (status === 'ok') {
          afterLoginSuccess(params);
        } else {
          setErrorMessage(msg || '登录出错');
        }
      })
      .finally(() => {
        setLoading(false);
      });
  }

  const handleSubmit = (values: any) => {
    console.log('Received values of form: ', values);
    login(values);
  };

  return (
    <Form
      name='normal_login'
      className='login-form'
      initialValues={{ remember: true }}
      onFinish={handleSubmit}
    >
      <div className='login-form-title'>登录体验监控管理平台</div>
      <div className='login-form-sub-title'>登录体验监控管理平台</div>
      <div className='margin12' />
      <Form.Item name='username' rules={[{ required: true, message: '用户名未填写!' }]}>
        <Input
          prefix={<UserOutlined className='site-form-item-icon' />}
          placeholder='请填写用户名'
        />
      </Form.Item>
      <Form.Item name='password' rules={[{ required: true, message: '密码未填写!' }]}>
        <Input
          prefix={<LockOutlined className='site-form-item-icon' />}
          type='password'
          placeholder='请填写密码'
        />
      </Form.Item>
      <Form.Item>
        <Form.Item name='agree' valuePropName='checked' noStyle>
          <Checkbox>我已经阅读服务条款</Checkbox>
        </Form.Item>

        <a className='login-form-forgot' href=''>
          忘记密码
        </a>
      </Form.Item>
      <Form.Item>
        <Button type='primary' htmlType='submit' className='login-form-button'>
          登录
        </Button>
        <a href=''>注册</a>
      </Form.Item>
      {errorMessage}
    </Form>
  );
};

export default Component;

banner、footer略

3.1.3 接口开发

TODO