React+Umi从零快速搭建中后台系统保姆级记录教程(一、项目创建及初始化)。

💕💕😍👍初心:希望帮助更多像我一样无助且迷茫,想提升自我的小伙伴,同时提升自己,分享自己的学习方法。

Umi从零搭建后中后台系统保姆级记录教程。

代码地址:github.com/XiaoRongwen...

前言

在学习此教程之前你大概要会用HTML/CSS/JS 其次是React和Ant Design UI,其次是要理解为什么要用Umi,他能解决什么问题,带来什么好处,本文不做介绍,纯实践,分享如何去学习。 如果你是有丰富经验的大佬,可以直接去看官方文档,如果和我一样菜,可以看我的文档一步一步实践。

学习本文你会了解和应用到以下内容

UMI官网(目前访问需要梯子)。ProComponents (好像访问也需要梯子)。 React和 Ant Design

教程

教程正式开始了。

环境信息

本教程开发环境如下,如果你需要安装环境或者切换node版本,请访问我这篇教程juejin.cn/post/724327...

Node :18.16.0 NPM:9.5.1

安装创建&&启动查看项目

官网上面也有相应的教程,你可以按我的过程走,也可以按官网的走,大同小异。

使用 npx create-umi@latest在项目目录创建umi+ant design pro项目,

安装好后我们可以看到项目中出现了一堆似曾相识的目录,我们先启动项目看看是什么B样子。使用npm run dev 启动项目 启动项目之后,是这个样子。~额真丑啊😒😒😒。。。。

附上一张全过程图片,但是糊了应该

项目分析

具体的目录结构自己仔细斟酌一下官网的教程,讲的很细致了。这里不做解释umijs.org/docs/guides...

这里分享一个创建完项目后的目录结构和package.json,可以看到帮我们默认安装好了antd的一套插件。

ts 复制代码
"@ant-design/icons": "^5.0.1",
"@ant-design/pro-components": "^2.4.4",
"@umijs/max": "^4.0.72",
"antd": "^5.4.0"
坑:ts报错 解决方法重启ts服务试试,试试就逝世

依赖安装好后我们开始项目的初始化,包括基本的布局,请求封装,路由配置等功能。

tips:在看以下内容大概了解一下什么是'运行时配置'和'构建时配置'两个概念,虽然我也没看懂,配就完了。

配置文件

这里项目中给我简单弄了几个示例的页面。我们在此基础上进行更改。 分析结构目录,我们可以看出大量的配置文件都在 .umirc.ts文件, 然后再看这个官方教程的解释 我们在根目录下创建config/config.ts文件,然后把.umirc.ts文件内容复制粘贴到config/config.ts,并删除.umirc.ts(因为.umirc.ts优先级较高),这个时候我们的配置文件就都在config.ts里面了,保存运行,出来的效果是一样的

设置全局加载页面

1.根据文档,我们可以看出在src下loading.tsx就是全局的加载动画页面。

2.在src下新建global.less,写一下loading动画的居中和loading的css动画

