• 推荐一个react拖拽排序的库,@dnd-kit


    @dnd-kit

    官网
    GitHub

    基于这个库封装了一个组件

    效果图

    在这里插入图片描述

    代码

    index
    import { forwardRef, ForwardedRef, useState, useRef, useCallback, useMemo } from 'react';
    import { Checkbox } from 'antd';
    import { CheckboxChangeEvent } from 'antd/es/checkbox';
    import { Resizable, NumberSize, ResizeDirection } from 're-resizable';
    import type { ERPTransferProps, ERPTransferRef } from './type';
    import { LeftHeaderLeft, LeftHeaderRight, RightHeaderLeft, RightHeaderRight } from './components/headers';
    import DraggableList from './components/draggable';
    import { LeftSearch } from './components/search';
    import './index.less';
    import { debounce, isUndefined } from 'lodash-es';
    
    type ResizeCallbackParams = [MouseEvent | TouchEvent, ResizeDirection, HTMLElement, NumberSize];
    
    function Index<T>(props: ERPTransferProps<T>, ref: ForwardedRef<ERPTransferRef<T>>) {
      const {
        width = 688,
        height = 376,
        transferWidth = 336,
        allowResizable = false,
        dataSource,
        keyCode,
        nameCode,
        leftHeaderLeft,
        leftHeaderRight,
        rightHeaderLeft,
        rightHeaderRight,
        onChange,
      } = props;
    
      const [leftWidth, setLeftWidth] = useState(transferWidth);
      const [rightWidth, setRightWidth] = useState(transferWidth);
      const leftWidthRef = useRef(transferWidth);
      const rightWidthRef = useRef(transferWidth);
      const [selectedKeys, setSelectedKeys] = useState<(string | number)[]>([]);
      const [searchLeftData, setSearchLeftData] = useState<T[]>();
      const [searchRightData, setSearchRightData] = useState<T[]>();
    
      // 用map对象存一下dataSource
      const dataSourceMapMemo = useMemo(() => {
        return new Map(dataSource.map((i) => [i[keyCode], i]));
      }, [dataSource]);
    
      /* —————————————————————————拖拽的回调处理—————————————————————————————————— */
      const onLeftResize = useCallback((...rest: ResizeCallbackParams) => {
        const { width } = rest[3];
        setLeftWidth(leftWidthRef.current + width);
        setRightWidth(rightWidthRef.current - width);
      }, []);
      const onRightResize = useCallback((...rest: ResizeCallbackParams) => {
        const { width } = rest[3];
        setRightWidth(rightWidthRef.current + width);
        setLeftWidth(leftWidthRef.current - width);
      }, []);
    
      const onLeftResizeStop = useCallback((...rest: ResizeCallbackParams) => {
        const { width } = rest[3];
        leftWidthRef.current = leftWidthRef.current + width;
        rightWidthRef.current = rightWidthRef.current - width;
      }, []);
      const onRightResizeStop = useCallback((...rest: ResizeCallbackParams) => {
        const { width } = rest[3];
        leftWidthRef.current = leftWidthRef.current - width;
        rightWidthRef.current = rightWidthRef.current + width;
      }, []);
    
      /* —————————————————————————左侧—————————————————————————————————— */
      const handleClickItem = (e: CheckboxChangeEvent, keyCode: string) => {
        const checked = e.target.checked;
        if (checked) {
          const selectedKeysTemp = [...selectedKeys, keyCode];
          setSelectedKeys(selectedKeysTemp);
          handleOnchange(selectedKeysTemp);
        } else {
          const selectedKeysTemp = selectedKeys.filter((item) => item !== keyCode);
          setSelectedKeys(selectedKeysTemp);
          handleOnchange(selectedKeysTemp);
        }
      };
      const handleAllSelected = (all: boolean) => {
        if (all) {
          const selectedKeysTemp = dataSource.map((item) => item[keyCode]);
          setSelectedKeys(selectedKeysTemp);
          handleOnchange(selectedKeysTemp);
        } else {
          setSelectedKeys([]);
          handleOnchange([]);
        }
      };
      const handleLeftSearch = (searchText: string) => {
        if (searchText === '') {
          setSearchLeftData(undefined);
        } else {
          const searchResult = dataSource.filter((i) => i[nameCode].includes(searchText));
          setSearchLeftData(searchResult);
        }
      };
      /* —————————————————————————右侧—————————————————————————————————— */
      const handleOnClean = () => {
        setSelectedKeys([]);
        handleOnchange([]);
      };
      const handleRightSearch = (searchText: string) => {
        if (searchText === '') {
          setSearchRightData(undefined);
        } else {
          const searchResult = dataSource.filter((i) => i[nameCode].includes(searchText));
          setSearchRightData(searchResult);
        }
      };
      const handleDraggableListData = () => {
        if (!searchRightData) return selectedKeys;
        else {
          return searchRightData.filter((i) => selectedKeys.includes(i[keyCode])).map((i) => i[keyCode]);
        }
      };
    
      /* —————————————————————————onChange—————————————————————————————————— */
      const handleOnchange = (keys: (string | number)[]) => {
        let result: T[] = [];
        for (const key of keys) {
          if (dataSourceMapMemo.has(key)) {
            result.push(dataSourceMapMemo.get(key)!);
          }
        }
        onChange?.(result);
      };
    
      return (
        <div style={{ width, height }} className="panui-fi-transfer-erp-container">
          <Resizable
            size={{ width: leftWidth, height }}
            onResize={onLeftResize}
            onResizeStop={onLeftResizeStop}
            enable={{
              right: allowResizable,
            }}
          >
            <div className="left-wrapper">
              <div className="left-header">
                <LeftHeaderLeft
                  numerator={selectedKeys.length}
                  denominator={dataSource.length}
                  leftHeaderLeft={leftHeaderLeft}
                  onChange={handleAllSelected}
                />
                <LeftHeaderRight leftHeaderRight={leftHeaderRight} />
              </div>
              <LeftSearch onChange={debounce(handleLeftSearch, 300)} />
              <ul className="list">
                {(searchLeftData ?? dataSource).map((i) => (
                  <li key={i[keyCode]} className="li-item">
                    <Checkbox checked={selectedKeys.includes(i[keyCode])} onChange={(e) => handleClickItem(e, i[keyCode])}>
                      {i[nameCode]}
                    </Checkbox>
                  </li>
                ))}
                {searchLeftData?.length === 0 && <li className="li-item-no">无搜索结果</li>}
              </ul>
            </div>
          </Resizable>
          <Resizable
            size={{ width: rightWidth, height }}
            onResize={onRightResize}
            onResizeStop={onRightResizeStop}
            enable={{
              left: allowResizable,
            }}
          >
            <div className="right-wrapper">
              <div className="right-header">
                <RightHeaderLeft selected={selectedKeys.length} rightHeaderLeft={rightHeaderLeft} />
                <RightHeaderRight rightHeaderRight={rightHeaderRight} onClean={handleOnClean} />
              </div>
              <LeftSearch onChange={debounce(handleRightSearch, 300)} />
              <div className="list">
                <DraggableList
                  data={handleDraggableListData()}
                  setSortData={setSelectedKeys}
                  dataSourceMap={dataSourceMapMemo}
                  nameCode={nameCode}
                  handleOnchange={handleOnchange}
                  disabledDraggable={!isUndefined(searchRightData)}
                />
              </div>
            </div>
          </Resizable>
        </div>
      );
    }
    
    export default forwardRef(Index);
    
    • 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
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183
    • 184
    • 185
    • 186
    • 187
    • 188
    • 189
    • 190
    • 191
    less
    .panui-fi-transfer-erp-container {
      position: relative;
      display: flex;
      justify-content: space-between;
      .left-wrapper {
        width: 100%;
        height: 100%;
        border: 1px solid rgba(227, 231, 237, 1);
        border-radius: 4px;
        display: flex;
        flex-direction: column;
        .left-header {
          border-bottom: 1px solid #e3e7ed;
          color: #86909c;
          display: flex;
          justify-content: space-between;
          .left-header-left {
            height: 38px;
            line-height: 38px;
            padding-left: 12px;
            .ant-checkbox + span {
              color: #86909c;
            }
          }
          .left-header-right {
            line-height: 38px;
            padding-right: 12px;
          }
        }
        .list {
          margin: 0;
          padding: 0;
          list-style: none;
          flex: 1;
          overflow: hidden auto;
          .li-item {
            line-height: 32px;
            padding-left: 12px;
            cursor: pointer;
            &:hover {
              background-color: #e6ecfa;
            }
            .panui-base-checkbox-container {
              width: 100%;
              margin-right: 4px;
              .ant-checkbox {
                .ant-checkbox-inner {
                  border-radius: 4px;
                }
                & + span {
                  width: 100%;
                }
              }
            }
          }
          .li-item-no {
            text-align: center;
            color: #86909c;
            line-height: 64px;
          }
        }
      }
      .right-wrapper {
        width: 100%;
        height: 100%;
        border: 1px solid rgba(227, 231, 237, 1);
        border-radius: 4px;
        display: flex;
        flex-direction: column;
        .right-header {
          border-bottom: 1px solid #e3e7ed;
          color: #86909c;
          display: flex;
          justify-content: space-between;
          .right-header-left {
            line-height: 38px;
            padding-left: 12px;
          }
          .right-header-right {
            line-height: 38px;
            padding-right: 12px;
            color: #0e42d2;
            cursor: pointer;
          }
        }
        .list {
          padding: 0 12px;
          flex: 1;
          overflow: hidden auto;
          .li-item {
            width: 100%;
            line-height: 30px;
            display: flex;
            justify-content: space-between;
            border: 1px solid transparent;
            align-items: center;
            .li-item-name {
              height: 30px;
              display: flex;
              align-items: center;
              span {
                height: inherit;
              }
              .panui-icon-anticon {
                display: flex;
                align-items: center;
                cursor: grab;
              }
            }
            .panui-icon-anticon {
              cursor: pointer;
            }
          }
          .li-item[aria-pressed='true'] {
            background: rgba(223, 0, 36, 0.04);
            border: 1px solid rgba(223, 0, 36, 0.3);
            border-radius: 4px;
            .li-item-name {
              visibility: hidden;
            }
            .li-item-name + span {
              visibility: hidden;
            }
          }
          .li-item.li-item-overlay {
            background: rgba(255, 255, 255, 0.9);
            border: 1px solid rgba(223, 0, 36, 0.3);
            box-shadow: 0px 0px 6px 0px rgba(0, 0, 0, 0.1);
            border-radius: 4px;
            cursor: grabbing;
            .panui-icon-anticon {
              cursor: grabbing;
            }
            .li-item-name {
              color: #86909c;
            }
            .li-item-name + span {
              visibility: hidden;
            }
          }
          .li-item.li-item-disabled-draggable {
            .panui-icon-anticon {
              cursor: default;
            }
          }
        }
      }
      .search-container {
        padding: 8px 12px;
      }
    }
    
    • 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
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    components
    // header
    import React from 'react';
    import { Checkbox } from 'antd';
    interface LeftHeaderLeftProps {
      leftHeaderLeft?: (numerator: number, denominator: number) => React.ReactNode;
      numerator: number;
      denominator: number;
      onChange: (all: boolean) => void;
    }
    
    export const LeftHeaderLeft = (props: LeftHeaderLeftProps) => {
      const { leftHeaderLeft, numerator, denominator, onChange } = props;
      return (
        <div className="left-header-left">
          <Checkbox
            indeterminate={numerator > 0 && denominator !== numerator}
            checked={numerator === denominator}
            onChange={(e) => onChange(e.target.checked)}
          >
            {leftHeaderLeft ? leftHeaderLeft(numerator, denominator) : `${numerator}/${denominator}`}
          </Checkbox>
        </div>
      );
    };
    
    export const LeftHeaderRight = ({ leftHeaderRight }: { leftHeaderRight?: React.ReactNode }) => {
      return leftHeaderRight ? <div>{leftHeaderRight}</div> : <span className="left-header-right">可选择</span>;
    };
    
    export const RightHeaderLeft = ({
      selected,
      rightHeaderLeft,
    }: {
      selected: number;
      rightHeaderLeft?: (selected: number) => React.ReactNode;
    }) => {
      return rightHeaderLeft ? (
        <div>{rightHeaderLeft(selected)}</div>
      ) : (
        <span className="right-header-left">
          已选择 <span style={{ color: '#1D2129' }}>{selected}</span>
        </span>
      );
    };
    
    export const RightHeaderRight = ({
      rightHeaderRight,
      onClean,
    }: {
      rightHeaderRight?: React.ReactNode;
      onClean: () => void;
    }) => {
      return rightHeaderRight ? (
        <div>{rightHeaderRight}</div>
      ) : (
        <span className="right-header-right" onClick={onClean}>
          清空
        </span>
      );
    };
    
    
    • 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
    // Search
    import { Input } from 'antd';
    export function LeftSearch({ onChange }: { onChange: (searchText: string) => void }) {
      return (
        <div className="search-container">
          <Input placeholder="搜索" suffix="放大镜" onChange={(e) => onChange(e.target.value)} />
        </div>
      );
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    // draggable
    import React, { useState } from 'react';
    import {
      DndContext,
      closestCenter,
      KeyboardSensor,
      PointerSensor,
      useSensor,
      useSensors,
      DragEndEvent,
      DragOverlay,
      DragStartEvent,
    } from '@dnd-kit/core';
    import {
      arrayMove,
      SortableContext,
      sortableKeyboardCoordinates,
      verticalListSortingStrategy,
    } from '@dnd-kit/sortable';
    import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers';
    
    import { SortableItem } from './sortItem';
    import { isEmpty } from 'lodash-es';
    
    interface DraggableListProps<T> {
      data: (string | number)[];
      setSortData: React.Dispatch<React.SetStateAction<(string | number)[]>>;
      dataSourceMap: Map<string | number, T>;
      nameCode: string;
      handleOnchange: (keys: (string | number)[]) => void;
      disabledDraggable?: boolean;
    }
    
    export default function Index<T>(props: DraggableListProps<T>) {
      const { data, setSortData, dataSourceMap, nameCode, handleOnchange, disabledDraggable } = props;
      const sensors = useSensors(
        useSensor(PointerSensor),
        useSensor(KeyboardSensor, {
          coordinateGetter: sortableKeyboardCoordinates,
        }),
      );
    
      const handleDeleteItem = (id: string | number) => {
        setSortData((items) => {
          const keys = items.filter((item) => item !== id);
          handleOnchange(keys);
          return keys;
        });
      };
      const [activeId, setActiveId] = useState<string | number | null>(null);
    
      return (
        <DndContext
          sensors={sensors}
          collisionDetection={closestCenter}
          onDragStart={handleDragStart}
          onDragEnd={handleDragEnd}
          modifiers={[restrictToParentElement]}
        >
          <SortableContext items={data} strategy={verticalListSortingStrategy} disabled={disabledDraggable}>
            {data.map((i) => (
              <SortableItem
                key={i}
                id={i}
                name={dataSourceMap.get(i)?.[nameCode]}
                deleteItem={handleDeleteItem}
                className={disabledDraggable ? 'li-item-disabled-draggable' : ''}
              />
            ))}
            {isEmpty(data) && disabledDraggable && (
              <span
                style={{
                  display: 'inline-block',
                  width: '100%',
                  textAlign: 'center',
                  color: '#86909c',
                  lineHeight: '64px',
                }}
              >
                无搜索结果
              </span>
            )}
          </SortableContext>
          <DragOverlay>
            {activeId ? (
              <SortableItem
                key={activeId}
                id={activeId}
                name={dataSourceMap.get(activeId)?.[nameCode]}
                className="li-item-overlay"
              />
            ) : null}
          </DragOverlay>
        </DndContext>
      );
    
      function handleDragStart(event: DragStartEvent) {
        setActiveId(event.active.id);
      }
      function handleDragEnd(event: DragEndEvent) {
        const { active, over } = event;
        if (active.id !== over?.id && over) {
          setSortData((items) => {
            const oldIndex = items.indexOf(active.id);
            const newIndex = items.indexOf(over?.id);
            const keys = arrayMove(items, oldIndex, newIndex);
            handleOnchange(keys);
            return keys;
          });
        }
      }
    }
    
    • 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
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    // SortableItem
    import { useSortable } from '@dnd-kit/sortable';
    import classNames from 'classnames';
    import { CSS } from '@dnd-kit/utilities';
    import { PWrong1Outlined, PDrag2Filled } from '@panui/icons';
    
    interface SortableItemProps {
      id: string | number;
      name: string;
      deleteItem?: (id: string | number) => void;
      className?: string;
    }
    
    export function SortableItem(props: SortableItemProps) {
      const { id, name, deleteItem, className } = props;
      const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
    
      const style = {
        transform: CSS.Transform.toString(transform),
        transition,
      };
    
      return (
        <div ref={setNodeRef} style={style} className={classNames(['li-item', className])} {...attributes}>
          <div className="li-item-name">
            <span {...listeners}>
              <PDrag2Filled />
            </span>
            <span style={{ marginLeft: '8px' }}>{name}</span>
          </div>
          <span onClick={() => deleteItem?.(id)}>
            <PWrong1Outlined />
          </span>
        </div>
      );
    }
    
    
    • 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

    PS:

    1. 线性的复选框是又封装了一层,贴的代码里换成了antd的

    一些基本使用总结

    1. 基本demo地址
    2. 内容要嵌套在DndContext组件中
    3. DndContext的modifiers属性控制运动检测坐标,作用如:锁定x、y等
    4. listeners属性可以用来指定可拖拽的dom,setNodeRef给要拖拽的dom
    5. DragOverlay组件是拖拽覆盖的组件,通过handleDragStart来配合拖拽的是哪个
    6. 可以通过属性选择器[aria-pressed=‘true’]来判断当前拖拽的是哪个
  • 相关阅读:
    一个C#跨平台的机器视觉和机器学习的开源库
    【前端灵魂脚本语言JavaScript⑤】——JS中数组的使用
    B_QuRT_User_Guide(28)
    互联网金融P2P主业务场景自动化测试
    python代码实现论文〖文献引用顺序〗修改校对
    性能分析插件
    系统数据数据和信息
    单片机ADC常见的几种滤波方法
    React的基础用法-创建组件、渲染组件、处理状态和事件
    【云原生之Docker实战】使用Docker部署Homepage应用程序仪表盘
  • 原文地址:https://blog.csdn.net/BWater_monster/article/details/133359046