Add offline transfer history and mobile app support

This commit is contained in:
yoyuzh
2026-04-02 17:31:28 +08:00
parent 2cdda3c305
commit f02ff9342f
17 changed files with 2600 additions and 2 deletions

View File

@@ -0,0 +1,609 @@
import React, { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import {
ChevronRight,
Folder,
Download,
Upload,
Plus,
MoreVertical,
Copy,
Share2,
X,
Edit2,
Trash2,
FolderPlus,
ChevronLeft
} 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';
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 default function MobileFiles() {
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
const fileInputRef = useRef<HTMLInputElement | null>(null);
const directoryInputRef = useRef<HTMLInputElement | null>(null);
const uploadMeasurementsRef = useRef(new Map<string, UploadMeasurement>());
const [currentPath, setCurrentPath] = useState<string[]>(initialPath);
const currentPathRef = useRef(currentPath);
const [currentFiles, setCurrentFiles] = useState<UiFile[]>(initialCachedFiles.map(toUiFile));
const [selectedFile, setSelectedFile] = useState<UiFile | null>(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<UiFile | null>(null);
const [fileToDelete, setFileToDelete] = useState<UiFile | null>(null);
const [targetActionFile, setTargetActionFile] = useState<UiFile | null>(null);
const [targetAction, setTargetAction] = useState<NetdiskTargetAction | null>(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 loadCurrentPath = async (pathParts: string[]) => {
const response = await apiRequest<PageResponse<FileMetadata>>(
`/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<FileMetadata[]>(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<InitiateUploadResponse>('/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<FileMetadata>('/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<FileMetadata>(`/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<FileMetadata>(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<FileMetadata>(`/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<Array<FileMetadata | null>>([]);
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<HTMLInputElement>) => {
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<string>(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<HTMLInputElement>) => {
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<FileMetadata>(`/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<DownloadUrlResponse>(`/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 (
<div className="flex flex-col h-[calc(100vh-3.5rem)] relative overflow-hidden text-white bg-[#07101D]">
<div className="pointer-events-none absolute inset-0 z-0">
<div className="absolute top-[-12%] left-[-24%] h-72 w-72 rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[100px] animate-blob" />
<div className="absolute top-[22%] right-[-20%] h-80 w-80 rounded-full bg-purple-600 opacity-20 mix-blend-screen blur-[100px] animate-blob animation-delay-2000" />
<div className="absolute bottom-[-18%] left-[8%] h-80 w-80 rounded-full bg-indigo-600 opacity-20 mix-blend-screen blur-[100px] animate-blob animation-delay-4000" />
</div>
<input type="file" multiple ref={fileInputRef} className="hidden" onChange={handleFileChange} />
<input type="file" ref={directoryInputRef} className="hidden" onChange={handleFolderChange} />
{/* Top Header - Path navigation */}
<div className="flex-none px-4 py-3 bg-[#0f172a]/80 border-b border-white/5 sticky top-0 z-20 shadow-md backdrop-blur-xl">
<div className="flex flex-nowrap items-center text-sm overflow-x-auto custom-scrollbar whitespace-nowrap">
{currentPath.length > 0 && (
<button className="mr-3 p-1.5 rounded-full bg-white/5 text-slate-300 active:bg-white/10" onClick={handleBackClick}>
<ChevronLeft className="w-4 h-4" />
</button>
)}
<button className="text-slate-400 hover:text-white" onClick={() => handleBreadcrumbClick(-1)}></button>
{currentPath.map((pathItem, index) => (
<React.Fragment key={index}>
<ChevronRight className="w-3 h-3 mx-1 text-slate-600 shrink-0" />
<button onClick={() => handleBreadcrumbClick(index)} className={cn(index === currentPath.length - 1 ? 'text-white font-medium' : 'text-slate-400', 'shrink-0')}>{pathItem}</button>
</React.Fragment>
))}
</div>
</div>
{/* File List */}
<div className="relative z-10 flex-1 overflow-y-auto px-3 py-2 space-y-1.5 pb-24">
{currentFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-slate-500">
<FolderPlus className="w-10 h-10 mb-3 opacity-20" />
<p className="text-sm"></p>
</div>
) : (
currentFiles.map((file) => (
<div key={file.id} className="glass-panel w-full rounded-xl p-3 flex flex-row items-center gap-3 active:bg-white/5 select-none" onClick={() => handleFolderClick(file)}>
<div className="shrink-0 p-1.5 rounded-xl bg-black/20 border border-white/5">
<FileTypeIcon type={file.type} size="md" />
</div>
<div className="flex-1 min-w-0 flex flex-col justify-center">
<span className="text-sm text-white truncate w-full block">{file.name}</span>
<div className="flex items-center text-[10px] text-slate-400 mt-0.5 gap-2">
<span className={cn('px-1.5 py-0.5 rounded text-[9px] font-medium', getFileTypeTheme(file.type).badgeClassName)}>{file.typeLabel}</span>
<span>{file.modified}</span>
{file.type !== 'folder' && <span>{file.size}</span>}
</div>
</div>
{file.type !== 'folder' && (
<button className="p-2 shrink-0 text-slate-400 hover:text-white" onClick={(e) => { e.stopPropagation(); openActionSheet(file); }}>
<MoreVertical className="w-5 h-5" />
</button>
)}
</div>
))
)}
</div>
{/* Floating Action Button (FAB) + Menu */}
<div className="fixed bottom-20 right-6 z-30 flex flex-col items-end gap-3 pointer-events-none">
<AnimatePresence>
{fabOpen && (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} className="flex flex-col gap-3 pointer-events-auto items-end mr-1">
<button onClick={() => { fileInputRef.current?.click(); setFabOpen(false); }} className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-blue-500 text-white shadow-lg active:scale-95 text-sm font-medium">
<Upload className="w-4 h-4"/>
</button>
<button onClick={() => { directoryInputRef.current?.click(); setFabOpen(false); }} className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-emerald-500 text-white shadow-lg active:scale-95 text-sm font-medium">
<FolderPlus className="w-4 h-4"/>
</button>
<button onClick={handleCreateFolder} className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-purple-500 text-white shadow-lg active:scale-95 text-sm font-medium">
<Plus className="w-4 h-4"/>
</button>
</motion.div>
)}
</AnimatePresence>
<button onClick={() => setFabOpen(!fabOpen)} className={cn("pointer-events-auto flex items-center justify-center w-14 h-14 rounded-full shadow-2xl transition-transform active:scale-95", fabOpen ? "bg-[#0f172a] border border-white/10 rotate-45" : "bg-[#336EFF]")}>
<Plus className="w-6 h-6 text-white" />
</button>
</div>
{/* FAB Backdrop */}
<AnimatePresence>
{fabOpen && <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-20 bg-black/40 backdrop-blur-sm" onClick={() => setFabOpen(false)} />}
</AnimatePresence>
{/* Action Sheet */}
<AnimatePresence>
{actionSheetOpen && selectedFile && (
<div className="fixed inset-0 z-50 flex items-end">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={closeActionSheet} />
<motion.div initial={{ y: '100%' }} animate={{ y: 0 }} exit={{ y: '100%' }} transition={{ type: "spring", damping: 25, stiffness: 200 }} className="relative w-full bg-[#0f172a] rounded-t-3xl border-t border-white/10 pt-4 pb-8 px-4 flex flex-col z-10 glass-panel">
<div className="w-12 h-1 bg-white/20 rounded-full mx-auto mb-4" />
<div className="flex border-b border-white/10 pb-4 mb-4 gap-4 items-center px-2">
<FileTypeIcon type={selectedFile.type} size="md" />
<div className="min-w-0">
<p className="text-sm font-semibold truncate text-white">{selectedFile.name}</p>
<p className="text-xs text-slate-400 mt-1">{selectedFile.size} {selectedFile.modified}</p>
</div>
</div>
<div className="grid grid-cols-4 gap-2 mb-4 px-2">
<ActionButton icon={Download} label="下载" onClick={() => { handleDownload(); closeActionSheet(); }} color="text-amber-400" />
<ActionButton icon={Share2} label="分享" onClick={() => handleShare(selectedFile)} color="text-emerald-400" />
<ActionButton icon={Copy} label="复制" onClick={() => openTargetActionModal(selectedFile, 'copy')} color="text-blue-400" />
<ActionButton icon={Folder} label="移动" onClick={() => openTargetActionModal(selectedFile, 'move')} color="text-indigo-400" />
<ActionButton icon={Edit2} label="重命名" onClick={() => openRenameModal(selectedFile)} color="text-slate-300" />
<ActionButton icon={Trash2} label="删除" onClick={() => openDeleteModal(selectedFile)} color="text-red-400" />
</div>
{shareStatus && <div className="mx-2 mt-2 p-3 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-[10px] text-emerald-400 break-all">{shareStatus}</div>}
<Button variant="ghost" onClick={closeActionSheet} className="mt-4 text-slate-400 py-6 text-sm"></Button>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Target Action Modal */}
{targetAction && (
<NetdiskPathPickerModal
isOpen
title={targetAction === 'move' ? '移动到' : '复制到'}
confirmLabel={targetAction === 'move' ? '移动至此' : '复制至此'}
onClose={() => setTargetAction(null)}
onConfirm={(path) => void handleMoveToPath(path)}
/>
)}
{/* Rename Modal */}
<AnimatePresence>
{renameModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setRenameModalOpen(false)} />
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="relative w-full max-w-sm glass-panel bg-[#0f172a] border border-white/10 rounded-2xl p-5 z-10 shadow-2xl">
<h3 className="text-lg font-bold text-white mb-4"></h3>
<Input value={newFileName} onChange={(e) => setNewFileName(e.target.value)} className="bg-black/20 text-white mb-2 h-12" placeholder="请输入新名称" />
{renameError && <p className="text-xs text-red-400 mb-4">{renameError}</p>}
<div className="flex gap-3 mt-6">
<Button variant="outline" className="flex-1 bg-white/5 border-white/10 text-white" onClick={() => setRenameModalOpen(false)}></Button>
<Button className="flex-1 bg-[#336EFF] hover:bg-[#2958cc] text-white" onClick={handleRename} disabled={isRenaming}>{isRenaming ? '保存中' : '保存'}</Button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Delete Modal */}
<AnimatePresence>
{deleteModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setDeleteModalOpen(false)} />
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="relative w-full max-w-sm glass-panel bg-[#0f172a] border border-white/10 rounded-2xl p-5 z-10 shadow-2xl">
<h3 className="text-lg font-bold text-white mb-2 flex items-center gap-2"><Trash2 className="text-red-400 w-5 h-5"/></h3>
<p className="text-sm text-slate-300 mb-6 mt-3"> <span className="text-white font-medium break-all">{fileToDelete?.name}</span> </p>
<div className="flex gap-3">
<Button variant="outline" className="flex-1 bg-white/5 border-white/10 text-white" onClick={() => setDeleteModalOpen(false)}></Button>
<Button className="flex-1 bg-red-500 text-white hover:bg-red-600" onClick={handleDelete}></Button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}
function ActionButton({ icon: Icon, label, color, onClick }: any) {
return (
<div className="flex flex-col items-center gap-2 p-2 hover:bg-white/5 rounded-xl transition-colors active:bg-white/10" onClick={onClick}>
<div className={cn("p-3 rounded-full bg-black/20 border border-white/5 shadow-inner", color)}>
<Icon className="w-5 h-5" />
</div>
<span className="text-xs text-slate-300">{label}</span>
</div>
);
}