前言

课题组前端开发项目,主要需求为开发一个Web平台:

  • 1)基本的注册登录功能
  • 2)三种权限,不同权限访问不同页面
  • 3)用户管理界面,管理员可以访问该界面进行用户的增删改查
  • 4)分页查询、下载功能
  • 5)token动态验证鉴权

调研后选择基于Ant Design Pro开发,网上并没有比较全面的Ant Design Pro教程,笔者主要基于Ant Design Pro 官方文档Ant Design Pro 从零到一教程 学习,结合chatgpt进行快速开发,本文主要记录学习和解决问题的过程。

代码开源在:基于Ant Design Pro + Flask 的Web系统

技术框架

如果你第一次使用前端框架,并不了解 umi ,Ant Design,webpack 等开发工具可以参考下面的文档,了解 Ant Design Pro 的技术框架

Ant Design Pro 新手须知

pro

在我们第一次接触AntD的时候,会遇到两个东西,一个是Ant Design 另一个是Ant Design Pro,

  • Ant Design 是一套设计语言与组件库
  • Ant Design Pro是一套基于 Ant Design 和 umi 封装的解决方案

可能这样说还不够直接,说白了就是,Ant Design Pro 是Ant Design 的脚手架,当你构建项目基本框架用Pro ,然后要使用其中一些组件就去Ant Design中去查。

环境搭建

需要本地安装yarn、node和git

  • yarn:facebook发布的一款取代npm的包管理工具。
  • node:安装node会顺便自动安装Npm
  1. 首先初始化

    1
    2
    3
    4
    5
    # 安装所需要的包
    npm i @ant-design/pro-cli -g

    # 创建项目
    npx pro create myapp

    选择simple,执行完成后生成项目目录

  2. 使用webstorm或者vscode打开项目文件夹,使用npm或yarn安装依赖

    1
    2
    3
    4
    5
    cd myapp

    npm install
    或者
    yarm
  3. 启动项目

    1
    npm start

    访问 http://localhost:8000 ,可以看到成功启动

    image-20240721172811475

项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
├── config                   # umi 配置,包含路由,构建等配置
├── mock # 本地模拟数据
├── public
│ └── favicon.png # Favicon
├── src
│ ├── assets # 本地静态资源
│ ├── components # 业务通用组件
│ ├── e2e # 集成测试用例
│ ├── layouts # 通用布局
│ ├── models # 全局 dva model
│ ├── pages # 业务页面入口和常用模板
│ ├── services # 后台接口服务
│ ├── utils # 工具库
│ ├── locales # 国际化资源
│ ├── global.less # 全局样式
│ └── global.ts # 全局 JS
├── tests # 测试工具
├── README.md
└── package.json

配置文件

  • config\config.ts : webpack的配置
  • config\proxy.ts : 代理的配置
  • config\router.ts : 路由表的配置
  • src\app.ts :运行时配置
  1. config\routes.ts :路由配置文件

    主要包含以下字段:

    • path :访问的路径,即web页面中输入的url

      • / 开头为绝对路径
      • 如果不是以 / 开头会拼接父路由
    • name:该路由的名称,用于国际化(i18n)处理。它会从国际化文件 src\locales\zh-CN\menu.ts 中获取相应的值来作为路由的标题,并在导航菜单或者页面标题中展示出来。例如name为welcome的话就会去文件中找到对应的条目,可能为“欢迎”,显示在页面标题上

      这里我们也可以修改这个文件,让条目的名称改变。如果没有在这里找到的话就会直接显示原始的名称

    • icon:指定路由对应的图标。这些图标通常会在导航菜单或其他需要展示图标的地方显示。

    • routes:指定该路由下面的子路由

    • component:指定当路径与路由匹配时要渲染的 React 组件。这个字段可以包含绝对路径或者相对路径。相对路径通常是从 src/pages 目录开始查找。

    • redirect: 定义了一个重定向目标路径,当用户访问该路由时,会被自动重定向到指定的路径。

    • layout:用于指定是否使用全局布局组件。

      • layout: false 表示不使用全局布局组件,路由将直接渲染配置的组件,而不包含全局布局。

      • 如果没有指定 layout 字段或设置为 true(默认值),则路由会使用全局布局组件,这通常包含导航栏、侧边栏、页脚等公共部分。

        通过配置 layout 字段,可以灵活地决定某些页面是否需要包含全局布局。例如,登录页面通常不需要包含导航栏和侧边栏,因此可以设置 layout: false,而其他页面可能需要全局布局组件,以提供一致的用户体验。

    • access:定义该路由的访问权限控制,后面跟的是定义的权限函数。这里是 canAdmin

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      {
      path: '/admin',
      name: 'admin',
      icon: 'crown',
      access: 'canAdmin',
      routes: [
      {
      path: '/admin',
      redirect: '/admin/sub-page',
      },
      {
      path: '/admin/sub-page',
      name: 'sub-page',
      component: './Admin',
      },
      ],
      },

      在项目中有一个 src/access.ts 文件,在其中定义了权限函数 canAdmin。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      /**
      * @see https://umijs.org/docs/max/access#access
      * */
      export default function access(initialState: { currentUser?: API.CurrentUser } | undefined) {
      const { currentUser } = initialState ?? {};
      return {
      canAdmin: currentUser && currentUser.access === 'admin',
      };
      }

      后续说到权限控制的时候再详细说明

  2. config/defaultSettings 网站主题配置文件

    • title :网站标题

    • logo:网站logo

      image-20240722161631078

    • iconfontUrl:网站标签页的icon

