项目扩展 鱼皮 API 开放平台子项目 stateful-backend-frontend 项目总结

发布时间 2023-08-30 09:24:24作者: Ba11ooner

前言

学习程序员鱼皮API 开放平台项目开源项目:https://github.com/liyupi/yuapi-backend-public

通过开源项目中给出的前端技术栈,倒推 stateful-backend 的前端实现

前端技术选型

  • React 18
  • Ant Design Pro 5.x 脚手架
  • Ant Design & Procomponents 组件库
  • Umi 4 前端框架
  • OpenAPI 前端代码生成

项目实现

项目全局配置

页面结构
知识补充

tsx = TypeScript 版本的 jsx

命名规则:一般文件夹全小写,页面文件夹大驼峰

具体结构
  • src:源码文件夹
    • components:通用组件,但是按页面的目录结构组织
      • Footer:采用页面的目录结构进行组织的通用组件
        • index.tsx:组件(页面)主体
    • pages:页面
      • TableList:表格页面
        • index.tsx:页面主体
      • user
        • Forget:忘记密码页面
          • index.tsx
        • Login:登录页面
          • components:页面内部组件
            • LoginPageFooter.jsx:组件主体
          • index.less:页面样式
          • index.tsx:页面主体
        • Register:注册页面
          • components:页面内部组件
            • RegisterPageFooter.jsx:组件主体
          • index.less:页面样式
          • index.tsx:页面主体
全局路由配置

/myapp/config/routes.ts

export default [
  {
    path: '/user',
    layout: false,
    routes: [
      {name: '登录', path: '/user/login', component: './user/Login'},
      {name: '注册', path: '/user/register', component: './user/Register'},
      {name: '重置密码', path: '/user/forget', component: './user/Forget'},
      {component: './404'},
    ],
  },
  {path: '/welcome', name: '欢迎', icon: 'smile', component: './Welcome'},
  {
    path: '/admin',
    name: '管理页',
    icon: 'crown',
    access: 'canAdmin',
    routes: [
      {path: '/admin/sub-page', name: '二级管理页', icon: 'smile', component: './Welcome'},
      {component: './404'},
    ],
  },
  {name: '查询表格', icon: 'table', path: '/list', component: './TableList'},
  {path: '/', redirect: '/welcome'},
  {component: './404'},
];

注意:路由内部从上往下扫描,404 页面要写在最下面,否则会出现 404 覆盖正常页面的现象

运行时配置

在构建时是无法使用 dom 的,所以有些配置可能需要运行时来配置,一般而言我们都是在 src/app.tsx 中管理运行时配置。

官方文档

/myapp/src/app.tsx

//import 部分省略,非关键代码省略

//放行页面配置(无需登录也能跳转到的页面)
const loginPath = '/user/login';
const register = '/user/register'
const forget = '/user/forget'
const paths = [loginPath, register, forget]

//获取页面初始状态
// 参数:无
// 返回值:Promise 对象,包含一个对象,对象的属性有:
//  settings: 可选的 Partial 类型,表示布局设置的部分内容
//	currentUser: 可选的 API.CurrentUser 类型,表示当前用户信息
//	loading: 可选的 boolean 类型,表示加载状态
//	fetchUserInfo: 可选的函数,返回一个 Promise 对象,用于获取用户信息
export async function getInitialState(): Promise<{
  settings?: Partial<LayoutSettings>;
  currentUser?: API.CurrentUser;
  loading?: boolean;
  fetchUserInfo?: () => Promise<API.CurrentUser | undefined>;
}> {
  // 声明用于获取用户信息的函数
  const fetchUserInfo = async () => {
    try {
      //使用 Ant Design Pro 提供的用户态机制,但是利用自定义的后端接口获取用户信息
      const msg = await getLoginUserUsingGET();
      //后端正常响应
      if (msg.code === 200) {
        //使用 Ant Design Pro 提供的用户态机制 → 需要将返回值封装成 API.CurrentUser 类型的对象
        const res: API.CurrentUser = {
          name: msg.data?.userAccount,
          userid: msg.data?.id?.toString(),
          access: msg.data?.userRole,
        };
        //判断通过后端获取到的用户态信息是否为空,若存在空属性,则返回 undefined
        if (res.name === undefined
          || res.userid === undefined
          || res.access === undefined) {
          return undefined
        }
        //若不为空,则返回封装成 API.CurrentUser 类型的从后端获取的用户态信息
        return res;
      } else {
        //后端响应异常,直接返回 undefined
        return undefined;
      }
    } catch (error) { //出现其他异常,跳转回登录页面
      history.push(loginPath);
    }
    return undefined;
  };

  // 如果不是直接放行的页面,先尝试获取用户信息
  if (!paths.includes(history.location.pathname)) {
    //获取用户信息
    const currentUser = await fetchUserInfo();
    //返回 Promise 对象(带用户信息)
    return {
      fetchUserInfo,
      currentUser,
      settings: defaultSettings,
    };
  }
  //如果是放行的页面,无需获取用户信息,直接返回 Promise 对象(不带用户信息)
  return {
    fetchUserInfo,
    settings: defaultSettings,
  };
}

//运行时布局配置(无需额外配置)
export const layout: RunTimeLayoutConfig = ({initialState, setInitialState}) => {
  return {
    //右边栏渲染器
    rightContentRender: () => <RightContent/>,
    disableContentMargin: false,
    waterMarkProps: {
      content: initialState?.currentUser?.name,
    },
    //页脚渲染器
    footerRender: () => <Footer/>,
    onPageChange: () => {
      const {location} = history;
      // 如果没有用户态信息,重定向到 login
      // 如果不是放行页面,重定向到 login
      // 即如果有用户态信息或是放行页面,则不重定向(逆否命题)
      if (!initialState?.currentUser && !paths.includes(location.pathname)) {
        console.log(initialState?.currentUser);
        history.push(loginPath);
      }
    },
    //如果是 dev 环境,显示以下内容
    links: isDev
      ? [
        <Link key="openapi" to="/umi/plugin/openapi" target="_blank">
          <LinkOutlined/>
          <span>OpenAPI 文档</span>
        </Link>,
        <Link to="/~docs" key="docs">
          <BookOutlined/>
          <span>业务组件文档</span>
        </Link>,
      ]
      : [],
    menuHeaderRender: undefined,
    
    //子渲染器(无需额外配置)
    childrenRender: (children, props) => {
      return (
        <>
          {children}
          {!props.location?.pathname?.includes('/login') && (
            <SettingDrawer
              disableUrlParams
              enableDarkTheme
              settings={initialState?.settings}
              onSettingChange={(settings) => {
                setInitialState((preInitialState) => ({
                  ...preInitialState,
                  settings,
                }));
              }}
            />
          )}
        </>
      );
    },
    ...initialState?.settings,
  };
};

