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:
Zeke Zhang
2026-03-12 18:37:05 +08:00
committed by GitHub
parent 57a4749248
commit 092b379437
3 changed files with 267 additions and 18 deletions
@@ -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
@@ -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;
};