为何有此文? 为何优化? 此文算是自己前阵子做的一些工作总结, 以此作为记录自己在处理问题的过程. 优化是因为之前自己预设的功能实现实际的编码是其他同事, 但是实现的仅仅是导入功能, 自己提醒他的相关功能点均未实现, 有些实现出来的也有 bug, 可能他对代码的要求仅仅只是运行吧, 故有此事.

excel 导入应该具有的功能点

  • 导入原始数据展示
  • 校验及转换
  • 校验结果
  • 校验结果排序
  • 在线编辑, 删除
  • 业务校验
  • 上传
  • 回退

以下进行一一说明, 对应功能的点需要满足的理由, 基于的出发点为用户使用体验更好, 使用中基本没有疑惑, 且想进行的行为比较方便.

导入原始数据展示主要为了展示 excel 中最原始的数据, 为何最初的基点是原始数据不是经过处理的验证或者转换的数据, 因为用户进行导入功能开始的出发就是 excel 的相关数据, 如果不展示该数据, 用户可能会疑惑为何 excel 中的数据是这样, 导入之后变成这样了? 可能会怀疑 excel 中的数据错误, 其实可能是因为系统多做了转换的处理, 所以原始数据的展示是十分有必要的.

校验及转换功能主要针对数据的筛选, 筛选出非法数据, 以及针对数据的转换操作, 比如 excel 中某列为日期, 用户看到的是日期, 但是实际导入解析到的是时间戳, 考虑到此, 可以抉择原始数据展示是否要做最初步的数据转换, 但是还需要考虑的是校验所针对的数据范围包含所有数据, 如果非法数据贸然做转换需要谨慎处理. 这里则将类似日期的转换放到校验之后来做, 校验之后的数据能有效筛选出合法及非法数据, 同时对非法数据进行顺序前置及显红处理, 每行可以显红同时某行中某列也能显红, 具体的提示信息也具有, 当用户看到显红的数据鼠标放置上去时就能看到, 并且提示的错误信息是友好的, 具体友好的可以对比如 no 不能为空管码号不能为空, 其中表头为 管码号, no 为内置的数据字段, 优先选择后者, 因为该信息是展示给用户的, 用户所知道的可能就是 管码号, 没必要也不知道 no. 到此, 用户可以很明确定位到非法错误及原因, 而之后也不需要在 excel 找到对应非法数据更改之后重新导入, 而是直接在线编辑更改及删除.

排序的主要依据是按照数据非法优先, 警告其次, 合法最后, 方便用户优先处理影响后续流程的数据, 也符合操作习惯.

在线编辑就是为了满足用户处理非法数据以及对合法数据进行进一步处理的, 这是十分有必要的, 如果没有此功能, 用户更改数据的方式只有一种, 那就是回到 excel 中更改再进行导入, 这是十分低效的, 而且加重用户心智负担, 同时也是功能设计的重大失误, 甚至说是愚痴, 读者需引以为戒, 曾见很多这种情况, 功能上想少实现, 校验没做好, 最后用户上传不成功, 系统不提示具体错误, 后面看到接口的错误可能是 管码非法, 然后告诉用户管码格式不合法让用户更改, 忽视系统 本具该具 的功能, 错误出现的原因, 数据非法直接推给 用户, 是为愚痴, 用户的责任只有上传数据, 数据合法与否是系统要做的, 后面为了满足用户更改数据, 尤其是非法数据, 必然要有编辑, 删除功能, 且编辑之后立马会有对应校验结果出现.

业务校验功能本质上系属于校验功能, 只不过不同于数据校验很多都是本地数据校验, 有些业务上的数据进行接口校验也是必要的, 此处单提出来稍微显多余, 本地校验有规则有校验结果, 业务校验可能请求业务接口完成校验工作, 对接口返回信息具有较高要求, 而且返回的信息应该只设计业务校验结果, 数据格式的校验可能本地已经完成.

上传基本属于最后一步操作了, 该操作经过前面的流程处理之后会异常简单, 基本上将所有异常的境况都摒除了, 但是还需要做异常处理, 针对失败的情况进行相关的提示, 为何如此, 譬如我要导入 no 1234 的记录, no 为 唯一主键, 前方流程校验都没有问题, 甚至业务接口校验也没有问题, 但是其他人在这个时候将该记录导入进去了, 这就会造成校验都正常, 但是最后一步过不去的现象, 所以兜底工作一定要做好.

其实还涉及到数据量的问题, 这里暂时不做讨论. 数据量的问题主要体现在接口, 业务校验和上传过程中, 涉及分批处理, 处理完的实时更新相关状态, 及控制是否全部处理完.

最后回退暂时未实现, 取代实现了同数据源 hash 跳转, 后续通过 hash 拿到原始数据, 刷新页面回到最初位置, 虽然有很多弊端, 比如某个步骤操作的记录没了, 后续回到这个步骤还需要重新操作.

一个封装的通用组件