知识补充:常规函数声明 vs 箭头函数声明
//常规函数声明
function greet(name: string): string {
  return `Hello, ${name}!`;
}
//箭头函数声明
const greet = (name: string): string => {
  return `Hello, ${name}!`;
};
  • 相同点:
    • 都可以用来定义函数。
    • 都可以接收参数,并返回结果。
  • 不同点:
    • 常规函数声明使用 function 关键字,箭头函数声明使用箭头(=>)符号。
    • 箭头函数声明更加简洁,可以在一行内定义函数体,而常规函数声明需要使用大括号包裹函数体。
    • 箭头函数声明中的 this 指向的是定义时的上下文,而常规函数声明中的 this 指向的是调用时的上下文。

登录页面实现

  • 静态页面
    • 登录表单
      • 输入框
      • 提示信息
  • 接口对接
    • 用户信息获取
    • 用户登录
代码汇总

省略 import 部分的代码,反复使用的自定义组件只说明一次

页面主体
//函数组件(不带参数):登录
const Login: React.FC = () => {
  //通过 useModel 处理初始状态
  const {initialState, setInitialState} = useModel('@@initialState');

  //获取用户信息
  //1.尝试从初始状态中获取
  //2.初始状态中不存在用户信息,则尝试从接口中获取,并将信息保存到初始状态中
  const fetchUserInfo = async () => {
    //尝试从初始状态中获取用户信息
    const userInfo = initialState?.currentUser;
    //初始状态中的用户信息不存在,则通过后端接口获取信息
    if (!userInfo) {
      const msg = await getLoginUserUsingGET();
      if (msg.code === 200) {
        const res: API.CurrentUser = {
          name: msg.data?.userAccount,
          userid: msg.data?.id?.toString(),
          access: msg.data?.userRole
        }
        console.log(res)
        //设置初始状态,保存获取的用户信息
        await setInitialState((s) => ({
          ...s,
          currentUser: res,
        }));
        //返回用户信息
        return userInfo;
      }
    }
    return userInfo;
  };

  //注册函数,对接自动生成的接口函数
  const handleSubmit = async (values: API.loginUserParams) => {
    try {
      console.log("login:userLoginUsingPOST")
      const res = await userLoginUsingPOST({
        userAccount: values.username,
        userPassword: values.password
      });
      console.log(res)
      //根据返回值判断是否登录成功
      if (res.code === 200) {
        //登录成功,则提示成功信息
        message.success("登录成功");
        //获取已登录的用户信息
        await fetchUserInfo();
        //页面跳转
        /** 此方法会跳转到 redirect 参数所在的位置 */
        if (!history) return;
        const {query} = history.location;
        const {redirect} = query as {
          redirect: string;
        };
        history.push(redirect || '/');
        return;
      } else {
        //登录失败,
        message.error(res.message + " : " + res.description)
      }
    } catch (error) {
      message.error("内部错误,请联系管理员!");
    }
  }

  //页面元素描述,等效于 React 类组件中的 render()
  return (
    //只允许一个根组件
    <div className={styles.container}>
      <div className={styles.content}>

        {/* 登录表单 */}
        <LoginForm
          logo={<img alt="logo" src="/logo.svg"/>}
          title="登录页面"
          subTitle={'本项目为基于 Ant Design Pro 的通用中后台系统模板'}
          initialValues={{
            autoLogin: true,
          }}
          onFinish={async (values) => {
            await handleSubmit(values as API.loginUserParams);
          }}
        >
          <Tabs>
            <Tabs.TabPane tab={'账户密码登录'}/>
          </Tabs>
          <ProFormText
            name="username"
            fieldProps={{
              size: 'large',
              prefix: <UserOutlined className={styles.prefixIcon}/>,
            }}
            placeholder={'请输入用户名'}
            rules={[
              {
                required: true,
                message: '用户名是必填项!',
              },
            ]}
          />
          <ProFormText.Password
            name="password"
            fieldProps={{
              size: 'large',
              prefix: <LockOutlined className={styles.prefixIcon}/>,
            }}
            placeholder={'请输入密码'}
            rules={[
              {
                required: true,
                message: '密码是必填项!',
              },
            ]}
          />
          <LoginPageFooter/>
          <div style={{height: "3vh"}}/>
        </LoginForm>
      </div>
      <Footer/>
    </div>
  );
};
export default Login;
自定义组件
const LoginPageFooter = () => {
  const history = useHistory();
  const handleRegisterClick = () => {
    // 处理点击“没有账号?”时的跳转逻辑
    // 跳转到注册页面
    history.push('/user/register');
  };

  const handleForgotPasswordClick = () => {
    // 处理点击“忘记密码?”时的跳转逻辑
    // 跳转到找回密码页面
    history.push('/user/forget');
  };

  return (
    <div>
      <div style={{float: 'left'}}>
        没有账号?
        <a onClick={handleRegisterClick}>注册</a>
      </div>
      <div style={{float: 'right'}}>
        <a onClick={handleForgotPasswordClick}>忘记密码?</a>
      </div>
    </div>
  );
};

export default LoginPageFooter;
const Footer: React.FC = () => {
  const defaultMessage = '蚂蚁集团体验技术部出品';
  const currentYear = new Date().getFullYear();
  return (
    <DefaultFooter
      copyright={`${currentYear} ${defaultMessage}`}
      links={[
        {
          key: 'Ant Design Pro',
          title: 'Ant Design Pro',
          href: 'https://pro.ant.design',
          blankTarget: true,
        },
        {
          key: 'github',
          title: <GithubOutlined />,
          href: 'https://github.com/ant-design/ant-design-pro',
          blankTarget: true,
        },
        {
          key: 'Ant Design',
          title: 'Ant Design',
          href: 'https://ant.design',
          blankTarget: true,
        },
      ]}
    />
  );
};
export default Footer;

注册页面实现

  • 静态页面
    • 通用表单
      • 输入框
      • 提示信息
  • 接口对接
    • 注册功能
代码汇总
页面本体
const waitTime = (time: number = 100) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(true);
    }, time);
  });
};

const handleSubmit = async (values: any) => {
  console.log(values);
  if (values.password != values.checkPassword) {
    message.error("两次输入的密码不一致");
    return;
  }
  const req: API.UserRegisterRequest = {
    userAccount: values.username,
    userPassword: values.password,
    checkPassword: values.checkPassword
  }
  const msg = await register(req);
  console.log("register");
  console.log(msg);
  if (msg.code === 200) {
    message.success("注册成功")
    history.push("/user/login")
  } else {
    message.error(msg.message + ":" + msg.description);
  }
}

