基于 Mui 封装常用 React 组件

殷永嘉
2023-12-01

基于 Mui 封装常用 React 组件

Mui(MaterialUI)

Mui 是基于googleMateria设计风格开发的基于React框架的 UI 框架,之前的名称为MaterialUI

主题

Mui 提供了非常强大的主题解决方案,使用Mui提供的createTheme方法可以自定义配置生成主题:

import { createTheme } from "@mui/material";

const theme = createTheme({
  palette: {
    mode: "light",
    primary: {
      main: primary,
    },
    success: {
      main: "#4CAF50",
    },
    error: {
      main: "#F44336",
    },
    background: {
      // paper: "#f1f1f1",
    },
  },
  shape: { borderRadius: 4 },
  custom: {
    contentPadding: 16,
    commonBg: "#fff",
    background: "#F5F9FF",
    headerAvatarColor: "red",
    navItemHoverBg: `linear-gradient(89deg, #ffffff61, ${primary});opacity: 1`,
    tableBgColor: "red",
    templateConfigCardPadding: "12px 32px",
    borderColor: "rgba(0, 0, 0, 0.12)",
  },
  typography: {
    fontSize: (baseFontSize / 16) * 14,
    fontFamily: "HarmonyOS_Sans_SC_Regular",
    h4: {
      fontSize: 16,
      color: "rgba(0, 0, 0, 0.6)",
    },
    subtitle1: {
      color: "rgba(0, 0, 0, 0.6)",
    },
    subtitle2: {
      color: "rgba(0, 0, 0, 0.6)",
    },
  },
});

生成的主题数据只需要传给Mui暴露的组件ThemeProvider即可,之后,ThemeProvider组建的所有后代的 Mui 组件都会使用到该主题:

<ThemeProvider theme={theme}>
  <Routes />
</ThemeProvider>

动态切换主题

动态切换主题只需要生成主题数据传递给ThemeProvider组件即可。

Mui 样式处理 Api

Mui 提供了一些自定义样式的 Api,下面记录一些常用的

styled

styled是 Mui 暴露的一个 api,我们可以使用该 api 对 Mui 甚至 React 其他组件进行样式改造,比如对 Mui 的 TableRow 组件进行自定义样式:

import { lighten, styled, TableRow } from "@mui/material";

const StyledRow = styled(TableRow)(({ theme }) => {
  return {
    backgroundColor: "#000",
    "& .sticky-cell": {
      position: "sticky",
      backgroundColor: "#fff",
    },
    "&.MuiTableRow-root:hover": {
      backgroundColor: "#fbfbfb",
      "& .sticky-cell": {
        backgroundColor: "#fbfbfb",
      },
    },
    "&.Mui-selected": {
      backgroundColor: lighten(theme.palette.primary.main, 0.95),
      "& .sticky-cell": {
        backgroundColor: lighten(theme.palette.primary.main, 0.95),
      },
    },
    "&.Mui-selected:hover": {
      backgroundColor: lighten(theme.palette.primary.main, 0.9),
      "& .sticky-cell": {
        backgroundColor: lighten(theme.palette.primary.main, 0.9),
      },
    },
  };
});

export default StyledRow;

这段代码中有几种写法:

  • 直接写 css 样式:这种写法写的样式会直接加载到 TableRow 组件的根元素上。
  • “&selector”:使用&符号加 css 选择器,但是两者之间不带空格,这种写法是根元素如果能匹配这个选择器,那么这个选择器对应的样式会生效。
  • “& selector”:使用&符号加 css 选择器,并且两者之间用空格分割,代表 TableRow 根元素的后代元素如果能匹配这个选择器,那么这个选择器对应的样式会生效。
  • “&:hover” | “&selector:hover” | “& selector:hover”:分别代表 TableRow 的根元素的 hover 伪类、根元素如果匹配 selector 的 hover 伪类、根元素的后代元素如果匹配 selector 的 hover 伪类,其他伪类(active 等等)同理。

makeStyles

Mui 暴露了 makeStyles 和 useStyle 这两个方法,这两个方法配合使用可以生成样式以及对应的类名,我们生成后直接使用类名,就可以绑定对应的样式。这样写比起单独写样式文件的好处是代码可维护性更强,并且可以更加方便使用全局配置的主题:比如

import { Avatar, Box, Grid, Theme, Typography } from "@mui/material";
import { makeStyles } from "@mui/styles";

const useStyle = makeStyles((theme: Theme) => {
  return {
    avatarContainer: {
      width: 96,
      height: 96,
      cursor: "pointer",
      position: "relative",
    },
    avatar: { width: 96, height: 96 },
    avatarBorder: {
      height: "100%",
      width: "100%",
      position: "absolute",
      borderRadius: "50%",
      top: 0,
      left: 0,
      border: "3px solid " + theme.palette.success.main,
      boxSizing: "border-box",
    },
  };
});

export default function AvatarSelectDialog() {
  const classes = useStyle();

  return (
    <MatDialog>
      <Box className="flex-box">
        <Box className={classes.avatarContainer}>
          <Avatar className={classes.avatar}></Avatar>
          <p className={classes.avatarBorder}></p>
        </Box>
      </Box>
    </MatDialog>
  );
}

这里,Box 组件使用了 classes.avatarContainer 类名,就会应用 avatarContainer 对应的样式(实际上 Mui 编译的时候会生成一个随机类名分给 Box 组件的 class 属性,并且会生成对应的 css 样式然后会在加载该组件的时候加载该样式,因此该组件运行的时候就会使用该样式),p 组件使用了 avatarBorder 类名,该类名对应的样式里面使用了主题配置theme.palette.success.main,这就是 makeStyles 使用全局主题属性值的例子。

