Neptune 3 år sedan
förälder
incheckning
5852628513

+ 132 - 0
content/input/upload/index-en-US.md

@@ -484,6 +484,41 @@ import { IconUpload, IconFile } from '@douyinfe/semi-icons';
 };
 ```
 
+### Custom list operation area
+
+When `listType` is `list`, you can customize the list operation area by passing in `renderFileOperation`
+
+```jsx live=true width=48%
+import React from 'react';
+import { Upload, Button } from '@douyinfe/semi-ui';
+import { IconUpload, IconDownload, IconEyeOpened } from '@douyinfe/semi-icons';
+
+() => {
+    let action = 'https://run.mocky.io/v3/d6ac5c9e-4d39-4309-a747-7ed3b5694859';
+
+    const defaultFileList = [
+        {
+            uid: '1',
+            name: 'vigo.png',
+            status: 'success',
+            size: '130KB',
+            preview: true,
+            url: 'https://sf6-cdn-tos.douyinstatic.com/img/ee-finolhu/c2a65140483e4a20802d64af5fec1b39~noop.image',
+        }
+    ];
+    const renderFileOperation = (fileItem) => (
+        <div style={{display: 'flex',columnGap: 8, padding: '0 8px'}}>
+            <Button icon={<IconEyeOpened></IconEyeOpened>} type="tertiary" theme="borderless" size="small"></Button>
+            <Button icon={<IconDownload></IconDownload>} type="tertiary" theme="borderless" size="small"></Button>
+            <Button onClick={e=>fileItem.onRemove()} icon={<IconDelete></IconDelete>} type="tertiary" theme="borderless" size="small"></Button>
+        </div>
+    )
+    return <Upload action={action} defaultFileList={defaultFileList} itemStyle={{width: 300}} renderFileOperation={renderFileOperation}>
+            <Button icon={<IconUpload />} theme="light">Upload</Button>
+        </Upload>
+    }
+```
+
 ### Default file list
 
 The uploaded files can be displayed through `defaultFileList`. When you need to preview the thumbnail of the default file, you can set the `preview` attribute of the corresponding `item` in `defaultFileList` to `true`
@@ -653,6 +688,100 @@ import { IconPlus } from '@douyinfe/semi-icons';
 };
 ```
 