const Register: React.FC = () => {
  //页面元素描述,等效于 React 类组件中的 render()
  return (
    <>
      <div className={styles.container}>
        <div className={styles.content}>
          <div
            style={{
              margin: 24,
            }}
          >
            <ProForm
              onFinish={async (values: any) => {
                //TODO 控制冷却时间
                await waitTime(200);
                await handleSubmit(values);
              }}
            >
              <ProFormText
                // width="md"
                name="username"
                label="用户名"
                tooltip="长度限制为 4 到 20 个字符"
                fieldProps={{
                  size: 'large',
                  prefix: <UserOutlined className={styles.prefixIcon}/>,
                }}
                placeholder={'请输入用户名'}
                rules={[
                  {
                    min: 4,
                    max: 20,
                    required: true,
                    message: '请输入  4 到 20 个字符的用户名!',
                  },
                ]}
              />
              <ProFormText.Password
                label="用户密码"
                name="password"
                tooltip="长度至少为 8 个字符"
                fieldProps={{
                  size: 'large',
                  prefix: <LockOutlined className={styles.prefixIcon}/>,
                }}
                // placeholder={'密码: ant.design'}
                placeholder={'请输入密码'}
                rules={[
                  {
                    min: 8,
                    required: true,
                    message: '至少输入 8 位密码!',
                  },
                ]}
              />
              <ProFormText.Password
                name="checkPassword"
                label="密码检验"
                tooltip="长度至少为 8 个字符"
                fieldProps={{
                  size: 'large',
                  prefix: <LockOutlined className={styles.prefixIcon}/>,
                }}
                placeholder={'请再次输入密码'}
                rules={[
                  {
                    min: 8,
                    required: true,
                    message: '至少输入 8 位密码!',
                  },
                ]}
              />
              <RegisterPageFooter/>
            </ProForm>
            <Footer/>
          </div>
        </div>
      </div>
    </>
  );
};
export default Register;
自定义组件
const RegisterPageFooter = () => {
  const history = useHistory();
  const handleLoginClick = () => {
    // 处理点击“没有账号?”时的跳转逻辑
    // 跳转到注册页面
    history.push('/user/login');
  };

  return (
    <div>
      <div style={{float: 'right'}}>
        已有账号?
        <a onClick={handleLoginClick}>登录</a>
      </div>
    </div>
  );
};

export default RegisterPageFooter;

表单页面实现

  • 静态页面
    • 高级表格(ProTable)
    • 底部工具栏(FooterToolBar)
    • 弹窗表单(ModalForm)
    • 抽屉(Drawer)
  • 接口对接
    • Sample 管理
代码汇总
页面本体 · CRUD 接口对接函数
//引入接口方法,并重命名
import {
  addSampleUsingPOST as add,
  updateSampleUsingPOST as update,
  deleteSampleUsingPOST as remove,
  listUsingGET as list
} from "@/services/stateful-backend/sampleController";

//增
const handleAdd = async (fields: API.SampleAddRequest) => {
  console.log("handleAdd")
  console.log(fields)
  const hide = message.loading('正在添加');
  try {
    await add({
      ...fields,
    });
    hide();
    message.success('新建样例成功');
    return true;
  } catch (error) {
    hide();
    message.error('新建样例失败,请重试');
    return false;
  }
};

//删
const handleRemove = async (selectedRows: API.IdRequest[]) => {
  console.log("handleRemove")
  console.log(selectedRows)
  const hide = message.loading('正在删除');
  if (!selectedRows) return true;
  try {
    for (const row of selectedRows) {
      await remove(row);
    }
    hide();
    message.success('删除成功');
    return true;
  } catch (error) {
    hide();
    message.error('删除失败,请重试');
    return false;
  }
};

//改
const handleUpdate = async (fields: API.SampleUpdateRequest) => {
  console.log("handleUpdate")
  console.log(fields)
  const hide = message.loading('更新中');
  try {
    await update(fields);
    hide();
    message.success('更新成功');
    return true;
  } catch (error) {
    hide();
    message.error('更新失败,请重试');
    return false;
  }
};

//查
const getList = async () => {
  console.log("getList")
  const msg = await list({});
  return {
    data: msg.data,
    total: msg.data?.length,
    success: true
  };
}
页面本体 · 渲染器
const TableList: React.FC = () => {

  //add 表单弹窗状态(显示 or 不显示)控制
  const [createModalVisible, handleModalVisible] = useState<boolean>(false);

  //update 表单弹窗状态(显示 or 不显示)控制
  const [updateModalVisible, handleModalVisibleForUpdate] = useState<boolean>(false);

  //detail 抽屉状态(显示 or 不显示)控制
  const [showDetail, setShowDetail] = useState<boolean>(false);

  const actionRef = useRef<ActionType>();

  //行选取状态控制
  const [currentRow, setCurrentRow] = useState<API.SampleVO>();
  const [selectedRowsState, setSelectedRows] = useState<API.SampleVO[]>([]);

  //例属性配置
  const columns: ProColumns<API.SampleVO>[] = [...]
  
  // 渲染器配置
  return (
  <PageContainer>
    <ProTable>完整表单</ProTable>
    <FooterToolBar>底部工具栏</FooterToolBar>
    <ModalForm>add 表单</ModalForm>
    <ModalForm>update 表单</ModalForm>
    <Drawer>抽屉</Drawer>
  </PageContainer>);
};
export default TableList;
页面本体 · ProTable 表单
//列属性配置
const columns: ProColumns<API.SampleVO>[] = [
  {
    title: 'Sample',
    dataIndex: 'id',
    tip: 'The id is the unique key',
    search: false,
    render: (dom, entity) => {
      return (
        <a
          onClick={() => {
            setCurrentRow(entity);
            setShowDetail(true);
          }}
        >
          {dom}
        </a>
      );
    },
  },

  {
    title: '文本',
    dataIndex: 'sampleTest',
    //自动缩略
    ellipsis: true,
    search: false,
    render: (dom) => {
      return (
        <>{dom}</>
      );
    },
  },

  {
    title: '状态',
    dataIndex: 'sampleStatus',
    search: false,
    render: (dom) => [
      <>{dom}</>
    ],
  },

  {
    title: '操作',
    dataIndex: 'option',
    valueType: 'option',
    render: (_, record) => [
      <a
        key="config"
        onClick={() => {
          //设置当前行为选中的记录
          setCurrentRow(record);
          //打开弹窗
          handleModalVisibleForUpdate(true);
        }}
      >
        修改 Sample
      </a>,
    ],
  },
];
{/* 查询结果表格 */}
<ProTable
  <API.SampleVO, API.PageParams>
  //基础配置
  headerTitle={'查询表格'}
  actionRef={actionRef}
  rowKey="id"
  search={{labelWidth: 120,}}
  
  //工具栏渲染器
  toolBarRender={() => [
    <Button
      type="primary"
      key="primary"
      onClick={() => {
        handleModalVisible(true);
      }}
    >
      <PlusOutlined/> 新建
    </Button>,
  ]}

  //数据请求
  //使用前端自带的分页机制,此处获取所有数据即可
  request={async () => {
    const res = await getList();
    return {data: res.data, success: res.success, total: res.total}
  }}

  //列属性配置
  columns={columns}

  //行选择器
  rowSelection={{
    onChange: (_, selectedRows) => {
      setSelectedRows(selectedRows);
    },
  }}
/>
页面本体 · 批量删除工具栏
{/*批量删除工具栏*/}
{selectedRowsState?.length > 0 && (
  <FooterToolbar
    extra={
      <div>已选择{' '}
        <a style={{fontWeight: 600,}}>
          {selectedRowsState.length}
        </a>{' '} 项 &nbsp;&nbsp;
      </div>
    }
  >
    <Button
      onClick={async () => {
        await handleRemove(selectedRowsState);
        setSelectedRows([]);
        actionRef.current?.reloadAndRest?.();
      }}
    >
      批量删除
    </Button>
  </FooterToolbar>
)}
页面主体 · add 表单(ModalForm)
{/* add 表单 */}
<ModalForm
  //基础配置
  title={'新建 Sample'}
  width="400px"

  //状态监听(是否显示)
  visible={createModalVisible}
  onVisibleChange={handleModalVisible}

  //确定按钮事件绑定
  onFinish={async (value) => {
    //TODO:更改返回值类型
    const success = await handleAdd(value as API.SampleAddRequest);
    if (success) {
      handleModalVisible(false);
      if (actionRef.current) {
        actionRef.current.reload();
      }
    }
  }

  }