css 复制代码
.global-loading-body {
  height: 100vh;
  width: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  .loader {
    color: #1a88ff;
    font-size: 12px;
    margin: 50px auto;
    width: 1em;
    height: 1em;
    border-radius: 50%;
    position: relative;
    text-indent: -9999em;
    -webkit-animation: load4 1s infinite linear;
    animation: load4 1.3s infinite linear;
    -webkit-transform: translateZ(0);
    -ms-transform: translateZ(0);
    transform: translateZ(0);
  }
  @-webkit-keyframes load4 {
    0%,
    100% {
      box-shadow: 0 -3em 0 0.2em, 2em -2em 0 0em, 3em 0 0 -1em, 2em 2em 0 -1em,
        0 3em 0 -1em, -2em 2em 0 -1em, -3em 0 0 -1em, -2em -2em 0 0;
    }
    12.5% {
      box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em, 3em 0 0 0, 2em 2em 0 -1em,
        0 3em 0 -1em, -2em 2em 0 -1em, -3em 0 0 -1em, -2em -2em 0 -1em;
    }
    25% {
      box-shadow: 0 -3em 0 -0.5em, 2em -2em 0 0, 3em 0 0 0.2em, 2em 2em 0 0,
        0 3em 0 -1em, -2em 2em 0 -1em, -3em 0 0 -1em, -2em -2em 0 -1em;
    }
    37.5% {
      box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, 3em 0em 0 0, 2em 2em 0 0.2em,
        0 3em 0 0em, -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em;
    }
    50% {
      box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, 3em 0 0 -1em, 2em 2em 0 0em,
        0 3em 0 0.2em, -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em;
    }
    62.5% {
      box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, 3em 0 0 -1em, 2em 2em 0 -1em,
        0 3em 0 0, -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em;
    }
    75% {
      box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em, 3em 0em 0 -1em,
        2em 2em 0 -1em, 0 3em 0 -1em, -2em 2em 0 0, -3em 0em 0 0.2em,
        -2em -2em 0 0;
    }
    87.5% {
      box-shadow: 0em -3em 0 0, 2em -2em 0 -1em, 3em 0 0 -1em, 2em 2em 0 -1em,
        0 3em 0 -1em, -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em;
    }
  }
  @keyframes load4 {
    0%,
    100% {
      box-shadow: 0 -3em 0 0.2em, 2em -2em 0 0em, 3em 0 0 -1em, 2em 2em 0 -1em,
        0 3em 0 -1em, -2em 2em 0 -1em, -3em 0 0 -1em, -2em -2em 0 0;
    }
    12.5% {
      box-shadow: 0 -3em 0 0, 2em -2em 0 0.2em, 3em 0 0 0, 2em 2em 0 -1em,
        0 3em 0 -1em, -2em 2em 0 -1em, -3em 0 0 -1em, -2em -2em 0 -1em;
    }
    25% {
      box-shadow: 0 -3em 0 -0.5em, 2em -2em 0 0, 3em 0 0 0.2em, 2em 2em 0 0,
        0 3em 0 -1em, -2em 2em 0 -1em, -3em 0 0 -1em, -2em -2em 0 -1em;
    }
    37.5% {
      box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, 3em 0em 0 0, 2em 2em 0 0.2em,
        0 3em 0 0em, -2em 2em 0 -1em, -3em 0em 0 -1em, -2em -2em 0 -1em;
    }
    50% {
      box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, 3em 0 0 -1em, 2em 2em 0 0em,
        0 3em 0 0.2em, -2em 2em 0 0, -3em 0em 0 -1em, -2em -2em 0 -1em;
    }
    62.5% {
      box-shadow: 0 -3em 0 -1em, 2em -2em 0 -1em, 3em 0 0 -1em, 2em 2em 0 -1em,
        0 3em 0 0, -2em 2em 0 0.2em, -3em 0 0 0, -2em -2em 0 -1em;
    }
    75% {
      box-shadow: 0em -3em 0 -1em, 2em -2em 0 -1em, 3em 0em 0 -1em,
        2em 2em 0 -1em, 0 3em 0 -1em, -2em 2em 0 0, -3em 0em 0 0.2em,
        -2em -2em 0 0;
    }
    87.5% {
      box-shadow: 0em -3em 0 0, 2em -2em 0 -1em, 3em 0 0 -1em, 2em 2em 0 -1em,
        0 3em 0 -1em, -2em 2em 0 0, -3em 0em 0 0, -2em -2em 0 0.2em;
    }
  }
}

3.在新建的loading.tsx中写入加载动画页面

ts 复制代码
import json from '@/assets/json/loading.json';
import { Player } from '@lottiefiles/react-lottie-player';
export default function loading() {
  return (
    <div className="global_loading_body">
      <Player
        autoplay
        loop
        src={json}
        style={{ height: '150px', width: '150px' }}
      ></Player>
    </div>
  );
}

这样用满网速访问,我们就可以看到我们的loading页面了。

layout样式配置

1.根据官网的介绍,在config.ts下面进行构件配置,在app.ts进行运行时配置。查看下app.ts

默认代码他有导出一个getInitialState函数,和layout函数,其中getInitialState函数是做登录逻辑的函数,他会返回登录的信息,比如他给写死的name:'@umijs.max'这里先不管,后面会用到。