sx 属性

个人认为 sx 属性是 Mui 非常优秀的设计,所有的 Mui 组件均可以使用 sx 属性,该属性用来定义 Mui 组件的样式,并且该属性的值中的 css 属性有很多种简写形式,非常好用, 比如:

// padding、margin等值都和主题中的间距(spacing)有关,比如{p: 2}代表padding的值为2*theme.spacing
// 下面以spacing默认为4处理

{*/ padding: 1 * 4 = 4px /*}
<Box sx={{p: 1}}></Box>
{*/ padding-left: 1 * 4 = 4px, padding-right: 1 * 4 = 4px /*}
<Box sx={{px: 1}}></Box>
{*/ padding-left: 1 * 4 = 4px /*}
<Box sx={{pl: 1}}></Box>
{*/ padding-top: 1 * 4 = 4px, padding-bottom: 1 * 4 = 4px /*}
<Box sx={{py: 1}}></Box>
{*/ padding-right: 1 * 4 = 4px, background-color: 主题色 /*}
<Box sx={{pr: 1, bgcolor: theme => theme.palette.primary.main}}></Box>

封装组件

上面大概介绍了 Mui 组件样式设置的方法,下面是一些常用的组件的封装

输入框(MatInput)
import { FormControl, InputBaseProps, SxProps, TextField } from "@mui/material";
import { memo } from "react";
import { useTranslation } from "react-i18next";
import { MatFormItemProps } from "../../../models/base.model";
import { isNull } from "../../../utils";

interface MatInputProps extends MatFormItemProps {
  multiline?: boolean;
  maxRows?: number;
  inputProps?: InputBaseProps["inputProps"];
  sx?: SxProps;
  fullWidth?: boolean;
}

export default memo(function MatInput(props: MatInputProps) {
  const { width, size = "small", fullWidth = true } = props;
  const { t } = useTranslation();
  const value = isNull(props.value) ? "" : props.value;

  return (
    <FormControl
      error={props.error}
      sx={{ maxWidth: width || 1 / 1 }}
      fullWidth={fullWidth}
    >
      <TextField
        {...props}
        size={size}
        label={props.label && t(props.label)}
        value={value}
      ></TextField>
    </FormControl>
  );
});
多选框(checkbox)
import { FormControlLabel, FormControl } from "@mui/material";
import Checkbox from "@mui/material/Checkbox";
import { MatFormItemProps } from "../../../models/base.model";
import { t } from "i18next";

export interface MatCheckboxProps extends MatFormItemProps<boolean> {}

export default function MatCheckbox(props: MatCheckboxProps) {
  return (
    <FormControl
      sx={{
        height: 1 / 1,
        display: "flex",
        justifyContent: "center",
        ...(props.sx || {}),
      }}
    >
      <FormControlLabel
        control={
          <Checkbox
            disabled={props.disabled}
            onBlur={props.onBlur}
            name={props.name}
            onChange={props.onChange}
            checked={props.value || false}
          />
        }
        label={t(props.label) as string}
      />
    </FormControl>
  );
}
纸片(chip)
import { alpha, Chip, styled } from "@mui/material";

const MatChip = styled(Chip)(({ theme }) => {
  return {
    backgroundColor: alpha(theme.palette.primary.main, 0.08),
    color: theme.palette.primary.main,
    fontSize: 14,
    paddingRight: 6,
  };
});

export default MatChip;
确认框(confirm)
import * as React from "react";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import { useDispatch, useSelector } from "react-redux";
import { selectConfirmConfig } from "../../../store/selectors";
import { closeConfirmAction } from "../../../store/actions/tools.action";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@mui/lab";
import { Box } from "@mui/material";
import Iconfont from "../tools/Iconfont";
import { makeStyles } from "@mui/styles";

const useStyle = makeStyles(() => ({
  warningIcon: {
    paddingTop: "42px",
    "& i": {
      height: "24px",
      lineHeight: "1",
      color: "orange",
      fontSize: "32px !important",
      marginLeft: "24px",
    },
  },
}));

export default function MatConfirm() {
  const config = useSelector(selectConfirmConfig);
  const dispatch = useDispatch();
  const [loading, setLoading] = React.useState(false);
  const { t } = useTranslation();
  const classes = useStyle();

  const handleClose = () => {
    if (config.onCancel) {
      config.onCancel();
    }
    setLoading(false);
    dispatch(closeConfirmAction());
  };

  const handleOk = () => {
    if (config.onOk) {
      const re = config.onOk();
      if (re instanceof Promise) {
        setLoading(true);
        re.then(() => {
          handleClose();
          setLoading(false);
        }).catch(() => {
          setLoading(false);
        });
      } else {
        // setLoading(false)
        handleClose();
      }
    } else {
      handleClose();
    }
  };

  const okBtnVariant = config.okBtnColor === "error" ? "text" : "contained";

  return (
    <Box>
      <Dialog
        open={config.open || false}
        onClose={handleClose}
        aria-labelledby="alert-dialog-title"
        aria-describedby="alert-dialog-description"
      >
        <Box sx={{ display: "flex" }}>
          {config.showWarningIcon && (
            <Box className={classes.warningIcon}>
              <Iconfont mr={0} icon="ic_alert"></Iconfont>
            </Box>
          )}
          <Box>
            <DialogTitle id="alert-dialog-title">
              {t(config.title) as string}
            </DialogTitle>
            <DialogContent sx={{ minWidth: 380 }}>
              {config.customContent ? (
                config.content
              ) : (
                <DialogContentText id="alert-dialog-description">
                  {t(config.content as string)}
                </DialogContentText>
              )}
            </DialogContent>
            <DialogActions>
              {config.showCancelButton && (
                <Button onClick={handleClose}>
                  {t(config.cancelText || "common.cancel")}
                </Button>
              )}
              <LoadingButton
                disabled={config.okBtnDisabled}
                variant={okBtnVariant}
                loading={loading}
                onClick={handleOk}
                autoFocus
                color={config.okBtnColor || "primary"}
              >
                {t(config.okText || "common.confirm")}
              </LoadingButton>
            </DialogActions>
          </Box>
        </Box>
      </Dialog>
    </Box>
  );
}

// 使用 挂载在App组件 然后通过redux控制内容等等
/**
 * @param {ConfirmConfigData} config
 * @description 对话确认框 用showWarningIcon来控制是否展示警告图表
 */
export function $confirm(config: ConfirmConfigData) {
  // 给定默认值
  const {
    showWarningIcon = true,
    showCancelButton = true,
    okText = "common.confirm",
    okBtnColor = "primary",
    customContent = false,
    okBtnDisabled = false,
  } = config;
  store.dispatch(
    openConfirmAction({
      ...config,
      open: true,
      showWarningIcon,
      showCancelButton,
      okText,
      okBtnColor,
      customContent,
      okBtnDisabled,
    })
  );
}
/**
 * @param {ConfirmConfigData} config
 * @description 信息提示确认框
 */
export function $info(config: ConfirmConfigData) {
  store.dispatch(
    openConfirmAction({
      ...config,
      open: true,
      showCancelButton: false,
      okText: "common.confirm",
    })
  );
}
下拉菜单(dropdown)
import * as React, { ReactNode } from "react";
import MenuItem from "@mui/material/MenuItem";
import { Box, Divider, SxProps, Typography } from "@mui/material";
import MoreVertIcon from "@mui/icons-material/MoreVert";
// import { OperationMenu } from "../../../models/base.model";
import Iconfont from "../tools/Iconfont";
import { t } from "i18next";
import { StyledMenu } from "../styled/StyledMenu";
import TextButton from "./TextButton";

// 操作按钮
export class OperationMenu<T = any> {
  public customStartComponent?: ReactNode;
  constructor(
    public action: T,
    public title: string,
    public icon?: any,
    public showDivider?: boolean,
    public disabled?: boolean
  ) {}
}
/**
 *
 * @interface MatDropdownProps
 * @template T menu的Action的类型
 */
export interface MatDropdownProps<T> {
  menus: OperationMenu<T>[];
  selected?: T;
  onMenuClick?: (action: T) => void;
  // dropDown组件的触发组件  可以自定义 如果不自定义就是默认的TextButton(在tsx里面可以看到)
  trigger?: React.ReactNode;
  dividerKeys?: T[];
  sx?: SxProps;
  disabled?: boolean;
}

/**
 *
 * @export
 * @template T menu的Action的类型
 * @param {MatDropdownProps<T>} props
 * @returns
 */
export default function MatDropdown<T>(
  props: React.PropsWithChildren<MatDropdownProps<T>>
) {
  const { menus } = props;
  const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
  const open = Boolean(anchorEl);

  const handleClick = (event: React.MouseEvent<HTMLElement>) => {
    if (!props.disabled) {
      setAnchorEl(event.currentTarget);
    }
  };
  const handleClose = () => {
    setAnchorEl(null);
  };

  const onMenuClick = (e) => {
    if (props.onMenuClick) {
      props.onMenuClick(e);
    }
    handleClose();
  };

  return (
    <Box sx={{ pl: 2, ...props.sx }}>
      {props.trigger ? (
        <Box onClick={handleClick}>{props.trigger}</Box>
      ) : (
        // 如果没有trigger这个props就用默认的TextButton
        <TextButton
          className={props.disabled ? "btn-disabled" : ""}
          onClick={handleClick}
          sx={{ minWidth: "auto", borderRadius: 2 }}
        >
          <MoreVertIcon
            sx={{
              transform: "rotate(90deg)",
              color: (theme) => theme.palette.action.active,
            }}
          />
        </TextButton>
      )}
      {!props.disabled && (
        <StyledMenu
          sx={{ maxHeight: 450 }}
          MenuListProps={{
            "aria-labelledby": "demo-customized-button",
          }}
          anchorEl={anchorEl}
          open={open}
          onClose={handleClose}
        >
          {props.children}
          {menus?.map((menu, index) => (
            <Box key={index}>
              <MenuItem
                disabled={menu.disabled}
                sx={{ p: 1 }}
                onClick={() => onMenuClick(menu.action)}
                selected={props.selected === menu.action}
                disableRipple
              >
                {menu.customStartComponent}
                {menu.icon && (
                  <Iconfont fontSize={20} icon={menu.icon} mr={1}></Iconfont>
                )}
                <Typography sx={{ ml: 1 }}>{t(menu.title)}</Typography>
              </MenuItem>
              {menu.showDivider && (
                <Divider sx={{ m: "0 !important" }}></Divider>
              )}
            </Box>
          ))}
        </StyledMenu>
      )}
    </Box>
  );
}
密码框(password)
import { Visibility, VisibilityOff } from "@mui/icons-material";
import {
  FormHelperText,
  IconButton,
  InputAdornment,
  InputLabel,
  OutlinedInput,
  FormControl,
} from "@mui/material";
import { memo, PropsWithChildren, useState } from "react";
import { useTranslation } from "react-i18next";

export default memo(function MatPassword(
  props: PropsWithChildren<any> = { onChange() {}, onBlur() {} }
) {
  const [showPassword, setShowPwd] = useState<boolean>(false);
  const { t } = useTranslation();

  const handleClickShowPassword = () => {
    setShowPwd(!showPassword);
  };

  const handleMouseDownPassword = (
    event: React.MouseEvent<HTMLButtonElement>
  ) => {
    event.preventDefault();
  };

  return (
    <FormControl
      size={props.size || "small"}
      sx={{ maxWidth: props.width || 1 / 1 }}
      fullWidth
    >
      <p>{props.error}</p>
      <InputLabel error={props.error}>{t(props.label)}</InputLabel>
      <OutlinedInput
        disabled={props.disabled}
        value={props.value}
        error={props.error}
        name={props.name}
        onChange={props.onChange}
        onBlur={props.onBlur}
        type={showPassword ? "text" : "password"}
        endAdornment={
          <InputAdornment position="end">
            <IconButton
              size="small"
              aria-label="toggle password visibility"
              onClick={handleClickShowPassword}
              onMouseDown={handleMouseDownPassword}
              edge="end"
            >
              {showPassword ? <VisibilityOff /> : <Visibility />}
            </IconButton>
          </InputAdornment>
        }
        label={props.label && t(props.label)}
      />
      <FormHelperText error={props.error}>
        <span>{props.helperText}</span>
      </FormHelperText>
    </FormControl>
  );
});
进度条(progress)
import { Box, LinearProgress, Typography } from "@mui/material";
import { selectGreyColor } from "../../../utils/selectors";

export interface MatProgressProps {
  progress: number;
  color?: "primary" | "success" | "warning";
  height?: number;
  label?: string;
}

export default function MatProgress(props: MatProgressProps) {
  const value = (props.progress || 0).toFixed(2);
  const { height = 8, color = "primary", label } = props;

  const defaultLabel = value + "%";

  return (
    <Box className="flex-box-start">
      <Box sx={{ flex: 1 }}>
        <LinearProgress
          color={color}
          sx={{ height, borderRadius: "5px", mr: 1 }}
          variant="determinate"
          value={props.progress}
        />
      </Box>
      <Typography color={selectGreyColor}>{label || defaultLabel}</Typography>
    </Box>
  );
}
表格组件(table)
import Box from "@mui/material/Box";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import Paper from "@mui/material/Paper";
import Checkbox from "@mui/material/Checkbox";
import { Pagination, Typography } from "@mui/material";
import { EnhancedTableHead, Order } from "./EnhancedTableHead";
import { BaseData, Id } from "../../../models/base.model";
import { TableCellProps } from "@mui/material";
import EmptyData from "./EmptyData";
import { ReactNode, useEffect, useMemo, useState } from "react";
import { commonBoxShadow, isNull, parseIdObject, timeFormat } from "../../../utils";
import TableLoading from "./TableLoading";
import { useCallback } from "react";
import AddDescription from "../tools/AddDescription";
import StyledRow from "../styled/StyledRow";
import { useObSelector } from "../../../hooks/useAuth";
import { SortOrder } from "../../../models/request.model";

export interface HeadCell {
  id: string;
  label: string;
}

export enum TableCellPadding {
  NORMAL,
  NONE,
  CHECKBOX,
}

type StickyType = "left" | "right";
// 普通的tableColumns
export class TableColumns<T extends BaseData<string | Id> = BaseData> {
  public sortKey: string;
  constructor(
    public key: string,
    public title: string,
    public width?: number,
    public customCell?: (data: T, index?: number) => React.ReactNode | string,
    public keepWidth?: boolean,
    public sortAble?: boolean | string
  ) {
    if (typeof sortAble === "string") {
      this.sortKey = sortAble;
    } else {
      this.sortKey = this.key;
    }
  }
  public padding = "normal";
}

export class TableSortColumns<T extends BaseData<string | Id> = BaseData> extends TableColumns<T> {
  constructor(
    public key: string,
    public title: string,
    public sortAble?: boolean | string,
    public width?: number,
    public customCell?: (data: T, index?: number) => React.ReactNode | string,
    public keepWidth?: boolean
  ) {
    super(key, title, width, customCell, keepWidth, sortAble);
  }
}
// 操作按钮的tableColumns
export class TableOperationColumns<T extends BaseData<string | Id> = BaseData> extends TableColumns<T> {
  constructor(
    public key: string,
    public title: string = "common.operation",
    public sticky?: StickyType,
    public width?: number,
    public customCell?: (data: T, index?: number) => React.ReactNode | string,
    public keepWidth?: boolean,
    public sortAble?: boolean
  ) {
    super(key, title, width, customCell, keepWidth, sortAble);
  }
  public padding = "none";
}
// 解析时间的tableColumns
export class TableDateColumns<T extends BaseData<string | Id> = BaseData> extends TableColumns<T> {
  constructor(
    public key: string,
    public title: string,
    public width: number = 175,
    public customCell?: (data: T, index?: number) => React.ReactNode | string,
    public keepWidth?: boolean,
    public sortAble?: boolean
  ) {
    super(key, title, width, customCell, keepWidth, sortAble);
    if (!customCell) {
      this.customCell = (data) => <Typography>{timeFormat(data[key]) || "-"}</Typography>;
    }
  }
  public padding = "normal";
}
// “描述”字段的tableColumns
export class TableDescriptionColumns<T extends BaseData<string | Id> = BaseData> extends TableColumns<T> {
  constructor(
    private onOk: (data: T) => Promise<any>,
    public width?: number,
    public customCell?: (data: T, index?: number) => React.ReactNode | string,
    public key: string = "description",
    public title: string = "common.description",
    public keepWidth?: boolean,
    public sortAble?: boolean
  ) {
    super(key, title, width, customCell, keepWidth, sortAble);
    if (!customCell) {
      this.customCell = (data) => {
        const value = data[key];
        const onAddDescOk = (description: string) => {
          const newData: T = { ...data, description };
          // 这里直接修改data里面的description字段
          return this.onOk(newData);
        };
        return (
          <Box sx={{ pl: 2 }}>
            <AddDescription value={value} onOk={onAddDescOk}></AddDescription>
          </Box>
        );
      };
    }
    this.padding = "none";
  }
}

export class TableStickyColumns<T extends BaseData<string | Id> = BaseData> extends TableColumns<T> {
  constructor(
    public sticky: StickyType,
    public key: string,
    public title: string,
    public width?: number,
    public customCell?: (data: T, index?: number) => React.ReactNode | string,
    public keepWidth?: boolean,
    public sortAble?: boolean
  ) {
    super(key, title, width, customCell, keepWidth, sortAble);
  }
}

export function getColumnStickyStatus<T extends BaseData<Id | string>>(column: TableColumns<T>, showSelect: boolean) {
  if (column instanceof TableStickyColumns || column instanceof TableOperationColumns) {
    if (column.sticky === "left") {
      if (showSelect) {
        return "sticky-cell sticky-left-with-select";
      } else {
        return "sticky-cell sticky-left";
      }
    } else if (column.sticky === "right") {
      return "sticky-cell sticky-right";
    }
    return column.sticky;
  } else {
    return null;
  }
}

// 这里的泛型需要约束为BaseData类型  为了兼容性BaseData类型的泛型参数可能是Id或者string  因为整个系统中的
export interface CommonTableProps<T extends BaseData<string | Id> = BaseData> extends React.PropsWithChildren<any> {
  rows: T[];
  totalPages?: number;
  columns: TableColumns<T>[];
  orderAble?: boolean;
  sortProperty?: string;
  showSelect?: boolean;
  selected?: T[];
  width?: number;
  height?: number | string;
  minHeight?: number | string;
  pageSize?: number;
  hideBoxShadow?: boolean;
  pageChange?: (page: number) => void | Promise<any>;
  onSelectChange?(selected: T[]): void;
  onSortChange?(order: SortOrder, property: string): void;
  // 选择框是否可选的规则
  selectDisableRule?(data: T): boolean;
  children?: ReactNode;
  footerPy?: number;
  pagination?: boolean;
  loading?: boolean;
  page?: number;
  clearSelectOnPageChange?: boolean;
}

/**
 *
 *
 * @export
 * @template T
 * @param {CommonTableProps<T>} props
 * @description table的分页有两种模式 一种是父级组件控制,通常是服务端分页的情况,另一种是本组件自己维护分页,通过是否传入pageChange方法这个prop来确定
 * @returns
 */
export default function CommonTable<T extends BaseData<string | Id> = BaseData>(props: CommonTableProps<T>) {
  let { rows = [], orderAble, totalPages, columns, children, width, height, pageSize = 10, pagination = true, pageChange, onSortChange, clearSelectOnPageChange, selectDisableRule } = props;
  // 如果没有传入totalPages就自己计算页数
  if (totalPages === undefined) {
    totalPages = rows.length === 0 ? 0 : Math.ceil(rows.length / pageSize);
  }

  const [order, setOrder] = useState<Order>("desc");
  // const [orderBy, setOrderBy] = useState<string>(columns ? columns.find((v) => v.sortAble)?.key : null);
  const [orderBy, setOrderBy] = useState<string>(props.sortProperty || null);
  const [selected, setSelected] = useState<T[]>(props.selected || []);
  const [page, setPage] = useState(props.page || 0);
  const isOb = useObSelector();

  const computedShowSelect = useMemo(() => {
    return props.showSelect && !isOb;
  }, [isOb, props.showSelect]);

  // 监听页码变化
  const onPageChange = useCallback(
    (event: any, page: number) => {
      if (pageChange) {
        // 如果是有自定义pageChange事件则代表数据是来自分页查询数据 因此在pageChange的时候需要重置selected
        if (props.page !== page - 1) {
          pageChange(page - 1);
          if (clearSelectOnPageChange) {
            setSelected([]);
          }
        }
      } else {
        setPage(page - 1);
      }
    },
    [pageChange, props.page, clearSelectOnPageChange]
  );

  // 如果有传入pageChange  rows就接直接使用  否则需要利用page进行分页
  const displayedRows = useMemo(() => {
    if (pageChange) {
      return rows;
    } else {
      return rows.slice(page * pageSize, page * pageSize + pageSize);
    }
  }, [page, pageSize, rows, pageChange]);

  useEffect(() => {
    if (displayedRows.length <= 0 && rows.length) {
      onPageChange(null, 1);
    }
  }, [displayedRows, onPageChange, rows]);

  // 随时注意同步props传过来的selected数组
  useEffect(() => {
    if (props.selected) {
      setSelected(props.selected);
    }
  }, [props.selected]);

  // 排序
  const handleRequestSort = (event: React.MouseEvent<unknown>, property: string) => {
    const isDesc = orderBy === property && order === "desc";
    const sortOrder = isDesc ? "asc" : "desc";
    setOrder(sortOrder);
    setOrderBy(property);
    console.log(property, isDesc);
    onSortChange && onSortChange(sortOrder, property);
  };

  const isSelected = (data: T) => selected.some((v) => parseIdObject(v) === parseIdObject(data));
  // 被选中的属于当前页的
  const selectedOfThisPage = useMemo(() => {
    const selectedIds = selected.map((v) => parseIdObject(v));
    return displayedRows.filter((v) => selectedIds.includes(parseIdObject(v)));
  }, [selected, displayedRows]);
  //
  const selectedIdsOfThisPage = useMemo<string[]>(() => selectedOfThisPage.map((v) => parseIdObject(v)), [selectedOfThisPage]);
  // 点击全选CheckBox的时候的操作
  const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
    if (event.target.checked) {
      const newSelecteds = displayedRows.map((n: T) => n).filter((sn) => !selectDisableRule || !selectDisableRule(sn));
      setSelected(Array.from(new Set([...newSelecteds, ...selected])));
      return;
    }
    setSelected(selected.filter((item) => !selectedIdsOfThisPage.includes(parseIdObject(item))));
  };

  // 如果props里面有pageChange参数 就由父组件自己控制分页  否则由该组件自己控制
  const currentPage = pageChange ? props.page || 0 : page;

  const handleSelect = (event: React.ChangeEvent<HTMLInputElement>, data: T) => {
    if (event.target.checked) {
      setSelected([data, ...selected]);
    } else {
      setSelected(selected.filter((v) => parseIdObject(v) !== parseIdObject(data)));
    }
  };

  // 监听selected的变化
  useEffect(() => {
    if (props.onSelectChange) {
      props.onSelectChange(selected);
    }
    // eslint-disable-next-line
  }, [selected]);

  // Avoid a layout jump when reaching the last page with empty rows.
  // const emptyRows = page > 0 ? Math.max(0, (1 + page) * pageSize - rows.length) : 0;

  const maxTableHeight = useMemo(() => {
    if (height) {
      return height;
    } else if (pagination) {
      return "calc(100% - 68px)";
    } else {
      return "100%";
    }
  }, [height, pagination]);

  const ableToSelectRows = useMemo(() => {
    return displayedRows.filter((item) => !selectDisableRule || !selectDisableRule(item));
  }, [displayedRows, selectDisableRule]);

  return (
    <Box sx={{ width: "100%", height: 1 / 1 }}>
      <Paper
        sx={{
          width: "100%",
          height: 1 / 1,
          boxShadow: props.hideBoxShadow ? "none !important" : commonBoxShadow,
          position: "relative",
          // overflow: "hidden",
          boxSizing: "border-box",
          minHeight: props.minHeight,
        }}
      >
        <TableContainer sx={{ maxHeight: maxTableHeight, height: 1 / 1, minHeight: props.minHeight }}>
          <Table stickyHeader={true} sx={{ minWidth: width }} aria-labelledby="tableTitle" size="medium">
            {rows.length >= 0 && (
              <EnhancedTableHead<T>
                numSelected={selectedOfThisPage.length}
                order={order}
                orderBy={orderBy}
                onSelectAllClick={handleSelectAllClick}
                onRequestSort={handleRequestSort}
                rowCount={ableToSelectRows.length}
                headerCells={columns}
                orderAble={orderAble}
                showSelect={computedShowSelect}
              />
            )}
            <TableBody>
              {displayedRows?.map((row, index) => {
                const isItemSelected = isSelected(row);
                return (
                  <StyledRow
                    hover
                    // onClick={(event) => handleSelect(event, row)}
                    role="checkbox"
                    aria-checked={isItemSelected}
                    tabIndex={-1}
                    key={parseIdObject(row)}
                    selected={isItemSelected}
                    sx={{ pb: 2 }}
                  >
                    {computedShowSelect ? (
                      <TableCell className="sticky-cell" sx={{ border: "none" }} onClick={() => {}} padding="checkbox">
                        <Checkbox disabled={selectDisableRule && selectDisableRule(row)} onChange={(event) => handleSelect(event, row)} color="primary" checked={isItemSelected} />
                      </TableCell>
                    ) : null}
                    {columns?.map((column) => (
                      <TableCell
                        className={getColumnStickyStatus<T>(column, computedShowSelect)}
                        sx={{ border: "none" }}
                        width={column.width}
                        padding={column.padding as TableCellProps["padding"]}
                        key={column.key}
                      >
                        <Box sx={{ width: column.width, maxWidth: column.width }}>
                          {!isNull(column.customCell) ? column.customCell(row, index) || "-" : <Typography className="word-ellipsis">{isNull(row[column.key]) ? "-" : row[column.key]}</Typography>}
                        </Box>
                      </TableCell>
                    ))}
                  </StyledRow>
                );
              })}
            </TableBody>
          </Table>
          {rows.length <= 0 && !props.loading && (children || <EmptyData pt={4} />)}
        </TableContainer>
        {pagination && (
          <Box sx={{ p: 1.5, py: props.footerPy || 2, opacity: rows.length ? 1 : 0, pt: props.footerPy || 2.5 }}>
            <Pagination sx={{ display: "flex", justifyContent: "flex-end" }} count={totalPages} color="primary" onChange={onPageChange} page={currentPage + 1} />
          </Box>
        )}
        {props.loading && <TableLoading></TableLoading>}
      </Paper>
    </Box>
  );
}