环境为 react, ts, 所使用的校验, 转换库分别为 class-validator, class-transformer, ui 组件为 antd(^5.17.0) 及 @ant-design/pro-components(^2.4.4) 相关, 步骤提示使用的是 Steps(当前版本中有属性的 status 的 Steps有 bug), 表格展示使用的是 ProTable

index.tsx

import { ArrowRightOutlined, CloudUploadOutlined } from '@ant-design/icons';
import {
  ActionType,
  PageContainer,
  ProColumns,
  ProTable,
} from '@ant-design/pro-components';
import { Button, Space, StepProps, Steps, message, notification } from 'antd';
import { validate } from 'class-validator';
import { useRef, useState } from 'react';
import {
  ActionProvider,
  BaseRecord,
  ValidateStatus,
  validationErrorDeal,
} from './types';

type NotificationType = 'success' | 'info' | 'warning' | 'error';

// 定义排序顺序
export const order = [
  ValidateStatus.ERROR,
  ValidateStatus.WARNING,
  ValidateStatus.SUCCESS,
  ValidateStatus.WILL,
];

// 状态排序
export const indexStatus = <T extends BaseRecord>(newDataSource: (T)[]) => {
  newDataSource.sort(
    (a, b) => order.indexOf(a.status) - order.indexOf(b.status),
  );
};

export interface NotificationProp {
  type: NotificationType;
  message: string;
  description: string;
}

export interface ImportExcelProps<T, R> {
  title: string;
  // 原始数据
  rawDataSource: T[];
  // 完成
  // 原始列
  rawColumns: ProColumns<T>[];
  nextColumns: ProColumns<R>[];
  stepItems: StepProps[];
  stepActions: Array<
    (provider: ActionProvider<T, R>) =>
      | Promise<{
          data: (T | R | never)[];
          next: boolean;
          notification?: NotificationProp;
        }>
      | {
          data: (T | R | never)[];
          next: boolean;
          notification?: NotificationProp;
        }
  >;
}

const ImportExcel = <T extends BaseRecord, R extends BaseRecord>(
  props: ImportExcelProps<T, R>,
) => {
  const { rawDataSource, rawColumns, stepItems, stepActions, title } = props;

  const [api, contextHolder] = notification.useNotification();

  const [dataSource, setDataSource] = useState<(T | R)[]>(rawDataSource);
  const [columns, setColumns] = useState<ProColumns<T | R>[]>(
    rawColumns as ProColumns<T | R>[],
  );

  const [pageInfo, setPageInfo] = useState({ pageSize: 10, current: 1 });

  const actionRef = useRef<ActionType>();
  const [current, setCurrent] = useState<number>(0);
  // const [status, setStatus] = useState<'wait' | 'process' | 'finish' | 'error'>(
  //   'wait',
  // );

  const provider = {
    dataSource,
    setDataSource,
    columns,
    setColumns,
    props,
  };

  const hasError = (data: (T | R)[]) =>
    data.filter((i) => i.errorAtt.length).length !== 0;

  const openNotificationWithIcon: (props: NotificationProp) => void = ({
    type,
    message,
    description,
  }) => {
    api[type]({
      message,
      description,
    });
  };

  const stepChange = async (next: () => void) => {
    // 一步一步走, 暂时不跨步
    if (dataSource.length === 0) {
      return message.warning('无数据!');
    }
    if (hasError(dataSource)) {
      return message.warning('请先处理当前数据问题!');
    }
    // 去 stepActions 中选择对应的进行执行
    // 如果 1 去执行 1 对应的函数, 如果 2 去执行 2 对应的函数
    // setStatus('process');
    const action = stepActions[current];
    const res = await action(provider);
    const nestAble = res.next;
    if (res.notification) {
      openNotificationWithIcon(res.notification);
    }
    if (!res.next) {
      // return setStatus('error');
      return;
    }
    // setStatus('finish');
    if (nestAble) next();
  };

  return (
    <PageContainer>
      {contextHolder}
      <Space direction="vertical" size="large" style={{ display: 'flex' }}>
        <Steps
          current={current}
          // 有 bug
          // status={status}
          items={stepItems}
        />

        <ProTable<T | R>
          columns={columns as ProColumns<T | R>[]}
          dataSource={dataSource}
          actionRef={actionRef}
          cardBordered
          search={false}
          editable={{
            type: 'single',
            onSave: async (key, record) => {
              // 找到被编辑的行在数据源中的索引
              const index = dataSource.findIndex((item) => item.id === key);
              if (index > -1) {
                // 替换之前进行数据校验
                const rawRecord = dataSource[index];
                Object.assign(rawRecord, record);
                const errorAtt = await validate(rawRecord);
                rawRecord.errorAtt = [];
                validationErrorDeal([errorAtt]);
                rawRecord.status =
                  errorAtt.length > 0
                    ? ValidateStatus.ERROR
                    : ValidateStatus.SUCCESS;
                // 使用新的行数据替换原来的行数据
                const newDataSource = [...dataSource];
                newDataSource[index] = rawRecord;
                indexStatus(newDataSource);
                // 更新数据源
                setDataSource(newDataSource);
              }
            },
            onDelete: async (key) => {
              // 直接更新数据源
              setDataSource(dataSource.filter((i) => i.id !== key));
            },
          }}
          columnsState={{
            persistenceKey: 'pro-table-singe-demos',
            persistenceType: 'localStorage',
            defaultValue: {
              option: { fixed: 'right', disable: true },
            },
            onChange(value) {
              console.log('value: ', value);
            },
          }}
          rowKey="id"
          options={{
            setting: {
              listsHeight: 400,
            },
          }}
          form={{
            // 由于配置了 transform,提交的参与与定义的不同这里需要转化一下
            syncToUrl: (values, type) => {
              if (type === 'get') {
                return {
                  ...values,
                  created_at: [values.startTime, values.endTime],
                };
              }
              return values;
            },
          }}
          toolBarRender={() => [
            <>
              {current < stepItems.length - 1 && (
                <Button
                  key="button"
                  icon={<ArrowRightOutlined />}
                  onClick={async () => {
                    await stepChange(() => {
                      // 如果有 status 则有 bug 1 则设置 1, 其他设置 +1
                      setCurrent(current + 1);
                    });
                  }}
                  type="primary"
                >
                  下一步
                </Button>
              )}
            </>,
            <>
              {current >= stepItems.length - 1 && (
                <Button
                  key="button"
                  icon={<CloudUploadOutlined />}
                  disabled={current > stepItems.length - 1}
                  onClick={async () => {
                    await stepChange(() => {
                      // 如果有 status 则有 bug 1 则设置 1, 其他设置 +1
                      setCurrent(current + 1);
                    });
                  }}
                  type="primary"
                >
                  上传
                </Button>
              )}
            </>,
          ]}
          pagination={{
            pageSize: pageInfo.pageSize,
            onShowSizeChange: (current: number, size: number) =>
              setPageInfo({ pageSize: size, current: 1 }),
          }}
          dateFormatter="string"
          headerTitle={title}
        />
      </Space>
    </PageContainer>
  );
};

