Components

coderljw 2024-10-13 React
  • React
  • Components
大约 6 分钟

# 1. ProTable

  • 基于 Ant-Pro 封装,新增删除最后一项自动翻页、参数字段名称转换,修复 ProTable 清空时为空字符串和空数组的 BUG。
import React, { useRef } from 'react'
import { ProTable as AntProTable } from '@ant-design/pro-components'
import type { ActionType } from '@ant-design/pro-components'
import type { ProTableProps } from '@ant-design/pro-components'
import type { IProps, ParamsType, RequiredPick } from './interface'
import { generateParams, safeGetValue } from './utils'

const defaultConvertParams = {
  current: 'current',
  pageSize: 'pageSize',
  data: 'data',
  total: 'total',
  success: 'success',
}

const ProTable = <
  DataType extends Record<string, any>,
  Params extends ParamsType = ParamsType,
  ValueType = 'text',
>({
  request,
  actionRef,
  convertParams,
  filterNullValues = true,
  ...rest
}: ProTableProps<DataType, Params, ValueType> &
  RequiredPick<ProTableProps<DataType, Params, ValueType>, 'request'> &
  IProps) => {
  const innerActionRef = useRef<ActionType>()
  const proActionRef = (actionRef as ReturnType<typeof useRef<ActionType>>) || innerActionRef

  return (
    <AntProTable<DataType, Params, ValueType>
      rowKey='id'
      cardBordered
      defaultSize='large'
      dateFormatter='string'
      actionRef={proActionRef}
      form={{ ignoreRules: false }}
      editable={{ type: 'multiple' }}
      pagination={{ defaultPageSize: 10 }}
      request={async (params, ...restParams) => {
        const mergeParams = { ...defaultConvertParams, ...convertParams }
        const { success, data, total } = mergeParams
        const gParams = generateParams(params, mergeParams, filterNullValues) as Params

        const res = await request(gParams, ...restParams)
        const safeData = safeGetValue<DataType[]>(res, data, [])

        // 删除最后一项自动翻页
        if (!safeData.length && (params.current || 1) > 1) {
          proActionRef.current!.pageInfo!.current -= 1
        }

        return {
          data: safeData,
          total: safeGetValue<number>(res, total, safeData.length),
          success: safeGetValue<boolean>(res, success, false),
        }
      }}
      {...rest}
    />
  )
}

export default React.memo(ProTable)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
  • utils
import type { MergeParams, Params } from './interface'

export const generateParams = (
  params: Params,
  mergeParams: MergeParams,
  filterNullValues: boolean,
): Params => {
  const { current, pageSize, ...rest } = params
  const filterParams = Object.entries(rest).reduce((acc, [key, value]) => {
    if ((typeof value === 'string' || Array.isArray(value)) && !value?.length) {
      return acc
    }

    return { ...acc, [key]: value }
  }, {})

  return {
    [mergeParams.current]: current,
    [mergeParams.pageSize]: pageSize,
    ...(filterNullValues ? filterParams : rest),
  }
}