单选组(radio-group)
import Radio from "@mui/material/Radio";
import RadioGroup from "@mui/material/RadioGroup";
import FormControlLabel from "@mui/material/FormControlLabel";
import FormControl from "@mui/material/FormControl";
import { Box } from "@mui/system";
import { MatFormItemProps } from "../../../models/base.model";
import { useTranslation } from "react-i18next";
import { FormLabel } from "@mui/material";
import MessageTip from "../../regist-template/options/MessageTip";

export interface MatRadioOption<T> {
  label: string;
  value: T;
  tip?: string;
}

export interface MatRadioProps<T> extends MatFormItemProps<T> {
  options: MatRadioOption<T>[];
  row?: boolean;
  labelwidth?: number;
  optionlabelwidth?: number;
}

export default function MatRadioGroup<T>(props: MatRadioProps<T>) {
  const { t } = useTranslation();
  const { row = true } = props;

  return (
    <FormControl
      sx={{
        display: "flex",
        flexDirection: "row",
        alignItems: row ? "center" : "",
        flexWrap: "wrap",
      }}
    >
      <Box sx={{ width: props.labelwidth || 160 }}>
        <FormLabel sx={{ lineHeight: "36px" }}>{t(props.label)}</FormLabel>
      </Box>
      <RadioGroup {...props} value={props.value} row={row} name={props.name}>
        {props.options.map((option, index) => (
          <Box key={index} className="flex-box-start flex-wrap">
            <FormControlLabel
              sx={{ width: props.optionlabelwidth || 250 }}
              value={option.value}
              control={<Radio disabled={props.disabled} />}
              label={t(option.label) as string}
            />
            {option.tip && <MessageTip content={option.tip}></MessageTip>}
          </Box>
        ))}
      </RadioGroup>
    </FormControl>
  );
}
搜索框(search-input)
import {
  alpha,
  Icon,
  InputBaseProps,
  styled,
  TextField,
  SxProps,
} from "@mui/material";
import { t } from "i18next";
import debounce from "debounce";
import { ChangeEvent, Fragment, memo, useMemo, useState } from "react";
import Iconfont from "../tools/Iconfont";