2.修改菜单布局的配置 以下代码配置了我们菜单的布局样式,菜单的logo图标,具体内容参照procomponents.ant.design/components/... 进行配置。

ts 复制代码
// 运行时配置
import { RequestConfig, RunTimeLayoutConfig } from '@umijs/max';
import { message } from 'antd';
// 全局初始化数据配置,用于 Layout 用户信息和权限初始化
// 更多信息见文档:https://umijs.org/docs/api/runtime-config#getinitialstate
export async function getInitialState(): Promise<{
  name: string;
  avatar?: string;
}> {
  return {
    name: '小荣',
    avatar:
      'https://github.com/XiaoRongwen/imgs/blob/master/avatar.jpg?raw=true',
  };
}

export const layout: RunTimeLayoutConfig = ({ initialState }) => {
  //initialState上面登录函数返回的信息
  return {
    logo: 'https://github.com/XiaoRongwen/imgs/blob/master/logo.png?raw=true', //左上角Logo
    title: 'xxxx运营平台', //左上角Logo后面的名字
    menu: {
      locale: false, //菜单是否国际化
    },
    layout: 'mix', //菜单的方式,有mix,top,side三种,这里用mix
    splitMenus: true, // 这里用了mix才会生效,bia
    avatarProps: {
      src: initialState?.avatar || undefined, //右上角头像
      title: initialState?.name || '用户', //右上角名称
    },
    token: {
      //菜单的样式配置
      //   colorBgAppListIconHover: 'rgba(0,0,0,0.06)',
      //   colorTextAppListIconHover: 'rgba(255,255,255,0.95)',
      //   colorTextAppListIcon: 'rgba(255,255,255,0.85)',
      sider: {
        //侧边菜单的配置 ,这里具体看文档
        // colorBgCollapsedButton: '#fff',
        // colorTextCollapsedButtonHover: '#1677ff',
        // colorTextCollapsedButton: 'rgba(0,0,0,0.45)',
        colorMenuBackground: '#fff',
        // colorBgMenuItemCollapsedElevated: 'rgba(0,0,0,0.85)',
        colorMenuItemDivider: 'rgba(255,255,255,0.15)',
        colorBgMenuItemHover: 'rgba(0,0,0,0.06)',
        colorBgMenuItemSelected: 'rgba(0,0,0,0.05)',
        colorTextMenuSelected: '#1677ff',
        colorTextMenuItemHover: '#1677ff',
        // colorTextMenu: 'rgba(255,255,255,0.75)',
        // colorTextMenuSecondary: 'rgba(255,255,255,0.65)',
        colorTextMenuTitle: 'rgba(255,255,255,0.95)',
        colorTextMenuActive: '#1677ff',
        colorTextSubMenuSelected: '#1677ff',
      },
    },
  };
};

配置完成后我们的布局就成了这样

请求封装

1.了解请求封装 umi已经帮我们封装了请求方法,也就是 Umi Reuest,可以搜一下这个库,我只需要对他进行一些配置即可。 umijs.org/docs/max/re... 具体的解释可以看官网的这个介绍。

他也给出了一套配置好的demo,我们根据需求再去进行配置修改。

2.具体进行配置 根据上面图片(即文档说明),我们在app.ts中导出request的配置即可,主要对请求拦截,请求 响应处理做了一些配置,类似于axios的配置。配置完成后app.ts如下

js 复制代码
// 运行时配置
import { RequestConfig, RunTimeLayoutConfig } from '@umijs/max';
import { message } from 'antd';
// 全局初始化数据配置,用于 Layout 用户信息和权限初始化
// 更多信息见文档:https://umijs.org/docs/api/runtime-config#getinitialstate
export async function getInitialState(): Promise<{
  name: string;
  avatar?: string;
}> {
  return {
    name: '小荣',
    avatar:
      'https://github.com/XiaoRongwen/imgs/blob/master/avatar.jpg?raw=true',
  };
}

