@@ -1,8 +1,69 @@
import { useEffect , useMemo , useState } from 'react'
import { ChevronRight , Download , FileText , Folder , Plus , RefreshCw , Trash2 , Upload } from 'lucide-react'
import { createDir , deleteFile , downloadFile , getPwd , listFiles } from '../services/sftp'
import { formatBytes , formatSftpDate , formatSftpPermissions } from '../lib/utils'
import type { Connection , SftpFileInfo } from '../types'
import { useEffect , useMemo , useRef , useState , type FocusEvent } from 'react'
import {
AlertCircle ,
CheckCircle2 ,
ChevronDown ,
ChevronRight ,
Download ,
Eye ,
EyeOff ,
FileText ,
Folder ,
FolderPlus ,
LoaderCircle ,
RefreshCw ,
Search ,
Trash2 ,
Upload ,
X ,
} from 'lucide-react'
import {
createDir ,
deleteFile ,
downloadFile ,
getPwd ,
listFiles ,
subscribeUploadProgress ,
uploadFile ,
} from '../services/sftp'
import { cn , formatBytes , formatSftpDate , formatSftpPermissions } from '../lib/utils'
import type { Connection , SftpFileInfo , UploadConflictResponse , UploadTask } from '../types'
import Modal from './Modal'
import SftpCreateDirectoryModal from './SftpCreateDirectoryModal'
interface UploadQueueItem {
id : string
filename : string
status : 'queued' | 'running' | 'success' | 'error' | 'skipped' | 'cancelled'
progress : number
transferredBytes : number
totalBytes : number
message : string
createdAt : number
remoteTaskId? : string
}
type SftpSortField = 'name' | 'mtime'
type SftpSortDirection = 'asc' | 'desc'
type UploadConflictAction = 'skip' | 'overwrite' | 'cancel'
interface UploadConflictDialogState {
visible : boolean
fileName : string
fileType : 'file' | 'dir'
canOverwrite : boolean
queueId : string
message : string
}
const emptyUploadConflictDialog : UploadConflictDialogState = {
visible : false ,
fileName : '' ,
fileType : 'file' ,
canOverwrite : true ,
queueId : '' ,
message : '' ,
}
export default function SftpPane ( {
connection ,
@@ -15,14 +76,95 @@ export default function SftpPane({
onSelectedFilesChange : ( files : string [ ] ) = > void
onRefreshSignal ? : ( refresh : ( ) = > Promise < void > ) = > void
} ) {
const fileInputRef = useRef < HTMLInputElement | null > ( null )
const uploadMenuRef = useRef < HTMLDivElement | null > ( null )
const uploadSubscriptionsRef = useRef < Map < string , ( ) = > void > > ( new Map ( ) )
const uploadConflictResolverRef = useRef < ( ( decision : { action : UploadConflictAction ; applyToAll : boolean } ) = > void ) | null > ( null )
const uploadDispatchInProgressRef = useRef ( false )
const [ currentPath , setCurrentPath ] = useState ( '/' )
const [ entries , setEntries ] = useState < SftpFileInfo [ ] > ( [ ] )
const [ loading , setLoading ] = useState ( false )
const [ error , setError ] = useState < string | null > ( null )
const [ notice , setNotice ] = useState < string | null > ( null )
const [ searchQuery , setSearchQuery ] = useState ( '' )
const [ showHiddenFiles , setShowHiddenFiles ] = useState ( false )
const [ uploadMenuOpen , setUploadMenuOpen ] = useState ( false )
const [ uploadQueue , setUploadQueue ] = useState < UploadQueueItem [ ] > ( [ ] )
const [ createDirectoryModalOpen , setCreateDirectoryModalOpen ] = useState ( false )
const [ activeToolbarField , setActiveToolbarField ] = useState < 'path' | 'search' | null > ( null )
const [ conflictDialog , setConflictDialog ] = useState < UploadConflictDialogState > ( emptyUploadConflictDialog )
const [ applyToAll , setApplyToAll ] = useState ( false )
const [ sortField , setSortField ] = useState < SftpSortField > ( 'name' )
const [ sortDirection , setSortDirection ] = useState < SftpSortDirection > ( 'asc' )
const normalizedSearchQuery = searchQuery . trim ( ) . toLowerCase ( )
const visibleEntries = useMemo (
( ) = > entries . filter ( ( entry ) = > showHiddenFiles || ! entry . name . startsWith ( '.' ) ) ,
[ entries , showHiddenFiles ] ,
)
const filteredEntries = useMemo ( ( ) = > {
if ( ! normalizedSearchQuery ) return visibleEntries
return visibleEntries . filter ( ( entry ) = > entry . name . toLowerCase ( ) . includes ( normalizedSearchQuery ) )
} , [ visibleEntries , normalizedSearchQuery ] )
const sortedEntries = useMemo ( ( ) = > {
return filteredEntries . slice ( ) . sort ( ( left , right ) = > {
if ( left . directory !== right . directory ) {
return left . directory ? - 1 : 1
}
const nameDiff = left . name . localeCompare ( right . name , undefined , {
numeric : true ,
sensitivity : 'base' ,
} )
if ( sortField === 'name' ) {
if ( nameDiff !== 0 ) {
return sortDirection === 'asc' ? nameDiff : - nameDiff
}
return sortDirection === 'asc' ? left . mtime - right.mtime : right.mtime - left . mtime
}
const timeDiff = left . mtime - right . mtime
if ( timeDiff !== 0 ) {
return sortDirection === 'asc' ? timeDiff : - timeDiff
}
return nameDiff
} )
} , [ filteredEntries , sortDirection , sortField ] )
const visibleSelectedFiles = useMemo (
( ) = > filteredEntries . filter ( ( entry ) = > selectedFiles . includes ( entry . name ) ) ,
[ filteredEntries , selectedFiles ] ,
)
const allSelected = useMemo (
( ) = > entries . length > 0 && selectedFiles . length === entries . length ,
[ entries . length , selectedFiles . length ] ,
( ) = > filteredEntries . length > 0 && visibleSelectedFiles . length === filteredEntries . length ,
[ filteredEntries . length , visibleSelectedFiles . length ] ,
)
const activeUploads = useMemo (
( ) = > uploadQueue . filter ( ( item ) = > item . status === 'queued' || item . status === 'running' ) . length ,
[ uploadQueue ] ,
)
const uploadMenuId = 'sftp-upload-menu'
const uploadButtonLabel = activeUploads > 0 ? ` 上传,当前有 ${ activeUploads } 个任务进行中 ` : '上传'
const toolbarIconButtonClass =
'relative inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-700/80 bg-slate-900/80 text-slate-400 transition hover:border-slate-600 hover:bg-slate-800 hover:text-white'
const toolbarActiveIconButtonClass = 'border-blue-500/50 bg-blue-500/10 text-blue-200'
const pathToolbarFieldClass = cn (
'group flex h-9 min-w-0 shrink items-center overflow-hidden rounded-2xl border border-slate-700/80 bg-slate-950/90 px-3 text-sm text-slate-200 shadow-[inset_0_1px_0_rgba(148,163,184,0.08)] transition-[flex-grow,flex-basis,border-color,box-shadow] duration-200 focus-within:border-blue-500/60 focus-within:shadow-[inset_0_1px_0_rgba(96,165,250,0.18)]' ,
activeToolbarField === 'path'
? 'basis-[70%] grow-[7]'
: activeToolbarField === 'search'
? 'basis-[42%] grow-[21]'
: 'basis-[60%] grow-[6]' ,
)
const searchToolbarFieldClass = cn (
'group relative h-9 min-w-0 shrink overflow-hidden rounded-2xl border border-slate-700/80 bg-slate-950/90 text-slate-200 shadow-[inset_0_1px_0_rgba(148,163,184,0.08)] transition-[flex-grow,flex-basis,border-color,box-shadow] duration-200 focus-within:border-blue-500/60 focus-within:shadow-[inset_0_1px_0_rgba(96,165,250,0.18)]' ,
activeToolbarField === 'search'
? 'basis-[58%] grow-[29]'
: activeToolbarField === 'path'
? 'basis-[30%] grow-[3]'
: 'basis-[40%] grow-[4]' ,
)
const refresh = async ( nextPath? : string ) = > {
@@ -46,6 +188,20 @@ export default function SftpPane({
useEffect ( ( ) = > {
let ignore = false
; ( async ( ) = > {
setNotice ( null )
setSearchQuery ( '' )
setShowHiddenFiles ( false )
setUploadQueue ( [ ] )
setCreateDirectoryModalOpen ( false )
setActiveToolbarField ( null )
resolveConflictDecision ( 'cancel' , false )
setConflictDialog ( emptyUploadConflictDialog )
setApplyToAll ( false )
uploadDispatchInProgressRef . current = false
setSortField ( 'name' )
setSortDirection ( 'asc' )
uploadSubscriptionsRef . current . forEach ( ( unsubscribe ) = > unsubscribe ( ) )
uploadSubscriptionsRef . current . clear ( )
try {
const pwd = await getPwd ( connection . id )
if ( ! ignore ) {
@@ -60,6 +216,10 @@ export default function SftpPane({
} ) ( )
return ( ) = > {
ignore = true
resolveConflictDecision ( 'cancel' , false )
uploadDispatchInProgressRef . current = false
uploadSubscriptionsRef . current . forEach ( ( unsubscribe ) = > unsubscribe ( ) )
uploadSubscriptionsRef . current . clear ( )
}
} , [ connection . id ] )
@@ -67,65 +227,455 @@ export default function SftpPane({
onRefreshSignal ? . ( refresh )
} , [ onRefreshSignal ] )
useEffect ( ( ) = > {
if ( showHiddenFiles ) return
const hiddenSelectedFiles = new Set (
entries . filter ( ( entry ) = > entry . name . startsWith ( '.' ) && selectedFiles . includes ( entry . name ) ) . map ( ( entry ) = > entry . name ) ,
)
if ( hiddenSelectedFiles . size === 0 ) return
onSelectedFilesChange ( selectedFiles . filter ( ( name ) = > ! hiddenSelectedFiles . has ( name ) ) )
} , [ entries , onSelectedFilesChange , selectedFiles , showHiddenFiles ] )
useEffect ( ( ) = > {
if ( ! uploadMenuOpen ) return
const handleClickOutside = ( event : MouseEvent ) = > {
if ( ! uploadMenuRef . current ? . contains ( event . target as Node ) ) {
setUploadMenuOpen ( false )
}
}
document . addEventListener ( 'mousedown' , handleClickOutside )
return ( ) = > document . removeEventListener ( 'mousedown' , handleClickOutside )
} , [ uploadMenuOpen ] )
function joinPath ( base : string , name : string ) {
return base === '/' ? ` / ${ name } ` : ` ${ base . replace ( /\/$/ , '' ) } / ${ name } `
}
function updateUploadItem ( itemId : string , updater : ( task : UploadQueueItem ) = > UploadQueueItem ) {
setUploadQueue ( ( prev ) = > prev . map ( ( item ) = > ( item . id === itemId ? updater ( item ) : item ) ) )
}
function addUploadQueueItem ( file : File ) {
const queueId = ` ${ Date . now ( ) } - ${ file . name } - ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } `
setUploadQueue ( ( prev ) = > [
{
id : queueId ,
filename : file.name ,
status : 'queued' ,
progress : 0 ,
transferredBytes : 0 ,
totalBytes : file.size ,
message : '等待上传' ,
createdAt : Date.now ( ) ,
} ,
. . . prev ,
] )
return queueId
}
function getUploadConflict ( error : unknown ) {
const response = ( error as { response ? : { status? : number ; data? : UploadConflictResponse } } ) . response
if ( response ? . status !== 409 || response . data ? . code !== 'SFTP_UPLOAD_CONFLICT' ) {
return null
}
return response . data
}
function resolveConflictDecision ( action : UploadConflictAction , applyDecisionToAll = applyToAll ) {
const resolver = uploadConflictResolverRef . current
uploadConflictResolverRef . current = null
setConflictDialog ( emptyUploadConflictDialog )
setApplyToAll ( false )
resolver ? . ( { action , applyToAll : applyDecisionToAll } )
}
function waitForConflictDecision ( conflict : UploadConflictResponse , queueId : string ) {
setApplyToAll ( false )
setConflictDialog ( {
visible : true ,
fileName : conflict.fileName ,
fileType : conflict.conflictType ,
canOverwrite : conflict.canOverwrite ,
queueId ,
message : conflict.message ,
} )
return new Promise < { action : UploadConflictAction ; applyToAll : boolean } > ( ( resolve ) = > {
uploadConflictResolverRef . current = resolve
} )
}
async function handleDeleteMany() {
const targets = entries . filter ( ( entry ) = > selectedFiles . includes ( entry . name ) )
await Promise . all ( targets . map ( ( entry ) = > deleteFile ( connection . id , joinPath ( currentPath , entry . name ) , entry . directory ) ) )
await refresh ( )
}
async function handleCreateDir() {
const name = window . prompt ( '新建目录名称' )
if ( ! name ) return
async function handleCreateDir ( name : string ) {
await createDir ( connection . id , joinPath ( currentPath , name ) )
await refresh ( )
}
async function startQueuedUpload ( file : File , targetPath : string , queueId : string , overwrite : boolean ) {
const response = await uploadFile ( connection . id , targetPath , file , { overwrite } )
const taskId = response . data . taskId
updateUploadItem ( queueId , ( item ) = > ( {
. . . item ,
remoteTaskId : taskId ,
status : 'running' ,
message : overwrite ? '正在覆盖上传...' : '正在上传...' ,
} ) )
const unsubscribe = subscribeUploadProgress ( taskId , ( task : UploadTask ) = > {
updateUploadItem ( queueId , ( item ) = > ( {
. . . item ,
remoteTaskId : task.taskId ,
status : task.status ,
progress : task.progress ,
transferredBytes : task.transferredBytes ,
totalBytes : task.totalBytes ,
message : task.error || ( task . status === 'success' ? '上传完成' : '正在上传...' ) ,
} ) )
if ( task . status === 'success' ) {
void refresh ( )
}
if ( task . status === 'success' || task . status === 'error' ) {
uploadSubscriptionsRef . current . get ( taskId ) ? . ( )
uploadSubscriptionsRef . current . delete ( taskId )
}
} )
uploadSubscriptionsRef . current . set ( taskId , unsubscribe )
}
async function handleStartUpload ( files : File [ ] ) {
if ( files . length === 0 ) return
if ( uploadDispatchInProgressRef . current ) {
setNotice ( '当前上传批次仍在处理中,请先完成冲突选择。' )
return
}
const targetPath = currentPath
const batchDecision : { applyToAll : boolean ; action : Exclude < UploadConflictAction , 'cancel' > | null } = {
applyToAll : false ,
action : null ,
}
uploadDispatchInProgressRef . current = true
setApplyToAll ( false )
setNotice ( ` 已开始上传到 ${ targetPath } ` )
try {
for ( const file of files ) {
const queueId = addUploadQueueItem ( file )
let overwrite = batchDecision . applyToAll && batchDecision . action === 'overwrite'
while ( true ) {
try {
await startQueuedUpload ( file , targetPath , queueId , overwrite )
break
} catch ( err ) {
const conflict = getUploadConflict ( err )
if ( ! conflict ) {
const message =
( err as { response ? : { data ? : { message? : string ; error? : string } } } ) . response ? . data ? . message ||
( err as { response ? : { data ? : { message? : string ; error? : string } } } ) . response ? . data ? . error ||
'上传失败'
updateUploadItem ( queueId , ( item ) = > ( {
. . . item ,
status : 'error' ,
message ,
} ) )
break
}
updateUploadItem ( queueId , ( item ) = > ( {
. . . item ,
status : 'queued' ,
message : conflict.canOverwrite ? '等待冲突处理' : '检测到同名文件夹,等待处理' ,
} ) )
let decision : { action : UploadConflictAction ; applyToAll : boolean }
if ( batchDecision . applyToAll && batchDecision . action && ( batchDecision . action !== 'overwrite' || conflict . canOverwrite ) ) {
decision = {
action : batchDecision.action ,
applyToAll : true ,
}
} else {
decision = await waitForConflictDecision ( conflict , queueId )
}
if ( decision . action === 'cancel' ) {
batchDecision . applyToAll = false
batchDecision . action = null
updateUploadItem ( queueId , ( item ) = > ( {
. . . item ,
status : 'cancelled' ,
message : '已取消上传' ,
} ) )
setNotice ( '已取消当前上传批次,剩余文件未开始上传。' )
return
}
if ( decision . applyToAll ) {
batchDecision . applyToAll = true
batchDecision . action = decision . action
} else {
batchDecision . applyToAll = false
batchDecision . action = null
}
if ( decision . action === 'skip' ) {
updateUploadItem ( queueId , ( item ) = > ( {
. . . item ,
status : 'skipped' ,
message : '已跳过冲突文件' ,
} ) )
break
}
overwrite = true
updateUploadItem ( queueId , ( item ) = > ( {
. . . item ,
status : 'queued' ,
message : '准备覆盖上传' ,
} ) )
}
}
}
} finally {
uploadDispatchInProgressRef . current = false
setApplyToAll ( false )
}
}
function handleUploadFolderPlaceholder() {
setUploadMenuOpen ( false )
setNotice ( '后端暂未支持文件夹上传,后续实现。' )
}
function handleToolbarFieldBlur ( field : 'path' | 'search' , event : FocusEvent < HTMLDivElement > ) {
const nextTarget = event . relatedTarget
if ( nextTarget instanceof Node && event . currentTarget . contains ( nextTarget ) ) {
return
}
setActiveToolbarField ( ( current ) = > ( current === field ? null : current ) )
}
function handleSortChange ( field : SftpSortField ) {
if ( field === sortField ) {
setSortDirection ( ( current ) = > ( current === 'asc' ? 'desc' : 'asc' ) )
return
}
setSortField ( field )
setSortDirection ( field === 'mtime' ? 'desc' : 'asc' )
}
function getAriaSort ( field : SftpSortField ) {
if ( sortField !== field ) return 'none'
return sortDirection === 'asc' ? 'ascending' : 'descending'
}
function clearFinishedUploads() {
setUploadQueue ( ( prev ) = > prev . filter ( ( item ) = > item . status === 'queued' || item . status === 'running' ) )
}
return (
< div className = "relative flex h-full flex-col bg-slate-900" >
< div className = "flex h-10 items-center gap-2 border-b border-slate-800 bg-slate-800/80 px-3" >
< button
className = "rounded p-1 text-slate-400 transition hover:bg-slate-700 hover:text-whit e"
onClick = { ( ) = > {
if ( currentPath === '/' ) return
const parent = currentPath . split ( '/' ) . slice ( 0 , - 1 ) . join ( '/' ) || '/'
void refresh ( parent )
} }
>
< ChevronRight className = "rotate-180" size = { 16 } / >
< / button >
< div className = "flex flex-1 items-center rounded-xl border border-slate-700 bg-slate-950 px-3 py-1 text-sm text-slate-200" >
< Folder size = { 14 } className = "mr-2 text-blu e-4 00" / >
< input
className = "w-full bg-transparent outline-none"
value = { currentPath }
onChange = { ( event ) = > setCurrentPath ( event . target . value ) }
onKeyDown = { ( event ) = > {
if ( event . key === 'Enter' ) void refresh ( currentPath )
< input
ref = { fileInputRef }
type = "fil e"
multiple
className = "hidden"
onChange = { ( event ) = > {
const files = Array . from ( event . target . files ? ? [ ] )
void handleStartUpload ( files )
event . currentTarget . value = ''
} }
/ >
< div className = "flex min-h-14 flex-nowrap items-center gap-2 border-b border-slate-800 bg-slat e-8 00/80 px-3 py-2" >
< div className = "flex min-w-0 flex-1 items-center gap-2 overflow-hidden" >
< button
className = "inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-700/80 bg-slate-900/80 text-slate-400 transition hover:border-slate-600 hover:bg-slate-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
onClick = { ( ) = > {
if ( currentPath === '/' ) return
const parent = currentPath . split ( '/' ) . slice ( 0 , - 1 ) . join ( '/' ) || '/'
void refresh ( parent )
} }
/ >
disabled = { currentPath === '/' }
title = "返回上级目录"
aria-label = "返回上级目录"
>
< ChevronRight className = "rotate-180" size = { 16 } / >
< / button >
< div
className = { pathToolbarFieldClass }
onFocusCapture = { ( ) = > setActiveToolbarField ( 'path' ) }
onBlurCapture = { ( event ) = > handleToolbarFieldBlur ( 'path' , event ) }
>
< Folder
size = { 14 }
className = "mr-2 shrink-0 text-blue-400 transition-colors group-focus-within:text-blue-300"
/ >
< input
className = "min-w-0 flex-1 bg-transparent outline-none placeholder:text-slate-500"
value = { currentPath }
onChange = { ( event ) = > setCurrentPath ( event . target . value ) }
onKeyDown = { ( event ) = > {
if ( event . key === 'Enter' ) void refresh ( currentPath )
} }
/ >
< / div >
< div
className = { searchToolbarFieldClass }
onFocusCapture = { ( ) = > setActiveToolbarField ( 'search' ) }
onBlurCapture = { ( event ) = > handleToolbarFieldBlur ( 'search' , event ) }
>
< Search
size = { 14 }
className = "absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 transition-colors group-focus-within:text-blue-400"
/ >
< input
type = "text"
placeholder = "搜索文件..."
value = { searchQuery }
onChange = { ( event ) = > setSearchQuery ( event . target . value ) }
className = "h-full w-full min-w-0 bg-transparent py-2 pl-9 pr-8 text-xs text-slate-200 outline-none placeholder:text-slate-500"
/ >
{ searchQuery ? (
< button
onClick = { ( ) = > setSearchQuery ( '' ) }
className = "absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-500 transition hover:bg-slate-800 hover:text-slate-300"
title = "清空搜索"
aria-label = "清空搜索"
>
< X size = { 12 } / >
< / button >
) : null }
< / div >
< / div >
< div className = "ml-1 flex gap-1 border-l border-slate-700 pl-2 " >
< button className = "rounded p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-emerald-400" >
< Upload size = { 16 } / >
< / button >
< button className = "rounded p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-blue-400" onClick = { handleCreateDir } >
< Plus size = { 16 } / >
< / button >
< button className = "rounded p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-white" onClick = { ( ) = > void refresh ( ) } >
< div className = "ml-auto flex shrink-0 items-center gap-1.5 rounded-2xl border border-slate-700/80 bg-slate-950/70 p-1 " >
< div className = "relative" ref = { uploadMenuRef } >
< button
className = { cn (
toolbarIconButtonClass ,
uploadMenuOpen || activeUploads > 0
? toolbarActiveIconButtonClass
: undefined ,
) }
onClick = { ( ) = > setUploadMenuOpen ( ( prev ) = > ! prev ) }
title = "上传"
aria-label = { uploadButtonLabel }
aria-haspopup = "menu"
aria-controls = { uploadMenuId }
aria-expanded = { uploadMenuOpen }
>
< Upload size = { 16 } / >
< ChevronDown
size = { 11 }
aria-hidden = "true"
className = { cn (
'absolute bottom-1 right-1 text-slate-500 transition' ,
uploadMenuOpen || activeUploads > 0 ? 'text-blue-200' : 'text-slate-500' ,
uploadMenuOpen && 'rotate-180' ,
) }
/ >
{ activeUploads > 0 ? (
< span
aria-hidden = "true"
className = "absolute -right-1 -top-1 rounded-full bg-blue-500/20 px-1.5 py-0.5 text-[10px] leading-none text-blue-100"
>
{ activeUploads }
< / span >
) : null }
< / button >
{ uploadMenuOpen ? (
< div
id = { uploadMenuId }
role = "menu"
className = "absolute right-0 top-[calc(100%+8px)] z-20 w-44 overflow-hidden rounded-2xl border border-slate-700 bg-slate-900 shadow-2xl shadow-black/40"
>
< button
role = "menuitem"
className = "flex w-full items-center gap-2 px-4 py-3 text-left text-sm text-slate-200 transition hover:bg-slate-800"
onClick = { ( ) = > {
setUploadMenuOpen ( false )
fileInputRef . current ? . click ( )
} }
>
< Upload size = { 14 } className = "text-blue-400" / >
上 传 文 件 . . .
< / button >
< button
role = "menuitem"
className = "flex w-full items-center gap-2 border-t border-slate-800 px-4 py-3 text-left text-sm text-slate-200 transition hover:bg-slate-800"
onClick = { handleUploadFolderPlaceholder }
>
< Folder size = { 14 } className = "text-amber-400" / >
上 传 文 件 夹 . . .
< / button >
< / div >
) : null }
< / div >
< button
className = { toolbarIconButtonClass }
onClick = { ( ) = > void refresh ( ) }
title = "刷新目录"
aria-label = "刷新目录"
>
< RefreshCw size = { 16 } / >
< / button >
< button
className = { cn (
toolbarIconButtonClass ,
showHiddenFiles ? toolbarActiveIconButtonClass : undefined ,
) }
onClick = { ( ) = > setShowHiddenFiles ( ( prev ) = > ! prev ) }
title = { showHiddenFiles ? '关闭隐藏文件显示' : '显示隐藏文件' }
aria-label = { showHiddenFiles ? '关闭隐藏文件显示' : '显示隐藏文件' }
aria-pressed = { showHiddenFiles }
>
{ showHiddenFiles ? < Eye size = { 16 } / > : < EyeOff size = { 16 } / > }
< / button >
< button
className = { toolbarIconButtonClass }
onClick = { ( ) = > setCreateDirectoryModalOpen ( true ) }
title = "新建目录"
aria-label = "新建目录"
>
< FolderPlus size = { 16 } / >
< / button >
< / div >
< / div >
{ notice ? (
< div className = "border-b border-blue-900/30 bg-blue-950/20 px-4 py-2 text-sm text-blue-200" >
{ notice }
< / div >
) : null }
{ selectedFiles . length > 0 ? (
< div className = "flex h-10 items-center justify-between border-b border-blue-900/50 bg-blue-900/15 px-4 text-sm" >
< span className = "text-blue-300" > 已 选 择 { selectedFiles . length } 项 < / span >
< div className = "flex items-center gap-3 text-slate-300" >
< button onClick = { ( ) = > entries . filter ( ( entry ) = > selectedFiles . includes ( entry . name ) ) . forEach ( ( entry ) = > void downloadFile ( connection . id , joinPath ( currentPath , entry . name ) , entry . name ) ) } className = "flex items-center gap-1 hover:text-white" >
< button
onClick = { ( ) = >
entries
. filter ( ( entry ) = > selectedFiles . includes ( entry . name ) )
. forEach ( ( entry ) = > void downloadFile ( connection . id , joinPath ( currentPath , entry . name ) , entry . name ) )
}
className = "flex items-center gap-1 hover:text-white"
>
< Download size = { 14 } / >
下 载
< / button >
@@ -137,21 +687,73 @@ export default function SftpPane({
< / div >
) : null }
< div className = "flex-1 overflow-auto" >
< table className = "w-full text-left text-sm text-slate-300" >
< thead className = "sticky top-0 bg-slate-800/95 text-xs text-slate-400" >
< div className = "flex-1 overflow-auto isolate " >
< table className = "min-w-[48rem] w-full text-left text-sm text-slate-300" >
< thead className = "text-xs text-slate-400" >
< tr >
< th className = "w-1 0 px-4 py-3" >
< th className = "sticky top-0 z-20 w-10 border-b border-slate-700 bg-slate-80 0 px-4 py-3" >
< input
type = "checkbox"
checked = { allSelected }
onChange = { ( ) = > onSelectedFilesChange ( allSelected ? [ ] : entries . map ( ( entry ) = > entry . name ) ) }
onChange = { ( ) = >
onSelectedFilesChange (
allSelected
? selectedFiles . filter ( ( name ) = > ! filteredEntries . some ( ( entry ) = > entry . name === name ) )
: Array . from ( new Set ( [ . . . selectedFiles , . . . filteredEntries . map ( ( entry ) = > entry . name ) ] ) ) ,
)
}
/ >
< / th >
< th className = "px-2 py-3" > 文 件 名 < / th >
< th className = "w-24 px-4 py-3" > 大 小 < / th >
< th className = "w-40 px-4 py-3" > 修 改 时 间 < / th >
< th className = "w-28 px-4 py-3" > 权 限 < / th >
< th className = "sticky top-0 z-20 border-b border-slate-700 bg-slate-800 px-2 py-3" aria-sort = { getAriaSort ( 'name' ) } >
< button
type = "button"
className = { cn (
'inline-flex items-center gap-1.5 rounded-md transition hover:text-white' ,
sortField === 'name' ? 'text-blue-300' : undefined ,
) }
onClick = { ( ) = > handleSortChange ( 'name' ) }
title = { sortField === 'name' && sortDirection === 'asc' ? '按文件名降序排序' : '按文件名升序排序' }
aria-label = { sortField === 'name' && sortDirection === 'asc' ? '按文件名降序排序' : '按文件名升序排序' }
>
< span > 文 件 名 < / span >
{ sortField === 'name' ? (
< ChevronDown
size = { 13 }
className = { cn ( 'transition' , sortDirection === 'asc' ? 'rotate-180' : undefined ) }
/ >
) : (
< span aria-hidden = "true" className = "text-[10px] text-slate-600" >
⇅
< / span >
) }
< / button >
< / th >
< th className = "sticky top-0 z-20 w-24 border-b border-slate-700 bg-slate-800 px-4 py-3" > 大 小 < / th >
< th className = "sticky top-0 z-20 w-44 border-b border-slate-700 bg-slate-800 px-4 py-3" aria-sort = { getAriaSort ( 'mtime' ) } >
< button
type = "button"
className = { cn (
'inline-flex items-center gap-1.5 rounded-md whitespace-nowrap transition hover:text-white' ,
sortField === 'mtime' ? 'text-blue-300' : undefined ,
) }
onClick = { ( ) = > handleSortChange ( 'mtime' ) }
title = { sortField === 'mtime' && sortDirection === 'desc' ? '按修改时间从旧到新排序' : '按修改时间从新到旧排序' }
aria-label = { sortField === 'mtime' && sortDirection === 'desc' ? '按修改时间从旧到新排序' : '按修改时间从新到旧排序' }
>
< span > 修 改 时 间 < / span >
{ sortField === 'mtime' ? (
< ChevronDown
size = { 13 }
className = { cn ( 'transition' , sortDirection === 'asc' ? 'rotate-180' : undefined ) }
/ >
) : (
< span aria-hidden = "true" className = "text-[10px] text-slate-600" >
⇅
< / span >
) }
< / button >
< / th >
< th className = "sticky top-0 z-20 w-28 border-b border-slate-700 bg-slate-800 px-4 py-3" > 权 限 < / th >
< / tr >
< / thead >
< tbody >
@@ -169,8 +771,31 @@ export default function SftpPane({
< / td >
< / tr >
) : null }
{ ! loading && ! error && filteredEntries . length === 0 ? (
< tr >
< td colSpan = { 5 } className = "px-4 py-16 text-center text-slate-500" >
< div className = "mx-auto flex max-w-sm flex-col items-center gap-3 rounded-[28px] border border-dashed border-slate-700 bg-slate-950/40 px-6 py-8" >
< Upload size = { 22 } className = "text-blue-400" / >
< div className = "text-sm text-slate-300" >
{ entries . length === 0
? '当前目录为空'
: visibleEntries . length === 0 && ! normalizedSearchQuery
? '当前目录暂无可见文件'
: '未找到匹配文件' }
< / div >
< div className = "text-xs text-slate-500" >
{ entries . length === 0
? '拖拽文件到这里,或使用上方“上传文件...”入口开始传输。'
: visibleEntries . length === 0 && ! normalizedSearchQuery
? '当前目录内容均为隐藏文件,可点击上方眼睛按钮临时显示。'
: '尝试更换关键词,或清空搜索后查看当前目录全部内容。' }
< / div >
< / div >
< / td >
< / tr >
) : null }
{ ! loading && ! error
? entries . map ( ( entry ) = > {
? sortedEntries . map ( ( entry ) = > {
const checked = selectedFiles . includes ( entry . name )
return (
< tr
@@ -197,16 +822,28 @@ export default function SftpPane({
< span className = "truncate" > { entry . name } < / span >
< / div >
< div className = "absolute right-2 top-1/2 hidden -translate-y-1/2 gap-1 rounded-lg border border-slate-700 bg-slate-900 p-1 group-hover:flex" >
< button className = "p-1 text-slate-400 hover:text-blue-300" onClick = { ( ) = > void downloadFile ( connection . id , joinPath ( currentPath , entry . name ) , entry . name ) } >
< button
className = "p-1 text-slate-400 hover:text-blue-300"
onClick = { ( ) = > void downloadFile ( connection . id , joinPath ( currentPath , entry . name ) , entry . name ) }
title = { ` 下载 ${ entry . name } ` }
aria-label = { ` 下载 ${ entry . name } ` }
>
< Download size = { 13 } / >
< / button >
< button className = "p-1 text-slate-400 hover:text-red-400" onClick = { ( ) = > void deleteFile ( connection . id , joinPath ( currentPath , entry . name ) , entry . directory ) . then ( ( ) = > refresh ( ) ) } >
< button
className = "p-1 text-slate-400 hover:text-red-400"
onClick = { ( ) = > void deleteFile ( connection . id , joinPath ( currentPath , entry . name ) , entry . directory ) . then ( ( ) = > refresh ( ) ) }
title = { ` 删除 ${ entry . name } ` }
aria-label = { ` 删除 ${ entry . name } ` }
>
< Trash2 size = { 13 } / >
< / button >
< / div >
< / td >
< td className = "px-4 py-3 text-slate-400" > { entry . directory ? '-' : formatBytes ( entry . size ) } < / td >
< td className = "px-4 py-3 text-slate-400" > { formatSftpDate ( entry . mtime ) } < / td >
< td className = "whitespace-nowrap px-4 py-3 font-mono text-[11px] tabular-nums text-slate-400" >
{ formatSftpDate ( entry . mtime ) }
< / td >
< td className = "px-4 py-3 font-mono text-xs text-slate-500" > { formatSftpPermissions ( entry ) } < / td >
< / tr >
)
@@ -215,6 +852,132 @@ export default function SftpPane({
< / tbody >
< / table >
< / div >
{ uploadQueue . length > 0 ? (
< div className = "pointer-events-none absolute bottom-4 right-4 z-10 w-[min(360px,calc(100%-2rem))]" >
< div className = "pointer-events-auto overflow-hidden rounded-[28px] border border-slate-700 bg-slate-950/95 shadow-2xl shadow-black/30 backdrop-blur" >
< div className = "flex items-center justify-between border-b border-slate-800 px-4 py-3" >
< div >
< div className = "text-sm font-medium text-slate-100" > 上 传 队 列 < / div >
< div className = "text-xs text-slate-500" > 单 文 件 上 传 已 接 入 现 有 接 口 与 进 度 流 。 < / div >
< / div >
< button className = "text-xs text-slate-400 transition hover:text-white" onClick = { clearFinishedUploads } >
清 空 已 完 成
< / button >
< / div >
< div className = "max-h-72 space-y-3 overflow-auto p-3" >
{ uploadQueue . map ( ( item ) = > (
< div key = { item . id } className = "rounded-2xl border border-slate-800 bg-slate-900/80 p-3" >
< div className = "flex items-start justify-between gap-3" >
< div className = "min-w-0" >
< div className = "truncate text-sm text-slate-100" > { item . filename } < / div >
< div className = "mt-1 flex items-center gap-2 text-xs text-slate-500" >
{ item . status === 'running' || item . status === 'queued' ? (
< LoaderCircle size = { 12 } className = "animate-spin text-blue-400" / >
) : item . status === 'success' ? (
< CheckCircle2 size = { 12 } className = "text-emerald-400" / >
) : item . status === 'skipped' || item . status === 'cancelled' ? (
< X size = { 12 } className = "text-slate-400" / >
) : (
< AlertCircle size = { 12 } className = "text-red-400" / >
) }
< span > { item . message } < / span >
< / div >
< / div >
< span className = "text-xs text-slate-400" > { item . progress } % < / span >
< / div >
< div className = "mt-3 h-2 rounded-full bg-slate-800" >
< div
className = { cn (
'h-2 rounded-full transition-all' ,
item . status === 'success'
? 'bg-emerald-500'
: item . status === 'skipped' || item . status === 'cancelled'
? 'bg-slate-600'
: item . status === 'error'
? 'bg-red-500'
: 'bg-blue-500' ,
) }
style = { { width : ` ${ item . progress } % ` } }
/ >
< / div >
< div className = "mt-2 text-[11px] text-slate-500" >
{ formatBytes ( item . transferredBytes ) } / { formatBytes ( item . totalBytes ) }
< / div >
< / div >
) ) }
< / div >
< / div >
< / div >
) : null }
{ conflictDialog . visible ? (
< Modal
title = "文件冲突"
maxWidth = "max-w-lg"
onClose = { ( ) = > resolveConflictDecision ( 'cancel' , false ) }
footer = {
< >
< button
onClick = { ( ) = > resolveConflictDecision ( 'cancel' , false ) }
className = "rounded bg-slate-700 px-4 py-2 text-sm text-slate-300 transition-colors hover:bg-slate-600 hover:text-white"
>
取 消
< / button >
< button
onClick = { ( ) = > resolveConflictDecision ( 'skip' ) }
className = "rounded bg-slate-700 px-4 py-2 text-sm text-slate-300 transition-colors hover:bg-slate-600 hover:text-white"
>
跳 过
< / button >
< button
onClick = { ( ) = > resolveConflictDecision ( 'overwrite' ) }
disabled = { ! conflictDialog . canOverwrite }
className = { cn (
'rounded px-4 py-2 text-sm text-white transition-colors shadow-lg shadow-blue-500/20' ,
conflictDialog . canOverwrite
? 'bg-blue-600 hover:bg-blue-500'
: 'cursor-not-allowed bg-slate-700 text-slate-500 shadow-none' ,
) }
>
覆 盖
< / button >
< / >
}
>
< div className = "flex items-start gap-3 text-slate-300" >
< AlertCircle className = "mt-0.5 shrink-0 text-yellow-500" size = { 24 } / >
< div >
< p className = "mb-2" >
目 标 目 录 中 已 存 在 名 为 < strong className = "text-white" > { conflictDialog . fileName } < / strong > 的
{ conflictDialog . fileType === 'dir' ? '文件夹' : '文件' } 。
< / p >
< p className = "text-sm text-slate-400" > { conflictDialog . message } < / p >
{ conflictDialog . canOverwrite ? (
< p className = "mt-2 text-sm text-slate-400" > 请 选 择 要 执 行 的 操 作 : 覆 盖 原 有 文 件 , 或 者 跳 过 该 传 输 任 务 。 < / p >
) : (
< p className = "mt-2 text-sm text-slate-400" > 同 名 文 件 夹 无 法 直 接 覆 盖 , 请 选 择 跳 过 该 文 件 或 取 消 当 前 批 次 。 < / p >
) }
< label className = "group mt-5 flex w-max cursor-pointer items-center gap-2" >
< input
type = "checkbox"
checked = { applyToAll }
onChange = { ( event ) = > setApplyToAll ( event . target . checked ) }
className = "cursor-pointer rounded border-slate-600 bg-slate-900 text-blue-500 transition-colors focus:ring-blue-500 focus:ring-offset-slate-800"
/ >
< span className = "text-sm text-slate-300 transition-colors group-hover:text-white" > 应 用 到 之 后 的 所 有 冲 突 文 件 < / span >
< / label >
< / div >
< / div >
< / Modal >
) : null }
< SftpCreateDirectoryModal
open = { createDirectoryModalOpen }
currentPath = { currentPath }
onClose = { ( ) = > setCreateDirectoryModalOpen ( false ) }
onSubmit = { handleCreateDir }
/ >
< / div >
)
}