mirror of
https://github.com/langgenius/dify.git
synced 2026-02-09 23:20:12 -05:00
feat(skill): add three-state upload progress tooltip
Replace simple uploading/success indicator with a full three-state tooltip (uploading, success, partial_error) that overlays the DropTip position. Add upload slice to skill editor store and wire progress tracking into file/folder upload operations.
This commit is contained in:
@@ -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<FileTreeProps> = ({ className }) => {
|
||||
{t('skillSidebar.empty')}
|
||||
</span>
|
||||
</div>
|
||||
<DropTip />
|
||||
<UploadStatusTooltip fallback={<DropTip />} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -271,7 +272,7 @@ const FileTree: React.FC<FileTreeProps> = ({ 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<FileTreeProps> = ({ className }) => {
|
||||
</div>
|
||||
{dragOverFolderId
|
||||
? <DragActionTooltip action={currentDragType ?? 'upload'} />
|
||||
: <DropTip />}
|
||||
: <UploadStatusTooltip fallback={<DropTip />} />}
|
||||
</div>
|
||||
<TreeContextMenu treeRef={treeRef} />
|
||||
</>
|
||||
|
||||
@@ -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<UploadStatusTooltipProps> = ({ fallback }) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const storeApi = useWorkflowStore()
|
||||
const uploadStatus = useStore(s => s.uploadStatus)
|
||||
const uploadProgress = useStore(s => s.uploadProgress)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<div className="flex shrink-0 items-center justify-center px-2 py-3">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex w-full items-center gap-2 overflow-hidden rounded-lg py-1.5 pl-3 pr-2.5 shadow-lg backdrop-blur-[5px]',
|
||||
'border-[0.5px] border-components-panel-border bg-components-tooltip-bg',
|
||||
)}
|
||||
>
|
||||
{uploadStatus === 'uploading' && (
|
||||
<div className="absolute inset-[-0.5px] animate-pulse bg-state-accent-hover-alt opacity-60" />
|
||||
)}
|
||||
{uploadStatus === 'success' && (
|
||||
<div className="absolute inset-[-0.5px] bg-toast-success-bg opacity-40" />
|
||||
)}
|
||||
{uploadStatus === 'partial_error' && (
|
||||
<div className="absolute inset-[-0.5px] bg-state-warning-hover opacity-40" />
|
||||
)}
|
||||
|
||||
<div className="relative z-10 shrink-0">
|
||||
{uploadStatus === 'uploading' && (
|
||||
<RiUploadCloud2Line className="size-5 text-text-accent" />
|
||||
)}
|
||||
{uploadStatus === 'success' && (
|
||||
<RiCheckboxCircleFill className="size-5 text-text-success" />
|
||||
)}
|
||||
{uploadStatus === 'partial_error' && (
|
||||
<RiAlertFill className="size-5 text-text-warning" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex min-w-0 flex-1 flex-col">
|
||||
<span className="system-xs-semibold truncate text-text-primary">
|
||||
{uploadStatus === 'uploading' && t('skillSidebar.uploadingItems', {
|
||||
uploaded: uploadProgress.uploaded,
|
||||
total: uploadProgress.total,
|
||||
})}
|
||||
{uploadStatus === 'success' && t('skillSidebar.uploadSuccess')}
|
||||
{uploadStatus === 'partial_error' && t('skillSidebar.uploadPartialError')}
|
||||
</span>
|
||||
<span className="system-2xs-regular truncate text-text-tertiary">
|
||||
{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'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
className="relative z-10 shrink-0 rounded p-0.5 text-text-tertiary hover:text-text-secondary focus-visible:outline focus-visible:outline-2 focus-visible:outline-state-accent-solid"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(UploadStatusTooltip)
|
||||
@@ -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<HTMLInputElement>(null)
|
||||
const folderInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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<string, File>()
|
||||
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,
|
||||
|
||||
@@ -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<SkillEditorSliceShape> = (...args) => ({
|
||||
...createTabSlice(...args),
|
||||
@@ -23,6 +25,7 @@ export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...a
|
||||
...createDirtySlice(...args),
|
||||
...createMetadataSlice(...args),
|
||||
...createFileOperationsMenuSlice(...args),
|
||||
...createUploadSlice(...args),
|
||||
|
||||
resetSkillEditor: () => {
|
||||
const [set] = args
|
||||
@@ -40,6 +43,8 @@ export const createSkillEditorSlice: StateCreator<SkillEditorSliceShape> = (...a
|
||||
dirtyMetadataIds: new Set<string>(),
|
||||
contextMenu: null,
|
||||
fileTreeSearchTerm: '',
|
||||
uploadStatus: 'idle',
|
||||
uploadProgress: { uploaded: 0, total: 0, failed: 0 },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "内部结构",
|
||||
|
||||
Reference in New Issue
Block a user