TypeScript 基本语法

  1. 组件

    TypeScript 本身要求对 props 进行类型检查, JSX并没有此类规定。通过 React.FC,可以确保组件和其子组件遵循正确的类型。

    • React.FC 表示 Function Component

    TypeScript 定义组件的基本语法如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    import React from 'react';

    interface Props {
    message: string;
    }

    const MyComponent: React.FC<Props> = ({ message }) => {
    return <div>{message}</div>;
    };

页面开发方法

新增页面

这里的『页面』指配置了路由,能够通过链接直接访问的模块,要新建一个页面,需要:

  1. 绑定路由

    config\routes.ts 文件中添加新的页面路由

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    {
    path:'/demo',
    name:'demo',
    icon: 'star',
    routes:[
    {
    name: 'demo1',
    // path: 'demo1',
    path: '/demo/demo1',
    component: './demo/demo1'
    },{
    name: 'demo2',
    path: 'demo2',
    component: './demo/demo2'
    }
    ]
    },

    注意这里不要把path写错了,上面介绍的两种写法都是可以的,但是不要加上 .,不然会无法访问

  2. 新建ts、less 文件

    • .tsx:构建界面主要文件,这里采用TypeScript的形式进行创建(ts的拓展语言tsx构建的文件,相当于js的拓展语言文件 .jsx
    • .less:样式文件,相当于css。咋办暂时不考虑

    1)在 src\page\ 下新建文件夹

    image-20240722111058795

    2)写入 index.tsx 文件(如果你选择jsx进行开发的话,新建 index.js 也是可以的)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // demo1:index.js
    export default () => {
    return <div>我的第一个AntD-demo1</div>;
    };

    // demo2:index.tsx
    import React from "react";
    class Index extends React.Component {
    render() {
    return <div>
    我的第一个AntD-demo2
    </div>
    }
    }
    export default Index;

    成功创建了页面

    image-20240722142704647

  3. 使用ant design组件

    创建的页面比较简单,可以使用一些 ant design 的组件来丰富我们的页面:ant design组件总览

    打开页面后,我们可以找自己想要的组件。这里我们选择表格组件,找到一个比较满意的代码演示效果,展开代码后复制对应的代码

    image-20240722143800131

Mock模拟数据

mock文件夹

  1. 基本概念

    Mock用来模拟数据,为什么会有它的出现呢?因为现在前后端开发基本是分离的,但是数据结构一般都会先定好,在日常开发中,为了前端的进度不受到后端的影响,常用Mock来做虚拟数据来模拟后端发来的请求。当你在开发过程中请求某个 API 时,mock 文件夹中的模拟接口会拦截这个请求并返回模拟数据。

  2. 创建mock

    AntdPro 中约定了mock在根目录的 mock目录 中接入,该目录下的ts文件都是用来模拟数据

    例如我们可以创建一个文件 test.ts

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    export default {
    'GET /api/getValue': {
    data:[{
    name:'zhangsang',
    sex:'男'
    },{
    name:'李四',
    sex:'男'
    },]
    },
    };

    当前端代码中对 http://localhost:8000/api/getValue 发起请求后,mock拦截并发挥模拟是数据,我们可以直接在浏览器中访问这个接口。

    可以看到直接返回了我们定义的模拟数据

    image-20240722194059590

  3. 创建异步mock

    我们一般发送请求都是异步的,

    • 什么是异步:异步行为意味着当你向服务器发送请求(比如通过浏览器访问一个API接口)时,服务器会在后台处理这个请求,而不会阻塞或等待这个请求完成后再处理其他请求。这样,服务器可以同时处理多个请求,提升效率和响应速度。对前端而言,前端向API发出请求,这时候后端响应可能需要时间。而异步操作允许前端在等待服务器响应的同时继续执行其他任务。例如,用户点击一个按钮触发数据请求时,前端可以立即更新界面上的按钮状态(如显示“加载中”),而不需要等到请求完成。而如果不使用异步操作,用户界面会被阻塞,导致用户无法进行其他操作。这会严重影响用户体验。

    • 如何设置mock发送异步请求

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      import {Request,Response} from "express";

      const getList = async (req:Request,res:Response)=>{
      const result = {
      success:true,
      data:[
      {
      id:1,
      name:'test01',
      sort:13
      }
      ]
      };
      return res.json(result);

      }
      export default {
      'GET /api/testList':getList,
      }

      从上面可以看到就是简单的加了async关键字,然后加了request和response

