import React, { useEffect, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import { useNavigate } from 'react-router-dom'; import { ChevronRight, Folder, Download, Upload, Plus, MoreVertical, Copy, Share2, X, Edit2, Trash2, FolderPlus, ChevronLeft, RotateCcw, } from 'lucide-react'; import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal'; import { Button } from '@/src/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card'; import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon'; import { Input } from '@/src/components/ui/input'; import { ApiError, apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api'; import { copyFileToNetdiskPath } from '@/src/lib/file-copy'; import { moveFileToNetdiskPath } from '@/src/lib/file-move'; import { resolveStoredFileType, type FileTypeKind } from '@/src/lib/file-type'; import { readCachedValue, writeCachedValue } from '@/src/lib/cache'; import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share'; import { ellipsizeFileName } from '@/src/lib/file-name'; import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache'; import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types'; import { cn } from '@/src/lib/utils'; // Imports directly from the original pages directories import { buildUploadProgressSnapshot, cancelUploadTask, createUploadMeasurement, createUploadTasks, completeUploadTask, failUploadTask, prepareUploadTaskForCompletion, prepareFolderUploadEntries, prepareUploadFile, shouldUploadEntriesSequentially, type PendingUploadEntry, type UploadMeasurement, type UploadTask, } from '@/src/pages/files-upload'; import { registerFilesUploadTaskCanceler, replaceFilesUploads, setFilesUploadPanelOpen, unregisterFilesUploadTaskCanceler, updateFilesUploadTask, } from '@/src/pages/files-upload-store'; import { clearSelectionIfDeleted, getNextAvailableName, getActionErrorMessage, removeUiFile, replaceUiFile, syncSelectedFile, } from '@/src/pages/files-state'; import { toDirectoryPath, } from '@/src/pages/files-tree'; import { RECYCLE_BIN_RETENTION_DAYS, RECYCLE_BIN_ROUTE } from '@/src/pages/recycle-bin-state'; function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } function toBackendPath(pathParts: string[]) { return toDirectoryPath(pathParts); } function formatFileSize(size: number) { if (size <= 0) return '—'; const units = ['B', 'KB', 'MB', 'GB']; const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1); const value = size / 1024 ** index; return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; } function formatDateTime(value: string) { const date = new Date(value); return `${date.getMonth() + 1}-${date.getDate()} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`; } function toUiFile(file: FileMetadata) { const resolvedType = resolveStoredFileType({ filename: file.filename, contentType: file.contentType, directory: file.directory, }); return { id: file.id, name: file.filename, type: resolvedType.kind, typeLabel: resolvedType.label, size: file.directory ? '—' : formatFileSize(file.size), originalSize: file.directory ? 0 : file.size, modified: formatDateTime(file.createdAt), }; } interface UiFile { id: FileMetadata['id']; modified: string; name: string; size: string; originalSize: number; type: FileTypeKind; typeLabel: string; } type NetdiskTargetAction = 'move' | 'copy'; export function getMobileFilesLayoutClassNames() { return { root: 'relative flex min-h-full flex-col text-white bg-transparent', toolbar: 'sticky top-0 z-30 flex-none px-4 py-2', toolbarInner: 'glass-panel flex items-center gap-3 rounded-[22px] border border-white/10 bg-[#0f172a]/72 px-3.5 py-2.5 shadow-md backdrop-blur-2xl', list: 'relative z-10 flex-1 px-3 pt-2 pb-4 space-y-1.5', }; } export default function MobileFiles() { const navigate = useNavigate(); const initialPath = readCachedValue(getFilesLastPathCacheKey()) ?? []; const initialCachedFiles = readCachedValue(getFilesListCacheKey(toBackendPath(initialPath))) ?? []; const fileInputRef = useRef(null); const directoryInputRef = useRef(null); const uploadMeasurementsRef = useRef(new Map()); const [currentPath, setCurrentPath] = useState(initialPath); const currentPathRef = useRef(currentPath); const [currentFiles, setCurrentFiles] = useState(initialCachedFiles.map(toUiFile)); const [selectedFile, setSelectedFile] = useState(null); // Modals inside mobile action sheet const [actionSheetOpen, setActionSheetOpen] = useState(false); const [renameModalOpen, setRenameModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [fileToRename, setFileToRename] = useState(null); const [fileToDelete, setFileToDelete] = useState(null); const [targetActionFile, setTargetActionFile] = useState(null); const [targetAction, setTargetAction] = useState(null); const [newFileName, setNewFileName] = useState(''); const [renameError, setRenameError] = useState(''); const [isRenaming, setIsRenaming] = useState(false); const [shareStatus, setShareStatus] = useState(''); // Floating Action Button const [fabOpen, setFabOpen] = useState(false); const layoutClassNames = getMobileFilesLayoutClassNames(); const loadCurrentPath = async (pathParts: string[]) => { const response = await apiRequest>( `/files/list?path=${encodeURIComponent(toBackendPath(pathParts))}&page=0&size=100` ); writeCachedValue(getFilesListCacheKey(toBackendPath(pathParts)), response.items); writeCachedValue(getFilesLastPathCacheKey(), pathParts); setCurrentFiles(response.items.map(toUiFile)); }; useEffect(() => { currentPathRef.current = currentPath; const cachedFiles = readCachedValue(getFilesListCacheKey(toBackendPath(currentPath))); writeCachedValue(getFilesLastPathCacheKey(), currentPath); if (cachedFiles) { setCurrentFiles(cachedFiles.map(toUiFile)); } loadCurrentPath(currentPath).catch(() => { if (!cachedFiles) setCurrentFiles([]); }); }, [currentPath]); useEffect(() => { if (directoryInputRef.current) { directoryInputRef.current.setAttribute('webkitdirectory', ''); directoryInputRef.current.setAttribute('directory', ''); } }, []); const handleBreadcrumbClick = (index: number) => { setCurrentPath(currentPath.slice(0, index + 1)); }; const handleBackClick = () => { if (currentPath.length > 0) { setCurrentPath(currentPath.slice(0, -1)); } }; const handleFolderClick = (file: UiFile) => { if (file.type === 'folder') { setCurrentPath([...currentPath, file.name]); } else { openActionSheet(file); } }; const openActionSheet = (file: UiFile) => { setSelectedFile(file); setActionSheetOpen(true); setShareStatus(''); }; const closeActionSheet = () => { setActionSheetOpen(false); }; const openRenameModal = (file: UiFile) => { setFileToRename(file); setNewFileName(file.name); setRenameError(''); setRenameModalOpen(true); closeActionSheet(); }; const openDeleteModal = (file: UiFile) => { setFileToDelete(file); setDeleteModalOpen(true); closeActionSheet(); }; const openTargetActionModal = (file: UiFile, action: NetdiskTargetAction) => { setTargetAction(action); setTargetActionFile(file); closeActionSheet(); }; // Upload Logic (Identical to reference) const runUploadEntries = async (entries: PendingUploadEntry[]) => { if (entries.length === 0) return; setFilesUploadPanelOpen(true); uploadMeasurementsRef.current.clear(); const batchTasks = createUploadTasks(entries); replaceFilesUploads(batchTasks); const runSingleUpload = async ({file: uploadFile, pathParts: uploadPathParts}: PendingUploadEntry, uploadTask: UploadTask) => { const uploadPath = toBackendPath(uploadPathParts); const uploadAbortController = new AbortController(); registerFilesUploadTaskCanceler(uploadTask.id, () => uploadAbortController.abort()); uploadMeasurementsRef.current.set(uploadTask.id, createUploadMeasurement(Date.now())); try { const updateProgress = ({loaded, total}: {loaded: number; total: number}) => { const snapshot = buildUploadProgressSnapshot({ loaded, total, now: Date.now(), previous: uploadMeasurementsRef.current.get(uploadTask.id), }); uploadMeasurementsRef.current.set(uploadTask.id, snapshot.measurement); updateFilesUploadTask(uploadTask.id, (task) => ({ ...task, progress: snapshot.progress, speed: snapshot.speed })); }; let initiated: InitiateUploadResponse | null = null; try { initiated = await apiRequest('/files/upload/initiate', { method: 'POST', body: { path: uploadPath, filename: uploadFile.name, contentType: uploadFile.type || null, size: uploadFile.size }, }); } catch (e) { if (!(e instanceof ApiError && e.status === 404)) throw e; } let uploadedFile: FileMetadata; if (initiated?.direct) { try { await apiBinaryUploadRequest(initiated.uploadUrl, { method: initiated.method, headers: initiated.headers, body: uploadFile, onProgress: updateProgress, signal: uploadAbortController.signal }); uploadedFile = await apiRequest('/files/upload/complete', { method: 'POST', signal: uploadAbortController.signal, body: { path: uploadPath, filename: uploadFile.name, storageName: initiated.storageName, contentType: uploadFile.type || null, size: uploadFile.size } }); } catch (error) { if (!(error instanceof ApiError && error.isNetworkError)) throw error; const formData = new FormData(); formData.append('file', uploadFile); uploadedFile = await apiUploadRequest(`/files/upload?path=${encodeURIComponent(uploadPath)}`, { body: formData, onProgress: updateProgress, signal: uploadAbortController.signal }); } } else if (initiated) { const formData = new FormData(); formData.append('file', uploadFile); uploadedFile = await apiUploadRequest(initiated.uploadUrl, { body: formData, method: initiated.method, headers: initiated.headers, onProgress: updateProgress, signal: uploadAbortController.signal }); } else { const formData = new FormData(); formData.append('file', uploadFile); uploadedFile = await apiUploadRequest(`/files/upload?path=${encodeURIComponent(uploadPath)}`, { body: formData, onProgress: updateProgress, signal: uploadAbortController.signal }); } updateFilesUploadTask(uploadTask.id, (task) => prepareUploadTaskForCompletion(task)); await sleep(120); updateFilesUploadTask(uploadTask.id, (task) => completeUploadTask(task)); return uploadedFile; } catch (error) { if (uploadAbortController.signal.aborted) { updateFilesUploadTask(uploadTask.id, (task) => cancelUploadTask(task)); return null; } updateFilesUploadTask(uploadTask.id, (task) => failUploadTask(task, error instanceof Error && error.message ? error.message : '上传失败')); return null; } finally { uploadMeasurementsRef.current.delete(uploadTask.id); unregisterFilesUploadTaskCanceler(uploadTask.id); } }; if (shouldUploadEntriesSequentially(entries)) { let previousPromise = Promise.resolve>([]); for (let i = 0; i < entries.length; i++) { previousPromise = previousPromise.then(async (prev) => { const current = await runSingleUpload(entries[i], batchTasks[i]); return [...prev, current]; }); } const results = await previousPromise; if (results.some(Boolean)) await loadCurrentPath(currentPathRef.current).catch(() => {}); } else { const results = await Promise.all(entries.map((entry, index) => runSingleUpload(entry, batchTasks[index]))); if (results.some(Boolean)) await loadCurrentPath(currentPathRef.current).catch(() => {}); } }; const handleFileChange = async (event: React.ChangeEvent) => { setFabOpen(false); const files = event.target.files ? (Array.from(event.target.files) as File[]) : []; event.target.value = ''; if (files.length === 0) return; const reservedNames = new Set(currentFiles.map((file) => file.name)); const entries: PendingUploadEntry[] = files.map((file) => { const preparedUpload = prepareUploadFile(file, reservedNames); reservedNames.add(preparedUpload.file.name); return { file: preparedUpload.file, pathParts: [...currentPath], source: 'file', noticeMessage: preparedUpload.noticeMessage }; }); await runUploadEntries(entries); }; const handleFolderChange = async (event: React.ChangeEvent) => { setFabOpen(false); const files = event.target.files ? (Array.from(event.target.files) as File[]) : []; event.target.value = ''; if (files.length === 0) return; const entries = prepareFolderUploadEntries(files, [...currentPath], currentFiles.map((file) => file.name)); await runUploadEntries(entries); }; const handleCreateFolder = async () => { setFabOpen(false); const folderName = window.prompt('请输入新文件夹名称'); if (!folderName?.trim()) return; const normalizedFolderName = folderName.trim(); const nextFolderName = getNextAvailableName(normalizedFolderName, new Set(currentFiles.filter(f => f.type === 'folder').map(f => f.name))); if (nextFolderName !== normalizedFolderName) window.alert(`名称冲突,重命名为 ${nextFolderName}`); const basePath = toBackendPath(currentPath).replace(/\/$/, ''); const fullPath = `${basePath}/${nextFolderName}` || '/'; await apiRequest('/files/mkdir', { method: 'POST', body: new URLSearchParams({ path: fullPath.startsWith('/') ? fullPath : `/${fullPath}` }), headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, }); await loadCurrentPath(currentPath); }; const handleRename = async () => { if (!fileToRename || !newFileName.trim() || isRenaming) return; setIsRenaming(true); setRenameError(''); try { const renamedFile = await apiRequest(`/files/${fileToRename.id}/rename`, { method: 'PATCH', body: { filename: newFileName.trim() }, }); const nextUiFile = toUiFile(renamedFile); setCurrentFiles((prev) => replaceUiFile(prev, nextUiFile)); setSelectedFile((prev) => syncSelectedFile(prev, nextUiFile)); setRenameModalOpen(false); setFileToRename(null); setNewFileName(''); await loadCurrentPath(currentPath).catch(() => {}); } catch (error) { setRenameError(getActionErrorMessage(error, '重命名失败')); } finally { setIsRenaming(false); } }; const handleDelete = async () => { if (!fileToDelete) return; await apiRequest(`/files/${fileToDelete.id}`, { method: 'DELETE' }); setCurrentFiles((prev) => removeUiFile(prev, fileToDelete.id)); setSelectedFile((prev) => clearSelectionIfDeleted(prev, fileToDelete.id)); setDeleteModalOpen(false); setFileToDelete(null); await loadCurrentPath(currentPath).catch(() => {}); }; const handleMoveToPath = async (path: string) => { if (!targetActionFile || !targetAction) return; if (targetAction === 'move') { await moveFileToNetdiskPath(targetActionFile.id, path); setSelectedFile((prev) => clearSelectionIfDeleted(prev, targetActionFile.id)); } else { await copyFileToNetdiskPath(targetActionFile.id, path); } setTargetAction(null); setTargetActionFile(null); await loadCurrentPath(currentPath).catch(() => {}); }; const handleDownload = async (targetFile: UiFile | null = selectedFile) => { const actFile = targetFile || selectedFile; if (!actFile) return; if (actFile.type === 'folder') { const response = await apiDownload(`/files/download/${actFile.id}`); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${actFile.name}.zip`; link.click(); window.URL.revokeObjectURL(url); return; } try { const response = await apiRequest(`/files/download/${actFile.id}/url`); const link = document.createElement('a'); link.href = response.url; link.download = actFile.name; link.rel = 'noreferrer'; link.target = '_blank'; link.click(); return; } catch (error) { if (!(error instanceof ApiError && error.status === 404)) throw error; } const response = await apiDownload(`/files/download/${actFile.id}`); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = actFile.name; link.click(); window.URL.revokeObjectURL(url); }; const handleShare = async (targetFile: UiFile) => { try { const response = await createFileShareLink(targetFile.id); const shareUrl = getCurrentFileShareUrl(response.token); try { await navigator.clipboard.writeText(shareUrl); setShareStatus('链接已复制到剪贴板,快发送给朋友吧'); } catch { setShareStatus(`可全选复制链接:${shareUrl}`); } } catch (error) { setShareStatus(error instanceof Error ? error.message : '分享失败'); } }; return (
{/* Top Header - Path navigation */}
{currentPath.length > 0 && ( )} {currentPath.map((pathItem, index) => ( ))}
{/* File List */}
{currentFiles.length === 0 ? (

文件夹是空的

) : ( currentFiles.map((file) => (
handleFolderClick(file)}>
{file.name}
{file.typeLabel} {file.modified} {file.type !== 'folder' && {file.size}}
{file.type !== 'folder' && ( )}
)) )}
{/* Floating Action Button (FAB) + Menu */}
{fabOpen && ( )}
{/* FAB Backdrop */} {fabOpen && setFabOpen(false)} />} {/* Action Sheet */} {actionSheetOpen && selectedFile && (

{selectedFile.name}

{selectedFile.size} • {selectedFile.modified}

{ handleDownload(); closeActionSheet(); }} color="text-amber-400" /> handleShare(selectedFile)} color="text-emerald-400" /> openTargetActionModal(selectedFile, 'copy')} color="text-blue-400" /> openTargetActionModal(selectedFile, 'move')} color="text-indigo-400" /> openRenameModal(selectedFile)} color="text-slate-300" /> openDeleteModal(selectedFile)} color="text-red-400" />
{shareStatus &&
{shareStatus}
}
)} {/* Target Action Modal */} {targetAction && ( setTargetAction(null)} onConfirm={(path) => void handleMoveToPath(path)} /> )} {/* Rename Modal */} {renameModalOpen && (
setRenameModalOpen(false)} />

重命名文件

setNewFileName(e.target.value)} className="bg-black/20 text-white mb-2 h-12" placeholder="请输入新名称" /> {renameError &&

{renameError}

}
)}
{/* Delete Modal */} {deleteModalOpen && (
setDeleteModalOpen(false)} />

确认删除

确定要将 {fileToDelete?.name} 移入回收站吗?文件会保留 {RECYCLE_BIN_RETENTION_DAYS} 天,期间可以恢复。

)}
); } function ActionButton({ icon: Icon, label, color, onClick }: any) { return (
{label}
); }