export const layout: RunTimeLayoutConfig = ({ initialState }) => {
  //initialState上面登录函数返回的信息
  return {
    logo: 'https://github.com/XiaoRongwen/imgs/blob/master/logo.png?raw=true', //左上角Logo
    title: 'xxxx运营平台', //左上角Logo后面的名字
    menu: {
      locale: false, //菜单是否国际化
    },
    layout: 'mix', //菜单的方式,有mix,top,side三种,这里用mix
    splitMenus: true, // 这里用了mix才会生效,bia
    avatarProps: {
      src: initialState?.avatar || undefined, //右上角头像
      title: initialState?.name || '用户', //右上角名称
    },
    token: {
      //菜单的样式配置
      //   colorBgAppListIconHover: 'rgba(0,0,0,0.06)',
      //   colorTextAppListIconHover: 'rgba(255,255,255,0.95)',
      //   colorTextAppListIcon: 'rgba(255,255,255,0.85)',
      sider: {
        //侧边菜单的配置 ,这里具体看文档
        // colorBgCollapsedButton: '#fff',
        // colorTextCollapsedButtonHover: '#1677ff',
        // colorTextCollapsedButton: 'rgba(0,0,0,0.45)',
        colorMenuBackground: '#fff',
        // colorBgMenuItemCollapsedElevated: 'rgba(0,0,0,0.85)',
        colorMenuItemDivider: 'rgba(255,255,255,0.15)',
        colorBgMenuItemHover: 'rgba(0,0,0,0.06)',
        colorBgMenuItemSelected: 'rgba(0,0,0,0.05)',
        colorTextMenuSelected: '#1677ff',
        colorTextMenuItemHover: '#1677ff',
        // colorTextMenu: 'rgba(255,255,255,0.75)',
        // colorTextMenuSecondary: 'rgba(255,255,255,0.65)',
        colorTextMenuTitle: 'rgba(255,255,255,0.95)',
        colorTextMenuActive: '#1677ff',
        colorTextSubMenuSelected: '#1677ff',
      },
    },
  };
};

export const request: RequestConfig = {
  timeout: 1000,
  // other axios options you want
  errorConfig: {
    errorHandler(error: any) {
      const { response } = error;
      if (response && response.status === 500) {
        message.error('请求错误:服务器故障,请稍后再试');
      }
    },
    errorThrower() {},
  },
  // 请求拦截
  requestInterceptors: [
    (config: any) => {
      let token = localStorage.getItem('token') || '';
      if (token.startsWith('"')) {
        token = JSON.parse(token);
      }
      if (token) {
        config.headers.Authorization = 'Bearer ' + token;
      }
      return config;
    },
    (error: any) => {
      return error;
    },
  ],
  // 相应拦截
  responseInterceptors: [
    (response: any) => {
      const { data, message } = response;
      if (!data.success) {
        message.error(message);
      }
      return response;
    },
  ],
};

路由配置

1.路由配置解释

umi有一套文件路由系统,但是用不惯,我们就先用路由配置文件去配置。 看到配置文件中的路由我们会很奇怪,routes的配置也挺正常的,但是引入的component是./Home指向哪里的?这个时候我们再去翻一下文档:umijs.org/docs/guides...

看到这里就~ 哦 ~幡然醒悟。。。。。。。。他是从src/page下./Home找的,这就懂了。然后你就可以欢快的新建你的页面了。

2.单独把路由文件提出来

按正常思维逻辑我们要单独把router文件独立出来进行配置, 在配置文件config目录下新建一个router.ts,把路由内容复制过来并到处,在config.ts引入并使用路由配置。 3.创建子路由且展示侧边菜单栏

在router.ts文件中新增一个路,我们拿客户管理作为一个示例。 在src/pages/下面新建一个CustomerManage/index.tsx,CustomerManage/ClientAuthentication/index.tsx,CustomerManage/index.tsx。三个文件,分别代表客户管理页面及子页面客户列表客户认证.

创建好后我们在router.ts去进行路由的配置。在路由CURD示例后面加入以下内容。

ts 复制代码
{
    name: '客户管理',
    path: '/customer-manage',
    component: './CustomerManage',
    routes: [
      {
        name: ' 客户列表',
        icon: 'TeamOutlined',
        path: '/customer-manage/customer-list',
        component: './CustomerManage/CustomerList',
      },
      {
        name: ' 客户认证',
        icon: 'FileProtectOutlined',
        path: '/customer-manage/authentication',
        component: './CustomerManage/ClientAuthentication',
      },
    ],
  },