services文件夹

  1. 基本概念

    services文件夹用于存储与后端 API 交互的服务函数。前端通过这些服务函数向后端发送请求,并处理返回的数据。定义实际的 API 调用,封装 HTTP 请求逻辑,方便在各个组件中复用和管理与后端的通信。

    注意services文件夹和mock文件夹之间的区别:

    • services:定义实际的 API 调用,主要在生产阶段使用。
    • mock:定义了模拟的数据。如果设置了mock的话,services中定义的API调用会被mock拦截(API名称相同),返回mock模拟数据
  2. 基本写法

    在services文件夹下创建ts文件,在其中定义API函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    import {request} from "umi";

    // get请求可以不写method字段
    export async function rule(
    params: {
    // query
    /** 当前的页码 */
    current?: number;
    /** 页面的容量 */
    pageSize?: number;
    },
    options?: { [key: string]: any },
    ) {
    return request<API.RuleList>('/api/rule', {
    method: 'GET',
    params: {
    ...params,
    },
    ...(options || {}),
    });
    }

    // post请求
    export async function fakeAccountLogin(params: LoginParamsType) {
    return request('/api/login/account', {
    method: 'POST',
    data: params,
    });
    }
    • ?: 表示为非必须字段

    • options?: { [key: string]: any }:表示一个可选的参数对象,键为字符串,值为任何类型

    • API.RuleList 表示该请求的返回值被强制指定为 API.RuleList 类型,对应的类型声明可以在 src\services\ant-design-pro\typings.d.ts 中找到

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      type RuleListItem = {
      key?: number;
      disabled?: boolean;
      href?: string;
      avatar?: string;
      name?: string;
      owner?: string;
      desc?: string;
      callNo?: number;
      status?: number;
      updatedAt?: string;
      createdAt?: string;
      progress?: number;
      };

      type RuleList = {
      data?: RuleListItem[];
      /** 列表的内容总数 */
      total?: number;
      success?: boolean;
      };
    • ...(options || {}) 的作用是展开传入的 options 参数。如果 options 没有传递或为 null 或 undefined,则使用一个空对象 {} 作为默认值。

例子

test\index.tsx 中写入以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import React from "react";
import ProTable from "@ant-design/pro-table";
import {ProColumns} from "@ant-design/pro-table";
import {getList} from "@/services/test";
import {PageContainer} from "@ant-design/pro-layout";
import {Card} from "antd";
const TestList : React.FC= ()=>{
const columns:ProColumns[]=[
{
title:'id',
dataIndex:'id'
},{
title: 'name',
dataIndex: 'name'
},{
title: 'sort',
dataIndex: 'sort'
}]

return <div>
<PageContainer>
<Card>
<ProTable columns={columns}
request={async (params) =>{
let result = await getList();
return result;
}}/>
</Card>
</PageContainer>
</div>
}

export default TestList;

model数据流管理

