Featured image of post 前端工程师 用户中心项目 React前端实战

前端工程师 用户中心项目 React前端实战

🌏Java工程师 用户中心 🎯 这篇文章用于记录 用户中心项目实战过程 这个项目专注于实现企业核心的用户中心系统 是基于SpringBoot后端 + React前端的全栈项目 实现了用户注册、登录、查询等基础功能 这篇文章是第2部分开发记录 为前端部分

🎄概述

这个项目专注于实现企业核心的用户中心系统 是基于SpringBoot后端 + React前端的全栈项目 实现了用户注册、登录、查询等基础功能 这篇文章是第2部分开发记录 为前端部分

⚡前置文章

Java工程师 用户中心项目 Java后端实战

🎄项目瘦身 · 目录结构

现在观察项目目录 过于复杂 需要进行瘦身 移除目前不需要的组价

移除国际化支持 执行移除命令 i18n-remove 点击左侧绿色按钮

删除swagger、e2e文件夹

public目录下的静态文件可以通过根目录 / 直接访问到

🎄React基本的使用语法总结

🔮TSX文件

TSX是一种文件扩展名 代表TypeScriptXML 是一种特殊的TypeScript文件,用于开发React应用程序中的组件 TSX文件中可以同时编写JSX代码和TypeScript代码,🎯这有助于开发者在JavaScript语法中编写具有静态类型检查的React组件。JSX语法是在JavaScript代码中嵌入XML标签的一种方式,🎯它允许我们直接在JavaScript代码中编写类似HTML的标记 在编译过程中 TSX文件会被转换为标准的JavaScript文件(通常使用.js或.jsx扩展名)以供浏览器解析和执行 因此,TSX文件在React应用程序中发挥着关键的作用,它使得开发者可以更轻松地编写和维护可复用的UI组件

在.tsx文件中,我们通常会定义一个函数组件或类组件,并在这个组件的函数体或render方法中使用JSX语法来描述组件的UI结构 🔵return语句用于在函数组件或类组件中返回JSX元素,这些JSX元素描述了组件在屏幕上应该显示的内容和结构

例如,下面是一个简单的函数组件的例子:

import React from 'react';
function MyComponent() {
  🔵return (
    <div>
      <h1>Hello, World!</h1>
      <p>This is a React component.</p>
    </div>
  );
}
export default MyComponent;

在这个例子中,return语句返回了一个包含<div><h1><p>等JSX元素的根元素。当MyComponent组件被渲染时,React会将这些JSX元素转换为对应的DOM元素,并将它们插入到页面中的相应位置。

总结 .tsx文件中的return语句的含义是返回组件的UI结构,供React框架进行渲染

🍭import from

导入会使用到的组件

// 导入会使用到的组件
import {🎯DefaultFooter} from '@ant-design/pro-components';

const 😎Footer: React.FC = () => {
  // 定义要在后面使用的变量 🔷defaultMessage 🔷currentYear
  🔷const defaultMessage = '@BigBigMeng技术出品';
  🔷const currentYear = new Date().getFullYear();
  // 🍭return的含义是什么?return语句的含义是将组件的UI结构返回给React框架进行渲染
  return (
    <🎯DefaultFooter
      // copyright和links 🤪目前猜测是DefaultFooter组件内部定义的变量 所以可以直接使用
      // 至于copyright和links组件的位置呈现则由DefaultFooter内部定义的样式决定
      copyright={`${🔷currentYear} ${🔷defaultMessage}`}
      links={[
        {
          key: '我亦无他',
          title: '我亦无他',
          href: '#',
          blankTarget: true,
        },
        {
          key: '唯手熟尔',
          title: '唯手熟尔',
          href: '#',
          blankTarget: true,
        },
      ]}
    />
  );
};
// 将这个组件导出 名为😎Footer 注意这个变量的名字和上面定义的一致
export default 😎Footer;

🍭export default NAME

绿色框中的Footer就是导出的组件

🍭对象解构

const{属性1,属性2}=某个对象; 这样解构以后 后面就可以直接使用属性1和属性2

const {status, type: loginType} = userLoginState;

🎄跨域设置

🔮跨域·前端正向代理

后端配置context-path:🎯