这个时候我们在运行项目就成了这个样子

4.创建单独登录页面(没有菜单的页面)

在src/pages下面创建登录页面Login/index.tsx,我们打开Pro Components的地址,发现他有登录表单的功能,我直接点击链接去CV!CV!CV! procomponents.ant.design/components/...

CV过来其实是有一些小问题的(框架更新,但是文档没有及时更新导致的问题,我已经修改好了),代码如下

ts 复制代码
import {
  AlipayOutlined,
  LockOutlined,
  MobileOutlined,
  TaobaoOutlined,
  UserOutlined,
  WeiboOutlined,
} from '@ant-design/icons';
import {
  LoginFormPage,
  ProFormCaptcha,
  ProFormCheckbox,
  ProFormText,
} from '@ant-design/pro-components';
import { Divider, Space, Tabs, message } from 'antd';
import type { CSSProperties } from 'react';
import { useState } from 'react';
import { history } from 'umi';

type LoginType = 'phone' | 'account';

const iconStyles: CSSProperties = {
  color: 'rgba(0, 0, 0, 0.2)',
  fontSize: '18px',
  verticalAlign: 'middle',
  cursor: 'pointer',
};

export default () => {
  const items = [
    { label: '账户密码登录', key: 'account' },
    { label: '手机号登录', key: 'phone' },
  ];
  const [loginType, setLoginType] = useState<LoginType>('phone');

  const onSubmit = async (formData: any) => {
    console.log(formData);
    history.push('/');
  };
  return (
    <div
      style={{
        backgroundColor: 'white',
        height: '100vh',
        width: '100vw',
      }}
    >
      <LoginFormPage
        onFinish={onSubmit}
        backgroundImageUrl="https://gw.alipayobjects.com/zos/rmsportal/FfdJeJRQWjEeGTpqgBKj.png"
        logo="https://github.githubassets.com/images/modules/logos_page/Octocat.png"
        title="Github"
        subTitle="全球最大的代码托管平台"
        // activityConfig={{
        //   style: {
        //     boxShadow: '0px 0px 8px rgba(0, 0, 0, 0.2)',
        //     color: '#fff',
        //     borderRadius: 8,
        //     backgroundColor: '#1677FF',
        //   },
        //   title: '活动标题,可配置图片',
        //   subTitle: '活动介绍说明文字',
        //   action: (
        //     <Button
        //       size="large"
        //       style={{
        //         borderRadius: 20,
        //         background: '#fff',
        //         color: '#1677FF',
        //         width: 120,
        //       }}
        //     >
        //       去看看
        //     </Button>
        //   ),
        // }}
        actions={
          <div
            style={{
              display: 'flex',
              justifyContent: 'center',
              alignItems: 'center',
              flexDirection: 'column',
            }}
          >
            <Divider plain>
              <span
                style={{ color: '#CCC', fontWeight: 'normal', fontSize: 14 }}
              >
                其他登录方式
              </span>
            </Divider>
            <Space align="center" size={24}>
              <div
                style={{
                  display: 'flex',
                  justifyContent: 'center',
                  alignItems: 'center',
                  flexDirection: 'column',
                  height: 40,
                  width: 40,
                  border: '1px solid #D4D8DD',
                  borderRadius: '50%',
                }}
              >
                <AlipayOutlined style={{ ...iconStyles, color: '#1677FF' }} />
              </div>
              <div
                style={{
                  display: 'flex',
                  justifyContent: 'center',
                  alignItems: 'center',
                  flexDirection: 'column',
                  height: 40,
                  width: 40,
                  border: '1px solid #D4D8DD',
                  borderRadius: '50%',
                }}
              >
                <TaobaoOutlined style={{ ...iconStyles, color: '#FF6A10' }} />
              </div>
              <div
                style={{
                  display: 'flex',
                  justifyContent: 'center',
                  alignItems: 'center',
                  flexDirection: 'column',
                  height: 40,
                  width: 40,
                  border: '1px solid #D4D8DD',
                  borderRadius: '50%',
                }}
              >
                <WeiboOutlined style={{ ...iconStyles, color: '#333333' }} />
              </div>
            </Space>
          </div>
        }
      >
        <Tabs
          centered
          items={items}
          activeKey={loginType}
          onChange={(activeKey) => setLoginType(activeKey as LoginType)}
        ></Tabs>

        {loginType === 'account' && (
          <>
            <ProFormText
              name="username"
              fieldProps={{
                size: 'large',
                prefix: <UserOutlined className={'prefixIcon'} />,
              }}
              placeholder={'请输入账号/邮箱/电话号码'}
              rules={[
                {
                  required: true,
                  message: '请输入用户名!',
                },
              ]}
            />
            <ProFormText.Password
              name="password"
              fieldProps={{
                size: 'large',
                prefix: <LockOutlined className={'prefixIcon'} />,
              }}
              placeholder={'请输入密码'}
              rules={[
                {
                  required: true,
                  message: '请输入密码!',
                },
              ]}
            />
          </>
        )}
        {loginType === 'phone' && (
          <>
            <ProFormText
              fieldProps={{
                size: 'large',
                prefix: <MobileOutlined className={'prefixIcon'} />,
              }}
              name="mobile"
              placeholder={'手机号'}
              rules={[
                {
                  required: true,
                  message: '请输入手机号!',
                },
                {
                  pattern: /^1\d{10}$/,
                  message: '手机号格式错误!',
                },
              ]}
            />
            <ProFormCaptcha
              fieldProps={{
                size: 'large',
                prefix: <LockOutlined className={'prefixIcon'} />,
              }}
              captchaProps={{
                size: 'large',
              }}
              placeholder={'请输入验证码'}
              captchaTextRender={(timing, count) => {
                if (timing) {
                  return `${count} ${'获取验证码'}`;
                }
                return '获取验证码';
              }}
              name="captcha"
              rules={[
                {
                  required: true,
                  message: '请输入验证码!',
                },
              ]}
              onGetCaptcha={async () => {
                message.success('获取验证码成功!验证码为:1234');
              }}
            />
          </>
        )}
        <div style={{ marginBlockEnd: 24 }}>
          <ProFormCheckbox noStyle name="autoLogin">
            自动登录
          </ProFormCheckbox>
          <a style={{ float: 'right' }}>忘记密码 </a>
        </div>
      </LoginFormPage>
    </div>
  );
};