>
  <ProFormText
    placeholder='请输入 id'
    rules={[
      {
        required: true,
        message: 'id 为必填项',
      },
    ]}
    width="md"
    name="id"
  />
  <ProFormText
    placeholder='请输入样例文本'
    rules={[
      {
        required: true,
        message: '样例文本为必填项',
      },
    ]}
    width="md"
    name="sampleTest"
  />
  <ProFormText
    placeholder='请输入样例状态'
    rules={[
      {
        required: false,
      },
    ]}
    width="md"
    name="sampleStatus"
  />
</ModalForm>
页面主体 · update 表单(ModalForm)
{/* update 表单 */}
<ModalForm
  //基础配置
  title={'更新 Sample'}
  width="400px"
  modalProps={{destroyOnClose: true}} //是否关闭时清除输入的内容

  //状态监听(是否显示)
  visible={updateModalVisible}
  onVisibleChange={handleModalVisibleForUpdate}

  //确定按钮事件绑定
  onFinish={async (value) => {
    console.log(value)
    //TODO:更改返回值类型
    const success = await handleUpdate(value as API.SampleUpdateRequest);
    if (success) {
      handleModalVisible(false);
      if (actionRef.current) {
        actionRef.current.reload();
      }
    }
    //返回 true 则操作完成后关闭弹窗
    return true
  }
  }
>
  <ProFormText
    initialValue={currentRow?.id}
    width="md"
    name="id"
    disabled
  />
  <ProFormText
    placeholder='请输入样例文本'
    rules={[
      {
        required: true,
        message: '样例文本为必填项',
      },
    ]}
    width="md"
    name="sampleTest"
  />
  <ProFormText
    placeholder='请输入状态'
    rules={[
      {
        required: true,
        message: '状态为必填项',
      },
    ]}
    width="md"
    name="sampleStatus"
  />
</ModalForm>
页面本体 · 抽屉
{/*抽屉*/}
<Drawer
  width={600}
  visible={showDetail}
  onClose={() => {
    setCurrentRow(undefined);
    setShowDetail(false);
  }}
  closable={false}
>
  <ProDescriptions
    //TODO 更改返回值类型
    <API.SampleVO>
    column={1}
    bordered={true}
    size={"default"}
    title="样例"
    dataSource={
      {
        id: currentRow?.id,
        sampleTest: currentRow?.sampleTest,
        sampleStatus: currentRow?.sampleStatus,
      }}
    columns={[
      {
        title: 'id',
        key: 'id',
        dataIndex: 'id',
      },
      {
        title: '文本',
        copyable: true,
        dataIndex: 'sampleTest',
        //自动缩略
        ellipsis: false
      },
      {
        title: '状态',
        dataIndex: 'sampleStatus',
      },
    ]}
  />
</Drawer>

项目总结

项目开发流程

回顾 Web 开发分工
  • 前端开发 = 用户交互 + 数据处理 + 接口对接
  • 后端开发 = 业务逻辑 + 数据存取 + 接口封装
个人开发流程(实践总结)
  1. 预备工作:需求分析 + 系统设计
  2. 后端开发(业务逻辑 + 数据存取 + 接口封装)
  3. 后端通过 Knife4j 生成遵守规范(此处为 OpenAPI 规范:OAS)的 接口管理文档
  4. 前端通过 umi openapi ,利用后端生成的接口管理文档,生成前端 接口方法 和对应的 对象类型
  5. 前端开发(静态页面)
  6. 前端对接生成的 接口方法
团队开发流程(理论设计)
  1. 预备工作:需求分析 + 系统设计
  2. 前后端并行开发
    • 前端实现静态页面(用户交互 + 数据处理)
    • 后端实现业务逻辑、数据存取、接口封装
  3. 后端通过 Knife4j 生成遵守规范(此处为 OpenAPI 规范:OAS)的 接口管理文档
  4. 前端通过 umi openapi ,利用后端生成的接口管理文档,生成前端 接口方法 和对应的 对象类型
  5. 前端对接生成的 接口方法

ProComponents 常用组件用法汇总

不够详细的官方文档

表格组件(ProTable)

ProTable

//列属性配置
const columns: ProColumns<API.SampleVO>[] = [
  {
    title: 'Sample',
    dataIndex: 'id',
    tip: 'The id is the unique key',
    search: false,
    render: (dom, entity) => {
      return (
        <a
          onClick={() => {
            setCurrentRow(entity);
            setShowDetail(true);
          }}
        >
          {dom}
        </a>
      );
    },
  },

  {
    title: '文本',
    dataIndex: 'sampleTest',
    //自动缩略
    ellipsis: true,
    search: false,
    render: (dom) => {
      return (
        <>{dom}</>
      );
    },
  },

  {
    title: '状态',
    dataIndex: 'sampleStatus',
    search: false,
    render: (dom) => [
      <>{dom}</>
    ],
  },

  {
    title: '操作',
    dataIndex: 'option',
    valueType: 'option',
    render: (_, record) => [
      <a
        key="config"
        onClick={() => {
          //设置当前行为选中的记录
          setCurrentRow(record);
          //打开弹窗
          handleModalVisibleForUpdate(true);
        }}
      >
        修改 Sample
      </a>,
    ],
  },
];
{/* 查询结果表格 */}
<ProTable
  <API.SampleVO, API.PageParams>
  //基础配置
  headerTitle={'查询表格'}
  actionRef={actionRef}
  rowKey="id"
  search={{labelWidth: 120,}}
  
  //工具栏渲染器
  toolBarRender={() => [
    <Button
      type="primary"
      key="primary"
      onClick={() => {
        handleModalVisible(true);
      }}
    >
      <PlusOutlined/> 新建
    </Button>,
  ]}

  //数据请求
  //使用前端自带的分页机制,此处获取所有数据即可
  request={async () => {
    const res = await getList();
    return {data: res.data, success: res.success, total: res.total}
  }}

  //列属性配置
  columns={columns}

  //行选择器
  rowSelection={{
    onChange: (_, selectedRows) => {
      setSelectedRows(selectedRows);
    },
  }}
/>
文本输入框(ProFormText)
<ProFormText
  // width="md" //宽度调整
  // disabled //是否禁用
  name="username"
  label="用户名"
  tooltip="长度限制为 4 到 20 个字符"
  fieldProps={{
    size: 'large',
    prefix: <UserOutlined className={styles.prefixIcon}/>,
  }}
  placeholder={'请输入用户名'}
  rules={[
    {
      min: 4,
      max: 20,
      required: true,
      message: '请输入  4 到 20 个字符的用户名!',
    },
  ]}
