Просмотр исходного кода

feat: 💪 add login page (#5088)

* add login in pro

* update menu down

* add isReady

* fix lint

* fix menu style
陈帅 6 лет назад
Родитель
Сommit
ad5db1c268

+ 35 - 18
config/config.ts

@@ -1,18 +1,13 @@
 import { IConfig, IPlugin } from 'umi-types';
+import defaultSettings from './defaultSettings'; // https://umijs.org/config/
 
-import defaultSettings from './defaultSettings';
-// https://umijs.org/config/
 import slash from 'slash2';
 import webpackPlugin from './plugin.config';
-
-const { pwa, primaryColor } = defaultSettings;
-
-// preview.pro.ant.design only do not use in your production ;
+const { pwa, primaryColor } = defaultSettings; // preview.pro.ant.design only do not use in your production ;
 // preview.pro.ant.design 专用环境变量,请不要在你的项目中使用它。
-const { ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION } = process.env;
 
+const { ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION } = process.env;
 const isAntDesignProPreview = ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION === 'site';
-
 const plugins: IPlugin[] = [
   [
     'umi-plugin-react',
@@ -41,8 +36,7 @@ const plugins: IPlugin[] = [
               importWorkboxFrom: 'local',
             },
           }
-        : false,
-      // default close dll, because issue https://github.com/ant-design/ant-design-pro/issues/4665
+        : false, // default close dll, because issue https://github.com/ant-design/ant-design-pro/issues/4665
       // dll features https://webpack.js.org/plugins/dll-plugin/
       // dll: {
       //   include: ['dva', 'dva/router', 'dva/saga', 'dva/fetch'],
@@ -59,9 +53,8 @@ const plugins: IPlugin[] = [
       autoAddMenu: true,
     },
   ],
-];
+]; // 针对 preview.pro.ant.design 的 GA 统计代码
 