server:
  port: 8888
  servlet:
    context-path: /api # 🎯

前端配置代理:(把下图的"userCenter"修改为"api")

编写api.ts文件 这个文件存放前端的应用编程接口(把下图的"userCenter"修改为"api")

看到oneapi.json文件 api这个字符串是系统自定义的 所以将之前的配置的userCenter全部修改为api

尝试请求 发现仍然携带了userCenter字符串

逐个排查哪个文件中有userCenter字符串并将其删除 最终发现是在app.tsx文件中 之前自己定义的

删除后重新执行 [HPM] Error occurred while trying to proxy request /api/user/login from localhost:8000 to http://locathost:8888 (ENOTFOUND) (https://nodejs.org/ api/errors.html#errors_common_system_errors)

发现被自己坑了💩 locathost🟥写错了 修改为localhost重新执行 还是存在以下问题

修改跨域配置 如下 添加withCredentials: true

dev: {
    // localhost:8000/api/** -> http://localhost:8888/api/**
    '/api': {
      // 正向代理 要代理的地址
      target: 'http://localhost:8888',
      withCredentials: true, // 配置存储Cookie
      changeOrigin: true,
      pathRewrite: { 'api': 'api' }, // 请求路径重写?
    },
  }

再次执行 浏览器存储了后端生成的Cookie

🔮跨域·后端配置

采用在后端解决跨域问题的方式 这个时候前端就不需要配置代理了 只需要添加一个访问前缀

后端配置类配置跨域支持 启动前后端进行访问