这里主要参考 DvaJS入门课 | Model

  1. 基本概念

    中后台场景下,绝大多数页面的数据流转都是在当前页完成,在页面挂载的时候请求后端接口获取并消费,这种场景下并不需要复杂的数据流方案。但是也存在需要全局共享的数据,如用户的角色权限信息或者其他一些页面间共享的数据。那么怎么才能缓存并支持在多个页面直接去共享这部分数据呢

    为了实现在多个页面中的数据共享,以及一些业务可能需要的简易的数据流管理的场景,model 是用于管理应用状态的一个重要概念。Ant Design Pro 默认使用 dva 进行状态管理,而 model 是 dva 的核心部分之一。

    model其实就是对数据的处理过程又进行了一次封装,每个 model 负责管理一种特定类型的数据(按照功能业务逻辑进行花粉)。例如,你可以有一个用户数据的 model,一个商品数据的 model,一个订单数据的 model 等等。model 不仅仅是存储数据,还包含了如何处理这些数据的业务逻辑。它包括同步操作(reducers)和异步操作(effects)。

    一般来说在AntD中数据请求过程是这样的:

    1.UI 组件交互操作;

    2.调用 model 的 effect;

    3.调用统一管理的 service 请求函数;

    4.使用封装的 request.ts 发送请求;

    5.获取服务端返回;

    6.然后调用 reducer 改变 state;

    7.更新 model。

    View->model->Service->后端(或Mock)

  2. 数据流图

    img

    核心概念:

    1)state:一个对象,保存整个应用状态。是储存数据的地方,收到 Action 以后会更新数据。

    2)View:React 组件构成的视图层。从 State 取数据后,渲染成 HTML 代码。只要 State 有变化,View 就会自动更新

    3)Action:用来描述 UI 层事件的一个对象。

    4)connect:一个函数,绑定 State 到 View。

    5)dispatch:一个函数方法,用来将 Action 发送给 State。

  3. 使用Model

    • state :保存应用状态。每个 model 都有自己独立的 state,用于存储与该 model 相关的数据。状态可以是任何类型的值,包括对象、数组等。在 Page 中连接的时候使用到。
    • reducers:处理同步操作的纯函数,接收当前的 state 和 action,返回新的 state。它们描述了如何根据 action 来修改 state。
    • effects:用于处理异步操作的函数,通常用于发起网络请求、执行异步逻辑等。effects 通常使用 redux-saga 来实现,effects 可以调用其他的 reducers 来更新 state。
    • subscriptions :用于订阅数据源的机制,可以在应用启动时进行一些数据初始化操作或监听路由变化等。例如当访问某个页面的时候设置自动获取某些数据。
    • namespace: model 的命名空间,用于区分不同 model 的作用域。在 dispatch action 时,需要加上 namespace 以便明确指向哪个 model。
  4. 示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    import type {Effect} from "umi";
    import {Reducer} from "umi";
    import {getTableValue, selectTableValue} from "@/pages/table/service";

    // 1. 设置类型
    export type TableValue={
    key?: String;
    name?:String;
    age?:Number;
    address?: String;
    time?:String;
    }
    export type allValue = {
    tableValue?:TableValue;
    }

    // 2. Model类型定义
    export type TableModelType = {
    namespace:'tableDemo';
    state:allValue;
    effects:{
    getValue:Effect;
    selectValue:Effect;
    }
    reducers:{
    saveValue:Reducer<allValue>;
    }
    }

    // Model的实现
    const TableModel:TableModelType={
    //命名空间
    namespace: "tableDemo",
    //设置state
    state: {
    tableValue:{},
    },
    //提供给view调用的接口
    effects: {
    *getValue(_,{call,put}){
    const res = yield call(getTableValue);
    yield put({
    type:'saveValue',
    payload:res
    })
    },

    *selectValue({payload},{call,put}){
    console.log("model:"+payload)
    const res = yield call(selectTableValue,payload);
    console.log(res)
    yield put({
    type:'saveValue',
    payload:res
    })
    }
    },
    //提供给effects通信put的接口
    reducers: {
    saveValue(date,action){
    return{
    ...date,
    tableValue: action.payload
    };
    }
    }

    }
    export default TableModel;
    • export type:定义数据类型( ?: 表示为非必须字段)
    • 2.Model类型定义:通过 TypeScript 类型定义,明确描述模型的结构,包括命名空间、状态、effects 和 reducers。这样可以在编写和维护代码时提供类型检查和自动补全功能,提高代码的可靠性和可读性
    • 其中effects内我们可以看到有一个带*号的方法,表示这个方法是提供给Page调用的
    • put、call、select方法 是提供通信的,例如yield put()是对reducers中的方法调用。yield call是去访问service中的方法。

权限设置

  1. 全局初始数据

    几乎大部分中台项目都有一个需求,就是在整个应用加载前请求用户信息或者一些全局依赖的基础数据。这些信息通常会用于 Layout 上的基础信息(通常是用户信息),权限初始化,以及很多页面都可能会用到的基础数据。

    如何使用全局初始数据:

    运行时配置文件 src/app.ts 中添加运行时配置 getInitialState

    1
    2
    3
    4
    5
    export async function getInitialState() {
    return {
    userName: 'xxx',
    };
    }

    该方法返回的数据最后会被默认注入到一个 namespace 为 @@initialState 的 model 中。后续可以通过 useModel 这个 hook来消费它

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import React from 'react';

    import { useModel } from 'umi';
    import { Spin } from 'antd';

    export default () => {
    const { initialState, loading, refresh, setInitialState } = useModel('@@initialState');

    if (loading) {
    return <Spin />;
    }

    return <div>{initialState.userName}</div>;
    };
  2. 初始化

    权限定义文件 src\access.ts 下定义用户拥有的权限,在该文件中 export default 一个函数,定义用户拥有的权限,以下是示例定义:

    1
    2
    3
    4
    5
    6
    7
    8
    // src/access.ts
    export default function (initialState) {
    return {
    canReadFoo: true,
    canUpdateFoo: () => true,
    canDeleteFoo: (data) => data?.status < 1, // 按业务需求自己任意定义鉴权函数
    };
    }

    该文件需要返回一个 function,返回的 function 会在应用初始化阶段被执行,执行后返回的对象将会被作为用户所有权限的定义。对象的每个 key 对应一个 boolean 值,只有 true 和 false,代表用户是否有该权限。

  3. 页面内的权限控制(即设置页面中的部分内容对用户不可见)

    使用 useAccess hook 来获取权限定义

    也可以使用 Access 组件用于页面的元素显示和隐藏的控制。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    import React from 'react';
    import { useAccess, Access } from 'umi';

    const PageA = (props) => {
    const { foo } = props;
    const access = useAccess(); // access 实例的成员: canReadFoo, canUpdateFoo, canDeleteFoo

    if (access.canReadFoo) {
    // 任意操作
    }

    return (
    <div>
    <Access accessible={access.canReadFoo} fallback={<div>Can not read foo content.</div>}>
    Foo content.
    </Access>
    <Access accessible={access.canUpdateFoo()} fallback={<div>Can not update foo.</div>}>
    Update foo.
    </Access>
    <Access accessible={access.canDeleteFoo(foo)} fallback={<div>Can not delete foo.</div>}>
    Delete foo.
    </Access>
    </div>
    );
    };
  4. 路由和菜单的权限控制

    在路由配置文件 config.ts 中想要添加访问控制的路由上加上 access 字段即可

    1
    2
    3
    4
    5
    6
    7
    {
    path: '/admin',
    name: 'admin',
    icon: 'crown',
    // 调用 src/access.ts 中返回的 normalRouteFilter 进行鉴权
    access: 'normalRouteFilter'
    }

    对应鉴权函数(比如 adminRouteFilter)在接收路由作为参数后返回值为 false,该条路由将会被禁用,并且从左侧 layout 菜单中移除,如果直接从 URL 访问对应路由,将看到一个 403 页面。