+You can customize the preview icon through `renderPicPreviewIcon`, `onPreviewClick`, when the replacement icon `showReplace` is displayed, the preview icon will no longer be displayed. <br />
+When you need to customize the preview/replacement function, you need to turn off the replacement function and use `renderPicPreviewIcon` to listen for icon click events. <br />
+`onPreviewClick` listens for the click event of the single image container
+
+```jsx live=true width=48%
+import React from 'react';
+import { Upload } from '@douyinfe/semi-ui';
+import { IconPlus, IconEyeOpened } from '@douyinfe/semi-icons';
+
+() => {
+    let action = 'https://run.mocky.io/v3/d6ac5c9e-4d39-4309-a747-7ed3b5694859';
+    const defaultFileList = [
+        {
+            uid: '1',
+            name: 'jiafang.png',
+            status: 'success',
+            size: '130KB',
+            preview: true,
+            url:
+                'https://sf6-cdn-tos.douyinstatic.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/e82f3b261133d2b20d85e8483c203112.jpg',
+        },
+    ];
+    const handlePreview = (file) => {
+        const feature = "width=300,height=300";
+        window.open(file.url, 'imagePreview', feature);
+    }
+    return (
+        <>
+            <Upload
+                action={action}
+                listType="picture"
+                showPicInfo
+                accept="image/*"
+                multiple
+                defaultFileList={defaultFileList}
+                onPreviewClick={handlePreview}
+                renderPicPreviewIcon={()=><IconEyeOpened style={{color: 'var(--semi-color-white)', fontSize: 24}} />}
+            >
+                <IconPlus size="extra-large" />
+            </Upload>
+        </>
+    );
+};
+```
+
+Set `hotSpotLocation` to customize the order of click hotspots, the default is at the end of the photo wall list
+
+```jsx live=true width=48%
+import React from 'react';
+import { Upload, Select } from '@douyinfe/semi-ui';
+import { IconPlus, IconEyeOpened } from '@douyinfe/semi-icons';
+
+() => {
+    let action = 'https://run.mocky.io/v3/d6ac5c9e-4d39-4309-a747-7ed3b5694859';
+    const defaultFileList = [
+        {
+            uid: '1',
+            name: 'jiafang.png',
+            status: 'success',
+            size: '130KB',
+            preview: true,
+            url:
+                'https://sf6-cdn-tos.douyinstatic.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/e82f3b261133d2b20d85e8483c203112.jpg',
+        },
+    ];
+    const handlePreview = (file) => {
+        const feature = "width=300,height=300";
+        window.open(file.url, 'imagePreview', feature);
+    };
+    const [hotSpotLocation, $hotSpotLocation] = useState('end');
+    return (
+        <>
+            <Select value={hotSpotLocation} onChange={$hotSpotLocation} style={{ width: 120 }}>
+                <Select.Option value='start'>Start</Select.Option>
+                <Select.Option value='end'>End</Select.Option>
+            </Select>
+            <hr />
+            <Upload
+                action={action}
+                listType="picture"
+                showPicInfo
+                accept="image/*"
+                multiple
+                hotSpotLocation={hotSpotLocation}
+                defaultFileList={defaultFileList}
+                onPreviewClick={handlePreview}
+            >
+                <IconPlus size="extra-large" />
+            </Upload>
+        </>
+    );
+};
+```
+
 ### Disabled
 
 ```jsx live=true width=48%
@@ -1102,6 +1231,7 @@ The Upload component is an interactive control that can trigger file selection w
 |fileList | A list of uploaded files. When this value is passed in, upload is a controlled component | Array<FileItem\> | | 1.0.0 |
 |fileName | has the same function as name and is mainly used in Form.Upload. In order to avoid conflicts with the props.name of Field, a renamed props is provided here | string | | 1.0.0 |
 |headers | The headers attached to the upload or the method to return the uploaded additional headers| object\|(file: [File](https://developer.mozilla.org/zh-CN/docs/Web/API/File)) = > object | {} | |
+|hotSpotLocation | 照片墙点击热区的放置位置,可选值 `start`, `end` | string | 'end' | 2.5.0 |
 |itemStyle | Inline style of fileCard | CSSProperties | | 1.0.0 |
 |limit | Maximum number of files allowed to be uploaded | number | | |
 |listType | File list display type, optional `picture`, `list` | string |'list' | |
@@ -1127,7 +1257,9 @@ The Upload component is an interactive control that can trigger file selection w
 |prompt | Custom slot, which can be used to insert prompt text. Different from writing directly in `children`, the content of `prompt` will not trigger upload when clicked.<br/>(In the picture wall mode, the incoming prompt is only supported after v1.3.0) | ReactNode | | |
 |promptPosition | The position of the prompt text. When the listType is list, the reference object is the children element; when the listType is picture, the reference object is the picture list. Optional values ​​`left`, `right`, `bottom`<br/> (In picture wall mode, promptPosition is only supported after v1.3.0) | string |'right' | |
 |renderFileItem | Custom rendering of fileCard | (renderProps: RenderFileItemProps) => ReactNode | | 1.0.0 |
+|renderFileOperation | Custom list item operation area | (renderProps: RenderFileItemProps)=>ReactNode | | 2.5.0 |
 |renderPicInfo| Custom photo wall information, only valid in photo wall mode| (renderProps: RenderFileItemProps)=>ReactNode | | 2.2.0 |
+|renderPicPreviewIcon| The preview icon displayed when customizing the photo wall hover, only valid in photo wall mode | (renderProps: RenderFileItemProps)=>ReactNode | | 2.5.0 |
 |renderThumbnail| Custom picture wall thumb, only valid in photo wall mode| (renderProps: RenderFileItemProps)=>ReactNode | | 2.2.0 |
 |showClear | When limit is not 1 and the current number of uploaded files is greater than 1, whether to show the clear button | boolean | true | 1.0.0 |
 |showPicInfo| Whether to display picture information, only valid in photo wall mode | boolean| false | 2.2.0 |

+ 133 - 0
content/input/upload/index.md

@@ -488,6 +488,41 @@ import { IconUpload, IconFile } from '@douyinfe/semi-icons';
 };
 ```
 
+### 自定义列表操作区
+
+`listType` 为 `list` 时,可以通过传入 `renderFileOperation` 来自定义列表操作区
+
+```jsx live=true width=48%
+import React from 'react';
+import { Upload, Button } from '@douyinfe/semi-ui';
+import { IconUpload, IconDownload, IconEyeOpened } from '@douyinfe/semi-icons';
+
+() => {
+    let action = 'https://run.mocky.io/v3/d6ac5c9e-4d39-4309-a747-7ed3b5694859';
+
+    const defaultFileList = [
+        {
+            uid: '1',
+            name: 'vigo.png',
+            status: 'success',
+            size: '130KB',
+            preview: true,
+            url: 'https://sf6-cdn-tos.douyinstatic.com/img/ee-finolhu/c2a65140483e4a20802d64af5fec1b39~noop.image',
+        }
+    ];
+    const renderFileOperation = (fileItem) => (
+        <div style={{display: 'flex',columnGap: 8, padding: '0 8px'}}>
+            <Button icon={<IconEyeOpened></IconEyeOpened>} type="tertiary" theme="borderless" size="small"></Button>
+            <Button icon={<IconDownload></IconDownload>} type="tertiary" theme="borderless" size="small"></Button>
+            <Button onClick={e=>fileItem.onRemove()} icon={<IconDelete></IconDelete>} type="tertiary" theme="borderless" size="small"></Button>
+        </div>
+    )
+    return <Upload action={action} defaultFileList={defaultFileList} itemStyle={{width: 300}} renderFileOperation={renderFileOperation}>
+            <Button icon={<IconUpload />} theme="light">点击上传</Button>
+        </Upload>
+    }
+```
+
 ### 默认文件列表
 
 通过 `defaultFileList` 可以展示已上传的文件。当需要预览默认文件的缩略图时,你可以将 `defaultFileList` 内对应 `item` 的 `preview` 属性设为 `true`