const SytledInput = styled(TextField)(({ theme }) => {
  return {
    "& .MuiOutlinedInput-root": {
      paddingRight: 4,
    },
    backgroundColor: alpha(theme.palette.primary.main, 0.04),
    "&:hover fieldset": {
      borderColor: theme.palette.primary.main + " !important",
    },
  };
});

interface MatSearchInputProps {
  value: string;
  onChange(text: string): void;
  placeholder?: string;
  width?: number;
  fullWidth?: boolean;
  inputProps?: InputBaseProps["inputProps"];
  sx?: SxProps;
}

export default memo(function MatSearchInput(props: MatSearchInputProps) {
  const [value, setValue] = useState(props.value || "");
  const onChange = (event: ChangeEvent<HTMLInputElement>) => {
    setValue(event.target.value);
    emitChangeByProps(event);
  };

  // 这里用debounce做防抖  做性能优化
  const emitChangeByProps = useMemo(() => {
    return debounce(function (event: ChangeEvent<HTMLInputElement>) {
      if (props.onChange) props.onChange(event.target.value);
    }, 300);
  }, [props]);

  const clearContent = () => {
    setValue("");
    props.onChange("");
  };

  return (
    <Fragment>
      <SytledInput
        {...props}
        style={{ width: props.fullWidth ? "100%" : props.width || 250 }}
        InputProps={{
          startAdornment: (
            <Icon sx={{ mr: 1, color: (theme) => theme.palette.action.active }}>
              search
            </Icon>
          ),
          endAdornment: !!value && (
            <Iconfont
              onClick={clearContent}
              icon="ic_chip_close"
              mr={0.5}
              fontSize={16}
              style={{ cursor: "pointer" }}
            ></Iconfont>
          ),
        }}
        placeholder={props.placeholder ? t(props.placeholder) : "Search..."}
        color="primary"
        size="small"
        value={value}
        onChange={onChange}
      ></SytledInput>
      <input type="text" className="not-to-show" />
    </Fragment>
  );
});
选择框(select)
import {
  Box,
  FormControl,
  InputLabel,
  MenuItem,
  Select,
  SxProps,
  Typography,
} from "@mui/material";
import { nanoid } from "nanoid";
import React, { memo } from "react";
import { useTranslation } from "react-i18next";
import { MatFormItemProps } from "../../../models/base.model";
import { isNull } from "../../../utils";
import { selectGreyColor } from "../../../utils/selectors";