export const safeGetValue = <T,>(
  source: Record<string, any>,
  prop: string,
  underwriteValue?: any,
): T => {
  const keys = prop.split('.')
  let res: any = source

  keys.forEach((k) => {
    const curData = res?.[k]
    res = curData || underwriteValue
  })

  return res
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
  • interface
export type ParamsType = Record<string, any>
export type RequiredPick<T, K extends keyof T> = Required<Pick<T, K>>
export type FilterUndefined<T> = T extends undefined ? never : T

export type IProps = Partial<{
  /**
   * @name 转换参数
   * @desc 可链式取值
   * @example { total: count }
   * @example { data: data.list }
   */
  convertParams: Partial<{
    current: string
    pageSize: string
    data: string
    total: string
    success: string
  }>
  /**
   * @name 过滤空值
   * @desc 过滤ProTable清空时,出现的空字符串与空数组
   */
  filterNullValues: boolean
}>

export type Params = ParamsType & {
  pageSize?: number | undefined
  current?: number | undefined
  keyword?: string | undefined
}

export type MergeParams = FilterUndefined<Required<IProps['convertParams']>>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

# 2. Upload

  • UploadAvatar
import React, { useState } from 'react'
import { LoadingOutlined, PlusOutlined } from '@ant-design/icons'
import { Upload } from 'antd'
import type { UploadProps } from 'antd'
import { customRequest, generateBeforeUpload, getBase64 } from './utils'

export type UploadAvatarProps = UploadProps & {
  /**
   * @name 图片链接
   */
  value?: string
  onChange?: (url: string) => void
  /**
   * @name 上传图片类型
   * @default ['jpg', 'png', 'webp']
   */
  fileType?: string[] | null
  /**
   * @name 限制上传图片大小
   * @desc 单位:M
   * @default 5
   */
  fileSize?: number | null
}

const UploadAvatar: React.FC<UploadAvatarProps> = ({
  value,
  onChange,
  fileType = ['jpg', 'png', 'webp'],
  fileSize = 5,
  children,
  ...rest
}) => {
  const [loading, setLoading] = useState(false)
  const [imageUrl, setImageUrl] = useState<string | undefined>(value)

  const handleChange: UploadProps['onChange'] = async ({ file }) => {
    const { status, originFileObj, response } = file
    if (status === 'uploading') {
      setLoading(true)
    }

    if (status === 'done') {
      const url = await getBase64(originFileObj!)
      setLoading(false)
      setImageUrl(url)
      onChange?.(response)
    }
  }

  return (
    <Upload
      listType='picture-card'
      showUploadList={false}
      customRequest={customRequest}
      beforeUpload={generateBeforeUpload({ fileType, fileSize })}
      onChange={handleChange}
      {...rest}
    >
      {React.Children.toArray(children).length ? (
        React.cloneElement(children as React.ReactElement, { loading, imageUrl })
      ) : imageUrl ? (
        <img src={imageUrl} alt={imageUrl} style={{ width: '100%' }} />
      ) : (
        <div>
          {loading ? <LoadingOutlined /> : <PlusOutlined />}
          <div style={{ marginTop: 8 }}>Upload</div>
        </div>
      )}
    </Upload>
  )
}

export default React.memo(UploadAvatar)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
  • ProFormUploadAvatar
import React from 'react'
import type { ProFormItemProps } from '@ant-design/pro-components'
import { ProForm } from '@ant-design/pro-components'
import UploadAvatar from './UploadAvatar'
import type { UploadAvatarProps } from './UploadAvatar'

type ProFormUploadAvatarProps = ProFormItemProps & {
  fieldProps?: UploadAvatarProps
}

const ProFormUploadAvatar: React.FC<ProFormUploadAvatarProps> = ({
  fieldProps,
  children,
  ...rest
}) => {
  return (
    <ProForm.Item {...rest}>
      <UploadAvatar children={children} {...fieldProps} />
    </ProForm.Item>
  )
}

export default React.memo(ProFormUploadAvatar)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  • ProFormUploadButton
import React, { useState } from 'react'
import { ProFormUploadButton as AntProFormUploadButton } from '@ant-design/pro-components'
import type { ProFormUploadButtonProps as AntProFormUploadButtonProps } from '@ant-design/pro-components'
import { Modal } from 'antd'
import type { UploadProps } from 'antd'
import type { ModalProps } from 'antd'
import { saveAs } from 'file-saver'
import { customRequest, generateBeforeUpload, getBase64 } from './utils'

type ProFormUploadButtonProps = AntProFormUploadButtonProps & {
  /**
   * @name 上传图片类型
   * @default ['jpg', 'png', 'webp']
   */
  fileType?: string[] | null
  /**
   * @name 限制上传图片大小
   * @desc 单位:M
   * @default 5
   */
  fileSize?: number | null
  fieldProps?: UploadProps
  /**
   * @name 图片预览对话框
   */
  modalProps?: ModalProps
}

const ProFormUploadButton: React.FC<ProFormUploadButtonProps> = ({
  fileType = ['jpg', 'png', 'webp'],
  fileSize = 5,
  fieldProps,
  modalProps,
  ...rest
}) => {
  const [visible, setVisible] = useState(false)
  const [previewImg, setPreviewImg] = useState<string>()

  const onPreview: UploadProps['onPreview'] = async (file) => {
    if (file.type?.startsWith('image')) {
      if (!file.url && !file.preview) {
        file.preview = await getBase64(file.originFileObj!)
      }
      setPreviewImg(file.url || file.preview)
      setVisible(true)
    } else {
      saveAs(file.originFileObj as File, file.originFileObj?.name)
    }
  }

  const onChange: UploadProps['onChange'] = ({ fileList }) => {
    return fileList.map((file) => {
      if (!file.status) file.status = 'error'
      return file
    })
  }

  const beforeUpload = generateBeforeUpload({ fileType, fileSize })

  return (
    <>
      <AntProFormUploadButton
        max={1}
        fieldProps={{
          name: 'file',
          listType: 'picture-card',
          customRequest,
          beforeUpload,
          onPreview,
          onChange,
          ...fieldProps,
        }}
        {...rest}
      />

      <Modal
        visible={visible}
        footer={null}
        onCancel={() => setVisible(false)}
        width='50%'
        {...modalProps}
      >
        <img src={previewImg} alt={previewImg} style={{ width: '100%' }} />
      </Modal>
    </>
  )
}

export default React.memo(ProFormUploadButton)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
  • utils
import type { UploadProps } from 'antd'
import { message } from 'antd'
import { lookup } from 'mime-types'
import { UploadImage } from '@/services/pusheng/OtherService'

export const getBase64 = (file: File): Promise<string> => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.readAsDataURL(file)
    reader.onload = () => resolve(reader.result as string)
    reader.onerror = reject
  })
}