页面开发实例

创建mock

在 mock 文件夹下新建 mock\logindata.mock.ts 文件添加新定义的API所返回的模拟数据json形式,定义 GET /api/logindata 。实际情况下几乎所有的请求都应该设置为异步,但在某些特定情况下,例如需要确保顺序执行的任务,可以根据需求使用同步请求。然而,这种情况非常少见,大多数前端应用都应使用异步请求。

所以我们在这里创建的是异步请求,并且模拟异步请求延迟,请求API后5秒返回数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { Request, Response } from 'express';

const logindata = [
{
"url": "a",
"username": "a",
"password": "a",
"createtime": "2024-04-16 05:19:34"
},
{
"url": "b",
"username": "b",
"password": "b",
"createtime": "2024-04-16 07:14:25"
},
];

const getLoginData = async (req: Request, res: Response) => {
await new Promise(resolve => setTimeout(resolve, 5000)); // 模拟异步请求延迟
return res.json({ data: logindata });
};

export default {
'GET /api/logindata': getLoginData,
};

访问 http://localhost:8000/api/logindata ,加载5秒后可以查看返回的模拟数据

创建API函数

创建 src\services\ant-design-pro\logindata.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { request } from '@umijs/max';

/** 查询登录数据 GET /api/logindata */
export async function queryLoginData(
params: {
username?: string;
url?: string;
},
options?: { [key: string]: any },
) {
return request<API.LogindataList>('/api/logindata', {
method: 'GET',
params: {
...params,
},
...(options || {}),
});
}

这里的 API.LogindataList 是事先定义好的返回数据类型,写在 src\services\ant-design-pro\typings.d.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
type LogindataListItem = {
url: string;
username: string;
password: string;
createtime: string;
};

type LogindataList = {
data?: RuleListItem[];
/** 列表的内容总数 */
total?: number;
success?: boolean;
};

编写组件函数

创建 src\pages\Search\index.tsx,来写一个查询+展示的页面

结合按钮、输入框和表格组件,编写主组件对查询到的数据进行渲染

笔者也是初学react,所以对react的语法并不熟悉,而为了快速完成开发,我主要结合GPT来写代码。可以将 ant design组件库 中你想要使用的组件代码喂给gpt,让他为你生成主组件代码并实现你自己的一些业务需求。注意可以一步步去添加实现功能,慢慢让gpt完善,一次性提太多复杂的功能会影响代码的质量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
//查询页面

import React, { useState } from 'react';
import { queryLoginData } from '@/services/ant-design-pro/logindata';
// 使用 Ant Design 的按钮、表格组件
import { Input, Button, Table, Space } from 'antd';
import type { TableColumnsType, TableProps } from 'antd';

interface DataType {
key: React.Key;
url: string;
username: string;
password: string;
createtime: string;
}

// 定义表格列
const columns: TableColumnsType<DataType> = [
{
title: 'URL',
dataIndex: 'url',
key: 'url',
},
{
title: 'Username',
dataIndex: 'username',
key: 'username',
},
{
title: 'Password',
dataIndex: 'password',
key: 'password',
},
{
title: 'Create Time',
dataIndex: 'createtime',
key: 'createtime',
},
];