这样我们的登录页面就做好了,怎么 跳转过去呢? 细心的同学会发现我们创建的所有路由都是带菜单的,那怎么不带菜单单独一个页面呢?看文档!!! umijs.org/docs/guides... 大致意思就是默认所有路由都会带这个菜单(layouts),想要不带,就需要在路由下面添加一个 layout:false 我们在路由配置文件下 根目录/config/router.ts添加

js 复制代码
{
    name: 'Login',
    path: '/login',
    component: './Login',
    layout: false,
  },

此时我们访问http://localhost:8000/login 就不会出现菜单了,能够正常访问到登录页面了。

总结

由于片段真的太长了,实在肝不动了,请关注下一章节,下一章节将讲述后台管理常用的面包屑,表单配置,antd主题定义。

相关推荐
栈老师不回家6 分钟前
Vue 计算属性和监听器
前端·javascript·vue.js
前端啊龙11 分钟前
用vue3封装丶高仿element-plus里面的日期联级选择器,日期选择器
前端·javascript·vue.js
一颗松鼠16 分钟前
JavaScript 闭包是什么?简单到看完就理解!
开发语言·前端·javascript·ecmascript
小远yyds36 分钟前
前端Web用户 token 持久化
开发语言·前端·javascript·vue.js
阿伟来咯~1 小时前
记录学习react的一些内容
javascript·学习·react.js
吕彬-前端1 小时前
使用vite+react+ts+Ant Design开发后台管理项目(五)
前端·javascript·react.js
学前端的小朱2 小时前
Redux的简介及其在React中的应用
前端·javascript·react.js·redux·store
guai_guai_guai2 小时前
uniapp
前端·javascript·vue.js·uni-app
bysking3 小时前
【前端-组件】定义行分组的表格表单实现-bysking
前端·react.js
王哲晓3 小时前
第三十章 章节练习商品列表组件封装
前端·javascript·vue.js