项目扩展 鱼皮 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客户端库,它封装了XMLHttpRequest和fetch等底层API,提供了更简单、更强大的接口来发送HTTP请求和处理响应。

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

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

        知识补充:async await 机制使用

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

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

        使用 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