// 定义主组件LoginDataQuery
const LoginDataQuery: React.FC = () => {
// 定义状态变更函数
const [data, setData] = useState<DataType[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [url, setUrl] = useState<string>('');
const [username, setUsername] = useState<string>('');

// logindata表 查询函数
const fetchFilteredLoginData = async () => {
setLoading(true);
try {
const response = await queryLoginData({ url, username });
if (response.data) {
const formattedData = response.data.map((item, index) => ({
key: index,
url: item.url,
username: item.username,
password: item.password,
createtime: item.createtime,
}));
setData(formattedData);
} else {
console.error('No data returned from API');
}
} catch (error) {
console.error('获取登录数据失败', error);
}
setLoading(false);
};

// 渲染
return (
<div>
<Space direction="horizontal" size="middle" style={{ marginBottom: 16 }}>
<div>
<label>输入URL:</label>
<Input
placeholder="Enter URL"
value={url}
onChange={e => setUrl(e.target.value)}
style={{ width: 200 }}
/>
</div>
<div>
<label>输入Username:</label>
<Input
placeholder="Enter Username"
value={username}
onChange={e => setUsername(e.target.value)}
style={{ width: 200 }}
/>
</div>
<Button type="primary" onClick={fetchFilteredLoginData} loading={loading}>
查询
</Button>
</Space>
<Table columns={columns} dataSource={data} loading={loading} />
</div>
);
};

export default LoginDataQuery;

权限控制

ant design pro默认设置了user和admin两个用户类型,user可以范围跟所有的页面除了用户管理界面(仅admin可以访问)

需求:用户分为3种,包括管理员、普通会员、高级会员

  • 普通用户:只拥有部分查询页面权限

  • 高级会员:拥有所有查询功能

  • 管理员:拥有完整后台权限包含所有查询功能,会员管理功能

这样看的话我们只需要再添加一个高级会员类型的用户

首先在 mock\user.ts 中的登录API函数 POST /api/login/account 中添加

1
2
3
4
5
6
7
8
9
10
// 高级用户权限
if (password === 'ant.design' && username === 'advancedUser') {
res.send({
status: 'ok',
type,
currentAuthority: 'advancedUser',
});
access = 'advancedUser';
return;
}

src\access.ts 文件中,定义权限 canAdvancedUser

1
2
3
4
5
6
7
8
9
export default function access(initialState: { currentUser?: API.CurrentUser } | undefined) {
const { currentUser } = initialState ?? {};
return {
canAdmin: currentUser && currentUser.access === 'admin',
// 高级会员权限:可以访问下载功能,管理员也可以使用
canAdvancedUser: currentUser && (currentUser.access === 'advancedUser' || currentUser.access === 'admin'),
};
}

config\routes.ts 中,设置仅advancedUser可以访问的页面

1
2
3
4
5
6
7
8
// 高级会员和管理员可访问,进行需求2的操作
{
name: '下载',
icon: 'DownloadOutlined',
access: 'canAdvancedUser',
path: '/download',
component: './Download'
}

因为我们只需要设置页面权限,不需要设置页面内部分内容的权限,这里就已经完成了。

登录页面修改

默认的登录页面如下:

image-20240725150834228

需求:仅需要账号密码登录,添加注册功能,删除掉其他登陆方式,再修改一下UI

这一部分主要参考 react+antdpro+ts实现企业级前端项目三:实现系统登陆 文章进行修改

删除其他登录方式

打开 src\pages\User\Login\index.tsx 文件,找到下面两块代码,注释掉:

  • ActionIcons 组件,定义了登录页面底部的其他登录方式图标,使用了 AlipayCircleOutlinedTaobaoCircleOutlinedWeiboCircleOutlined 三个图标。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ActionIcons 组件
const ActionIcons = () => {
const { styles } = useStyles();

return (
<>
<AlipayCircleOutlined key="AlipayCircleOutlined" className={styles.action} />
<TaobaoCircleOutlined key="TaobaoCircleOutlined" className={styles.action} />
<WeiboCircleOutlined key="WeiboCircleOutlined" className={styles.action} />
</>
);
};

// 使用ActionIcons 组件
actions={[
<FormattedMessage
key="loginWith"
id="pages.login.loginWith"
defaultMessage="其他登录方式"
/>,
<ActionIcons key="icons" />,
]}

删除掉手机号注册

直接参考上面的文章进行注释

字样修改

src\components\Footer\index.tsx 中修改底边栏

image-20240725152020901

找到标题和副标题,修改主标题,对副标题进行注释

1
2
title="Ant Design"
subTitle={intl.formatMessage({ id: 'pages.layouts.userLayout.title' })}

如果想要换成其他副标题的话可以直接:

1
subTitle='基于React+ant design Pro +Ts的企业级应用'

添加注册页面

需求:前端存在对字段的各种验证机制

最终页面显示如下:

image-20240725170206028

在pages/User下创建Register文件夹并创建index.tsx文件

然后在config/routes创建register注册路由。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
path: '/user',
layout: false,
routes: [
// 登录
{
name: 'login',
path: '/user/login',
component: './User/Login',
},
// 注册
{
name: 'register',
path: '/user/register',
component: './User/Register'}
],
},

mock\user.ts 文件下添加处理注册请求的mock

1
2
3
4
5
6
7
8
9
10
11
// 注册
'POST /api/register': async (req: Request, res: Response) => {
const { username, password } = req.body;
await waitTime(2000);
// 这里可以添加更多的注册逻辑,如检查用户名是否已存在等
if (username && password) {
res.send({ status: 'ok', currentAuthority: 'user', success: true });
return;
}
res.send({ status: 'error', message: '注册失败', success: false });
},

src\services\ant-design-pro\api.ts 文件下添加对应的API接口

