diff --git a/web/app/components/workflow/skill/file-tree/index.tsx b/web/app/components/workflow/skill/file-tree/index.tsx index 3eb0ce9718..1158c27a09 100644 --- a/web/app/components/workflow/skill/file-tree/index.tsx +++ b/web/app/components/workflow/skill/file-tree/index.tsx @@ -28,6 +28,7 @@ import { isDescendantOf } from '../utils/tree-utils' import DragActionTooltip from './drag-action-tooltip' import TreeContextMenu from './tree-context-menu' import TreeNode from './tree-node' +import UploadStatusTooltip from './upload-status-tooltip' type FileTreeProps = { className?: string @@ -240,7 +241,7 @@ const FileTree: React.FC = ({ className }) => { {t('skillSidebar.empty')} - + } /> ) } @@ -271,7 +272,7 @@ const FileTree: React.FC = ({ className }) => { data-skill-tree-container className={cn( 'flex min-h-[150px] flex-1 flex-col overflow-y-auto', - isMutating && 'pointer-events-none opacity-50', + isMutating && 'pointer-events-none', className, )} > @@ -314,7 +315,7 @@ const FileTree: React.FC = ({ className }) => { {dragOverFolderId ? - : } + : } />} diff --git a/web/app/components/workflow/skill/file-tree/upload-status-tooltip.tsx b/web/app/components/workflow/skill/file-tree/upload-status-tooltip.tsx new file mode 100644 index 0000000000..1c2c00a78a --- /dev/null +++ b/web/app/components/workflow/skill/file-tree/upload-status-tooltip.tsx @@ -0,0 +1,116 @@ +'use client' + +import type { FC, ReactNode } from 'react' +import { + RiAlertFill, + RiCheckboxCircleFill, + RiCloseLine, + RiUploadCloud2Line, +} from '@remixicon/react' +import { memo, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { useStore, useWorkflowStore } from '@/app/components/workflow/store' +import { cn } from '@/utils/classnames' + +type UploadStatusTooltipProps = { + fallback?: ReactNode +} + +const SUCCESS_DISPLAY_MS = 2000 + +const UploadStatusTooltip: FC = ({ fallback }) => { + const { t } = useTranslation('workflow') + const storeApi = useWorkflowStore() + const uploadStatus = useStore(s => s.uploadStatus) + const uploadProgress = useStore(s => s.uploadProgress) + const timerRef = useRef | null>(null) + + useEffect(() => { + if (timerRef.current) + clearTimeout(timerRef.current) + + if (uploadStatus === 'success') { + timerRef.current = setTimeout(() => { + storeApi.getState().resetUpload() + }, SUCCESS_DISPLAY_MS) + } + + return () => { + if (timerRef.current) + clearTimeout(timerRef.current) + } + }, [storeApi, uploadStatus]) + + if (uploadStatus === 'idle') + return <>{fallback} + + const handleClose = () => { + storeApi.getState().resetUpload() + } + + return ( +
+
+ {uploadStatus === 'uploading' && ( +
+ )} + {uploadStatus === 'success' && ( +
+ )} + {uploadStatus === 'partial_error' && ( +
+ )} + +
+ {uploadStatus === 'uploading' && ( + + )} + {uploadStatus === 'success' && ( + + )} + {uploadStatus === 'partial_error' && ( + + )} +
+ +
+ + {uploadStatus === 'uploading' && t('skillSidebar.uploadingItems', { + uploaded: uploadProgress.uploaded, + total: uploadProgress.total, + })} + {uploadStatus === 'success' && t('skillSidebar.uploadSuccess')} + {uploadStatus === 'partial_error' && t('skillSidebar.uploadPartialError')} + + + {uploadStatus === 'success' && t('skillSidebar.uploadSuccessDetail', { + uploaded: uploadProgress.uploaded, + total: uploadProgress.total, + })} + {uploadStatus === 'partial_error' && t('skillSidebar.uploadPartialErrorDetail', { + failed: uploadProgress.failed, + total: uploadProgress.total, + })} + {uploadStatus === 'uploading' && '\u00A0'} + +
+ + +
+
+ ) +} + +export default memo(UploadStatusTooltip) diff --git a/web/app/components/workflow/skill/hooks/use-create-operations.ts b/web/app/components/workflow/skill/hooks/use-create-operations.ts index 26e7d8a04f..0673b8776f 100644 --- a/web/app/components/workflow/skill/hooks/use-create-operations.ts +++ b/web/app/components/workflow/skill/hooks/use-create-operations.ts @@ -4,8 +4,6 @@ import type { StoreApi } from 'zustand' import type { SkillEditorSliceShape } from '@/app/components/workflow/store/workflow/skill-editor/types' import type { BatchUploadNodeInput } from '@/types/app-asset' import { useCallback, useRef } from 'react' -import { useTranslation } from 'react-i18next' -import Toast from '@/app/components/base/toast' import { useBatchUpload, useCreateAppAssetFolder, @@ -29,7 +27,6 @@ export function useCreateOperations({ storeApi, onClose, }: UseCreateOperationsOptions) { - const { t } = useTranslation('workflow') const fileInputRef = useRef(null) const folderInputRef = useRef(null) @@ -54,33 +51,38 @@ export function useCreateOperations({ return } + const total = files.length + let uploaded = 0 + let failed = 0 + + storeApi.getState().setUploadStatus('uploading') + storeApi.getState().setUploadProgress({ uploaded: 0, total, failed: 0 }) + try { await Promise.all( - files.map(file => - uploadFile.mutateAsync({ - appId, - file, - parentId, - }), - ), + files.map(async (file) => { + try { + await uploadFile.mutateAsync({ appId, file, parentId }) + uploaded++ + } + catch { + failed++ + } + storeApi.getState().setUploadProgress({ uploaded, total, failed }) + }), ) - Toast.notify({ - type: 'success', - message: t('skillSidebar.menu.filesUploaded', { count: files.length }), - }) + storeApi.getState().setUploadStatus(failed > 0 ? 'partial_error' : 'success') + storeApi.getState().setUploadProgress({ uploaded, total, failed }) } catch { - Toast.notify({ - type: 'error', - message: t('skillSidebar.menu.uploadError'), - }) + storeApi.getState().setUploadStatus('partial_error') } finally { e.target.value = '' onClose() } - }, [appId, uploadFile, onClose, parentId, t]) + }, [appId, uploadFile, onClose, parentId, storeApi]) const handleFolderChange = useCallback(async (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []) @@ -89,6 +91,9 @@ export function useCreateOperations({ return } + storeApi.getState().setUploadStatus('uploading') + storeApi.getState().setUploadProgress({ uploaded: 0, total: files.length, failed: 0 }) + try { const fileMap = new Map() const tree: BatchUploadNodeInput[] = [] @@ -135,24 +140,22 @@ export function useCreateOperations({ tree, files: fileMap, parentId, + onProgress: (uploaded, total) => { + storeApi.getState().setUploadProgress({ uploaded, total, failed: 0 }) + }, }) - Toast.notify({ - type: 'success', - message: t('skillSidebar.menu.folderUploaded'), - }) + storeApi.getState().setUploadStatus('success') + storeApi.getState().setUploadProgress({ uploaded: files.length, total: files.length, failed: 0 }) } catch { - Toast.notify({ - type: 'error', - message: t('skillSidebar.menu.uploadError'), - }) + storeApi.getState().setUploadStatus('partial_error') } finally { e.target.value = '' onClose() } - }, [appId, batchUpload, onClose, t]) + }, [appId, batchUpload, onClose, parentId, storeApi]) return { fileInputRef, diff --git a/web/app/components/workflow/store/workflow/skill-editor/index.ts b/web/app/components/workflow/store/workflow/skill-editor/index.ts index 5a4d398319..087504c088 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/index.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/index.ts @@ -7,6 +7,7 @@ import { createFileOperationsMenuSlice } from './file-operations-menu-slice' import { createFileTreeSlice } from './file-tree-slice' import { createMetadataSlice } from './metadata-slice' import { createTabSlice } from './tab-slice' +import { createUploadSlice } from './upload-slice' export type { ClipboardSliceShape } from './clipboard-slice' export type { DirtySliceShape } from './dirty-slice' @@ -15,6 +16,7 @@ export type { FileTreeSliceShape } from './file-tree-slice' export type { MetadataSliceShape } from './metadata-slice' export type { OpenTabOptions, TabSliceShape } from './tab-slice' export type { SkillEditorSliceShape } from './types' +export type { UploadSliceShape } from './upload-slice' export const createSkillEditorSlice: StateCreator = (...args) => ({ ...createTabSlice(...args), @@ -23,6 +25,7 @@ export const createSkillEditorSlice: StateCreator = (...a ...createDirtySlice(...args), ...createMetadataSlice(...args), ...createFileOperationsMenuSlice(...args), + ...createUploadSlice(...args), resetSkillEditor: () => { const [set] = args @@ -40,6 +43,8 @@ export const createSkillEditorSlice: StateCreator = (...a dirtyMetadataIds: new Set(), contextMenu: null, fileTreeSearchTerm: '', + uploadStatus: 'idle', + uploadProgress: { uploaded: 0, total: 0, failed: 0 }, }) }, }) diff --git a/web/app/components/workflow/store/workflow/skill-editor/types.ts b/web/app/components/workflow/store/workflow/skill-editor/types.ts index b706da8dba..5b0ac8ab60 100644 --- a/web/app/components/workflow/store/workflow/skill-editor/types.ts +++ b/web/app/components/workflow/store/workflow/skill-editor/types.ts @@ -96,6 +96,22 @@ export type FileOperationsMenuSliceShape = { setContextMenu: (menu: ContextMenuState | null) => void } +export type UploadStatus = 'idle' | 'uploading' | 'success' | 'partial_error' + +export type UploadProgress = { + uploaded: number + total: number + failed: number +} + +export type UploadSliceShape = { + uploadStatus: UploadStatus + uploadProgress: UploadProgress + setUploadStatus: (status: UploadStatus) => void + setUploadProgress: (progress: UploadProgress) => void + resetUpload: () => void +} + export type SkillEditorSliceShape = TabSliceShape & FileTreeSliceShape @@ -103,6 +119,7 @@ export type SkillEditorSliceShape & DirtySliceShape & MetadataSliceShape & FileOperationsMenuSliceShape + & UploadSliceShape & { resetSkillEditor: () => void } diff --git a/web/app/components/workflow/store/workflow/skill-editor/upload-slice.ts b/web/app/components/workflow/store/workflow/skill-editor/upload-slice.ts new file mode 100644 index 0000000000..aee36644d8 --- /dev/null +++ b/web/app/components/workflow/store/workflow/skill-editor/upload-slice.ts @@ -0,0 +1,20 @@ +import type { StateCreator } from 'zustand' +import type { SkillEditorSliceShape, UploadSliceShape } from './types' + +export type { UploadSliceShape } from './types' + +export const createUploadSlice: StateCreator< + SkillEditorSliceShape, + [], + [], + UploadSliceShape +> = set => ({ + uploadStatus: 'idle', + uploadProgress: { uploaded: 0, total: 0, failed: 0 }, + setUploadStatus: status => set({ uploadStatus: status }), + setUploadProgress: progress => set({ uploadProgress: progress }), + resetUpload: () => set({ + uploadStatus: 'idle', + uploadProgress: { uploaded: 0, total: 0, failed: 0 }, + }), +}) diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 9e0303d36a..b394ce3ade 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -1141,7 +1141,12 @@ "skillSidebar.unsavedChanges.confirmClose": "Discard", "skillSidebar.unsavedChanges.content": "You have unsaved changes. Do you want to discard them?", "skillSidebar.unsavedChanges.title": "Unsaved changes", + "skillSidebar.uploadPartialError": "Some uploads failed", + "skillSidebar.uploadPartialErrorDetail": "{{failed}} of {{total}} uploads failed.", + "skillSidebar.uploadSuccess": "Upload successful", + "skillSidebar.uploadSuccessDetail": "{{uploaded}} of {{total}} uploads complete", "skillSidebar.uploading": "Uploading…", + "skillSidebar.uploadingItems": "Uploading {{uploaded}} of {{total}} items", "subGraphModal.canvasPlaceholder": "Click to configure the internal structure", "subGraphModal.defaultValueHint": "Returns the value below", "subGraphModal.internalStructure": "Internal structure", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 5e39eb1704..353077bbcb 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -1131,7 +1131,12 @@ "skillSidebar.unsavedChanges.confirmClose": "放弃", "skillSidebar.unsavedChanges.content": "您有未保存的更改,是否放弃?", "skillSidebar.unsavedChanges.title": "未保存的更改", + "skillSidebar.uploadPartialError": "部分上传失败", + "skillSidebar.uploadPartialErrorDetail": "{{total}} 个文件中有 {{failed}} 个上传失败。", + "skillSidebar.uploadSuccess": "上传成功", + "skillSidebar.uploadSuccessDetail": "{{total}} 个文件中已完成 {{uploaded}} 个", "skillSidebar.uploading": "上传中…", + "skillSidebar.uploadingItems": "正在上传 {{total}} 个项目中的第 {{uploaded}} 个", "subGraphModal.canvasPlaceholder": "点击配置内部结构", "subGraphModal.defaultValueHint": "返回以下值", "subGraphModal.internalStructure": "内部结构",