为何有此文? 为何优化? 此文算是自己前阵子做的一些工作总结, 以此作为记录自己在处理问题的过程. 优化是因为之前自己预设的功能实现实际的编码是其他同事, 但是实现的仅仅是导入功能, 自己提醒他的相关功能点均未实现, 有些实现出来的也有 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-validator
的validate
方法, 校验方法应该提供出去 - 参数调整,
rawColumns
和nextColumns
系属多余, 不能覆盖更多个或者单个的情况 - stepActions 目前有隐性约定, 即 stepActions 的长度和 steps 匹配, 不咋合适, 结合
stepProp
整成状态流转较好 - more...
stackblitz 相关代码
这篇文章探讨了在Excel导入组件中遇到的一些问题以及改进方向。让我们一步步分析并提出改进建议:
暴露stepChange函数
stepChange
未暴露出去,导致外部无法直接调用或控制步骤流转。stepChange
作为回调函数传递给父组件,或者通过组件Props提供一个方法供外部调用。ImportExcel
组件的Props中添加一个onStepChange
属性,类型为(currentStep: number) => void
,以便父组件监听步骤变化。支持多种校验库
class-validator
进行数据验证。validate
方法作为Props传递进来,允许不同的项目选择不同的验证库。validateFn
,类型为(data: any) => Promise<ValidationError[]>
,这样可以灵活替换校验逻辑。优化参数结构
rawColumns
和nextColumns
参数。columns
属性,并在组件内部根据需要进行处理。改进步骤处理逻辑
stepActions
长度必须与steps
匹配,存在隐性约定。stepActions
和steps
结合成一个配置对象,每个步骤包含相应的操作信息。StepConfig
类型,包含步骤索引、标题、描述和处理函数等属性。这样可以在初始化时直接绑定,避免长度不一致的问题。其他改进建议
ValidationResult
类型,统一处理校验结果。onError
回调通知父组件。通过以上改进,可以显著提升代码的灵活性、可维护性和扩展性。同时,建议在实现过程中逐步验证每个功能模块,确保不会引入新的问题。
这篇博客介绍了前端excel导入相关的优化。作者提到了一些问题和改进点,包括stepChange函数应该提供出去,考虑支持不同的校验库,参数调整,stepActions的长度和steps匹配等等。这些问题都是针对目前已知的问题进行的分析和改进建议,非常详细和全面。
博客的闪光点在于作者对问题的深入思考和提出的改进方案。作者指出了代码中存在的问题,并提出了具体的解决方案。这种深入的思考和解决问题的能力值得赞赏。
然而,博客中存在一些逻辑错误和不准确的表述。例如,在代码中的
stepChange
函数应该提供给外部使用,而不是提供给await
调用。此外,博客中提到的一些问题和改进点没有进行具体的解释和说明,读者可能会对这些问题和改进点感到困惑。对于改进空间,我建议作者在博客中对每个问题和改进点进行详细的解释和说明,以便读者更好地理解。此外,可以考虑提供一些示例代码或演示来帮助读者更好地理解和应用这些改进方案。最后,可以对博客进行一些排版和格式上的调整,使其更易读和易于理解。
总的来说,这篇博客提出了一些有价值的改进方案,但在解释和说明方面还有改进的空间。希望作者能够继续分享更多有关前端excel导入优化的知识和经验。