1
2
3
4
5
6
7
8
9
10
11
/** 注册接口 POST /api/register */
export async function register(body: API.RegisterParams, options?: { [key: string]: any }) {
return request<API.RegisterResult>('/api/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
});
}

src\services\ant-design-pro\typings.d.ts 文件下定义数据类型 API.RegisterResultAPI.RegisterParams

1
2
3
4
5
6
7
8
9
10
11
type RegisterParams = {
username?: string;
password?: string;
email?: string;
};

type RegisterResult = {
status?: string;
currentAuthority?: string;
success?: boolean;
};

接下来就是编写注册组件页面 src\pages\User\Register\index.tsx,这里不贴代码了,可以直接看仓库源码

最后再把页面绑定到登录页面 src\pages\User\Login\index.tsx 上,找到下面的代码

1
2
3
4
5
6
7
<a
style={{
float: 'right',
}}
>
<FormattedMessage id="pages.login.forgotPassword" defaultMessage="忘记密码" />
</a>

注释掉后换成

1
2
3
4
5
6
7
8
9
10
<a
style={{
float: 'right',
}}
onClick={() => {
history.push('/user/register');
}}
>
<FormattedMessage id="pages.login.register" defaultMessage="注册" />
</a>

完成注册界面的编写

用户管理页面

添加用户管理页面,设置了用户的增删改查功能,设置username为主键,不可修改username

image-20240801171000518

弹窗修改和新增用户

image-20240801171031979

这里就不贴代码了,直接去仓库看源码吧。可以仔细学习下这个页面怎么开发,大部分的管理页面都可以基于这个页面来进行修改

下载功能页面

在编写的时候,出现了一些问题:

  • 单个下载、批量下载和全选下载

    • 一般情况下,设计单个下载和批量下载两个接口即可。单个下载直接将下载的文件id发送给对应的api/download,批量下载将所有需要下载的文件id列表发送给api/download/batch

    • 但是因为我们的数据库数据量比较大,所以需要分页查询,即点击新的页面会发送新的请求,请求数据包中包含page、pagesize等参数。这样的话全选下载就会存在一些问题,在前端页面中,我们点击第二页的时候才会向服务器请求第二页的数据。如果我们在第一页直接选择全选下载,我们只向后端请求了第一页的数据,是获取不到第二页及后面所有的页面的id的,这样就不能用批量下载来进行全选下载了。

    • 因此,在这里我们设计三个api来分别实现单个下载、批量下载和全选下载

  • 如果我在第一页选中了一些行,点击第二页的时候,如果在第二页页选中了一些行,当我又选中一些行后,点击批量下载,第一页选中的行并没有下载

    为了解决这个问题,我们需要在前端保存所有选中的行,无论它们在哪一页。我们可以使用一个状态来跟踪所有选中的行,而不仅仅是当前页的选中行。然后,我们在批量下载时使用这个全局的选中行状态

  • 使用上面的方法后,发送给API的参数列表中包含了之前选中的行。但是,在前端页面中,如果我在第一页选中了一些行,点击第二页的时候,如果在第二页页选中了一些行,再点击第一页的时候,之前选中的行前面的勾选框没有被勾选(虽然点击批量下载按钮后这些行的数据发送给了API)

    这是因为在切换页面时,表格组件的 selectedRowKeys 状态没有被持久化并重新应用到表格中。当切换回第一页时,selectedRowKeys 的状态丢失了。我们需要确保在每次加载数据后,表格组件的 selectedRowKeysallSelectedRows 同步。

  • 已经勾选了的行并不能取消勾选

    为了解决已勾选的行无法取消勾选的问题,我们需要确保在取消勾选时,allSelectedRows状态能正确更新。我们需要在handleSelectChange函数中增加逻辑,以便在取消勾选时移除相应的行。

连接实际API

连接后端API,发现产生了报错:

image-20240726143246075

这是因为默认不允许浏览器跨域,需要在服务端设置CORS。

为了方便,我们可以也先设置浏览器使其允许跨域,参考:Chrome浏览器的跨域设置

使用跨域浏览器打开,成功获取到数据

添加token机制

src\requestErrorConfig.ts 文件中有请求拦截器

1
2
3
4
5
6
7
8
// 请求拦截器
requestInterceptors: [
(config: RequestOptions) => {
// 拦截请求配置,进行个性化处理。
const url = config?.url?.concat('?token = 123');
return { ...config, url };
},
],

这段代码的作用在于给每一个API请求都加上参数token=123,而在实际部署和上线的环境中,硬编码的 token=123 是不合理的,因为这样会暴露敏感信息并且无法应对动态变化的认证需求。正确的做法应该是使用动态获取和安全存储的方式来处理 token。