package bbm.com.config;
/**
@author Liu Xianmeng
@createTime 2023/9/26 10:40
@instruction 跨域配置
*/
@Configuration
public class CorsConfig {
    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/**")  // 允许跨域的路径
                    .allowedOrigins("*")  // 允许跨域请求的源
                    .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")  // 允许请求的方法
                    .allowedHeaders("*")  // 允许跨域请求包含的头部信息
                    .allowCredentials(true) // 是否发送Cookie
                    .maxAge(3600);  // 预检请求的有效期,单位为秒
            }};}}

请求错误

IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value “*” since that cannot be set on the “Access-Control-Allow-Origin” response header. To allow credentials to a set of origins, list them explicitly or consider using “allowedOriginPatterns” instead

解决办法:将.allowedOrigins🎯替换成.allowedOriginPatterns

@Override
public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/**")  // 允许跨域的路径
        🎯.allowedOriginPatterns("*")  // 允许跨域请求的源
        .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")  // 允许请求的方法
        .allowedHeaders("*")  // 允许跨域请求包含的头部信息
        .allowCredentials(true) // 是否发送Cookie
        .maxAge(3600);  // 预检请求的有效期,单位为秒
}};}}

重新启动 执行访问后端登录接口测试 测试成功✅

🎄进行前端功能开发

🔮登录

🍭第0阶段·数据追根溯源

从登录的原始出发点进行分析 当我们点击登录按钮的时候 前端的哪个js函数会被触发?

下面是登录页面的登录函数 这个函数调用了一个login()方法 这个login()方法从何而来?

文件的最上方显示这个login()方法是从@/services/ant-design-pro/api目录文件中引入的

打开api.ts文件 可以看到其中定义的login方法 这个login()方法使用到了一个变量API.LoginParams 这个登录参数变量很显然是要封装登录参数的

那这个API.LoginParams变量在哪个文件中进行定义的呢?

点进来查看:API命名空间定义在typings.d.ts文件文件下 除了定义有LoginParams变量 还有其他的 api.ts文件之所以能只用API namespace变量 是因为和typings.d.ts文件在同一个文件夹下

typings.d.ts文件还定义其他的很多ts变量:

到这为止 我们就大概了解了React + Ant Design Pro框架的数据组织方式 后面 我们来使用这些已经定义好的一系列数据文件

🍭第1阶段 -> 修改前端数据

修改typings.d.ts数据 🎯LoginParams

declare namespace API {
  type CurrentUser = {..};
  type LoginResult = {..};
  // 登录参数封装到变量LoginParams
  type 🎯LoginParams = {
    userAccount?: string;   // 命名和后端保持一致
    userPwd?: string;       // 命名和后端保持一致
    // autoLogin?: boolean; // 去掉
    // type?: string;       // 去掉
  };
}

修改typings.d.ts数据 🎯添加一个和后端一致的消息返回实体 ResponseEntity

type ResponseEntity = {
  code?: number;    // 返回状态码
  message?: string; // 返回简要消息
  map?: object;     // 携带的数据对象
}

修改typings.d.ts数据 🎯修改当前用户CurrentUser变量 和后端的User实体保持一致

// 只定义前端可以显示的数据项 比如userPwd就不需要定义了
type CurrentUser = {
    userId?: number;
    userNickname?: string;
    userAccount?: string;
    userAvatar?: string;
    userGender?:number;
    userPhone?: string;
    userEmail?: string;
    userStatus?: number;
    userRole?: number;
    userCode?: string;
    userCreateTime?: Date;
};

❓❓❓修改过的这些type变量如何使用呢?看到下面的截屏代码:

❓❓❓上面的映射关系是在哪里定义的?在oneapi.json文件中定义的

修改oneapi.json文件

"components": {
    "schemas": {
      "CurrentUser": {...省略...},
      "ResponseEntity": {
        "type": "object",
        "properties": {
          "code": {
            "type": "number"
          },
          "message": {
            "type": "string"
          },
          "map": {
            "type": "object"
          }
        }
      },
      "PageParams": {
          "type": "object",
        "properties": {
          "userAccount": {
            "type": "string"
          },
          "userPwd": {
            "type": "string"
          }
        }
      },
      ...省略...
    }
}

🍭第1阶段 -> 执行测试

查看前端响应

查看后端响应 发现数据为空 说明两个问题:

(1)前端的请求已经被后端收到(2)前端的数据并没有传到后端

这个时候我发现自己在后端少写了一个注解 添加@RequestBody注解 以支持后端自动封装此对象 💩这个操作是必须的 否则后端无法识别封装

🍭第2阶段·检查前端请求·重新测试

发现传送的参数名不对 应该是userAccountuserPwd才对 那问题在哪?😕😕😕

查看Login组件 发现是因为表单字段的name属性没有修改

修改name属性 和下面的LoginParams保持一致 同样也和后端用户字段保持一致

// 20230926 登录参数封装到变量LoginParams
  type LoginParams = {
    userAccount?: string;   // 命名和后端保持一致
    userPwd?: string;       // 命名和后端保持一致
    // autoLogin?: boolean; // 去掉
    // type?: string;       // 去掉
  };

修改后重新测试 完美✨

🍭第3阶段·前端页面跳转

在上面的第2阶段执行完之后发现有一个404请求:

点击查看 是请求当前用户404 这个是意料之内的 因为在前后端我还没有处理这个逻辑 也就是后端返回登录成功的消息后 前端的处理

接下来查看前端对应的处理代码 首先看到登录的提交函数如下 其中 handleSubmit函数在得到rst(就是前面定义的ResponseEntity)对象之后 执行了🎯🎯🎯fetchUserInfo()函数 之所以登录后没有跳转 就是因为fetchUserInfo()函数没有执行成功

/**
 * 这是一个React函数组件 命名为Login 它使用了React的Hooks功能 具体使用了1️useState和2️useModel
 */
const Login: React.FC = () => {
  ... 代码省略
  const 🎯🎯🎯fetchUserInfo = async () => {
  	const userInfo = await initialState?.fetchUserInfo?.();
    if (userInfo) {
      await setInitialState((s) => ({
        ...s,
        currentUser: userInfo,
      }));
    }
  };
  /**
   * handleSubmit 是一个异步函数 用于处理提交的表单值
   * 该函数接收一个名为values的参数 类型为API.LoginParams
   */
  const handleSubmit = async (values: API.LoginParams) => {
    try {
      // 1️⃣ 首先 通过解构赋值将values对象的属性传递给login函数进行登录操作
      //     并将返回结果存储在 rst 变量中
      const rst = await login({...values});
      // 2️⃣ 对rst执行判空操作
      if (rst) {
        // 显示提示 -> 是否登陆成功
        message.success(rst.message);
        // 🎯🎯🎯获取登录用户的信息
        await fetchUserInfo();
        /*
          下面代码段的作用是 如果存在浏览器的历史记录对象(history)
          则获取浏览器历史记录中的查询参数(query) 并从中提取重定向参数(redirect)
          然后使用history.push方法将页面重定向到重定向参数所指定的地址
          如果重定向参数不存在 则将页面重定向到根路径('/')
         */
        // (1)首先判断浏览器历史记录对象(history)是否存在 如果不存在 则直接返回 不执行后续代码
        if (!history) return;
        // (2)如果存在浏览器历史记录对象(history) 则通过history.location获取当前页面的URL信息
        const { query } = history.location;
        // (3)从URL中的查询参数(query)中提取重定向参数(redirect)
        const { redirect } = query as {
          redirect: string;
        };
        // (4)使用history.push方法将页面重定向到重定向参数所指定的地址
        //      如果重定向参数不存在 则将页面重定向到根路径('/')
        history.push(redirect || '/');
        return;
      }
    }
  };
  ... 代码省略
}

接下来我么追查🎯fetchUserInfo()函数的代码行:⚡const userInfo = await initialState?.fetchUserInfo?.(); 继续追查initialState?.fetchUserInfo方法的调用 这个方法定义在app.tsx文件

继续追查queryCurrentUser()函数 看看具体的代码 到这里就明白了 🎯/api/currentUser路径的后端接口还没有实现 所以出现前面的404 从而成功登录后得不到跳转

/** 获取当前的用户 GET /api/currentUser */
export async function currentUser(options?: { [key: string]: any }) {
  return request<{
    data: API.CurrentUser;
  }>('/api/🎯currentUser', {
    method: 'GET',
    ...(options || {}),
  });
}

所以现在补充编写后端的currentUser的Controller接口实现 将后端Redis存储的用户信息的🎯KEY修改为ticket(之前的实现是KEY=userAccount:ticket) 以适配前端的/api/currentUser请求路径

@RequestMapping(value = "/currentUser", method = RequestMethod.GET)
@ResponseBody
public User currentUser(@CookieValue String ticket) {
    if(ticket == null) {
        return null;
    } else {
        return getSafetyUser( (User) redisTemplate.opsForValue().🎯get(ticket) );
    }
}

完成后 重启后端 执行前端的测试 报400错误

再看后端控制台信息 告诉我们前端没有携带登录凭证CooKie

这是因为前后端的跨域造成的 虽然已经配置了后端的跨域设置 前端也需要配置 才能在发送请求的时候携带对应IP:PortCookie 前端跨域配置补充 🎯withCredentials: true

export default {
  dev: {
    // localhost:8000/api/** -> http://localhost:8888/api/**
    '/api': {
      target: 'http://localhost:8888',
      🎯withCredentials: true, // 配置存储Cookie
      changeOrigin: true,
      // 请求路径重写?如果将"api"重写为"api"则后端的 context-path='/api'
      pathRewrite: { 'api': 'api' }, 
    },
  },
};

接下来注释Mock数据

重新登录 即可进入欢迎页面

🔮注册

🍭建立页面修改样式

有了前面的前端的登录的实现基础 接下来可以直接复制登录页面进行改造 开发注册页面

看源代码有没有登录按钮

点击查看引入的组件源码 在LoginForm组件的index.js文件找到了 将其修改

image-20230929162726680

查看效果

🍭编写注册接口·测试1

api.ts文件

/** 注册 POST /api/user/register */
export async function register(body: 🎯API.RegisterParams, options?: { [key: string]: any }) {
  return request<🎯API.ResponseEntity>('/api/user/register', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    data: body,
    ...(options || {}),
  });
}