@@ -665,6 +700,101 @@ import { IconPlus } from '@douyinfe/semi-icons';
 };
 ```
 
+可以通过 `renderPicPreviewIcon`,`onPreviewClick` 来自定义预览图标,当显示替换图标 `showReplace` 时,不会再显示预览图标<br />
+当需要自定义预览/替换功能时,需要关闭替换功能,使用 `renderPicPreviewIcon` 监听图标点击事件即可。<br />
+`onPreviewClick` 监听的是单张图片容器的点击事件
+
+
+```jsx live=true width=48%
+import React from 'react';
+import { Upload } from '@douyinfe/semi-ui';
+import { IconPlus, IconEyeOpened } from '@douyinfe/semi-icons';
+
+() => {
+    let action = 'https://run.mocky.io/v3/d6ac5c9e-4d39-4309-a747-7ed3b5694859';
+    const defaultFileList = [
+        {
+            uid: '1',
+            name: 'jiafang.png',
+            status: 'success',
+            size: '130KB',
+            preview: true,
+            url:
+                'https://sf6-cdn-tos.douyinstatic.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/e82f3b261133d2b20d85e8483c203112.jpg',
+        },
+    ];
+    const handlePreview = (file) => {
+        const feature = "width=300,height=300";
+        window.open(file.url, 'imagePreview', feature);
+    }
+    return (
+        <>
+            <Upload
+                action={action}
+                listType="picture"
+                showPicInfo
+                accept="image/*"
+                multiple
+                defaultFileList={defaultFileList}
+                onPreviewClick={handlePreview}
+                renderPicPreviewIcon={()=><IconEyeOpened style={{color: 'var(--semi-color-white)', fontSize: 24}} />}
+            >
+                <IconPlus size="extra-large" />
+            </Upload>
+        </>
+    );
+};
+```
+
+设置 `hotSpotLocation` 自定义点击热区的顺序,默认在照片墙列表结尾
+
+```jsx live=true width=48%
+import React from 'react';
+import { Upload, Select } from '@douyinfe/semi-ui';
+import { IconPlus, IconEyeOpened } from '@douyinfe/semi-icons';
+
+() => {
+    let action = 'https://run.mocky.io/v3/d6ac5c9e-4d39-4309-a747-7ed3b5694859';
+    const defaultFileList = [
+        {
+            uid: '1',
+            name: 'jiafang.png',
+            status: 'success',
+            size: '130KB',
+            preview: true,
+            url:
+                'https://sf6-cdn-tos.douyinstatic.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/e82f3b261133d2b20d85e8483c203112.jpg',
+        },
+    ];
+    const handlePreview = (file) => {
+        const feature = "width=300,height=300";
+        window.open(file.url, 'imagePreview', feature);
+    };
+    const [hotSpotLocation, $hotSpotLocation] = useState('end');
+    return (
+        <>
+            <Select value={hotSpotLocation} onChange={$hotSpotLocation} style={{ width: 120 }}>
+                <Select.Option value='start'>开始</Select.Option>
+                <Select.Option value='end'>结尾</Select.Option>
+            </Select>
+            <hr />
+            <Upload
+                action={action}
+                listType="picture"
+                showPicInfo
+                accept="image/*"
+                multiple
+                hotSpotLocation={hotSpotLocation}
+                defaultFileList={defaultFileList}
+                onPreviewClick={handlePreview}
+            >
+                <IconPlus size="extra-large" />
+            </Upload>
+        </>
+    );
+};
+```
+
 ### 禁用
 
 ```jsx live=true width=48%
@@ -1115,6 +1245,7 @@ Upload组件是一个可交互的控件,在点击或拖拽时触发文件选
 |fileList | 已上传的文件列表,传入该值时,upload 即为受控组件 | Array<FileItem\> |  | 1.0.0 |
 |fileName | 作用与 name 相同,主要在 Form.Upload 中使用,为了避免与 Field 的 props.name 冲突,此处另外提供一个重命名的 props | string |  | 1.0.0 |
 |headers | 上传时附带的 headers 或返回上传额外 headers 的方法 | object\|(file: [File](https://developer.mozilla.org/zh-CN/docs/Web/API/File)) => object | {} |  |
+|hotSpotLocation | 照片墙点击热区的放置位置,可选值 `start`, `end` | string | 'end' | 2.5.0 |
 |itemStyle | fileCard 的内联样式 | CSSProperties |  | 1.0.0 |
 |limit | 最大允许上传文件个数 | number |  |  |
 |listType | 文件列表展示类型,可选`picture`、`list` | string | 'list' |  |
@@ -1140,7 +1271,9 @@ Upload组件是一个可交互的控件,在点击或拖拽时触发文件选
 |prompt | 自定义插槽,可用于插入提示文本。与直接在 `children` 中写的区别时,`prompt` 的内容在点击时不会触发上传<br/>(图片墙模式下,v1.3.0 后才支持传入 prompt) | ReactNode |  |  |
 |promptPosition | 提示文本的位置,当 listType 为 list 时,参照物为 children 元素;当 listType 为 picture 时,参照物为图片列表。可选值 `left`、`right`、`bottom`<br/>(图片墙模式下,v1.3.0 后才支持使用 promptPosition) | string | 'right' |  |
 |renderFileItem | fileCard 的自定义渲染 | (renderProps: RenderFileItemProps) => ReactNode |  | 1.0.0 |
+|renderFileOperation | 自定义列表项操作区 | (renderProps: RenderFileItemProps)=>ReactNode | | 2.5.0 |
 |renderPicInfo| 自定义照片墙信息,只在照片墙模式下有效| (renderProps: RenderFileItemProps)=>ReactNode | | 2.2.0 |
+|renderPicPreviewIcon| 自定义照片墙hover时展示的预览图标,只在照片墙模式下有效 | (renderProps: RenderFileItemProps)=>ReactNode | | 2.5.0 |
 |renderThumbnail| 自定义图片墙缩略图,只在照片墙模式下有效| (renderProps: RenderFileItemProps)=>ReactNode | | 2.2.0 |
 |showClear | 在 limit 不为 1 且当前已上传文件数大于 1 时,是否展示清空按钮 | boolean | true | 1.0.0 |
 |showPicInfo| 是否显示图片信息,只在照片墙模式下有效| boolean| false | 2.2.0 |

+ 66 - 41
packages/semi-foundation/upload/upload.scss

@@ -1,4 +1,4 @@
-@import "./variables.scss";
+@import './variables.scss';
 
 $module: #{$prefix}-upload;
 
@@ -47,7 +47,7 @@ $module: #{$prefix}-upload;
         align-items: center;
     }
 
-    &[x-prompt-pos="right"] {
+    &[x-prompt-pos='right'] {
         .#{$module}-add {
             display: inline-flex;
         }
@@ -57,7 +57,7 @@ $module: #{$prefix}-upload;
         }
     }
 