export const customRequest: UploadProps['customRequest'] = ({
  file,
  filename,
  onSuccess,
  onProgress,
  onError,
}) => {
  const formData = new FormData()
  formData.append(filename!, file)
  UploadImage(formData, {
    onUploadProgress: onProgress,
  })
    .then(({ data }) => onSuccess?.(data?.[0]))
    .catch(onError)
}

type GenerateBeforeUpload = (params?: {
  fileType?: string[] | null
  fileSize?: number | null
}) => UploadProps['beforeUpload']

export const generateBeforeUpload: GenerateBeforeUpload = ({ fileType, fileSize } = {}) => {
  return ({ type, size }) => {
    if (fileType?.length && !fileType.some((i) => type === lookup(i))) {
      message.warning(`仅支持${fileType.join('、')}图片类型`)
      return false
    }

    if (fileSize && size > fileSize * 1024 * 1024) {
      message.warning(`图片大小不能超过${fileSize}M`)
      return false
    }

    return true
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

# 3. Tinymce

import React from 'react'
import { Editor } from '@tinymce/tinymce-react'
import type { IAllProps } from '@tinymce/tinymce-react'
import { lookup } from 'mime-types'
import type { TinyMCE } from 'tinymce'
import { UploadImage } from '@/services/pusheng/OtherService'

type UploadHandler = Parameters<TinyMCE['init']>[0]['images_upload_handler']

export type TinymceProps = Omit<IAllProps, 'onChange'> & {
  /**
   * @name 上传图片类型
   * @default ['jpg', 'png', 'webp']
   */
  fileType?: string[]
  /**
   * @name 限制上传图片大小
   * @desc 单位:M
   * @default 5
   */
  fileSize?: number
  onChange?: (content: string) => void
}

const Tinymce: React.FC<TinymceProps> = ({
  init,
  fileType = ['jpg', 'png', 'webp'],
  fileSize = 5,
  onChange,
  ...rest
}) => {
  const beforeUpload = async ({ type, size }: Blob) => {
    if (fileType?.length && !fileType.some(i => type === lookup(i))) {
      throw Error(`仅支持${fileType.join('、')}图片类型`)
    }

    if (fileSize && size > fileSize * 1024 * 1024) {
      throw Error(`图片大小不能超过${fileSize}M`)
    }
  }

  const uploadImage: UploadHandler = async ({ blob, name }, progress) => {
    await beforeUpload(blob())
    const formData = new FormData()
    formData.append(name(), blob())
    const { data } = await UploadImage(formData, {
      onUploadProgress: ({ loaded, total }: ProgressEvent) =>
        progress((loaded / total) * 100),
    })
    return data?.[0] as string
  }

  return (
    <Editor
      tinymceScriptSrc='/tinymce/tinymce.min.js'
      init={{
        height: 500,
        menubar: false,
        language: 'zh-Hans',
        images_reuse_filename: true,
        images_upload_handler: uploadImage,
        plugins: [
          'advlist',
          'autolink',
          'lists',
          'link',
          'image',
          'charmap',
          'anchor',
          'searchreplace',
          'visualblocks',
          'code',
          'fullscreen',
          'insertdatetime',
          'media',
          'table',
          'preview',
          'help',
          'wordcount',
        ],
        toolbar: `undo redo | forecolor  backcolor | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent lineheight | removeformat charmap table link image insertdatetime fullscreen`,
        contextmenu: 'link image imagetools table spellchecker bold copy',
        content_style:
          'body { font-family: system-ui, —apple-system, Segoe UI, Rototo, Emoji, Helvetica, Arial,sans-serif; font-size:14px }',
        placeholder: '请输入',
        ...init,
      }}
      onEditorChange={content => onChange?.(content)}
      {...rest}
    />
  )
}

export default React.memo(Tinymce)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
  • ProFormTinymce
import React from 'react'
import { ProForm } from '@ant-design/pro-components'
import type { ProFormItemProps } from '@ant-design/pro-components'
import Tinymcefrom from './index'
import type { TinymceProps } from './index'

export type ProFormTinymceProps = ProFormItemProps & {
  fieldProps?: TinymceProps
}

const ProFormTinymce: React.FC<ProFormTinymceProps> = ({ fieldProps, ...rest }) => {
  return (
    <ProForm.Item {...rest}>
      <Tinymcefrom {...fieldProps} />
    </ProForm.Item>
  )
}

export default React.memo(ProFormTinymce)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 4. ErrorRetry

import React from 'react'
import { Alert, Button, Spin } from 'antd'
import type { AlertProps, ButtonProps, SpinProps } from 'antd'

export type ErrorRetryProps = {
  /**
   * @name 错误信息
   */
  error?: Error | string
  /**
   * @name 加载状态
   * @default false
   */
  loading?: boolean
  /**
   * @name 重试按钮的加载状态
   * @default false
   */
  retryLoading?: boolean
  /**
   * @name 重试按钮的文本
   * @default false
   */
  retryText?: React.ReactNode
  /**
   * @name 点击重试按钮的回调
   */
  onRetry?: () => void
  /**
   * @name Spin组件属性
   */
  spinProps?: SpinProps
  /**
   * @name Alert组件属性
   */
  alertProps?: AlertProps
  /**
   * @name Button组件属性
   */
  buttonProps?: ButtonProps
  /**
   * @name 子元素
   */
  children?: React.ReactNode
}

const ErrorRetry: React.FC<ErrorRetryProps> = ({
  error,
  loading = false,
  retryLoading = false,
  retryText,
  onRetry,
  spinProps,
  alertProps,
  buttonProps,
  children,
}) => {
  return (
    <Spin spinning={loading} {...spinProps}>
      {!!error && (
        <Alert
          showIcon
          type='error'
          message='Request Error'
          description={(typeof error === 'string' ? error : error?.message) || '暂无错误信息'}
          action={
            <Button danger loading={retryLoading} onClick={onRetry} {...buttonProps}>
              重试
            </Button>
          }
          {...alertProps}
        />
      )}

      <div style={{ display: !!error ? 'node' : 'unset' }}> {children}</div>
    </Spin>
  )
}

export default React.memo(ErrorRetry)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

# 5. OperationGroup

import React from 'react'
import { EllipsisOutlined } from '@ant-design/icons'
import type { DropdownProps, SpaceProps } from 'antd'
import { Dropdown, Space } from 'antd'
import { omit } from 'lodash'

export type OperationGroupProps = {
  /**
   * @name 展示数量
   */
  count?: number
  /**
   * @name 省略元素
   * @desc 默认三个点 EllipsisOutlined
   */
  restElement?: React.ReactNode
  /**
   * @name Space组件属性
   */
  spaceProps?: SpaceProps
  /**
   * @name Dropdown组件属性
   */
  dropdownProps?: DropdownProps
  /**
   * @name 子元素
   */
  children?: React.ReactElement | React.ReactElement[]
} & Record<string, any>

const OperationGroup: React.FC<OperationGroupProps> = ({
  count,
  restElement,
  spaceProps,
  dropdownProps,
  children,
  ...rest
}) => {
  const components = React.Children.toArray(children) as React.ReactElement[]
  const showComponents = components.slice(0, count)
  const dropdownComponents = components.slice(count || components.length)

  const generateComponent = (Component: React.ReactElement) => {
    return React.cloneElement(Component, {
      ...Component.props,
      ...rest,
    })
  }

  return (
    <Space {...spaceProps}>
      {showComponents.map(generateComponent)}
      {!!dropdownComponents.length && (
        <Dropdown
          trigger={['click']}
          menu={{
            items: dropdownComponents.map((Component, index) => ({
              key: index,
              label: generateComponent(Component),
            })),
            ...omit(dropdownProps?.menu, 'items'),
          }}
          {...omit(dropdownProps, 'menu')}
        >
          {restElement || (
            <a>
              <EllipsisOutlined />
            </a>
          )}
        </Dropdown>
      )}
    </Space>
  )
}

export default React.memo(OperationGroup)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
以父之名
周杰伦.mp3