Register.index.tsx文件

const Register: React.FC = () => {
  const [userRegisterState, setUserRegisterState] = useState<API.🎯ResponseEntity>({});
  const [type, setType] = useState<string>('account');
  const { initialState, setInitialState } = useModel('@@initialState');
  const fetchUserInfo = async () => {
    const userInfo = await initialState?.fetchUserInfo?.();
    if (userInfo) {
      await setInitialState((s) => ({
        ...s,
        currentUser: userInfo,
      }));
    }
  };
  const handleSubmit = async (values: API.🎯RegisterParams) => {
    try {
      // 注册 rst是返回的结果
      const rst = await register({...values});
      if (rst) {
        message.success(rst.message);
        await fetchUserInfo();
        ...
        return;
      }
      console.log(rst);
    } catch (error) {
      ...
    }
  };
}

执行测试

查看后端

🍭修改后端·测试2

查看问题所在 -> Controller方法要加上@RequestBody注解 才能将UserRegister自动封装起来

重新执行测试 注册成功

把登录页面获取currentUser的操作去掉

最终注册页的代码

import Footer from '@/components/Footer';
import { register } from '@/services/ant-design-pro/api';
import {
  LockOutlined,
  UserOutlined,
} from '@ant-design/icons';
import {
  LoginForm,
  // ProFormCaptcha,
  ProFormText,
} from '@ant-design/pro-components';
import { message, Tabs } from 'antd';
import React, { useState } from 'react';
import { history } from 'umi';
import styles from './index.less';

