feat(portal): land files platform and frontend workspace refresh

This commit is contained in:
yoyuzh
2026-04-09 18:35:03 +08:00
parent 67cd0f6e6f
commit 99e00cd7f7
68 changed files with 5795 additions and 2911 deletions

View File

@@ -11,7 +11,8 @@ import MobileOverview from './mobile-pages/MobileOverview';
import MobileFiles from './mobile-pages/MobileFiles';
import MobileTransfer from './mobile-pages/MobileTransfer';
import MobileFileShare from './mobile-pages/MobileFileShare';
import RecycleBin from './pages/RecycleBin';
import MobileRecycleBin from './mobile-pages/MobileRecycleBin';
import MobileAdminUnavailable from './mobile-pages/MobileAdminUnavailable';
function LegacyTransferRedirect() {
const location = useLocation();
@@ -54,16 +55,16 @@ function MobileAppRoutes() {
<Route index element={<Navigate to="/overview" replace />} />
<Route path="overview" element={<MobileOverview />} />
<Route path="files" element={<MobileFiles />} />
<Route path="recycle-bin" element={<RecycleBin />} />
<Route path="recycle-bin" element={<MobileRecycleBin />} />
<Route path="games" element={<Navigate to="/overview" replace />} />
</Route>
<Route path="/games/:gameId" element={<Navigate to={isAuthenticated ? '/overview' : '/login'} replace />} />
{/* Admin dashboard is not mobile-optimized in this phase yet, redirect to overview or login */}
{/* Admin dashboard is not mobile-optimized in this phase yet, show stub page */}
<Route
path="/admin/*"
element={isAuthenticated ? <Navigate to="/overview" replace /> : <Navigate to="/login" replace />}
element={isAuthenticated ? <MobileAdminUnavailable /> : <Navigate to="/login" replace />}
/>
<Route

View File

@@ -50,7 +50,6 @@ export function Layout({ children }: LayoutProps = {}) {
const navItems = getVisibleNavItems(isAdmin);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null);
const [selectedAvatarFile, setSelectedAvatarFile] = useState<File | null>(null);
@@ -192,7 +191,6 @@ export function Layout({ children }: LayoutProps = {}) {
if (!currentSession) {
return;
}
saveStoredSession({
...currentSession,
user: nextProfile,
@@ -326,113 +324,69 @@ export function Layout({ children }: LayoutProps = {}) {
};
return (
<div className="min-h-screen flex flex-col bg-[#07101D] text-white relative overflow-hidden">
<div className="fixed inset-0 z-0 pointer-events-none">
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[120px] animate-blob" />
<div className="absolute top-[20%] right-[-10%] w-[50%] h-[50%] rounded-full bg-purple-600 opacity-20 mix-blend-screen blur-[120px] animate-blob animation-delay-2000" />
<div className="absolute bottom-[-20%] left-[20%] w-[60%] h-[60%] rounded-full bg-indigo-600 opacity-20 mix-blend-screen blur-[120px] animate-blob animation-delay-4000" />
</div>
<header className="fixed top-0 left-0 right-0 z-50 w-full glass-panel border-b border-white/10 bg-[#07101D]/60 backdrop-blur-xl">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-lg shadow-[#336EFF]/20">
<span className="text-white font-bold text-lg leading-none">Y</span>
</div>
<div className="flex flex-col">
<span className="text-white font-bold text-sm tracking-wider">YOYUZH.XYZ</span>
<span className="text-slate-400 text-[10px] uppercase tracking-widest">Personal Portal</span>
</div>
<div className="flex bg-[#07101D] text-white overflow-hidden w-full h-screen">
<aside className="h-full w-16 md:w-56 flex flex-col shrink-0 border-r border-white/10 bg-[#0f172a]/50">
<div className="h-14 flex items-center md:px-4 justify-center md:justify-start border-b border-white/10">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-lg shadow-[#336EFF]/20 shrink-0">
<span className="text-white font-bold text-lg leading-none">Y</span>
</div>
<div className="hidden md:flex flex-col ml-3">
<span className="text-white font-bold text-sm tracking-wider">YOYUZH.XYZ</span>
</div>
</div>
<nav className="hidden md:flex items-center gap-2">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 relative overflow-hidden group',
isActive ? 'text-white shadow-md shadow-[#336EFF]/20' : 'text-slate-400 hover:text-white hover:bg-white/5',
)
}
>
{({ isActive }) => (
<>
{isActive && <div className="absolute inset-0 bg-[#336EFF] opacity-100 z-0" />}
<item.icon className="w-4 h-4 relative z-10" />
<span className="relative z-10">{item.name}</span>
</>
)}
</NavLink>
))}
</nav>
<div className="flex items-center gap-4 relative">
<button
onClick={() => setIsDropdownOpen((current) => !current)}
className="w-10 h-10 rounded-full bg-slate-800 border border-white/10 flex items-center justify-center text-slate-300 hover:text-white hover:border-white/20 transition-all relative z-10 overflow-hidden"
aria-label="Account"
<nav className="flex-1 flex flex-col gap-2 p-2 relative overflow-y-auto overflow-x-hidden">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-0 md:px-4 justify-center md:justify-start h-10 rounded-xl text-sm font-medium transition-all duration-200 relative overflow-hidden group',
isActive ? 'text-white shadow-md shadow-[#336EFF]/20' : 'text-slate-400 hover:text-white hover:bg-white/5',
)
}
>
{({ isActive }) => (
<>
{isActive && <div className="absolute inset-0 bg-[#336EFF] opacity-100 z-0" />}
<item.icon className="w-[18px] h-[18px] relative z-10 shrink-0" />
<span className="relative z-10 hidden md:block">{item.name}</span>
</>
)}
</NavLink>
))}
</nav>
<div className="p-4 border-t border-white/10 shrink-0 flex flex-col gap-2 relative">
<button
onClick={() => setActiveModal('settings')}
className="w-full flex items-center justify-center md:justify-start gap-3 p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
>
<div className="w-8 h-8 rounded-full border border-white/10 flex items-center justify-center bg-slate-800 text-slate-300 relative z-10 overflow-hidden shrink-0">
{displayedAvatarUrl ? (
<img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span className="text-sm font-semibold">{avatarFallback}</span>
<span className="text-xs font-semibold">{avatarFallback}</span>
)}
</button>
<AnimatePresence>
{isDropdownOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsDropdownOpen(false)} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.15 }}
className="absolute right-0 top-full mt-2 w-56 bg-[#0f172a] border border-white/10 rounded-xl shadow-2xl z-50 py-2 overflow-hidden"
>
<div className="px-4 py-3 border-b border-white/10 mb-2">
<p className="text-sm font-medium text-white">{displayName}</p>
<p className="text-xs text-slate-400 truncate">{email}</p>
</div>
<button
onClick={() => {
setActiveModal('security');
setIsDropdownOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-slate-300 hover:bg-white/10 hover:text-white flex items-center gap-3 transition-colors"
>
<Shield className="w-4 h-4" />
</button>
<button
onClick={() => {
setActiveModal('settings');
setIsDropdownOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-slate-300 hover:bg-white/10 hover:text-white flex items-center gap-3 transition-colors"
>
<Settings className="w-4 h-4" />
</button>
<div className="h-px bg-white/10 my-2" />
<button
onClick={handleLogout}
className="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 flex items-center gap-3 transition-colors"
>
<LogOut className="w-4 h-4" /> 退
</button>
</motion.div>
</>
)}
</AnimatePresence>
</div>
</div>
<div className="hidden md:block flex-1 min-w-0 text-left">
<p className="text-sm font-medium text-white truncate">{displayName}</p>
<p className="text-xs text-slate-400 truncate">{email}</p>
</div>
</button>
<button
onClick={handleLogout}
className="w-full flex items-center justify-center md:justify-start gap-3 md:px-4 h-10 rounded-xl text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors"
>
<LogOut className="w-[18px] h-[18px]" />
<span className="hidden md:block font-medium">退</span>
</button>
</div>
</header>
</aside>
<main className="flex-1 container mx-auto px-4 pt-24 pb-8 relative z-10">
<main className="flex-1 flex flex-col min-w-0 h-full relative overflow-y-auto">
{children ?? <Outlet />}
</main>
@@ -460,80 +414,80 @@ export function Layout({ children }: LayoutProps = {}) {
<div className="space-y-4">
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
<Key className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-slate-400 mt-0.5">使 refresh token </p>
</div>
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
<Key className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-slate-400 mt-0.5">使 refresh token </p>
</div>
</div>
<div className="grid gap-3">
<Input
type="password"
placeholder="当前密码"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
className="bg-black/20 border-white/10"
/>
<Input
type="password"
placeholder="新密码"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
className="bg-black/20 border-white/10"
/>
<Input
type="password"
placeholder="确认新密码"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
className="bg-black/20 border-white/10"
/>
<div className="flex justify-end">
<Button variant="outline" disabled={passwordSubmitting} onClick={() => void handleChangePassword()}>
{passwordSubmitting ? '保存中...' : '修改'}
</Button>
</div>
<Input
type="password"
placeholder="当前密码"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
className="bg-black/20 border-white/10"
/>
<Input
type="password"
placeholder="新密码"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
className="bg-black/20 border-white/10"
/>
<Input
type="password"
placeholder="确认新密码"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
className="bg-black/20 border-white/10"
/>
<div className="flex justify-end">
<Button variant="outline" disabled={passwordSubmitting} onClick={() => void handleChangePassword()}>
{passwordSubmitting ? '保存中...' : '修改'}
</Button>
</div>
</div>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center">
<Smartphone className="w-5 h-5 text-emerald-400" />
</div>
<div>
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-slate-400 mt-0.5">{phoneNumber}</p>
</div>
<div className="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center">
<Smartphone className="w-5 h-5 text-emerald-400" />
</div>
<div>
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-slate-400 mt-0.5">{phoneNumber}</p>
</div>
</div>
<Button
variant="outline"
className="border-white/10 hover:bg-white/10 text-slate-300"
onClick={() => setActiveModal('settings')}
variant="outline"
className="border-white/10 hover:bg-white/10 text-slate-300"
onClick={() => setActiveModal('settings')}
>
</Button>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
<Mail className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-slate-400 mt-0.5">{email}</p>
</div>
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
<Mail className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-slate-400 mt-0.5">{email}</p>
</div>
</div>
<Button
variant="outline"
className="border-white/10 hover:bg-white/10 text-slate-300"
onClick={() => setActiveModal('settings')}
variant="outline"
className="border-white/10 hover:bg-white/10 text-slate-300"
onClick={() => setActiveModal('settings')}
>
</Button>
</div>
</div>
@@ -547,103 +501,124 @@ export function Layout({ children }: LayoutProps = {}) {
{activeModal === 'settings' && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="bg-[#0f172a] border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]"
>
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-white/5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Settings className="w-5 h-5 text-[#336EFF]" />
</h3>
<button onClick={closeModal} className="text-slate-400 hover:text-white transition-colors p-1 rounded-md hover:bg-white/10">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 overflow-y-auto space-y-6">
<div className="flex items-center gap-6 pb-6 border-b border-white/10">
<div className="relative group cursor-pointer" onClick={handleAvatarClick}>
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center text-2xl font-bold text-white shadow-lg overflow-hidden">
{displayedAvatarUrl ? <img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" /> : avatarFallback}
</div>
<div className="absolute inset-0 bg-black/50 rounded-full opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
<span className="text-xs text-white">{selectedAvatarFile ? '等待保存' : '更换头像'}</span>
</div>
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" />
</div>
<div className="flex-1 space-y-1">
<h4 className="text-lg font-medium text-white">{displayName}</h4>
<p className="text-sm text-slate-400">{roleLabel}</p>
</div>
</div>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="bg-[#0f172a] border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]"
>
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-white/5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Settings className="w-5 h-5 text-[#336EFF]" />
</h3>
<button onClick={closeModal} className="text-slate-400 hover:text-white transition-colors p-1 rounded-md hover:bg-white/10">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 overflow-y-auto space-y-6">
<div className="flex items-center gap-6 pb-6 border-b border-white/10">
<div className="relative group cursor-pointer" onClick={handleAvatarClick}>
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center text-2xl font-bold text-white shadow-lg overflow-hidden">
{displayedAvatarUrl ? <img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" /> : avatarFallback}
</div>
<div className="absolute inset-0 bg-black/50 rounded-full opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
<span className="text-xs text-white">{selectedAvatarFile ? '等待保存' : '更换头像'}</span>
</div>
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" />
</div>
<div className="flex-1 space-y-1">
<h4 className="text-lg font-medium text-white">{displayName}</h4>
<p className="text-sm text-slate-400">{roleLabel}</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<Input
value={profileDraft.displayName}
onChange={(event) => handleProfileDraftChange('displayName', event.target.value)}
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
/>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<Input
value={profileDraft.displayName}
onChange={(event) => handleProfileDraftChange('displayName', event.target.value)}
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<Input
type="email"
value={profileDraft.email}
onChange={(event) => handleProfileDraftChange('email', event.target.value)}
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<Input
type="email"
value={profileDraft.email}
onChange={(event) => handleProfileDraftChange('email', event.target.value)}
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<Input
type="tel"
value={profileDraft.phoneNumber}
onChange={(event) => handleProfileDraftChange('phoneNumber', event.target.value)}
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<Input
type="tel"
value={profileDraft.phoneNumber}
onChange={(event) => handleProfileDraftChange('phoneNumber', event.target.value)}
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<textarea
className="w-full min-h-[100px] rounded-md bg-black/20 border border-white/10 text-white p-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] resize-none"
value={profileDraft.bio}
onChange={(event) => handleProfileDraftChange('bio', event.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<textarea
className="w-full min-h-[100px] rounded-md bg-black/20 border border-white/10 text-white p-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] resize-none"
value={profileDraft.bio}
onChange={(event) => handleProfileDraftChange('bio', event.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<select
className="w-full rounded-md bg-black/20 border border-white/10 text-white p-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] appearance-none"
value={profileDraft.preferredLanguage}
onChange={(event) => handleProfileDraftChange('preferredLanguage', event.target.value)}
>
<option value="zh-CN"></option>
<option value="en-US">English</option>
</select>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<select
className="w-full rounded-md bg-black/20 border border-white/10 text-white p-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] appearance-none"
value={profileDraft.preferredLanguage}
onChange={(event) => handleProfileDraftChange('preferredLanguage', event.target.value)}
>
<option value="zh-CN"></option>
<option value="en-US">English</option>
</select>
</div>
{profileError && <p className="text-sm text-rose-300">{profileError}</p>}
{profileMessage && <p className="text-sm text-emerald-300">{profileMessage}</p>}
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
<Key className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-slate-400 mt-0.5"></p>
</div>
</div>
<Button
variant="outline"
className="border-white/10 hover:bg-white/10 text-slate-300"
onClick={() => {
setActiveModal('security');
}}
>
</Button>
</div>
</div>
<div className="pt-4 flex justify-end gap-3">
<Button variant="outline" onClick={closeModal} className="border-white/10 hover:bg-white/10 text-slate-300">
</Button>
<Button variant="default" disabled={profileSubmitting} onClick={() => void handleSaveProfile()}>
{profileSubmitting ? '保存中...' : '保存更改'}
</Button>
</div>
</div>
</motion.div>
{profileError && <p className="text-sm text-rose-300">{profileError}</p>}
{profileMessage && <p className="text-sm text-emerald-300">{profileMessage}</p>}
<div className="pt-4 flex justify-end gap-3">
<Button variant="outline" onClick={closeModal} className="border-white/10 hover:bg-white/10 text-slate-300">
</Button>
<Button variant="default" disabled={profileSubmitting} onClick={() => void handleSaveProfile()}>
{profileSubmitting ? '保存中...' : '保存更改'}
</Button>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>

View File

@@ -0,0 +1,42 @@
import React, { ReactNode } from 'react';
import { cn } from '@/src/lib/utils';
interface AppPageShellProps {
toolbar: ReactNode;
rail?: ReactNode;
inspector?: ReactNode;
children: ReactNode;
}
export function AppPageShell({ toolbar, rail, inspector, children }: AppPageShellProps) {
return (
<div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden relative z-10 w-full bg-[#07101D]">
{/* Top Toolbar */}
<header className="h-14 shrink-0 border-b border-white/10 bg-[#0f172a]/70 flex items-center px-4 w-full z-20 backdrop-blur-xl">
{toolbar}
</header>
{/* 3-Zone Content Segment */}
<div className="flex-1 flex min-h-0 w-full overflow-hidden">
{/* Nav Rail (e.g. Directory Tree) */}
{rail && (
<div className="w-64 shrink-0 border-r border-white/10 bg-[#0f172a]/20 h-full overflow-y-auto">
{rail}
</div>
)}
{/* Center Main Pane */}
<main className="flex-1 min-w-0 h-full overflow-y-auto bg-transparent relative">
{children}
</main>
{/* Inspector Panel (e.g. File Details) */}
{inspector && (
<div className="w-72 shrink-0 border-l border-white/10 bg-[#0f172a]/20 h-full overflow-y-auto hidden lg:block">
{inspector}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import React, { ReactNode } from 'react';
interface PageToolbarProps {
title: ReactNode;
actions?: ReactNode;
}
export function PageToolbar({ title, actions }: PageToolbarProps) {
return (
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
{typeof title === 'string' ? (
<h2 className="text-lg font-semibold text-white tracking-tight">{title}</h2>
) : (
title
)}
</div>
{actions && (
<div className="flex items-center gap-2">
{actions}
</div>
)}
</div>
);
}

View File

@@ -436,15 +436,41 @@ test('apiBinaryUploadRequest sends raw file body to signed upload url', async ()
request.triggerProgress(64, 128);
request.triggerProgress(128, 128);
request.responseHeaders.set('etag', '"etag-1"');
request.respond('', 200, 'text/plain');
await uploadPromise;
const payload = await uploadPromise;
assert.deepEqual(payload, {
status: 200,
headers: {},
});
assert.deepEqual(progressCalls, [
{loaded: 64, total: 128},
{loaded: 128, total: 128},
]);
});
test('apiBinaryUploadRequest returns requested response headers', async () => {
const uploadPromise = apiBinaryUploadRequest('https://upload.example.com/object', {
method: 'PUT',
body: new Blob(['hello-oss']),
responseHeaders: ['etag'],
});
const request = FakeXMLHttpRequest.latest;
assert.ok(request);
request.responseHeaders.set('etag', '"etag-part-2"');
request.respond('', 200, 'text/plain');
const payload = await uploadPromise;
assert.deepEqual(payload, {
status: 200,
headers: {
etag: '"etag-part-2"',
},
});
});
test('apiUploadRequest supports aborting a single upload task', async () => {
const controller = new AbortController();
const formData = new FormData();

View File

@@ -25,9 +25,15 @@ interface ApiBinaryUploadRequestInit {
headers?: HeadersInit;
method?: 'PUT' | 'POST';
onProgress?: (progress: {loaded: number; total: number}) => void;
responseHeaders?: string[];
signal?: AbortSignal;
}
export interface ApiBinaryUploadResponse {
status: number;
headers: Record<string, string>;
}
const AUTH_REFRESH_PATH = '/auth/refresh';
const DEFAULT_API_BASE_URL = '/api';
const DEFAULT_CAPACITOR_API_ORIGIN = 'https://api.yoyuzh.xyz';
@@ -537,7 +543,7 @@ export function apiUploadRequest<T>(path: string, init: ApiUploadRequestInit): P
export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadRequestInit) {
const headers = new Headers(init.headers);
return new Promise<void>((resolve, reject) => {
return new Promise<ApiBinaryUploadResponse>((resolve, reject) => {
const xhr = new XMLHttpRequest();
let settled = false;
@@ -545,14 +551,14 @@ export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadReques
init.signal?.removeEventListener('abort', handleAbortSignal);
};
const resolveOnce = () => {
const resolveOnce = (value: ApiBinaryUploadResponse) => {
if (settled) {
return;
}
settled = true;
detachAbortSignal();
resolve();
resolve(value);
};
const rejectOnce = (error: unknown) => {
@@ -613,7 +619,18 @@ export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadReques
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolveOnce();
const responseHeaders = Object.fromEntries(
(init.responseHeaders ?? [])
.map((headerName) => {
const value = xhr.getResponseHeader(headerName);
return [headerName.toLowerCase(), value];
})
.filter((entry): entry is [string, string] => Boolean(entry[1])),
);
resolveOnce({
status: xhr.status,
headers: responseHeaders,
});
return;
}

View File

@@ -1,6 +1,5 @@
import { apiBinaryUploadRequest, apiRequest, apiUploadRequest, ApiError } from './api';
import { joinNetdiskPath, resolveTransferSaveDirectory, splitNetdiskPath } from './netdisk-paths';
import type { FileMetadata, InitiateUploadResponse } from './types';
import { uploadFileToNetdiskViaSession } from './upload-session';
export function normalizeNetdiskTargetPath(path: string | null | undefined, fallback = '/下载') {
const rawPath = path?.trim();
@@ -17,44 +16,5 @@ export function resolveNetdiskSaveDirectory(relativePath: string | null | undefi
export async function saveFileToNetdisk(file: File, path: string) {
const normalizedPath = normalizeNetdiskTargetPath(path);
const initiated = await apiRequest<InitiateUploadResponse>('/files/upload/initiate', {
method: 'POST',
body: {
path: normalizedPath,
filename: file.name,
contentType: file.type || null,
size: file.size,
},
});
if (initiated.direct) {
try {
await apiBinaryUploadRequest(initiated.uploadUrl, {
method: initiated.method,
headers: initiated.headers,
body: file,
});
return await apiRequest<FileMetadata>('/files/upload/complete', {
method: 'POST',
body: {
path: normalizedPath,
filename: file.name,
storageName: initiated.storageName,
contentType: file.type || null,
size: file.size,
},
});
} catch (error) {
if (!(error instanceof ApiError && error.isNetworkError)) {
throw error;
}
}
}
const formData = new FormData();
formData.append('file', file);
return apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(normalizedPath)}`, {
body: formData,
});
return uploadFileToNetdiskViaSession(file, normalizedPath);
}

View File

@@ -159,6 +159,45 @@ export interface InitiateUploadResponse {
storageName: string;
}
export type UploadSessionUploadMode = 'PROXY' | 'DIRECT_SINGLE' | 'DIRECT_MULTIPART';
export interface UploadSessionStrategy {
prepareUrl: string | null;
proxyContentUrl: string | null;
partPrepareUrlTemplate: string | null;
partRecordUrlTemplate: string | null;
completeUrl: string;
proxyFormField: string | null;
}
export interface UploadSessionResponse {
sessionId: string;
objectKey: string;
directUpload: boolean;
multipartUpload: boolean;
uploadMode: UploadSessionUploadMode;
path: string;
filename: string;
contentType: string | null;
size: number;
storagePolicyId: number | null;
status: string;
chunkSize: number;
chunkCount: number;
expiresAt: string;
createdAt: string;
updatedAt: string;
strategy: UploadSessionStrategy;
}
export interface PreparedUploadResponse {
direct: boolean;
uploadUrl: string;
method: 'POST' | 'PUT';
headers: Record<string, string>;
storageName: string;
}
export interface DownloadUrlResponse {
url: string;
}

View File

@@ -0,0 +1,425 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, test } from 'node:test';
import {
uploadFileToNetdiskViaSession,
} from './upload-session';
class MemoryStorage implements Storage {
private store = new Map<string, string>();
get length() {
return this.store.size;
}
clear() {
this.store.clear();
}
getItem(key: string) {
return this.store.has(key) ? this.store.get(key)! : null;
}
key(index: number) {
return Array.from(this.store.keys())[index] ?? null;
}
removeItem(key: string) {
this.store.delete(key);
}
setItem(key: string, value: string) {
this.store.set(key, value);
}
}
const originalFetch = globalThis.fetch;
const originalStorage = globalThis.localStorage;
const originalXMLHttpRequest = globalThis.XMLHttpRequest;
class FakeXMLHttpRequest {
static instances: FakeXMLHttpRequest[] = [];
method = '';
url = '';
requestBody: Document | XMLHttpRequestBodyInit | null = null;
responseText = '';
status = 200;
headers = new Map<string, string>();
responseHeaders = new Map<string, string>();
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
onabort: null | (() => void) = null;
aborted = false;
upload = {
addEventListener: () => {},
};
constructor() {
FakeXMLHttpRequest.instances.push(this);
}
open(method: string, url: string) {
this.method = method;
this.url = url;
}
setRequestHeader(name: string, value: string) {
this.headers.set(name.toLowerCase(), value);
}
getResponseHeader(name: string) {
return this.responseHeaders.get(name.toLowerCase()) ?? this.responseHeaders.get(name) ?? null;
}
send(body: Document | XMLHttpRequestBodyInit | null) {
this.requestBody = body;
}
abort() {
this.aborted = true;
this.onabort?.();
}
respond(body: unknown, status = 200, contentType = 'application/json') {
this.status = status;
this.responseText = typeof body === 'string' ? body : JSON.stringify(body);
this.responseHeaders.set('content-type', contentType);
this.onload?.();
}
}
beforeEach(() => {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: new MemoryStorage(),
});
Object.defineProperty(globalThis, 'XMLHttpRequest', {
configurable: true,
value: FakeXMLHttpRequest,
});
FakeXMLHttpRequest.instances = [];
});
afterEach(() => {
globalThis.fetch = originalFetch;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: originalStorage,
});
Object.defineProperty(globalThis, 'XMLHttpRequest', {
configurable: true,
value: originalXMLHttpRequest,
});
});
async function waitFor(predicate: () => boolean, timeoutMs = 100) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error('timed out waiting for async upload work');
}
test('uploadFileToNetdiskViaSession completes a direct single upload session', async () => {
const calls: string[] = [];
globalThis.fetch = async (input, init) => {
const request = input instanceof Request ? input : new Request(new URL(String(input), 'http://localhost'), init);
calls.push(`${request.method} ${request.url}`);
if (request.url.endsWith('/api/v2/files/upload-sessions')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
sessionId: 'session-1',
objectKey: 'blobs/session-1',
directUpload: true,
multipartUpload: false,
uploadMode: 'DIRECT_SINGLE',
path: '/docs',
filename: 'movie.mp4',
contentType: 'video/mp4',
size: 9,
storagePolicyId: 1,
status: 'CREATED',
chunkSize: 8388608,
chunkCount: 1,
expiresAt: '2026-04-09T00:00:00',
createdAt: '2026-04-09T00:00:00',
updatedAt: '2026-04-09T00:00:00',
strategy: {
prepareUrl: '/api/v2/files/upload-sessions/session-1/prepare',
proxyContentUrl: null,
partPrepareUrlTemplate: null,
partRecordUrlTemplate: null,
completeUrl: '/api/v2/files/upload-sessions/session-1/complete',
proxyFormField: null,
},
},
}), {headers: {'Content-Type': 'application/json'}});
}
if (request.url.endsWith('/api/v2/files/upload-sessions/session-1/prepare')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
direct: true,
uploadUrl: 'https://upload.example.com/single',
method: 'PUT',
headers: {'Content-Type': 'video/mp4'},
storageName: 'blobs/session-1',
},
}), {headers: {'Content-Type': 'application/json'}});
}
if (request.url.endsWith('/api/v2/files/upload-sessions/session-1/complete')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
sessionId: 'session-1',
status: 'COMPLETED',
},
}), {headers: {'Content-Type': 'application/json'}});
}
throw new Error(`unexpected fetch ${request.method} ${request.url}`);
};
const uploadPromise = uploadFileToNetdiskViaSession(
new File([new Blob(['123456789'])], 'movie.mp4', {type: 'video/mp4'}),
'/docs',
);
await waitFor(() => FakeXMLHttpRequest.instances.length === 1);
assert.equal(FakeXMLHttpRequest.instances.length, 1);
const uploadRequest = FakeXMLHttpRequest.instances[0];
assert.equal(uploadRequest.method, 'PUT');
assert.equal(uploadRequest.url, 'https://upload.example.com/single');
uploadRequest.respond('', 200, 'text/plain');
const result = await uploadPromise;
assert.deepEqual(result, {
sessionId: 'session-1',
filename: 'movie.mp4',
path: '/docs',
});
assert.deepEqual(calls, [
'POST http://localhost/api/v2/files/upload-sessions',
'GET http://localhost/api/v2/files/upload-sessions/session-1/prepare',
'POST http://localhost/api/v2/files/upload-sessions/session-1/complete',
]);
});
test('uploadFileToNetdiskViaSession completes a proxy upload session', async () => {
const calls: string[] = [];
globalThis.fetch = async (input, init) => {
const request = input instanceof Request ? input : new Request(new URL(String(input), 'http://localhost'), init);
calls.push(`${request.method} ${request.url}`);
if (request.url.endsWith('/api/v2/files/upload-sessions')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
sessionId: 'session-2',
objectKey: 'blobs/session-2',
directUpload: false,
multipartUpload: false,
uploadMode: 'PROXY',
path: '/docs',
filename: 'notes.txt',
contentType: 'text/plain',
size: 5,
storagePolicyId: 1,
status: 'CREATED',
chunkSize: 8388608,
chunkCount: 1,
expiresAt: '2026-04-09T00:00:00',
createdAt: '2026-04-09T00:00:00',
updatedAt: '2026-04-09T00:00:00',
strategy: {
prepareUrl: null,
proxyContentUrl: '/api/v2/files/upload-sessions/session-2/content',
partPrepareUrlTemplate: null,
partRecordUrlTemplate: null,
completeUrl: '/api/v2/files/upload-sessions/session-2/complete',
proxyFormField: 'file',
},
},
}), {headers: {'Content-Type': 'application/json'}});
}
if (request.url.endsWith('/api/v2/files/upload-sessions/session-2/complete')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
sessionId: 'session-2',
status: 'COMPLETED',
},
}), {headers: {'Content-Type': 'application/json'}});
}
throw new Error(`unexpected fetch ${request.method} ${request.url}`);
};
const uploadPromise = uploadFileToNetdiskViaSession(
new File([new Blob(['hello'])], 'notes.txt', {type: 'text/plain'}),
'/docs',
);
await waitFor(() => FakeXMLHttpRequest.instances.length === 1);
assert.equal(FakeXMLHttpRequest.instances.length, 1);
const uploadRequest = FakeXMLHttpRequest.instances[0];
assert.equal(uploadRequest.method, 'POST');
assert.equal(uploadRequest.url, '/api/v2/files/upload-sessions/session-2/content');
uploadRequest.respond({
code: 0,
msg: 'success',
data: {
sessionId: 'session-2',
status: 'UPLOADING',
},
});
const result = await uploadPromise;
assert.deepEqual(result, {
sessionId: 'session-2',
filename: 'notes.txt',
path: '/docs',
});
assert.deepEqual(calls, [
'POST http://localhost/api/v2/files/upload-sessions',
'POST http://localhost/api/v2/files/upload-sessions/session-2/complete',
]);
});
test('uploadFileToNetdiskViaSession completes a multipart upload session', async () => {
const calls: string[] = [];
globalThis.fetch = async (input, init) => {
const request = input instanceof Request ? input : new Request(new URL(String(input), 'http://localhost'), init);
calls.push(`${request.method} ${request.url}`);
if (request.url.endsWith('/api/v2/files/upload-sessions')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
sessionId: 'session-3',
objectKey: 'blobs/session-3',
directUpload: true,
multipartUpload: true,
uploadMode: 'DIRECT_MULTIPART',
path: '/docs',
filename: 'archive.zip',
contentType: 'application/zip',
size: 10,
storagePolicyId: 1,
status: 'CREATED',
chunkSize: 5,
chunkCount: 2,
expiresAt: '2026-04-09T00:00:00',
createdAt: '2026-04-09T00:00:00',
updatedAt: '2026-04-09T00:00:00',
strategy: {
prepareUrl: null,
proxyContentUrl: null,
partPrepareUrlTemplate: '/api/v2/files/upload-sessions/session-3/parts/{partIndex}/prepare',
partRecordUrlTemplate: '/api/v2/files/upload-sessions/session-3/parts/{partIndex}',
completeUrl: '/api/v2/files/upload-sessions/session-3/complete',
proxyFormField: null,
},
},
}), {headers: {'Content-Type': 'application/json'}});
}
if (request.url.endsWith('/api/v2/files/upload-sessions/session-3/parts/0/prepare')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
direct: true,
uploadUrl: 'https://upload.example.com/part-1',
method: 'PUT',
headers: {'Content-Type': 'application/zip'},
storageName: 'blobs/session-3',
},
}), {headers: {'Content-Type': 'application/json'}});
}
if (request.url.endsWith('/api/v2/files/upload-sessions/session-3/parts/1/prepare')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
direct: true,
uploadUrl: 'https://upload.example.com/part-2',
method: 'PUT',
headers: {'Content-Type': 'application/zip'},
storageName: 'blobs/session-3',
},
}), {headers: {'Content-Type': 'application/json'}});
}
if (request.url.endsWith('/api/v2/files/upload-sessions/session-3/parts/0')
|| request.url.endsWith('/api/v2/files/upload-sessions/session-3/parts/1')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
sessionId: 'session-3',
status: 'UPLOADING',
},
}), {headers: {'Content-Type': 'application/json'}});
}
if (request.url.endsWith('/api/v2/files/upload-sessions/session-3/complete')) {
return new Response(JSON.stringify({
code: 0,
msg: 'success',
data: {
sessionId: 'session-3',
status: 'COMPLETED',
},
}), {headers: {'Content-Type': 'application/json'}});
}
throw new Error(`unexpected fetch ${request.method} ${request.url}`);
};
const uploadPromise = uploadFileToNetdiskViaSession(
new File([new Blob(['abcdefghij'])], 'archive.zip', {type: 'application/zip'}),
'/docs',
);
await waitFor(() => FakeXMLHttpRequest.instances.length === 1);
assert.equal(FakeXMLHttpRequest.instances.length, 1);
FakeXMLHttpRequest.instances[0].responseHeaders.set('etag', '"part-1"');
FakeXMLHttpRequest.instances[0].respond('', 200, 'text/plain');
await waitFor(() => FakeXMLHttpRequest.instances.length === 2);
assert.equal(FakeXMLHttpRequest.instances.length, 2);
FakeXMLHttpRequest.instances[1].responseHeaders.set('etag', '"part-2"');
FakeXMLHttpRequest.instances[1].respond('', 200, 'text/plain');
const result = await uploadPromise;
assert.deepEqual(result, {
sessionId: 'session-3',
filename: 'archive.zip',
path: '/docs',
});
assert.deepEqual(calls, [
'POST http://localhost/api/v2/files/upload-sessions',
'GET http://localhost/api/v2/files/upload-sessions/session-3/parts/0/prepare',
'PUT http://localhost/api/v2/files/upload-sessions/session-3/parts/0',
'GET http://localhost/api/v2/files/upload-sessions/session-3/parts/1/prepare',
'PUT http://localhost/api/v2/files/upload-sessions/session-3/parts/1',
'POST http://localhost/api/v2/files/upload-sessions/session-3/complete',
]);
});

View File

@@ -0,0 +1,220 @@
import { apiBinaryUploadRequest, apiUploadRequest, apiV2Request, ApiError } from './api';
import type {
PreparedUploadResponse,
UploadSessionResponse,
UploadSessionStrategy,
} from './types';
export interface UploadFileToNetdiskOptions {
onProgress?: (progress: {loaded: number; total: number}) => void;
signal?: AbortSignal;
}
export interface UploadedNetdiskFileRef {
sessionId: string;
filename: string;
path: string;
}
interface CreateUploadSessionRequest {
path: string;
filename: string;
contentType: string | null;
size: number;
}
interface UploadSessionPartRecordRequest {
etag: string;
size: number;
}
function replacePartIndex(template: string, partIndex: number) {
return template.replace('{partIndex}', String(partIndex));
}
function toInternalApiPath(path: string) {
return path.startsWith('/api/') ? path.slice('/api'.length) : path;
}
function getRequiredStrategyValue(value: string | null | undefined, key: keyof UploadSessionStrategy) {
if (!value) {
throw new Error(`上传会话缺少 ${key},无法继续上传`);
}
return value;
}
export function createUploadSession(request: CreateUploadSessionRequest) {
return apiV2Request<UploadSessionResponse>('/files/upload-sessions', {
method: 'POST',
body: request,
});
}
export function getUploadSession(sessionId: string) {
return apiV2Request<UploadSessionResponse>(`/files/upload-sessions/${sessionId}`);
}
export function cancelUploadSession(sessionId: string) {
return apiV2Request<UploadSessionResponse>(`/files/upload-sessions/${sessionId}`, {
method: 'DELETE',
});
}
export function prepareSingleUploadSession(sessionId: string) {
return apiV2Request<PreparedUploadResponse>(`/files/upload-sessions/${sessionId}/prepare`);
}
export function uploadUploadSessionContent(
sessionId: string,
file: File,
options: UploadFileToNetdiskOptions = {},
) {
const formData = new FormData();
formData.append('file', file);
return apiUploadRequest<UploadSessionResponse>(`/v2/files/upload-sessions/${sessionId}/content`, {
body: formData,
onProgress: options.onProgress,
signal: options.signal,
});
}
export function prepareUploadSessionPart(sessionId: string, partIndex: number) {
return apiV2Request<PreparedUploadResponse>(`/files/upload-sessions/${sessionId}/parts/${partIndex}/prepare`);
}
export function recordUploadSessionPart(
sessionId: string,
partIndex: number,
request: UploadSessionPartRecordRequest,
) {
return apiV2Request<UploadSessionResponse>(`/files/upload-sessions/${sessionId}/parts/${partIndex}`, {
method: 'PUT',
body: request,
});
}
export function completeUploadSession(sessionId: string) {
return apiV2Request<UploadSessionResponse>(`/files/upload-sessions/${sessionId}/complete`, {
method: 'POST',
});
}
async function runProxyUpload(session: UploadSessionResponse, file: File, options: UploadFileToNetdiskOptions) {
const proxyFormField = getRequiredStrategyValue(session.strategy.proxyFormField, 'proxyFormField');
const formData = new FormData();
formData.append(proxyFormField, file);
await apiUploadRequest<UploadSessionResponse>(
toInternalApiPath(getRequiredStrategyValue(session.strategy.proxyContentUrl, 'proxyContentUrl')),
{
body: formData,
onProgress: options.onProgress,
signal: options.signal,
},
);
}
async function runDirectSingleUpload(session: UploadSessionResponse, file: File, options: UploadFileToNetdiskOptions) {
const prepared = await prepareSingleUploadSession(session.sessionId);
await apiBinaryUploadRequest(prepared.uploadUrl, {
method: prepared.method,
headers: prepared.headers,
body: file,
onProgress: options.onProgress,
signal: options.signal,
});
}
async function runMultipartUpload(session: UploadSessionResponse, file: File, options: UploadFileToNetdiskOptions) {
const partPrepareUrlTemplate = getRequiredStrategyValue(session.strategy.partPrepareUrlTemplate, 'partPrepareUrlTemplate');
const partRecordUrlTemplate = getRequiredStrategyValue(session.strategy.partRecordUrlTemplate, 'partRecordUrlTemplate');
let uploadedBytes = 0;
for (let partIndex = 0; partIndex < session.chunkCount; partIndex += 1) {
const partStart = partIndex * session.chunkSize;
const partEnd = Math.min(file.size, partStart + session.chunkSize);
const partBlob = file.slice(partStart, partEnd);
const prepared = await apiV2Request<PreparedUploadResponse>(
toInternalApiPath(replacePartIndex(partPrepareUrlTemplate, partIndex)).replace('/v2', ''),
);
const uploadResult = await apiBinaryUploadRequest(prepared.uploadUrl, {
method: prepared.method,
headers: prepared.headers,
body: partBlob,
responseHeaders: ['etag'],
signal: options.signal,
onProgress: options.onProgress
? ({loaded, total}) => {
options.onProgress?.({
loaded: uploadedBytes + loaded,
total: Math.max(file.size, uploadedBytes + total),
});
}
: undefined,
});
const etag = uploadResult.headers.etag;
if (!etag) {
throw new Error('分片上传成功但未返回 etag无法完成上传');
}
await apiV2Request<UploadSessionResponse>(
toInternalApiPath(replacePartIndex(partRecordUrlTemplate, partIndex)).replace('/v2', ''),
{
method: 'PUT',
body: {
etag,
size: partBlob.size,
},
},
);
uploadedBytes += partBlob.size;
options.onProgress?.({
loaded: uploadedBytes,
total: file.size,
});
}
}
export async function uploadFileToNetdiskViaSession(
file: File,
path: string,
options: UploadFileToNetdiskOptions = {},
): Promise<UploadedNetdiskFileRef> {
const session = await createUploadSession({
path,
filename: file.name,
contentType: file.type || null,
size: file.size,
});
let shouldCancelSession = true;
try {
switch (session.uploadMode) {
case 'PROXY':
await runProxyUpload(session, file, options);
break;
case 'DIRECT_SINGLE':
await runDirectSingleUpload(session, file, options);
break;
case 'DIRECT_MULTIPART':
await runMultipartUpload(session, file, options);
break;
default:
throw new Error(`不支持的上传模式:${String(session.uploadMode)}`);
}
await completeUploadSession(session.sessionId);
shouldCancelSession = false;
return {
sessionId: session.sessionId,
filename: file.name,
path,
};
} catch (error) {
if (shouldCancelSession && !(error instanceof ApiError && error.message === '上传已取消')) {
await cancelUploadSession(session.sessionId).catch(() => undefined);
}
if (shouldCancelSession && error instanceof ApiError && error.message === '上传已取消') {
await cancelUploadSession(session.sessionId).catch(() => undefined);
}
throw error;
}
}

View File

@@ -0,0 +1,41 @@
import React, { ReactNode } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { cn } from '@/src/lib/utils';
export interface ResponsiveSheetProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
className?: string;
}
export function ResponsiveSheet({ isOpen, onClose, children, className }: ResponsiveSheetProps) {
return (
<AnimatePresence>
{isOpen && (
<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={onClose}
/>
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className={cn(
'relative w-full max-h-[90vh] overflow-y-auto bg-[#0f172a] rounded-t-3xl border-t border-white/10 pt-4 pb-8 px-4 flex flex-col z-10 glass-panel',
className
)}
>
<div className="w-12 h-1 shrink-0 bg-white/20 rounded-full mx-auto mb-4" />
{children}
</motion.div>
</div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { ShieldAlert } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/src/components/ui/button';
export default function MobileAdminUnavailable() {
const navigate = useNavigate();
return (
<div className="flex flex-col h-full bg-[#07101D] text-slate-300 min-h-[100dvh] pb-24 items-center justify-center p-6">
<div className="w-20 h-20 rounded-full bg-blue-500/10 flex items-center justify-center mb-6 shadow-inner border border-blue-500/20">
<ShieldAlert className="w-10 h-10 text-blue-400" />
</div>
<h1 className="text-xl font-bold text-white mb-3 text-center"></h1>
<p className="text-sm text-slate-400 text-center max-w-sm mb-8 leading-relaxed">
使访
</p>
<Button
className="bg-[#336EFF] hover:bg-blue-600 text-white rounded-xl shadow-lg border-none px-8 py-6"
onClick={() => navigate('/overview')}
>
</Button>
</div>
);
}

View File

@@ -1,650 +1 @@
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, removeCachedValue, writeCachedValue } from '@/src/lib/cache';
import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share';
import { subscribeFileEvents } from '@/src/lib/file-events';
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<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 layoutClassNames = getMobileFilesLayoutClassNames();
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', '');
}
}, []);
useEffect(() => {
const subscription = subscribeFileEvents({
path: toBackendPath(currentPath),
onFileEvent: () => {
const activePath = currentPathRef.current;
removeCachedValue(getFilesListCacheKey(toBackendPath(activePath)));
loadCurrentPath(activePath).catch(() => undefined);
},
onError: () => undefined,
});
return () => {
subscription.close();
};
}, [currentPath]);
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={layoutClassNames.root}>
<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={layoutClassNames.toolbar}>
<div className={layoutClassNames.toolbarInner}>
<div className="flex min-w-0 flex-1 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>
<button
type="button"
onClick={() => navigate(RECYCLE_BIN_ROUTE)}
className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-200"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* File List */}
<div className={layoutClassNames.list}>
{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> {RECYCLE_BIN_RETENTION_DAYS} </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>
);
}
export { MobileFilesPage as default, getMobileFilesLayoutClassNames } from './files/MobileFilesPage';

View File

@@ -0,0 +1,128 @@
import React, { useEffect, useState } from 'react';
import { RefreshCw, RotateCcw, Trash2, Folder, Clock3 } from 'lucide-react';
import { apiRequest } from '@/src/lib/api';
import type { PageResponse, RecycleBinItem } from '@/src/lib/types';
import { formatRecycleBinExpiresLabel, RECYCLE_BIN_RETENTION_DAYS } from '@/src/pages/recycle-bin-state';
import { Button } from '@/src/components/ui/button';
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]}`;
}
export default function MobileRecycleBin() {
const [items, setItems] = useState<RecycleBinItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [restoringId, setRestoringId] = useState<number | null>(null);
const loadRecycleBin = async () => {
setLoading(true);
setError('');
try {
const response = await apiRequest<PageResponse<RecycleBinItem>>('/files/recycle-bin?page=0&size=100');
setItems(response.items);
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '回收站加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadRecycleBin();
}, []);
const handleRestore = async (itemId: number) => {
setRestoringId(itemId);
setError('');
try {
await apiRequest(`/files/recycle-bin/${itemId}/restore`, { method: 'POST' });
setItems((previous) => previous.filter((item) => item.id !== itemId));
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '恢复失败');
} finally {
setRestoringId(null);
}
};
return (
<div className="flex flex-col h-full bg-[#07101D] text-slate-300 min-h-[100dvh] pb-24">
{/* Mobile Top Header */}
<header className="sticky top-0 z-40 flex items-center justify-between px-4 py-3 bg-[#07101D]/80 backdrop-blur-xl border-b border-white/5">
<h1 className="text-lg font-bold text-white tracking-tight flex items-center gap-2">
<Trash2 className="w-5 h-5 text-slate-400" />
</h1>
<button
onClick={() => void loadRecycleBin()}
className="p-2 -mr-2 rounded-full hover:bg-white/10 active:bg-white/20 transition-colors"
disabled={loading}
>
<RefreshCw className={`w-5 h-5 text-white ${loading ? 'animate-spin' : ''}`} />
</button>
</header>
<div className="px-4 py-4 space-y-4">
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-4 text-xs text-slate-400 leading-relaxed shadow-sm">
{RECYCLE_BIN_RETENTION_DAYS}
</div>
{error && (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-200">
{error}
</div>
)}
{loading ? (
<div className="py-20 text-center text-sm text-slate-500">...</div>
) : items.length === 0 ? (
<div className="py-20 flex flex-col items-center justify-center gap-4 opacity-60">
<div className="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center">
<Trash2 className="w-8 h-8 text-slate-400" />
</div>
<p className="text-sm"></p>
</div>
) : (
<div className="space-y-3">
{items.map((item) => (
<div key={item.id} className="rounded-2xl border border-white/5 bg-black/20 p-4 transition-colors active:bg-white/5 relative">
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-xl border border-white/5 bg-white/[0.03] p-2 text-slate-300 shrink-0">
<Folder className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-base font-medium text-white mb-1">{item.filename}</p>
<p className="truncate text-xs text-slate-500 mb-2">{item.path}</p>
<div className="flex flex-wrap items-center gap-2 text-[10px] text-slate-400">
<span>{item.directory ? '文件夹' : formatFileSize(item.size)}</span>
<span className="flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-200/80">
<Clock3 className="h-3 w-3" />
{formatRecycleBinExpiresLabel(item.expiresAt)}
</span>
</div>
</div>
</div>
<div className="mt-4 pt-3 border-t border-white/5 flex justify-end">
<Button
size="sm"
variant="outline"
className="h-8 border-white/10 text-slate-300 shrink-0"
onClick={() => void handleRestore(item.id)}
disabled={restoringId === item.id}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{restoringId === item.id ? '恢复中' : '恢复'}
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { Download, Share2, Copy, Folder, Edit2, Trash2 } from 'lucide-react';
import { Button } from '@/src/components/ui/button';
import { FileTypeIcon } from '@/src/components/ui/FileTypeIcon';
import { cn } from '@/src/lib/utils';
import type { UiFile } from '@/src/pages/files/file-types';
import { ResponsiveSheet } from '@/src/mobile-components/ResponsiveSheet';
export 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>
);
}
export function MobileFileActionSheet({
isOpen,
selectedFile,
shareStatus,
onClose,
onDownload,
onShare,
onMove,
onCopy,
onRename,
onDelete,
}: {
isOpen: boolean;
selectedFile: UiFile | null;
shareStatus: string;
onClose: () => void;
onDownload: () => void;
onShare: (file: UiFile) => void;
onMove: (file: UiFile) => void;
onCopy: (file: UiFile) => void;
onRename: (file: UiFile) => void;
onDelete: (file: UiFile) => void;
}) {
return (
<ResponsiveSheet isOpen={isOpen && selectedFile !== null} onClose={onClose}>
{selectedFile && (
<>
<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={() => { onDownload(); onClose(); }} color="text-amber-400" />
{selectedFile.type !== 'folder' && <ActionButton icon={Share2} label="分享" onClick={() => onShare(selectedFile)} color="text-emerald-400" />}
<ActionButton icon={Copy} label="复制" onClick={() => onCopy(selectedFile)} color="text-blue-400" />
<ActionButton icon={Folder} label="移动" onClick={() => onMove(selectedFile)} color="text-indigo-400" />
<ActionButton icon={Edit2} label="重命名" onClick={() => onRename(selectedFile)} color="text-slate-300" />
<ActionButton icon={Trash2} label="删除" onClick={() => onDelete(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={onClose} className="mt-4 text-slate-400 py-6 text-sm"></Button>
</>
)}
</ResponsiveSheet>
);
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { FolderPlus, MoreVertical } from 'lucide-react';
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
import { cn } from '@/src/lib/utils';
import type { UiFile } from '@/src/pages/files/file-types';
export function MobileFilesList({
currentFiles,
onFolderClick,
onOpenActionSheet,
}: {
currentFiles: UiFile[];
onFolderClick: (file: UiFile) => void;
onOpenActionSheet: (file: UiFile) => void;
}) {
if (currentFiles.length === 0) {
return (
<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>
);
}
return (
<>
{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={() => onFolderClick(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(); onOpenActionSheet(file); }}>
<MoreVertical className="w-5 h-5" />
</button>
)}
</div>
))}
</>
);
}

View File

@@ -0,0 +1,449 @@
import React, { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { useNavigate } from 'react-router-dom';
import { ChevronRight, ChevronLeft, RotateCcw, Plus, Upload, FolderPlus, Trash2 } from 'lucide-react';
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
import { Button } from '@/src/components/ui/button';
import { Input } from '@/src/components/ui/input';
import { ApiError, apiDownload, apiRequest } from '@/src/lib/api';
import { copyFileToNetdiskPath } from '@/src/lib/file-copy';
import { moveFileToNetdiskPath } from '@/src/lib/file-move';
import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share';
import { uploadFileToNetdiskViaSession } from '@/src/lib/upload-session';
import type { DownloadUrlResponse, FileMetadata } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
import {
buildUploadProgressSnapshot,
cancelUploadTask,
createUploadMeasurement,
createUploadTasks,
completeUploadTask,
failUploadTask,
prepareUploadTaskForCompletion,
prepareFolderUploadEntries,
prepareUploadFile,
shouldUploadEntriesSequentially,
type PendingUploadEntry,
} 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 { RECYCLE_BIN_RETENTION_DAYS, RECYCLE_BIN_ROUTE } from '@/src/pages/recycle-bin-state';
import { useFilesDirectoryState, splitBackendPath, toBackendPath } from '@/src/pages/files/useFilesDirectoryState';
import { toUiFile, type UiFile } from '@/src/pages/files/file-types';
import { MobileFilesList } from './MobileFilesList';
import { MobileFileActionSheet } from './MobileFileActionSheet';
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
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 function MobileFilesPage() {
const navigate = useNavigate();
const directoryState = useFilesDirectoryState();
const layoutClassNames = getMobileFilesLayoutClassNames();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const directoryInputRef = useRef<HTMLInputElement | null>(null);
const uploadMeasurementsRef = useRef(new Map());
const [selectedFile, setSelectedFile] = useState<UiFile | null>(null);
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<'move' | 'copy' | null>(null);
const [newFileName, setNewFileName] = useState('');
const [renameError, setRenameError] = useState('');
const [isRenaming, setIsRenaming] = useState(false);
const [shareStatus, setShareStatus] = useState('');
const [fabOpen, setFabOpen] = useState(false);
useEffect(() => {
if (directoryInputRef.current) {
directoryInputRef.current.setAttribute('webkitdirectory', '');
directoryInputRef.current.setAttribute('directory', '');
}
}, []);
const handleBreadcrumbClick = (index: number) => {
directoryState.setCurrentPath(directoryState.currentPath.slice(0, index + 1));
};
const handleBackClick = () => {
if (directoryState.currentPath.length > 0) {
directoryState.setCurrentPath(directoryState.currentPath.slice(0, -1));
}
};
const handleFolderClick = (file: UiFile) => {
if (file.type === 'folder') {
directoryState.setCurrentPath([...directoryState.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: 'move' | 'copy') => {
setTargetAction(action);
setTargetActionFile(file);
closeActionSheet();
};
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: any) => {
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 }));
};
const uploadedFile = await uploadFileToNetdiskViaSession(uploadFile, uploadPath, {
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<Awaited<ReturnType<typeof runSingleUpload>>>>([]);
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 directoryState.loadCurrentPath(directoryState.currentPath).catch(() => {});
} else {
const results = await Promise.all(entries.map((entry, index) => runSingleUpload(entry, batchTasks[index])));
if (results.some(Boolean)) await directoryState.loadCurrentPath(directoryState.currentPath).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>(directoryState.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: [...directoryState.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, [...directoryState.currentPath], directoryState.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(directoryState.currentFiles.filter(f => f.type === 'folder').map(f => f.name)));
if (nextFolderName !== normalizedFolderName) window.alert(`名称冲突,重命名为 ${nextFolderName}`);
const basePath = toBackendPath(directoryState.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 directoryState.loadCurrentPath(directoryState.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);
directoryState.setCurrentFiles((prev) => replaceUiFile(prev, nextUiFile));
setSelectedFile((prev) => syncSelectedFile(prev, nextUiFile));
setRenameModalOpen(false); setFileToRename(null); setNewFileName('');
await directoryState.loadCurrentPath(directoryState.currentPath).catch(() => {});
} catch (error) {
setRenameError(getActionErrorMessage(error, '重命名失败'));
} finally { setIsRenaming(false); }
};
const handleDelete = async () => {
if (!fileToDelete) return;
await apiRequest(`/files/${fileToDelete.id}`, { method: 'DELETE' });
directoryState.setCurrentFiles((prev) => removeUiFile(prev, fileToDelete.id));
setSelectedFile((prev) => clearSelectionIfDeleted(prev, fileToDelete.id));
setDeleteModalOpen(false); setFileToDelete(null);
await directoryState.loadCurrentPath(directoryState.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 directoryState.loadCurrentPath(directoryState.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={layoutClassNames.root}>
<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} />
<div className={layoutClassNames.toolbar}>
<div className={layoutClassNames.toolbarInner}>
<div className="flex min-w-0 flex-1 flex-nowrap items-center text-sm overflow-x-auto custom-scrollbar whitespace-nowrap">
{directoryState.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>
{directoryState.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 === directoryState.currentPath.length - 1 ? 'text-white font-medium' : 'text-slate-400', 'shrink-0')}>{pathItem}</button>
</React.Fragment>
))}
</div>
<button
type="button"
onClick={() => navigate(RECYCLE_BIN_ROUTE)}
className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-200"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className={layoutClassNames.list}>
<MobileFilesList currentFiles={directoryState.currentFiles} onFolderClick={handleFolderClick} onOpenActionSheet={openActionSheet} />
</div>
<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>
<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>
<MobileFileActionSheet
isOpen={actionSheetOpen}
selectedFile={selectedFile}
shareStatus={shareStatus}
onClose={closeActionSheet}
onDownload={handleDownload}
onShare={handleShare}
onMove={(f) => openTargetActionModal(f, 'move')}
onCopy={(f) => openTargetActionModal(f, 'copy')}
onRename={openRenameModal}
onDelete={openDeleteModal}
/>
{targetAction && (
<NetdiskPathPickerModal
isOpen
title={targetAction === 'move' ? '移动到' : '复制到'}
confirmLabel={targetAction === 'move' ? '移动至此' : '复制至此'}
onClose={() => setTargetAction(null)}
onConfirm={(path) => void handleMoveToPath(path)}
/>
)}
<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>
<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> {RECYCLE_BIN_RETENTION_DAYS} </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>
);
}
export default MobileFilesPage;

View File

@@ -8,6 +8,8 @@ import { Button } from '@/src/components/ui/button';
import { getFileShareDetails, importSharedFile } from '@/src/lib/file-share';
import { normalizeNetdiskTargetPath } from '@/src/lib/netdisk-upload';
import type { FileMetadata, FileShareDetailsResponse } from '@/src/lib/types';
import { AppPageShell } from '@/src/components/ui/AppPageShell';
import { PageToolbar } from '@/src/components/ui/PageToolbar';
function formatFileSize(size: number) {
if (size <= 0) {
@@ -97,15 +99,8 @@ export default function FileShare() {
}
return (
<div className="min-h-screen bg-[#07101D] px-4 py-10 text-white">
<div className="mx-auto w-full max-w-3xl">
<div className="mb-10 text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-cyan-500 via-sky-500 to-blue-500 shadow-lg shadow-cyan-500/20">
<Link2 className="h-8 w-8 text-white" />
</div>
<h1 className="text-3xl font-bold"></h1>
<p className="mt-3 text-slate-400"></p>
</div>
<AppPageShell toolbar={<PageToolbar title="网盘分享导入" />}>
<div className="p-4 md:p-6 mx-auto w-full max-w-3xl h-full">
<div className="rounded-3xl border border-white/10 bg-[#0f172a]/80 p-8 shadow-2xl backdrop-blur-xl">
{loading ? (
@@ -204,6 +199,6 @@ export default function FileShare() {
onClose={() => setPathPickerOpen(false)}
onConfirm={handleImportToPath}
/>
</div>
</AppPageShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@ import { Gamepad2, Cat, Car, ExternalLink, Play } from 'lucide-react';
import { cn } from '@/src/lib/utils';
import { calculateCardTilt } from './games-card-tilt';
import { MORE_GAMES_LABEL, MORE_GAMES_URL, resolveGamePlayerPath, type GameId } from './games-links';
import { AppPageShell } from '@/src/components/ui/AppPageShell';
import { PageToolbar } from '@/src/components/ui/PageToolbar';
const GAMES: Array<{
id: GameId;
@@ -137,34 +139,20 @@ export default function Games() {
const [activeTab, setActiveTab] = useState<'featured' | 'all'>('featured');
return (
<div className="space-y-8">
{/* Hero Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass-panel rounded-3xl p-8 relative overflow-hidden"
>
<div className="absolute top-0 right-0 w-64 h-64 bg-purple-500 rounded-full mix-blend-screen filter blur-[100px] opacity-20" />
<div className="relative z-10 space-y-4 max-w-2xl">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 border border-white/10 w-fit">
<Gamepad2 className="w-4 h-4 text-purple-400" />
<span className="text-xs text-slate-300 font-medium tracking-wide uppercase">Entertainment</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold text-white tracking-tight"></h1>
<p className="text-sm text-slate-400 leading-relaxed">
</p>
<a
href={MORE_GAMES_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-sm text-slate-300 transition-colors hover:text-white"
>
<ExternalLink className="h-4 w-4" />
{MORE_GAMES_LABEL}
</a>
</div>
</motion.div>
<AppPageShell
toolbar={
<PageToolbar
title="游戏"
actions={
<a href={MORE_GAMES_URL} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 text-sm text-slate-300 transition-colors hover:text-white">
<ExternalLink className="h-4 w-4" />
{MORE_GAMES_LABEL}
</a>
}
/>
}
>
<div className="p-4 md:p-6 space-y-8 h-full">
{/* Category Tabs */}
<div className="flex bg-black/20 p-1 rounded-xl w-fit">
@@ -194,6 +182,7 @@ export default function Games() {
<GameCard key={game.id} game={game} index={index} />
))}
</div>
</div>
</div>
</AppPageShell>
);
}

View File

@@ -19,6 +19,8 @@ import { shouldLoadAvatarWithAuth } from '@/src/components/layout/account-utils'
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 { AppPageShell } from '@/src/components/ui/AppPageShell';
import { PageToolbar } from '@/src/components/ui/PageToolbar';
import { apiDownload, apiRequest } from '@/src/lib/api';
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { resolveStoredFileType } from '@/src/lib/file-type';
@@ -197,23 +199,8 @@ export default function Overview() {
}, [profile?.avatarUrl]);
return (
<div className="space-y-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass-panel rounded-3xl p-8 relative overflow-hidden"
>
<div className="absolute top-0 right-0 w-64 h-64 bg-[#336EFF] rounded-full mix-blend-screen filter blur-[100px] opacity-20" />
<div className="relative z-10 space-y-2">
<h1 className="text-3xl md:text-4xl font-bold text-white tracking-tight">
{profile?.username ?? '访客'}
</h1>
<p className="text-[#336EFF] font-medium"> {currentTime} · {greeting}</p>
<p className="text-sm text-slate-400 mt-4 max-w-xl leading-relaxed">
</p>
</div>
</motion.div>
<AppPageShell toolbar={<PageToolbar title={`总览 · ${greeting}${profile?.username ?? '访客'}`} />}>
<div className="p-4 md:p-6 space-y-6 relative z-10">
{loadingError ? (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }}>
@@ -441,7 +428,8 @@ export default function Overview() {
</Card>
</div>
</div>
</div>
</div>
</AppPageShell>
);
}

View File

@@ -6,6 +6,8 @@ import { Button } from '@/src/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { apiRequest } from '@/src/lib/api';
import type { PageResponse, RecycleBinItem } from '@/src/lib/types';
import { AppPageShell } from '@/src/components/ui/AppPageShell';
import { PageToolbar } from '@/src/components/ui/PageToolbar';
import { formatRecycleBinExpiresLabel, RECYCLE_BIN_RETENTION_DAYS } from './recycle-bin-state';
@@ -68,38 +70,35 @@ export default function RecycleBin() {
};
return (
<div className="mx-auto flex h-full w-full max-w-6xl flex-col gap-6">
<Card className="overflow-hidden">
<CardHeader className="flex flex-col gap-4 border-b border-white/10 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-2">
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300">
<Trash2 className="h-3.5 w-3.5" />
{RECYCLE_BIN_RETENTION_DAYS}
<AppPageShell
toolbar={
<PageToolbar
title={
<div className="flex items-center gap-3">
<span></span>
<div className="hidden sm:flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300 font-normal">
<Trash2 className="h-3.5 w-3.5" />
{RECYCLE_BIN_RETENTION_DAYS}
</div>
</div>
<CardTitle className="text-2xl text-white"></CardTitle>
<p className="text-sm text-slate-400">
{RECYCLE_BIN_RETENTION_DAYS}
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
className="border-white/10 bg-white/5 text-slate-200 hover:bg-white/10"
onClick={() => void loadRecycleBin()}
disabled={loading}
>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
<Link
to="/files"
className="inline-flex h-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-slate-200 transition-colors hover:bg-white/10"
>
</Link>
</div>
</CardHeader>
<CardContent className="p-6">
}
actions={
<>
<Button variant="outline" className="h-9 border-white/10 bg-white/5 text-slate-200 hover:bg-white/10" onClick={() => void loadRecycleBin()} disabled={loading}>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
<Link to="/files" className="inline-flex h-9 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4 text-sm font-medium text-slate-200 transition-colors hover:bg-white/10">
</Link>
</>
}
/>
}
>
<div className="p-4 md:p-6 mx-auto flex h-full w-full max-w-6xl flex-col gap-6">
<Card className="overflow-hidden bg-transparent border-0 shadow-none">
<CardContent className="p-0">
{error ? (
<div className="mb-4 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-200">
{error}
@@ -160,6 +159,7 @@ export default function RecycleBin() {
)}
</CardContent>
</Card>
</div>
</div>
</AppPageShell>
);
}

View File

@@ -65,6 +65,8 @@ import {
resolveInitialTransferTab,
} from './transfer-state';
import TransferReceive from './TransferReceive';
import { AppPageShell } from '@/src/components/ui/AppPageShell';
import { PageToolbar } from '@/src/components/ui/PageToolbar';
type SendPhase = 'idle' | 'creating' | 'waiting' | 'connecting' | 'uploading' | 'transferring' | 'completed' | 'error';
@@ -576,16 +578,8 @@ export default function Transfer() {
}
return (
<div className="flex-1 py-6 md:py-10">
<div className="mx-auto w-full max-w-4xl">
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-[#336EFF] via-blue-500 to-cyan-400 shadow-lg shadow-[#336EFF]/20 mb-6">
<Send className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-3"></h1>
<p className="text-slate-400">线 P2P 线 7 </p>
</div>
<AppPageShell toolbar={<PageToolbar title="文件快传" />}>
<div className="p-4 md:p-6 mx-auto w-full max-w-4xl">
<div className="glass-panel border border-white/10 rounded-3xl overflow-hidden bg-[#0f172a]/80 backdrop-blur-xl shadow-2xl">
{allowSend ? (
<div className="flex border-b border-white/10">
@@ -1046,6 +1040,6 @@ export default function Transfer() {
</motion.div>
) : null}
</AnimatePresence>
</div>
</AppPageShell>
);
}

View File

@@ -0,0 +1,131 @@
import React from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { Download, Share2, Folder, Copy, Edit2, Trash2, MoreVertical } from 'lucide-react';
import type { UiFile } from './file-types';
export function FileActionMenu({
file,
activeDropdown,
onToggle,
onDownload,
onShare,
onMove,
onCopy,
onRename,
onDelete,
onClose,
allowMutatingActions = true,
}: {
file: UiFile;
activeDropdown: number | null;
onToggle: (fileId: number) => void;
onDownload: (file: UiFile) => Promise<void>;
onShare: (file: UiFile) => Promise<void>;
onMove: (file: UiFile) => void;
onCopy: (file: UiFile) => void;
onRename: (file: UiFile) => void;
onDelete: (file: UiFile) => void;
onClose: () => void;
allowMutatingActions?: boolean;
}) {
return (
<div className="relative inline-block text-left">
<button
onClick={(event) => {
event.stopPropagation();
onToggle(file.id);
}}
className="rounded-md p-1.5 text-slate-500 opacity-0 transition-all hover:bg-white/10 hover:text-white group-hover:opacity-100"
>
<MoreVertical className="w-4 h-4" />
</button>
{activeDropdown === file.id && (
<div
className="fixed inset-0 z-40"
onClick={(event) => {
event.stopPropagation();
onClose();
}}
/>
)}
<AnimatePresence>
{activeDropdown === file.id && (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.15 }}
className="absolute right-0 top-full z-50 mt-1 w-32 overflow-hidden rounded-lg border border-white/10 bg-[#1e293b] py-1 shadow-xl"
>
<button
onClick={(event) => {
event.stopPropagation();
void onDownload(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Download className="w-4 h-4" /> {file.type === 'folder' ? '下载文件夹' : '下载文件'}
</button>
{file.type !== 'folder' ? (
<button
onClick={(event) => {
event.stopPropagation();
void onShare(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Share2 className="w-4 h-4" />
</button>
) : null}
{allowMutatingActions ? (
<>
<button
onClick={(event) => {
event.stopPropagation();
onMove(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Folder className="w-4 h-4" />
</button>
<button
onClick={(event) => {
event.stopPropagation();
onCopy(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={(event) => {
event.stopPropagation();
onRename(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={(event) => {
event.stopPropagation();
onDelete(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-red-400 transition-colors hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="w-4 h-4" />
</button>
</>
) : null}
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import React from 'react';
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
import { cn } from '@/src/lib/utils';
import { ellipsizeFileName } from '@/src/lib/file-name';
import { FileActionMenu } from './FileActionMenu';
import type { UiFile } from './file-types';
export function FileGridView({
files,
selectedFileId,
activeDropdown,
isSearchResult = false,
onFileClick,
onFileDoubleClick,
onToggleDropdown,
onDownload,
onShare,
onMove,
onCopy,
onRename,
onDelete,
onCloseDropdown,
}: {
files: (UiFile & { originalPath?: string; originalDirectory?: boolean })[];
selectedFileId: number | null;
activeDropdown: number | null;
isSearchResult?: boolean;
onFileClick: (file: any) => void;
onFileDoubleClick: (file: any) => void;
onToggleDropdown: (fileId: number) => void;
onDownload: (file: UiFile) => Promise<void>;
onShare: (file: UiFile) => Promise<void>;
onMove: (file: UiFile) => void;
onCopy: (file: UiFile) => void;
onRename: (file: UiFile) => void;
onDelete: (file: UiFile) => void;
onCloseDropdown: () => void;
}) {
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{files.map((file) => {
const selected = selectedFileId === file.id;
return (
<div
key={file.id}
onClick={() => onFileClick(file)}
onDoubleClick={() => onFileDoubleClick(file)}
className={cn(
'group relative flex cursor-pointer flex-col items-center rounded-xl border p-4 transition-all',
selected
? 'border-[#336EFF]/30 bg-[#336EFF]/10'
: 'border-white/5 bg-white/[0.02] hover:border-white/10 hover:bg-white/[0.04]',
)}
>
<div className="absolute right-2 top-2">
<FileActionMenu
file={file}
activeDropdown={activeDropdown}
onToggle={onToggleDropdown}
onDownload={onDownload}
onShare={onShare}
onMove={onMove}
onCopy={onCopy}
onRename={onRename}
onDelete={onDelete}
onClose={onCloseDropdown}
allowMutatingActions={!isSearchResult}
/>
</div>
<FileTypeIcon type={file.type} size="lg" className="mb-3 transition-transform duration-200 group-hover:scale-[1.03]" />
<span className={cn('w-full truncate px-2 text-center text-sm font-medium', selected ? 'text-[#336EFF]' : 'text-slate-200')}>
{ellipsizeFileName(file.name, 24)}
</span>
<span className={cn('mt-1 inline-flex rounded-full px-2 py-1 text-[11px] font-medium', getFileTypeTheme(file.type).badgeClassName)}>
{file.typeLabel}
</span>
<span className="mt-2 text-xs text-slate-500">
{file.type === 'folder' ? file.modified : file.size}
</span>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,125 @@
import React from 'react';
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
import { cn } from '@/src/lib/utils';
import { ellipsizeFileName } from '@/src/lib/file-name';
import { FileActionMenu } from './FileActionMenu';
import type { UiFile } from './file-types';
import type { FileMetadata } from '@/src/lib/types';
import { splitBackendPath } from './useFilesDirectoryState';
export function FileListView({
files,
selectedFileId,
activeDropdown,
isSearchResult = false,
onFileClick,
onFileDoubleClick,
onToggleDropdown,
onDownload,
onShare,
onMove,
onCopy,
onRename,
onDelete,
onCloseDropdown,
}: {
files: (UiFile & { originalPath?: string; originalDirectory?: boolean })[];
selectedFileId: number | null;
activeDropdown: number | null;
isSearchResult?: boolean;
onFileClick: (file: any) => void;
onFileDoubleClick: (file: any) => void;
onToggleDropdown: (fileId: number) => void;
onDownload: (file: UiFile) => Promise<void>;
onShare: (file: UiFile) => Promise<void>;
onMove: (file: UiFile) => void;
onCopy: (file: UiFile) => void;
onRename: (file: UiFile) => void;
onDelete: (file: UiFile) => void;
onCloseDropdown: () => void;
}) {
return (
<table className="w-full table-fixed text-left border-collapse">
<thead>
<tr className="text-xs font-semibold text-slate-500 uppercase tracking-wider border-b border-white/5">
<th className={cn("pb-3 pl-4 font-medium", isSearchResult ? "w-[40%]" : "w-[44%]")}></th>
{isSearchResult && <th className="hidden pb-3 font-medium md:table-cell w-[26%]"></th>}
<th className={cn("hidden pb-3 font-medium", isSearchResult ? "lg:table-cell w-[20%]" : "md:table-cell w-[22%]")}></th>
{!isSearchResult && <th className="hidden pb-3 font-medium lg:table-cell w-[14%]"></th>}
<th className="pb-3 font-medium w-[10%]"></th>
<th className={cn("pb-3", isSearchResult ? "w-[4%]" : "w-[10%]")}></th>
</tr>
</thead>
<tbody>
{files.map((file) => {
const selected = selectedFileId === file.id;
return (
<tr
key={file.id}
onClick={() => onFileClick(file)}
onDoubleClick={() => onFileDoubleClick(file)}
className={cn(
'group cursor-pointer transition-colors border-b border-white/5 last:border-0',
selected ? 'bg-[#336EFF]/10' : 'hover:bg-white/[0.02]',
)}
>
<td className="py-3 pl-4 max-w-0">
<div className="flex min-w-0 items-center gap-3">
<FileTypeIcon type={file.type} size="sm" />
<div className="min-w-0">
<span
className={cn('block truncate text-sm font-medium', selected ? 'text-[#336EFF]' : 'text-slate-200')}
title={file.name}
>
{ellipsizeFileName(file.name, 48)}
</span>
{isSearchResult && file.originalPath && (
<span className="hidden truncate text-xs text-slate-500 md:block" title={file.originalPath}>
{file.originalPath}
</span>
)}
</div>
</div>
</td>
{isSearchResult && (
<td className="hidden py-3 text-sm text-slate-400 md:table-cell">{file.originalPath}</td>
)}
<td className={cn("hidden py-3 text-sm text-slate-400", isSearchResult ? "lg:table-cell" : "md:table-cell")}>
{file.modified}
</td>
{!isSearchResult && (
<td className="hidden py-3 text-sm text-slate-400 lg:table-cell">
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-medium tracking-wide',
getFileTypeTheme(file.type).badgeClassName,
)}
>
{file.typeLabel}
</span>
</td>
)}
<td className="py-3 text-sm text-slate-400 font-mono">{file.size}</td>
<td className="py-3 pr-4 text-right">
<FileActionMenu
file={file}
activeDropdown={activeDropdown}
onToggle={onToggleDropdown}
onDownload={onDownload}
onShare={onShare}
onMove={onMove}
onCopy={onCopy}
onRename={onRename}
onDelete={onDelete}
onClose={onCloseDropdown}
allowMutatingActions={!isSearchResult}
/>
</td>
</tr>
);
})}
</tbody>
</table>
);
}

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { Card, CardContent } from '@/src/components/ui/card';
import { Folder, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
import { cn } from '@/src/lib/utils';
import { getFilesSidebarFooterEntries, RECYCLE_BIN_RETENTION_DAYS, RECYCLE_BIN_ROUTE } from '../recycle-bin-state';
import type { DirectoryTreeNode } from '../files-tree';
import { useLocation, useNavigate } from 'react-router-dom';
function DirectoryTreeItem({
node,
onSelect,
onToggle,
}: {
node: DirectoryTreeNode;
onSelect: (path: string[]) => void;
onToggle: (path: string[]) => void;
}) {
return (
<div>
<div
className={cn(
'group flex items-center gap-1 rounded-xl px-2 py-1.5 transition-colors',
node.active ? 'bg-[#336EFF]/15' : 'hover:bg-white/5',
)}
style={{ paddingLeft: `${node.depth * 14 + 8}px` }}
>
<button
type="button"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => onToggle(node.path)}
aria-label={`${node.expanded ? '收起' : '展开'} ${node.name}`}
>
{node.expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
<button
type="button"
className={cn(
'flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-1 text-left text-sm transition-colors',
node.active ? 'text-[#336EFF]' : 'text-slate-300 hover:text-white',
)}
onClick={() => onSelect(node.path)}
>
<Folder className={cn('h-4 w-4 shrink-0', node.active ? 'text-[#336EFF]' : 'text-slate-500')} />
<span className="truncate">{node.name}</span>
</button>
</div>
{node.expanded ? node.children.map((child) => (
<DirectoryTreeItem key={child.id} node={child} onSelect={onSelect} onToggle={onToggle} />
)) : null}
</div>
);
}
export function FilesDirectoryRail({
currentPath,
directoryTree,
onNavigateToPath,
onDirectoryToggle,
}: {
currentPath: string[];
directoryTree: DirectoryTreeNode[];
onNavigateToPath: (pathParts: string[]) => void;
onDirectoryToggle: (pathParts: string[]) => void;
}) {
const navigate = useNavigate();
const location = useLocation();
return (
<Card className="w-full lg:w-64 shrink-0 flex flex-col h-full overflow-hidden">
<CardContent className="flex h-full flex-col p-4">
<div className="min-h-0 flex-1 space-y-2">
<p className="px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider"></p>
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-white/5 bg-black/20 p-2">
<button
type="button"
onClick={() => onNavigateToPath([])}
className={cn(
'flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm font-medium transition-colors',
currentPath.length === 0 ? 'bg-[#336EFF]/15 text-[#336EFF]' : 'text-slate-200 hover:bg-white/5 hover:text-white',
)}
>
<Folder className={cn('h-4 w-4', currentPath.length === 0 ? 'text-[#336EFF]' : 'text-slate-500')} />
<span className="truncate"></span>
</button>
<div className="mt-1 min-h-0 flex-1 space-y-0.5 overflow-y-auto pr-1">
{directoryTree.map((node) => (
<DirectoryTreeItem
key={node.id}
node={node}
onSelect={onNavigateToPath}
onToggle={onDirectoryToggle}
/>
))}
</div>
</div>
</div>
<div className="mt-4 border-t border-white/10 pt-4">
{getFilesSidebarFooterEntries().map((entry) => {
const isActive = location.pathname === entry.path || location.pathname === RECYCLE_BIN_ROUTE;
return (
<button
key={entry.path}
type="button"
onClick={() => navigate(entry.path)}
className={cn(
'flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left text-sm transition-colors',
isActive
? 'border-[#336EFF]/30 bg-[#336EFF]/15 text-[#7ea6ff]'
: 'border-white/10 bg-white/5 text-slate-300 hover:bg-white/10 hover:text-white',
)}
>
<RotateCcw className={cn('h-4 w-4', isActive ? 'text-[#7ea6ff]' : 'text-slate-400')} />
<div className="min-w-0">
<p className="font-medium">{entry.label}</p>
<p className="truncate text-xs text-slate-500"> {RECYCLE_BIN_RETENTION_DAYS} </p>
</div>
</button>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { FileTypeIcon } from '@/src/components/ui/FileTypeIcon';
import { Button } from '@/src/components/ui/button';
import { Share2, Edit2, Folder, Copy, RotateCcw, Trash2, Download } from 'lucide-react';
import { cn } from '@/src/lib/utils';
import type { UiFile } from './file-types';
function DetailItem({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-xs font-medium text-slate-500 mb-1">{label}</p>
<p className="text-sm text-slate-300">{value}</p>
</div>
);
}
export function FilesInspector({
selectedFile,
currentPath,
shareStatus,
backgroundTaskActionId,
onShare,
onRename,
onMove,
onCopy,
onCreateMediaMetadataTask,
onDelete,
onFolderDoubleClick,
onDownload,
}: {
selectedFile: UiFile;
currentPath: string[];
shareStatus: string;
backgroundTaskActionId: number | null;
onShare: (file: UiFile) => void;
onRename: (file: UiFile) => void;
onMove: (file: UiFile) => void;
onCopy: (file: UiFile) => void;
onCreateMediaMetadataTask: () => void;
onDelete: (file: UiFile) => void;
onFolderDoubleClick: (file: UiFile) => void;
onDownload: (file: UiFile) => void;
}) {
return (
<Card className="h-full">
<CardHeader className="pb-4 border-b border-white/10">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
<div className="flex w-full flex-col items-center text-center space-y-3">
<FileTypeIcon type={selectedFile.type} size="lg" />
<h3 className="w-full truncate text-sm font-medium text-white" title={selectedFile.name}>
{selectedFile.name}
</h3>
</div>
<div className="space-y-4">
<DetailItem label="位置" value={`网盘 > ${currentPath.length === 0 ? '根目录' : currentPath.join(' > ')}`} />
<DetailItem label="大小" value={selectedFile.size} />
<DetailItem label="修改时间" value={selectedFile.modified} />
<DetailItem label="类型" value={selectedFile.typeLabel} />
</div>
<div className="pt-4 space-y-3 border-t border-white/10">
<div className="grid grid-cols-2 gap-3">
{selectedFile.type !== 'folder' ? (
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => onShare(selectedFile)}>
<Share2 className="w-4 h-4" />
</Button>
) : null}
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => onRename(selectedFile)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => onMove(selectedFile)}>
<Folder className="w-4 h-4" />
</Button>
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => onCopy(selectedFile)}>
<Copy className="w-4 h-4" />
</Button>
{selectedFile.type !== 'folder' ? (
<Button
variant="outline"
className="col-span-2 w-full gap-2 border-white/10 bg-white/5 hover:bg-white/10"
onClick={onCreateMediaMetadataTask}
disabled={backgroundTaskActionId === selectedFile.id}
>
<RotateCcw className={cn('w-4 h-4', backgroundTaskActionId === selectedFile.id ? 'animate-spin' : '')} />
{backgroundTaskActionId === selectedFile.id ? '创建中...' : '提取媒体信息'}
</Button>
) : null}
<Button
variant="outline"
className="w-full gap-2 border-red-500/20 bg-red-500/5 text-red-400 hover:bg-red-500/10 hover:text-red-300"
onClick={() => onDelete(selectedFile)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
{selectedFile.type === 'folder' && (
<div className="space-y-3">
<Button variant="default" className="w-full gap-2" onClick={() => onFolderDoubleClick(selectedFile)}>
</Button>
<Button variant="default" className="w-full gap-2" onClick={() => onDownload(selectedFile)}>
<Download className="w-4 h-4" />
</Button>
</div>
)}
{selectedFile.type !== 'folder' && (
<Button variant="default" className="w-full gap-2" onClick={() => onDownload(selectedFile)}>
<Download className="w-4 h-4" />
</Button>
)}
{shareStatus && selectedFile.type !== 'folder' ? (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-200">
{shareStatus}
</div>
) : null}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,175 @@
import React from 'react';
import { Folder } from 'lucide-react';
import { cn } from '@/src/lib/utils';
import { FilesSearchPanel } from './FilesSearchPanel';
import { FileListView } from './FileListView';
import { FileGridView } from './FileGridView';
import type { UiFile } from './file-types';
import type { FileMetadata } from '@/src/lib/types';
export function FilesMainPane({
currentPath,
currentFiles,
shareStatus,
viewMode,
isSearchActive,
searchQuery,
searchLoading,
searchError,
searchResults,
selectedSearchFile,
selectedFile,
activeDropdown,
onViewModeChange,
onSearchQueryChange,
onSearchSubmit,
onClearSearch,
onFileClick,
onFileDoubleClick,
onSearchFileClick,
onSearchFileDoubleClick,
onToggleDropdown,
onDownload,
onShare,
onMove,
onCopy,
onRename,
onDelete,
onCloseDropdown,
}: {
currentPath: string[];
currentFiles: UiFile[];
shareStatus: string;
viewMode: 'list' | 'grid';
isSearchActive: boolean;
searchQuery: string;
searchLoading: boolean;
searchError: string;
searchResults: FileMetadata[] | null;
selectedSearchFile: FileMetadata | null;
selectedFile: UiFile | null;
activeDropdown: number | null;
onViewModeChange: (mode: 'list' | 'grid') => void;
onSearchQueryChange: (query: string) => void;
onSearchSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onClearSearch: () => void;
onFileClick: (file: UiFile) => void;
onFileDoubleClick: (file: UiFile) => void;
onSearchFileClick: (file: FileMetadata) => void;
onSearchFileDoubleClick: (file: FileMetadata) => void;
onToggleDropdown: (fileId: number) => void;
onDownload: (file: UiFile) => Promise<void>;
onShare: (file: UiFile) => Promise<void>;
onMove: (file: UiFile) => void;
onCopy: (file: UiFile) => void;
onRename: (file: UiFile) => void;
onDelete: (file: UiFile) => void;
onCloseDropdown: () => void;
}) {
return (
<div className="flex-1 flex flex-col h-full overflow-hidden bg-transparent">
<FilesSearchPanel
searchQuery={searchQuery}
searchLoading={searchLoading}
isSearchActive={isSearchActive}
searchError={searchError}
onSearchQueryChange={onSearchQueryChange}
onSearchSubmit={onSearchSubmit}
onClearSearch={onClearSearch}
/>
{isSearchActive ? (
<div className="flex-1 overflow-y-auto p-0">
{searchLoading ? (
<div className="flex flex-col items-center justify-center space-y-3 py-12 text-slate-500">
<Folder className="h-12 w-12 opacity-20" />
<p className="text-sm">...</p>
</div>
) : (searchResults?.length ?? 0) === 0 ? (
<div className="flex flex-col items-center justify-center space-y-3 py-12 text-slate-500">
<Folder className="h-12 w-12 opacity-20" />
<p className="text-sm"></p>
</div>
) : viewMode === 'list' ? (
<FileListView
files={searchResults!.map(f => ({ ...f, typeLabel: f.contentType || 'unknown', originalPath: f.path, modified: f.createdAt } as any))}
selectedFileId={selectedSearchFile?.id ?? null}
activeDropdown={activeDropdown}
isSearchResult={true}
onFileClick={onSearchFileClick}
onFileDoubleClick={onSearchFileDoubleClick}
onToggleDropdown={onToggleDropdown}
onDownload={onDownload}
onShare={onShare}
onMove={onMove}
onCopy={onCopy}
onRename={onRename}
onDelete={onDelete}
onCloseDropdown={onCloseDropdown}
/>
) : (
<FileGridView
files={searchResults!.map(f => ({ ...f, typeLabel: f.contentType || 'unknown', originalPath: f.path, modified: f.createdAt } as any))}
selectedFileId={selectedSearchFile?.id ?? null}
activeDropdown={activeDropdown}
isSearchResult={true}
onFileClick={onSearchFileClick}
onFileDoubleClick={onSearchFileDoubleClick}
onToggleDropdown={onToggleDropdown}
onDownload={onDownload}
onShare={onShare}
onMove={onMove}
onCopy={onCopy}
onRename={onRename}
onDelete={onDelete}
onCloseDropdown={onCloseDropdown}
/>
)}
</div>
) : (
<div className="flex-1 overflow-y-auto p-0 md:p-4">
{currentFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center space-y-3 py-12 text-slate-500">
<Folder className="w-12 h-12 opacity-20" />
<p className="text-sm"></p>
</div>
) : viewMode === 'list' ? (
<FileListView
files={currentFiles}
selectedFileId={selectedFile?.id ?? null}
activeDropdown={activeDropdown}
isSearchResult={false}
onFileClick={onFileClick}
onFileDoubleClick={onFileDoubleClick}
onToggleDropdown={onToggleDropdown}
onDownload={onDownload}
onShare={onShare}
onMove={onMove}
onCopy={onCopy}
onRename={onRename}
onDelete={onDelete}
onCloseDropdown={onCloseDropdown}
/>
) : (
<FileGridView
files={currentFiles}
selectedFileId={selectedFile?.id ?? null}
activeDropdown={activeDropdown}
isSearchResult={false}
onFileClick={onFileClick}
onFileDoubleClick={onFileDoubleClick}
onToggleDropdown={onToggleDropdown}
onDownload={onDownload}
onShare={onShare}
onMove={onMove}
onCopy={onCopy}
onRename={onRename}
onDelete={onDelete}
onCloseDropdown={onCloseDropdown}
/>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,425 @@
import React, { useRef, useState, useEffect } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { Edit2, X, Trash2 } from 'lucide-react';
import { Input } from '@/src/components/ui/input';
import { Button } from '@/src/components/ui/button';
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
import { ApiError, apiDownload, apiRequest } from '@/src/lib/api';
import { copyFileToNetdiskPath } from '@/src/lib/file-copy';
import { moveFileToNetdiskPath } from '@/src/lib/file-move';
import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share';
import { uploadFileToNetdiskViaSession } from '@/src/lib/upload-session';
import { getNextAvailableName, getActionErrorMessage, removeUiFile, replaceUiFile, syncSelectedFile, clearSelectionIfDeleted } from '../files-state';
import {
buildUploadProgressSnapshot,
cancelUploadTask,
createUploadMeasurement,
createUploadTasks,
completeUploadTask,
failUploadTask,
prepareUploadTaskForCompletion,
prepareFolderUploadEntries,
prepareUploadFile,
shouldUploadEntriesSequentially,
type PendingUploadEntry,
type UploadMeasurement,
type UploadTask,
} from '../files-upload';
import {
registerFilesUploadTaskCanceler,
replaceFilesUploads,
setFilesUploadPanelOpen,
unregisterFilesUploadTaskCanceler,
updateFilesUploadTask,
} from '../files-upload-store';
import { buildDirectoryTree } from '../files-tree';
import { RECYCLE_BIN_RETENTION_DAYS } from '../recycle-bin-state';
import type { FileMetadata } from '@/src/lib/types';
import { toUiFile, type UiFile } from './file-types';
import { useFilesDirectoryState, splitBackendPath, toBackendPath } from './useFilesDirectoryState';
import { useFilesSearchState } from './useFilesSearchState';
import { useBackgroundTasksState } from './useBackgroundTasksState';
import { useFilesOverlayState } from './useFilesOverlayState';
import { FilesDirectoryRail } from './FilesDirectoryRail';
import { FilesMainPane } from './FilesMainPane';
import { FilesInspector } from './FilesInspector';
import { FilesTaskPanel } from './FilesTaskPanel';
import { FilesToolbar } from './FilesToolbar';
import { AppPageShell } from '@/src/components/ui/AppPageShell';
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function FilesPage() {
const directoryState = useFilesDirectoryState();
const searchState = useFilesSearchState();
const tasksState = useBackgroundTasksState();
const overlayState = useFilesOverlayState();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const directoryInputRef = useRef<HTMLInputElement | null>(null);
const uploadMeasurementsRef = useRef(new Map<string, UploadMeasurement>());
const [viewMode, setViewMode] = useState<'list' | 'grid'>('list');
const [shareStatus, setShareStatus] = useState('');
const [selectedFile, setSelectedFile] = useState<UiFile | null>(null);
useEffect(() => {
if (directoryInputRef.current) {
directoryInputRef.current.setAttribute('webkitdirectory', '');
directoryInputRef.current.setAttribute('directory', '');
}
void tasksState.loadBackgroundTasks();
}, [tasksState.loadBackgroundTasks]);
const handleNavigateToPath = (pathParts: string[]) => {
searchState.clearSearchState();
directoryState.setCurrentPath(pathParts);
setSelectedFile(null);
overlayState.setActiveDropdown(null);
};
const directoryTree = buildDirectoryTree(directoryState.directoryChildren, directoryState.currentPath, directoryState.expandedDirectories);
const handleDownload = async (targetFile: UiFile | null = selectedFile) => {
if (!targetFile) return;
if (targetFile.type === 'folder') {
const response = await apiDownload(`/files/download/${targetFile.id}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${targetFile.name}.zip`;
link.click();
window.URL.revokeObjectURL(url);
return;
}
try {
const response = await apiRequest<{url: string}>(`/files/download/${targetFile.id}/url`);
const url = response.url;
const link = document.createElement('a');
link.href = url;
link.download = targetFile.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/${targetFile.id}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = targetFile.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 : '创建分享链接失败');
}
};
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 startedAt = Date.now();
const uploadAbortController = new AbortController();
registerFilesUploadTaskCanceler(uploadTask.id, () => uploadAbortController.abort());
uploadMeasurementsRef.current.set(uploadTask.id, createUploadMeasurement(startedAt));
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,
}));
};
const uploadedFile = await uploadFileToNetdiskViaSession(uploadFile, uploadPath, {
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);
}
};
const results = shouldUploadEntriesSequentially(entries)
? await entries.reduce<Promise<Array<Awaited<ReturnType<typeof runSingleUpload>>>>>(async (prev, entry, i) => [...await prev, await runSingleUpload(entry, batchTasks[i])], Promise.resolve([]))
: await Promise.all(entries.map((entry, index) => runSingleUpload(entry, batchTasks[index])));
if (results.some(Boolean)) {
await directoryState.loadCurrentPath(directoryState.currentPath).catch(() => undefined);
}
};
const handleRename = async () => {
if (!overlayState.fileToRename || !overlayState.newFileName.trim() || overlayState.isRenaming) return;
overlayState.setIsRenaming(true);
overlayState.setRenameError('');
try {
const renamedFile = await apiRequest<FileMetadata>(`/files/${overlayState.fileToRename.id}/rename`, {
method: 'PATCH', body: { filename: overlayState.newFileName.trim() },
});
const nextUiFile = toUiFile(renamedFile);
directoryState.setCurrentFiles((prev) => replaceUiFile(prev, nextUiFile));
setSelectedFile((prev) => syncSelectedFile(prev, nextUiFile));
overlayState.setRenameModalOpen(false);
overlayState.setFileToRename(null);
overlayState.setNewFileName('');
await directoryState.loadCurrentPath(directoryState.currentPath).catch(() => undefined);
} catch (error) {
overlayState.setRenameError(getActionErrorMessage(error, '重命名失败'));
} finally {
overlayState.setIsRenaming(false);
}
};
const handleDelete = async () => {
if (!overlayState.fileToDelete) return;
await apiRequest(`/files/${overlayState.fileToDelete.id}`, { method: 'DELETE' });
directoryState.setCurrentFiles((prev) => removeUiFile(prev, overlayState.fileToDelete!.id));
setSelectedFile((prev) => clearSelectionIfDeleted(prev, overlayState.fileToDelete!.id));
overlayState.setDeleteModalOpen(false);
overlayState.setFileToDelete(null);
await directoryState.loadCurrentPath(directoryState.currentPath).catch(() => undefined);
};
const handleMoveToPath = async (path: string) => {
if (!overlayState.targetActionFile || !overlayState.targetAction) return;
if (overlayState.targetAction === 'move') {
await moveFileToNetdiskPath(overlayState.targetActionFile.id, path);
setSelectedFile((prev) => clearSelectionIfDeleted(prev, overlayState.targetActionFile!.id));
} else {
await copyFileToNetdiskPath(overlayState.targetActionFile.id, path);
}
overlayState.setTargetAction(null);
overlayState.setTargetActionFile(null);
await directoryState.loadCurrentPath(directoryState.currentPath).catch(() => undefined);
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
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>(directoryState.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: [...directoryState.currentPath], source: 'file', noticeMessage: preparedUpload.noticeMessage };
});
await runUploadEntries(entries);
};
const handleFolderChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
event.target.value = '';
if (files.length === 0) return;
const entries = prepareFolderUploadEntries(files, [...directoryState.currentPath], directoryState.currentFiles.map((f) => f.name));
await runUploadEntries(entries);
};
const handleCreateFolder = async () => {
const folderName = window.prompt('请输入新文件夹名称');
if (!folderName?.trim()) return;
const nextFolderName = getNextAvailableName(folderName.trim(), new Set(directoryState.currentFiles.filter((f) => f.type === 'folder').map((f) => f.name)));
const basePath = toBackendPath(directoryState.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 directoryState.loadCurrentPath(directoryState.currentPath).catch(() => undefined);
};
const toolbar = (
<FilesToolbar
currentPath={directoryState.currentPath}
shareStatus={shareStatus}
viewMode={viewMode}
onNavigateToRoot={() => handleNavigateToPath([])}
onBreadcrumbClick={(index) => handleNavigateToPath(directoryState.currentPath.slice(0, index + 1))}
onViewModeChange={setViewMode}
onUploadClick={() => fileInputRef.current?.click()}
onUploadFolderClick={() => directoryInputRef.current?.click()}
onCreateFolder={handleCreateFolder}
fileInputRef={fileInputRef}
directoryInputRef={directoryInputRef}
onFileChange={handleFileChange}
onFolderChange={handleFolderChange}
/>
);
const rail = (
<div className="h-full p-4 pl-0 pr-0">
<FilesDirectoryRail
currentPath={directoryState.currentPath}
directoryTree={directoryTree}
onNavigateToPath={handleNavigateToPath}
onDirectoryToggle={directoryState.handleDirectoryToggle}
/>
</div>
);
const inspector = (
<div className="h-full space-y-4 p-4 pr-0">
{selectedFile && (
<FilesInspector
selectedFile={selectedFile}
currentPath={directoryState.currentPath}
shareStatus={shareStatus}
backgroundTaskActionId={tasksState.backgroundTaskActionId}
onShare={handleShare}
onRename={overlayState.openRenameModal}
onMove={(f) => overlayState.openTargetActionModal(f, 'move')}
onCopy={(f) => overlayState.openTargetActionModal(f, 'copy')}
onCreateMediaMetadataTask={() => tasksState.handleCreateMediaMetadataTask(selectedFile.id, selectedFile.name, selectedFile.type === 'folder', directoryState.currentPath)}
onDelete={overlayState.openDeleteModal}
onFolderDoubleClick={(f) => f.type === 'folder' && handleNavigateToPath([...directoryState.currentPath, f.name])}
onDownload={handleDownload}
/>
)}
<FilesTaskPanel
backgroundTasks={tasksState.backgroundTasks}
backgroundTasksLoading={tasksState.backgroundTasksLoading}
backgroundTasksError={tasksState.backgroundTasksError}
backgroundTaskNotice={tasksState.backgroundTaskNotice}
backgroundTaskActionId={tasksState.backgroundTaskActionId}
onRefresh={tasksState.loadBackgroundTasks}
onCancelTask={tasksState.handleCancelBackgroundTask}
/>
</div>
);
return (
<AppPageShell toolbar={toolbar} rail={rail} inspector={inspector}>
<FilesMainPane
currentPath={directoryState.currentPath}
currentFiles={directoryState.currentFiles}
shareStatus={shareStatus}
viewMode={viewMode}
isSearchActive={searchState.isSearchActive}
searchQuery={searchState.searchQuery}
searchLoading={searchState.searchLoading}
searchError={searchState.searchError}
searchResults={searchState.searchResults}
selectedSearchFile={searchState.selectedSearchFile}
selectedFile={selectedFile}
activeDropdown={overlayState.activeDropdown}
onViewModeChange={setViewMode}
onSearchQueryChange={searchState.setSearchQuery}
onSearchSubmit={searchState.handleSearchSubmit}
onClearSearch={searchState.clearSearchState}
onFileClick={(f) => setSelectedFile(f)}
onFileDoubleClick={(f) => f.type === 'folder' && handleNavigateToPath([...directoryState.currentPath, f.name])}
onSearchFileClick={(f) => searchState.setSelectedSearchFile(f)}
onSearchFileDoubleClick={(f) => f.directory && handleNavigateToPath(splitBackendPath(f.path))}
onToggleDropdown={(id) => overlayState.setActiveDropdown(overlayState.activeDropdown === id ? null : id)}
onDownload={handleDownload}
onShare={handleShare}
onMove={(f) => overlayState.openTargetActionModal(f, 'move')}
onCopy={(f) => overlayState.openTargetActionModal(f, 'copy')}
onRename={overlayState.openRenameModal}
onDelete={overlayState.openDeleteModal}
onCloseDropdown={() => overlayState.setActiveDropdown(null)}
/>
<AnimatePresence>
{overlayState.renameModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<motion.div initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} className="w-full max-w-sm overflow-hidden rounded-xl border border-white/10 bg-[#0f172a] shadow-2xl">
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 p-4">
<h3 className="flex items-center gap-2 text-lg font-semibold text-white"><Edit2 className="w-5 h-5 text-[#336EFF]" /> </h3>
<button onClick={() => { overlayState.setRenameModalOpen(false); overlayState.setFileToRename(null); overlayState.setRenameError(''); }} className="rounded-md p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-5 p-5">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<Input value={overlayState.newFileName} onChange={(e) => overlayState.setNewFileName(e.target.value)} className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]" autoFocus disabled={overlayState.isRenaming} onKeyDown={(e) => { if (e.key === 'Enter' && !overlayState.isRenaming) void handleRename(); }} />
</div>
{overlayState.renameError && <div className="rounded-xl border border-red-500/20 bg-red-500/10 p-3 text-sm text-red-400">{overlayState.renameError}</div>}
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" onClick={() => { overlayState.setRenameModalOpen(false); overlayState.setFileToRename(null); overlayState.setRenameError(''); }} disabled={overlayState.isRenaming} className="border-white/10 text-slate-300 hover:bg-white/10"></Button>
<Button variant="default" onClick={() => void handleRename()} disabled={overlayState.isRenaming}>{overlayState.isRenaming ? '重命名中...' : '确定'}</Button>
</div>
</div>
</motion.div>
</div>
)}
{overlayState.deleteModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<motion.div initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} className="w-full max-w-sm overflow-hidden rounded-xl border border-white/10 bg-[#0f172a] shadow-2xl">
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 p-4">
<h3 className="flex items-center gap-2 text-lg font-semibold text-white"><Trash2 className="w-5 h-5 text-red-500" /> </h3>
<button onClick={() => { overlayState.setDeleteModalOpen(false); overlayState.setFileToDelete(null); }} className="rounded-md p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-5 p-5">
<p className="text-sm leading-relaxed text-slate-300"> <span className="rounded bg-white/10 px-1 py-0.5 font-medium text-white">{overlayState.fileToDelete?.name}</span> {RECYCLE_BIN_RETENTION_DAYS} </p>
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" onClick={() => { overlayState.setDeleteModalOpen(false); overlayState.setFileToDelete(null); }} className="border-white/10 text-slate-300 hover:bg-white/10"></Button>
<Button variant="outline" className="border-red-500/30 bg-red-500 text-white hover:bg-red-600" onClick={() => void handleDelete()}></Button>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
<NetdiskPathPickerModal
isOpen={Boolean(overlayState.targetActionFile && overlayState.targetAction)}
title={overlayState.targetAction === 'copy' ? '选择复制目标' : '选择移动目标'}
description={overlayState.targetAction === 'copy' ? '选择要把当前文件或文件夹复制到哪个目录。' : '选择要把当前文件或文件夹移动到哪个目录。'}
initialPath={toBackendPath(directoryState.currentPath)}
confirmLabel={overlayState.targetAction === 'copy' ? '复制到这里' : '移动到这里'}
onClose={() => { overlayState.setTargetAction(null); overlayState.setTargetActionFile(null); }}
onConfirm={handleMoveToPath}
/>
</AppPageShell>
);
}
export default FilesPage;

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { Input } from '@/src/components/ui/input';
import { Button } from '@/src/components/ui/button';
export function FilesSearchPanel({
searchQuery,
searchLoading,
isSearchActive,
searchError,
onSearchQueryChange,
onSearchSubmit,
onClearSearch,
}: {
searchQuery: string;
searchLoading: boolean;
isSearchActive: boolean;
searchError: string;
onSearchQueryChange: (query: string) => void;
onSearchSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onClearSearch: () => void;
}) {
return (
<form className="border-b border-white/10 p-4 pt-0" onSubmit={onSearchSubmit}>
<div className="mt-3 flex flex-col gap-2 md:flex-row">
<Input
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
placeholder="按文件名搜索"
className="h-10 border-white/10 bg-black/20 text-white placeholder:text-slate-500 focus-visible:ring-[#336EFF]"
/>
<div className="flex gap-2">
<Button type="submit" className="shrink-0" disabled={searchLoading}>
{searchLoading ? '搜索中...' : '搜索'}
</Button>
{isSearchActive ? (
<Button
type="button"
variant="outline"
className="shrink-0 border-white/10 text-slate-300 hover:bg-white/10"
onClick={onClearSearch}
>
</Button>
) : null}
</div>
</div>
{searchError ? <p className="mt-2 text-sm text-red-400">{searchError}</p> : null}
</form>
);
}

View File

@@ -0,0 +1,148 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button';
import { RotateCcw } from 'lucide-react';
import { cn } from '@/src/lib/utils';
import type { BackgroundTask } from '@/src/lib/background-tasks';
import { formatDateTime } from './file-types';
export function formatTaskDateTime(value: string) {
return formatDateTime(value);
}
export function getBackgroundTaskTypeLabel(type: BackgroundTask['type']) {
switch (type) {
case 'ARCHIVE': return '压缩任务';
case 'EXTRACT': return '解压任务';
case 'MEDIA_META': return '媒体信息提取任务';
}
}
export function getBackgroundTaskStatusLabel(status: BackgroundTask['status']) {
switch (status) {
case 'QUEUED': return '排队中';
case 'RUNNING': return '执行中';
case 'COMPLETED': return '已完成';
case 'FAILED': return '已失败';
case 'CANCELLED': return '已取消';
}
}
export function getBackgroundTaskStatusClassName(status: BackgroundTask['status']) {
switch (status) {
case 'QUEUED': return 'text-amber-300';
case 'RUNNING': return 'text-sky-300';
case 'COMPLETED': return 'text-emerald-300';
case 'FAILED': return 'text-red-300';
case 'CANCELLED': return 'text-slate-400';
}
}
export function FilesTaskPanel({
backgroundTasks,
backgroundTasksLoading,
backgroundTasksError,
backgroundTaskNotice,
backgroundTaskActionId,
onRefresh,
onCancelTask,
}: {
backgroundTasks: BackgroundTask[];
backgroundTasksLoading: boolean;
backgroundTasksError: string;
backgroundTaskNotice: { kind: 'success' | 'error'; message: string } | null;
backgroundTaskActionId: number | null;
onRefresh: () => void;
onCancelTask: (taskId: number) => void;
}) {
return (
<Card>
<CardHeader className="border-b border-white/10 pb-4">
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-base"></CardTitle>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={onRefresh}
aria-label="刷新后台任务"
>
<RotateCcw className={cn('h-4 w-4', backgroundTasksLoading ? 'animate-spin' : '')} />
</button>
</div>
</CardHeader>
<CardContent className="space-y-3 p-4">
{backgroundTaskNotice ? (
<div
className={cn(
'rounded-xl border px-3 py-2 text-xs leading-relaxed',
backgroundTaskNotice.kind === 'error'
? 'border-red-500/20 bg-red-500/10 text-red-200'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200',
)}
aria-live="polite"
>
{backgroundTaskNotice.message}
</div>
) : null}
{backgroundTasksError ? (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-xs text-red-200">
{backgroundTasksError}
</div>
) : null}
{backgroundTasksLoading ? (
<div className="rounded-xl border border-white/10 bg-white/[0.02] px-3 py-4 text-sm text-slate-400">
...
</div>
) : backgroundTasks.length === 0 ? (
<div className="rounded-xl border border-white/10 bg-white/[0.02] px-3 py-4 text-sm text-slate-400">
</div>
) : (
<div className="max-h-[32rem] space-y-3 overflow-y-auto pr-1">
{backgroundTasks.map((task) => {
const canCancel = task.status === 'QUEUED' || task.status === 'RUNNING';
return (
<div key={task.id} className="rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{getBackgroundTaskTypeLabel(task.type)}</p>
<p className={cn('text-xs', getBackgroundTaskStatusClassName(task.status))}>
{getBackgroundTaskStatusLabel(task.status)}
</p>
</div>
{canCancel ? (
<Button
type="button"
variant="outline"
className="shrink-0 border-white/10 bg-white/5 px-3 text-xs text-slate-200 hover:bg-white/10"
onClick={() => onCancelTask(task.id)}
disabled={backgroundTaskActionId === task.id}
>
{backgroundTaskActionId === task.id ? '取消中...' : '取消'}
</Button>
) : null}
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div className="min-w-0">
<p className="text-slate-500"></p>
<p className="truncate text-slate-300">{formatTaskDateTime(task.createdAt)}</p>
</div>
<div className="min-w-0">
<p className="text-slate-500"></p>
<p className="truncate text-slate-300">{task.finishedAt ? formatTaskDateTime(task.finishedAt) : '未完成'}</p>
</div>
</div>
{task.errorMessage ? (
<div className="mt-3 break-words rounded-lg border border-red-500/20 bg-red-500/10 px-2 py-1 text-xs leading-relaxed text-red-200">
{task.errorMessage}
</div>
) : null}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { ChevronRight, List, LayoutGrid, Upload, FolderUp, Plus } from 'lucide-react';
import { Button } from '@/src/components/ui/button';
import { cn } from '@/src/lib/utils';
export function FilesToolbar({
currentPath,
shareStatus,
viewMode,
onNavigateToRoot,
onBreadcrumbClick,
onViewModeChange,
onUploadClick,
onUploadFolderClick,
onCreateFolder,
fileInputRef,
directoryInputRef,
onFileChange,
onFolderChange,
}: {
currentPath: string[];
shareStatus: string;
viewMode: 'list' | 'grid';
onNavigateToRoot: () => void;
onBreadcrumbClick: (index: number) => void;
onViewModeChange: (mode: 'list' | 'grid') => void;
onUploadClick: () => void;
onUploadFolderClick: () => void;
onCreateFolder: () => void;
fileInputRef: React.RefObject<HTMLInputElement>;
directoryInputRef: React.RefObject<HTMLInputElement>;
onFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onFolderChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<>
<div className="p-4 border-b border-white/10 flex items-center justify-between shrink-0">
<div className="flex items-center text-sm text-slate-400">
<button className="hover:text-white transition-colors" onClick={onNavigateToRoot}>
</button>
{currentPath.map((pathItem, index) => (
<React.Fragment key={index}>
<ChevronRight className="w-4 h-4 mx-1" />
<button
onClick={() => onBreadcrumbClick(index)}
className={cn('transition-colors', index === currentPath.length - 1 ? 'text-white font-medium' : 'hover:text-white')}
>
{pathItem}
</button>
</React.Fragment>
))}
</div>
{shareStatus ? (
<div className="hidden max-w-xs truncate text-xs text-emerald-300 md:block">{shareStatus}</div>
) : null}
<div className="flex items-center gap-2 bg-black/20 p-1 rounded-lg">
<button
onClick={() => onViewModeChange('list')}
className={cn(
'p-1.5 rounded-md transition-colors',
viewMode === 'list' ? 'bg-white/10 text-white' : 'text-slate-400 hover:text-white',
)}
>
<List className="w-4 h-4" />
</button>
<button
onClick={() => onViewModeChange('grid')}
className={cn(
'p-1.5 rounded-md transition-colors',
viewMode === 'grid' ? 'bg-white/10 text-white' : 'text-slate-400 hover:text-white',
)}
>
<LayoutGrid className="w-4 h-4" />
</button>
</div>
</div>
<div className="p-4 border-t border-white/10 flex items-center gap-3 shrink-0 bg-white/[0.01]">
<Button variant="default" className="gap-2" onClick={onUploadClick}>
<Upload className="w-4 h-4" />
</Button>
<Button variant="outline" className="gap-2" onClick={onUploadFolderClick}>
<FolderUp className="w-4 h-4" />
</Button>
<Button variant="outline" className="gap-2" onClick={onCreateFolder}>
<Plus className="w-4 h-4" />
</Button>
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={onFileChange} />
{/* @ts-ignore - directory attributes are non-standard but work */}
<input ref={directoryInputRef} type="file" multiple directory="" webkitdirectory="" className="hidden" onChange={onFolderChange} />
</div>
</>
);
}

View File

@@ -0,0 +1,46 @@
import { resolveStoredFileType, type FileTypeKind } from '@/src/lib/file-type';
import type { FileMetadata } from '@/src/lib/types';
export interface UiFile {
id: FileMetadata['id'];
modified: string;
name: string;
size: string;
type: FileTypeKind;
typeLabel: string;
}
export 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]}`;
}
export function formatDateTime(value: string) {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value));
}
export function toUiFile(file: FileMetadata): UiFile {
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),
modified: formatDateTime(file.createdAt),
};
}

View File

@@ -0,0 +1,98 @@
import { useState, useCallback } from 'react';
import {
cancelBackgroundTask,
createMediaMetadataTask,
listBackgroundTasks,
type BackgroundTask,
} from '@/src/lib/background-tasks';
import { toBackendPath } from './useFilesDirectoryState';
export function useBackgroundTasksState() {
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTask[]>([]);
const [backgroundTasksLoading, setBackgroundTasksLoading] = useState(false);
const [backgroundTasksError, setBackgroundTasksError] = useState('');
const [backgroundTaskNotice, setBackgroundTaskNotice] = useState<{ kind: 'success' | 'error'; message: string } | null>(null);
const [backgroundTaskActionId, setBackgroundTaskActionId] = useState<number | null>(null);
const loadBackgroundTasks = useCallback(async () => {
setBackgroundTasksLoading(true);
setBackgroundTasksError('');
try {
const response = await listBackgroundTasks({ page: 0, size: 10 });
setBackgroundTasks(response.items);
} catch (error) {
setBackgroundTasksError(error instanceof Error ? error.message : '获取后台任务失败');
} finally {
setBackgroundTasksLoading(false);
}
}, []);
const handleCreateMediaMetadataTask = async (
fileId: number,
fileName: string,
isDirectory: boolean,
currentPath: string[]
) => {
if (isDirectory) return;
const taskPath = currentPath.length === 0 ? `/${fileName}` : `${toBackendPath(currentPath)}/${fileName}`;
const correlationId = `media-meta:${fileId}:${Date.now()}`;
setBackgroundTaskNotice(null);
setBackgroundTaskActionId(fileId);
try {
await createMediaMetadataTask({
fileId,
path: taskPath,
correlationId,
});
setBackgroundTaskNotice({
kind: 'success',
message: '已创建媒体信息提取任务,可在右侧后台任务面板查看状态。',
});
await loadBackgroundTasks();
} catch (error) {
setBackgroundTaskNotice({
kind: 'error',
message: error instanceof Error ? error.message : '创建媒体信息提取任务失败',
});
} finally {
setBackgroundTaskActionId(null);
}
};
const handleCancelBackgroundTask = async (taskId: number) => {
setBackgroundTaskNotice(null);
setBackgroundTaskActionId(taskId);
try {
await cancelBackgroundTask(taskId);
setBackgroundTaskNotice({
kind: 'success',
message: `已取消任务 ${taskId},后台列表已刷新。`,
});
await loadBackgroundTasks();
} catch (error) {
setBackgroundTaskNotice({
kind: 'error',
message: error instanceof Error ? error.message : '取消任务失败',
});
} finally {
setBackgroundTaskActionId(null);
}
};
return {
backgroundTasks,
backgroundTasksLoading,
backgroundTasksError,
backgroundTaskNotice,
backgroundTaskActionId,
loadBackgroundTasks,
handleCreateMediaMetadataTask,
handleCancelBackgroundTask,
setBackgroundTaskNotice,
};
}

View File

@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from 'react';
import { apiRequest } from '@/src/lib/api';
import { readCachedValue, removeCachedValue, writeCachedValue } from '@/src/lib/cache';
import { subscribeFileEvents } from '@/src/lib/file-events';
import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache';
import type { FileMetadata, PageResponse } from '@/src/lib/types';
import {
createExpandedDirectorySet,
getMissingDirectoryListingPaths,
hasLoadedDirectoryListing,
mergeDirectoryChildren,
toDirectoryPath,
type DirectoryChildrenMap,
} from '../files-tree';
import { toUiFile, type UiFile } from './file-types';
export function toBackendPath(pathParts: string[]) {
return toDirectoryPath(pathParts);
}
export function splitBackendPath(path: string) {
return path.split('/').filter(Boolean);
}
export function useFilesDirectoryState() {
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
const [currentPath, setCurrentPath] = useState<string[]>(initialPath);
const currentPathRef = useRef(currentPath);
const [directoryChildren, setDirectoryChildren] = useState<DirectoryChildrenMap>(() => {
if (initialCachedFiles.length === 0) return {};
return mergeDirectoryChildren(
{},
toBackendPath(initialPath),
initialCachedFiles.filter((file) => file.directory).map((file) => file.filename),
);
});
const [loadedDirectoryPaths, setLoadedDirectoryPaths] = useState<Set<string>>(
() => new Set(initialCachedFiles.length === 0 ? [] : [toBackendPath(initialPath)]),
);
const [expandedDirectories, setExpandedDirectories] = useState(() => createExpandedDirectorySet(initialPath));
const [currentFiles, setCurrentFiles] = useState<UiFile[]>(initialCachedFiles.map(toUiFile));
const recordDirectoryChildren = (pathParts: string[], items: FileMetadata[]) => {
setDirectoryChildren((previous) => {
let next = mergeDirectoryChildren(
previous,
toBackendPath(pathParts),
items.filter((file) => file.directory).map((file) => file.filename),
);
for (let index = 0; index < pathParts.length; index += 1) {
next = mergeDirectoryChildren(
next,
toBackendPath(pathParts.slice(0, index)),
[pathParts[index]],
);
}
return next;
});
};
const markDirectoryLoaded = (pathParts: string[]) => {
const path = toBackendPath(pathParts);
setLoadedDirectoryPaths((previous) => {
if (previous.has(path)) return previous;
const next = new Set(previous);
next.add(path);
return next;
});
};
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);
recordDirectoryChildren(pathParts, response.items);
markDirectoryLoaded(pathParts);
setCurrentFiles(response.items.map(toUiFile));
};
useEffect(() => {
currentPathRef.current = currentPath;
setExpandedDirectories((previous) => {
const next = new Set(previous);
for (const path of createExpandedDirectorySet(currentPath)) {
next.add(path);
}
return next;
});
const cachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(currentPath)));
writeCachedValue(getFilesLastPathCacheKey(), currentPath);
if (cachedFiles) {
recordDirectoryChildren(currentPath, cachedFiles);
setCurrentFiles(cachedFiles.map(toUiFile));
}
loadCurrentPath(currentPath).catch(() => {
if (!cachedFiles) {
setCurrentFiles([]);
}
});
}, [currentPath]);
useEffect(() => {
const missingAncestors = getMissingDirectoryListingPaths(currentPath, loadedDirectoryPaths);
if (missingAncestors.length === 0) return;
let cancelled = false;
Promise.all(
missingAncestors.map(async (pathParts) => {
const path = toBackendPath(pathParts);
const response = await apiRequest<PageResponse<FileMetadata>>(
`/files/list?path=${encodeURIComponent(path)}&page=0&size=100`
);
writeCachedValue(getFilesListCacheKey(path), response.items);
return { pathParts, items: response.items };
}),
).then((responses) => {
if (cancelled) return;
for (const response of responses) {
recordDirectoryChildren(response.pathParts, response.items);
markDirectoryLoaded(response.pathParts);
}
}).catch(() => {});
return () => { cancelled = true; };
}, [currentPath, loadedDirectoryPaths]);
useEffect(() => {
const subscription = subscribeFileEvents({
path: toBackendPath(currentPath),
onFileEvent: () => {
const activePath = currentPathRef.current;
removeCachedValue(getFilesListCacheKey(toBackendPath(activePath)));
loadCurrentPath(activePath).catch(() => undefined);
},
onError: () => undefined,
});
return () => { subscription.close(); };
}, [currentPath]);
const handleDirectoryToggle = async (pathParts: string[]) => {
const path = toBackendPath(pathParts);
let shouldLoadChildren = false;
setExpandedDirectories((previous) => {
const next = new Set(previous);
if (next.has(path)) {
next.delete(path);
return next;
}
next.add(path);
shouldLoadChildren = !hasLoadedDirectoryListing(pathParts, loadedDirectoryPaths);
return next;
});
if (!shouldLoadChildren) return;
try {
const response = await apiRequest<PageResponse<FileMetadata>>(
`/files/list?path=${encodeURIComponent(path)}&page=0&size=100`
);
writeCachedValue(getFilesListCacheKey(path), response.items);
recordDirectoryChildren(pathParts, response.items);
markDirectoryLoaded(pathParts);
} catch {}
};
return {
currentPath,
setCurrentPath,
directoryChildren,
expandedDirectories,
currentFiles,
setCurrentFiles,
handleDirectoryToggle,
loadCurrentPath,
};
}

View File

@@ -0,0 +1,61 @@
import { useState } from 'react';
import type { UiFile } from './file-types';
export type NetdiskTargetAction = 'move' | 'copy';
export function useFilesOverlayState() {
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 [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [renameError, setRenameError] = useState('');
const [isRenaming, setIsRenaming] = useState(false);
const openRenameModal = (file: UiFile) => {
setFileToRename(file);
setNewFileName(file.name);
setRenameError('');
setRenameModalOpen(true);
};
const openDeleteModal = (file: UiFile) => {
setFileToDelete(file);
setDeleteModalOpen(true);
};
const openTargetActionModal = (file: UiFile, action: NetdiskTargetAction) => {
setTargetAction(action);
setTargetActionFile(file);
setActiveDropdown(null);
};
return {
renameModalOpen,
setRenameModalOpen,
deleteModalOpen,
setDeleteModalOpen,
fileToRename,
setFileToRename,
fileToDelete,
setFileToDelete,
targetActionFile,
setTargetActionFile,
targetAction,
setTargetAction,
newFileName,
setNewFileName,
activeDropdown,
setActiveDropdown,
renameError,
setRenameError,
isRenaming,
setIsRenaming,
openRenameModal,
openDeleteModal,
openTargetActionModal,
};
}

View File

@@ -0,0 +1,81 @@
import { useRef, useState } from 'react';
import { searchFiles } from '@/src/lib/file-search';
import type { FileMetadata } from '@/src/lib/types';
export function useFilesSearchState() {
const [searchQuery, setSearchQuery] = useState('');
const [searchAppliedQuery, setSearchAppliedQuery] = useState('');
const [searchResults, setSearchResults] = useState<FileMetadata[] | null>(null);
const [searchLoading, setSearchLoading] = useState(false);
const [searchError, setSearchError] = useState('');
const [selectedSearchFile, setSelectedSearchFile] = useState<FileMetadata | null>(null);
const searchRequestIdRef = useRef(0);
const clearSearchState = () => {
searchRequestIdRef.current += 1;
setSearchQuery('');
setSearchAppliedQuery('');
setSearchResults(null);
setSearchLoading(false);
setSearchError('');
setSelectedSearchFile(null);
};
const executeSearch = async (query: string, onStart?: () => void) => {
const nextQuery = query.trim();
if (!nextQuery) {
clearSearchState();
return;
}
const requestId = searchRequestIdRef.current + 1;
searchRequestIdRef.current = requestId;
setSearchAppliedQuery(nextQuery);
setSearchLoading(true);
setSearchError('');
setSearchResults(null);
setSelectedSearchFile(null);
onStart?.();
try {
const response = await searchFiles({
name: nextQuery,
type: 'all',
page: 0,
size: 100,
});
if (searchRequestIdRef.current !== requestId) return;
setSearchResults(response.items);
} catch (error) {
if (searchRequestIdRef.current !== requestId) return;
setSearchResults([]);
setSearchError(error instanceof Error ? error.message : '搜索失败');
} finally {
if (searchRequestIdRef.current === requestId) {
setSearchLoading(false);
}
}
};
const handleSearchSubmit = async (event: React.FormEvent<HTMLFormElement>, onStart?: () => void) => {
event.preventDefault();
await executeSearch(searchQuery, onStart);
};
const isSearchActive = searchAppliedQuery.trim().length > 0;
return {
searchQuery,
setSearchQuery,
searchAppliedQuery,
searchResults,
searchLoading,
searchError,
selectedSearchFile,
setSelectedSearchFile,
clearSearchState,
handleSearchSubmit,
isSearchActive,
};
}