const MenuProps = {
  PaperProps: {
    style: {
      maxHeight: 280,
      // width: 250,
    },
  },
};

export interface MatSelectOption<
  T = string | number | readonly string[] | any
> {
  value: T;
  label: string;
}

export class MatSelectOptionFactory implements MatSelectOption {
  constructor(public value: MatSelectOption["value"], public label: string) {}
}

export interface MatSelectProps
  extends MatFormItemProps<MatSelectOption["value"]> {
  options: MatSelectOption[];
  size?: "small" | "medium";
  sx?: SxProps;
}

const initNoneValue = "@@INIT" + nanoid();

export default memo(function MatSelect(props: MatSelectProps) {
  const { t } = useTranslation();

  // 初始化默认值  根据是否传入placeholder来判断
  const defaultValue = props.placeholder ? initNoneValue : "";

  const value = isNull(props.value) ? defaultValue : props.value;
  const optionHeight = props.size === "medium" ? 48 : 40;

  return (
    <FormControl
      size={props.size || "small"}
      fullWidth
      sx={{ maxWidth: props.width || 1 / 1, ...(props.sx || {}) }}
    >
      <InputLabel>{t(props.label)}</InputLabel>
      <Select
        onChange={props.onChange}
        disabled={props.disabled}
        onBlur={props.onBlur}
        name={props.name}
        variant="outlined"
        error={props.error}
        MenuProps={MenuProps}
        labelId="demo-simple-select-label"
        value={value}
        label={props.label ? t(props.label) : undefined}
      >
        {props.placeholder && (
          <MenuItem sx={{ display: "none" }} disabled value={initNoneValue}>
            <Typography color={selectGreyColor}>
              {t(props.placeholder)}
            </Typography>
          </MenuItem>
        )}
        {!(props.options?.length > 0) && (
          <Box sx={{ px: 2, py: 1 }}>{t("common.noDataFound")}</Box>
        )}
        {props.options?.map((option, index) => (
          <MenuItem
            key={index}
            sx={{ height: optionHeight }}
            value={option.value}
          >
            {t(option.label)}
          </MenuItem>
        ))}
      </Select>
    </FormControl>
  );
});
滑动输入框(slider)
import {
  alpha,
  Box,
  FormControl,
  FormControlLabel,
  Slider,
  styled,
  Theme,
} from "@mui/material";
import { makeStyles } from "@mui/styles";
import { t } from "i18next";
import { memo } from "react";
import { MatFormItemProps } from "../../../models/base.model";

