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主题定义。

相关推荐
01传说14 分钟前
vue3 配置安装 pnpm 报错 已解决
java·前端·vue.js·前端框架·npm·node.js
Misha韩21 分钟前
React Native 一些API详解
react native·react.js
小李飞飞砖21 分钟前
React Native 组件间通信方式详解
javascript·react native·react.js
小李飞飞砖21 分钟前
React Native 状态管理方案全面对比
javascript·react native·react.js
烛阴1 小时前
Python装饰器解除:如何让被装饰的函数重获自由?
前端·python
千鼎数字孪生-可视化2 小时前
Web技术栈重塑HMI开发:HTML5+WebGL的轻量化实践路径
前端·html5·webgl
凌辰揽月2 小时前
7月10号总结 (1)
前端·css·css3
天天扭码2 小时前
很全面的前端面试——CSS篇(上)
前端·css·面试
EndingCoder2 小时前
搜索算法在前端的实践
前端·算法·性能优化·状态模式·搜索算法
sunbyte2 小时前
50天50个小项目 (Vue3 + Tailwindcss V4) ✨ | DoubleVerticalSlider(双垂直滑块)
前端·javascript·css·vue.js·vue