-// 针对 preview.pro.ant.design 的 GA 统计代码
 if (isAntDesignProPreview) {
   plugins.push([
     'umi-plugin-ga',
@@ -92,22 +85,46 @@ export default {
   // umi routes: https://umijs.org/zh/guide/router.html
   routes: [
     {
+      path: '/user',
+      component: '../layouts/UserLayout',
+      routes: [
+        {
+          name: 'login',
+          path: '/user/login',
+          component: './user/login',
+        },
+      ],
+    },
+    {
       path: '/',
-      component: '../layouts/BasicLayout',
-      Routes: ['src/pages/Authorized'],
-      authority: ['admin', 'user'],
+      component: '../layouts/SecurityLayout',
       routes: [
         {
           path: '/',
-          name: 'welcome',
-          icon: 'smile',
-          component: './Welcome',
+          component: '../layouts/BasicLayout',
+          authority: ['admin', 'user'],
+          routes: [
+            {
+              path: '/',
+              redirect: '/welcome',
+            },
+            {
+              path: '/welcome',
+              name: 'welcome',
+              icon: 'smile',
+              component: './Welcome',
+            },
+            {
+              component: './404',
+            },
+          ],
         },
         {
           component: './404',
         },
       ],
     },
+
     {
       component: './404',
     },

+ 6 - 0
mock/user.ts

@@ -1,4 +1,8 @@
 import { Request, Response } from 'express';
+
+function getFakeCaptcha(req: Request, res: Response) {
+  return res.json('captcha-xxx');
+}
 // 代码中会兼容本地 service mock 以及部署站点的静态数据
 export default {
   // 支持值为 Object 和 Array
@@ -136,4 +140,6 @@ export default {
       path: '/base/category/list',
     });
   },
+
+  'GET  /api/login/captcha': getFakeCaptcha,
 };

+ 16 - 18
src/components/GlobalHeader/AvatarDropdown.tsx

@@ -33,26 +33,24 @@ class AvatarDropdown extends React.Component<GlobalHeaderRightProps> {
   };
 
   render(): React.ReactNode {
-    const { currentUser = {}, menu } = this.props;
-    if (!menu) {
-      return (
-        <span className={`${styles.action} ${styles.account}`}>
-          <Avatar size="small" className={styles.avatar} src={currentUser.avatar} alt="avatar" />
-          <span className={styles.name}>{currentUser.name}</span>
-        </span>
-      );
-    }
+    const { currentUser = { avatar: '', name: '' }, menu } = this.props;
+
     const menuHeaderDropdown = (
       <Menu className={styles.menu} selectedKeys={[]} onClick={this.onMenuClick}>
-        <Menu.Item key="center">
-          <Icon type="user" />
-          <FormattedMessage id="menu.account.center" defaultMessage="account center" />
-        </Menu.Item>
-        <Menu.Item key="settings">
-          <Icon type="setting" />
-          <FormattedMessage id="menu.account.settings" defaultMessage="account settings" />
-        </Menu.Item>
-        <Menu.Divider />
+        {menu && (
+          <Menu.Item key="center">
+            <Icon type="user" />
+            <FormattedMessage id="menu.account.center" defaultMessage="account center" />
+          </Menu.Item>
+        )}
+        {menu && (
+          <Menu.Item key="settings">
+            <Icon type="setting" />
+            <FormattedMessage id="menu.account.settings" defaultMessage="account settings" />
+          </Menu.Item>
+        )}
+        {menu && <Menu.Divider />}
+
         <Menu.Item key="logout">
           <Icon type="logout" />
           <FormattedMessage id="menu.account.logout" defaultMessage="logout" />

+ 10 - 7
src/layouts/BasicLayout.tsx

@@ -11,12 +11,13 @@ import ProLayout, {
 } from '@ant-design/pro-layout';
 import React, { useEffect } from 'react';
 import Link from 'umi/link';
+import { Dispatch } from 'redux';
 import { connect } from 'dva';
 import { formatMessage } from 'umi-plugin-react/locale';
 
 import Authorized from '@/utils/Authorized';
 import RightContent from '@/components/GlobalHeader/RightContent';
-import { ConnectState, Dispatch } from '@/models/connect';
+import { ConnectState } from '@/models/connect';
 import { isAntDesignPro } from '@/utils/utils';
 import logo from '../assets/logo.svg';
 
@@ -90,12 +91,14 @@ const BasicLayout: React.FC<BasicLayoutProps> = props => {
   /**
    * init variables
    */
-  const handleMenuCollapse = (payload: boolean): void =>
-    dispatch &&
-    dispatch({
-      type: 'global/changeLayoutCollapsed',
-      payload,
-    });
+  const handleMenuCollapse = (payload: boolean): void => {
+    if (dispatch) {
+      dispatch({
+        type: 'global/changeLayoutCollapsed',
+        payload,
+      });
+    }
+  };
 
   return (
     <ProLayout

+ 50 - 0
src/layouts/SecurityLayout.tsx

@@ -0,0 +1,50 @@
+import React from 'react';
+import { connect } from 'dva';
+import { Redirect } from 'umi';
+import { ConnectState, ConnectProps } from '@/models/connect';
+import { CurrentUser } from '@/models/user';
+import PageLoading from '@/components/PageLoading';
+
+interface SecurityLayoutProps extends ConnectProps {
+  loading: boolean;
+  currentUser: CurrentUser;
+}
+
+interface SecurityLayoutState {
+  isReady: boolean;
+}
+
+class SecurityLayout extends React.Component<SecurityLayoutProps, SecurityLayoutState> {
+  state: SecurityLayoutState = {
+    isReady: false,
+  };
+
+  componentDidMount() {
+    this.setState({
+      isReady: true,
+    });
+    const { dispatch } = this.props;
+    if (dispatch) {
+      dispatch({
+        type: 'user/fetchCurrent',
+      });
+    }
+  }
+
+  render() {
+    const { isReady } = this.state;
+    const { children, loading, currentUser } = this.props;
+    if ((!currentUser.userid && loading) || !isReady) {
+      return <PageLoading />;
+    }
+    if (!currentUser.userid) {
+      return <Redirect to="/user/login"></Redirect>;
+    }
+    return children;
+  }
+}
+
+export default connect(({ user, loading }: ConnectState) => ({
+  currentUser: user.currentUser,
+  loading: loading.models.user,
+}))(SecurityLayout);

+ 5 - 19
src/models/connect.d.ts

@@ -1,10 +1,10 @@
-import { AnyAction } from 'redux';
-import { EffectsCommandMap } from 'dva';
+import { AnyAction, Dispatch } from 'redux';
 import { MenuDataItem } from '@ant-design/pro-layout';
 import { RouterTypes } from 'umi';
 import { GlobalModelState } from './global';
 import { DefaultSettings as SettingModelState } from '../../config/defaultSettings';
 import { UserModelState } from './user';
+import { LoginModelType } from './login';
 
 export { GlobalModelState, SettingModelState, UserModelState };
 
@@ -16,6 +16,7 @@ export interface Loading {
     menu?: boolean;
     setting?: boolean;
     user?: boolean;
+    login?: boolean;
   };
 }
 
@@ -24,24 +25,9 @@ export interface ConnectState {
   loading: Loading;
   settings: SettingModelState;
   user: UserModelState;
+  login: LoginModelType;
 }
 
-export type Effect = (
-  action: AnyAction,
-  effects: EffectsCommandMap & { select: <T>(func: (state: ConnectState) => T) => T },
-) => void;
-
-/**
- * @type P: Type of payload
- * @type C: Type of callback
- */
-export type Dispatch = <P = any, C = (payload: P) => void>(action: {
-  type: string;
-  payload?: P;
-  callback?: C;
-  [key: string]: any;
-}) => any;
-
 export interface Route extends MenuDataItem {
   routes?: Route[];
 }
@@ -50,5 +36,5 @@ export interface Route extends MenuDataItem {
  * @type T: Params matched in dynamic routing
  */
 export interface ConnectProps<T = {}> extends Partial<RouterTypes<Route, T>> {
-  dispatch?: Dispatch;
+  dispatch?: Dispatch<AnyAction>;
 }

+ 6 - 6
src/models/global.ts

@@ -1,9 +1,9 @@
 import { Reducer } from 'redux';
-import { Subscription } from 'dva';
+import { Subscription, Effect } from 'dva';
 
-import { Effect } from './connect.d';
 import { NoticeIconData } from '@/components/NoticeIcon';
 import { queryNotices } from '@/services/user';
+import { ConnectState } from './connect.d';
 
 export interface NoticeItem extends NoticeIconData {
   id: string;
@@ -48,7 +48,7 @@ const GlobalModel: GlobalModelType = {
         payload: data,
       });
       const unreadCount: number = yield select(
-        state => state.global.notices.filter(item => !item.read).length,
+        (state: ConnectState) => state.global.notices.filter(item => !item.read).length,
       );
       yield put({
         type: 'user/changeNotifyCount',
@@ -63,9 +63,9 @@ const GlobalModel: GlobalModelType = {
         type: 'saveClearedNotices',
         payload,
       });
-      const count: number = yield select(state => state.global.notices.length);
+      const count: number = yield select((state: ConnectState) => state.global.notices.length);
       const unreadCount: number = yield select(
-        state => state.global.notices.filter(item => !item.read).length,
+        (state: ConnectState) => state.global.notices.filter(item => !item.read).length,
       );
       yield put({
         type: 'user/changeNotifyCount',
@@ -76,7 +76,7 @@ const GlobalModel: GlobalModelType = {
       });
     },
     *changeNoticeReadState({ payload }, { put, select }) {
-      const notices: NoticeItem[] = yield select(state =>
+      const notices: NoticeItem[] = yield select((state: ConnectState) =>
         state.global.notices.map(item => {
           const notice = { ...item };
           if (notice.id === payload) {

+ 48 - 17
src/models/login.ts

@@ -1,32 +1,32 @@
-import { AnyAction, Reducer } from 'redux';
-import { parse, stringify } from 'qs';
-
-import { EffectsCommandMap } from 'dva';
+import { Reducer } from 'redux';
 import { routerRedux } from 'dva/router';
+import { Effect } from 'dva';
+import { stringify } from 'querystring';
 
-export function getPageQuery(): {
-  [key: string]: string;
-} {
-  return parse(window.location.href.split('?')[1]);
-}
+import { fakeAccountLogin, getFakeCaptcha } from '@/services/login';
+import { setAuthority } from '@/utils/authority';
+import { getPageQuery } from '@/utils/utils';
 
-export type Effect = (
-  action: AnyAction,
-  effects: EffectsCommandMap & { select: <T>(func: (state: {}) => T) => T },
-) => void;
+export interface StateType {
+  status?: 'ok' | 'error';
+  type?: string;
+  currentAuthority?: 'user' | 'guest' | 'admin';
+}
 
-export interface ModelType {
+export interface LoginModelType {
   namespace: string;
-  state: {};
+  state: StateType;
   effects: {
+    login: Effect;
+    getCaptcha: Effect;
     logout: Effect;
   };
   reducers: {
-    changeLoginStatus: Reducer<{}>;
+    changeLoginStatus: Reducer<StateType>;
   };
 }
 
-const Model: ModelType = {
+const Model: LoginModelType = {
   namespace: 'login',
 
   state: {
@@ -34,6 +34,36 @@ const Model: ModelType = {
   },
 
   effects: {
+    *login({ payload }, { call, put }) {
+      const response = yield call(fakeAccountLogin, payload);
+      yield put({
+        type: 'changeLoginStatus',
+        payload: response,
+      });
+      // Login successfully
+      if (response.status === 'ok') {
+        const urlParams = new URL(window.location.href);
+        const params = getPageQuery();
+        let { redirect } = params as { redirect: string };
+        if (redirect) {
+          const redirectUrlParams = new URL(redirect);
+          if (redirectUrlParams.origin === urlParams.origin) {
+            redirect = redirect.substr(urlParams.origin.length);
+            if (redirect.match(/^\/.*#/)) {
+              redirect = redirect.substr(redirect.indexOf('#') + 1);
+            }
+          } else {
+            window.location.href = redirect;
+            return;
+          }
+        }
+        yield put(routerRedux.replace(redirect || '/'));
+      }
+    },
+
+    *getCaptcha({ payload }, { call }) {
+      yield call(getFakeCaptcha, payload);
+    },
     *logout(_, { put }) {
       const { redirect } = getPageQuery();
       // redirect
@@ -52,6 +82,7 @@ const Model: ModelType = {
 
   reducers: {
     changeLoginStatus(state, { payload }) {
+      setAuthority(payload.currentAuthority);
       return {
         ...state,
         status: payload.status,

+ 1 - 0
src/models/user.ts

@@ -13,6 +13,7 @@ export interface CurrentUser {
     key: string;
     label: string;
   }[];
+  userid?: string;
   unreadCount?: number;
 }
 

+ 13 - 0
src/pages/user/login/components/Login/LoginContext.tsx

@@ -0,0 +1,13 @@
+import { createContext } from 'react';
+
+export interface LoginContextProps {
+  tabUtil?: {
+    addTab: (id: string) => void;
+    removeTab: (id: string) => void;
+  };
+  updateActive?: (activeItem: { [key: string]: string } | string) => void;
+}
+
+const LoginContext: React.Context<LoginContextProps> = createContext({});
+
+export default LoginContext;

+ 196 - 0
src/pages/user/login/components/Login/LoginItem.tsx

@@ -0,0 +1,196 @@
+import { Button, Col, Form, Input, Row } from 'antd';
+import React, { Component } from 'react';
+import { FormComponentProps } from 'antd/es/form';
+import { GetFieldDecoratorOptions } from 'antd/es/form/Form';
+
+import omit from 'omit.js';
+import ItemMap from './map';
+import LoginContext, { LoginContextProps } from './LoginContext';
+import styles from './index.less';
+
+type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
+
+export type WrappedLoginItemProps = Omit<LoginItemProps, 'form' | 'type' | 'updateActive'>;
+export type LoginItemKeyType = keyof typeof ItemMap;
+export interface LoginItemType {
+  UserName: React.FC<WrappedLoginItemProps>;
+  Password: React.FC<WrappedLoginItemProps>;
+  Mobile: React.FC<WrappedLoginItemProps>;
+  Captcha: React.FC<WrappedLoginItemProps>;
+}
+
+export interface LoginItemProps extends GetFieldDecoratorOptions {
+  name?: string;
+  style?: React.CSSProperties;
+  onGetCaptcha?: (event?: MouseEvent) => void | Promise<boolean> | false;
+  placeholder?: string;
+  buttonText?: React.ReactNode;
+  onPressEnter?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
+  countDown?: number;
+  getCaptchaButtonText?: string;
+  getCaptchaSecondText?: string;
+  updateActive?: LoginContextProps['updateActive'];
+  type?: string;
+  defaultValue?: string;
+  form?: FormComponentProps['form'];
+  customProps?: { [key: string]: unknown };
+  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
+  tabUtil?: LoginContextProps['tabUtil'];
+}
+
+interface LoginItemState {
+  count: number;
+}
+
+const FormItem = Form.Item;
+
+class WrapFormItem extends Component<LoginItemProps, LoginItemState> {
+  static defaultProps = {
+    getCaptchaButtonText: 'captcha',
+    getCaptchaSecondText: 'second',
+  };
+
+  interval: number | undefined = undefined;
+
+  constructor(props: LoginItemProps) {
+    super(props);
+    this.state = {
+      count: 0,
+    };
+  }
+
+  componentDidMount() {
+    const { updateActive, name = '' } = this.props;
+    if (updateActive) {
+      updateActive(name);
+    }
+  }
+
+  componentWillUnmount() {
+    clearInterval(this.interval);
+  }
+
+  onGetCaptcha = () => {
+    const { onGetCaptcha } = this.props;
+    const result = onGetCaptcha ? onGetCaptcha() : null;
+    if (result === false) {
+      return;
+    }
+    if (result instanceof Promise) {
+      result.then(this.runGetCaptchaCountDown);
+    } else {
+      this.runGetCaptchaCountDown();
+    }
+  };
+
+  getFormItemOptions = ({ onChange, defaultValue, customProps = {}, rules }: LoginItemProps) => {
+    const options: {
+      rules?: LoginItemProps['rules'];
+      onChange?: LoginItemProps['onChange'];
+      initialValue?: LoginItemProps['defaultValue'];
+    } = {
+      rules: rules || (customProps.rules as LoginItemProps['rules']),
+    };
+    if (onChange) {
+      options.onChange = onChange;
+    }
+    if (defaultValue) {
+      options.initialValue = defaultValue;
+    }
+    return options;
+  };
+
+  runGetCaptchaCountDown = () => {
+    const { countDown } = this.props;
+    let count = countDown || 59;
+    this.setState({ count });
+    this.interval = window.setInterval(() => {
+      count -= 1;
+      this.setState({ count });
+      if (count === 0) {
+        clearInterval(this.interval);
+      }
+    }, 1000);
+  };
+
+  render() {
+    const { count } = this.state;
+
+    // 这么写是为了防止restProps中 带入 onChange, defaultValue, rules props tabUtil
+    const {
+      onChange,
+      customProps,
+      defaultValue,
+      rules,
+      name,
+      getCaptchaButtonText,
+      getCaptchaSecondText,
+      updateActive,
+      type,
+      form,
+      tabUtil,
+      ...restProps
+    } = this.props;
+    if (!name) {
+      return null;
+    }
+    if (!form) {
+      return null;
+    }
+    const { getFieldDecorator } = form;
+    // get getFieldDecorator props
+    const options = this.getFormItemOptions(this.props);
+    const otherProps = restProps || {};
+
+    if (type === 'Captcha') {
+      const inputProps = omit(otherProps, ['onGetCaptcha', 'countDown']);
+
+      return (
+        <FormItem>
+          <Row gutter={8}>
+            <Col span={16}>
+              {getFieldDecorator(name, options)(<Input {...customProps} {...inputProps} />)}
+            </Col>
+            <Col span={8}>
+              <Button
+                disabled={!!count}
+                className={styles.getCaptcha}
+                size="large"
+                onClick={this.onGetCaptcha}
+              >
+                {count ? `${count} ${getCaptchaSecondText}` : getCaptchaButtonText}
+              </Button>
+            </Col>
+          </Row>
+        </FormItem>
+      );
+    }
+    return (
+      <FormItem>
+        {getFieldDecorator(name, options)(<Input {...customProps} {...otherProps} />)}
+      </FormItem>
+    );
+  }
+}
+
+const LoginItem: Partial<LoginItemType> = {};
+
+Object.keys(ItemMap).forEach(key => {
+  const item = ItemMap[key];
+  LoginItem[key] = (props: LoginItemProps) => (
+    <LoginContext.Consumer>
+      {context => (
+        <WrapFormItem
+          customProps={item.props}
+          rules={item.rules}
+          {...props}
+          type={key}
+          {...context}
+          updateActive={context.updateActive}
+        />
+      )}
+    </LoginContext.Consumer>
+  );
+});
+
+export default LoginItem as LoginItemType;

+ 23 - 0
src/pages/user/login/components/Login/LoginSubmit.tsx

@@ -0,0 +1,23 @@
+import { Button, Form } from 'antd';
+
+import { ButtonProps } from 'antd/es/button';
+import React from 'react';
+import classNames from 'classnames';
+import styles from './index.less';
+
+const FormItem = Form.Item;
+
+interface LoginSubmitProps extends ButtonProps {
+  className?: string;
+}
+
+const LoginSubmit: React.FC<LoginSubmitProps> = ({ className, ...rest }) => {
+  const clsString = classNames(styles.submit, className);
+  return (
+    <FormItem>
+      <Button size="large" className={clsString} type="primary" htmlType="submit" {...rest} />
+    </FormItem>
+  );
+};
+
+export default LoginSubmit;

+ 53 - 0
src/pages/user/login/components/Login/LoginTab.tsx

@@ -0,0 +1,53 @@
+import React, { Component } from 'react';
+
+import { TabPaneProps } from 'antd/es/tabs';
+import { Tabs } from 'antd';
+import LoginContext, { LoginContextProps } from './LoginContext';
+
+const { TabPane } = Tabs;
+
+const generateId = (() => {
+  let i = 0;
+  return (prefix = '') => {
+    i += 1;
+    return `${prefix}${i}`;
+  };
+})();
+
+interface LoginTabProps extends TabPaneProps {
+  tabUtil: LoginContextProps['tabUtil'];
+}
+
+class LoginTab extends Component<LoginTabProps> {
+  uniqueId: string = '';
+
+  constructor(props: LoginTabProps) {
+    super(props);
+    this.uniqueId = generateId('login-tab-');
+  }
+
+  componentDidMount() {
+    const { tabUtil } = this.props;
+    if (tabUtil) {
+      tabUtil.addTab(this.uniqueId);
+    }
+  }
+
+  render() {
+    const { children } = this.props;
+    return <TabPane {...this.props}>{children}</TabPane>;
+  }
+}
+
+const WrapContext: React.FC<TabPaneProps> & {
+  typeName: string;
+} = props => (
+  <LoginContext.Consumer>
+    {value => <LoginTab tabUtil={value.tabUtil} {...props} />}
+  </LoginContext.Consumer>
+);
+
+// 标志位 用来判断是不是自定义组件
+WrapContext.typeName = 'LoginTab';
+
+export default WrapContext;

+ 53 - 0
src/pages/user/login/components/Login/index.less

@@ -0,0 +1,53 @@
+@import '~antd/es/style/themes/default.less';
+
+.login {
+  :global {
+    .ant-tabs .ant-tabs-bar {
+      margin-bottom: 24px;
+      text-align: center;
+      border-bottom: 0;
+    }
+
+    .ant-form-item {
+      margin: 0 2px 24px;
+    }
+  }
+
+  .getCaptcha {
+    display: block;
+    width: 100%;
+  }
+
+  .icon {
+    margin-left: 16px;
+    color: rgba(0, 0, 0, 0.2);
+    font-size: 24px;
+    vertical-align: middle;
+    cursor: pointer;
+    transition: color 0.3s;
+
+    &:hover {
+      color: @primary-color;
+    }
+  }
+
+  .other {
+    margin-top: 24px;
+    line-height: 22px;
+    text-align: left;
+
+    .register {
+      float: right;
+    }
+  }
+
+  .prefixIcon {
+    color: @disabled-color;
+    font-size: @font-size-base;
+  }
+
+  .submit {
+    width: 100%;
+    margin-top: 24px;
+  }
+}

+ 173 - 0
src/pages/user/login/components/Login/index.tsx

@@ -0,0 +1,173 @@
+import { Form, Tabs } from 'antd';
+import React, { Component } from 'react';
+import { FormComponentProps } from 'antd/es/form';
+import classNames from 'classnames';
+import LoginContext, { LoginContextProps } from './LoginContext';
+import LoginItem, { LoginItemProps, LoginItemType } from './LoginItem';
+
+import LoginSubmit from './LoginSubmit';
+import LoginTab from './LoginTab';
+import styles from './index.less';
+import { LoginParamsType } from '@/services/login';
+
+export interface LoginProps {
+  defaultActiveKey?: string;
+  onTabChange?: (key: string) => void;
+  style?: React.CSSProperties;
+  onSubmit?: (error: unknown, values: LoginParamsType) => void;
+  className?: string;
+  form: FormComponentProps['form'];
+  onCreate?: (form?: FormComponentProps['form']) => void;
+  children: React.ReactElement<LoginTab>[];
+}
+
+interface LoginState {
+  tabs?: string[];
+  type?: string;
+  active?: { [key: string]: unknown[] };
+}
+
+class Login extends Component<LoginProps, LoginState> {
+  public static Tab = LoginTab;
+
+  public static Submit = LoginSubmit;
+
+  public static UserName: React.FunctionComponent<LoginItemProps>;
+
+  public static Password: React.FunctionComponent<LoginItemProps>;
+
+  public static Mobile: React.FunctionComponent<LoginItemProps>;
+
+  public static Captcha: React.FunctionComponent<LoginItemProps>;
+
+  static defaultProps = {
+    className: '',
+    defaultActiveKey: '',
+    onTabChange: () => {},
+    onSubmit: () => {},
+  };
+
+  constructor(props: LoginProps) {
+    super(props);
+    this.state = {
+      type: props.defaultActiveKey,
+      tabs: [],
+      active: {},
+    };
+  }
+
+  componentDidMount() {
+    const { form, onCreate } = this.props;
+    if (onCreate) {
+      onCreate(form);
+    }
+  }
+
+  onSwitch = (type: string) => {
+    this.setState(
+      {
+        type,
+      },
+      () => {
+        const { onTabChange } = this.props;
+        if (onTabChange) {
+          onTabChange(type);
+        }
+      },
+    );
+  };
+
+  getContext: () => LoginContextProps = () => {
+    const { form } = this.props;
+    const { tabs = [] } = this.state;
+    return {
+      tabUtil: {
+        addTab: id => {
+          this.setState({
+            tabs: [...tabs, id],
+          });
+        },
+        removeTab: id => {
+          this.setState({
+            tabs: tabs.filter(currentId => currentId !== id),
+          });
+        },
+      },
+      form: { ...form },
+      updateActive: activeItem => {
+        const { type = '', active = {} } = this.state;
+        if (active[type]) {
+          active[type].push(activeItem);
+        } else {
+          active[type] = [activeItem];
+        }
+        this.setState({
+          active,
+        });
+      },
+    };
+  };
+
+  handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    const { active = {}, type = '' } = this.state;
+    const { form, onSubmit } = this.props;
+    const activeFields = active[type] || [];
+    if (form) {
+      form.validateFields(activeFields as string[], { force: true }, (err, values) => {
+        if (onSubmit) {
+          onSubmit(err, values);
+        }
+      });
+    }
+  };
+
+  render() {
+    const { className, children } = this.props;
+    const { type, tabs = [] } = this.state;
+    const TabChildren: React.ReactComponentElement<LoginTab>[] = [];
+    const otherChildren: React.ReactElement<unknown>[] = [];
+    React.Children.forEach(
+      children,
+      (child: React.ReactComponentElement<LoginTab> | React.ReactElement<unknown>) => {
+        if (!child) {
+          return;
+        }
+        if (child.type.typeName === 'LoginTab') {
+          TabChildren.push(child as React.ReactComponentElement<LoginTab>);
+        } else {
+          otherChildren.push(child);
+        }
+      },
+    );
+    return (
+      <LoginContext.Provider value={this.getContext()}>
+        <div className={classNames(className, styles.login)}>
+          <Form onSubmit={this.handleSubmit}>
+            {tabs.length ? (
+              <React.Fragment>
+                <Tabs
+                  animated={false}
+                  className={styles.tabs}
+                  activeKey={type}
+                  onChange={this.onSwitch}
+                >
+                  {TabChildren}
+                </Tabs>
+                {otherChildren}
+              </React.Fragment>
+            ) : (
+              children
+            )}
+          </Form>
+        </div>
+      </LoginContext.Provider>
+    );
+  }
+}
+
+(Object.keys(LoginItem) as (keyof LoginItemType)[]).forEach(item => {
+  Login[item] = LoginItem[item];
+});
+
+export default Form.create<LoginProps>()(Login);

+ 65 - 0
src/pages/user/login/components/Login/map.tsx

@@ -0,0 +1,65 @@
+import { Icon } from 'antd';
+import React from 'react';
+import styles from './index.less';
+
+export default {
+  UserName: {
+    props: {
+      size: 'large',
+      id: 'userName',
+      prefix: <Icon type="user" className={styles.prefixIcon} />,
+      placeholder: 'admin',
+    },
+    rules: [
+      {
+        required: true,
+        message: 'Please enter username!',
+      },
+    ],
+  },
+  Password: {
+    props: {
+      size: 'large',
+      prefix: <Icon type="lock" className={styles.prefixIcon} />,
+      type: 'password',
+      id: 'password',
+      placeholder: '888888',
+    },
+    rules: [
+      {
+        required: true,
+        message: 'Please enter password!',
+      },
+    ],
+  },
+  Mobile: {
+    props: {
+      size: 'large',
+      prefix: <Icon type="mobile" className={styles.prefixIcon} />,
+      placeholder: 'mobile number',
+    },
+    rules: [
+      {
+        required: true,
+        message: 'Please enter mobile number!',
+      },
+      {
+        pattern: /^1\d{10}$/,
+        message: 'Wrong mobile number format!',
+      },
+    ],
+  },
+  Captcha: {
+    props: {
+      size: 'large',
+      prefix: <Icon type="mail" className={styles.prefixIcon} />,
+      placeholder: 'captcha',
+    },
+    rules: [
+      {
+        required: true,
+        message: 'Please enter Captcha!',
+      },
+    ],
+  },
+};

+ 205 - 0
src/pages/user/login/index.tsx

@@ -0,0 +1,205 @@
+import { Alert, Checkbox, Icon } from 'antd';
+import { FormattedMessage, formatMessage } from 'umi-plugin-react/locale';
+import React, { Component } from 'react';
+
+import { CheckboxChangeEvent } from 'antd/es/checkbox';
+import { Dispatch, AnyAction } from 'redux';
+import { FormComponentProps } from 'antd/es/form';
+import Link from 'umi/link';
+import { connect } from 'dva';
+import { StateType } from '@/models/login';
+import LoginComponents from './components/Login';
+import styles from './style.less';
+import { LoginParamsType } from '@/services/login';
+import { ConnectState } from '@/models/connect';
+
+const { Tab, UserName, Password, Mobile, Captcha, Submit } = LoginComponents;
+
+interface LoginProps {
+  dispatch: Dispatch<AnyAction>;
+  userLogin: StateType;
+  submitting: boolean;
+}
+interface LoginState {
+  type: string;
+  autoLogin: boolean;
+}
+
+@connect(({ login, loading }: ConnectState) => ({
+  userLogin: login,
+  submitting: loading.effects['login/login'],
+}))
+class Login extends Component<LoginProps, LoginState> {
+  loginForm: FormComponentProps['form'] | undefined | null = undefined;
+
+  state: LoginState = {
+    type: 'account',
+    autoLogin: true,
+  };
+
+  changeAutoLogin = (e: CheckboxChangeEvent) => {
+    this.setState({
+      autoLogin: e.target.checked,
+    });
+  };
+
+  handleSubmit = (err: unknown, values: LoginParamsType) => {
+    const { type } = this.state;
+    if (!err) {
+      const { dispatch } = this.props;
+      dispatch({
+        type: 'login/login',
+        payload: {
+          ...values,
+          type,
+        },
+      });
+    }
+  };
+
+  onTabChange = (type: string) => {
+    this.setState({ type });
+  };
+
+  onGetCaptcha = () =>
+    new Promise<boolean>((resolve, reject) => {
+      if (!this.loginForm) {
+        return;
+      }
+      this.loginForm.validateFields(
+        ['mobile'],
+        {},
+        async (err: unknown, values: LoginParamsType) => {
+          if (err) {
+            reject(err);
+          } else {
+            const { dispatch } = this.props;
+            try {
+              const success = await ((dispatch({
+                type: 'login/getCaptcha',
+                payload: values.mobile,
+              }) as unknown) as Promise<unknown>);
+              resolve(!!success);
+            } catch (error) {
+              reject(error);
+            }
+          }
+        },
+      );
+    });
+
+  renderMessage = (content: string) => (
+    <Alert style={{ marginBottom: 24 }} message={content} type="error" showIcon />
+  );
+
+  render() {
+    const { userLogin, submitting } = this.props;
+    const { status, type: loginType } = userLogin;
+    const { type, autoLogin } = this.state;
+    return (
+      <div className={styles.main}>
+        <LoginComponents
+          defaultActiveKey={type}
+          onTabChange={this.onTabChange}
+          onSubmit={this.handleSubmit}
+          onCreate={(form?: FormComponentProps['form']) => {
+            this.loginForm = form;
+          }}
+        >
+          <Tab key="account" tab={formatMessage({ id: 'user-login.login.tab-login-credentials' })}>
+            {status === 'error' &&
+              loginType === 'account' &&
+              !submitting &&
+              this.renderMessage(
+                formatMessage({ id: 'user-login.login.message-invalid-credentials' }),
+              )}
+            <UserName
+              name="userName"
+              placeholder={`${formatMessage({ id: 'user-login.login.userName' })}: admin or user`}
+              rules={[
+                {
+                  required: true,
+                  message: formatMessage({ id: 'user-login.userName.required' }),
+                },
+              ]}
+            />
+            <Password
+              name="password"
+              placeholder={`${formatMessage({ id: 'user-login.login.password' })}: ant.design`}
+              rules={[
+                {
+                  required: true,
+                  message: formatMessage({ id: 'user-login.password.required' }),
+                },
+              ]}
+              onPressEnter={e => {
+                e.preventDefault();
+                if (this.loginForm) {
+                  this.loginForm.validateFields(this.handleSubmit);
+                }
+              }}
+            />
+          </Tab>
+          <Tab key="mobile" tab={formatMessage({ id: 'user-login.login.tab-login-mobile' })}>
+            {status === 'error' &&
+              loginType === 'mobile' &&
+              !submitting &&
+              this.renderMessage(
+                formatMessage({ id: 'user-login.login.message-invalid-verification-code' }),
+              )}
+            <Mobile
+              name="mobile"
+              placeholder={formatMessage({ id: 'user-login.phone-number.placeholder' })}
+              rules={[
+                {
+                  required: true,
+                  message: formatMessage({ id: 'user-login.phone-number.required' }),
+                },
+                {
+                  pattern: /^1\d{10}$/,
+                  message: formatMessage({ id: 'user-login.phone-number.wrong-format' }),
+                },
+              ]}
+            />
+            <Captcha
+              name="captcha"
+              placeholder={formatMessage({ id: 'user-login.verification-code.placeholder' })}
+              countDown={120}
+              onGetCaptcha={this.onGetCaptcha}
+              getCaptchaButtonText={formatMessage({ id: 'user-login.form.get-captcha' })}
+              getCaptchaSecondText={formatMessage({ id: 'user-login.captcha.second' })}
+              rules={[
+                {
+                  required: true,
+                  message: formatMessage({ id: 'user-login.verification-code.required' }),
+                },
+              ]}
+            />
+          </Tab>
+          <div>
+            <Checkbox checked={autoLogin} onChange={this.changeAutoLogin}>
+              <FormattedMessage id="user-login.login.remember-me" />
+            </Checkbox>
+            <a style={{ float: 'right' }} href="">
+              <FormattedMessage id="user-login.login.forgot-password" />
+            </a>
+          </div>
+          <Submit loading={submitting}>
+            <FormattedMessage id="user-login.login.login" />
+          </Submit>
+          <div className={styles.other}>
+            <FormattedMessage id="user-login.login.sign-in-with" />
+            <Icon type="alipay-circle" className={styles.icon} theme="outlined" />
+            <Icon type="taobao-circle" className={styles.icon} theme="outlined" />
+            <Icon type="weibo-circle" className={styles.icon} theme="outlined" />
+            <Link className={styles.register} to="/user/register">
+              <FormattedMessage id="user-login.login.signup" />
+            </Link>
+          </div>
+        </LoginComponents>
+      </div>
+    );
+  }
+}
+
+export default Login;

+ 78 - 0
src/pages/user/login/locales/en-US.ts

@@ -0,0 +1,78 @@
+export default {
+  'user-login.login.userName': 'userName',
+  'user-login.login.password': 'password',
+  'user-login.login.message-invalid-credentials':
+    'Invalid username or password(admin/ant.design)',
+  'user-login.login.message-invalid-verification-code': 'Invalid verification code',
+  'user-login.login.tab-login-credentials': 'Credentials',
+  'user-login.login.tab-login-mobile': 'Mobile number',
+  'user-login.login.remember-me': 'Remember me',
+  'user-login.login.forgot-password': 'Forgot your password?',
+  'user-login.login.sign-in-with': 'Sign in with',
+  'user-login.login.signup': 'Sign up',
+  'user-login.login.login': 'Login',
+  'user-login.register.register': 'Register',
+  'user-login.register.get-verification-code': 'Get code',
+  'user-login.register.sign-in': 'Already have an account?',
+  'user-login.register-result.msg': 'Account:registered at {email}',
+  'user-login.register-result.activation-email':
+    'The activation email has been sent to your email address and is valid for 24 hours. Please log in to the email in time and click on the link in the email to activate the account.',
+  'user-login.register-result.back-home': 'Back to home',
+  'user-login.register-result.view-mailbox': 'View mailbox',
+  'user-login.email.required': 'Please enter your email!',
+  'user-login.email.wrong-format': 'The email address is in the wrong format!',
+  'user-login.userName.required': 'Please enter your userName!',
+  'user-login.password.required': 'Please enter your password!',
+  'user-login.password.twice': 'The passwords entered twice do not match!',
+  'user-login.strength.msg':
+    "Please enter at least 6 characters and don't use passwords that are easy to guess.",
+  'user-login.strength.strong': 'Strength: strong',
+  'user-login.strength.medium': 'Strength: medium',
+  'user-login.strength.short': 'Strength: too short',
+  'user-login.confirm-password.required': 'Please confirm your password!',
+  'user-login.phone-number.required': 'Please enter your phone number!',
+  'user-login.phone-number.wrong-format': 'Malformed phone number!',
+  'user-login.verification-code.required': 'Please enter the verification code!',
+  'user-login.title.required': 'Please enter a title',
+  'user-login.date.required': 'Please select the start and end date',
+  'user-login.goal.required': 'Please enter a description of the goal',
+  'user-login.standard.required': 'Please enter a metric',
+  'user-login.form.get-captcha': 'Get Captcha',
+  'user-login.captcha.second': 'sec',
+  'user-login.form.optional': ' (optional) ',
+  'user-login.form.submit': 'Submit',
+  'user-login.form.save': 'Save',
+  'user-login.email.placeholder': 'Email',
+  'user-login.password.placeholder': 'Password',
+  'user-login.confirm-password.placeholder': 'Confirm password',
+  'user-login.phone-number.placeholder': 'Phone number',
+  'user-login.verification-code.placeholder': 'Verification code',
+  'user-login.title.label': 'Title',
+  'user-login.title.placeholder': 'Give the target a name',
+  'user-login.date.label': 'Start and end date',
+  'user-login.placeholder.start': 'Start date',
+  'user-login.placeholder.end': 'End date',
+  'user-login.goal.label': 'Goal description',
+  'user-login.goal.placeholder': 'Please enter your work goals',
+  'user-login.standard.label': 'Metrics',
+  'user-login.standard.placeholder': 'Please enter a metric',
+  'user-login.client.label': 'Client',
+  'user-login.label.tooltip': 'Target service object',
+  'user-login.client.placeholder':
+    'Please describe your customer service, internal customers directly @ Name / job number',
+  'user-login.invites.label': 'Inviting critics',
+  'user-login.invites.placeholder':
+    'Please direct @ Name / job number, you can invite up to 5 people',
+  'user-login.weight.label': 'Weight',
+  'user-login.weight.placeholder': 'Please enter weight',
+  'user-login.public.label': 'Target disclosure',
+  'user-login.label.help': 'Customers and invitees are shared by default',
+  'user-login.radio.public': 'Public',
+  'user-login.radio.partially-public': 'Partially public',
+  'user-login.radio.private': 'Private',
+  'user-login.publicUsers.placeholder': 'Open to',
+  'user-login.option.A': 'Colleague A',
+  'user-login.option.B': 'Colleague B',
+  'user-login.option.C': 'Colleague C',
+  'user-login.navBar.lang': 'Languages',
+};

+ 74 - 0
src/pages/user/login/locales/zh-CN.ts

@@ -0,0 +1,74 @@
+export default {
+  'user-login.login.userName': '用户名',
+  'user-login.login.password': '密码',
+  'user-login.login.message-invalid-credentials': '账户或密码错误(admin/ant.design)',
+  'user-login.login.message-invalid-verification-code': '验证码错误',
+  'user-login.login.tab-login-credentials': '账户密码登录',
+  'user-login.login.tab-login-mobile': '手机号登录',
+  'user-login.login.remember-me': '自动登录',
+  'user-login.login.forgot-password': '忘记密码',
+  'user-login.login.sign-in-with': '其他登录方式',
+  'user-login.login.signup': '注册账户',
+  'user-login.login.login': '登录',
+  'user-login.register.register': '注册',
+  'user-login.register.get-verification-code': '获取验证码',
+  'user-login.register.sign-in': '使用已有账户登录',
+  'user-login.register-result.msg': '你的账户:{email} 注册成功',
+  'user-login.register-result.activation-email':
+    '激活邮件已发送到你的邮箱中,邮件有效期为24小时。请及时登录邮箱,点击邮件中的链接激活帐户。',
+  'user-login.register-result.back-home': '返回首页',
+  'user-login.register-result.view-mailbox': '查看邮箱',
+  'user-login.email.required': '请输入邮箱地址!',
+  'user-login.email.wrong-format': '邮箱地址格式错误!',
+  'user-login.userName.required': '请输入用户名!',
+  'user-login.password.required': '请输入密码!',
+  'user-login.password.twice': '两次输入的密码不匹配!',
+  'user-login.strength.msg': '请至少输入 6 个字符。请不要使用容易被猜到的密码。',
+  'user-login.strength.strong': '强度:强',
+  'user-login.strength.medium': '强度:中',
+  'user-login.strength.short': '强度:太短',
+  'user-login.confirm-password.required': '请确认密码!',
+  'user-login.phone-number.required': '请输入手机号!',
+  'user-login.phone-number.wrong-format': '手机号格式错误!',
+  'user-login.verification-code.required': '请输入验证码!',
+  'user-login.title.required': '请输入标题',
+  'user-login.date.required': '请选择起止日期',
+  'user-login.goal.required': '请输入目标描述',
+  'user-login.standard.required': '请输入衡量标准',
+  'user-login.form.get-captcha': '获取验证码',
+  'user-login.captcha.second': '秒',
+  'user-login.form.optional': '(选填)',
+  'user-login.form.submit': '提交',
+  'user-login.form.save': '保存',
+  'user-login.email.placeholder': '邮箱',
+  'user-login.password.placeholder': '至少6位密码,区分大小写',
+  'user-login.confirm-password.placeholder': '确认密码',
+  'user-login.phone-number.placeholder': '手机号',
+  'user-login.verification-code.placeholder': '验证码',
+  'user-login.title.label': '标题',
+  'user-login.title.placeholder': '给目标起个名字',
+  'user-login.date.label': '起止日期',
+  'user-login.placeholder.start': '开始日期',
+  'user-login.placeholder.end': '结束日期',
+  'user-login.goal.label': '目标描述',
+  'user-login.goal.placeholder': '请输入你的阶段性工作目标',
+  'user-login.standard.label': '衡量标准',
+  'user-login.standard.placeholder': '请输入衡量标准',
+  'user-login.client.label': '客户',
+  'user-login.label.tooltip': '目标的服务对象',
+  'user-login.client.placeholder': '请描述你服务的客户,内部客户直接 @姓名/工号',
+  'user-login.invites.label': '邀评人',
+  'user-login.invites.placeholder': '请直接 @姓名/工号,最多可邀请 5 人',
+  'user-login.weight.label': '权重',
+  'user-login.weight.placeholder': '请输入',
+  'user-login.public.label': '目标公开',
+  'user-login.label.help': '客户、邀评人默认被分享',
+  'user-login.radio.public': '公开',
+  'user-login.radio.partially-public': '部分公开',
+  'user-login.radio.private': '不公开',
+  'user-login.publicUsers.placeholder': '公开给',
+  'user-login.option.A': '同事甲',
+  'user-login.option.B': '同事乙',
+  'user-login.option.C': '同事丙',
+  'user-login.navBar.lang': '语言',
+};

+ 74 - 0
src/pages/user/login/locales/zh-TW.ts

@@ -0,0 +1,74 @@
+export default {
+  'user-login.login.userName': '賬戶',
+  'user-login.login.password': '密碼',
+  'user-login.login.message-invalid-credentials': '賬戶或密碼錯誤(admin/ant.design)',
+  'user-login.login.message-invalid-verification-code': '驗證碼錯誤',
+  'user-login.login.tab-login-credentials': '賬戶密碼登錄',
+  'user-login.login.tab-login-mobile': '手機號登錄',
+  'user-login.login.remember-me': '自動登錄',
+  'user-login.login.forgot-password': '忘記密碼',
+  'user-login.login.sign-in-with': '其他登錄方式',
+  'user-login.login.signup': '註冊賬戶',
+  'user-login.login.login': '登錄',
+  'user-login.register.register': '註冊',
+  'user-login.register.get-verification-code': '獲取驗證碼',
+  'user-login.register.sign-in': '使用已有賬戶登錄',
+  'user-login.register-result.msg': '妳的賬戶:{email} 註冊成功',
+  'user-login.register-result.activation-email':
+    '激活郵件已發送到妳的郵箱中,郵件有效期為24小時。請及時登錄郵箱,點擊郵件中的鏈接激活帳戶。',
+  'user-login.register-result.back-home': '返回首頁',
+  'user-login.register-result.view-mailbox': '查看郵箱',
+  'user-login.email.required': '請輸入郵箱地址!',
+  'user-login.email.wrong-format': '郵箱地址格式錯誤!',
+  'user-login.userName.required': '請輸入賬戶!',
+  'user-login.password.required': '請輸入密碼!',
+  'user-login.password.twice': '兩次輸入的密碼不匹配!',
+  'user-login.strength.msg': '請至少輸入 6 個字符。請不要使用容易被猜到的密碼。',
+  'user-login.strength.strong': '強度:強',
+  'user-login.strength.medium': '強度:中',
+  'user-login.strength.short': '強度:太短',
+  'user-login.confirm-password.required': '請確認密碼!',
+  'user-login.phone-number.required': '請輸入手機號!',
+  'user-login.phone-number.wrong-format': '手機號格式錯誤!',
+  'user-login.verification-code.required': '請輸入驗證碼!',
+  'user-login.title.required': '請輸入標題',
+  'user-login.date.required': '請選擇起止日期',
+  'user-login.goal.required': '請輸入目標描述',
+  'user-login.standard.required': '請輸入衡量標淮',
+  'user-login.form.get-captcha': '獲取驗證碼',
+  'user-login.captcha.second': '秒',
+  'user-login.form.optional': '(選填)',
+  'user-login.form.submit': '提交',
+  'user-login.form.save': '保存',
+  'user-login.email.placeholder': '郵箱',
+  'user-login.password.placeholder': '至少6位密碼,區分大小寫',
+  'user-login.confirm-password.placeholder': '確認密碼',
+  'user-login.phone-number.placeholder': '手機號',
+  'user-login.verification-code.placeholder': '驗證碼',
+  'user-login.title.label': '標題',
+  'user-login.title.placeholder': '給目標起個名字',
+  'user-login.date.label': '起止日期',
+  'user-login.placeholder.start': '開始日期',
+  'user-login.placeholder.end': '結束日期',
+  'user-login.goal.label': '目標描述',
+  'user-login.goal.placeholder': '請輸入妳的階段性工作目標',
+  'user-login.standard.label': '衡量標淮',
+  'user-login.standard.placeholder': '請輸入衡量標淮',
+  'user-login.client.label': '客戶',
+  'user-login.label.tooltip': '目標的服務對象',
+  'user-login.client.placeholder': '請描述妳服務的客戶,內部客戶直接 @姓名/工號',
+  'user-login.invites.label': '邀評人',
+  'user-login.invites.placeholder': '請直接 @姓名/工號,最多可邀請 5 人',
+  'user-login.weight.label': '權重',
+  'user-login.weight.placeholder': '請輸入',
+  'user-login.public.label': '目標公開',
+  'user-login.label.help': '客戶、邀評人默認被分享',
+  'user-login.radio.public': '公開',
+  'user-login.radio.partially-public': '部分公開',
+  'user-login.radio.private': '不公開',
+  'user-login.publicUsers.placeholder': '公開給',
+  'user-login.option.A': '同事甲',
+  'user-login.option.B': '同事乙',
+  'user-login.option.C': '同事丙',
+  'user-login.navBar.lang': '語言',
+};

+ 39 - 0
src/pages/user/login/style.less

@@ -0,0 +1,39 @@
+@import '~antd/es/style/themes/default.less';
+
+.main {
+  width: 368px;
+  margin: 0 auto;
+  @media screen and (max-width: @screen-sm) {
+    width: 95%;
+  }
+
+  .icon {
+    margin-left: 16px;
+    color: rgba(0, 0, 0, 0.2);
+    font-size: 24px;
+    vertical-align: middle;
+    cursor: pointer;
+    transition: color 0.3s;
+
+    &:hover {
+      color: @primary-color;
+    }
+  }
+
+  .other {
+    margin-top: 24px;
+    line-height: 22px;
+    text-align: left;
+
+    .register {
+      float: right;
+    }
+  }
+
+  :global {
+    .antd-pro-login-submit {
+      width: 100%;
+      margin-top: 24px;
+    }
+  }
+}

+ 19 - 0
src/services/login.ts

@@ -0,0 +1,19 @@
+import request from 'umi-request';
+
+export interface LoginParamsType {
+  userName: string;
+  password: string;
+  mobile: string;
+  captcha: string;
+}
+
+export async function fakeAccountLogin(params: LoginParamsType) {
+  return request('/api/login/account', {
+    method: 'POST',
+    data: params,
+  });
+}
+
+export async function getFakeCaptcha(mobile: string) {
+  return request(`/api/login/captcha?mobile=${mobile}`);
+}

+ 6 - 4
src/utils/utils.ts

@@ -1,9 +1,11 @@
+import { parse } from 'querystring';
+
 /* eslint no-useless-escape:0 import/prefer-default-export:0 */
 const reg = /(((^https?:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)$/;
 
-const isUrl = (path: string): boolean => reg.test(path);
+export const isUrl = (path: string): boolean => reg.test(path);
 
-const isAntDesignPro = (): boolean => {
+export const isAntDesignPro = (): boolean => {
   if (ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION === 'site') {
     return true;
   }
@@ -11,7 +13,7 @@ const isAntDesignPro = (): boolean => {
 };
 
 // 给官方演示站点用,用于关闭真实开发环境不需要使用的特性
-const isAntDesignProOrDev = (): boolean => {
+export const isAntDesignProOrDev = (): boolean => {
   const { NODE_ENV } = process.env;
   if (NODE_ENV === 'development') {
     return true;
@@ -19,4 +21,4 @@ const isAntDesignProOrDev = (): boolean => {
   return isAntDesignPro();
 };
 
-export { isAntDesignProOrDev, isAntDesignPro, isUrl };
+export const getPageQuery = () => parse(window.location.href.split('?')[1]);