export interface MatSliderProps extends MatFormItemProps<number> {}

const StyledFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
  "	.MuiFormControlLabel-label": {
    whiteSpace: "nowrap",
    display: "inline-block",
    marginRight: 16,
  },
}));

const useStyles = makeStyles((theme: Theme) => {
  return {
    valueContainer: {
      width: 45,
      fontSize: 14,
      height: 32,
      borderRadius: 16,
      backgroundColor: alpha(theme.palette.primary.main, 0.08),
      color: theme.palette.primary.main,
    },
  };
});

export default memo(function MatSlider(props: MatSliderProps) {
  const classes = useStyles();
  return (
    <FormControl
      fullWidth
      sx={{
        flexDirection: "row",
        maxWidth: props.width || 1 / 1,
        height: 1 / 1,
        alignItems: "center",
        pl: 0,
      }}
    >
      {/* <Slider size="small" defaultValue={70} valueLabelDisplay="auto" /> */}
      <StyledFormControlLabel
        sx={{ whiteSpace: "nowrap", mr: 1, flex: 1, ml: 0 }}
        labelPlacement="start"
        label={t(props.label) as string}
        control={<Slider size="small" {...props} valueLabelDisplay="auto" />}
      ></StyledFormControlLabel>
      <Box className={classes.valueContainer + " flex-box"}>
        {props.value + "%"}
      </Box>
    </FormControl>
  );
});
信息框(message)
import { Alert, AlertProps, Snackbar } from "@mui/material";
import { t } from "i18next";
import { useSelector, useDispatch } from "react-redux";
import { closeSnackBar } from "../../../store/actions/tools.action";
import { selectSnackBarConfig } from "../../../store/selectors";