在这里,我们设置通过登录获取 Token 并在请求中使用

  • 实现用户登录功能,从服务器获取 Token
  • 将 Token 存储在客户端(例如 localStorage 或 cookies 中)
  • 在每个请求中从存储中获取 Token 并附加到请求中
  1. 首先我们修改登录的api函数

    src\services\ant-design-pro\api.ts 中的登录api函数可以不修改,但是我们需要修改一下对应的数据类型,打开 src\services\ant-design-pro\typings.d.ts 进行修改

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    type LoginParams = {
    username?: string;
    password?: string;
    autoLogin?: boolean;
    type?: string;
    };

    type LoginResult = {
    status?: string;
    type?: string;
    currentAuthority?: string;
    token: string;
    };
  2. 修改请求函数文件,使得登录的时候从服务器中获取token

    修改 src\pages\User\Login\index.tsx 文件,在 handleSubmit 函数中,获取 Token 并将其存储在 localStorage 中。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    // 添加获取token
    const handleSubmit = async (values: API.LoginParams) => {
    try {
    const msg = await login({ ...values, type });
    if (msg.status === 'ok' && msg.token) {
    const defaultLoginSuccessMessage = intl.formatMessage({
    id: 'pages.login.success',
    defaultMessage: '登录成功!',
    });
    message.success(defaultLoginSuccessMessage);
    localStorage.setItem('authToken', msg.token); // 存储 token
    await fetchUserInfo();
    const urlParams = new URL(window.location.href).searchParams;
    history.push(urlParams.get('redirect') || '/');
    return;
    }
    setUserLoginState(msg);
    } catch (error) {
    const defaultLoginFailureMessage = intl.formatMessage({
    id: 'pages.login.failure',
    defaultMessage: '登录失败,请重试!',
    });
    message.error(defaultLoginFailureMessage);
    }
    };
  3. 在请求中附加 Token

    src/requestErrorConfig.ts 中的请求拦截器中附加 Token,使得后续每一次请求的时候,都从 localStorage 中提取出来token,放置到请求头的Authorization字段中。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    requestInterceptors: [
    (config: RequestOptions) => {
    const token = localStorage.getItem('authToken');
    if (token) {
    config.headers = {
    ...config.headers,
    Authorization: `Bearer ${token}`,
    };
    }
    return config;
    },
    ],

    antd 源代码中硬编码token且作为参数传递,正式部署上线一般都是将token放在请求头中的Authorization字段

  4. 修改mock模拟服务端返回token

    这里对 mock\user.ts 文件进行修改。antd源代码方法是从环境变量中读取用户权限,指的是通过读取环境变量 MY_CUSTOM_ENV_VARIABLE 来设置当前用户的权限

    1
    2
    const { MY_CUSTOM_ENV_VARIABLE } = process.env;
    let access = MY_CUSTOM_ENV_VARIABLE === 'site' ? 'admin' : '';

    我们将access定义为前面设置的三种权限,并新定义生成token和验证token的方法,分别在login和currentUser的api下执行。

    这里写的比较粗糙,并没有应用动态的token,就不贴例子了,可以直接去代码中看

  5. 修改退出登录api

    既然登录后获取到了api并且存储在 localStorage 中,在我们退出登录的时候就需要清除用户会话或 token

    • 服务端:确保在用户退出时使其Token无效。

    • 客户端:确保客户端在退出登录时删除本地存储的Token。

      注意,antd源代码中硬编码token,所以退出登录中并没有使用到token,这里的逻辑其实是点击就直接返回success,这个退出登录其实完全没有退出

      image-20240801155538805

      点击退出登录后,客户端并没有删除掉本地存储的Token,虽然重定向到了登录页面,但我们其实可以不需要再次登录,就可以访问原本登录的用户有权限访问的页面。

      我们尝试一下,登录amin后退出登录,不再次登录直接访问页面 http://localhost:8000/search/logindata,可以看到加载出了页面,随便输入一个domain进行查询

      image-20240801163221506

      可以看到可以直接访问,请求头中包含token,因此我们需要在点击退出登录后删除掉本地存储的token

      修改 src\components\RightContent\AvatarDropdown.tsx 文件中的 loginOut 函数,这个函数的功能是描述了点击退出登录后,调用退出登录的api并进行的操作,我们修改后加上删除本地存储的token

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      const loginOut = async () => {
      try {
      await outLogin();
      // 删除本地存储的Token
      localStorage.removeItem('authToken');
      const { search, pathname } = window.location;
      const urlParams = new URL(window.location.href).searchParams;
      const redirect = urlParams.get('redirect');
      if (window.location.pathname !== '/user/login' && !redirect) {
      history.replace({
      pathname: '/user/login',
      search: stringify({
      redirect: pathname + search,
      }),
      });
      }
      } catch (error) {
      console.error('Logout failed:', error);
      }
      };

      现在我们再次尝试登录,退出登录后直接访问后面的页面 http://localhost:8000/search/logindata,可以看到请求直接被拦截,因为请求头中不再包含token,验证无法通过

      image-20240801163846493

      到这里我们前端的token功能添加就完成了!

      在退出登录的请求中,通常只需要在请求头中包含Token即可,参数可以省略。这样可以确保Token在服务器端被正确识别和处理以使其失效。