feat: 初始化项目并完成基础功能开发

- 完成项目初始化
- 实现用户注册、登录功能
- 完成用户管理与权限管理模块
- 开发后端 Tracker 服务器项目管理接口
- 实现日志管理接口
Change-Id: Ia4bde1c9ff600352a7ff0caca0cc50b02cad1af7
diff --git a/react-ui/src/components/DictTag/index.tsx b/react-ui/src/components/DictTag/index.tsx
new file mode 100644
index 0000000..2b2a791
--- /dev/null
+++ b/react-ui/src/components/DictTag/index.tsx
@@ -0,0 +1,115 @@
+import React from 'react';
+import { Tag } from 'antd';
+import { ProSchemaValueEnumType } from '@ant-design/pro-components';
+import { DefaultOptionType } from 'antd/es/select';
+
+/* *
+ *
+ * @author whiteshader@163.com
+ * @datetime  2023/02/10
+ *
+ * */
+
+export interface DictValueEnumType extends ProSchemaValueEnumType {
+    id?: string | number;
+    key?: string | number;
+    value: string | number;
+    label: string;
+    listClass?: string;
+}
+
+export interface DictOptionType extends DefaultOptionType {
+    id?: string | number;
+    key?: string | number;
+    text: string;
+    listClass?: string;
+}
+
+
+export type DictValueEnumObj = Record<string | number, DictValueEnumType>;
+
+export type DictTagProps = {
+    key?: string;
+    value?: string | number;
+    enums?: DictValueEnumObj;
+    options?: DictOptionType[];
+};
+
+const DictTag: React.FC<DictTagProps> = (props) => {
+    function getDictColor(type?: string) {
+        switch (type) {
+            case 'primary':
+                return 'blue';
+            case 'success':
+                return 'success';
+            case 'info':
+                return 'green';
+            case 'warning':
+                return 'warning';
+            case 'danger':
+                return 'error';
+            case 'default':
+            default:
+                return 'default';
+        }
+    }
+
+    function getDictLabelByValue(value: string | number | undefined): string {
+        if (value === undefined) {
+            return '';
+        }
+        if (props.enums) {
+            const item = props.enums[value];
+            return item.label;
+        }
+        if (props.options) {
+            if (!Array.isArray(props.options)) {
+                console.log('DictTag options is no array!')
+                return '';
+            }
+            for (const item of props.options) {
+                if (item.value === value) {
+                    return item.text;
+                }
+            }
+        }
+        return String(props.value);
+    }
+
+    function getDictListClassByValue(value: string | number | undefined): string {
+        if (value === undefined) {
+            return 'default';
+        }
+        if (props.enums) {
+            const item = props.enums[value];
+            return item.listClass || 'default';
+        }
+        if (props.options) {
+            if (!Array.isArray(props.options)) {
+                console.log('DictTag options is no array!')
+                return 'default';
+            }
+            for (const item of props.options) {
+                if (item.value === value) {
+                    return item.listClass || 'default';
+                }
+            }
+        }
+        return String(props.value);
+    }
+
+    const getTagColor = () => {
+        return getDictColor(getDictListClassByValue(props.value).toLowerCase());
+    };
+
+    const getTagText = (): string => {
+        return getDictLabelByValue(props.value);
+    };
+
+    return (
+        <Tag color={getTagColor()}>{getTagText()}</Tag>
+    )
+}
+
+
+export default DictTag;
\ No newline at end of file
diff --git a/react-ui/src/components/Footer/index.tsx b/react-ui/src/components/Footer/index.tsx
new file mode 100644
index 0000000..f204ac2
--- /dev/null
+++ b/react-ui/src/components/Footer/index.tsx
@@ -0,0 +1,35 @@
+import { GithubOutlined } from '@ant-design/icons';
+import { DefaultFooter } from '@ant-design/pro-components';
+import React from 'react';
+
+const Footer: React.FC = () => {
+  return (
+    <DefaultFooter
+      style={{
+        background: 'none',
+      }}
+      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;
diff --git a/react-ui/src/components/HeaderDropdown/index.tsx b/react-ui/src/components/HeaderDropdown/index.tsx
new file mode 100644
index 0000000..f89052d
--- /dev/null
+++ b/react-ui/src/components/HeaderDropdown/index.tsx
@@ -0,0 +1,27 @@
+import { Dropdown } from 'antd';
+import type { DropDownProps } from 'antd/es/dropdown';
+import React from 'react';
+import { createStyles } from 'antd-style';
+import classNames from 'classnames';
+
+const useStyles = createStyles(({ token }) => {
+  return {
+    dropdown: {
+      [`@media screen and (max-width: ${token.screenXS}px)`]: {
+        width: '100%',
+      },
+    },
+  };
+});
+
+export type HeaderDropdownProps = {
+  overlayClassName?: string;
+  placement?: 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topCenter' | 'topRight' | 'bottomCenter';
+} & Omit<DropDownProps, 'overlay'>;
+
+const HeaderDropdown: React.FC<HeaderDropdownProps> = ({ overlayClassName: cls, ...restProps }) => {
+  const { styles } = useStyles();
+  return <Dropdown overlayClassName={classNames(styles.dropdown, cls)} {...restProps} />;
+};
+
+export default HeaderDropdown;
diff --git a/react-ui/src/components/IconSelector/Category.tsx b/react-ui/src/components/IconSelector/Category.tsx
new file mode 100644
index 0000000..dd0e93f
--- /dev/null
+++ b/react-ui/src/components/IconSelector/Category.tsx
@@ -0,0 +1,63 @@
+import * as React from 'react';
+import CopyableIcon from './CopyableIcon';
+import type { ThemeType } from './index';
+import type { CategoriesKeys } from './fields';
+import { useIntl } from '@umijs/max';
+import styles from './style.less';
+
+interface CategoryProps {
+  title: CategoriesKeys;
+  icons: string[];
+  theme: ThemeType;
+  newIcons: string[];
+  onSelect: (type: string, name: string) => any;
+}
+
+const Category: React.FC<CategoryProps> = props => {
+
+  const { icons, title, newIcons, theme } = props;
+  const intl = useIntl();
+  const [justCopied, setJustCopied] = React.useState<string | null>(null);
+  const copyId = React.useRef<NodeJS.Timeout | null>(null);
+  const onSelect = React.useCallback((type: string, text: string) => {
+    const { onSelect } = props;
+    if (onSelect) {
+      onSelect(type, text);
+    }
+    setJustCopied(type);
+    copyId.current = setTimeout(() => {
+      setJustCopied(null);
+    }, 2000);
+  }, []);
+  React.useEffect(
+    () => () => {
+      if (copyId.current) {
+        clearTimeout(copyId.current);
+      }
+    },
+    [],
+  );
+
+  return (
+    <div>
+      <h4>{intl.formatMessage({
+        id: `app.docs.components.icon.category.${title}`,
+        defaultMessage: '信息',
+      })}</h4>
+      <ul className={styles.anticonsList}>
+        {icons.map(name => (
+          <CopyableIcon
+            key={name}
+            name={name}
+            theme={theme}
+            isNew={newIcons.includes(name)}
+            justCopied={justCopied}
+            onSelect={onSelect}
+          />
+        ))}
+      </ul>
+    </div>
+  );
+};
+
+export default Category;
diff --git a/react-ui/src/components/IconSelector/CopyableIcon.tsx b/react-ui/src/components/IconSelector/CopyableIcon.tsx
new file mode 100644
index 0000000..371cba0
--- /dev/null
+++ b/react-ui/src/components/IconSelector/CopyableIcon.tsx
@@ -0,0 +1,47 @@
+import * as React from 'react';
+import { Tooltip } from 'antd';
+import classNames from 'classnames';
+import * as AntdIcons from '@ant-design/icons';
+import type { ThemeType } from './index';
+import styles from './style.less';
+
+const allIcons: {
+  [key: string]: any;
+} = AntdIcons;
+
+export interface CopyableIconProps {
+  name: string;
+  isNew: boolean;
+  theme: ThemeType;
+  justCopied: string | null;
+  onSelect: (type: string, text: string) => any;
+}
+
+const CopyableIcon: React.FC<CopyableIconProps> = ({
+  name,
+  justCopied,
+  onSelect,
+  theme,
+}) => {
+  const className = classNames({
+    copied: justCopied === name,
+    [theme]: !!theme,
+  });
+  return (
+    <li className={className}
+      onClick={() => {
+        if (onSelect) {
+          onSelect(theme, name);
+        }
+      }}>
+      <Tooltip title={name}>
+        {React.createElement(allIcons[name], { className: styles.anticon })}
+      </Tooltip>
+      {/* <span className={styles.anticonClass}>
+          <Badge dot={isNew}>{name}</Badge>
+        </span> */}
+    </li>
+  );
+};
+
+export default CopyableIcon;
diff --git a/react-ui/src/components/IconSelector/IconPicSearcher.tsx b/react-ui/src/components/IconSelector/IconPicSearcher.tsx
new file mode 100644
index 0000000..3a4cf01
--- /dev/null
+++ b/react-ui/src/components/IconSelector/IconPicSearcher.tsx
@@ -0,0 +1,233 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { Upload, Tooltip, Popover, Modal, Progress, Spin, Result } from 'antd';
+import * as AntdIcons from '@ant-design/icons';
+import { useIntl } from '@umijs/max';
+import './style.less';
+
+const allIcons: { [key: string]: any } = AntdIcons;
+
+const { Dragger } = Upload;
+interface AntdIconClassifier {
+  load: () => void;
+  predict: (imgEl: HTMLImageElement) => void;
+}
+declare global {
+  interface Window {
+    antdIconClassifier: AntdIconClassifier;
+  }
+}
+
+interface PicSearcherState {
+  loading: boolean;
+  modalOpen: boolean;
+  popoverVisible: boolean;
+  icons: iconObject[];
+  fileList: any[];
+  error: boolean;
+  modelLoaded: boolean;
+}
+
+interface iconObject {
+  type: string;
+  score: number;
+}
+
+const PicSearcher: React.FC = () => {
+  const intl = useIntl();
+  const {formatMessage} = intl;
+  const [state, setState] = useState<PicSearcherState>({
+    loading: false,
+    modalOpen: false,
+    popoverVisible: false,
+    icons: [],
+    fileList: [],
+    error: false,
+    modelLoaded: false,
+  });
+  const predict = (imgEl: HTMLImageElement) => {
+    try {
+      let icons: any[] = window.antdIconClassifier.predict(imgEl);
+      if (gtag && icons.length) {
+        gtag('event', 'icon', {
+          event_category: 'search-by-image',
+          event_label: icons[0].className,
+        });
+      }
+      icons = icons.map(i => ({ score: i.score, type: i.className.replace(/\s/g, '-') }));
+      setState(prev => ({ ...prev, loading: false, error: false, icons }));
+    } catch {
+      setState(prev => ({ ...prev, loading: false, error: true }));
+    }
+  };
+  // eslint-disable-next-line class-methods-use-this
+  const toImage = (url: string) =>
+    new Promise(resolve => {
+      const img = new Image();
+      img.setAttribute('crossOrigin', 'anonymous');
+      img.src = url;
+      img.onload = () => {
+        resolve(img);
+      };
+    });
+
+  const uploadFile = useCallback((file: File) => {
+    setState(prev => ({ ...prev, loading: true }));
+    const reader = new FileReader();
+    reader.onload = () => {
+      toImage(reader.result as string).then(predict);
+      setState(prev => ({
+        ...prev,
+        fileList: [{ uid: 1, name: file.name, status: 'done', url: reader.result }],
+      }));
+    };
+    reader.readAsDataURL(file);
+  }, []);
+
+  const onPaste = useCallback((event: ClipboardEvent) => {
+    const items = event.clipboardData && event.clipboardData.items;
+    let file = null;
+    if (items && items.length) {
+      for (let i = 0; i < items.length; i++) {
+        if (items[i].type.includes('image')) {
+          file = items[i].getAsFile();
+          break;
+        }
+      }
+    }
+    if (file) {
+      uploadFile(file);
+    }
+  }, []);
+  const toggleModal = useCallback(() => {
+    setState(prev => ({
+      ...prev,
+      modalOpen: !prev.modalOpen,
+      popoverVisible: false,
+      fileList: [],
+      icons: [],
+    }));
+    if (!localStorage.getItem('disableIconTip')) {
+      localStorage.setItem('disableIconTip', 'true');
+    }
+  }, []);
+
+  useEffect(() => {
+    const script = document.createElement('script');
+    script.onload = async () => {
+      await window.antdIconClassifier.load();
+      setState(prev => ({ ...prev, modelLoaded: true }));
+      document.addEventListener('paste', onPaste);
+    };
+    script.src = 'https://cdn.jsdelivr.net/gh/lewis617/antd-icon-classifier@0.0/dist/main.js';
+    document.head.appendChild(script);
+    setState(prev => ({ ...prev, popoverVisible: !localStorage.getItem('disableIconTip') }));
+    return () => {
+      document.removeEventListener('paste', onPaste);
+    };
+  }, []);
+
+  return (
+    <div className="iconPicSearcher">
+      <Popover
+        content={formatMessage({id: 'app.docs.components.icon.pic-searcher.intro'})}
+        open={state.popoverVisible}
+      >
+        <AntdIcons.CameraOutlined className="icon-pic-btn" onClick={toggleModal} />
+      </Popover>
+      <Modal
+        title={intl.formatMessage({
+          id: 'app.docs.components.icon.pic-searcher.title',
+          defaultMessage: '信息',
+        })}
+        open={state.modalOpen}
+        onCancel={toggleModal}
+        footer={null}
+      >
+        {state.modelLoaded || (
+          <Spin
+            spinning={!state.modelLoaded}
+            tip={formatMessage({
+              id: 'app.docs.components.icon.pic-searcher.modelloading',
+
+            })}
+          >
+            <div style={{ height: 100 }} />
+          </Spin>
+        )}
+        {state.modelLoaded && (
+          <Dragger
+            accept="image/jpeg, image/png"
+            listType="picture"
+            customRequest={o => uploadFile(o.file as File)}
+            fileList={state.fileList}
+            showUploadList={{ showPreviewIcon: false, showRemoveIcon: false }}
+          >
+            <p className="ant-upload-drag-icon">
+              <AntdIcons.InboxOutlined />
+            </p>
+            <p className="ant-upload-text">
+              {formatMessage({id: 'app.docs.components.icon.pic-searcher.upload-text'})}
+            </p>
+            <p className="ant-upload-hint">
+              {formatMessage({id: 'app.docs.components.icon.pic-searcher.upload-hint'})}
+            </p>
+          </Dragger>
+        )}
+        <Spin
+          spinning={state.loading}
+          tip={formatMessage({id: 'app.docs.components.icon.pic-searcher.matching'})}
+        >
+          <div className="icon-pic-search-result">
+            {state.icons.length > 0 && (
+              <div className="result-tip">
+                {formatMessage({id: 'app.docs.components.icon.pic-searcher.result-tip'})}
+              </div>
+            )}
+            <table>
+              {state.icons.length > 0 && (
+                <thead>
+                  <tr>
+                    <th className="col-icon">
+                      {formatMessage({id: 'app.docs.components.icon.pic-searcher.th-icon'})}
+                    </th>
+                    <th>{formatMessage({id: 'app.docs.components.icon.pic-searcher.th-score'})}</th>
+                  </tr>
+                </thead>
+              )}
+              <tbody>
+                {state.icons.map(icon => {
+                  const { type } = icon;
+                  const iconName = `${type
+                    .split('-')
+                    .map(str => `${str[0].toUpperCase()}${str.slice(1)}`)
+                    .join('')}Outlined`;
+                  return (
+                    <tr key={iconName}>
+                      <td className="col-icon">
+                          <Tooltip title={icon.type} placement="right">
+                            {React.createElement(allIcons[iconName])}
+                          </Tooltip>
+                      </td>
+                      <td>
+                        <Progress percent={Math.ceil(icon.score * 100)} />
+                      </td>
+                    </tr>
+                  );
+                })}
+              </tbody>
+            </table>
+            {state.error && (
+              <Result
+                status="500"
+                title="503"
+                subTitle={formatMessage({id: 'app.docs.components.icon.pic-searcher.server-error'})}
+              />
+            )}
+          </div>
+        </Spin>
+      </Modal>
+    </div>
+  );
+};
+
+export default PicSearcher;
diff --git a/react-ui/src/components/IconSelector/fields.ts b/react-ui/src/components/IconSelector/fields.ts
new file mode 100644
index 0000000..de37e67
--- /dev/null
+++ b/react-ui/src/components/IconSelector/fields.ts
@@ -0,0 +1,223 @@
+import * as AntdIcons from '@ant-design/icons/lib/icons';
+
+const all = Object.keys(AntdIcons)
+  .map(n => n.replace(/(Outlined|Filled|TwoTone)$/, ''))
+  .filter((n, i, arr) => arr.indexOf(n) === i);
+
+const direction = [
+  'StepBackward',
+  'StepForward',
+  'FastBackward',
+  'FastForward',
+  'Shrink',
+  'ArrowsAlt',
+  'Down',
+  'Up',
+  'Left',
+  'Right',
+  'CaretUp',
+  'CaretDown',
+  'CaretLeft',
+  'CaretRight',
+  'UpCircle',
+  'DownCircle',
+  'LeftCircle',
+  'RightCircle',
+  'DoubleRight',
+  'DoubleLeft',
+  'VerticalLeft',
+  'VerticalRight',
+  'VerticalAlignTop',
+  'VerticalAlignMiddle',
+  'VerticalAlignBottom',
+  'Forward',
+  'Backward',
+  'Rollback',
+  'Enter',
+  'Retweet',
+  'Swap',
+  'SwapLeft',
+  'SwapRight',
+  'ArrowUp',
+  'ArrowDown',
+  'ArrowLeft',
+  'ArrowRight',
+  'PlayCircle',
+  'UpSquare',
+  'DownSquare',
+  'LeftSquare',
+  'RightSquare',
+  'Login',
+  'Logout',
+  'MenuFold',
+  'MenuUnfold',
+  'BorderBottom',
+  'BorderHorizontal',
+  'BorderInner',
+  'BorderOuter',
+  'BorderLeft',
+  'BorderRight',
+  'BorderTop',
+  'BorderVerticle',
+  'PicCenter',
+  'PicLeft',
+  'PicRight',
+  'RadiusBottomleft',
+  'RadiusBottomright',
+  'RadiusUpleft',
+  'RadiusUpright',
+  'Fullscreen',
+  'FullscreenExit',
+];
+
+const suggestion = [
+  'Question',
+  'QuestionCircle',
+  'Plus',
+  'PlusCircle',
+  'Pause',
+  'PauseCircle',
+  'Minus',
+  'MinusCircle',
+  'PlusSquare',
+  'MinusSquare',
+  'Info',
+  'InfoCircle',
+  'Exclamation',
+  'ExclamationCircle',
+  'Close',
+  'CloseCircle',
+  'CloseSquare',
+  'Check',
+  'CheckCircle',
+  'CheckSquare',
+  'ClockCircle',
+  'Warning',
+  'IssuesClose',
+  'Stop',
+];
+
+const editor = [
+  'Edit',
+  'Form',
+  'Copy',
+  'Scissor',
+  'Delete',
+  'Snippets',
+  'Diff',
+  'Highlight',
+  'AlignCenter',
+  'AlignLeft',
+  'AlignRight',
+  'BgColors',
+  'Bold',
+  'Italic',
+  'Underline',
+  'Strikethrough',
+  'Redo',
+  'Undo',
+  'ZoomIn',
+  'ZoomOut',
+  'FontColors',
+  'FontSize',
+  'LineHeight',
+  'Dash',
+  'SmallDash',
+  'SortAscending',
+  'SortDescending',
+  'Drag',
+  'OrderedList',
+  'UnorderedList',
+  'RadiusSetting',
+  'ColumnWidth',
+  'ColumnHeight',
+];
+
+const data = [
+  'AreaChart',
+  'PieChart',
+  'BarChart',
+  'DotChart',
+  'LineChart',
+  'RadarChart',
+  'HeatMap',
+  'Fall',
+  'Rise',
+  'Stock',
+  'BoxPlot',
+  'Fund',
+  'Sliders',
+];
+
+const logo = [
+  'Android',
+  'Apple',
+  'Windows',
+  'Ie',
+  'Chrome',
+  'Github',
+  'Aliwangwang',
+  'Dingding',
+  'WeiboSquare',
+  'WeiboCircle',
+  'TaobaoCircle',
+  'Html5',
+  'Weibo',
+  'Twitter',
+  'Wechat',
+  'Youtube',
+  'AlipayCircle',
+  'Taobao',
+  'Skype',
+  'Qq',
+  'MediumWorkmark',
+  'Gitlab',
+  'Medium',
+  'Linkedin',
+  'GooglePlus',
+  'Dropbox',
+  'Facebook',
+  'Codepen',
+  'CodeSandbox',
+  'CodeSandboxCircle',
+  'Amazon',
+  'Google',
+  'CodepenCircle',
+  'Alipay',
+  'AntDesign',
+  'AntCloud',
+  'Aliyun',
+  'Zhihu',
+  'Slack',
+  'SlackSquare',
+  'Behance',
+  'BehanceSquare',
+  'Dribbble',
+  'DribbbleSquare',
+  'Instagram',
+  'Yuque',
+  'Alibaba',
+  'Yahoo',
+  'Reddit',
+  'Sketch',
+  'WhatsApp',
+  'Dingtalk',
+];
+
+const datum = [...direction, ...suggestion, ...editor, ...data, ...logo];
+
+const other = all.filter(n => !datum.includes(n));
+
+export const categories = {
+  direction,
+  suggestion,
+  editor,
+  data,
+  logo,
+  other,
+};
+
+export default categories;
+
+export type Categories = typeof categories;
+export type CategoriesKeys = keyof Categories;
diff --git a/react-ui/src/components/IconSelector/index.tsx b/react-ui/src/components/IconSelector/index.tsx
new file mode 100644
index 0000000..78dc931
--- /dev/null
+++ b/react-ui/src/components/IconSelector/index.tsx
@@ -0,0 +1,142 @@
+import * as React from 'react';
+import Icon, * as AntdIcons from '@ant-design/icons';
+import { Radio, Input, Empty } from 'antd';
+import type { RadioChangeEvent } from 'antd/es/radio/interface';
+import debounce from 'lodash/debounce';
+import Category from './Category';
+import IconPicSearcher from './IconPicSearcher';
+import { FilledIcon, OutlinedIcon, TwoToneIcon } from './themeIcons';
+import type { CategoriesKeys } from './fields';
+import { categories } from './fields';
+// import { useIntl } from '@umijs/max';
+
+export enum ThemeType {
+  Filled = 'Filled',
+  Outlined = 'Outlined',
+  TwoTone = 'TwoTone',
+}
+
+const allIcons: { [key: string]: any } = AntdIcons;
+
+interface IconSelectorProps {
+  //intl: any;
+  onSelect: any;
+}
+
+interface IconSelectorState {
+  theme: ThemeType;
+  searchKey: string;
+}
+
+const IconSelector: React.FC<IconSelectorProps> = (props) => {
+  // const intl = useIntl();
+  // const { messages } = intl;
+  const { onSelect } = props;
+  const [displayState, setDisplayState] = React.useState<IconSelectorState>({
+    theme: ThemeType.Outlined,
+    searchKey: '',
+  });
+
+  const newIconNames: string[] = [];
+
+  const handleSearchIcon = React.useCallback(
+    debounce((searchKey: string) => {
+      setDisplayState(prevState => ({ ...prevState, searchKey }));
+    }),
+    [],
+  );
+
+  const handleChangeTheme = React.useCallback((e: RadioChangeEvent) => {
+    setDisplayState(prevState => ({ ...prevState, theme: e.target.value as ThemeType }));
+  }, []);
+
+  const renderCategories = React.useMemo<React.ReactNode | React.ReactNode[]>(() => {
+    const { searchKey = '', theme } = displayState;
+
+    const categoriesResult = Object.keys(categories)
+      .map((key: CategoriesKeys) => {
+        let iconList = categories[key];
+        if (searchKey) {
+          const matchKey = searchKey
+            // eslint-disable-next-line prefer-regex-literals
+            .replace(new RegExp(`^<([a-zA-Z]*)\\s/>$`, 'gi'), (_, name) => name)
+            .replace(/(Filled|Outlined|TwoTone)$/, '')
+            .toLowerCase();
+          iconList = iconList.filter((iconName:string) => iconName.toLowerCase().includes(matchKey));
+        }
+
+        // CopyrightCircle is same as Copyright, don't show it
+        iconList = iconList.filter((icon:string) => icon !== 'CopyrightCircle');
+
+        return {
+          category: key,
+          icons: iconList.map((iconName:string) => iconName + theme).filter((iconName:string) => allIcons[iconName]),
+        };
+      })
+      .filter(({ icons }) => !!icons.length)
+      .map(({ category, icons }) => (
+        <Category
+          key={category}
+          title={category as CategoriesKeys}
+          theme={theme}
+          icons={icons}
+          newIcons={newIconNames}
+          onSelect={(type, name) => {
+            if (onSelect) {
+              onSelect(name, allIcons[name]);
+            }
+          }}
+        />
+      ));
+    return categoriesResult.length === 0 ? <Empty style={{ margin: '2em 0' }} /> : categoriesResult;
+  }, [displayState.searchKey, displayState.theme]);
+  return (
+    <>
+      <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+        <Radio.Group
+          value={displayState.theme}
+          onChange={handleChangeTheme}
+          size="large"
+          optionType="button"
+          buttonStyle="solid"
+          options={[
+            {
+              label:  <Icon component={OutlinedIcon} />,
+              value: ThemeType.Outlined
+            },
+            {
+              label: <Icon component={FilledIcon} />,
+              value: ThemeType.Filled
+            },
+            {
+              label: <Icon component={TwoToneIcon} />,
+              value: ThemeType.TwoTone
+            },
+          ]}
+        >
+          {/* <Radio.Button value={ThemeType.Outlined}>
+            <Icon component={OutlinedIcon} /> {messages['app.docs.components.icon.outlined']}
+          </Radio.Button>
+          <Radio.Button value={ThemeType.Filled}>
+            <Icon component={FilledIcon} /> {messages['app.docs.components.icon.filled']}
+          </Radio.Button>
+          <Radio.Button value={ThemeType.TwoTone}>
+            <Icon component={TwoToneIcon} /> {messages['app.docs.components.icon.two-tone']}
+          </Radio.Button> */}
+        </Radio.Group>
+        <Input.Search
+          // placeholder={messages['app.docs.components.icon.search.placeholder']}
+          style={{ margin: '0 10px', flex: 1 }}
+          allowClear
+          onChange={e => handleSearchIcon(e.currentTarget.value)}
+          size="large"
+          autoFocus
+          suffix={<IconPicSearcher />}
+        />
+      </div>
+      {renderCategories}
+    </>
+  );
+};
+
+export default IconSelector
diff --git a/react-ui/src/components/IconSelector/style.less b/react-ui/src/components/IconSelector/style.less
new file mode 100644
index 0000000..0a4353d
--- /dev/null
+++ b/react-ui/src/components/IconSelector/style.less
@@ -0,0 +1,137 @@
+.iconPicSearcher {
+  display: inline-block;
+  margin: 0 8px;
+
+  .icon-pic-btn {
+    color: @text-color-secondary;
+    cursor: pointer;
+    transition: all 0.3s;
+
+    &:hover {
+      color: @input-icon-hover-color;
+    }
+  }
+}
+
+.icon-pic-preview {
+  width: 30px;
+  height: 30px;
+  margin-top: 10px;
+  padding: 8px;
+  text-align: center;
+  border: 1px solid @border-color-base;
+  border-radius: 4px;
+
+  > img {
+    max-width: 50px;
+    max-height: 50px;
+  }
+}
+
+.icon-pic-search-result {
+  min-height: 50px;
+  padding: 0 10px;
+
+  > .result-tip {
+    padding: 10px 0;
+    color: @text-color-secondary;
+  }
+
+  > table {
+    width: 100%;
+
+    .col-icon {
+      width: 80px;
+      padding: 10px 0;
+
+      > .anticon {
+        font-size: 30px;
+
+        :hover {
+          color: @link-hover-color;
+        }
+      }
+    }
+  }
+}
+
+ul.anticonsList {
+  margin: 2px 0;
+  overflow: hidden;
+  direction: ltr;
+  list-style: none;
+
+  li {
+    position: relative;
+    float: left;
+    width: 48px;
+    height: 48px;
+    margin: 3px 0;
+    padding: 2px 0 0;
+    overflow: hidden;
+    color: #555;
+    text-align: center;
+    list-style: none;
+    background-color: inherit;
+    border-radius: 4px;
+    cursor: pointer;
+    transition: color 0.3s ease-in-out, background-color 0.3s ease-in-out;
+
+    .rtl & {
+      margin: 3px 0;
+      padding: 2px 0 0;
+    }
+
+    .anticon {
+      margin: 4px 0 2px;
+      font-size: 24px;
+      transition: transform 0.3s ease-in-out;
+      will-change: transform;
+    }
+
+    .anticonClass {
+      display: block;
+      font-family: 'Lucida Console', Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
+      white-space: nowrap;
+      text-align: center;
+      transform: scale(0.83);
+
+      .ant-badge {
+        transition: color 0.3s ease-in-out;
+      }
+    }
+
+    &:hover {
+      color: #fff;
+      background-color: @primary-color;
+
+      .anticon {
+        transform: scale(1.4);
+      }
+
+      .ant-badge {
+        color: #fff;
+      }
+    }
+
+    &.TwoTone:hover {
+      background-color: #8ecafe;
+    }
+
+    &.copied:hover {
+      color: rgba(255, 255, 255, 0.2);
+    }
+
+    &.copied::after {
+      top: -2px;
+      opacity: 1;
+    }
+  }
+}
+
+.copied-code {
+  padding: 2px 4px;
+  font-size: 12px;
+  background: #f5f5f5;
+  border-radius: 2px;
+}
diff --git a/react-ui/src/components/IconSelector/themeIcons.tsx b/react-ui/src/components/IconSelector/themeIcons.tsx
new file mode 100644
index 0000000..abefe04
--- /dev/null
+++ b/react-ui/src/components/IconSelector/themeIcons.tsx
@@ -0,0 +1,41 @@
+import * as React from 'react';
+
+
+export const FilledIcon: React.FC = props => {
+  const path =
+    'M864 64H160C107 64 64 107 64 160v' +
+    '704c0 53 43 96 96 96h704c53 0 96-43 96-96V16' +
+    '0c0-53-43-96-96-96z';
+  return (
+    <svg {...props} viewBox="0 0 1024 1024">
+      <path d={path} />
+    </svg>
+  );
+};
+
+export const OutlinedIcon: React.FC = props => {
+  const path =
+    'M864 64H160C107 64 64 107 64 160v7' +
+    '04c0 53 43 96 96 96h704c53 0 96-43 96-96V160c' +
+    '0-53-43-96-96-96z m-12 800H172c-6.6 0-12-5.4-' +
+    '12-12V172c0-6.6 5.4-12 12-12h680c6.6 0 12 5.4' +
+    ' 12 12v680c0 6.6-5.4 12-12 12z';
+  return (
+    <svg {...props} viewBox="0 0 1024 1024">
+      <path d={path} />
+    </svg>
+  );
+};
+
+export const TwoToneIcon: React.FC = props => {
+  const path =
+    'M16 512c0 273.932 222.066 496 496 49' +
+    '6s496-222.068 496-496S785.932 16 512 16 16 238.' +
+    '066 16 512z m496 368V144c203.41 0 368 164.622 3' +
+    '68 368 0 203.41-164.622 368-368 368z';
+  return (
+    <svg {...props} viewBox="0 0 1024 1024">
+      <path d={path} />
+    </svg>
+  );
+};
diff --git a/react-ui/src/components/RightContent/AvatarDropdown.tsx b/react-ui/src/components/RightContent/AvatarDropdown.tsx
new file mode 100644
index 0000000..34ceb15
--- /dev/null
+++ b/react-ui/src/components/RightContent/AvatarDropdown.tsx
@@ -0,0 +1,142 @@
+import { LogoutOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons';
+import { history, useModel } from '@umijs/max';
+import { Spin } from 'antd';
+import { createStyles } from 'antd-style';
+import { stringify } from 'querystring';
+import type { MenuInfo } from 'rc-menu/lib/interface';
+import React, { useCallback } from 'react';
+import { flushSync } from 'react-dom';
+import HeaderDropdown from '../HeaderDropdown';
+import { setRemoteMenu } from '@/services/session';
+import { clearSessionToken } from '@/access';
+import { logout } from '@/services/system/auth';
+
+export type GlobalHeaderRightProps = {
+  menu?: boolean;
+  children?: React.ReactNode;
+};
+
+export const AvatarName = () => {
+  const { initialState } = useModel('@@initialState');
+  const { currentUser } = initialState || {};
+  return <span className="anticon">{currentUser?.nickName}</span>;
+};
+
+const useStyles = createStyles(({ token }) => {
+  return {
+    action: {
+      display: 'flex',
+      height: '48px',
+      marginLeft: 'auto',
+      overflow: 'hidden',
+      alignItems: 'center',
+      padding: '0 8px',
+      cursor: 'pointer',
+      borderRadius: token.borderRadius,
+      '&:hover': {
+        backgroundColor: token.colorBgTextHover,
+      },
+    },
+  };
+});
+
+export const AvatarDropdown: React.FC<GlobalHeaderRightProps> = ({ menu, children }) => {
+  /**
+   * 退出登录,并且将当前的 url 保存
+   */
+  const loginOut = async () => {
+    await logout();
+    clearSessionToken();
+    setRemoteMenu(null);
+    const { search, pathname } = window.location;
+    const urlParams = new URL(window.location.href).searchParams;
+    /** 此方法会跳转到 redirect 参数所在的位置 */
+    const redirect = urlParams.get('redirect');
+    // Note: There may be security issues, please note
+    if (window.location.pathname !== '/user/login' && !redirect) {
+      history.replace({
+        pathname: '/user/login',
+        search: stringify({
+          redirect: pathname + search,
+        }),
+      });
+    }
+  };
+  const { styles } = useStyles();
+
+  const { initialState, setInitialState } = useModel('@@initialState');
+
+  const onMenuClick = useCallback(
+    (event: MenuInfo) => {
+      const { key } = event;
+      if (key === 'logout') {
+        flushSync(() => {
+          setInitialState((s) => ({ ...s, currentUser: undefined }));
+        });
+        loginOut();
+        return;
+      }
+      history.push(`/account/${key}`);
+    },
+    [setInitialState],
+  );
+
+  const loading = (
+    <span className={styles.action}>
+      <Spin
+        size="small"
+        style={{
+          marginLeft: 8,
+          marginRight: 8,
+        }}
+      />
+    </span>
+  );
+
+  if (!initialState) {
+    return loading;
+  }
+
+  const { currentUser } = initialState;
+
+  if (!currentUser || !currentUser.nickName) {
+    return loading;
+  }
+
+  const menuItems = [
+    ...(menu
+      ? [
+          {
+            key: 'center',
+            icon: <UserOutlined />,
+            label: '个人中心',
+          },
+          {
+            key: 'settings',
+            icon: <SettingOutlined />,
+            label: '个人设置',
+          },
+          {
+            type: 'divider' as const,
+          },
+        ]
+      : []),
+    {
+      key: 'logout',
+      icon: <LogoutOutlined />,
+      label: '退出登录',
+    },
+  ];
+
+  return (
+    <HeaderDropdown
+      menu={{
+        selectedKeys: [],
+        onClick: onMenuClick,
+        items: menuItems,
+      }}
+    >
+      {children}
+    </HeaderDropdown>
+  );
+};
diff --git a/react-ui/src/components/RightContent/index.tsx b/react-ui/src/components/RightContent/index.tsx
new file mode 100644
index 0000000..20a7831
--- /dev/null
+++ b/react-ui/src/components/RightContent/index.tsx
@@ -0,0 +1,31 @@
+import { QuestionCircleOutlined } from '@ant-design/icons';
+import { SelectLang as UmiSelectLang } from '@umijs/max';
+import React from 'react';
+
+export type SiderTheme = 'light' | 'dark';
+
+export const SelectLang = () => {
+  return (
+    <UmiSelectLang
+      style={{
+        padding: 4,
+      }}
+    />
+  );
+};
+
+export const Question = () => {
+  return (
+    <div
+      style={{
+        display: 'flex',
+        height: 26,
+      }}
+      onClick={() => {
+        window.open('https://pro.ant.design/docs/getting-started');
+      }}
+    >
+      <QuestionCircleOutlined />
+    </div>
+  );
+};
diff --git a/react-ui/src/components/index.ts b/react-ui/src/components/index.ts
new file mode 100644
index 0000000..ca88a6d
--- /dev/null
+++ b/react-ui/src/components/index.ts
@@ -0,0 +1,12 @@
+/**
+ * 这个文件作为组件的目录
+ * 目的是统一管理对外输出的组件,方便分类
+ */
+/**
+ * 布局组件
+ */
+import Footer from './Footer';
+import { Question, SelectLang } from './RightContent';
+import { AvatarDropdown, AvatarName } from './RightContent/AvatarDropdown';
+
+export { Footer, Question, SelectLang, AvatarDropdown, AvatarName };