export interface MatSnackBarProps {
  duration: number;
  type: AlertProps["severity"];
}

export default function MatSnackBar() {
  const config = useSelector(selectSnackBarConfig);
  const dispatch = useDispatch();

  const onSnackbarClose = () => {
    dispatch(closeSnackBar());
  };

  return (
    <Snackbar
      anchorOrigin={{ vertical: "top", horizontal: "center" }}
      open={config.open}
      onClose={onSnackbarClose}
      autoHideDuration={config.duration * 1000}
    >
      <Alert severity={config.type} sx={{ width: "100%" }}>
        {t(config.content)}
      </Alert>
    </Snackbar>
  );
}

// 使用 一开始就挂载在App组件 然后通过store控制显隐以及内容 以及type等等
export function message(
  type = "info" as AlertProps["severity"],
  content = "",
  duration = 3
) {
  store.dispatch(openSnackBar({ open: true, type, content, duration }));
}
/**
 *
 * @param {string} content 显示的内容 可以直接写翻译参数
 * @param {number} [duration=3] 显示的时间 默认3秒
 */
export const $message = {
  success(content: string, duration = 3) {
    message("success", content, duration);
  },
  info(content: string, duration = 3) {
    message("info", content, duration);
  },
  warning(content: string, duration = 3) {
    message("warning", content, duration);
  },
  error(content: string, duration = 3) {
    message("error", content, duration);
  },
};
开关(switch)
import * as React from "react";
import FormControlLabel, {
  FormControlLabelProps,
} from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
import { MatFormItemProps } from "../../../models/base.model";
import { useTranslation } from "react-i18next";
import { Box, Tooltip, Typography } from "@mui/material";
import Iconfont from "../tools/Iconfont";
// interface MatSwiy