/>
<ProFormText.Password
  label="用户密码"
  name="password"
  tooltip="长度至少为 8 个字符"
  fieldProps={{
    size: 'large',
    prefix: <LockOutlined className={styles.prefixIcon}/>,
  }}
  // placeholder={'密码: ant.design'}
  placeholder={'请输入密码'}
  rules={[
    {
      min: 8,
      required: true,
      message: '至少输入 8 位密码!',
    },
  ]}
/>
常规表单组件(ProForm)
<ProForm
  onFinish={async (values: any) => {
    await waitTime(200);
    await handleSubmit(values);
  }}
>
  {/*内部标签省略,一般就是放 <ProFormText/> */}
</ProForm>
弹窗表单组件(ModalForm)
//add 表单弹窗状态(显示 or 不显示)控制
const [createModalVisible, handleModalVisible] = useState<boolean>(false);

{/* add 表单 */}
<ModalForm
  //基础配置
  title={'新建 Sample'}
  width="400px"

  //状态监听(是否显示)
  visible={createModalVisible}
  onVisibleChange={handleModalVisible}

  //确定按钮事件绑定
  onFinish={async (value) => {
    //...
  }}
>
  {/*内部标签省略,一般就是放 <ProFormText/> */}
</ModalForm>
抽屉组件(Drawer)
//detail 抽屉状态(显示 or 不显示)控制
const [showDetail, setShowDetail] = useState<boolean>(false);

{/*抽屉*/}
<Drawer
  //宽度
  width={600}
  //状态控制
  visible={showDetail}
  //关闭时执行的回调函数
  onClose={() => {
    setCurrentRow(undefined);
    setShowDetail(false);
  }}
  //是否显示关闭按钮
  closable={false}
>
  {/*内部标签省略,一般就是放 <ProDescriptions/> */}
</Drawer>
<ProDescriptions
  //类型声明
  <API.SampleVO>
  {/*每行显示的属性个数(列数)*/}
  column={1}
  {/*是否显示边框*/}
  bordered={true}
  {/*标题*/}
  title="样例"
  {/*数据源*/}
  dataSource={
    {
      id: currentRow?.id,
      sampleTest: currentRow?.sampleTest,
      sampleStatus: currentRow?.sampleStatus,
    }}
  {/*类属性配置*/}
  columns={[
    {
      title: 'id',
      key: 'id',
      dataIndex: 'id',
    },
    {
      title: '文本',
      copyable: true,
      dataIndex: 'sampleTest',
      //自动缩略,设置为 false 时,能全部显示
      ellipsis: false
    },
    {
      title: '状态',
      dataIndex: 'sampleStatus',
    },
  ]}
/>

Ant Design Pro 知识补充

简介

开箱即用的中台前端/设计解决方案

  • 前端 = 组件交互 + 数据处理 + 接口对接
  • 开箱即用:封装程度高
  • 中台:数据多,重 CRUD,轻用户交互

感受:Ant Design Pro 的引入,使得前端规范化(强类型 + 组件化) + 前后端对齐

  • 前端规范化
    • 强类型 By TypeScript 的引入
    • 组件化 By React Style 的引入
  • 前后端对齐 By services 的引入
项目元素
  • 页面

    • 组织
      • 大驼峰文件夹
        • components:内部组件文件夹
        • index.less:页面样式
        • index.tsx:页面主体
    • 使用:绑定路由,作为页面整体使用
  • 组件

    • 组织:大驼峰.tsx
    • 使用:通过标签,作为页面部分使用
  • 类型

    • 组织:typings.d.ts
    • 使用:按 <namespace-name>.<type-name> 的形式使用
  • 接口

    • 组织:小驼峰.ts

    • 使用:

      1. 预备工作:配置并生成接口方法

        1. 配置 config/config.ts
          • defineConfig.openAPI.schemaPath
          • defineConfig.openAPI.projectName
        2. 通过 umi openapi 生成接口方法
      2. import 接口文件内部 export 的方法

        知识补充:import {request} from 'umi'

        在UMI中的request函数实际上是对axios的封装,提供了更加简洁、易用的API,使得我们可以更方便地发送HTTP请求并处理响应。

        axios是一个基于Promise的HTTP客户端库,它封装了XMLHttpRequestfetch等底层API,提供了更简单、更强大的接口来发送HTTP请求和处理响应。

        通过axios,我们可以方便地发送各种类型的HTTP请求,包括GET、POST、PUT、DELETE等,并且可以设置请求头、处理请求参数、设置超时时间、处理响应等。

      3. 通过该方法请求后端响应

        知识补充:async await 机制使用

        async/await 是 JavaScript 中的一种异步编程模式,它基于 JavaScript 的 Promise 对象,并提供了一种更简洁、更直观的语法来处理异步操作。

        • 在 async 函数内部,通过在函数前面加上 async 关键字来声明一个异步函数。
        • 在异步函数中,可以使用 await 关键字来等待一个 Promise 对象的状态变为 fulfilledrejected,然后获取其结果。

        使用 async/await 的语法,可以实现顺序执行异步操作的效果,让代码看起来更像是同步的,更易于理解。

TypeScript 知识点汇总
简介

TypeScript ? JavaScript 类似于 C++ ? C

TypeScript JavaScript
TypeScript 是 JavaScript 的超集 JavaScript 是一种编程语言
强类型,支持静态类型检查 弱类型,动态类型检查
可以编译成纯 JavaScript 代码 直接执行在浏览器或服务器上
支持面向对象、类、接口等概念 支持面向对象、函数式编程等概念
提供更好的工具和编辑器支持 工具和编辑器支持相对较弱
拥有更多的语言特性和扩展 相对较少的语言特性和扩展
能够提高代码的可读性和可维护性 更加灵活但可能导致代码质量下降

TypeScript 编译器将 TypeScript 代码转换为相应的 ECMAScript(JavaScript)版本的代码,然后在运行时执行这些代码。这是因为浏览器和服务器环境只能理解和执行 JavaScript 代码,不能直接执行 TypeScript 代码,所以需要将 TypeScript 代码编译为 JavaScript 代码才能运行。

类型介绍

TypeScript 的基础数据类型包括:

  • 布尔类型(boolean)
  • 数字类型(number)
  • 字符串类型(string)
  • 数组类型(array)
  • 元组类型(tuple)
  • 枚举类型(enum)
  • 任意类型(any)
  • 空类型(void)
  • null 和 undefined

通过 namespace 可以组成复合数据类型。在 TypeScript 中,namespace 可以用来创建具有层级结构的命名空间,用来组织和管理类型或代码。可以通过在命名空间中声明变量、函数、类等,并通过命名空间来调用它们。命名空间可以嵌套,以形成复杂的数据结构。在使用命名空间内的变量、函数、类时,需要使用命名空间作为前缀来访问。

通过 namespace 创建等效类

//namespace 声明
namespace MyNamespace {
  export interface Person {
    name: string;
    age: number;
  }

  export function sayHello(name: string) {
    console.log(`Hello, ${name}!`);
  }
}

//namespace 使用
const person: MyNamespace.Person = { name: "Alice", age: 25 };
MyNamespace.sayHello(person.name);

通过 namespace 声明复合数据类型(等效结构体)

declare namespace MyNamespace {
  type Person = {
    name: string;
    age: number;
  };
}

在使用 declare namespace 声明的命名空间中,类型不会被实际生成为 JavaScript 代码,而只是用于编译器进行类型检查。

类型使用

当在 TypeScript 中声明变量时,我们可以使用类型注解来指定变量的类型。下面是一个示例代码,在 TypeScript 中按类型声明变量的几种方式:

// 使用冒号加上类型注解的方式
let num1: number = 10;
let str1: string = 'Hello';
let bool1: boolean = true;

// 使用 as 关键字的方式
let num2 = 20 as number;
let str2 = 'World' as string;
let bool2 = false as boolean;

// 使用 <> 操作符的方式
let num3 = <number>30;
let str3 = <string>'Hi';
let bool3 = <boolean>false;

以上代码展示了三种不同的方式来按类型声明变量,使用冒号加上类型注解的方式是最常见且推荐的方式。另外,注意在 TypeScript 中,也可以通过类型推断的方式,省略类型注解,让编译器根据赋值来推断变量的类型。

空值检查

作用类似于 Java 中的 Optional

  • 不保证非空

    • 条件判断

      if (res.data) {
        // 对 res.data 进行操作
        const data: DataType = res.data;
      } else {
        // 处理 'res.data' 为 undefined 的情况
        let res.data = defaultValue;
      }
      
    • 条件式 res.value ? res.value : defaultValue

      // 等效的条件式写法
      const data: DataType = res.data ? res.data : defaultValue;
      
  • 保证非空

    • 非空断言操作符 res.data!

      const data: DataType = res.data!;
      

Ant Design Pro 机制整理

可复用 CRUD 机制
机制实现

通过 umi openapi自动生成接口文档机制,实现接口方法和对象类型的自动生成(后端接口对齐)

对以下组件进行改造,实现通用的信息录入逻辑

  • ProTable
  • ModalForm
  • Drawer
机制载入

复制 TableList 页面

机制使用
  1. umi openapi 生成接口方法
  2. TableList 页面配置
    1. 修改引入的方法
    2. 修改页面元素类型
    3. 配置表格列属性
    4. 更改列表元素类型 改第一个就行
    5. 更改 handteAdd 方法的参数类型
    6. 更改 handleUpdate 方法的参数类型
    7. 配置 <Draw> 中的 <ProDescriptions>
    8. 修改页面文本

Ant Design Pro 解决方案整理

接口方法生成解决方案
解决方案原理

前提条件是建立规范

处理流程:
接口文件即中间代码,接口方法即最终代码

  1. 静态分析:umi openapi 会使用 AST(抽象语法树)去分析项目中的接口文件,提取接口信息,包括请求 URL、请求方式、参数等。
  2. 代码生成:根据接口信息,umi openapi 会自动生成对应的 API 接口方法的代码,这些方法会被写入到指定的目标文件中,一般是一个单独的文件用于存放所有的 API 接口方法。
解决方案配置
export default defineConfig({

  ...
  
  openAPI: [
    {
      requestLibPath: "import { request } from 'umi'",
      // 配置接口文档的请求路径
      schemaPath:'http://localhost:8080/v3/api-docs',
      // 配置生成代码的文件夹名称
      projectName: 'stateful-backend',
    },
  ],

  ...
  
});
解决方案使用

运行 package.json 中的以下命令

{

  ...
  
  "scripts": {
		 
		...
    
    "openapi": "umi openapi",
    
    ...
    
    }
  
  ...

}
分页解决方案
解决方案原理

原理不明

解决方案配置

无需额外配置

解决方案使用
<ProTable

  ...

  //数据请求
  //使用前端自带的分页机制,此处获取所有数据即可
  request={async () => {
    const res = await getList();
    return {data: res.data, success: res.success, total: res.total}
  }}

  ...

/>
Mock 解决方案
解决方案原理

原理不明

解决方案配置

无需额外配置

解决方案使用
  • mock 模拟:通过编写一个 mock.ts 文件作为前端访问的假数据

    • mock 数据生成
    • mock 数据获取
    • mock 数据处理
  • 真实后端接入:通过控制 baseUrl 实现后端切换(真实 or mock),接口请求方法是完全相同的

    //真实后端接入
    const baseUrl:string = "http://localhost:8080";
    //接入 mock
    const baseUrl:string = "";
    
    /** 获取用户信息列表 GET /api/v1/user/getLists */
    export async function users(
      params: {
    		//参数省略...
      },
      options?: { [key: string]: any },) {
      return request<API.userList>(baseUrl + '/api/v1/user/getLists', {
        method: 'GET',
        params: {
          ...params,
        },
        ...(options || {}),
      });
    }
    

出现以下内容,证明没设置 mock (即没给出对应的 ts 文件)又访问 mock

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="theme-color" content="#1890ff" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta
      name="keywords"
      content="antd,umi,umijs,ant design,Scaffolding, layout, Ant Design, project, Pro, admin, console, homepage, out-of-the-box, middle and back office, solution, component library"
    />
    <meta
      name="description"
      content="
    An out-of-box UI solution for enterprise applications as a React boilerplate."
    />
    <meta
      name="description"
      content="
      Out-of-the-box mid-stage front-end/design solution."
    />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
    />
    <title>Ant Design Pro</title>
    <link rel="icon" href="/favicon.ico" type="image/x-icon" />
    <script>
      window.routerBase = "/";
    </script>
    <script src="/@@/devScripts.js"></script>
    <script>
      //! umi version: 3.5.41
    </script>
    <script>
      !(function () {
        var e =
            navigator.cookieEnabled && void 0 !== window.localStorage
              ? localStorage.getItem("dumi:prefers-color")
              : "auto",
          o = window.matchMedia("(prefers-color-scheme: dark)").matches,
          t = ["light", "dark", "auto"];
        document.documentElement.setAttribute(
          "data-prefers-color",
          e === t[2] ? (o ? t[1] : t[0]) : t.indexOf(e) > -1 ? e : t[0]
        );
      })();
    </script>
  </head>
  <body>
    <noscript>
      <div class="noscript-container">
        Hi there! Please
        <div class="noscript-enableJS">
          <a
            href="https://www.enablejavascript.io/en"
            target="_blank"
            rel="noopener noreferrer"
          >
            <b>enable Javascript</b>
          </a>
        </div>
        in your browser to use Ant Design, Out-of-the-box mid-stage front/design
        solution!
      </div>
    </noscript>
    <div id="root">
      <style>
        html,
        body,
        #root {
          height: 100%;
          margin: 0;
          padding: 0;
        }
        #root {
          background-repeat: no-repeat;
          background-size: 100% auto;
        }
        .noscript-container {
          display: flex;
          align-content: center;
          justify-content: center;
          margin-top: 90px;
          font-size: 20px;
          font-family: "Lucida Sans", "Lucida Sans Regular", "Lucida Grande",
            "Lucida Sans Unicode", Geneva, Verdana, sans-serif;
        }
        .noscript-enableJS {
          padding-right: 3px;
          padding-left: 3px;
        }
        .page-loading-warp {
          display: flex;
          align-items: center;
          justify-content: center;
          padding: 98px;
        }
        .ant-spin {
          position: absolute;
          display: none;
          -webkit-box-sizing: border-box;
          box-sizing: border-box;
          margin: 0;
          padding: 0;
          color: rgba(0, 0, 0, 0.65);
          color: #1890ff;
          font-size: 14px;
          font-variant: tabular-nums;
          line-height: 1.5;
          text-align: center;
          list-style: none;
          opacity: 0;
          -webkit-transition: -webkit-transform 0.3s
            cubic-bezier(0.78, 0.14, 0.15, 0.86);
          transition: -webkit-transform 0.3s
            cubic-bezier(0.78, 0.14, 0.15, 0.86);
          transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
          transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),
            -webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
          -webkit-font-feature-settings: "tnum";
          font-feature-settings: "tnum";
        }

        .ant-spin-spinning {
          position: static;
          display: inline-block;
          opacity: 1;
        }

        .ant-spin-dot {
          position: relative;
          display: inline-block;
          width: 20px;
          height: 20px;
          font-size: 20px;
        }

        .ant-spin-dot-item {
          position: absolute;
          display: block;
          width: 9px;
          height: 9px;
          background-color: #1890ff;
          border-radius: 100%;
          -webkit-transform: scale(0.75);
          -ms-transform: scale(0.75);
          transform: scale(0.75);
          -webkit-transform-origin: 50% 50%;
          -ms-transform-origin: 50% 50%;
          transform-origin: 50% 50%;
          opacity: 0.3;
          -webkit-animation: antspinmove 1s infinite linear alternate;
          animation: antSpinMove 1s infinite linear alternate;
        }

        .ant-spin-dot-item:nth-child(1) {
          top: 0;
          left: 0;
        }

        .ant-spin-dot-item:nth-child(2) {
          top: 0;
          right: 0;
          -webkit-animation-delay: 0.4s;
          animation-delay: 0.4s;
        }

        .ant-spin-dot-item:nth-child(3) {
          right: 0;
          bottom: 0;
          -webkit-animation-delay: 0.8s;
          animation-delay: 0.8s;
        }

        .ant-spin-dot-item:nth-child(4) {
          bottom: 0;
          left: 0;
          -webkit-animation-delay: 1.2s;
          animation-delay: 1.2s;
        }

        .ant-spin-dot-spin {
          -webkit-transform: rotate(45deg);
          -ms-transform: rotate(45deg);
          transform: rotate(45deg);
          -webkit-animation: antrotate 1.2s infinite linear;
          animation: antRotate 1.2s infinite linear;
        }

        .ant-spin-lg .ant-spin-dot {
          width: 32px;
          height: 32px;
          font-size: 32px;
        }

        .ant-spin-lg .ant-spin-dot i {
          width: 14px;
          height: 14px;
        }

        @media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
          .ant-spin-blur {
            background: #fff;
            opacity: 0.5;
          }
        }

        @-webkit-keyframes antSpinMove {
          to {
            opacity: 1;
          }
        }

        @keyframes antSpinMove {
          to {
            opacity: 1;
          }
        }

        @-webkit-keyframes antRotate {
          to {
            -webkit-transform: rotate(405deg);
            transform: rotate(405deg);
          }
        }

        @keyframes antRotate {
          to {
            -webkit-transform: rotate(405deg);
            transform: rotate(405deg);
          }
        }
      </style>
      <div
        style="
          display: flex;
          flex-direction: column;
          align-items: center;
          justify-content: center;
          height: 100%;
          min-height: 420px;
        "
      >
        <img src="/pro_icon.svg" alt="logo" width="256" />
        <div class="page-loading-warp">
          <div class="ant-spin ant-spin-lg ant-spin-spinning">
            <span class="ant-spin-dot ant-spin-dot-spin"
              ><i class="ant-spin-dot-item"></i><i class="ant-spin-dot-item"></i
              ><i class="ant-spin-dot-item"></i><i class="ant-spin-dot-item"></i
            ></span>
          </div>
        </div>
        <div
          style="display: flex; align-items: center; justify-content: center"
        >
          <img
            src="https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg"
            width="32"
            style="margin-right: 8px"
          />
          Ant Design
        </div>
      </div>
    </div>

    <script src="/umi.js"></script>
  </body>
</html>

样例 1: 列表数据获取

//import 部分省略

//mock 数据生成
const genList = (current: number, pageSize: number) => {
  const tableListDataSource: API.RuleListItem[] = [];

  for (let i = 0; i < pageSize; i += 1) {
    const index = (current - 1) * 10 + i;
    tableListDataSource.push({
      key: index,
      disabled: i % 6 === 0,
      href: 'https://ant.design',
      avatar: [
        'https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png',
        'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png',
      ][i % 2],
      name: `TradeCode ${index}`,
      owner: '曲丽丽',
      desc: '这是一段描述',
      callNo: Math.floor(Math.random() * 1000),
      status: Math.floor(Math.random() * 10) % 4,
      updatedAt: moment().format('YYYY-MM-DD'),
      createdAt: moment().format('YYYY-MM-DD'),
      progress: Math.ceil(Math.random() * 100),
    });
  }
  tableListDataSource.reverse();
  return tableListDataSource;
};

//mock 数据获取
let tableListDataSource = genList(1, 100);

//mock 数据处理
function getRule(req: Request, res: Response, u: string) {
  let realUrl = u;
  if (!realUrl || Object.prototype.toString.call(realUrl) !== '[object String]') {
    realUrl = req.url;
  }
  const { current = 1, pageSize = 10 } = req.query;
  const params = parse(realUrl, true).query as unknown as API.PageParams &
    API.RuleListItem & {
    sorter: any;
    filter: any;
  };

  let dataSource = [...tableListDataSource].slice(
    ((current as number) - 1) * (pageSize as number),
    (current as number) * (pageSize as number),
  );
  if (params.sorter) {
    const sorter = JSON.parse(params.sorter);
    dataSource = dataSource.sort((prev, next) => {
      let sortNumber = 0;
      Object.keys(sorter).forEach((key) => {
        if (sorter[key] === 'descend') {
          if (prev[key] - next[key] > 0) {
            sortNumber += -1;
          } else {
            sortNumber += 1;
          }
          return;
        }
        if (prev[key] - next[key] > 0) {
          sortNumber += 1;
        } else {
          sortNumber += -1;
        }
      });
      return sortNumber;
    });
  }
  if (params.filter) {
    const filter = JSON.parse(params.filter as any) as {
      [key: string]: string[];
    };
    if (Object.keys(filter).length > 0) {
      dataSource = dataSource.filter((item) => {
        return Object.keys(filter).some((key) => {
          if (!filter[key]) {
            return true;
          }
          if (filter[key].includes(`${item[key]}`)) {
            return true;
          }
          return false;
        });
      });
    }
  }

  if (params.name) {
    dataSource = dataSource.filter((data) => data?.name?.includes(params.name || ''));
  }
  const result = {
    data: dataSource,
    total: tableListDataSource.length,
    success: true,
    pageSize,
    current: parseInt(`${params.current}`, 10) || 1,
  };

  return res.json(result);
}

// mock 方法暴露
export default {
  'GET /api/rule': getRule,
};

样例 2: 登录接口功能

import {Request, Response} from 'express';

const waitTime = (time: number = 100) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(true);
    }, time);
  });
};

export default {
  //登录
  'POST /user/login': async (req: Request, res: Response) => {
    const {
      userAccount,
      userPassword
    } = req.body;
    await waitTime(200);
    if (userPassword === 'ant.design' && userAccount == 'user') {
      res.send({
        code: 200,
        data: {
          "createTime": "2022-01-01",
          "id": 1,
          "isDelete": 0,
          "updateTime": "2022-01-02",
          "userAccount": "user",
          "userPassword": "",
          "userRole": "user"
        },
        description: '',
        message: 'ok',
      })
      return
    }
    if (userPassword === 'ant.design' && userAccount == 'admin') {
      res.send({
        code: 200,
        data: {
          "createTime": "2022-01-01",
          "id": 2,
          "isDelete": 0,
          "updateTime": "2022-01-02",
          "userAccount": "admin",
          "userPassword": "",
          "userRole": "admin"
        },
        description: '',
        message: 'ok',
      })
      return
    }
    res.send({
      code: 40000,
      data: {},
      description: '用户不存在或密码错误',
      message: '请求参数错误',
    })
  },

  //获取已登录用户信息
  'GET /user/get/login': (req: Request, res: Response) => {
    res.send({
      code: 200,
      data: {
        "createTime": "2022-01-01",
        "id": 1,
        "isDelete": 0,
        "updateTime": "2022-01-02",
        "userAccount": "testUser",
        "userPassword": "",
        "userRole": "admin"
      },
      description: '',
      message: 'ok',
    })
    return
  },
}
用户态存储 → 页面访问逻辑控制 解决方案
解决方案原理

Ant Design Pro 内置了一套通过 useModel 控制页面初始状态来实现用户态记录的解决方案

通过用户态记录,可以实现对于页面访问逻辑的控制

解决方案配置

接口方法配置

/** getLoginUser GET /user/get/login */
export async function getLoginUserUsingGET(options?: { [key: string]: any }) {
  return request<API.BaseResponseUserVO>(local + '/user/get/login', {
    //TODO 携带 cookie
    credentials:'include',
    method: 'GET',
    ...(options || {}),
  });
}

/** userLogin POST /user/login */
export async function userLoginUsingPOST(
  body: API.UserLoginRequest,
  options?: { [key: string]: any },
) {
  return request<API.BaseResponseUser>(local + '/user/login', {
    //TODO 携带 cookie
    credentials:'include',
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    data: body,
    ...(options || {}),
  });
}

后端拦截器配置

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        // 覆盖所有请求
        registry.addMapping("/**")
                // 允许发送 Cookie
                .allowCredentials(true)
                // 放行哪些域名(必须用 patterns,否则 * 会和 allowCredentials 冲突)
                .allowedOriginPatterns("*")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .exposedHeaders("*");
    }
}

放行页面配置

//import 省略

//放行页面配置(无需登录也能跳转到的页面)
const loginPath = '/user/login';
const register = '/user/register'
const forget = '/user/forget'
const paths = [loginPath, register, forget]

export async function getInitialState(): Promise<{
  //页面配置
  settings?: Partial<LayoutSettings>;
  //当前用户信息
  currentUser?: API.CurrentUser;
  loading?: boolean;
  //获取用户信息
  fetchUserInfo?: () => Promise<API.CurrentUser | undefined>;
}> {
  //获取用户信息
  const fetchUserInfo = async () => {
    console.log("app:fetchUserInfo")
    try {
      // 更改为后端获取用户信息的方法
      // 为了复用 Ant Design Pro CurrentUser 的机制,此处要进行数据类型转换
      // 将后台获取到的信息包装成 API.CurrentUser 类型
      const msg = await getLoginUserUsingGET();
      //后端正常响应
      if (msg.code === 200) {
        const res: API.CurrentUser = {
          name: msg.data?.userAccount,
          userid: msg.data?.id?.toString(),
          access: msg.data?.userRole,
        };
        //判断通过后端获取到的用户态是否为空,若为空,则返回 undefined
        if (res.name === undefined
          || res.userid === undefined
          || res.access === undefined) {
          return undefined
        }
        //若不为空,则返回用户态信息
        return res;
      } else {
        //后端响应异常,直接返回 undefined
        return undefined;
      }
    } catch (error) {
      history.push(loginPath);
    }
    return undefined;
  };

  // 如果不是直接放行的页面,先尝试获取用户信息
  if (!paths.includes(history.location.pathname)) {
    //获取用户信息
    const currentUser = await fetchUserInfo();
    return {
      fetchUserInfo,
      currentUser,
      settings: defaultSettings,
    };
  }
  //如果是放行的页面,无需获取用户信息
  return {
    fetchUserInfo,
    settings: defaultSettings,
  };
}
解决方案使用

用户态存取

//函数组件(不带参数):登录
const Login: React.FC = () => {
  //通过 useModel 处理初始状态
  const {initialState, setInitialState} = useModel('@@initialState');

  //获取用户信息
  //1.尝试从初始状态中获取
  //2.初始状态中不存在用户信息,则尝试从接口中获取,并将信息保存到初始状态中
  const fetchUserInfo = async () => {
    //尝试从初始状态中获取用户信息
    const userInfo = initialState?.currentUser;
    console.log("login:fetchUserInfo")
    console.log(userInfo)
    //初始状态中的用户信息不存在,则通过后端接口获取信息
    if (!userInfo) {
      console.log("login:getLoginUserUsingGET")
      const msg = await getLoginUserUsingGET();
      console.log(msg)
      if (msg.code === 200) {
        const res: API.CurrentUser = {
          name: msg.data?.userAccount,
          userid: msg.data?.id?.toString(),
          access: msg.data?.userRole
        }
        console.log(res)
        //设置初始状态,保存获取的用户信息
        await setInitialState((s) => ({
          ...s,
          currentUser: res,
        }));
        //返回用户信息
        return userInfo;
      }
    }
    return userInfo;
  };

  //登录函数,对接自动生成的接口函数
  const handleSubmit = async (values: API.loginUserParams) => {
    try {
      console.log("login:userLoginUsingPOST")
      const res = await userLoginUsingPOST({
        userAccount: values.username,
        userPassword: values.password
      });
      console.log(res)
      //根据返回值判断是否登录成功
      if (res.code === 200) {
        //登录成功,则提示成功信息
        message.success("登录成功");
        //获取已登录的用户信息
        await fetchUserInfo();
        //页面跳转
        /** 此方法会跳转到 redirect 参数所在的位置 */
        if (!history) return;
        const {query} = history.location;
        const {redirect} = query as {
          redirect: string;
        };
        history.push(redirect || '/');
        return;
      } else {
        //登录失败,
        message.error(res.message + " : " + res.description)
      }
    } catch (error) {
      message.error("内部错误,请联系管理员!");
    }
  }

  //页面元素描述,等效于 React 类组件中的 render()
  return (
    //页面代码省略
  )
}

项目源码

https://github.com/Ba11ooner/stateful-backend-frontend