const Register: React.FC = () => {
  const [type, setType] = useState<string>('account');
  const handleSubmit = async (values: API.RegisterParams) => {
    try {
      const rst = await register({...values});
      if (rst) {
        message.success(rst.message);
        if (!history) return;
        const { query } = history.location;
        const { redirect } = query as {
          redirect: string;
        };
        history.push(redirect || '/');
        return;
      }
      console.log(rst);
    } catch (error) {
      const defaultRegisterFailureMessage = '注册失败,请重试!';
      message.error(defaultRegisterFailureMessage);
    }
  };
  return (
    <div className={styles.container}>
      <div className={styles.content}>
        <LoginForm
          submitter={{
            searchConfig : {
              submitText : '注册'
            }
          }}
          logo={<img alt="logo" src="https://bigmeng-bucket-100.oss-cn-qingdao.aliyuncs.com/bigmeng.png" />}
          title="用户中心·注册"
          subTitle={'实现企业必备用户中心后台管理方法'}
          initialValues={{ autoLogin: true, }}
          onFinish={async (values: API.RegisterParams) => {
            await handleSubmit(values as API.RegisterParams);
          }}
        >
          <Tabs activeKey={type} onChange={setType}>
            <Tabs.TabPane key="account" tab={'使用账户密码注册'} />
            {/*<Tabs.TabPane key="mobile" tab={'手机号注册'} />*/}
          </Tabs>
          {type === 'account' && (
            <>
              <ProFormText
                name='userAccount'
                initialValue={'bigbigmeng'} // 20230928 设置默认值
                fieldProps={{
                  size: 'large',
                  prefix: <UserOutlined className={styles.prefixIcon} />,
                }}
                placeholder={'请输入账号'}
                rules={[
                  {
                    required: true,
                    message: '用户名是必填项!',
                  },
                ]}
              />
              <ProFormText.Password
                name="userPwd"
                initialValue={'bigbigmeng'} // 20230928 设置默认值
                fieldProps={{
                  size: 'large',
                  prefix: <LockOutlined className={styles.prefixIcon} />,
                }}
                placeholder={'请输入密码'}
                rules={[
                  {
                    required: true,
                    message: '密码是必填项!',
                  },
                ]}
              />
              <ProFormText.Password
                name="userRePwd"
                initialValue={'bigbigmeng'} // 20230928 设置默认值
                fieldProps={{
                  size: 'large',
                  prefix: <LockOutlined className={styles.prefixIcon} />,
                }}
                placeholder={'请输入密码'}
                rules={[
                  {
                    required: true,
                    message: '确认密码是必填项!',
                  },
                ]}
              />
            </>
          )}
        </LoginForm>
      </div>
      <Footer />
    </div>
  );
};
export default Register;

查看后端和数据库表结果

🍭总结

到此 注册功能也开发完毕~✨

🔮主页显示用户信息

🍭问题分析

🍭定位头像的组件位置

找组件发现找不到:

直接全局搜索avatar:

🍭修改·查看效果

修改字段名

查看前端效果

🎄进行用户管理页面的开发

🔮安装 AntDesignPro-Components

npm i @ant-design/pro-components --save

🔮导入管理页面

import { useRef } from 'react';
import type { ProColumns, ActionType } from '@ant-design/pro-table';
import ProTable, { TableDropdown } from '@ant-design/pro-table';
import { search } from "@/services/ant-design-pro/api";
import {Image} from "antd";