-    &[x-prompt-pos="bottom"] {
+    &[x-prompt-pos='bottom'] {
         .#{$module}-add {
             display: flex;
         }
@@ -72,15 +72,14 @@ $module: #{$prefix}-upload;
         }
     }
 
-    &[x-prompt-pos="left"] {
+    &[x-prompt-pos='left'] {
         .#{$module}-add {
             display: inline-flex;
-            order: 2;
         }
 
         .#{$module}-prompt {
             display: inline-flex;
-            order: 1;
+            order: -1;
         }
 
         .#{$module}-file-list {
@@ -247,7 +246,6 @@ $module: #{$prefix}-upload;
         }
 
         &-icon {
-
             &-loading,
             &-error {
                 font-size: $width-upload_file_card-icon;
@@ -275,7 +273,6 @@ $module: #{$prefix}-upload;
             }
 
             .#{$module}-file-card {
-
                 &-info-validate-message {
                     color: $color-upload_file_card_fail_info-text;
                 }
@@ -288,26 +285,18 @@ $module: #{$prefix}-upload;
     &-picture {
         display: flex;
 
-        &[x-prompt-pos="bottom"] {
+        &[x-prompt-pos='bottom'] {
             flex-direction: column;
 
             .#{$module}-prompt {
                 order: 1;
             }
-
-            .#{$module}-add {
-                order: 0;
-            }
         }
 
-        &[x-prompt-pos="right"] {
+        &[x-prompt-pos='right'] {
             .#{$module}-prompt {
                 order: 1;
             }
-
-            .#{$module}-add {
-                order: 0;
-            }
         }
 
         &-add {
@@ -321,7 +310,6 @@ $module: #{$prefix}-upload;
             border: $width-upload_picture_add-border dashed $color-upload-border;
             color: $color-upload-icon;
             border-radius: $radius-upload_picture_add;
-            order: 2;
             cursor: pointer;
 
             &:hover {
@@ -370,38 +358,32 @@ $module: #{$prefix}-upload;
             }
 
             &-close {
-                width: $width-upload_picture_file_card_close;
-                height: $width-upload_picture_file_card_close;
-                background-color: $color-upload_pic_remove-bg;
+                visibility: hidden;
+                display: inline-flex;
                 position: absolute;
                 top: $spacing-upload_picture_file_card_close-top;
                 right: $spacing-upload_picture_file_card_close-right;
-                @include all-center;
-                display: none;
                 border-radius: $radius-upload_picture_file_card_close;
-                color: white;
                 cursor: pointer;
                 transition: all 0s;
             }
-
-            &:hover {
-                .#{$module}-picture-file-card-close {
-                    display: flex;
-                }
-
-                .#{$module}-picture-file-card-replace {
-                    visibility: visible;
-                }
+            &-icon-close {
+                font-size: $width-upload_picture_file_card_close;
+                color: $color-upload_picture_file_card_close-icon;
             }
 
-            .#{$prefix}-progress-circle {
+            &::before {
+                visibility: hidden;
+                background-color: $color-upload_picture_file_card_hover-bg;
+                content: '';
                 position: absolute;
-                top: 50%;
-                left: 50%;
-                transform: translate(-50%, -50%);
+                left: 0;
+                right: 0;
+                top: 0;
+                bottom: 0;
             }
-
             &-retry {
+                visibility: hidden;
                 background-color: $color-upload_file_card_retry-bg;
                 width: $width-upload_file_card_retry;
                 height: $width-upload_file_card_retry;
@@ -416,7 +398,6 @@ $module: #{$prefix}-upload;
                 justify-content: center;
                 cursor: pointer;
             }
-
             &-icon-retry {
                 transform: scale(-1, 1);
                 font-size: $width-upload_file_card_retry-icon;
@@ -432,6 +413,17 @@ $module: #{$prefix}-upload;
                 color: $color-upload_replace-text;
                 transform: translate3D(-50%, -50%, 0);
             }
