diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/client/models/UploadFieldModel.tsx b/packages/plugins/@nocobase/plugin-file-manager/src/client/models/UploadFieldModel.tsx index b5a3d0d7573..a6fb5cec02b 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/src/client/models/UploadFieldModel.tsx +++ b/packages/plugins/@nocobase/plugin-file-manager/src/client/models/UploadFieldModel.tsx @@ -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 => 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 ( { .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; + } + ` + : ''} `} > { 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 && } + {quickUpload && showActionSlot ? : null} {previewImage && ( @@ -173,7 +191,7 @@ export const CardUpload = (props) => { onDownload={onDownload} /> )} - {allowSelectExistingRecord ? ( + {allowSelectExistingRecord && showActionSlot ? (
{ + 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); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/client/models/uploadFieldUtils.ts b/packages/plugins/@nocobase/plugin-file-manager/src/client/models/uploadFieldUtils.ts new file mode 100644 index 00000000000..c8a6af45b6f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-file-manager/src/client/models/uploadFieldUtils.ts @@ -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; +};