Refactor backend and frontend modules for architecture alignment
This commit is contained in:
@@ -4,17 +4,27 @@ import AdminDashboard from './admin/dashboard';
|
||||
import AdminFilesList from './admin/files-list';
|
||||
import AdminStoragePoliciesList from './admin/storage-policies-list';
|
||||
import AdminUsersList from './admin/users-list';
|
||||
import AdminLayout from './admin/AdminLayout';
|
||||
|
||||
// 新增占位页面
|
||||
import AdminSettings from './admin/settings';
|
||||
import AdminFilesystem from './admin/filesystem';
|
||||
import AdminFileBlobs from './admin/fileblobs';
|
||||
import AdminShares from './admin/shares';
|
||||
import AdminTasks from './admin/tasks';
|
||||
import AdminOAuthApps from './admin/oauthapps';
|
||||
|
||||
import Layout from './components/layout/Layout';
|
||||
import MobileLayout from './mobile-components/MobileLayout';
|
||||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
import Login from './pages/Login';
|
||||
import Overview from './pages/Overview';
|
||||
import RecycleBin from './pages/RecycleBin';
|
||||
import Shares from './pages/Shares';
|
||||
import Tasks from './pages/Tasks';
|
||||
import Transfer from './pages/Transfer';
|
||||
import FileShare from './pages/FileShare';
|
||||
import FilesPage from './pages/files/FilesPage';
|
||||
import Login from './account/pages/LoginPage';
|
||||
import Overview from './workspace/pages/OverviewPage';
|
||||
import FilesPage from './workspace/pages/FilesPage';
|
||||
import RecycleBin from './workspace/pages/RecycleBinPage';
|
||||
import Shares from './sharing/pages/SharesPage';
|
||||
import FileShare from './sharing/pages/FileSharePage';
|
||||
import Tasks from './common/pages/TasksPage';
|
||||
import Transfer from './transfer/pages/TransferPage';
|
||||
|
||||
function AnimatedRoutes({ isMobile }: { isMobile: boolean }) {
|
||||
const location = useLocation();
|
||||
@@ -33,13 +43,22 @@ function AnimatedRoutes({ isMobile }: { isMobile: boolean }) {
|
||||
<Route path="/shares" element={<Shares />} />
|
||||
<Route path="/recycle-bin" element={<RecycleBin />} />
|
||||
<Route path="/transfer" element={<Transfer />} />
|
||||
<Route path="/admin">
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route path="dashboard" element={isMobile ? <Navigate to="/overview" replace /> : <AdminDashboard />} />
|
||||
<Route path="users" element={isMobile ? <Navigate to="/overview" replace /> : <AdminUsersList />} />
|
||||
<Route path="files" element={isMobile ? <Navigate to="/overview" replace /> : <AdminFilesList />} />
|
||||
<Route path="storage-policies" element={isMobile ? <Navigate to="/overview" replace /> : <AdminStoragePoliciesList />} />
|
||||
|
||||
{/* 管理台路由重构 */}
|
||||
<Route path="/admin" element={isMobile ? <Navigate to="/overview" replace /> : <AdminLayout />}>
|
||||
<Route index element={<Navigate to="dashboard" replace />} />
|
||||
<Route path="dashboard" element={<AdminDashboard />} />
|
||||
<Route path="settings" element={<AdminSettings />} />
|
||||
<Route path="filesystem" element={<AdminFilesystem />} />
|
||||
<Route path="storage-policies" element={<AdminStoragePoliciesList />} />
|
||||
<Route path="users" element={<AdminUsersList />} />
|
||||
<Route path="files" element={<AdminFilesList />} />
|
||||
<Route path="file-blobs" element={<AdminFileBlobs />} />
|
||||
<Route path="shares" element={<AdminShares />} />
|
||||
<Route path="tasks" element={<AdminTasks />} />
|
||||
<Route path="oauth-apps" element={<AdminOAuthApps />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/overview" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
1
front/src/account/pages/LoginPage.tsx
Normal file
1
front/src/account/pages/LoginPage.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@/src/pages/Login';
|
||||
72
front/src/admin/AdminLayout.tsx
Normal file
72
front/src/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import {
|
||||
Activity,
|
||||
Database,
|
||||
FileBox,
|
||||
Files,
|
||||
HardDrive,
|
||||
Key,
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
Settings,
|
||||
Share2,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
export default function AdminLayout() {
|
||||
const adminNavItems = [
|
||||
{ to: 'dashboard', icon: LayoutDashboard, label: '总览' },
|
||||
{ to: 'settings', icon: Settings, label: '系统设置' },
|
||||
{ to: 'filesystem', icon: HardDrive, label: '文件系统' },
|
||||
{ to: 'storage-policies', icon: Database, label: '存储策略' },
|
||||
{ to: 'users', icon: Users, label: '用户管理' },
|
||||
{ to: 'files', icon: Files, label: '文件审计' },
|
||||
{ to: 'file-blobs', icon: FileBox, label: '对象实体' },
|
||||
{ to: 'shares', icon: Share2, label: '分享管理' },
|
||||
{ to: 'tasks', icon: ListTodo, label: '任务监控' },
|
||||
{ to: 'oauth-apps', icon: Key, label: '三方应用' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full overflow-hidden">
|
||||
{/* Admin Secondary Sidebar */}
|
||||
<aside className="w-64 flex-shrink-0 border-r border-white/10 bg-white/5 dark:bg-black/20 flex flex-col z-10">
|
||||
<div className="px-8 py-8">
|
||||
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">管理中心</h2>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto px-4 pb-8 space-y-1 custom-scrollbar">
|
||||
{adminNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 px-4 py-3 rounded-lg text-xs font-black uppercase tracking-widest transition-all duration-300",
|
||||
isActive
|
||||
? "bg-blue-600/10 text-blue-600 dark:text-blue-400 shadow-sm border border-blue-500/10"
|
||||
: "text-gray-700 dark:text-gray-200 hover:bg-white/10 dark:hover:bg-white/5 opacity-60 hover:opacity-100"
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Admin Content Area */}
|
||||
<main className="flex-1 overflow-hidden relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="h-full w-full overflow-y-auto custom-scrollbar"
|
||||
>
|
||||
<Outlet />
|
||||
</motion.div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
front/src/admin/fileblobs.tsx
Normal file
1
front/src/admin/fileblobs.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export default function AdminFileBlobs() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin FileBlobs (待开发)</h1></div>; }
|
||||
1
front/src/admin/filesystem.tsx
Normal file
1
front/src/admin/filesystem.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export default function AdminFilesystem() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin Filesystem (待开发)</h1></div>; }
|
||||
1
front/src/admin/oauthapps.tsx
Normal file
1
front/src/admin/oauthapps.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export default function AdminOAuthApps() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin OAuthApps (待开发)</h1></div>; }
|
||||
1
front/src/admin/settings.tsx
Normal file
1
front/src/admin/settings.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export default function AdminSettings() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin Settings (待开发)</h1></div>; }
|
||||
1
front/src/admin/shares.tsx
Normal file
1
front/src/admin/shares.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export default function AdminShares() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin Shares (待开发)</h1></div>; }
|
||||
1
front/src/admin/tasks.tsx
Normal file
1
front/src/admin/tasks.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export default function AdminTasks() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin Tasks (待开发)</h1></div>; }
|
||||
1
front/src/common/pages/TasksPage.tsx
Normal file
1
front/src/common/pages/TasksPage.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@/src/pages/Tasks';
|
||||
@@ -20,6 +20,10 @@ const initialState: ThemeProviderState = {
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
function resolveSystemTheme(): 'dark' | 'light' {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
@@ -32,20 +36,26 @@ export function ThemeProvider({
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
const applyTheme = (nextTheme: Theme) => {
|
||||
const resolved = nextTheme === 'system' ? resolveSystemTheme() : nextTheme;
|
||||
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.remove('dark');
|
||||
if (resolved === 'dark') {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
root.style.colorScheme = resolved;
|
||||
};
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
.matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
applyTheme(theme);
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
if (theme !== 'system') {
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = () => applyTheme('system');
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
|
||||
@@ -16,28 +16,35 @@ import { cn } from '@/src/lib/utils';
|
||||
import { logout } from '@/src/lib/auth';
|
||||
import { getSession, type PortalSession } from '@/src/lib/session';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
import { useSessionRuntime } from '@/src/hooks/use-session-runtime';
|
||||
import { UploadCenter } from '../upload/UploadCenter';
|
||||
import { TaskSummaryPanel } from '../tasks/TaskSummaryPanel';
|
||||
import { realtimeRuntime } from '@/src/lib/realtime-runtime';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [session, setSession] = useState<PortalSession | null>(() => getSession());
|
||||
const { session } = useSessionRuntime();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const handleSessionChange = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<PortalSession | null>;
|
||||
setSession(customEvent.detail ?? getSession());
|
||||
};
|
||||
window.addEventListener('portal-session-changed', handleSessionChange);
|
||||
return () => window.removeEventListener('portal-session-changed', handleSessionChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session && location.pathname !== '/transfer') {
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
if (session) {
|
||||
realtimeRuntime.start();
|
||||
} else {
|
||||
realtimeRuntime.stop();
|
||||
}
|
||||
return () => realtimeRuntime.stop();
|
||||
}, [location.pathname, navigate, session]);
|
||||
|
||||
|
||||
const navItems = [
|
||||
{ to: '/overview', icon: LayoutDashboard, label: '概览' },
|
||||
{ to: '/files', icon: HardDrive, label: '网盘' },
|
||||
@@ -55,7 +62,8 @@ export default function Layout() {
|
||||
{/* Sidebar */}
|
||||
<aside className="w-68 flex-shrink-0 border-r border-white/20 dark:border-white/10 bg-white/40 dark:bg-black/40 backdrop-blur-2xl flex flex-col z-20 shadow-xl">
|
||||
<div className="h-24 flex items-center justify-between px-8 border-b border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-6">
|
||||
<TaskSummaryPanel />
|
||||
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center text-white font-black shadow-lg text-lg tracking-tighter">P</div>
|
||||
<span className="text-2xl font-black tracking-tight uppercase">门户</span>
|
||||
</div>
|
||||
@@ -116,6 +124,8 @@ export default function Layout() {
|
||||
<main className="relative flex min-w-0 flex-1 flex-col overflow-hidden z-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<UploadCenter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
42
front/src/components/media/FileThumbnail.tsx
Normal file
42
front/src/components/media/FileThumbnail.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { File, FileAudio, FileImage, FileText, FileVideo, Folder } from 'lucide-react';
|
||||
import { type FileItem } from '@/src/lib/files';
|
||||
import { getApiBaseUrl } from '@/src/lib/api';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
|
||||
interface FileThumbnailProps {
|
||||
file: FileItem;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FileThumbnail({ file, className }: FileThumbnailProps) {
|
||||
if (file.directory) {
|
||||
return <Folder className={cn("h-full w-full text-blue-500", className)} />;
|
||||
}
|
||||
|
||||
// 如果有缩略图 Key,展示缩略图
|
||||
if (file.thumbnailKey) {
|
||||
const thumbUrl = `${getApiBaseUrl()}/v2/files/blobs/${file.thumbnailKey}/content`;
|
||||
return (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={file.filename}
|
||||
className={cn("h-full w-full object-cover rounded shadow-inner", className)}
|
||||
onError={(e) => {
|
||||
// 如果缩略图加载失败,回退到图标
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 根据 contentType 展示图标
|
||||
const type = file.contentType || '';
|
||||
if (type.startsWith('image/')) return <FileImage className={cn("h-full w-full text-green-500", className)} />;
|
||||
if (type.startsWith('video/')) return <FileVideo className={cn("h-full w-full text-purple-500", className)} />;
|
||||
if (type.startsWith('audio/')) return <FileAudio className={cn("h-full w-full text-amber-500", className)} />;
|
||||
if (type.includes('pdf') || type.includes('word') || type.includes('text')) {
|
||||
return <FileText className={cn("h-full w-full text-blue-400", className)} />;
|
||||
}
|
||||
|
||||
return <File className={cn("h-full w-full text-gray-400", className)} />;
|
||||
}
|
||||
45
front/src/components/tasks/TaskSummaryPanel.tsx
Normal file
45
front/src/components/tasks/TaskSummaryPanel.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ListTodo, Loader2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { taskRuntime } from '@/src/lib/task-runtime';
|
||||
import { type BackgroundTask } from '@/src/lib/background-tasks';
|
||||
|
||||
export function TaskSummaryPanel() {
|
||||
const [activeTasks, setActiveTasks] = useState<BackgroundTask[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
taskRuntime.startPolling();
|
||||
const unsubscribe = taskRuntime.subscribe(() => {
|
||||
setActiveTasks(taskRuntime.getActiveTasks());
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
// 在这个简单的单页面应用中我们可能不需要停止轮询,除非组件被销毁
|
||||
// taskRuntime.stopPolling();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (activeTasks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 rounded-lg bg-blue-500/10 border border-blue-500/20 shadow-sm transition-all animate-in fade-in slide-in-from-top-2">
|
||||
<div className="relative">
|
||||
<ListTodo className="h-4 w-4 text-blue-500" />
|
||||
<span className="absolute -top-1.5 -right-1.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-blue-600 text-[8px] font-black text-white shadow-sm">
|
||||
{activeTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-[9px] font-black uppercase tracking-widest text-blue-600/60 leading-none mb-0.5">
|
||||
后台任务
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-blue-500/60" />
|
||||
<span className="text-[10px] font-bold truncate max-w-[120px] uppercase tracking-tight">
|
||||
{activeTasks[0].type} ...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
front/src/components/upload/UploadCenter.tsx
Normal file
109
front/src/components/upload/UploadCenter.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CheckCircle2, ChevronDown, ChevronUp, Loader2, X, XCircle } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { uploadRuntime, type UploadTask } from '@/src/lib/upload-runtime';
|
||||
import { formatBytes } from '@/src/lib/format';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
|
||||
export function UploadCenter() {
|
||||
const [tasks, setTasks] = useState<UploadTask[]>([]);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = uploadRuntime.subscribe(() => {
|
||||
setTasks(uploadRuntime.getTasks());
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
const uploadingCount = tasks.filter(t => t.status === 'UPLOADING').length;
|
||||
const successCount = tasks.filter(t => t.status === 'SUCCESS').length;
|
||||
const errorCount = tasks.filter(t => t.status === 'ERROR').length;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-24 right-8 z-[60] w-80">
|
||||
<motion.div
|
||||
layout
|
||||
className="glass-panel-no-hover overflow-hidden rounded-xl shadow-2xl border border-white/20 bg-white/10 dark:bg-black/40 backdrop-blur-xl"
|
||||
>
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between px-5 py-4 hover:bg-white/5 transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
{uploadingCount > 0 ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-blue-500" />
|
||||
) : (
|
||||
<div className="h-5 w-5 rounded-full bg-blue-500/20 flex items-center justify-center">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] font-black uppercase tracking-widest">
|
||||
传输队列 ({tasks.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4 opacity-40" /> : <ChevronUp className="h-4 w-4 opacity-40" />}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); uploadRuntime.clearFinished(); }}
|
||||
className="p-1 hover:bg-white/10 rounded transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4 opacity-40 hover:opacity-100" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="border-t border-white/10"
|
||||
>
|
||||
<div className="max-h-80 overflow-y-auto p-4 custom-scrollbar space-y-3">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[11px] font-bold uppercase tracking-tight opacity-90">
|
||||
{task.filename}
|
||||
</div>
|
||||
<div className="text-[9px] font-black opacity-40 uppercase tracking-widest mt-0.5">
|
||||
{formatBytes(task.size)} • {task.status === 'SUCCESS' ? '已完成' : task.status === 'ERROR' ? '失败' : `${task.progress}%`}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{task.status === 'SUCCESS' && <CheckCircle2 className="h-4 w-4 text-green-500" />}
|
||||
{task.status === 'ERROR' && <XCircle className="h-4 w-4 text-red-500" />}
|
||||
{task.status === 'UPLOADING' && <span className="text-[10px] font-black tabular-nums text-blue-500">{task.progress}%</span>}
|
||||
</div>
|
||||
</div>
|
||||
{task.status === 'UPLOADING' && (
|
||||
<div className="h-1 w-full rounded-full bg-white/5 overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${task.progress}%` }}
|
||||
className="h-full bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{task.error && (
|
||||
<div className="text-[9px] font-bold text-red-500/80 uppercase tracking-tighter">
|
||||
{task.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
front/src/hooks/use-directory-data.ts
Normal file
51
front/src/hooks/use-directory-data.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { listFiles, type FileItem } from '../lib/files';
|
||||
import { filesCache, type DirectoryCacheEntry } from '../lib/files-cache';
|
||||
|
||||
export function useDirectoryData(path: string, page = 0, size = 100) {
|
||||
const [data, setData] = useState<DirectoryCacheEntry | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const key = filesCache.getCacheKey(path, page, size);
|
||||
|
||||
const fetchLatest = useCallback(async (isSilent = false) => {
|
||||
if (!isSilent) setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await listFiles(path, page, size);
|
||||
filesCache.set(key, result.items, result.total);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '获取目录数据失败');
|
||||
} finally {
|
||||
if (!isSilent) setLoading(false);
|
||||
}
|
||||
}, [key, path, page, size]);
|
||||
|
||||
useEffect(() => {
|
||||
const entry = filesCache.get(key);
|
||||
if (entry) {
|
||||
setData(entry);
|
||||
if (entry.isStale) {
|
||||
void fetchLatest(true);
|
||||
}
|
||||
} else {
|
||||
void fetchLatest();
|
||||
}
|
||||
|
||||
const unsubscribe = filesCache.subscribe(() => {
|
||||
setData(filesCache.get(key));
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [key, fetchLatest]);
|
||||
|
||||
return {
|
||||
items: data?.items ?? [],
|
||||
total: data?.total ?? 0,
|
||||
loading,
|
||||
error,
|
||||
refresh: () => fetchLatest(),
|
||||
isStale: data?.isStale ?? false,
|
||||
};
|
||||
}
|
||||
15
front/src/hooks/use-session-runtime.ts
Normal file
15
front/src/hooks/use-session-runtime.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { sessionRuntime, type SessionState } from '../lib/session-runtime';
|
||||
|
||||
export function useSessionRuntime(): SessionState {
|
||||
const [state, setState] = useState<SessionState>(() => sessionRuntime.getState());
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = sessionRuntime.subscribe((nextState) => {
|
||||
setState(nextState);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-aurora-1: #c4d9ff;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { clearSession, getSession, setSession, type PortalSession } from './session';
|
||||
import { getSession, setSession, clearSession, type PortalSession } from './session';
|
||||
import { sessionRuntime } from './session-runtime';
|
||||
|
||||
|
||||
const CLIENT_HEADER = 'X-Yoyuzh-Client';
|
||||
const CLIENT_ID_HEADER = 'X-Yoyuzh-Client-Id';
|
||||
@@ -168,6 +170,7 @@ export async function fetchApi<T = unknown>(endpoint: string, options: FetchApiO
|
||||
if ((response.status === 401 || response.status === 403) && auth && session?.refreshToken && retryOnAuthFailure) {
|
||||
try {
|
||||
const refreshed = await refreshAccessToken(session);
|
||||
sessionRuntime.updateSession(refreshed);
|
||||
return fetchApi<T>(endpoint, {
|
||||
...options,
|
||||
retryOnAuthFailure: false,
|
||||
@@ -177,10 +180,15 @@ export async function fetchApi<T = unknown>(endpoint: string, options: FetchApiO
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
clearSession();
|
||||
sessionRuntime.handleAuthFailure('EXPIRED');
|
||||
throw new ApiError('登录已过期,请重新登录', 401);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 401 && !retryOnAuthFailure) {
|
||||
sessionRuntime.handleAuthFailure('EXPIRED');
|
||||
}
|
||||
|
||||
if (rawResponse) {
|
||||
if (!response.ok) {
|
||||
throw new ApiError(`请求失败(HTTP ${response.status})`, response.status);
|
||||
|
||||
56
front/src/lib/files-cache.ts
Normal file
56
front/src/lib/files-cache.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { type FileItem } from './files';
|
||||
|
||||
export interface DirectoryCacheEntry {
|
||||
items: FileItem[];
|
||||
total: number;
|
||||
timestamp: number;
|
||||
isStale: boolean;
|
||||
}
|
||||
|
||||
const CACHE_TTL = 1000 * 60 * 5; // 5 分钟有效
|
||||
|
||||
class FilesCache {
|
||||
private cache: Map<string, DirectoryCacheEntry> = new Map();
|
||||
private listeners: Set<() => void> = new Set();
|
||||
|
||||
getCacheKey(path: string, page: number, size: number, sort?: string) {
|
||||
return `${path}:${page}:${size}:${sort || 'default'}`;
|
||||
}
|
||||
|
||||
get(key: string): DirectoryCacheEntry | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
const isStale = Date.now() - entry.timestamp > CACHE_TTL;
|
||||
return { ...entry, isStale };
|
||||
}
|
||||
|
||||
set(key: string, items: FileItem[], total: number) {
|
||||
this.cache.set(key, {
|
||||
items,
|
||||
total,
|
||||
timestamp: Date.now(),
|
||||
isStale: false,
|
||||
});
|
||||
this.notify();
|
||||
}
|
||||
|
||||
invalidate(pathPrefix: string) {
|
||||
const keysToDELETE = Array.from(this.cache.keys()).filter((key) => key.startsWith(pathPrefix));
|
||||
keysToDELETE.forEach((key) => this.cache.delete(key));
|
||||
this.notify();
|
||||
}
|
||||
|
||||
subscribe(listener: () => void) {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private notify() {
|
||||
this.listeners.forEach((l) => l());
|
||||
}
|
||||
}
|
||||
|
||||
export const filesCache = new FilesCache();
|
||||
@@ -15,6 +15,8 @@ export type FileItem = {
|
||||
contentType: string;
|
||||
directory: boolean;
|
||||
createdAt: string;
|
||||
thumbnailKey?: string | null;
|
||||
publicMetaJson?: string | null;
|
||||
};
|
||||
|
||||
export type RecycleBinItem = {
|
||||
|
||||
88
front/src/lib/realtime-runtime.ts
Normal file
88
front/src/lib/realtime-runtime.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { getApiBaseUrl } from './api';
|
||||
import { getSession } from './session';
|
||||
import { filesCache } from './files-cache';
|
||||
import { taskRuntime } from './task-runtime';
|
||||
|
||||
export interface RealtimeEvent {
|
||||
type: string;
|
||||
payload: any;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
class RealtimeRuntime {
|
||||
private eventSource: EventSource | null = null;
|
||||
private listeners: Set<(event: RealtimeEvent) => void> = new Set();
|
||||
private reconnectTimer: number | null = null;
|
||||
|
||||
start() {
|
||||
if (this.eventSource) return;
|
||||
|
||||
const session = getSession();
|
||||
if (!session) return;
|
||||
|
||||
// SSE 不支持直接传递 Bearer 头,通常使用查询参数或 Cookie
|
||||
// 这里假设后端支持 token 查询参数,或者后端已通过 Cookie 鉴权
|
||||
const url = new URL(`${getApiBaseUrl()}/v2/files/events`, window.location.origin);
|
||||
url.searchParams.set('token', session.accessToken);
|
||||
|
||||
this.eventSource = new EventSource(url.toString());
|
||||
|
||||
this.eventSource.onmessage = (e) => {
|
||||
try {
|
||||
const event = JSON.parse(e.data) as RealtimeEvent;
|
||||
this.handleEvent(event);
|
||||
} catch (err) {
|
||||
console.error('实时事件解析失败', err);
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = () => {
|
||||
console.warn('实时连接异常,尝试重连...');
|
||||
this.stop();
|
||||
this.reconnectTimer = window.setTimeout(() => this.start(), 3000);
|
||||
};
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(event: RealtimeEvent) {
|
||||
// 基础事件分发
|
||||
switch (event.type) {
|
||||
case 'FILE_CREATED':
|
||||
case 'FILE_UPDATED':
|
||||
case 'FILE_DELETED':
|
||||
case 'FILE_MOVED':
|
||||
// 简单暴力失效:前缀匹配或全量标记
|
||||
// 实际上可以根据 payload 中的路径精准失效
|
||||
if (event.payload?.path) {
|
||||
filesCache.invalidate(event.payload.path);
|
||||
} else {
|
||||
filesCache.invalidate('/');
|
||||
}
|
||||
break;
|
||||
case 'TASK_UPDATED':
|
||||
case 'TASK_FINISHED':
|
||||
// 触发任务运行时立即刷新
|
||||
void taskRuntime.refresh();
|
||||
break;
|
||||
}
|
||||
|
||||
this.listeners.forEach(l => l(event));
|
||||
}
|
||||
|
||||
subscribe(listener: (event: RealtimeEvent) => void) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
}
|
||||
|
||||
export const realtimeRuntime = new RealtimeRuntime();
|
||||
89
front/src/lib/session-runtime.ts
Normal file
89
front/src/lib/session-runtime.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { getSession, setSession, clearSession, type PortalSession } from './session';
|
||||
|
||||
export type AuthFailureReason = 'EXPIRED' | 'REVOKED' | 'BANNED' | 'CONCURRENT_LOGIN' | 'UNKNOWN';
|
||||
|
||||
export interface SessionState {
|
||||
session: PortalSession | null;
|
||||
isValid: boolean;
|
||||
lastAuthFailure: AuthFailureReason | null;
|
||||
}
|
||||
|
||||
type SessionChangeListener = (state: SessionState) => void;
|
||||
|
||||
class SessionRuntime {
|
||||
private listeners: Set<SessionChangeListener> = new Set();
|
||||
private state: SessionState;
|
||||
|
||||
constructor() {
|
||||
this.state = {
|
||||
session: getSession(),
|
||||
isValid: !!getSession(),
|
||||
lastAuthFailure: null,
|
||||
};
|
||||
|
||||
// 监听原生 storage 事件以支持多标签同步
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key === 'portal-session') {
|
||||
this.syncWithStorage();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听旧的自定义事件以保持兼容
|
||||
window.addEventListener('portal-session-changed', () => {
|
||||
this.syncWithStorage();
|
||||
});
|
||||
}
|
||||
|
||||
private syncWithStorage() {
|
||||
const session = getSession();
|
||||
this.updateState({
|
||||
session,
|
||||
isValid: !!session,
|
||||
});
|
||||
}
|
||||
|
||||
private updateState(patch: Partial<SessionState>) {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.notify();
|
||||
}
|
||||
|
||||
getState(): SessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
subscribe(listener: SessionChangeListener) {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private notify() {
|
||||
this.listeners.forEach((l) => l(this.state));
|
||||
}
|
||||
|
||||
updateSession(session: PortalSession) {
|
||||
setSession(session);
|
||||
this.updateState({ session, isValid: true, lastAuthFailure: null });
|
||||
}
|
||||
|
||||
handleAuthFailure(reason: AuthFailureReason) {
|
||||
clearSession();
|
||||
this.updateState({
|
||||
session: null,
|
||||
isValid: false,
|
||||
lastAuthFailure: reason
|
||||
});
|
||||
}
|
||||
|
||||
logout() {
|
||||
clearSession();
|
||||
this.updateState({
|
||||
session: null,
|
||||
isValid: false,
|
||||
lastAuthFailure: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const sessionRuntime = new SessionRuntime();
|
||||
63
front/src/lib/task-runtime.ts
Normal file
63
front/src/lib/task-runtime.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { getTasks, type BackgroundTask } from './background-tasks';
|
||||
|
||||
class TaskRuntime {
|
||||
private activeTasks: BackgroundTask[] = [];
|
||||
private listeners: Set<() => void> = new Set();
|
||||
private pollInterval: number | null = null;
|
||||
|
||||
getActiveTasks() {
|
||||
return this.activeTasks;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify() {
|
||||
this.listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
startPolling() {
|
||||
if (this.pollInterval) return;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const result = await getTasks(0, 20);
|
||||
// 我们只关注活跃的任务(未完成的任务)
|
||||
const nextActive = result.items.filter(t => !t.finishedAt);
|
||||
|
||||
// 如果任务状态发生变化,通知订阅者
|
||||
if (JSON.stringify(nextActive) !== JSON.stringify(this.activeTasks)) {
|
||||
this.activeTasks = nextActive;
|
||||
this.notify();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('任务轮询失败', err);
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
this.pollInterval = window.setInterval(() => { void poll(); }, 5000);
|
||||
}
|
||||
|
||||
stopPolling() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 立即触发一次强制更新
|
||||
async refresh() {
|
||||
try {
|
||||
const result = await getTasks(0, 20);
|
||||
this.activeTasks = result.items.filter(t => !t.finishedAt);
|
||||
this.notify();
|
||||
} catch (err) {
|
||||
console.error('任务刷新失败', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const taskRuntime = new TaskRuntime();
|
||||
@@ -1,113 +1 @@
|
||||
import { fetchApi, getApiBaseUrl } from './api';
|
||||
|
||||
export type TransferMode = 'ONLINE' | 'OFFLINE';
|
||||
|
||||
export type TransferFilePayload = {
|
||||
name: string;
|
||||
relativePath: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
export type TransferFileItem = TransferFilePayload & {
|
||||
id?: string | null;
|
||||
uploaded?: boolean | null;
|
||||
};
|
||||
|
||||
export type TransferSessionResponse = {
|
||||
sessionId: string;
|
||||
pickupCode: string;
|
||||
mode: TransferMode;
|
||||
expiresAt: string;
|
||||
files: TransferFileItem[];
|
||||
};
|
||||
|
||||
export type LookupTransferSessionResponse = {
|
||||
sessionId: string;
|
||||
pickupCode: string;
|
||||
mode: TransferMode;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export function sanitizePickupCode(value: string) {
|
||||
return value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6);
|
||||
}
|
||||
|
||||
export function getTransferFileRelativePath(file: File) {
|
||||
const rawRelativePath =
|
||||
'webkitRelativePath' in file && typeof file.webkitRelativePath === 'string' && file.webkitRelativePath
|
||||
? file.webkitRelativePath
|
||||
: file.name;
|
||||
|
||||
const normalizedPath = rawRelativePath
|
||||
.replaceAll('\\', '/')
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
|
||||
return normalizedPath || file.name;
|
||||
}
|
||||
|
||||
export function toTransferFilePayload(files: File[]) {
|
||||
return files.map<TransferFilePayload>((file) => ({
|
||||
name: file.name,
|
||||
relativePath: getTransferFileRelativePath(file),
|
||||
size: file.size,
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
}));
|
||||
}
|
||||
|
||||
export function createTransferSession(files: File[], mode: TransferMode) {
|
||||
return fetchApi<TransferSessionResponse>('/transfer/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
mode,
|
||||
files: toTransferFilePayload(files),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function lookupTransferSession(pickupCode: string) {
|
||||
return fetchApi<LookupTransferSessionResponse>(
|
||||
`/transfer/sessions/lookup?pickupCode=${encodeURIComponent(sanitizePickupCode(pickupCode))}`,
|
||||
{
|
||||
auth: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function joinTransferSession(sessionId: string) {
|
||||
return fetchApi<TransferSessionResponse>(`/transfer/sessions/${encodeURIComponent(sessionId)}/join`, {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function listMyOfflineTransferSessions() {
|
||||
return fetchApi<TransferSessionResponse[]>('/transfer/sessions/offline/mine');
|
||||
}
|
||||
|
||||
export function uploadOfflineTransferFile(sessionId: string, fileId: string, file: File) {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
|
||||
return fetchApi<void>(`/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/content`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildOfflineTransferDownloadUrl(sessionId: string, fileId: string) {
|
||||
return `${getApiBaseUrl()}/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/download`;
|
||||
}
|
||||
|
||||
export function importOfflineTransferFile(sessionId: string, fileId: string, path: string) {
|
||||
return fetchApi<{ id: number; filename: string; path: string }>(
|
||||
`/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/import`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path }),
|
||||
},
|
||||
);
|
||||
}
|
||||
export * from '@/src/transfer/api/transfer';
|
||||
|
||||
141
front/src/lib/upload-runtime.ts
Normal file
141
front/src/lib/upload-runtime.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
createUploadSession,
|
||||
getUploadSession,
|
||||
prepareUpload,
|
||||
prepareUploadPart,
|
||||
recordUploadedPart,
|
||||
completeUploadSession,
|
||||
type UploadSession
|
||||
} from './upload-session';
|
||||
import { fetchApi } from './api';
|
||||
|
||||
export type UploadStatus = 'WAITING' | 'UPLOADING' | 'COMPLETING' | 'SUCCESS' | 'ERROR' | 'CANCELLED';
|
||||
|
||||
export interface UploadTask {
|
||||
id: string; // sessionId
|
||||
file: File;
|
||||
path: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
progress: number;
|
||||
status: UploadStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
class UploadRuntime {
|
||||
private tasks: Map<string, UploadTask> = new Map();
|
||||
private listeners: Set<() => void> = new Set();
|
||||
|
||||
getTasks() {
|
||||
return Array.from(this.tasks.values());
|
||||
}
|
||||
|
||||
subscribe(listener: () => void) {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private notify() {
|
||||
this.listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
async uploadFile(file: File, path = '/') {
|
||||
const session = await createUploadSession({
|
||||
path,
|
||||
filename: file.name,
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
const task: UploadTask = {
|
||||
id: session.sessionId,
|
||||
file,
|
||||
path,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
progress: 0,
|
||||
status: 'UPLOADING',
|
||||
};
|
||||
|
||||
this.tasks.set(task.id, task);
|
||||
this.notify();
|
||||
|
||||
try {
|
||||
await this.processUpload(session, file, (progress) => {
|
||||
task.progress = progress;
|
||||
this.notify();
|
||||
});
|
||||
task.status = 'SUCCESS';
|
||||
task.progress = 100;
|
||||
} catch (err) {
|
||||
task.status = 'ERROR';
|
||||
task.error = err instanceof Error ? err.message : '上传失败';
|
||||
} finally {
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
private async processUpload(session: UploadSession, file: File, onProgress: (p: number) => void) {
|
||||
if (session.uploadMode === 'PROXY') {
|
||||
const formData = new FormData();
|
||||
formData.append(session.strategy.proxyFieldName || 'file', file);
|
||||
// 注意:PROXY 模式目前难以获取原生 fetch 的上传进度,除非使用 XMLHttpRequest
|
||||
await fetchApi(session.strategy.proxyUploadUrl || `/v2/files/upload-sessions/${session.sessionId}/content`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
onProgress(100);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.uploadMode === 'DIRECT_SINGLE') {
|
||||
const prepared = await prepareUpload(session.sessionId);
|
||||
await fetch(prepared.uploadUrl, {
|
||||
method: prepared.method || 'PUT',
|
||||
headers: prepared.headers,
|
||||
body: file,
|
||||
});
|
||||
onProgress(100);
|
||||
await completeUploadSession(session.sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 分片上传进度模拟
|
||||
const chunkSize = Math.max(session.chunkSize || 5 * 1024 * 1024, 5 * 1024 * 1024);
|
||||
for (let partIndex = 0; partIndex < session.chunkCount; partIndex += 1) {
|
||||
const start = partIndex * chunkSize;
|
||||
const end = Math.min(file.size, start + chunkSize);
|
||||
const chunk = file.slice(start, end);
|
||||
const prepared = await prepareUploadPart(session.sessionId, partIndex);
|
||||
|
||||
const uploadResponse = await fetch(prepared.uploadUrl, {
|
||||
method: prepared.method || 'PUT',
|
||||
headers: prepared.headers,
|
||||
body: chunk,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) throw new Error(`分片 ${partIndex} 上传失败`);
|
||||
|
||||
const etag = uploadResponse.headers.get('etag') ?? '';
|
||||
await recordUploadedPart(session.sessionId, partIndex, etag.replaceAll('"', ''), chunk.size);
|
||||
|
||||
const currentProgress = Math.round(((partIndex + 1) / session.chunkCount) * 100);
|
||||
onProgress(currentProgress);
|
||||
}
|
||||
|
||||
await completeUploadSession(session.sessionId);
|
||||
}
|
||||
|
||||
clearFinished() {
|
||||
for (const [id, task] of this.tasks.entries()) {
|
||||
if (task.status === 'SUCCESS' || task.status === 'ERROR' || task.status === 'CANCELLED') {
|
||||
this.tasks.delete(id);
|
||||
}
|
||||
}
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
export const uploadRuntime = new UploadRuntime();
|
||||
@@ -6,20 +6,18 @@ import { cn } from '@/src/lib/utils';
|
||||
import { ThemeToggle } from '@/src/components/ThemeToggle';
|
||||
import { logout } from '@/src/lib/auth';
|
||||
import { getSession, type PortalSession } from '@/src/lib/session';
|
||||
import { useSessionRuntime } from '@/src/hooks/use-session-runtime';
|
||||
import { UploadCenter } from '../components/upload/UploadCenter';
|
||||
import { TaskSummaryPanel } from '../components/tasks/TaskSummaryPanel';
|
||||
|
||||
|
||||
export default function MobileLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [session, setSession] = useState<PortalSession | null>(() => getSession());
|
||||
const { session } = useSessionRuntime();
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handleSessionChange = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<PortalSession | null>;
|
||||
setSession(customEvent.detail ?? getSession());
|
||||
};
|
||||
window.addEventListener('portal-session-changed', handleSessionChange);
|
||||
return () => window.removeEventListener('portal-session-changed', handleSessionChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session && location.pathname !== '/transfer') {
|
||||
@@ -44,9 +42,12 @@ export default function MobileLayout() {
|
||||
className="flex h-screen w-full flex-col overflow-hidden bg-aurora text-gray-900 dark:text-gray-100 transition-colors"
|
||||
>
|
||||
<header className="fixed top-4 left-4 right-4 z-50 flex items-center justify-between glass-panel rounded-lg px-6 py-4 shadow-xl border-white/20">
|
||||
<div>
|
||||
<div className="text-sm font-black tracking-tight text-blue-600 dark:text-blue-400 uppercase">移动端门户</div>
|
||||
<div className="text-sm font-bold opacity-80 dark:opacity-90 uppercase tracking-[0.2em]">{session?.user.username || '游客用户'}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] font-black tracking-tight text-blue-600 dark:text-blue-400 uppercase leading-none mb-1">移动端门户</div>
|
||||
<div className="text-sm font-black uppercase tracking-tight">{session?.user.username || '游客用户'}</div>
|
||||
</div>
|
||||
<TaskSummaryPanel />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
@@ -63,6 +64,9 @@ export default function MobileLayout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<UploadCenter />
|
||||
|
||||
|
||||
<main className="relative flex-1 overflow-y-auto pt-28 pb-28 px-4">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function Login() {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="min-h-screen bg-aurora flex flex-col justify-center py-12 px-6 lg:px-8 relative overflow-hidden"
|
||||
className="min-h-screen bg-aurora text-gray-900 dark:text-gray-100 flex flex-col justify-center py-12 px-6 lg:px-8 relative overflow-hidden"
|
||||
>
|
||||
{/* Theme Toggle Top Right */}
|
||||
<motion.div
|
||||
@@ -145,7 +145,7 @@ export default function Login() {
|
||||
placeholder="用户名"
|
||||
value={loginForm.username}
|
||||
onChange={(event) => setLoginForm((current) => ({ ...current, username: event.target.value }))}
|
||||
className="w-full px-5 py-4 bg-white/10 dark:bg-black/20 border border-white/10 dark:border-white/5 rounded-lg placeholder-white/30 text-base focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-bold tracking-wide"
|
||||
className="w-full px-5 py-4 bg-white/10 dark:bg-black/20 border border-white/10 dark:border-white/5 rounded-lg text-gray-900 dark:text-gray-100 placeholder:text-gray-500 dark:placeholder:text-gray-400 text-base focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-bold tracking-wide"
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
@@ -155,7 +155,7 @@ export default function Login() {
|
||||
placeholder="密码"
|
||||
value={loginForm.password}
|
||||
onChange={(event) => setLoginForm((current) => ({ ...current, password: event.target.value }))}
|
||||
className="w-full px-5 py-4 bg-white/10 dark:bg-black/20 border border-white/10 dark:border-white/5 rounded-lg placeholder-white/30 text-base focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-bold tracking-wide"
|
||||
className="w-full px-5 py-4 bg-white/10 dark:bg-black/20 border border-white/10 dark:border-white/5 rounded-lg text-gray-900 dark:text-gray-100 placeholder:text-gray-500 dark:placeholder:text-gray-400 text-base focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-bold tracking-wide"
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
@@ -190,7 +190,7 @@ export default function Login() {
|
||||
placeholder={field.placeholder}
|
||||
value={registerForm[field.name as keyof RegisterFormState]}
|
||||
onChange={(event) => setRegisterForm((current) => ({ ...current, [field.name]: event.target.value }))}
|
||||
className="w-full px-5 py-3.5 bg-white/10 dark:bg-black/20 border border-white/10 dark:border-white/5 rounded-lg placeholder-white/20 text-base focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-bold tracking-wide"
|
||||
className="w-full px-5 py-3.5 bg-white/10 dark:bg-black/20 border border-white/10 dark:border-white/5 rounded-lg text-gray-900 dark:text-gray-100 placeholder:text-gray-500 dark:placeholder:text-gray-400 text-base focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-bold tracking-wide"
|
||||
required
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,635 +1 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { Clock3, Copy, Download, ExternalLink, FolderDown, Link as LinkIcon, RefreshCw, Send, Upload, ChevronRight } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { formatBytes, formatDateTime } from '@/src/lib/format';
|
||||
import { getSession } from '@/src/lib/session';
|
||||
import {
|
||||
buildOfflineTransferDownloadUrl,
|
||||
createTransferSession,
|
||||
importOfflineTransferFile,
|
||||
joinTransferSession,
|
||||
listMyOfflineTransferSessions,
|
||||
lookupTransferSession,
|
||||
sanitizePickupCode,
|
||||
uploadOfflineTransferFile,
|
||||
type LookupTransferSessionResponse,
|
||||
type TransferFileItem,
|
||||
type TransferMode,
|
||||
type TransferSessionResponse,
|
||||
} from '@/src/lib/transfer';
|
||||
|
||||
type TransferTab = 'send' | 'receive' | 'history';
|
||||
|
||||
function getModeLabel(mode: string) {
|
||||
return mode === 'ONLINE' ? '在线快传' : mode === 'OFFLINE' ? '离线快传' : mode;
|
||||
}
|
||||
|
||||
function getTransferShareUrl(pickupCode: string) {
|
||||
const url = new URL('/transfer', window.location.origin);
|
||||
url.searchParams.set('code', pickupCode);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function findSessionFile(sourceFile: File, sessionFiles: TransferFileItem[]) {
|
||||
return sessionFiles.find(
|
||||
(item) =>
|
||||
item.name === sourceFile.name &&
|
||||
item.relativePath.replaceAll('\\', '/') === (('webkitRelativePath' in sourceFile && sourceFile.webkitRelativePath) || sourceFile.name).replaceAll('\\', '/') &&
|
||||
item.size === sourceFile.size,
|
||||
);
|
||||
}
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 10, opacity: 0 },
|
||||
show: { y: 0, opacity: 1 }
|
||||
};
|
||||
|
||||
export default function Transfer() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState<TransferTab>('send');
|
||||
const [sendMode, setSendMode] = useState<TransferMode>('OFFLINE');
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [sendLoading, setSendLoading] = useState(false);
|
||||
const [sendError, setSendError] = useState('');
|
||||
const [sendMessage, setSendMessage] = useState('');
|
||||
const [createdSession, setCreatedSession] = useState<TransferSessionResponse | null>(null);
|
||||
const [uploadedCount, setUploadedCount] = useState(0);
|
||||
const [receiveCode, setReceiveCode] = useState(() => sanitizePickupCode(searchParams.get('code') ?? ''));
|
||||
const [lookupLoading, setLookupLoading] = useState(false);
|
||||
const [receiveError, setReceiveError] = useState('');
|
||||
const [receiveMessage, setReceiveMessage] = useState('');
|
||||
const [lookupResult, setLookupResult] = useState<LookupTransferSessionResponse | null>(null);
|
||||
const [joinedSession, setJoinedSession] = useState<TransferSessionResponse | null>(null);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState('');
|
||||
const [historyMessage, setHistoryMessage] = useState('');
|
||||
const [historySessions, setHistorySessions] = useState<TransferSessionResponse[]>([]);
|
||||
|
||||
const loggedIn = Boolean(getSession());
|
||||
|
||||
useEffect(() => {
|
||||
const codeFromQuery = sanitizePickupCode(searchParams.get('code') ?? '');
|
||||
if (codeFromQuery && codeFromQuery !== receiveCode) {
|
||||
setReceiveCode(codeFromQuery);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'history' || !loggedIn) {
|
||||
return;
|
||||
}
|
||||
void loadHistory();
|
||||
}, [activeTab, loggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
const codeFromQuery = sanitizePickupCode(searchParams.get('code') ?? '');
|
||||
if (!codeFromQuery) {
|
||||
return;
|
||||
}
|
||||
setActiveTab('receive');
|
||||
void handleLookup(codeFromQuery);
|
||||
}, []);
|
||||
|
||||
const transferShareUrl = useMemo(
|
||||
() => (createdSession ? getTransferShareUrl(createdSession.pickupCode) : ''),
|
||||
[createdSession],
|
||||
);
|
||||
|
||||
async function loadHistory() {
|
||||
setHistoryLoading(true);
|
||||
setHistoryError('');
|
||||
try {
|
||||
setHistorySessions(await listMyOfflineTransferSessions());
|
||||
} catch (err) {
|
||||
setHistoryError(err instanceof Error ? err.message : '加载记录失败');
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateSession() {
|
||||
if (selectedFiles.length === 0) {
|
||||
setSendError('请先选择至少一个文件。');
|
||||
return;
|
||||
}
|
||||
|
||||
setSendLoading(true);
|
||||
setSendError('');
|
||||
setSendMessage('');
|
||||
setCreatedSession(null);
|
||||
setUploadedCount(0);
|
||||
|
||||
try {
|
||||
const session = await createTransferSession(selectedFiles, sendMode);
|
||||
setCreatedSession(session);
|
||||
|
||||
if (session.mode === 'ONLINE') {
|
||||
setSendMessage('在线快传会话已创建。目前暂未接入浏览器直连发送。');
|
||||
return;
|
||||
}
|
||||
|
||||
let completed = 0;
|
||||
for (const file of selectedFiles) {
|
||||
const matched = findSessionFile(file, session.files);
|
||||
if (!matched?.id) {
|
||||
throw new Error(`无法验证文件:${file.name}`);
|
||||
}
|
||||
await uploadOfflineTransferFile(session.sessionId, matched.id, file);
|
||||
completed += 1;
|
||||
setUploadedCount(completed);
|
||||
}
|
||||
|
||||
setSendMessage('同步完成。');
|
||||
} catch (err) {
|
||||
setSendError(err instanceof Error ? err.message : '创建失败');
|
||||
} finally {
|
||||
setSendLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLookup(code = receiveCode) {
|
||||
const normalized = sanitizePickupCode(code);
|
||||
if (normalized.length !== 6) {
|
||||
setReceiveError('请输入 6 位取件码。');
|
||||
return;
|
||||
}
|
||||
|
||||
setLookupLoading(true);
|
||||
setReceiveError('');
|
||||
setReceiveMessage('');
|
||||
setLookupResult(null);
|
||||
setJoinedSession(null);
|
||||
|
||||
try {
|
||||
const result = await lookupTransferSession(normalized);
|
||||
setReceiveCode(result.pickupCode);
|
||||
setLookupResult(result);
|
||||
setSearchParams({ code: result.pickupCode });
|
||||
} catch (err) {
|
||||
setReceiveError(err instanceof Error ? err.message : '查找失败');
|
||||
} finally {
|
||||
setLookupLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleJoinSession() {
|
||||
if (!lookupResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLookupLoading(true);
|
||||
setReceiveError('');
|
||||
setReceiveMessage('');
|
||||
try {
|
||||
const session = await joinTransferSession(lookupResult.sessionId);
|
||||
setJoinedSession(session);
|
||||
if (session.mode === 'ONLINE') {
|
||||
setReceiveMessage('在线会话已打开,等待发送方响应。');
|
||||
} else {
|
||||
setReceiveMessage('对象已就绪,可执行下载或导入。');
|
||||
}
|
||||
} catch (err) {
|
||||
setReceiveError(err instanceof Error ? err.message : '打开失败');
|
||||
} finally {
|
||||
setLookupLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport(sessionId: string, fileId: string) {
|
||||
const targetPath = window.prompt('导入到路径', '/') || '/';
|
||||
try {
|
||||
const saved = await importOfflineTransferFile(sessionId, fileId, targetPath);
|
||||
setReceiveMessage(`${saved.filename} -> ${saved.path}`);
|
||||
} catch (err) {
|
||||
setReceiveError(err instanceof Error ? err.message : '导入失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHistoryImport(sessionId: string, fileId: string) {
|
||||
const targetPath = window.prompt('导入到路径', '/') || '/';
|
||||
try {
|
||||
const saved = await importOfflineTransferFile(sessionId, fileId, targetPath);
|
||||
setHistoryMessage(`${saved.filename} -> ${saved.path}`);
|
||||
} catch (err) {
|
||||
setHistoryError(err instanceof Error ? err.message : '导入失败');
|
||||
}
|
||||
}
|
||||
|
||||
const uploadProgressText =
|
||||
createdSession?.mode === 'OFFLINE' && selectedFiles.length > 0
|
||||
? `${uploadedCount} / ${selectedFiles.length}`
|
||||
: '-';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
|
||||
>
|
||||
<div className="mb-10">
|
||||
<h1 className="text-4xl font-black tracking-tight animate-text-reveal">即时快传</h1>
|
||||
<p className="mt-3 text-sm font-black uppercase tracking-[0.2em] opacity-70">即时数据中转 / 安全取件通道</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-10 flex gap-2 p-1.5 rounded-lg glass-panel-no-hover w-fit shadow-2xl border border-white/10">
|
||||
{([
|
||||
['send', '发送'],
|
||||
['receive', '接收'],
|
||||
['history', '记录'],
|
||||
] as const).map(([tab, label]) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"rounded-md px-8 py-3 text-[10px] font-black uppercase tracking-widest transition-all duration-300",
|
||||
activeTab === tab
|
||||
? "bg-blue-600 text-white shadow-xl scale-[1.02]"
|
||||
: "opacity-40 hover:opacity-100 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'send' && (
|
||||
<motion.div
|
||||
key="send"
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -20, opacity: 0 }}
|
||||
className="grid gap-8 lg:grid-cols-[1.2fr_0.8fr]"
|
||||
>
|
||||
<section className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] opacity-70">传输配置</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-10 flex gap-4">
|
||||
{([
|
||||
['OFFLINE', '离线快传'],
|
||||
['ONLINE', '在线快传'],
|
||||
] as const).map(([mode, label]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setSendMode(mode)}
|
||||
className={cn(
|
||||
"rounded-lg px-6 py-2.5 text-[10px] font-black uppercase tracking-widest transition-all border",
|
||||
sendMode === mode
|
||||
? "bg-blue-600/10 border-blue-500/40 text-blue-500 shadow-inner"
|
||||
: "border-white/10 opacity-30 hover:opacity-100 hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="mb-10 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-white/10 bg-white/5 px-10 py-20 text-center transition-all hover:border-blue-500/40 hover:bg-blue-500/5 group border-white/10">
|
||||
<Upload className="mb-6 h-12 w-12 text-blue-500 opacity-40 group-hover:opacity-100 group-hover:scale-110 transition-all" />
|
||||
<div className="text-[11px] font-black uppercase tracking-[0.2em]">选择要发送的文件</div>
|
||||
<div className="mt-3 text-xs font-bold opacity-80 dark:opacity-90 uppercase tracking-widest">支持多文件,大小受系统限制</div>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
setSelectedFiles(Array.from(event.target.files ?? []));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="mb-10 rounded-lg bg-black/20 p-6 border border-white/10">
|
||||
<div className="mb-4 text-xs font-black uppercase tracking-[0.3em] opacity-70">已选择文件({selectedFiles.length})</div>
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto pr-2 custom-scrollbar">
|
||||
{selectedFiles.map((file) => (
|
||||
<div key={`${file.name}-${file.size}`} className="flex items-center justify-between gap-4 p-3 rounded bg-white/5 border border-white/5">
|
||||
<span className="truncate text-[11px] font-black uppercase tracking-tight">{file.name}</span>
|
||||
<span className="shrink-0 text-sm font-bold opacity-80 dark:opacity-90">{formatBytes(file.size)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sendError && <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-[10px] text-red-600 font-black uppercase tracking-widest backdrop-blur-md">{sendError}</div>}
|
||||
{sendMessage && <div className="mb-8 rounded-lg bg-green-500/10 border border-green-500/20 px-6 py-4 text-[10px] text-green-600 font-black uppercase tracking-widest backdrop-blur-md">{sendMessage}</div>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateSession()}
|
||||
disabled={sendLoading || selectedFiles.length === 0}
|
||||
className="w-full inline-flex items-center justify-center gap-4 rounded-lg bg-blue-600 px-8 py-5 text-[11px] font-black uppercase tracking-[0.3em] text-white shadow-2xl hover:bg-blue-500 hover:scale-[1.01] transition-all disabled:opacity-30 disabled:hover:scale-100"
|
||||
>
|
||||
{sendLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
{sendLoading ? '处理中...' : '创建会话'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<aside className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10 h-fit">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] opacity-70">会话信息</h2>
|
||||
</div>
|
||||
{createdSession ? (
|
||||
<div className="space-y-10">
|
||||
<div className="rounded-lg bg-blue-600/5 dark:bg-blue-600/10 border border-blue-500/20 p-10 text-center">
|
||||
<div className="text-[9px] font-black text-blue-500 uppercase tracking-[0.4em] mb-4">取件码</div>
|
||||
<div className="text-5xl font-black tracking-[0.3em] text-blue-500 ml-[0.3em] drop-shadow-xl">{createdSession.pickupCode}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 p-6 rounded-lg bg-white/5 border border-white/10 text-[10px] font-black uppercase tracking-widest">
|
||||
<div className="flex justify-between border-b border-white/5 pb-3">
|
||||
<span className="opacity-80 dark:opacity-90">模式</span>
|
||||
<span>{getModeLabel(createdSession.mode)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-white/5 pb-3">
|
||||
<span className="opacity-80 dark:opacity-90">过期</span>
|
||||
<span className="text-amber-500">{formatDateTime(createdSession.expiresAt).split(' ')[0]}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="opacity-80 dark:opacity-90">进度</span>
|
||||
<span className="text-blue-500">{uploadProgressText}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { navigator.clipboard.writeText(createdSession.pickupCode); window.alert('取件码已复制'); }}
|
||||
className="flex items-center justify-center gap-3 rounded-lg glass-panel border-white/10 p-4 text-[9px] font-black uppercase tracking-[0.2em] hover:bg-white/40 transition-all"
|
||||
>
|
||||
<Copy className="h-4 w-4" /> 复制取件码
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { navigator.clipboard.writeText(transferShareUrl); window.alert('链接已复制'); }}
|
||||
className="flex items-center justify-center gap-3 rounded-lg glass-panel border-white/10 p-4 text-[9px] font-black uppercase tracking-[0.2em] hover:bg-white/40 transition-all"
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" /> 复制链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-20 text-center">
|
||||
<div className="mb-6 inline-flex p-6 rounded-lg bg-white/5 opacity-10">
|
||||
<Copy className="h-10 w-10" />
|
||||
</div>
|
||||
<p className="text-sm font-black uppercase tracking-widest opacity-70">等待会话创建<br/>创建后可查看</p>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'receive' && (
|
||||
<motion.div
|
||||
key="receive"
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -20, opacity: 0 }}
|
||||
className="grid gap-8 lg:grid-cols-[0.8fr_1.2fr]"
|
||||
>
|
||||
<section className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10 h-fit">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] opacity-70">接收会话</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8">
|
||||
<input
|
||||
value={receiveCode}
|
||||
onChange={(event) => setReceiveCode(sanitizePickupCode(event.target.value))}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') void handleLookup(); }}
|
||||
placeholder="000000"
|
||||
className="w-full rounded-lg glass-panel bg-black/40 p-8 text-center text-5xl font-black tracking-[0.5em] outline-none border border-white/10 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10 placeholder:opacity-10 transition-all duration-500 text-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLookup()}
|
||||
disabled={lookupLoading}
|
||||
className="w-full rounded-lg bg-blue-600 p-5 text-[11px] font-black uppercase tracking-[0.3em] text-white shadow-2xl hover:bg-blue-500 transition-all disabled:opacity-30"
|
||||
>
|
||||
{lookupLoading ? <RefreshCw className="h-4 w-4 animate-spin inline mr-3" /> : null}
|
||||
查询取件码
|
||||
</button>
|
||||
|
||||
{receiveError && <div className="mt-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-[10px] text-red-600 font-black uppercase tracking-widest backdrop-blur-md">{receiveError}</div>}
|
||||
{receiveMessage && <div className="mt-8 rounded-lg bg-green-500/10 border border-green-500/20 px-6 py-4 text-[10px] text-green-600 font-black uppercase tracking-widest backdrop-blur-md">{receiveMessage}</div>}
|
||||
|
||||
{lookupResult && (
|
||||
<div className="mt-10 p-8 rounded-lg bg-blue-600/5 border border-blue-500/20">
|
||||
<div className="flex items-center gap-5 mb-6">
|
||||
<div className="p-4 rounded-lg bg-blue-600 text-white font-black text-2xl tracking-[0.2em]">
|
||||
{lookupResult.pickupCode}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black uppercase tracking-widest text-blue-500">已找到会话</div>
|
||||
<div className="text-xs font-bold opacity-80 dark:opacity-90 uppercase tracking-widest">{getModeLabel(lookupResult.mode)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleJoinSession()}
|
||||
disabled={lookupLoading}
|
||||
className="w-full rounded-lg glass-panel border-white/10 p-4 text-[10px] font-black uppercase tracking-widest hover:bg-blue-600 hover:text-white transition-all"
|
||||
>
|
||||
加入会话
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] opacity-70">文件列表</h2>
|
||||
</div>
|
||||
{joinedSession ? (
|
||||
joinedSession.mode === 'OFFLINE' ? (
|
||||
<div className="space-y-4">
|
||||
{joinedSession.files.map((file) => (
|
||||
<div key={`${file.id}-${file.size}`} className="p-6 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 transition-all group">
|
||||
<div className="flex flex-wrap items-center justify-between gap-6">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-lg font-black uppercase tracking-tight group-hover:text-blue-500 transition-colors">{file.name}</div>
|
||||
<div className="mt-1 truncate text-xs font-bold opacity-80 dark:opacity-90 uppercase tracking-widest">{file.relativePath}</div>
|
||||
<div className="mt-2 text-[10px] font-black text-blue-500 flex items-center gap-1">
|
||||
<span className="opacity-40 font-black">大小:</span>{formatBytes(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{file.id && (
|
||||
<a
|
||||
href={buildOfflineTransferDownloadUrl(joinedSession.sessionId, file.id)}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-5 py-3 text-[10px] font-black uppercase tracking-widest text-white hover:bg-blue-500 shadow-xl transition-all"
|
||||
>
|
||||
<Download className="h-4 w-4" /> 下载
|
||||
</a>
|
||||
)}
|
||||
{loggedIn && file.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleImport(joinedSession.sessionId, file.id!)}
|
||||
className="inline-flex items-center gap-2 rounded-lg glass-panel border-white/10 px-5 py-3 text-[10px] font-black uppercase tracking-widest hover:bg-white/40 transition-all"
|
||||
>
|
||||
<FolderDown className="h-4 w-4" /> 导入网盘
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-32 text-center rounded-lg bg-amber-500/5 border border-amber-500/10 px-10">
|
||||
<RefreshCw className="h-12 w-12 text-amber-500 mx-auto mb-6 opacity-30 animate-spin-slow" />
|
||||
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-amber-500">等待发送方在线</h3>
|
||||
<p className="mt-4 text-[10px] font-bold opacity-40 uppercase tracking-widest leading-relaxed">在线快传需要发送方保持在线</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="py-40 text-center">
|
||||
<div className="mb-8 inline-flex p-6 rounded-lg bg-white/5 opacity-10">
|
||||
<FolderDown className="h-10 w-10" />
|
||||
</div>
|
||||
<p className="text-sm font-black uppercase tracking-widest opacity-70">暂无会话<br/>输入取件码后查询</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<motion.div
|
||||
key="history"
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -20, opacity: 0 }}
|
||||
className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10"
|
||||
>
|
||||
<div className="mb-10 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] opacity-70">离线快传记录</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadHistory()}
|
||||
disabled={!loggedIn || historyLoading}
|
||||
className="flex items-center gap-3 rounded-lg glass-panel border-white/10 px-6 py-3 text-[10px] font-black uppercase tracking-widest hover:bg-white/40 transition-all border-white/10 disabled:opacity-20"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", historyLoading && "animate-spin")} />
|
||||
刷新记录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!loggedIn ? (
|
||||
<div className="py-32 text-center">
|
||||
<p className="text-sm font-black uppercase tracking-[0.3em] opacity-70">登录后可查看记录</p>
|
||||
</div>
|
||||
) : historyLoading && historySessions.length === 0 ? (
|
||||
<div className="py-32 text-center text-sm font-black uppercase tracking-widest opacity-70">加载中...</div>
|
||||
) : historySessions.length === 0 ? (
|
||||
<div className="py-32 text-center text-sm font-black uppercase tracking-widest opacity-70">暂无记录</div>
|
||||
) : (
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid gap-8"
|
||||
>
|
||||
{historyError && <div className="rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 font-bold">{historyError}</div>}
|
||||
{historyMessage && <div className="rounded-lg bg-green-500/10 border border-green-500/30 px-6 py-4 text-xs text-green-600 font-bold">{historyMessage}</div>}
|
||||
{historySessions.map((session) => (
|
||||
<motion.div key={session.sessionId} variants={itemVariants} className="p-8 rounded-lg bg-white/5 border border-white/10 hover:border-blue-500/30 transition-all group">
|
||||
<div className="mb-8 flex flex-wrap items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="p-5 rounded-lg bg-blue-600 text-white font-black text-3xl tracking-[0.3em] shadow-xl">
|
||||
{session.pickupCode}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-[10px] font-black text-amber-500 mb-2 uppercase tracking-widest">
|
||||
<Clock3 className="h-4 w-4" />
|
||||
过期时间:{formatDateTime(session.expiresAt).split(' ')[0]}
|
||||
</div>
|
||||
<div className="text-xs font-bold opacity-80 dark:opacity-90 uppercase tracking-[0.2em]">文件数:{session.files.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { navigator.clipboard.writeText(session.pickupCode); window.alert('取件码已复制'); }}
|
||||
className="p-3.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white transition-all border border-white/10 shadow-sm"
|
||||
title="复制取件码"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { navigator.clipboard.writeText(getTransferShareUrl(session.pickupCode)); window.alert('链接已复制'); }}
|
||||
className="p-3.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white transition-all border border-white/10 shadow-sm"
|
||||
title="复制链接"
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{session.files.map((file) => (
|
||||
<div key={`${file.id}-${file.size}`} className="p-5 rounded-lg bg-black/40 border border-white/5 group/file">
|
||||
<div className="truncate text-[11px] font-black uppercase tracking-tight mb-4 group-hover/file:text-blue-500 transition-colors">{file.name}</div>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-white/5 pt-3">
|
||||
<span className="text-xs font-bold opacity-80 dark:opacity-90 uppercase">{formatBytes(file.size)}</span>
|
||||
<div className="flex gap-2">
|
||||
{file.id && (
|
||||
<a
|
||||
href={buildOfflineTransferDownloadUrl(session.sessionId, file.id)}
|
||||
className="p-2 rounded-lg hover:bg-blue-600/20 text-blue-500 transition-colors"
|
||||
title="下载"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
{file.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleHistoryImport(session.sessionId, file.id!)}
|
||||
className="p-2 rounded-lg hover:bg-white/10 text-gray-700 dark:text-gray-100 opacity-80 hover:opacity-100 transition-all"
|
||||
title="导入网盘"
|
||||
>
|
||||
<FolderDown className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
export { default } from '@/src/transfer/pages/TransferPage';
|
||||
|
||||
@@ -3,12 +3,15 @@ import { ChevronRight, Copy, Download, FolderPlus, HardDrive, Move, RefreshCw, S
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { createMediaMetadataTask } from '@/src/lib/background-tasks';
|
||||
import { copyFile, createDirectory, deleteFile, getDownloadUrl, listFiles, moveFile, renameFile, searchFiles, type FileItem } from '@/src/lib/files';
|
||||
import { copyFile, createDirectory, deleteFile, getDownloadUrl, moveFile, renameFile, searchFiles, type FileItem } from '@/src/lib/files';
|
||||
import { formatBytes, formatDateTime } from '@/src/lib/format';
|
||||
import { buildSharePublicUrl, createShare } from '@/src/lib/shares-v2';
|
||||
import { uploadFileWithSession } from '@/src/lib/upload-session';
|
||||
import { uploadRuntime } from '@/src/lib/upload-runtime';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
|
||||
import { useDirectoryData } from '@/src/hooks/use-directory-data';
|
||||
import { filesCache } from '@/src/lib/files-cache';
|
||||
|
||||
function joinPath(basePath: string, name: string) {
|
||||
if (basePath === '/') {
|
||||
return `/${name}`;
|
||||
@@ -42,39 +45,63 @@ const itemVariants = {
|
||||
export default function FilesPage() {
|
||||
const navigate = useNavigate();
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 核心路径状态
|
||||
const [path, setPath] = useState('/');
|
||||
|
||||
// 搜索相关状态(与目录视图解耦)
|
||||
const [query, setQuery] = useState('');
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<FileItem[]>([]);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [searchError, setSearchError] = useState('');
|
||||
|
||||
// 目录数据 Hook
|
||||
const {
|
||||
items: directoryFiles,
|
||||
loading: directoryLoading,
|
||||
error: directoryError,
|
||||
refresh,
|
||||
isStale
|
||||
} = useDirectoryData(path);
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<FileItem | null>(null);
|
||||
const [directoryTree, setDirectoryTree] = useState<Record<string, FileItem[]>>({});
|
||||
|
||||
async function loadFiles(nextPath = path, nextQuery = query) {
|
||||
setError('');
|
||||
// 最终展示出的文件列表
|
||||
const displayFiles = isSearching ? searchResults : directoryFiles;
|
||||
const isLoading = isSearching ? searchLoading : directoryLoading;
|
||||
const error = isSearching ? searchError : directoryError;
|
||||
|
||||
// 当目录数据变化时,更新左侧树
|
||||
useEffect(() => {
|
||||
if (!isSearching && directoryFiles.length > 0) {
|
||||
setDirectoryTree((current) => ({
|
||||
...current,
|
||||
[path]: directoryFiles.filter((item) => item.directory),
|
||||
}));
|
||||
}
|
||||
}, [directoryFiles, path, isSearching]);
|
||||
|
||||
// 处理搜索
|
||||
async function performSearch(val: string) {
|
||||
if (!val.trim()) {
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
setIsSearching(true);
|
||||
setSearchLoading(true);
|
||||
setSearchError('');
|
||||
try {
|
||||
const result = nextQuery.trim()
|
||||
? await searchFiles(nextQuery.trim(), 0, 100)
|
||||
: await listFiles(nextPath, 0, 100);
|
||||
setFiles(result.items);
|
||||
if (!nextQuery.trim()) {
|
||||
setDirectoryTree((current) => ({
|
||||
...current,
|
||||
[nextPath]: result.items.filter((item) => item.directory),
|
||||
}));
|
||||
}
|
||||
setSelectedFile((current) => result.items.find((item) => item.id === current?.id) ?? null);
|
||||
const result = await searchFiles(val.trim(), 0, 100);
|
||||
setSearchResults(result.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载文件失败');
|
||||
setSearchError(err instanceof Error ? err.message : '搜索失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [path]);
|
||||
|
||||
const breadcrumbs = useMemo(() => splitPath(path), [path]);
|
||||
|
||||
function renderTreeNodes(basePath: string) {
|
||||
@@ -88,7 +115,11 @@ export default function FilesPage() {
|
||||
<div key={item.id} className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPath(nodePath)}
|
||||
onClick={() => {
|
||||
setIsSearching(false);
|
||||
setQuery('');
|
||||
setPath(nodePath);
|
||||
}}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-black uppercase tracking-wider transition-all",
|
||||
path === nodePath
|
||||
@@ -117,13 +148,18 @@ export default function FilesPage() {
|
||||
className="flex gap-6 h-full w-full p-8 overflow-hidden text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<aside className="hidden lg:flex w-72 flex-col flex-shrink-0 glass-panel-no-hover rounded-lg overflow-hidden shadow-2xl border-white/10">
|
||||
<div className="border-b border-white/10 px-6 py-6">
|
||||
<div className="border-b border-white/10 px-6 py-6 flex items-center justify-between">
|
||||
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-40">目录结构</h2>
|
||||
{isStale && <span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" title="后台刷新中..."></span>}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1.5 overflow-y-auto p-4 custom-scrollbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPath('/')}
|
||||
onClick={() => {
|
||||
setIsSearching(false);
|
||||
setQuery('');
|
||||
setPath('/');
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-black uppercase tracking-wider transition-all",
|
||||
path === '/' ? "bg-blue-600/10 text-blue-600 dark:text-blue-400 shadow-sm border border-blue-500/20" : "text-gray-700 dark:text-gray-200 hover:bg-white/30 dark:hover:bg-white/5"
|
||||
@@ -151,7 +187,14 @@ export default function FilesPage() {
|
||||
<div className="flex flex-col gap-6 px-8 py-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-6">
|
||||
<div className="flex flex-wrap items-center text-[11px] font-black uppercase tracking-widest">
|
||||
<button type="button" onClick={() => setPath('/')} className="opacity-40 hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsSearching(false);
|
||||
setQuery('');
|
||||
setPath('/');
|
||||
}} className="opacity-40 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
文件系统
|
||||
</button>
|
||||
{breadcrumbs.map((segment, index) => {
|
||||
@@ -159,7 +202,14 @@ export default function FilesPage() {
|
||||
return (
|
||||
<div key={target} className="flex items-center">
|
||||
<ChevronRight className="mx-2 h-3 w-3 opacity-20" />
|
||||
<button type="button" onClick={() => setPath(target)} className="opacity-40 hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsSearching(false);
|
||||
setQuery('');
|
||||
setPath(target);
|
||||
}} className="opacity-40 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
</div>
|
||||
@@ -170,11 +220,16 @@ export default function FilesPage() {
|
||||
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 opacity-70 group-focus-within:opacity-100 text-blue-500 transition-opacity" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onChange={(event) => {
|
||||
const val = event.target.value;
|
||||
setQuery(val);
|
||||
if (!val.trim()) {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
setLoading(true);
|
||||
void loadFiles(path, event.currentTarget.value);
|
||||
void performSearch(event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
placeholder="搜索文件..."
|
||||
@@ -191,13 +246,13 @@ export default function FilesPage() {
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await uploadFileWithSession(file, path);
|
||||
await loadFiles();
|
||||
await uploadRuntime.uploadFile(file, path);
|
||||
filesCache.invalidate(path);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '上传失败');
|
||||
setLoading(false);
|
||||
|
||||
console.error('上传失败', err);
|
||||
} finally {
|
||||
event.target.value = '';
|
||||
}
|
||||
@@ -216,13 +271,12 @@ export default function FilesPage() {
|
||||
onClick={async () => {
|
||||
const name = window.prompt('请输入文件夹名称');
|
||||
if (!name) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await createDirectory(joinPath(path, name));
|
||||
await loadFiles();
|
||||
filesCache.invalidate(path);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '创建失败');
|
||||
setLoading(false);
|
||||
console.error('创建失败', err);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-lg glass-panel border-white/10 px-6 py-2.5 text-sm font-black uppercase tracking-widest text-gray-700 dark:text-gray-200 hover:bg-white/40 transition-all"
|
||||
@@ -233,12 +287,11 @@ export default function FilesPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
void loadFiles();
|
||||
refresh();
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-lg glass-panel border-white/10 px-6 py-2.5 text-sm font-black uppercase tracking-widest text-gray-700 dark:text-gray-200 hover:bg-white/40 transition-all border-white/10"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
@@ -248,7 +301,7 @@ export default function FilesPage() {
|
||||
<div className="flex min-h-0 flex-1 relative z-10">
|
||||
<div className="min-w-0 flex-1 overflow-y-auto p-8 custom-scrollbar">
|
||||
{error ? <div className="mb-6 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 dark:text-red-400 font-bold backdrop-blur-md">{error}</div> : null}
|
||||
{loading ? (
|
||||
{isLoading && !displayFiles.length ? (
|
||||
<div className="rounded-lg glass-panel border-white/10 px-4 py-24 text-center text-[10px] font-black uppercase tracking-[0.3em] opacity-40">加载中...</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg glass-panel border-white/10 shadow-2xl relative shadow-blue-500/5">
|
||||
@@ -267,7 +320,7 @@ export default function FilesPage() {
|
||||
animate="show"
|
||||
className="divide-y divide-white/10 dark:divide-white/5"
|
||||
>
|
||||
{files.map((file) => (
|
||||
{displayFiles.map((file) => (
|
||||
<motion.tr
|
||||
key={file.id}
|
||||
variants={itemVariants}
|
||||
@@ -288,10 +341,10 @@ export default function FilesPage() {
|
||||
<td className="px-8 py-5 text-sm font-bold opacity-80 dark:opacity-90 tracking-tighter uppercase">{formatDateTime(file.createdAt)}</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
{files.length === 0 ? (
|
||||
{displayFiles.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-8 py-24 text-center text-sm font-black uppercase tracking-widest opacity-70">
|
||||
{query.trim() ? '没有匹配文件' : '当前目录为空'}
|
||||
{isSearching ? '没有匹配文件' : '当前目录为空'}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
@@ -359,7 +412,11 @@ export default function FilesPage() {
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const nextName = window.prompt('请输入新名称', selectedFile.filename);
|
||||
if (nextName) { await renameFile(selectedFile.id, nextName); await loadFiles(); }
|
||||
if (nextName) {
|
||||
await renameFile(selectedFile.id, nextName);
|
||||
filesCache.invalidate(path);
|
||||
refresh();
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center gap-2 rounded-lg glass-panel border-white/10 p-4 text-xs font-black uppercase tracking-widest hover:bg-white/40 transition-all"
|
||||
>
|
||||
@@ -369,7 +426,12 @@ export default function FilesPage() {
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const targetPath = window.prompt('请输入目标路径', selectedFile.path);
|
||||
if (targetPath) { await moveFile(selectedFile.id, targetPath); await loadFiles(); }
|
||||
if (targetPath) {
|
||||
await moveFile(selectedFile.id, targetPath);
|
||||
filesCache.invalidate(path);
|
||||
filesCache.invalidate(targetPath);
|
||||
refresh();
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center gap-2 rounded-lg glass-panel border-white/10 p-4 text-xs font-black uppercase tracking-widest hover:bg-white/40 transition-all"
|
||||
>
|
||||
@@ -382,7 +444,8 @@ export default function FilesPage() {
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`确认删除 ${selectedFile.filename} 吗?`)) return;
|
||||
await deleteFile(selectedFile.id);
|
||||
await loadFiles();
|
||||
filesCache.invalidate(path);
|
||||
refresh();
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-lg glass-panel border-white/10 px-4 py-4 text-sm font-black uppercase tracking-[0.2em] text-red-500 hover:bg-red-500 hover:text-white transition-all border-red-500/20"
|
||||
>
|
||||
|
||||
1
front/src/sharing/pages/FileSharePage.tsx
Normal file
1
front/src/sharing/pages/FileSharePage.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@/src/pages/FileShare';
|
||||
1
front/src/sharing/pages/SharesPage.tsx
Normal file
1
front/src/sharing/pages/SharesPage.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@/src/pages/Shares';
|
||||
113
front/src/transfer/api/transfer.ts
Normal file
113
front/src/transfer/api/transfer.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { fetchApi, getApiBaseUrl } from '../../lib/api';
|
||||
|
||||
export type TransferMode = 'ONLINE' | 'OFFLINE';
|
||||
|
||||
export type TransferFilePayload = {
|
||||
name: string;
|
||||
relativePath: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
export type TransferFileItem = TransferFilePayload & {
|
||||
id?: string | null;
|
||||
uploaded?: boolean | null;
|
||||
};
|
||||
|
||||
export type TransferSessionResponse = {
|
||||
sessionId: string;
|
||||
pickupCode: string;
|
||||
mode: TransferMode;
|
||||
expiresAt: string;
|
||||
files: TransferFileItem[];
|
||||
};
|
||||
|
||||
export type LookupTransferSessionResponse = {
|
||||
sessionId: string;
|
||||
pickupCode: string;
|
||||
mode: TransferMode;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export function sanitizePickupCode(value: string) {
|
||||
return value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6);
|
||||
}
|
||||
|
||||
export function getTransferFileRelativePath(file: File) {
|
||||
const rawRelativePath =
|
||||
'webkitRelativePath' in file && typeof file.webkitRelativePath === 'string' && file.webkitRelativePath
|
||||
? file.webkitRelativePath
|
||||
: file.name;
|
||||
|
||||
const normalizedPath = rawRelativePath
|
||||
.replaceAll('\\', '/')
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
|
||||
return normalizedPath || file.name;
|
||||
}
|
||||
|
||||
export function toTransferFilePayload(files: File[]) {
|
||||
return files.map<TransferFilePayload>((file) => ({
|
||||
name: file.name,
|
||||
relativePath: getTransferFileRelativePath(file),
|
||||
size: file.size,
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
}));
|
||||
}
|
||||
|
||||
export function createTransferSession(files: File[], mode: TransferMode) {
|
||||
return fetchApi<TransferSessionResponse>('/transfer/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
mode,
|
||||
files: toTransferFilePayload(files),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function lookupTransferSession(pickupCode: string) {
|
||||
return fetchApi<LookupTransferSessionResponse>(
|
||||
`/transfer/sessions/lookup?pickupCode=${encodeURIComponent(sanitizePickupCode(pickupCode))}`,
|
||||
{
|
||||
auth: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function joinTransferSession(sessionId: string) {
|
||||
return fetchApi<TransferSessionResponse>(`/transfer/sessions/${encodeURIComponent(sessionId)}/join`, {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function listMyOfflineTransferSessions() {
|
||||
return fetchApi<TransferSessionResponse[]>('/transfer/sessions/offline/mine');
|
||||
}
|
||||
|
||||
export function uploadOfflineTransferFile(sessionId: string, fileId: string, file: File) {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
|
||||
return fetchApi<void>(`/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/content`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildOfflineTransferDownloadUrl(sessionId: string, fileId: string) {
|
||||
return `${getApiBaseUrl()}/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/download`;
|
||||
}
|
||||
|
||||
export function importOfflineTransferFile(sessionId: string, fileId: string, path: string) {
|
||||
return fetchApi<{ id: number; filename: string; path: string }>(
|
||||
`/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/import`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path }),
|
||||
},
|
||||
);
|
||||
}
|
||||
635
front/src/transfer/pages/TransferPage.tsx
Normal file
635
front/src/transfer/pages/TransferPage.tsx
Normal file
@@ -0,0 +1,635 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { Clock3, Copy, Download, ExternalLink, FolderDown, Link as LinkIcon, RefreshCw, Send, Upload, ChevronRight } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { formatBytes, formatDateTime } from '@/src/lib/format';
|
||||
import { getSession } from '@/src/lib/session';
|
||||
import {
|
||||
buildOfflineTransferDownloadUrl,
|
||||
createTransferSession,
|
||||
importOfflineTransferFile,
|
||||
joinTransferSession,
|
||||
listMyOfflineTransferSessions,
|
||||
lookupTransferSession,
|
||||
sanitizePickupCode,
|
||||
uploadOfflineTransferFile,
|
||||
type LookupTransferSessionResponse,
|
||||
type TransferFileItem,
|
||||
type TransferMode,
|
||||
type TransferSessionResponse,
|
||||
} from '@/src/transfer/api/transfer';
|
||||
|
||||
type TransferTab = 'send' | 'receive' | 'history';
|
||||
|
||||
function getModeLabel(mode: string) {
|
||||
return mode === 'ONLINE' ? '在线快传' : mode === 'OFFLINE' ? '离线快传' : mode;
|
||||
}
|
||||
|
||||
function getTransferShareUrl(pickupCode: string) {
|
||||
const url = new URL('/transfer', window.location.origin);
|
||||
url.searchParams.set('code', pickupCode);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function findSessionFile(sourceFile: File, sessionFiles: TransferFileItem[]) {
|
||||
return sessionFiles.find(
|
||||
(item) =>
|
||||
item.name === sourceFile.name &&
|
||||
item.relativePath.replaceAll('\\', '/') === (('webkitRelativePath' in sourceFile && sourceFile.webkitRelativePath) || sourceFile.name).replaceAll('\\', '/') &&
|
||||
item.size === sourceFile.size,
|
||||
);
|
||||
}
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 10, opacity: 0 },
|
||||
show: { y: 0, opacity: 1 }
|
||||
};
|
||||
|
||||
export default function Transfer() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState<TransferTab>('send');
|
||||
const [sendMode, setSendMode] = useState<TransferMode>('OFFLINE');
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [sendLoading, setSendLoading] = useState(false);
|
||||
const [sendError, setSendError] = useState('');
|
||||
const [sendMessage, setSendMessage] = useState('');
|
||||
const [createdSession, setCreatedSession] = useState<TransferSessionResponse | null>(null);
|
||||
const [uploadedCount, setUploadedCount] = useState(0);
|
||||
const [receiveCode, setReceiveCode] = useState(() => sanitizePickupCode(searchParams.get('code') ?? ''));
|
||||
const [lookupLoading, setLookupLoading] = useState(false);
|
||||
const [receiveError, setReceiveError] = useState('');
|
||||
const [receiveMessage, setReceiveMessage] = useState('');
|
||||
const [lookupResult, setLookupResult] = useState<LookupTransferSessionResponse | null>(null);
|
||||
const [joinedSession, setJoinedSession] = useState<TransferSessionResponse | null>(null);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState('');
|
||||
const [historyMessage, setHistoryMessage] = useState('');
|
||||
const [historySessions, setHistorySessions] = useState<TransferSessionResponse[]>([]);
|
||||
|
||||
const loggedIn = Boolean(getSession());
|
||||
|
||||
useEffect(() => {
|
||||
const codeFromQuery = sanitizePickupCode(searchParams.get('code') ?? '');
|
||||
if (codeFromQuery && codeFromQuery !== receiveCode) {
|
||||
setReceiveCode(codeFromQuery);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'history' || !loggedIn) {
|
||||
return;
|
||||
}
|
||||
void loadHistory();
|
||||
}, [activeTab, loggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
const codeFromQuery = sanitizePickupCode(searchParams.get('code') ?? '');
|
||||
if (!codeFromQuery) {
|
||||
return;
|
||||
}
|
||||
setActiveTab('receive');
|
||||
void handleLookup(codeFromQuery);
|
||||
}, []);
|
||||
|
||||
const transferShareUrl = useMemo(
|
||||
() => (createdSession ? getTransferShareUrl(createdSession.pickupCode) : ''),
|
||||
[createdSession],
|
||||
);
|
||||
|
||||
async function loadHistory() {
|
||||
setHistoryLoading(true);
|
||||
setHistoryError('');
|
||||
try {
|
||||
setHistorySessions(await listMyOfflineTransferSessions());
|
||||
} catch (err) {
|
||||
setHistoryError(err instanceof Error ? err.message : '加载记录失败');
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateSession() {
|
||||
if (selectedFiles.length === 0) {
|
||||
setSendError('请先选择至少一个文件。');
|
||||
return;
|
||||
}
|
||||
|
||||
setSendLoading(true);
|
||||
setSendError('');
|
||||
setSendMessage('');
|
||||
setCreatedSession(null);
|
||||
setUploadedCount(0);
|
||||
|
||||
try {
|
||||
const session = await createTransferSession(selectedFiles, sendMode);
|
||||
setCreatedSession(session);
|
||||
|
||||
if (session.mode === 'ONLINE') {
|
||||
setSendMessage('在线快传会话已创建。目前暂未接入浏览器直连发送。');
|
||||
return;
|
||||
}
|
||||
|
||||
let completed = 0;
|
||||
for (const file of selectedFiles) {
|
||||
const matched = findSessionFile(file, session.files);
|
||||
if (!matched?.id) {
|
||||
throw new Error(`无法验证文件:${file.name}`);
|
||||
}
|
||||
await uploadOfflineTransferFile(session.sessionId, matched.id, file);
|
||||
completed += 1;
|
||||
setUploadedCount(completed);
|
||||
}
|
||||
|
||||
setSendMessage('同步完成。');
|
||||
} catch (err) {
|
||||
setSendError(err instanceof Error ? err.message : '创建失败');
|
||||
} finally {
|
||||
setSendLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLookup(code = receiveCode) {
|
||||
const normalized = sanitizePickupCode(code);
|
||||
if (normalized.length !== 6) {
|
||||
setReceiveError('请输入 6 位取件码。');
|
||||
return;
|
||||
}
|
||||
|
||||
setLookupLoading(true);
|
||||
setReceiveError('');
|
||||
setReceiveMessage('');
|
||||
setLookupResult(null);
|
||||
setJoinedSession(null);
|
||||
|
||||
try {
|
||||
const result = await lookupTransferSession(normalized);
|
||||
setReceiveCode(result.pickupCode);
|
||||
setLookupResult(result);
|
||||
setSearchParams({ code: result.pickupCode });
|
||||
} catch (err) {
|
||||
setReceiveError(err instanceof Error ? err.message : '查找失败');
|
||||
} finally {
|
||||
setLookupLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleJoinSession() {
|
||||
if (!lookupResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLookupLoading(true);
|
||||
setReceiveError('');
|
||||
setReceiveMessage('');
|
||||
try {
|
||||
const session = await joinTransferSession(lookupResult.sessionId);
|
||||
setJoinedSession(session);
|
||||
if (session.mode === 'ONLINE') {
|
||||
setReceiveMessage('在线会话已打开,等待发送方响应。');
|
||||
} else {
|
||||
setReceiveMessage('对象已就绪,可执行下载或导入。');
|
||||
}
|
||||
} catch (err) {
|
||||
setReceiveError(err instanceof Error ? err.message : '打开失败');
|
||||
} finally {
|
||||
setLookupLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport(sessionId: string, fileId: string) {
|
||||
const targetPath = window.prompt('导入到路径', '/') || '/';
|
||||
try {
|
||||
const saved = await importOfflineTransferFile(sessionId, fileId, targetPath);
|
||||
setReceiveMessage(`${saved.filename} -> ${saved.path}`);
|
||||
} catch (err) {
|
||||
setReceiveError(err instanceof Error ? err.message : '导入失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHistoryImport(sessionId: string, fileId: string) {
|
||||
const targetPath = window.prompt('导入到路径', '/') || '/';
|
||||
try {
|
||||
const saved = await importOfflineTransferFile(sessionId, fileId, targetPath);
|
||||
setHistoryMessage(`${saved.filename} -> ${saved.path}`);
|
||||
} catch (err) {
|
||||
setHistoryError(err instanceof Error ? err.message : '导入失败');
|
||||
}
|
||||
}
|
||||
|
||||
const uploadProgressText =
|
||||
createdSession?.mode === 'OFFLINE' && selectedFiles.length > 0
|
||||
? `${uploadedCount} / ${selectedFiles.length}`
|
||||
: '-';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
|
||||
>
|
||||
<div className="mb-10">
|
||||
<h1 className="text-4xl font-black tracking-tight animate-text-reveal">即时快传</h1>
|
||||
<p className="mt-3 text-sm font-black uppercase tracking-[0.2em] opacity-70">即时数据中转 / 安全取件通道</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-10 flex gap-2 p-1.5 rounded-lg glass-panel-no-hover w-fit shadow-2xl border border-white/10">
|
||||
{([
|
||||
['send', '发送'],
|
||||
['receive', '接收'],
|
||||
['history', '记录'],
|
||||
] as const).map(([tab, label]) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"rounded-md px-8 py-3 text-[10px] font-black uppercase tracking-widest transition-all duration-300",
|
||||
activeTab === tab
|
||||
? "bg-blue-600 text-white shadow-xl scale-[1.02]"
|
||||
: "opacity-40 hover:opacity-100 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'send' && (
|
||||
<motion.div
|
||||
key="send"
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -20, opacity: 0 }}
|
||||
className="grid gap-8 lg:grid-cols-[1.2fr_0.8fr]"
|
||||
>
|
||||
<section className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] opacity-70">传输配置</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-10 flex gap-4">
|
||||
{([
|
||||
['OFFLINE', '离线快传'],
|
||||
['ONLINE', '在线快传'],
|
||||
] as const).map(([mode, label]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setSendMode(mode)}
|
||||
className={cn(
|
||||
"rounded-lg px-6 py-2.5 text-[10px] font-black uppercase tracking-widest transition-all border",
|
||||
sendMode === mode
|
||||
? "bg-blue-600/10 border-blue-500/40 text-blue-500 shadow-inner"
|
||||
: "border-white/10 opacity-30 hover:opacity-100 hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="mb-10 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-white/10 bg-white/5 px-10 py-20 text-center transition-all hover:border-blue-500/40 hover:bg-blue-500/5 group border-white/10">
|
||||
<Upload className="mb-6 h-12 w-12 text-blue-500 opacity-40 group-hover:opacity-100 group-hover:scale-110 transition-all" />
|
||||
<div className="text-[11px] font-black uppercase tracking-[0.2em]">选择要发送的文件</div>
|
||||
<div className="mt-3 text-xs font-bold opacity-80 dark:opacity-90 uppercase tracking-widest">支持多文件,大小受系统限制</div>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
setSelectedFiles(Array.from(event.target.files ?? []));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="mb-10 rounded-lg bg-black/20 p-6 border border-white/10">
|
||||
<div className="mb-4 text-xs font-black uppercase tracking-[0.3em] opacity-70">已选择文件({selectedFiles.length})</div>
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto pr-2 custom-scrollbar">
|
||||
{selectedFiles.map((file) => (
|
||||
<div key={`${file.name}-${file.size}`} className="flex items-center justify-between gap-4 p-3 rounded bg-white/5 border border-white/5">
|
||||
<span className="truncate text-[11px] font-black uppercase tracking-tight">{file.name}</span>
|
||||
<span className="shrink-0 text-sm font-bold opacity-80 dark:opacity-90">{formatBytes(file.size)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sendError && <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-[10px] text-red-600 font-black uppercase tracking-widest backdrop-blur-md">{sendError}</div>}
|
||||
{sendMessage && <div className="mb-8 rounded-lg bg-green-500/10 border border-green-500/20 px-6 py-4 text-[10px] text-green-600 font-black uppercase tracking-widest backdrop-blur-md">{sendMessage}</div>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateSession()}
|
||||
disabled={sendLoading || selectedFiles.length === 0}
|
||||
className="w-full inline-flex items-center justify-center gap-4 rounded-lg bg-blue-600 px-8 py-5 text-[11px] font-black uppercase tracking-[0.3em] text-white shadow-2xl hover:bg-blue-500 hover:scale-[1.01] transition-all disabled:opacity-30 disabled:hover:scale-100"
|
||||
>
|
||||
{sendLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
{sendLoading ? '处理中...' : '创建会话'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<aside className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10 h-fit">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] opacity-70">会话信息</h2>
|
||||
</div>
|
||||
{createdSession ? (
|
||||
<div className="space-y-10">
|
||||
<div className="rounded-lg bg-blue-600/5 dark:bg-blue-600/10 border border-blue-500/20 p-10 text-center">
|
||||
<div className="text-[9px] font-black text-blue-500 uppercase tracking-[0.4em] mb-4">取件码</div>
|
||||
<div className="text-5xl font-black tracking-[0.3em] text-blue-500 ml-[0.3em] drop-shadow-xl">{createdSession.pickupCode}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 p-6 rounded-lg bg-white/5 border border-white/10 text-[10px] font-black uppercase tracking-widest">
|
||||
<div className="flex justify-between border-b border-white/5 pb-3">
|
||||
<span className="opacity-80 dark:opacity-90">模式</span>
|
||||
<span>{getModeLabel(createdSession.mode)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-white/5 pb-3">
|
||||
<span className="opacity-80 dark:opacity-90">过期</span>
|
||||
<span className="text-amber-500">{formatDateTime(createdSession.expiresAt).split(' ')[0]}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="opacity-80 dark:opacity-90">进度</span>
|
||||
<span className="text-blue-500">{uploadProgressText}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { navigator.clipboard.writeText(createdSession.pickupCode); window.alert('取件码已复制'); }}
|
||||
className="flex items-center justify-center gap-3 rounded-lg glass-panel border-white/10 p-4 text-[9px] font-black uppercase tracking-[0.2em] hover:bg-white/40 transition-all"
|
||||
>
|
||||
<Copy className="h-4 w-4" /> 复制取件码
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { navigator.clipboard.writeText(transferShareUrl); window.alert('链接已复制'); }}
|
||||
className="flex items-center justify-center gap-3 rounded-lg glass-panel border-white/10 p-4 text-[9px] font-black uppercase tracking-[0.2em] hover:bg-white/40 transition-all"
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" /> 复制链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-20 text-center">
|
||||
<div className="mb-6 inline-flex p-6 rounded-lg bg-white/5 opacity-10">
|
||||
<Copy className="h-10 w-10" />
|
||||
</div>
|
||||
<p className="text-sm font-black uppercase tracking-widest opacity-70">等待会话创建<br/>创建后可查看</p>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'receive' && (
|
||||
<motion.div
|
||||
key="receive"
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -20, opacity: 0 }}
|
||||
className="grid gap-8 lg:grid-cols-[0.8fr_1.2fr]"
|
||||
>
|
||||
<section className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10 h-fit">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] opacity-70">接收会话</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8">
|
||||
<input
|
||||
value={receiveCode}
|
||||
onChange={(event) => setReceiveCode(sanitizePickupCode(event.target.value))}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') void handleLookup(); }}
|
||||
placeholder="000000"
|
||||
className="w-full rounded-lg glass-panel bg-black/40 p-8 text-center text-5xl font-black tracking-[0.5em] outline-none border border-white/10 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10 placeholder:opacity-10 transition-all duration-500 text-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLookup()}
|
||||
disabled={lookupLoading}
|
||||
className="w-full rounded-lg bg-blue-600 p-5 text-[11px] font-black uppercase tracking-[0.3em] text-white shadow-2xl hover:bg-blue-500 transition-all disabled:opacity-30"
|
||||
>
|
||||
{lookupLoading ? <RefreshCw className="h-4 w-4 animate-spin inline mr-3" /> : null}
|
||||
查询取件码
|
||||
</button>
|
||||
|
||||
{receiveError && <div className="mt-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-[10px] text-red-600 font-black uppercase tracking-widest backdrop-blur-md">{receiveError}</div>}
|
||||
{receiveMessage && <div className="mt-8 rounded-lg bg-green-500/10 border border-green-500/20 px-6 py-4 text-[10px] text-green-600 font-black uppercase tracking-widest backdrop-blur-md">{receiveMessage}</div>}
|
||||
|
||||
{lookupResult && (
|
||||
<div className="mt-10 p-8 rounded-lg bg-blue-600/5 border border-blue-500/20">
|
||||
<div className="flex items-center gap-5 mb-6">
|
||||
<div className="p-4 rounded-lg bg-blue-600 text-white font-black text-2xl tracking-[0.2em]">
|
||||
{lookupResult.pickupCode}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black uppercase tracking-widest text-blue-500">已找到会话</div>
|
||||
<div className="text-xs font-bold opacity-80 dark:opacity-90 uppercase tracking-widest">{getModeLabel(lookupResult.mode)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleJoinSession()}
|
||||
disabled={lookupLoading}
|
||||
className="w-full rounded-lg glass-panel border-white/10 p-4 text-[10px] font-black uppercase tracking-widest hover:bg-blue-600 hover:text-white transition-all"
|
||||
>
|
||||
加入会话
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] opacity-70">文件列表</h2>
|
||||
</div>
|
||||
{joinedSession ? (
|
||||
joinedSession.mode === 'OFFLINE' ? (
|
||||
<div className="space-y-4">
|
||||
{joinedSession.files.map((file) => (
|
||||
<div key={`${file.id}-${file.size}`} className="p-6 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 transition-all group">
|
||||
<div className="flex flex-wrap items-center justify-between gap-6">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-lg font-black uppercase tracking-tight group-hover:text-blue-500 transition-colors">{file.name}</div>
|
||||
<div className="mt-1 truncate text-xs font-bold opacity-80 dark:opacity-90 uppercase tracking-widest">{file.relativePath}</div>
|
||||
<div className="mt-2 text-[10px] font-black text-blue-500 flex items-center gap-1">
|
||||
<span className="opacity-40 font-black">大小:</span>{formatBytes(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{file.id && (
|
||||
<a
|
||||
href={buildOfflineTransferDownloadUrl(joinedSession.sessionId, file.id)}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-5 py-3 text-[10px] font-black uppercase tracking-widest text-white hover:bg-blue-500 shadow-xl transition-all"
|
||||
>
|
||||
<Download className="h-4 w-4" /> 下载
|
||||
</a>
|
||||
)}
|
||||
{loggedIn && file.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleImport(joinedSession.sessionId, file.id!)}
|
||||
className="inline-flex items-center gap-2 rounded-lg glass-panel border-white/10 px-5 py-3 text-[10px] font-black uppercase tracking-widest hover:bg-white/40 transition-all"
|
||||
>
|
||||
<FolderDown className="h-4 w-4" /> 导入网盘
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-32 text-center rounded-lg bg-amber-500/5 border border-amber-500/10 px-10">
|
||||
<RefreshCw className="h-12 w-12 text-amber-500 mx-auto mb-6 opacity-30 animate-spin-slow" />
|
||||
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-amber-500">等待发送方在线</h3>
|
||||
<p className="mt-4 text-[10px] font-bold opacity-40 uppercase tracking-widest leading-relaxed">在线快传需要发送方保持在线</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="py-40 text-center">
|
||||
<div className="mb-8 inline-flex p-6 rounded-lg bg-white/5 opacity-10">
|
||||
<FolderDown className="h-10 w-10" />
|
||||
</div>
|
||||
<p className="text-sm font-black uppercase tracking-widest opacity-70">暂无会话<br/>输入取件码后查询</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<motion.div
|
||||
key="history"
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -20, opacity: 0 }}
|
||||
className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10"
|
||||
>
|
||||
<div className="mb-10 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] opacity-70">离线快传记录</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadHistory()}
|
||||
disabled={!loggedIn || historyLoading}
|
||||
className="flex items-center gap-3 rounded-lg glass-panel border-white/10 px-6 py-3 text-[10px] font-black uppercase tracking-widest hover:bg-white/40 transition-all border-white/10 disabled:opacity-20"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", historyLoading && "animate-spin")} />
|
||||
刷新记录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!loggedIn ? (
|
||||
<div className="py-32 text-center">
|
||||
<p className="text-sm font-black uppercase tracking-[0.3em] opacity-70">登录后可查看记录</p>
|
||||
</div>
|
||||
) : historyLoading && historySessions.length === 0 ? (
|
||||
<div className="py-32 text-center text-sm font-black uppercase tracking-widest opacity-70">加载中...</div>
|
||||
) : historySessions.length === 0 ? (
|
||||
<div className="py-32 text-center text-sm font-black uppercase tracking-widest opacity-70">暂无记录</div>
|
||||
) : (
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid gap-8"
|
||||
>
|
||||
{historyError && <div className="rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 font-bold">{historyError}</div>}
|
||||
{historyMessage && <div className="rounded-lg bg-green-500/10 border border-green-500/30 px-6 py-4 text-xs text-green-600 font-bold">{historyMessage}</div>}
|
||||
{historySessions.map((session) => (
|
||||
<motion.div key={session.sessionId} variants={itemVariants} className="p-8 rounded-lg bg-white/5 border border-white/10 hover:border-blue-500/30 transition-all group">
|
||||
<div className="mb-8 flex flex-wrap items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="p-5 rounded-lg bg-blue-600 text-white font-black text-3xl tracking-[0.3em] shadow-xl">
|
||||
{session.pickupCode}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-[10px] font-black text-amber-500 mb-2 uppercase tracking-widest">
|
||||
<Clock3 className="h-4 w-4" />
|
||||
过期时间:{formatDateTime(session.expiresAt).split(' ')[0]}
|
||||
</div>
|
||||
<div className="text-xs font-bold opacity-80 dark:opacity-90 uppercase tracking-[0.2em]">文件数:{session.files.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { navigator.clipboard.writeText(session.pickupCode); window.alert('取件码已复制'); }}
|
||||
className="p-3.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white transition-all border border-white/10 shadow-sm"
|
||||
title="复制取件码"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { navigator.clipboard.writeText(getTransferShareUrl(session.pickupCode)); window.alert('链接已复制'); }}
|
||||
className="p-3.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white transition-all border border-white/10 shadow-sm"
|
||||
title="复制链接"
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{session.files.map((file) => (
|
||||
<div key={`${file.id}-${file.size}`} className="p-5 rounded-lg bg-black/40 border border-white/5 group/file">
|
||||
<div className="truncate text-[11px] font-black uppercase tracking-tight mb-4 group-hover/file:text-blue-500 transition-colors">{file.name}</div>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-white/5 pt-3">
|
||||
<span className="text-xs font-bold opacity-80 dark:opacity-90 uppercase">{formatBytes(file.size)}</span>
|
||||
<div className="flex gap-2">
|
||||
{file.id && (
|
||||
<a
|
||||
href={buildOfflineTransferDownloadUrl(session.sessionId, file.id)}
|
||||
className="p-2 rounded-lg hover:bg-blue-600/20 text-blue-500 transition-colors"
|
||||
title="下载"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
{file.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleHistoryImport(session.sessionId, file.id!)}
|
||||
className="p-2 rounded-lg hover:bg-white/10 text-gray-700 dark:text-gray-100 opacity-80 hover:opacity-100 transition-all"
|
||||
title="导入网盘"
|
||||
>
|
||||
<FolderDown className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
1
front/src/workspace/pages/FilesPage.tsx
Normal file
1
front/src/workspace/pages/FilesPage.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@/src/pages/files/FilesPage';
|
||||
1
front/src/workspace/pages/OverviewPage.tsx
Normal file
1
front/src/workspace/pages/OverviewPage.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@/src/pages/Overview';
|
||||
1
front/src/workspace/pages/RecycleBinPage.tsx
Normal file
1
front/src/workspace/pages/RecycleBinPage.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@/src/pages/RecycleBin';
|
||||
@@ -18,6 +18,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
Reference in New Issue
Block a user