export default ImportExcel;

types

import { ProColumns } from '@ant-design/pro-components';
import { ValidationError } from 'class-validator';
import { ImportExcelProps } from '.';

export const validationErrorDeal = <
  T extends {
    errorAtt?: errorItem[];
  },
>(
  errors: ValidationError[][],
) => {
  errors.forEach((error) => {
    if (error.length > 0) {
      error.forEach((item) => {
        const target = item.target as T;
        target.errorAtt ??= [];
        target.errorAtt.push({
          property: item.property,
          message: Object.keys(item.constraints!)
            .map((key) => item.constraints![key])
            .join(';'),
          value: item.value,
        });
      });
    }
  });
};

export type errorItem = { property: string; message: string; value: any };

export interface BaseRecord extends Record<string, any> {
  id: number;
  status: ValidateStatus;
  errorAtt: errorItem[];
}

export const getErrorMessage = <T extends Record<string, any>>(
  errors: errorItem[] | null,
  property: keyof T,
): { hasError: boolean; message: string[] } => {
  if (!errors) return { hasError: false, message: [] };
  const errorAtt = errors.filter(
    (item) => item.property.includes(property as string) || item.property === property,
  );
  return {
    hasError: errorAtt.length > 0,
    message: errorAtt.map((item) => item.message),
  };
};

export enum ValidateStatus {
  SUCCESS = 'success',
  ERROR = 'error',
  WARNING = 'warning',
  WILL = 'will',
}

export const ValidateStatusMapColor: Record<ValidateStatus, string> = {
  // "success", "processing", "error", "default", "warning"
  [ValidateStatus.WILL]: 'default',
  [ValidateStatus.SUCCESS]: 'success',
  [ValidateStatus.ERROR]: 'error',
  [ValidateStatus.WARNING]: 'warning',
};

export const ValidateStatusMapTxt: Record<ValidateStatus, string> = {
  // "success", "processing", "error", "default", "warning"
  [ValidateStatus.WILL]: '未验证',
  [ValidateStatus.SUCCESS]: '验证成功',
  [ValidateStatus.ERROR]: '验证错误',
  [ValidateStatus.WARNING]: '警告',
};

export type ActionProvider<T, R> = {
  dataSource: (T | R)[];
  setDataSource: React.Dispatch<React.SetStateAction<(T | R)[]>>;
  columns: ProColumns<T | R>[];
  setColumns: React.Dispatch<React.SetStateAction<ProColumns<T | R>[]>>;
  props: ImportExcelProps<T, R>;
};

针对 index.tsx 目前已知问题

  • stepChange 应该提供出去
  • 考虑支持不同的校验库, 目前更改时的校验直接使用了 class-validatorvalidate 方法, 校验方法应该提供出去
  • 参数调整, rawColumnsnextColumns 系属多余, 不能覆盖更多个或者单个的情况
  • stepActions 目前有隐性约定, 即 stepActions 的长度和 steps 匹配, 不咋合适, 结合 stepProp 整成状态流转较好
  • more...

stackblitz 相关代码