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:
yyh
2026-01-28 15:51:52 +08:00
parent 156b779a1d
commit 543802cc65
8 changed files with 203 additions and 31 deletions

View File

@@ -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} />
</>

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 },
})
},
})

View File

@@ -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
}

View File

@@ -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 },
}),
})

View File

@@ -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",

View File

@@ -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": "内部结构",