// 配置管理页面要显示的表头(用户信息)
const columns: ProColumns<API.CurrentUser>[] = [
  {
    dataIndex: 'userId',
    valueType: 'indexBorder',
    width: 48,
  },
  {
    title: '用户账户',
    dataIndex: 'userAccount',
    copyable: true,
  },
  {
    title: '头像',
    dataIndex: 'userAvatar',
    // 渲染用户头像
    render: (_, record) => (
      <div>
        <Image src={record.userAvatar} width={80} />
      </div>
    ),
  },
  {
    title: '用户昵称',
    dataIndex: 'userNickname',
    copyable: true,
  },
  {
    title: '性别',
    dataIndex: 'userGender',
    valueType: 'select',
    valueEnum: {
      1: { text: '男'},
      0: { text: '女'},
    },
  },
  {
    title: '用户编号',
    dataIndex: 'userCode',
  },
  {
    title: '角色',
    dataIndex: 'userRole',
    valueType: 'select',
    valueEnum: {
      0: { text: '普通用户', status: 'Success' },
      1: {
        text: '管理员',
        status: 'Warning',
      },
    },
  },
  {
    title: '创建时间',
    dataIndex: 'userCreateTime',
    valueType: 'dateTime',
  },
  {
    title: '操作',
    valueType: 'option',
    render: (text, record, _, action) => [
      <a
        key="editable"
        onClick={() => {
          // @ts-ignore
          action?.startEditable?.(record.userId);
        }}
      >
        编辑
      </a>,
      <a href={record.userAvatar} target="_blank" rel="noopener noreferrer" key="view">
        查看
      </a>,
      <TableDropdown
        key="actionGroup"
        onSelect={() => action?.reload()}
        menus={[
          { key: 'copy', name: '复制' },
          { key: 'delete', name: '删除' },
        ]}
      />,
    ],
  },
];

// 导出页面
export default () => {
  const actionRef = useRef<ActionType>();
  return (
    <ProTable<API.CurrentUser>
      columns={columns}
      actionRef={actionRef}
      cardBordered
      request = {async (params = {}, sort, filter) => {
        console.log(sort, filter);
        // ⚡⚡⚡获取后端的所有用户数据 ⚡⚡⚡search()函数的代码见后面的描述
        const userList = await search();
        return {
          // 返回给页面要呈现的数据
          data: userList.map.usersPageInfo.list
        }
      }}
      editable={{
        type: 'multiple',
      }}
      columnsState={{
        persistenceKey: 'pro-table-singe-demos',
        persistenceType: 'localStorage',
      }}
      rowKey="id"
      search={{
        labelWidth: 'auto',
      }}
      form={{
        // 由于配置了 transform,提交的参与与定义的不同这里需要转化一下
        syncToUrl: (values, type) => {
          if (type === 'get') {
            return {
              ...values,
              created_at: [values.startTime, values.endTime],
            };
          }
          return values;
        },
      }}
      pagination={{
        pageSize: 5,
      }}
      dateFormatter="string"
      headerTitle="高级表格"
    />
  );
};

🔮配置管理页面路由

{
    path: '/admin',
    name: '管理页',
    icon: 'crown',
    access: 'canAdmin', // 只有admin用户才能访问的路径
    routes: [
      🎯{ path: '/admin/userMng', name: '用户管理', icon: 'user', component: './manage/UserMng' }🎯,
      { path: '/admin/sub-page2', name: '商品管理', icon: 'smile', component: './Welcome' },
      { component: './404' },
    ],
  },

⚡前端请求后端所有用户数据函数

暂时不考虑分页的问题

/**
 * 根据查询条件 获取所有用户信息
 * searchCondition:API.CurrentUser,
 * pageRule:API.PageParams,
 */
export async function search(options? : {[key:string] : any}) {
  console.log("api.ts -> search() -> 获取所有的用户信息")
  return request<API.ResponseEntity>('/api/user/search', {
    method: "POST",
    ...(options || {}),
  })
}

🔮运行测试

Licensed under CC BY-NC-SA 4.0
最后更新于 2023年9月30日