diff --git a/config/config.ts b/config/config.ts index 980fbede..38093c73 100644 --- a/config/config.ts +++ b/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', @@ -91,23 +84,47 @@ export default { devtool: isAntDesignProPreview ? 'source-map' : false, // 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', }, diff --git a/mock/user.ts b/mock/user.ts index 4039e5a9..80cbd911 100644 --- a/mock/user.ts +++ b/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, }; diff --git a/src/components/GlobalHeader/AvatarDropdown.tsx b/src/components/GlobalHeader/AvatarDropdown.tsx index ccdb7494..9a7f0218 100644 --- a/src/components/GlobalHeader/AvatarDropdown.tsx +++ b/src/components/GlobalHeader/AvatarDropdown.tsx @@ -33,26 +33,24 @@ class AvatarDropdown extends React.Component { }; render(): React.ReactNode { - const { currentUser = {}, menu } = this.props; - if (!menu) { - return ( - - - {currentUser.name} - - ); - } + const { currentUser = { avatar: '', name: '' }, menu } = this.props; + const menuHeaderDropdown = ( - - - - - - - - - + {menu && ( + + + + + )} + {menu && ( + + + + + )} + {menu && } + diff --git a/src/layouts/BasicLayout.tsx b/src/layouts/BasicLayout.tsx index c2777e2c..93f9e9ea 100644 --- a/src/layouts/BasicLayout.tsx +++ b/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 = 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 ( { + 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 ; + } + if (!currentUser.userid) { + return ; + } + return children; + } +} + +export default connect(({ user, loading }: ConnectState) => ({ + currentUser: user.currentUser, + loading: loading.models.user, +}))(SecurityLayout); diff --git a/src/models/connect.d.ts b/src/models/connect.d.ts index 1336fad7..0875800c 100644 --- a/src/models/connect.d.ts +++ b/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: (func: (state: ConnectState) => T) => T }, -) => void; - -/** - * @type P: Type of payload - * @type C: Type of callback - */ -export type Dispatch =

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 extends Partial> { - dispatch?: Dispatch; + dispatch?: Dispatch; } diff --git a/src/models/global.ts b/src/models/global.ts index a12d3298..e1434020 100644 --- a/src/models/global.ts +++ b/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) { diff --git a/src/models/login.ts b/src/models/login.ts index 3dd5a824..740c8d68 100644 --- a/src/models/login.ts +++ b/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: (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; }; } -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, diff --git a/src/models/user.ts b/src/models/user.ts index 54bde7a5..360ba8e6 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -13,6 +13,7 @@ export interface CurrentUser { key: string; label: string; }[]; + userid?: string; unreadCount?: number; } diff --git a/src/pages/user/login/components/Login/LoginContext.tsx b/src/pages/user/login/components/Login/LoginContext.tsx new file mode 100644 index 00000000..ae571e0d --- /dev/null +++ b/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 = createContext({}); + +export default LoginContext; diff --git a/src/pages/user/login/components/Login/LoginItem.tsx b/src/pages/user/login/components/Login/LoginItem.tsx new file mode 100644 index 00000000..75e0fe5a --- /dev/null +++ b/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 = Pick>; + +export type WrappedLoginItemProps = Omit; +export type LoginItemKeyType = keyof typeof ItemMap; +export interface LoginItemType { + UserName: React.FC; + Password: React.FC; + Mobile: React.FC; + Captcha: React.FC; +} + +export interface LoginItemProps extends GetFieldDecoratorOptions { + name?: string; + style?: React.CSSProperties; + onGetCaptcha?: (event?: MouseEvent) => void | Promise | false; + placeholder?: string; + buttonText?: React.ReactNode; + onPressEnter?: (e: React.KeyboardEvent) => 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) => void; + tabUtil?: LoginContextProps['tabUtil']; +} + +interface LoginItemState { + count: number; +} + +const FormItem = Form.Item; + +class WrapFormItem extends Component { + 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 ( + + + + {getFieldDecorator(name, options)()} + + + + + + + ); + } + return ( + + {getFieldDecorator(name, options)()} + + ); + } +} + +const LoginItem: Partial = {}; + +Object.keys(ItemMap).forEach(key => { + const item = ItemMap[key]; + LoginItem[key] = (props: LoginItemProps) => ( + + {context => ( + + )} + + ); +}); + +export default LoginItem as LoginItemType; diff --git a/src/pages/user/login/components/Login/LoginSubmit.tsx b/src/pages/user/login/components/Login/LoginSubmit.tsx new file mode 100644 index 00000000..280fb0fc --- /dev/null +++ b/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 = ({ className, ...rest }) => { + const clsString = classNames(styles.submit, className); + return ( + +