+
+            &-preview {
+                visibility: hidden;
+                display: inline-flex;
+                position: absolute;
+                cursor: pointer;
+                top: 50%;
+                left: 50%;
+                transform: translate3D(-50%, -50%, 0);
+            }
+
             &-pic-info {
                 display: inline-flex;
                 box-sizing: border-box;
@@ -468,6 +460,39 @@ $module: #{$prefix}-upload;
             &-error {
                 outline: 1px solid $color-upload_picture_file_card_error-border;
             }
+
+            &:hover {
+                &::before {
+                    visibility: visible;
+                }
+
+                .#{$module}-picture-file-card-close {
+                    visibility: visible;
+                }
+                .#{$module}-picture-file-card-replace {
+                    visibility: visible;
+                }
+
+                .#{$module}-picture-file-card-retry {
+                    visibility: visible;
+                }
+                .#{$module}-picture-file-card-preview {
+                    visibility: visible;
+                }
+            }
+
+            &-uploading {
+                &::before {
+                    visibility: visible;
+                }
+            }
+
+            .#{$prefix}-progress-circle {
+                position: absolute;
+                top: 50%;
+                left: 50%;
+                transform: translate(-50%, -50%);
+            }
         }
     }
 
@@ -542,4 +567,4 @@ $module: #{$prefix}-upload;
     }
 }
 
-@import "./rtl.scss";
+@import './rtl.scss';

+ 3 - 1
packages/semi-foundation/upload/variables.scss

@@ -16,7 +16,7 @@ $color-upload_drag_area_tips-text: var(--semi-color-primary); // 上传可拖拽
 $color-upload_file_card_fail_info-text: var(--semi-color-danger); // 上传文件卡片失败提示信息文本颜色
 $color-upload_file_card_preview_placeholder-bg: rgba(var(--semi-grey-3), 1); // 文件卡片默认预览背景颜色
 $color-upload_file_card_preview_placeholder-text: rgba(var(--semi-white), 1); // 文件卡片默认预览图颜色
-$color-upload_file_card_retry-bg: #fff; // 重新上传按钮背景颜色
+$color-upload_file_card_retry-bg: var(--semi-color-white); // 重新上传按钮背景颜色
 $color-upload_file_card_retry-text: var(--semi-color-primary); // 重新上传按钮文本颜色
 $color-upload-icon: var(--semi-color-tertiary); // 图片墙上传图标加号颜色
 $color-upload_pic_add-bg-active: var(--semi-color-fill-2); // 图片墙上传背景色 - 按下
@@ -26,6 +26,8 @@ $color-upload_pic_remove-bg: var(--semi-color-overlay-bg); // 图片墙上传移
 $color-upload_picture_file_card_loading_error-icon: var(--semi-color-danger); // 图片墙上传移除图标颜色
 $color-upload_picture_file_card_error-border: var(--semi-color-danger); // 图片墙上传移除图标颜色
 $color-upload_picture_file_card_pic_info-text: var(--semi-color-white); // 图片墙图片信息(序号)文字颜色
+$color-upload_picture_file_card_close-icon: var(--semi-color-white); // 图片墙关闭图标颜色
+$color-upload_picture_file_card_hover-bg: var(--semi-color-overlay-bg); // 图片墙预览悬浮背景色
 $color-upload_preview-icon: var(--semi-color-text-2); // 上传文件卡片文本颜色
 $color-upload_retry-text: var(--semi-color-primary); // 上传文件卡片重新上传按钮文本颜色
 $color-upload_replace-text: var(--semi-color-white); // 上传文件卡片重新替换按钮文本颜色

+ 22 - 6
packages/semi-ui/upload/_story/upload.stories.js

@@ -2,7 +2,7 @@
 import React, { useState } from 'react';
 import { Upload, Button, Toast, Icon } from '@douyinfe/semi-ui/index';
 import { withField, Form } from '../../form/index';
-import { IconPlus, IconFile, IconUpload } from '@douyinfe/semi-icons';
+import { IconPlus, IconFile, IconUpload, IconEyeOpened, IconDownload, IconDelete } from '@douyinfe/semi-icons';
 
 import FileCard from '../fileCard';
 