export interface MatSwitchProps extends MatFormItemProps<boolean> {
  labelplacement?: FormControlLabelProps["labelPlacement"];
  tip?: string;
}

export default React.memo(function MatSwitch(props: MatSwitchProps) {
  const { t } = useTranslation();

  const renderLabel = () => {
    if (props.tip) {
      return (
        <Typography className="flex-box">
          {t(props.label)}
          <Tooltip title={t(props.tip)} sx={{ ml: 2, mt: 0.5 }}>
            <Box>
              <Iconfont
                color="#f3a15d"
                icon={"ic_alert"}
                fontSize={16}
                mr={1}
              ></Iconfont>
            </Box>
          </Tooltip>
        </Typography>
      );
    } else {
      return t(props.label);
    }
  };

  return (
    <FormControlLabel
      labelPlacement={props.labelplacement || "end"}
      control={<Switch checked={props.value} {...props} />}
      label={renderLabel()}
    />
  );
});
自定义按钮(button)
import { alpha, Button, styled } from "@mui/material";

const TextButton = styled(Button)(({ theme }) => {
  const color = alpha(theme.palette.primary.main, 0.08);
  const hoveredColor = alpha(theme.palette.primary.main, 0.16);
  const disabledColor = alpha(theme.palette.action.disabled, 0.11);
  return {
    backgroundColor: color,
    "&:hover": {
      backgroundColor: hoveredColor,
    },
    "&.btn-disabled": {
      backgroundColor: disabledColor,
      cursor: "not-allowed",
      opacity: 0.4,
    },
    "&.btn-disabled:active": {
      backgroundColor: disabledColor,
      cursor: "not-allowed",
      opacity: 0.4,
    },
  };
});

export default TextButton;
 类似资料: