mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-30 16:56:07 +08:00
fix(file-manager): improve upload field UX (#8856)
* fix(file-manager): improve upload field UX * fix(file-manager): address review comments * chore(ci): trigger rebuild
This commit is contained in:
+36
-18
@@ -13,10 +13,15 @@ import { Upload } from '@formily/antd-v5';
|
||||
import { castArray } from 'lodash';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { largeField, tExpr, EditableItemModel, observable } from '@nocobase/flow-engine';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useLayoutEffect, useState } from 'react';
|
||||
import { FieldContext } from '@formily/react';
|
||||
import { FieldModel, RecordPickerContent } from '@nocobase/client';
|
||||
import { FilePreviewRenderer, getPreviewThumbnailUrl } from '../previewer/filePreviewTypes';
|
||||
import { FilePreviewRenderer } from '../previewer/filePreviewTypes';
|
||||
import {
|
||||
getUploadFieldPreviewIndex,
|
||||
normalizeUploadFieldFileList,
|
||||
shouldShowUploadActionSlot,
|
||||
} from './uploadFieldUtils';
|
||||
|
||||
export const CardUpload = (props) => {
|
||||
const {
|
||||
@@ -28,14 +33,16 @@ export const CardUpload = (props) => {
|
||||
quickUpload = true,
|
||||
showFileName,
|
||||
} = props;
|
||||
const [fileList, setFileList] = useState(castArray(value || []));
|
||||
const [fileList, setFileList] = useState(() => normalizeUploadFieldFileList(castArray(value || [])));
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewImage, setPreviewImage] = useState(null);
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0); // 用来跟踪当前预览的图片索引
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
setFileList(normalizedFileList(castArray(value || [])));
|
||||
useLayoutEffect(() => {
|
||||
// 在浏览器绘制前完成外部值同步,避免先闪出旧槽位再切换成新布局。
|
||||
setFileList((previousFileList) => normalizeUploadFieldFileList(castArray(value || []), previousFileList));
|
||||
}, [value]);
|
||||
const showActionSlot = shouldShowUploadActionSlot(multiple, fileList.length);
|
||||
|
||||
const getBase64 = (file): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
@@ -45,7 +52,7 @@ export const CardUpload = (props) => {
|
||||
reader.onerror = (error) => reject(error);
|
||||
});
|
||||
const handlePreview = async (file) => {
|
||||
const index = +file.uid;
|
||||
const index = getUploadFieldPreviewIndex(fileList, file);
|
||||
if (!file.url && !file.preview) {
|
||||
file.preview = await getBase64(file.originFileObj);
|
||||
}
|
||||
@@ -89,15 +96,6 @@ export const CardUpload = (props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const normalizedFileList = (data) => {
|
||||
return data.map((file) => {
|
||||
return {
|
||||
...file,
|
||||
thumbUrl: getPreviewThumbnailUrl(file),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldContext.Provider
|
||||
value={
|
||||
@@ -118,6 +116,25 @@ export const CardUpload = (props) => {
|
||||
.ant-upload-select {
|
||||
margin: ${showFileName ? '8px 0px' : '0px'};
|
||||
}
|
||||
${!multiple
|
||||
? `
|
||||
.ant-upload-list-item-container.ant-upload-animate-inline-appear,
|
||||
.ant-upload-list-item-container.ant-upload-animate-inline-appear-active,
|
||||
.ant-upload-list-item-container.ant-upload-animate-inline-enter,
|
||||
.ant-upload-list-item-container.ant-upload-animate-inline-enter-active {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
transform: none !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.ant-upload.ant-upload-animate-inline-leave,
|
||||
.ant-upload.ant-upload-animate-inline-leave-active {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
`
|
||||
: ''}
|
||||
`}
|
||||
>
|
||||
<Upload
|
||||
@@ -126,7 +143,8 @@ export const CardUpload = (props) => {
|
||||
listType="picture-card"
|
||||
fileList={fileList}
|
||||
onChange={(newFileList) => {
|
||||
setFileList(newFileList);
|
||||
// 保留上传组件生成的 uid,避免上传完成后回灌值把同一项渲染成两张不同的卡片。
|
||||
setFileList((previousFileList) => normalizeUploadFieldFileList(newFileList, previousFileList));
|
||||
const doneFiles = newFileList.filter((f: any) => f.status === 'done' || f.id);
|
||||
if (newFileList.every((f: any) => f.status === 'done' || f.id)) {
|
||||
if (props.maxCount === 1) {
|
||||
@@ -158,7 +176,7 @@ export const CardUpload = (props) => {
|
||||
);
|
||||
}}
|
||||
>
|
||||
{quickUpload && <UploadOutlined style={{ fontSize: 20 }} />}
|
||||
{quickUpload && showActionSlot ? <UploadOutlined style={{ fontSize: 20 }} /> : null}
|
||||
</Upload>
|
||||
|
||||
{previewImage && (
|
||||
@@ -173,7 +191,7 @@ export const CardUpload = (props) => {
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
)}
|
||||
{allowSelectExistingRecord ? (
|
||||
{allowSelectExistingRecord && showActionSlot ? (
|
||||
<div style={{ marginLeft: 5 }}>
|
||||
<Upload disabled={disabled} multiple={multiple} listType={'picture-card'} showUploadList={false}>
|
||||
<div
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getUploadFieldPreviewIndex,
|
||||
normalizeUploadFieldFileList,
|
||||
shouldShowUploadActionSlot,
|
||||
} from '../uploadFieldUtils';
|
||||
|
||||
describe('shouldShowUploadActionSlot', () => {
|
||||
it('单值字段已有文件时应隐藏追加入口', () => {
|
||||
expect(shouldShowUploadActionSlot(false, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('单值字段无文件时应显示追加入口', () => {
|
||||
expect(shouldShowUploadActionSlot(false, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('多值字段已有文件时仍应显示追加入口', () => {
|
||||
expect(shouldShowUploadActionSlot(true, 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeUploadFieldFileList', () => {
|
||||
it('外部值回灌时应复用上一轮 uid,避免同一张图片重挂载', () => {
|
||||
const previousFileList = [
|
||||
{
|
||||
uid: 'rc-upload-1',
|
||||
filename: 'cover.png',
|
||||
response: {
|
||||
id: 1,
|
||||
url: '/files/cover.png',
|
||||
filename: 'cover.png',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const nextFileList = normalizeUploadFieldFileList(
|
||||
[
|
||||
{
|
||||
id: 1,
|
||||
url: '/files/cover.png',
|
||||
filename: 'cover.png',
|
||||
},
|
||||
],
|
||||
previousFileList,
|
||||
);
|
||||
|
||||
expect(nextFileList[0].uid).toBe('rc-upload-1');
|
||||
});
|
||||
|
||||
it('缺少上一轮列表时应基于记录标识生成稳定 uid', () => {
|
||||
const firstFileList = normalizeUploadFieldFileList([
|
||||
{
|
||||
id: 2,
|
||||
url: '/files/avatar.png',
|
||||
filename: 'avatar.png',
|
||||
},
|
||||
]);
|
||||
const secondFileList = normalizeUploadFieldFileList([
|
||||
{
|
||||
id: 2,
|
||||
url: '/files/avatar.png',
|
||||
filename: 'avatar.png',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(firstFileList[0].uid).toBe('id:2');
|
||||
expect(secondFileList[0].uid).toBe('id:2');
|
||||
});
|
||||
|
||||
it('应保留上传组件已有的 thumbUrl,避免上传中缩略图被清空', () => {
|
||||
const fileList = normalizeUploadFieldFileList([
|
||||
{
|
||||
id: 3,
|
||||
url: '/files/thumb.png',
|
||||
filename: 'thumb.png',
|
||||
thumbUrl: 'data:image/png;base64,thumb',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(fileList[0].thumbUrl).toBe('data:image/png;base64,thumb');
|
||||
});
|
||||
|
||||
it('应支持 id 为 0 的文件生成稳定标识', () => {
|
||||
const fileList = normalizeUploadFieldFileList([
|
||||
{
|
||||
id: 0,
|
||||
filename: 'zero.png',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(fileList[0].uid).toBe('id:0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUploadFieldPreviewIndex', () => {
|
||||
it('应按 uid 查找非数字 uid 的预览索引', () => {
|
||||
const fileList = [
|
||||
{ uid: 'id:1', filename: 'first.png' },
|
||||
{ uid: 'id:2', filename: 'second.png' },
|
||||
];
|
||||
|
||||
expect(getUploadFieldPreviewIndex(fileList, { uid: 'id:2' })).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { getPreviewThumbnailUrl } from '../previewer/filePreviewTypes';
|
||||
|
||||
interface UploadFieldFile {
|
||||
uid?: string;
|
||||
id?: number | string;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
name?: string;
|
||||
thumbUrl?: string;
|
||||
response?: UploadFieldFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断上传字段是否继续展示追加入口。
|
||||
*
|
||||
* 单值文件关系在已有文件后应隐藏“上传”和“选择”入口,多值字段允许继续追加。
|
||||
*
|
||||
* @param multiple 是否多值
|
||||
* @param fileCount 当前文件数量
|
||||
* @returns 是否显示追加入口
|
||||
* @example
|
||||
* ```typescript
|
||||
* shouldShowUploadActionSlot(false, 1);
|
||||
* ```
|
||||
*/
|
||||
export const shouldShowUploadActionSlot = (multiple: boolean | undefined, fileCount: number) => {
|
||||
return !!multiple || fileCount === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 为上传项提取可跨“上传中对象 / 服务端对象”复用的稳定标识。
|
||||
*
|
||||
* antd UploadList 依赖 uid 作为动画 key;如果外部值回灌后 uid 变化,
|
||||
* 同一张图片会被当成“旧节点离场 + 新节点进场”,造成位置闪动。
|
||||
*
|
||||
* @param file 当前文件对象
|
||||
* @param index 当前索引
|
||||
* @returns 稳定标识
|
||||
* @example
|
||||
* ```typescript
|
||||
* getUploadFieldFileIdentity({ response: { id: 1 } }, 0);
|
||||
* ```
|
||||
*/
|
||||
const getUploadFieldFileIdentity = (file: UploadFieldFile, index: number) => {
|
||||
const response = file?.response;
|
||||
const id = file?.id ?? response?.id;
|
||||
const url = file?.url ?? response?.url;
|
||||
const filename = file?.filename ?? response?.filename;
|
||||
const name = file?.name ?? response?.name;
|
||||
|
||||
if (id != null) {
|
||||
return `id:${String(id)}`;
|
||||
}
|
||||
|
||||
if (url != null) {
|
||||
return `url:${url}`;
|
||||
}
|
||||
|
||||
if (filename != null) {
|
||||
return `filename:${filename}:${index}`;
|
||||
}
|
||||
|
||||
if (name != null) {
|
||||
return `name:${name}:${index}`;
|
||||
}
|
||||
|
||||
if (file?.uid) {
|
||||
return `uid:${file.uid}`;
|
||||
}
|
||||
|
||||
return `index:${index}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 规范化上传字段文件列表,并尽量复用上一轮 uid,避免列表项重挂载。
|
||||
*
|
||||
* @param data 当前外部值或上传组件返回的文件列表
|
||||
* @param previousFileList 上一轮渲染中的文件列表
|
||||
* @returns 规范化后的文件列表
|
||||
* @example
|
||||
* ```typescript
|
||||
* normalizeUploadFieldFileList([{ id: 1, url: '/1.png' }], [{ uid: 'u1', response: { id: 1 } }]);
|
||||
* ```
|
||||
*/
|
||||
export const normalizeUploadFieldFileList = (data: UploadFieldFile[], previousFileList: UploadFieldFile[] = []) => {
|
||||
const previousUidMap = new Map(
|
||||
previousFileList
|
||||
.map((file, index) => [getUploadFieldFileIdentity(file, index), file.uid] as const)
|
||||
.filter(([, uid]) => !!uid),
|
||||
);
|
||||
|
||||
return data.map((file, index) => {
|
||||
const identity = getUploadFieldFileIdentity(file, index);
|
||||
|
||||
return {
|
||||
...file,
|
||||
uid: file.uid || previousUidMap.get(identity) || identity,
|
||||
thumbUrl: file.thumbUrl ?? getPreviewThumbnailUrl(file),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据当前文件列表解析预览索引,避免把非数字 uid 强转成 NaN。
|
||||
*
|
||||
* @param fileList 当前文件列表
|
||||
* @param targetFile 当前预览文件
|
||||
* @returns 预览索引
|
||||
* @example
|
||||
* ```typescript
|
||||
* getUploadFieldPreviewIndex([{ uid: 'id:1' }], { uid: 'id:1' });
|
||||
* ```
|
||||
*/
|
||||
export const getUploadFieldPreviewIndex = (fileList: UploadFieldFile[], targetFile: UploadFieldFile) => {
|
||||
const index = fileList.findIndex((file) => file.uid === targetFile?.uid);
|
||||
|
||||
return index >= 0 ? index : 0;
|
||||
};
|
||||
Reference in New Issue
Block a user