@@ -50,7 +50,7 @@ let commonProps = {
     let url = fileItem.url;
     console.log(fileItem);
     window.open(url);
-  },
+  }
 };
 
 export const BasicUsage = () => (
@@ -270,7 +270,7 @@ const defaultFileList = [
       'https://sf6-cdn-tos.douyinstatic.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/bf8647bffab13c38772c9ff94bf91a9d.jpg',
   },
   {
-    uid: '5',
+    uid: '3',
     name: 'jiafang3.jpeg',
     status: 'uploading',
     percent: 50,
@@ -279,7 +279,7 @@ const defaultFileList = [
       'https://sf6-cdn-tos.douyinstatic.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/bf8647bffab13c38772c9ff94bf91a9d.jpg',
   },
   {
-    uid: '5',
+    uid: '4',
     name: 'jiafang3.jpeg',
     status: 'validateFail',
     validateMessage: '文件过大',
@@ -288,7 +288,7 @@ const defaultFileList = [
       'https://sf6-cdn-tos.douyinstatic.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/bf8647bffab13c38772c9ff94bf91a9d.jpg',
   },
   {
-    uid: '4',
+    uid: '5',
     name: 'jiafang4.jpeg',
     status: 'validating',
     validateMessage: '校验中',
@@ -399,11 +399,12 @@ PictureListType.story = {
 export const PictureListTypeWithDefaultFileList = () => (
   <>
     <Upload
-      showReplace
       {...commonProps}
+      showReplace={false}
       action={action}
       listType="picture"
       accept="image/*"
+      renderPicPreviewIcon={()=><IconEyeOpened style={{color: 'var(--semi-color-white)',fontSize: 24}} />}
       defaultFileList={defaultFileList}
     >
       <React.Fragment>
@@ -941,3 +942,18 @@ export const _ForbiddenRemove = () => <ForbiddenRemove />;
 _ForbiddenRemove.story = {
   name: 'forbidden remove',
 };
+
+export const CustomListOperation = () => {
+  const renderFileOperation = (fileItem)=>{
+    return <div style={{display: 'flex',columnGap: 8, padding: '0 8px'}}>
+      <Button icon={<IconEyeOpened></IconEyeOpened>} type="tertiary" theme="borderless" size="small"></Button>
+      <Button icon={<IconDownload></IconDownload>} type="tertiary" theme="borderless" size="small"></Button>
+      <Button onClick={e=>fileItem.onRemove()} icon={<IconDelete></IconDelete>} type="tertiary" theme="borderless" size="small"></Button>
+    </div>
+  }
+  return <Upload defaultFileList={defaultFileList} itemStyle={{width: 300}} renderFileOperation={renderFileOperation}><Button icon={<IconUpload />} theme="light">点击上传</Button></Upload>
+}
+
+CustomListOperation.story = {
+  name: 'custom list operation',
+}

+ 23 - 23
packages/semi-ui/upload/fileCard.tsx

@@ -3,11 +3,11 @@ import cls from 'classnames';
 import PropTypes from 'prop-types';
 import { cssClasses, strings } from '@douyinfe/semi-foundation/upload/constants';
 import { getFileSize } from '@douyinfe/semi-foundation/upload/utils';
-import { IconAlertCircle, IconClose, IconFile, IconRefresh } from '@douyinfe/semi-icons';
+import { IconAlertCircle, IconClose, IconClear, IconFile, IconRefresh, IconEyeOpened } from '@douyinfe/semi-icons';
 import LocaleConsumer from '../locale/localeConsumer';
 import { Locale } from '../locale/interface';
 
-import IconButton from '../iconButton/index';
+import Button from '../button/index';
 import Progress from '../progress/index';
 import Tooltip from '../tooltip/index';
 import Spin from '../spin/index';
@@ -123,10 +123,11 @@ class FileCard extends PureComponent<FileCardProps> {
     }
 
     renderPic(locale: Locale['Upload']): ReactNode {
-        const { url, percent, status, disabled, style, onPreviewClick, showPicInfo, renderPicInfo, renderThumbnail, name, index } = this.props;
+        const { url, percent, status, disabled, style, onPreviewClick, showPicInfo, renderPicInfo, renderPicPreviewIcon, renderThumbnail, name, index } = this.props;
         const showProgress = status === strings.FILE_STATUS_UPLOADING && percent !== 100;
         const showRetry = status === strings.FILE_STATUS_UPLOAD_FAIL && this.props.showRetry;
         const showReplace = status === strings.FILE_STATUS_SUCCESS && this.props.showReplace;
+        const showPreview = status === strings.FILE_STATUS_SUCCESS && !this.props.showReplace;
         const filePicCardCls = cls({
             [`${prefixCls}-picture-file-card`]: true,
             [`${prefixCls}-picture-file-card-disabled`]: disabled,
@@ -134,7 +135,6 @@ class FileCard extends PureComponent<FileCardProps> {
             [`${prefixCls}-picture-file-card-error`]: status === strings.FILE_STATUS_UPLOAD_FAIL,
             [`${prefixCls}-picture-file-card-uploading`]: showProgress
         });
-        const closeCls = `${prefixCls}-picture-file-card-close`;
         const retry = (
             <div role="button" tabIndex={0} className={`${prefixCls}-picture-file-card-retry`} onClick={e => this.onRetry(e)}>
                 <IconRefresh className={`${prefixCls}-picture-file-card-icon-retry`} />
@@ -146,7 +146,16 @@ class FileCard extends PureComponent<FileCardProps> {
                     <ReplaceSvg className={`${prefixCls}-picture-file-card-icon-replace`} />
                 </div>
             </Tooltip>
-
+        );
+        const preview = (
+            <div className={`${prefixCls}-picture-file-card-preview`}>
+                {typeof renderPicPreviewIcon === 'function'? renderPicPreviewIcon(this.props): null}
+            </div>
+        );
+        const close = (
+            <div role="button" tabIndex={0} className={`${prefixCls}-picture-file-card-close`} onClick={e => this.onRemove(e)}>
+                <IconClear className={`${prefixCls}-picture-file-card-icon-close`} />
+            </div>
         );
 
         const picInfo = typeof renderPicInfo === 'function' ? renderPicInfo(this.props) : (
@@ -161,19 +170,16 @@ class FileCard extends PureComponent<FileCardProps> {
                 {showProgress ? <Progress percent={percent} type="circle" size="small" orbitStroke={'#FFF'} aria-label="uploading file progress" /> : null}
                 {showRetry ? retry : null}
                 {showReplace && replace}
+                {showPreview && preview}
                 {showPicInfo && picInfo}
-                {!disabled && (
-                    <div className={closeCls} onClick={e => this.onRemove(e)}>
-                        <IconClose tabIndex={0} role="button" size="extra-small" />
-                    </div>
-                )}
+                {!disabled && close}
                 {this.renderPicValidateMsg()}
             </div>
         );
     }
 
     renderFile(locale: Locale["Upload"]) {
-        const { name, size, percent, url, showRetry: propsShowRetry, showReplace: propsShowReplace, preview, previewFile, status, style, onPreviewClick } = this.props;
+        const { name, size, percent, url, showRetry: propsShowRetry, showReplace: propsShowReplace, preview, previewFile, status, style, onPreviewClick, renderFileOperation } = this.props;
         const fileCardCls = cls({
             [`${prefixCls}-file-card`]: true,
             [`${prefixCls}-file-card-fail`]: status === strings.FILE_STATUS_VALID_FAIL || status === strings.FILE_STATUS_UPLOAD_FAIL,
@@ -195,6 +201,7 @@ class FileCard extends PureComponent<FileCardProps> {
         if (previewFile) {
             previewContent = previewFile(this.props);
         }
+        const operation = typeof renderFileOperation === 'function'? renderFileOperation(this.props) : <Button onClick={e => this.onRemove(e)} type="tertiary" icon={<IconClose />} theme="borderless" size="small" className={closeCls} />;
         return (
             <div role="listitem" className={fileCardCls} style={style} onClick={onPreviewClick}>
                 <div className={previewCls}>
@@ -209,7 +216,7 @@ class FileCard extends PureComponent<FileCardProps> {
                             <span className={`${infoCls}-size`}>{fileSize}</span>
                             {showReplace && (
                                 <Tooltip trigger="hover" position="top" showArrow={false} content={locale.replace}>
-                                    <IconButton
+                                    <Button
                                         onClick={e => this.onReplace(e)}
                                         type="tertiary"
                                         theme="borderless"
@@ -231,31 +238,24 @@ class FileCard extends PureComponent<FileCardProps> {
                         {showRetry ? <span role="button" tabIndex={0} className={`${infoCls}-retry`} onClick={e => this.onRetry(e)}>{locale.retry}</span> : null}
                     </div>
                 </div>
-                <IconButton
-                    onClick={e => this.onRemove(e)}
-                    type="tertiary"
-                    icon={<IconClose />}
-                    theme="borderless"
-                    size="small"
-                    className={closeCls}
-                />
+                {operation}
             </div>
         );
     }
 
     onRemove(e: MouseEvent): void {
         e.stopPropagation();
-        this.props.onRemove(this.props, e);
+        this.props.onRemove();
     }
 
     onReplace(e: MouseEvent): void {
         e.stopPropagation();
-        this.props.onReplace(this.props, e);
+        this.props.onReplace();
     }
 
     onRetry(e: MouseEvent): void {
         e.stopPropagation();
-        this.props.onRetry(this.props, e);
+        this.props.onRetry();
     }
 
     render() {

+ 15 - 6
packages/semi-ui/upload/index.tsx

@@ -2,7 +2,7 @@
 import React, { ReactNode, CSSProperties, RefObject, ChangeEvent, DragEvent } from 'react';
 import cls from 'classnames';
 import PropTypes from 'prop-types';
-import { noop } from 'lodash';
+import { noop, pick } from 'lodash';
 import UploadFoundation, { CustomFile, UploadAdapter, BeforeUploadObjectResult, AfterUploadResult } from '@douyinfe/semi-foundation/upload/foundation';
 import { strings, cssClasses } from '@douyinfe/semi-foundation/upload/constants';
 import FileCard from './fileCard';
@@ -39,6 +39,7 @@ export interface UploadProps {
     fileList?: Array<FileItem>;
     fileName?: string;
     headers?: Record<string, any> | ((file: File) => Record<string, string>);
+    hotSpotLocation?: 'start' | 'end';
     itemStyle?: CSSProperties;
     limit?: number;
     listType?: UploadListType;
@@ -66,6 +67,8 @@ export interface UploadProps {
     renderFileItem?: (renderFileItemProps: RenderFileItemProps) => ReactNode;
     renderPicInfo?: (renderFileItemProps: RenderFileItemProps) => ReactNode;
     renderThumbnail?: (renderFileItemProps: RenderFileItemProps) => ReactNode;
+    renderPicPreviewIcon?: (renderFileItemProps: RenderFileItemProps) => ReactNode;
+    renderFileOperation?: (fileItem: RenderFileItemProps) => ReactNode;
     showClear?: boolean;
     showPicInfo?: boolean; // Show pic info in picture wall
     showReplace?: boolean; // Display replacement function
@@ -111,6 +114,7 @@ class Upload extends BaseComponent<UploadProps, UploadState> {
         fileList: PropTypes.array, // files had been uploaded
         fileName: PropTypes.string, // same as name, to avoid props conflict in Form.Upload
         headers: PropTypes.oneOfType([PropTypes.object, PropTypes.func]),
+        hotSpotLocation: PropTypes.oneOf(['start','end']),
         itemStyle: PropTypes.object,
         limit: PropTypes.number, // 最大允许上传文件个数
         listType: PropTypes.oneOf<UploadProps['listType']>(strings.LIST_TYPE),
@@ -136,6 +140,8 @@ class Upload extends BaseComponent<UploadProps, UploadState> {
         prompt: PropTypes.node,
         promptPosition: PropTypes.oneOf<UploadProps['promptPosition']>(strings.PROMPT_POSITION),
         renderFileItem: PropTypes.func,
+        renderPicPreviewIcon: PropTypes.func,
+        renderFileOperation: PropTypes.func,
         renderPicInfo: PropTypes.func,
         renderThumbnail: PropTypes.func,
         showClear: PropTypes.bool,
@@ -156,6 +162,7 @@ class Upload extends BaseComponent<UploadProps, UploadState> {
         defaultFileList: [],
         disabled: false,
         listType: 'list' as const,
+        hotSpotLocation: 'end',
         multiple: false,
         onAcceptInvalid: noop,
         onChange: noop,
@@ -326,7 +333,7 @@ class Upload extends BaseComponent<UploadProps, UploadState> {
 
     renderFile = (file: FileItem, index: number, locale: Locale['Upload']): ReactNode => {
         const { name, status, validateMessage, _sizeInvalid, uid } = file;
-        const { previewFile, listType, itemStyle, showRetry, showPicInfo, renderPicInfo, renderFileItem, renderThumbnail, disabled, onPreviewClick, showReplace } = this.props;
+        const { previewFile, listType, itemStyle, showPicInfo, renderPicInfo, renderPicPreviewIcon, renderFileOperation, renderFileItem, renderThumbnail, disabled, onPreviewClick } = this.props;
         const onRemove = (): void => this.remove(file);
         const onRetry = (): void => {
             this.foundation.retry(file);
@@ -335,6 +342,7 @@ class Upload extends BaseComponent<UploadProps, UploadState> {
             this.replace(index);
         };
         const fileCardProps = {
+            ...pick(this.props, ['showRetry', 'showReplace', '']),
             ...file,
             previewFile,
             listType,
@@ -342,13 +350,13 @@ class Upload extends BaseComponent<UploadProps, UploadState> {
             onRetry,
             index,
             key: uid || `${name}${index}`,
-            showRetry: typeof file.showRetry !== 'undefined' ? file.showRetry : showRetry,
             style: itemStyle,
             disabled,
             showPicInfo,
             renderPicInfo,
+            renderPicPreviewIcon,
+            renderFileOperation,
             renderThumbnail,
-            showReplace: typeof file.showReplace !== 'undefined' ? file.showReplace : showReplace,
             onReplace,
             onPreviewClick: typeof onPreviewClick !== 'undefined' ? (): void => this.foundation.handlePreviewClick(file) : undefined,
         };
@@ -382,7 +390,7 @@ class Upload extends BaseComponent<UploadProps, UploadState> {
     };
 
     renderFileListPic = () => {
-        const { showUploadList, limit, disabled, children, draggable } = this.props;
+        const { showUploadList, limit, disabled, children, draggable, hotSpotLocation } = this.props;
         const { fileList: stateFileList, dragAreaStatus } = this.state;
         const fileList = this.props.fileList || stateFileList;
         const showAddTriggerInList = limit ? limit > fileList.length : true;
@@ -434,8 +442,9 @@ class Upload extends BaseComponent<UploadProps, UploadState> {
                 {(locale: Locale['Upload']) => (
                     <div {...containerProps}>
                         <div className={mainCls} role="list" aria-label="picture list">
+                            {showAddTriggerInList && hotSpotLocation === 'start' ? addContent : null}
                             {fileList.map((file, index) => this.renderFile(file, index, locale))}
-                            {showAddTriggerInList ? addContent : null}
+                            {showAddTriggerInList && hotSpotLocation === 'end' ? addContent : null}
                         </div>
                     </div>
                 )}

+ 7 - 5
packages/semi-ui/upload/interface.ts

@@ -48,14 +48,16 @@ export interface RenderFileItemProps extends FileItem {
     index?: number;
     previewFile?: (fileItem: RenderFileItemProps) => ReactNode;
     listType: UploadListType;
-    onRemove: (props: RenderFileItemProps, e: MouseEvent) => void;
-    onRetry: (props: RenderFileItemProps, e: MouseEvent) => void;
-    onReplace: (props: RenderFileItemProps, e: MouseEvent) => void;
+    onRemove: () => void;
+    onRetry: () => void;
+    onReplace: () => void;
     key: string;
     showPicInfo?: boolean;
     renderPicInfo?: (renderFileItemProps: RenderFileItemProps) => ReactNode;
-    showRetry: boolean;
-    showReplace: boolean;
+    renderPicPreviewIcon?: (renderFileItemProps: RenderFileItemProps) => ReactNode;
+    renderFileOperation?: (fileItem: RenderFileItemProps) => ReactNode;
+    showRetry?: boolean;
+    showReplace?: boolean;
     style?: CSSProperties;
     disabled: boolean;
     onPreviewClick: () => void;