Refactor backend and frontend modules for architecture alignment

This commit is contained in:
yoyuzh
2026-04-12 00:32:21 +08:00
parent f59515f5dd
commit 30a9bbc1e7
253 changed files with 25462 additions and 4786 deletions

View File

@@ -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>

View File

@@ -0,0 +1 @@
export { default } from '@/src/pages/Login';

View 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>
);
}

View File

@@ -0,0 +1 @@
export default function AdminFileBlobs() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin FileBlobs ()</h1></div>; }

View File

@@ -0,0 +1 @@
export default function AdminFilesystem() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin Filesystem ()</h1></div>; }

View File

@@ -0,0 +1 @@
export default function AdminOAuthApps() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin OAuthApps ()</h1></div>; }

View File

@@ -0,0 +1 @@
export default function AdminSettings() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin Settings ()</h1></div>; }

View File

@@ -0,0 +1 @@
export default function AdminShares() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin Shares ()</h1></div>; }

View File

@@ -0,0 +1 @@
export default function AdminTasks() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin Tasks ()</h1></div>; }

View File

@@ -0,0 +1 @@
export { default } from '@/src/pages/Tasks';

View File

@@ -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 = {

View File

@@ -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>
);
}

View 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)} />;
}

View 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>
);
}

View 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>
);
}

View 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,
};
}

View 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;
}

View File

@@ -1,4 +1,5 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-aurora-1: #c4d9ff;

View File

@@ -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);

View 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();

View File

@@ -15,6 +15,8 @@ export type FileItem = {
contentType: string;
directory: boolean;
createdAt: string;
thumbnailKey?: string | null;
publicMetaJson?: string | null;
};
export type RecycleBinItem = {

View 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();

View 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();

View 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();

View File

@@ -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';

View 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();

View File

@@ -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>

View File

@@ -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>

View File

@@ -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';

View File

@@ -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"
>

View File

@@ -0,0 +1 @@
export { default } from '@/src/pages/FileShare';

View File

@@ -0,0 +1 @@
export { default } from '@/src/pages/Shares';

View 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 }),
},
);
}

View 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>
);
}

View File

@@ -0,0 +1 @@
export { default } from '@/src/pages/files/FilesPage';

View File

@@ -0,0 +1 @@
export { default } from '@/src/pages/Overview';

View File

@@ -0,0 +1 @@
export { default } from '@/src/pages/RecycleBin';

View File

@@ -18,6 +18,7 @@ export default defineConfig(({ mode }) => {
},
},
server: {
allowedHosts: true,
hmr: process.env.DISABLE_HMR !== 'true',
proxy: {
'/api': {