Add offline transfer history and mobile app support

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

View File

@@ -0,0 +1,25 @@
- generic [ref=e3]:
- generic [ref=e6]:
- generic [ref=e8]: "Y"
- heading "优立云盘" [level=1] [ref=e9]
- paragraph [ref=e10]: 集中管理网盘文件,跨设备快传,随时体验轻游戏。
- generic [ref=e13]:
- generic [ref=e14]:
- heading "登录" [level=3] [ref=e15]:
- img [ref=e16]
- text: 登录
- paragraph [ref=e19]: 登入您的账号以继续
- generic [ref=e21]:
- generic [ref=e22]:
- generic [ref=e23]:
- img [ref=e24]
- textbox "账号 / 用户名 / 学号" [ref=e27]
- generic [ref=e28]:
- img [ref=e29]
- textbox "••••••••" [ref=e32]
- generic [ref=e33]:
- button "进入系统" [ref=e34]
- button "直接进入快传" [ref=e35]:
- img [ref=e36]
- text: 直接进入快传
- button "还没有账号?立即注册" [ref=e40]

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -37,6 +37,8 @@
关键入口:
- `front/src/App.tsx`
- `front/src/MobileApp.tsx`
- `front/src/main.tsx`
- `front/src/lib/api.ts`
- `front/src/components/layout/Layout.tsx`
@@ -48,6 +50,7 @@
- `front/src/pages/Transfer.tsx`
- `front/src/pages/TransferReceive.tsx`
- `front/src/pages/FileShare.tsx`
- `front/src/mobile-pages/*`
### 2.2 后端
@@ -191,6 +194,11 @@
## 4. 关键业务流程
补充说明:
- 前端主入口会在 `main.tsx` 按屏幕宽度选择桌面壳或移动壳
- 当前规则为:宽度小于 `768px` 时渲染 `MobileApp`,否则渲染桌面 `App`
### 4.1 登录流程
1. 前端登录页调用 `/api/auth/login`

83
front/src/MobileApp.tsx Normal file
View File

@@ -0,0 +1,83 @@
import React, { Suspense } from 'react';
import { BrowserRouter, HashRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/src/auth/AuthProvider';
import { FILE_SHARE_ROUTE_PREFIX } from '@/src/lib/file-share';
import { getTransferRouterMode, LEGACY_PUBLIC_TRANSFER_ROUTE, PUBLIC_TRANSFER_ROUTE } from '@/src/lib/transfer-links';
import { MobileLayout } from './mobile-components/MobileLayout';
import MobileLogin from './mobile-pages/MobileLogin';
import MobileOverview from './mobile-pages/MobileOverview';
import MobileFiles from './mobile-pages/MobileFiles';
import MobileTransfer from './mobile-pages/MobileTransfer';
import MobileFileShare from './mobile-pages/MobileFileShare';
function LegacyTransferRedirect() {
const location = useLocation();
return <Navigate to={`${PUBLIC_TRANSFER_ROUTE}${location.search}`} replace />;
}
function MobileAppRoutes() {
const { ready, session } = useAuth();
const location = useLocation();
const isPublicTransferRoute = location.pathname === PUBLIC_TRANSFER_ROUTE || location.pathname === LEGACY_PUBLIC_TRANSFER_ROUTE;
const isPublicFileShareRoute = location.pathname.startsWith(`${FILE_SHARE_ROUTE_PREFIX}/`);
if (!ready && !isPublicTransferRoute && !isPublicFileShareRoute) {
return (
<div className="min-h-[100dvh] flex items-center justify-center bg-[#07101D] text-slate-300 flex-col gap-4">
<span className="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin" />
<span className="text-sm">...</span>
</div>
);
}
const isAuthenticated = Boolean(session?.token);
return (
<Routes>
<Route
path={PUBLIC_TRANSFER_ROUTE}
element={isAuthenticated ? <MobileLayout><MobileTransfer /></MobileLayout> : <MobileTransfer />}
/>
<Route path={`${FILE_SHARE_ROUTE_PREFIX}/:token`} element={<MobileFileShare />} />
<Route path={LEGACY_PUBLIC_TRANSFER_ROUTE} element={<LegacyTransferRedirect />} />
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/overview" replace /> : <MobileLogin />}
/>
<Route
path="/"
element={isAuthenticated ? <MobileLayout /> : <Navigate to="/login" replace />}
>
<Route index element={<Navigate to="/overview" replace />} />
<Route path="overview" element={<MobileOverview />} />
<Route path="files" element={<MobileFiles />} />
<Route path="games" element={<Navigate to="/overview" replace />} />
</Route>
<Route path="/games/:gameId" element={<Navigate to={isAuthenticated ? '/overview' : '/login'} replace />} />
{/* Admin dashboard is not mobile-optimized in this phase yet, redirect to overview or login */}
<Route
path="/admin/*"
element={isAuthenticated ? <Navigate to="/overview" replace /> : <Navigate to="/login" replace />}
/>
<Route
path="*"
element={<Navigate to={isAuthenticated ? '/overview' : '/login'} replace />}
/>
</Routes>
);
}
export default function MobileApp() {
const Router = getTransferRouterMode() === 'hash' ? HashRouter : BrowserRouter;
return (
<Router>
<MobileAppRoutes />
</Router>
);
}

View File

@@ -0,0 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MOBILE_APP_MAX_WIDTH, shouldUseMobileApp } from './app-shell';
test('shouldUseMobileApp enables the mobile shell below the width breakpoint', () => {
assert.equal(shouldUseMobileApp(MOBILE_APP_MAX_WIDTH - 1), true);
assert.equal(shouldUseMobileApp(MOBILE_APP_MAX_WIDTH), false);
assert.equal(shouldUseMobileApp(1280), false);
});

View File

@@ -0,0 +1,5 @@
export const MOBILE_APP_MAX_WIDTH = 768;
export function shouldUseMobileApp(width: number) {
return width < MOBILE_APP_MAX_WIDTH;
}

View File

@@ -1,13 +1,30 @@
import {StrictMode} from 'react';
import {StrictMode, useEffect, useState} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import MobileApp from './MobileApp.tsx';
import {AuthProvider} from './auth/AuthProvider.tsx';
import {shouldUseMobileApp} from './lib/app-shell.ts';
import './index.css';
function ResponsiveApp() {
const [isMobileApp, setIsMobileApp] = useState(() => shouldUseMobileApp(window.innerWidth));
useEffect(() => {
function syncAppShell() {
setIsMobileApp(shouldUseMobileApp(window.innerWidth));
}
window.addEventListener('resize', syncAppShell);
return () => window.removeEventListener('resize', syncAppShell);
}, []);
return isMobileApp ? <MobileApp /> : <App />;
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>
<App />
<ResponsiveApp />
</AuthProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getVisibleNavItems } from './MobileLayout';
test('mobile navigation hides the games entry', () => {
const visiblePaths = getVisibleNavItems(false).map((item) => item.path as string);
assert.equal(visiblePaths.includes('/games'), false);
assert.deepEqual(visiblePaths, ['/overview', '/files', '/transfer']);
});

View File

@@ -0,0 +1,424 @@
import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import {
FolderOpen,
Key,
LayoutDashboard,
LogOut,
Mail,
Send,
Settings,
Shield,
Smartphone,
X,
Menu,
} from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { useAuth } from '@/src/auth/AuthProvider';
import { apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api';
import { createSession, readStoredSession, saveStoredSession } from '@/src/lib/session';
import type { AuthResponse, InitiateUploadResponse, UserProfile } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
import { Button } from '@/src/components/ui/button';
import { Input } from '@/src/components/ui/input';
import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from '@/src/components/layout/account-utils';
import { UploadProgressPanel } from '@/src/components/layout/UploadProgressPanel';
const NAV_ITEMS = [
{ name: '总览', path: '/overview', icon: LayoutDashboard },
{ name: '网盘', path: '/files', icon: FolderOpen },
{ name: '快传', path: '/transfer', icon: Send },
] as const;
type ActiveModal = 'security' | 'settings' | null;
export function getVisibleNavItems(isAdmin: boolean) {
// 底部导航栏容量有限,后台页面可通过顶部头像菜单或者折叠菜单进入
return NAV_ITEMS;
}
interface LayoutProps {
children?: ReactNode;
}
export function MobileLayout({ children }: LayoutProps = {}) {
const navigate = useNavigate();
const { isAdmin, logout, refreshProfile, user } = useAuth();
const navItems = getVisibleNavItems(isAdmin);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null);
const [selectedAvatarFile, setSelectedAvatarFile] = useState<File | null>(null);
const [avatarSourceUrl, setAvatarSourceUrl] = useState<string | null>(user?.avatarUrl ?? null);
const [profileDraft, setProfileDraft] = useState(() =>
buildAccountDraft(
user ?? {
id: 0,
username: '',
email: '',
createdAt: '',
},
),
);
// States related to modales and profile editing (Same as original)
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [profileMessage, setProfileMessage] = useState('');
const [passwordMessage, setPasswordMessage] = useState('');
const [profileError, setProfileError] = useState('');
const [passwordError, setPasswordError] = useState('');
const [profileSubmitting, setProfileSubmitting] = useState(false);
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
useEffect(() => {
if (!user) return;
setProfileDraft(buildAccountDraft(user));
}, [user]);
useEffect(() => {
if (!avatarPreviewUrl) return undefined;
return () => URL.revokeObjectURL(avatarPreviewUrl);
}, [avatarPreviewUrl]);
useEffect(() => {
let active = true;
let objectUrl: string | null = null;
async function syncAvatar() {
if (!user?.avatarUrl) {
if (active) setAvatarSourceUrl(null);
return;
}
if (!shouldLoadAvatarWithAuth(user.avatarUrl)) {
if (active) setAvatarSourceUrl(user.avatarUrl);
return;
}
try {
const response = await apiDownload(user.avatarUrl);
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
if (active) setAvatarSourceUrl(objectUrl);
} catch {
if (active) setAvatarSourceUrl(null);
}
}
void syncAvatar();
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [user?.avatarUrl]);
const displayName = useMemo(() => user?.displayName || user?.username || '账户', [user]);
const email = user?.email || '暂无邮箱';
const phoneNumber = user?.phoneNumber || '未设置手机号';
const roleLabel = getRoleLabel(user?.role);
const avatarFallback = (displayName || 'Y').charAt(0).toUpperCase();
const displayedAvatarUrl = avatarPreviewUrl || avatarSourceUrl;
const handleLogout = () => {
logout();
navigate('/login');
};
const handleAvatarClick = () => fileInputRef.current?.click();
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setSelectedAvatarFile(file);
setAvatarPreviewUrl((current) => {
if (current) URL.revokeObjectURL(current);
return URL.createObjectURL(file);
});
};
const handleProfileDraftChange = (field: keyof typeof profileDraft, value: string) => {
setProfileDraft((current) => ({ ...current, [field]: value }));
};
const closeModal = () => {
setActiveModal(null);
setProfileMessage(''); setProfileError('');
setPasswordMessage(''); setPasswordError('');
};
const persistSessionUser = (nextProfile: UserProfile) => {
const currentSession = readStoredSession();
if (!currentSession) return;
saveStoredSession({ ...currentSession, user: nextProfile });
};
const uploadAvatar = async (file: File) => {
const initiated = await apiRequest<InitiateUploadResponse>('/user/avatar/upload/initiate', {
method: 'POST',
body: { filename: file.name, contentType: file.type || 'image/png', size: file.size },
});
if (initiated.direct) {
try {
await apiBinaryUploadRequest(initiated.uploadUrl, { method: initiated.method, headers: initiated.headers, body: file });
} catch {
const formData = new FormData();
formData.append('file', file);
await apiUploadRequest<void>(`/user/avatar/upload?storageName=${encodeURIComponent(initiated.storageName)}`, { body: formData });
}
} else {
const formData = new FormData();
formData.append('file', file);
await apiUploadRequest<void>(initiated.uploadUrl, { body: formData, method: initiated.method === 'PUT' ? 'PUT' : 'POST', headers: initiated.headers });
}
const nextProfile = await apiRequest<UserProfile>('/user/avatar/upload/complete', {
method: 'POST',
body: { filename: file.name, contentType: file.type || 'image/png', size: file.size, storageName: initiated.storageName },
});
persistSessionUser(nextProfile);
return nextProfile;
};
const handleSaveProfile = async () => {
setProfileSubmitting(true); setProfileMessage(''); setProfileError('');
try {
if (selectedAvatarFile) await uploadAvatar(selectedAvatarFile);
const nextProfile = await apiRequest<UserProfile>('/user/profile', {
method: 'PUT',
body: {
displayName: profileDraft.displayName.trim(), email: profileDraft.email.trim(),
phoneNumber: profileDraft.phoneNumber.trim(), bio: profileDraft.bio,
preferredLanguage: profileDraft.preferredLanguage,
},
});
persistSessionUser(nextProfile);
await refreshProfile();
setSelectedAvatarFile(null);
setAvatarPreviewUrl((current) => { if (current) URL.revokeObjectURL(current); return null; });
setProfileMessage('资料已保存');
} catch (error) {
setProfileError(error instanceof Error ? error.message : '保存失败');
} finally {
setProfileSubmitting(false);
}
};
const handleChangePassword = async () => {
setPasswordMessage(''); setPasswordError('');
if (newPassword !== confirmPassword) { setPasswordError('密码不一致'); return; }
setPasswordSubmitting(true);
try {
const auth = await apiRequest<AuthResponse>('/user/password', {
method: 'POST', body: { currentPassword, newPassword },
});
const currentSession = readStoredSession();
if (currentSession) {
saveStoredSession({ ...currentSession, ...createSession(auth), user: auth.user });
}
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('');
setPasswordMessage('密码已更新');
} catch (error) {
setPasswordError(error instanceof Error ? error.message : '修改失败');
} finally {
setPasswordSubmitting(false);
}
};
return (
<div className="flex flex-col h-[100dvh] w-full bg-[#07101D] text-white relative overflow-hidden">
{/* Background Animated Blobs */}
<div className="fixed inset-0 z-0 pointer-events-none">
<div className="absolute top-0 left-[-20%] w-[60%] h-[40%] rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[100px] animate-pulse" />
<div className="absolute bottom-[-10%] right-[-10%] w-[70%] h-[50%] rounded-full bg-purple-600 opacity-20 mix-blend-screen blur-[100px]" />
</div>
{/* Top App Bar */}
<header className="fixed top-0 left-0 right-0 z-40 w-full glass-panel border-b border-white/5 bg-[#07101D]/70 backdrop-blur-2xl">
<div className="flex items-center justify-between px-4 h-14">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-lg">
<span className="text-white font-bold text-sm leading-none">Y</span>
</div>
<span className="text-white font-bold text-sm tracking-wider"></span>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setIsDropdownOpen(true)}
className="w-8 h-8 rounded-full bg-slate-800 border border-white/10 flex items-center justify-center overflow-hidden"
>
{displayedAvatarUrl ? (
<img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span className="text-xs font-semibold">{avatarFallback}</span>
)}
</button>
</div>
</div>
</header>
<AnimatePresence>
{isDropdownOpen && (
<>
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
onClick={() => setIsDropdownOpen(false)}
/>
<motion.div
initial={{ y: '100%' }} animate={{ y: 0 }} exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed bottom-0 left-0 right-0 z-50 glass-panel bg-[#0f172a]/95 rounded-t-3xl border-t border-white/10 pt-2 pb-8 px-4"
>
<div className="w-12 h-1 bg-white/20 rounded-full mx-auto mb-6" />
<div className="flex items-center gap-4 mb-6 p-4 rounded-2xl bg-white/5 border border-white/10">
<div className="w-14 h-14 rounded-full overflow-hidden bg-slate-800">
{displayedAvatarUrl ? <img src={displayedAvatarUrl} className="w-full h-full object-cover" /> : <div className="w-full h-full flex items-center justify-center text-xl">{avatarFallback}</div>}
</div>
<div>
<div className="font-semibold text-lg">{displayName}</div>
<div className="text-sm text-slate-400">{email}</div>
<div className="text-xs px-2 py-0.5 mt-1 rounded bg-[#336EFF]/20 text-[#336EFF] inline-block">{roleLabel}</div>
</div>
</div>
<div className="space-y-2">
<button onClick={() => { setIsDropdownOpen(false); setActiveModal('settings'); }} className="w-full flex items-center gap-3 p-4 rounded-xl hover:bg-white/5 active:bg-white/10 transition-colors">
<Settings className="w-5 h-5 text-slate-300" /> <span></span>
</button>
<button onClick={() => { setIsDropdownOpen(false); setActiveModal('security'); }} className="w-full flex items-center gap-3 p-4 rounded-xl hover:bg-white/5 active:bg-white/10 transition-colors">
<Shield className="w-5 h-5 text-slate-300" /> <span></span>
</button>
{isAdmin && (
<button onClick={() => { setIsDropdownOpen(false); navigate('/admin'); }} className="w-full flex items-center gap-3 p-4 rounded-xl hover:bg-white/5 active:bg-white/10 transition-colors">
<Shield className="w-5 h-5 text-purple-400" /> <span className="text-purple-400"></span>
</button>
)}
<button onClick={handleLogout} className="w-full flex items-center gap-3 p-4 rounded-xl hover:bg-red-500/10 active:bg-red-500/20 text-red-400 transition-colors mt-2">
<LogOut className="w-5 h-5" /> <span>退</span>
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
{/* Main Content Area */}
<main className="flex-1 w-full overflow-y-auto overflow-x-hidden pt-14 pb-16 z-10">
{children ?? <Outlet />}
</main>
{/* Upload Panel (Floating above bottom bar) */}
<div className="fixed bottom-20 right-4 left-4 z-40 pointer-events-none">
<div className="pointer-events-auto">
<UploadProgressPanel />
</div>
</div>
{/* Bottom Navigation Bar */}
<nav className="fixed bottom-0 left-0 right-0 z-40 glass-panel border-t border-white/5 bg-[#0f172a]/90 backdrop-blur-2xl safe-area-pb">
<div className="flex items-center justify-around h-16 px-2">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
cn(
'flex flex-col items-center justify-center w-16 h-full gap-1 transition-colors',
isActive ? 'text-[#336EFF]' : 'text-slate-400 hover:text-slate-200'
)
}
>
{({ isActive }) => (
<>
<item.icon className={cn('w-6 h-6', isActive && 'fill-current opacity-20')} />
<span className="text-[10px] font-medium">{item.name}</span>
</>
)}
</NavLink>
))}
</div>
</nav>
{/* Support Modals (Settings & Security) */}
<AnimatePresence>
{activeModal === 'security' && (
<div className="fixed inset-0 z-[100] flex flex-col bg-[#07101D]">
<div className="glass-panel border-b border-white/10 h-14 flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-2 text-white"><Shield className="w-5 h-5 text-emerald-400"/> </div>
<button onClick={closeModal} className="p-2"><X className="w-5 h-5"/></button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Similar to original but vertically stacked without hover constraints */}
<div className="p-4 rounded-xl glass-panel space-y-4">
<div className="flex items-center gap-3">
<Key className="w-5 h-5 text-blue-400" /> <span className="font-medium text-white"></span>
</div>
<Input type="password" placeholder="当前密码" value={currentPassword} onChange={e=>setCurrentPassword(e.target.value)} className="bg-black/20" />
<Input type="password" placeholder="新密码" value={newPassword} onChange={e=>setNewPassword(e.target.value)} className="bg-black/20" />
<Input type="password" placeholder="确认新密码" value={confirmPassword} onChange={e=>setConfirmPassword(e.target.value)} className="bg-black/20" />
<Button className="w-full" onClick={()=>void handleChangePassword()} disabled={passwordSubmitting}>{passwordSubmitting?'处理中':'更新密码'}</Button>
{passwordError && <p className="text-sm text-red-400">{passwordError}</p>}
{passwordMessage && <p className="text-sm text-emerald-400">{passwordMessage}</p>}
</div>
<div className="p-4 rounded-xl glass-panel flex flex-col gap-2">
<div className="text-white text-sm font-medium"></div>
<div className="text-xs text-slate-400">{phoneNumber}</div>
</div>
</div>
</div>
)}
{activeModal === 'settings' && (
<div className="fixed inset-0 z-[100] flex flex-col bg-[#07101D]">
<div className="glass-panel border-b border-white/10 h-14 flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-2 text-white"><Settings className="w-5 h-5 text-[#336EFF]"/> </div>
<button onClick={closeModal} className="p-2"><X className="w-5 h-5"/></button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
<div className="flex flex-col items-center gap-4 py-4 glass-panel rounded-2xl">
<div onClick={handleAvatarClick} className="w-24 h-24 rounded-full overflow-hidden bg-slate-800 relative">
{displayedAvatarUrl ? <img src={displayedAvatarUrl} className="w-full h-full object-cover"/> : <div className="w-full h-full flex items-center justify-center text-3xl">{avatarFallback}</div>}
<div className="absolute inset-0 bg-black/40 flex items-center justify-center"><span className="text-xs text-white"></span></div>
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" />
</div>
<div className="text-center">
<div className="font-semibold text-white">{displayName}</div>
<div className="text-sm text-slate-400">{roleLabel}</div>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-xs text-slate-400 pl-1"></label>
<Input value={profileDraft.displayName} onChange={e=>handleProfileDraftChange('displayName', e.target.value)} className="bg-black/20 mt-1" />
</div>
<div>
<label className="text-xs text-slate-400 pl-1"></label>
<Input type="email" value={profileDraft.email} onChange={e=>handleProfileDraftChange('email', e.target.value)} className="bg-black/20 mt-1" />
</div>
<div>
<label className="text-xs text-slate-400 pl-1"></label>
<Input type="tel" value={profileDraft.phoneNumber} onChange={e=>handleProfileDraftChange('phoneNumber', e.target.value)} className="bg-black/20 mt-1" />
</div>
<div>
<label className="text-xs text-slate-400 pl-1"></label>
<textarea value={profileDraft.bio} onChange={e=>handleProfileDraftChange('bio', e.target.value)} className="w-full min-h-[80px] rounded-md bg-black/20 border-white/10 text-white p-3 text-sm resize-none mt-1" />
</div>
</div>
{profileError && <p className="text-sm text-red-400">{profileError}</p>}
{profileMessage && <p className="text-sm text-emerald-400">{profileMessage}</p>}
<Button className="w-full py-6" onClick={()=>void handleSaveProfile()} disabled={profileSubmitting}>{profileSubmitting?'保存中':'保存修改'}</Button>
<div className="h-8" />
</div>
</div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, ExternalLink } from 'lucide-react';
import { Button } from '@/src/components/ui/button';
import { GAME_EXIT_PATH, isGameId, resolveGameHref } from '@/src/pages/games-links';
export default function GamePlayerMobile() {
const navigate = useNavigate();
const { gameId } = useParams<{ gameId: string }>();
if (!gameId || !isGameId(gameId)) {
return <Navigate to={GAME_EXIT_PATH} replace />;
}
const gameHref = resolveGameHref(gameId);
return (
<div className="flex flex-col h-[100dvh] bg-black">
{/* 沉浸式顶部返回栏 */}
<div className="flex items-center justify-between px-4 py-3 bg-gradient-to-b from-black/80 to-transparent fixed top-0 left-0 right-0 z-20">
<Button
type="button"
onClick={() => navigate(GAME_EXIT_PATH)}
className="bg-black/40 hover:bg-black/60 text-white rounded-full p-2 h-10 w-10 backdrop-blur-md border border-white/10"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div className="text-white font-bold tracking-widest uppercase text-sm drop-shadow-md">
{gameId}
</div>
<Button
type="button"
onClick={() => window.open(gameHref, '_blank', 'noopener,noreferrer')}
className="bg-black/40 hover:bg-black/60 text-white rounded-full p-2 h-10 w-10 backdrop-blur-md border border-white/10"
>
<ExternalLink className="w-5 h-5" />
</Button>
</div>
{/* 沉浸式全屏播放器 */}
<div className="flex-1 w-full h-full relative z-10 pt-16">
<iframe
title={`${gameId} game`}
src={gameHref}
className="w-full h-full border-0 rounded-t-3xl shadow-[0_0_40px_rgba(51,110,255,0.15)] bg-black"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
import React, { useEffect, useState } from 'react';
import { CheckCircle2, DownloadCloud, Link2, Loader2, LogIn, Save, X } from 'lucide-react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useAuth } from '@/src/auth/AuthProvider';
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
import { Button } from '@/src/components/ui/button';
import { getFileShareDetails, importSharedFile } from '@/src/lib/file-share';
import { normalizeNetdiskTargetPath } from '@/src/lib/netdisk-upload';
import type { FileMetadata, FileShareDetailsResponse } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
import { resolveStoredFileType } from '@/src/lib/file-type';
function formatFileSize(size: number) {
if (size <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const unitIndex = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
const value = size / 1024 ** unitIndex;
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
export default function MobileFileShare() {
const { token } = useParams();
const location = useLocation();
const navigate = useNavigate();
const { session } = useAuth();
const [details, setDetails] = useState<FileShareDetailsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [path, setPath] = useState('/下载');
const [importing, setImporting] = useState(false);
const [importedFile, setImportedFile] = useState<FileMetadata | null>(null);
const [pathPickerOpen, setPathPickerOpen] = useState(false);
useEffect(() => {
if (!token) {
setLoading(false); setError('分享链接无效'); return;
}
let active = true;
setLoading(true); setError(''); setImportedFile(null);
void getFileShareDetails(token)
.then((res) => { if (active) setDetails(res); })
.catch((err) => { if (active) setError(err instanceof Error ? err.message : '无法读取分享详情'); })
.finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, [token]);
async function handleImportToPath(nextPath: string) {
setPath(normalizeNetdiskTargetPath(nextPath));
await handleImportAtPath(nextPath);
}
async function handleImportAtPath(nextPath: string) {
if (!token) return;
setImporting(true); setError('');
try {
const normalizedPath = normalizeNetdiskTargetPath(nextPath);
const savedFile = await importSharedFile(token, normalizedPath);
setPath(normalizedPath);
setImportedFile(savedFile);
} catch (err) {
setError(err instanceof Error ? err.message : '导入失败');
throw err;
} finally {
setImporting(false);
}
}
return (
<div className="flex flex-col min-h-[100dvh] bg-[#07101D] text-white">
{/* 顶部插画背景 */}
<div className="relative pt-12 pb-8 px-6 bg-gradient-to-b from-blue-900/30 to-[#07101D] flex flex-col items-center">
<div className="absolute top-0 right-[-10%] w-[80%] h-[150%] bg-[#336EFF] rounded-full mix-blend-screen filter blur-[80px] opacity-20" />
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#336EFF] to-cyan-400 flex items-center justify-center shadow-[0_10px_40px_rgba(51,110,255,0.3)] mb-4">
<Link2 className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold tracking-tight z-10 text-center"></h1>
<p className="text-xs text-slate-400 text-center mt-2 max-w-[240px] z-10">
</p>
</div>
<div className="flex-1 px-4 pb-12 flex flex-col items-center relative z-10">
<div className="w-full max-w-sm">
{loading ? (
<div className="glass-panel p-10 rounded-3xl flex flex-col items-center gap-4 text-slate-400">
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
<span className="text-sm">...</span>
</div>
) : error ? (
<div className="glass-panel p-6 rounded-3xl bg-rose-500/5 border border-rose-500/20 flex flex-col items-center gap-3">
<div className="w-12 h-12 rounded-full bg-rose-500/10 flex items-center justify-center">
<X className="w-6 h-6 text-rose-400" />
</div>
<p className="text-sm text-rose-300 font-medium">{error}</p>
</div>
) : details ? (
<div className="space-y-4">
<div className="glass-panel p-5 rounded-3xl flex flex-col items-center shadow-lg border border-white/5 bg-white/[0.02]">
<div className="p-4 rounded-full bg-black/20 border border-white/5 mb-3 shadow-inner">
<FileTypeIcon type={resolveStoredFileType({ filename: details.filename, contentType: details.contentType, directory: false }).kind} size="lg" />
</div>
<h2 className="text-lg font-bold text-white text-center break-all w-full leading-tight">{details.filename}</h2>
<div className="flex items-center gap-2 mt-3 text-xs text-slate-400">
<span className="bg-white/5 px-2 py-0.5 rounded-full">{details.ownerUsername} </span>
<span>{formatFileSize(details.size)}</span>
</div>
<div className="text-[10px] text-slate-500 mt-2">
{new Date(details.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
{!session?.token ? (
<div className="glass-panel p-5 rounded-3xl bg-amber-500/5 border border-amber-400/20 text-center">
<p className="text-xs text-amber-200/90 mb-4"></p>
<Button className="w-full h-12 rounded-xl bg-gradient-to-r from-amber-500 to-orange-400 hover:from-amber-600 hover:to-orange-500 text-white shadow-lg"
onClick={() => navigate(`/login?next=${encodeURIComponent(location.pathname + location.search)}`)}>
<LogIn className="mr-2 w-4 h-4" />
</Button>
</div>
) : (
<div className="glass-panel p-5 rounded-3xl border border-white/5 bg-white/[0.02]">
<div className="text-center mb-4">
<p className="text-xs text-slate-400"><span className="text-emerald-400 font-medium">{path}</span></p>
</div>
{importedFile ? (
<div className="flex flex-col items-center gap-4 pt-2">
<div className="w-12 h-12 rounded-full bg-emerald-500/20 flex items-center justify-center">
<CheckCircle2 className="w-6 h-6 text-emerald-400" />
</div>
<p className="text-sm font-medium text-emerald-300"></p>
<Button className="w-full h-12 rounded-xl bg-white/10 hover:bg-white/15 text-white mt-2" onClick={() => navigate('/files')}>
</Button>
</div>
) : (
<Button className="w-full h-12 rounded-xl bg-[#336EFF] hover:bg-blue-600 text-white shadow-xl text-base"
disabled={importing} onClick={() => setPathPickerOpen(true)}>
{importing ? <><Loader2 className="w-5 h-5 mr-2 animate-spin"/>...</> : <><Save className="w-5 h-5 mr-2"/></>}
</Button>
)}
</div>
)}
</div>
) : null}
</div>
</div>
<NetdiskPathPickerModal
isOpen={pathPickerOpen}
title="选择保存位置"
description="选择你要把文件保存到哪个文件夹"
initialPath={path}
confirmLabel="确认保存"
onClose={() => setPathPickerOpen(false)}
onConfirm={handleImportToPath}
/>
</div>
);
}

View File

@@ -0,0 +1,609 @@
import React, { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import {
ChevronRight,
Folder,
Download,
Upload,
Plus,
MoreVertical,
Copy,
Share2,
X,
Edit2,
Trash2,
FolderPlus,
ChevronLeft
} from 'lucide-react';
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
import { Button } from '@/src/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
import { Input } from '@/src/components/ui/input';
import { ApiError, apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api';
import { copyFileToNetdiskPath } from '@/src/lib/file-copy';
import { moveFileToNetdiskPath } from '@/src/lib/file-move';
import { resolveStoredFileType, type FileTypeKind } from '@/src/lib/file-type';
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share';
import { ellipsizeFileName } from '@/src/lib/file-name';
import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache';
import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
// Imports directly from the original pages directories
import {
buildUploadProgressSnapshot,
cancelUploadTask,
createUploadMeasurement,
createUploadTasks,
completeUploadTask,
failUploadTask,
prepareUploadTaskForCompletion,
prepareFolderUploadEntries,
prepareUploadFile,
shouldUploadEntriesSequentially,
type PendingUploadEntry,
type UploadMeasurement,
type UploadTask,
} from '@/src/pages/files-upload';
import {
registerFilesUploadTaskCanceler,
replaceFilesUploads,
setFilesUploadPanelOpen,
unregisterFilesUploadTaskCanceler,
updateFilesUploadTask,
} from '@/src/pages/files-upload-store';
import {
clearSelectionIfDeleted,
getNextAvailableName,
getActionErrorMessage,
removeUiFile,
replaceUiFile,
syncSelectedFile,
} from '@/src/pages/files-state';
import {
toDirectoryPath,
} from '@/src/pages/files-tree';
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function toBackendPath(pathParts: string[]) {
return toDirectoryPath(pathParts);
}
function formatFileSize(size: number) {
if (size <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
const value = size / 1024 ** index;
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function formatDateTime(value: string) {
const date = new Date(value);
return `${date.getMonth() + 1}-${date.getDate()} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
function toUiFile(file: FileMetadata) {
const resolvedType = resolveStoredFileType({
filename: file.filename,
contentType: file.contentType,
directory: file.directory,
});
return {
id: file.id,
name: file.filename,
type: resolvedType.kind,
typeLabel: resolvedType.label,
size: file.directory ? '—' : formatFileSize(file.size),
originalSize: file.directory ? 0 : file.size,
modified: formatDateTime(file.createdAt),
};
}
interface UiFile {
id: FileMetadata['id'];
modified: string;
name: string;
size: string;
originalSize: number;
type: FileTypeKind;
typeLabel: string;
}
type NetdiskTargetAction = 'move' | 'copy';
export default function MobileFiles() {
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
const fileInputRef = useRef<HTMLInputElement | null>(null);
const directoryInputRef = useRef<HTMLInputElement | null>(null);
const uploadMeasurementsRef = useRef(new Map<string, UploadMeasurement>());
const [currentPath, setCurrentPath] = useState<string[]>(initialPath);
const currentPathRef = useRef(currentPath);
const [currentFiles, setCurrentFiles] = useState<UiFile[]>(initialCachedFiles.map(toUiFile));
const [selectedFile, setSelectedFile] = useState<UiFile | null>(null);
// Modals inside mobile action sheet
const [actionSheetOpen, setActionSheetOpen] = useState(false);
const [renameModalOpen, setRenameModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [fileToRename, setFileToRename] = useState<UiFile | null>(null);
const [fileToDelete, setFileToDelete] = useState<UiFile | null>(null);
const [targetActionFile, setTargetActionFile] = useState<UiFile | null>(null);
const [targetAction, setTargetAction] = useState<NetdiskTargetAction | null>(null);
const [newFileName, setNewFileName] = useState('');
const [renameError, setRenameError] = useState('');
const [isRenaming, setIsRenaming] = useState(false);
const [shareStatus, setShareStatus] = useState('');
// Floating Action Button
const [fabOpen, setFabOpen] = useState(false);
const loadCurrentPath = async (pathParts: string[]) => {
const response = await apiRequest<PageResponse<FileMetadata>>(
`/files/list?path=${encodeURIComponent(toBackendPath(pathParts))}&page=0&size=100`
);
writeCachedValue(getFilesListCacheKey(toBackendPath(pathParts)), response.items);
writeCachedValue(getFilesLastPathCacheKey(), pathParts);
setCurrentFiles(response.items.map(toUiFile));
};
useEffect(() => {
currentPathRef.current = currentPath;
const cachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(currentPath)));
writeCachedValue(getFilesLastPathCacheKey(), currentPath);
if (cachedFiles) {
setCurrentFiles(cachedFiles.map(toUiFile));
}
loadCurrentPath(currentPath).catch(() => {
if (!cachedFiles) setCurrentFiles([]);
});
}, [currentPath]);
useEffect(() => {
if (directoryInputRef.current) {
directoryInputRef.current.setAttribute('webkitdirectory', '');
directoryInputRef.current.setAttribute('directory', '');
}
}, []);
const handleBreadcrumbClick = (index: number) => {
setCurrentPath(currentPath.slice(0, index + 1));
};
const handleBackClick = () => {
if (currentPath.length > 0) {
setCurrentPath(currentPath.slice(0, -1));
}
};
const handleFolderClick = (file: UiFile) => {
if (file.type === 'folder') {
setCurrentPath([...currentPath, file.name]);
} else {
openActionSheet(file);
}
};
const openActionSheet = (file: UiFile) => {
setSelectedFile(file);
setActionSheetOpen(true);
setShareStatus('');
};
const closeActionSheet = () => {
setActionSheetOpen(false);
};
const openRenameModal = (file: UiFile) => {
setFileToRename(file);
setNewFileName(file.name);
setRenameError('');
setRenameModalOpen(true);
closeActionSheet();
};
const openDeleteModal = (file: UiFile) => {
setFileToDelete(file);
setDeleteModalOpen(true);
closeActionSheet();
};
const openTargetActionModal = (file: UiFile, action: NetdiskTargetAction) => {
setTargetAction(action);
setTargetActionFile(file);
closeActionSheet();
};
// Upload Logic (Identical to reference)
const runUploadEntries = async (entries: PendingUploadEntry[]) => {
if (entries.length === 0) return;
setFilesUploadPanelOpen(true);
uploadMeasurementsRef.current.clear();
const batchTasks = createUploadTasks(entries);
replaceFilesUploads(batchTasks);
const runSingleUpload = async ({file: uploadFile, pathParts: uploadPathParts}: PendingUploadEntry, uploadTask: UploadTask) => {
const uploadPath = toBackendPath(uploadPathParts);
const uploadAbortController = new AbortController();
registerFilesUploadTaskCanceler(uploadTask.id, () => uploadAbortController.abort());
uploadMeasurementsRef.current.set(uploadTask.id, createUploadMeasurement(Date.now()));
try {
const updateProgress = ({loaded, total}: {loaded: number; total: number}) => {
const snapshot = buildUploadProgressSnapshot({
loaded, total, now: Date.now(), previous: uploadMeasurementsRef.current.get(uploadTask.id),
});
uploadMeasurementsRef.current.set(uploadTask.id, snapshot.measurement);
updateFilesUploadTask(uploadTask.id, (task) => ({ ...task, progress: snapshot.progress, speed: snapshot.speed }));
};
let initiated: InitiateUploadResponse | null = null;
try {
initiated = await apiRequest<InitiateUploadResponse>('/files/upload/initiate', {
method: 'POST', body: { path: uploadPath, filename: uploadFile.name, contentType: uploadFile.type || null, size: uploadFile.size },
});
} catch (e) { if (!(e instanceof ApiError && e.status === 404)) throw e; }
let uploadedFile: FileMetadata;
if (initiated?.direct) {
try {
await apiBinaryUploadRequest(initiated.uploadUrl, { method: initiated.method, headers: initiated.headers, body: uploadFile, onProgress: updateProgress, signal: uploadAbortController.signal });
uploadedFile = await apiRequest<FileMetadata>('/files/upload/complete', { method: 'POST', signal: uploadAbortController.signal, body: { path: uploadPath, filename: uploadFile.name, storageName: initiated.storageName, contentType: uploadFile.type || null, size: uploadFile.size } });
} catch (error) {
if (!(error instanceof ApiError && error.isNetworkError)) throw error;
const formData = new FormData(); formData.append('file', uploadFile);
uploadedFile = await apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(uploadPath)}`, { body: formData, onProgress: updateProgress, signal: uploadAbortController.signal });
}
} else if (initiated) {
const formData = new FormData(); formData.append('file', uploadFile);
uploadedFile = await apiUploadRequest<FileMetadata>(initiated.uploadUrl, { body: formData, method: initiated.method, headers: initiated.headers, onProgress: updateProgress, signal: uploadAbortController.signal });
} else {
const formData = new FormData(); formData.append('file', uploadFile);
uploadedFile = await apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(uploadPath)}`, { body: formData, onProgress: updateProgress, signal: uploadAbortController.signal });
}
updateFilesUploadTask(uploadTask.id, (task) => prepareUploadTaskForCompletion(task));
await sleep(120);
updateFilesUploadTask(uploadTask.id, (task) => completeUploadTask(task));
return uploadedFile;
} catch (error) {
if (uploadAbortController.signal.aborted) { updateFilesUploadTask(uploadTask.id, (task) => cancelUploadTask(task)); return null; }
updateFilesUploadTask(uploadTask.id, (task) => failUploadTask(task, error instanceof Error && error.message ? error.message : '上传失败'));
return null;
} finally {
uploadMeasurementsRef.current.delete(uploadTask.id);
unregisterFilesUploadTaskCanceler(uploadTask.id);
}
};
if (shouldUploadEntriesSequentially(entries)) {
let previousPromise = Promise.resolve<Array<FileMetadata | null>>([]);
for (let i = 0; i < entries.length; i++) {
previousPromise = previousPromise.then(async (prev) => {
const current = await runSingleUpload(entries[i], batchTasks[i]);
return [...prev, current];
});
}
const results = await previousPromise;
if (results.some(Boolean)) await loadCurrentPath(currentPathRef.current).catch(() => {});
} else {
const results = await Promise.all(entries.map((entry, index) => runSingleUpload(entry, batchTasks[index])));
if (results.some(Boolean)) await loadCurrentPath(currentPathRef.current).catch(() => {});
}
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
setFabOpen(false);
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
event.target.value = '';
if (files.length === 0) return;
const reservedNames = new Set<string>(currentFiles.map((file) => file.name));
const entries: PendingUploadEntry[] = files.map((file) => {
const preparedUpload = prepareUploadFile(file, reservedNames);
reservedNames.add(preparedUpload.file.name);
return { file: preparedUpload.file, pathParts: [...currentPath], source: 'file', noticeMessage: preparedUpload.noticeMessage };
});
await runUploadEntries(entries);
};
const handleFolderChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
setFabOpen(false);
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
event.target.value = '';
if (files.length === 0) return;
const entries = prepareFolderUploadEntries(files, [...currentPath], currentFiles.map((file) => file.name));
await runUploadEntries(entries);
};
const handleCreateFolder = async () => {
setFabOpen(false);
const folderName = window.prompt('请输入新文件夹名称');
if (!folderName?.trim()) return;
const normalizedFolderName = folderName.trim();
const nextFolderName = getNextAvailableName(normalizedFolderName, new Set(currentFiles.filter(f => f.type === 'folder').map(f => f.name)));
if (nextFolderName !== normalizedFolderName) window.alert(`名称冲突,重命名为 ${nextFolderName}`);
const basePath = toBackendPath(currentPath).replace(/\/$/, '');
const fullPath = `${basePath}/${nextFolderName}` || '/';
await apiRequest('/files/mkdir', {
method: 'POST',
body: new URLSearchParams({ path: fullPath.startsWith('/') ? fullPath : `/${fullPath}` }),
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
});
await loadCurrentPath(currentPath);
};
const handleRename = async () => {
if (!fileToRename || !newFileName.trim() || isRenaming) return;
setIsRenaming(true); setRenameError('');
try {
const renamedFile = await apiRequest<FileMetadata>(`/files/${fileToRename.id}/rename`, {
method: 'PATCH', body: { filename: newFileName.trim() },
});
const nextUiFile = toUiFile(renamedFile);
setCurrentFiles((prev) => replaceUiFile(prev, nextUiFile));
setSelectedFile((prev) => syncSelectedFile(prev, nextUiFile));
setRenameModalOpen(false); setFileToRename(null); setNewFileName('');
await loadCurrentPath(currentPath).catch(() => {});
} catch (error) {
setRenameError(getActionErrorMessage(error, '重命名失败'));
} finally { setIsRenaming(false); }
};
const handleDelete = async () => {
if (!fileToDelete) return;
await apiRequest(`/files/${fileToDelete.id}`, { method: 'DELETE' });
setCurrentFiles((prev) => removeUiFile(prev, fileToDelete.id));
setSelectedFile((prev) => clearSelectionIfDeleted(prev, fileToDelete.id));
setDeleteModalOpen(false); setFileToDelete(null);
await loadCurrentPath(currentPath).catch(() => {});
};
const handleMoveToPath = async (path: string) => {
if (!targetActionFile || !targetAction) return;
if (targetAction === 'move') {
await moveFileToNetdiskPath(targetActionFile.id, path);
setSelectedFile((prev) => clearSelectionIfDeleted(prev, targetActionFile.id));
} else {
await copyFileToNetdiskPath(targetActionFile.id, path);
}
setTargetAction(null); setTargetActionFile(null);
await loadCurrentPath(currentPath).catch(() => {});
};
const handleDownload = async (targetFile: UiFile | null = selectedFile) => {
const actFile = targetFile || selectedFile;
if (!actFile) return;
if (actFile.type === 'folder') {
const response = await apiDownload(`/files/download/${actFile.id}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url; link.download = `${actFile.name}.zip`; link.click();
window.URL.revokeObjectURL(url);
return;
}
try {
const response = await apiRequest<DownloadUrlResponse>(`/files/download/${actFile.id}/url`);
const link = document.createElement('a'); link.href = response.url; link.download = actFile.name; link.rel = 'noreferrer'; link.target = '_blank';
link.click(); return;
} catch (error) {
if (!(error instanceof ApiError && error.status === 404)) throw error;
}
const response = await apiDownload(`/files/download/${actFile.id}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a'); link.href = url; link.download = actFile.name; link.click();
window.URL.revokeObjectURL(url);
};
const handleShare = async (targetFile: UiFile) => {
try {
const response = await createFileShareLink(targetFile.id);
const shareUrl = getCurrentFileShareUrl(response.token);
try {
await navigator.clipboard.writeText(shareUrl);
setShareStatus('链接已复制到剪贴板,快发送给朋友吧');
} catch {
setShareStatus(`可全选复制链接:${shareUrl}`);
}
} catch (error) {
setShareStatus(error instanceof Error ? error.message : '分享失败');
}
};
return (
<div className="flex flex-col h-[calc(100vh-3.5rem)] relative overflow-hidden text-white bg-[#07101D]">
<div className="pointer-events-none absolute inset-0 z-0">
<div className="absolute top-[-12%] left-[-24%] h-72 w-72 rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[100px] animate-blob" />
<div className="absolute top-[22%] right-[-20%] h-80 w-80 rounded-full bg-purple-600 opacity-20 mix-blend-screen blur-[100px] animate-blob animation-delay-2000" />
<div className="absolute bottom-[-18%] left-[8%] h-80 w-80 rounded-full bg-indigo-600 opacity-20 mix-blend-screen blur-[100px] animate-blob animation-delay-4000" />
</div>
<input type="file" multiple ref={fileInputRef} className="hidden" onChange={handleFileChange} />
<input type="file" ref={directoryInputRef} className="hidden" onChange={handleFolderChange} />
{/* Top Header - Path navigation */}
<div className="flex-none px-4 py-3 bg-[#0f172a]/80 border-b border-white/5 sticky top-0 z-20 shadow-md backdrop-blur-xl">
<div className="flex flex-nowrap items-center text-sm overflow-x-auto custom-scrollbar whitespace-nowrap">
{currentPath.length > 0 && (
<button className="mr-3 p-1.5 rounded-full bg-white/5 text-slate-300 active:bg-white/10" onClick={handleBackClick}>
<ChevronLeft className="w-4 h-4" />
</button>
)}
<button className="text-slate-400 hover:text-white" onClick={() => handleBreadcrumbClick(-1)}></button>
{currentPath.map((pathItem, index) => (
<React.Fragment key={index}>
<ChevronRight className="w-3 h-3 mx-1 text-slate-600 shrink-0" />
<button onClick={() => handleBreadcrumbClick(index)} className={cn(index === currentPath.length - 1 ? 'text-white font-medium' : 'text-slate-400', 'shrink-0')}>{pathItem}</button>
</React.Fragment>
))}
</div>
</div>
{/* File List */}
<div className="relative z-10 flex-1 overflow-y-auto px-3 py-2 space-y-1.5 pb-24">
{currentFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-slate-500">
<FolderPlus className="w-10 h-10 mb-3 opacity-20" />
<p className="text-sm"></p>
</div>
) : (
currentFiles.map((file) => (
<div key={file.id} className="glass-panel w-full rounded-xl p-3 flex flex-row items-center gap-3 active:bg-white/5 select-none" onClick={() => handleFolderClick(file)}>
<div className="shrink-0 p-1.5 rounded-xl bg-black/20 border border-white/5">
<FileTypeIcon type={file.type} size="md" />
</div>
<div className="flex-1 min-w-0 flex flex-col justify-center">
<span className="text-sm text-white truncate w-full block">{file.name}</span>
<div className="flex items-center text-[10px] text-slate-400 mt-0.5 gap-2">
<span className={cn('px-1.5 py-0.5 rounded text-[9px] font-medium', getFileTypeTheme(file.type).badgeClassName)}>{file.typeLabel}</span>
<span>{file.modified}</span>
{file.type !== 'folder' && <span>{file.size}</span>}
</div>
</div>
{file.type !== 'folder' && (
<button className="p-2 shrink-0 text-slate-400 hover:text-white" onClick={(e) => { e.stopPropagation(); openActionSheet(file); }}>
<MoreVertical className="w-5 h-5" />
</button>
)}
</div>
))
)}
</div>
{/* Floating Action Button (FAB) + Menu */}
<div className="fixed bottom-20 right-6 z-30 flex flex-col items-end gap-3 pointer-events-none">
<AnimatePresence>
{fabOpen && (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} className="flex flex-col gap-3 pointer-events-auto items-end mr-1">
<button onClick={() => { fileInputRef.current?.click(); setFabOpen(false); }} className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-blue-500 text-white shadow-lg active:scale-95 text-sm font-medium">
<Upload className="w-4 h-4"/>
</button>
<button onClick={() => { directoryInputRef.current?.click(); setFabOpen(false); }} className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-emerald-500 text-white shadow-lg active:scale-95 text-sm font-medium">
<FolderPlus className="w-4 h-4"/>
</button>
<button onClick={handleCreateFolder} className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-purple-500 text-white shadow-lg active:scale-95 text-sm font-medium">
<Plus className="w-4 h-4"/>
</button>
</motion.div>
)}
</AnimatePresence>
<button onClick={() => setFabOpen(!fabOpen)} className={cn("pointer-events-auto flex items-center justify-center w-14 h-14 rounded-full shadow-2xl transition-transform active:scale-95", fabOpen ? "bg-[#0f172a] border border-white/10 rotate-45" : "bg-[#336EFF]")}>
<Plus className="w-6 h-6 text-white" />
</button>
</div>
{/* FAB Backdrop */}
<AnimatePresence>
{fabOpen && <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-20 bg-black/40 backdrop-blur-sm" onClick={() => setFabOpen(false)} />}
</AnimatePresence>
{/* Action Sheet */}
<AnimatePresence>
{actionSheetOpen && selectedFile && (
<div className="fixed inset-0 z-50 flex items-end">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={closeActionSheet} />
<motion.div initial={{ y: '100%' }} animate={{ y: 0 }} exit={{ y: '100%' }} transition={{ type: "spring", damping: 25, stiffness: 200 }} className="relative w-full bg-[#0f172a] rounded-t-3xl border-t border-white/10 pt-4 pb-8 px-4 flex flex-col z-10 glass-panel">
<div className="w-12 h-1 bg-white/20 rounded-full mx-auto mb-4" />
<div className="flex border-b border-white/10 pb-4 mb-4 gap-4 items-center px-2">
<FileTypeIcon type={selectedFile.type} size="md" />
<div className="min-w-0">
<p className="text-sm font-semibold truncate text-white">{selectedFile.name}</p>
<p className="text-xs text-slate-400 mt-1">{selectedFile.size} {selectedFile.modified}</p>
</div>
</div>
<div className="grid grid-cols-4 gap-2 mb-4 px-2">
<ActionButton icon={Download} label="下载" onClick={() => { handleDownload(); closeActionSheet(); }} color="text-amber-400" />
<ActionButton icon={Share2} label="分享" onClick={() => handleShare(selectedFile)} color="text-emerald-400" />
<ActionButton icon={Copy} label="复制" onClick={() => openTargetActionModal(selectedFile, 'copy')} color="text-blue-400" />
<ActionButton icon={Folder} label="移动" onClick={() => openTargetActionModal(selectedFile, 'move')} color="text-indigo-400" />
<ActionButton icon={Edit2} label="重命名" onClick={() => openRenameModal(selectedFile)} color="text-slate-300" />
<ActionButton icon={Trash2} label="删除" onClick={() => openDeleteModal(selectedFile)} color="text-red-400" />
</div>
{shareStatus && <div className="mx-2 mt-2 p-3 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-[10px] text-emerald-400 break-all">{shareStatus}</div>}
<Button variant="ghost" onClick={closeActionSheet} className="mt-4 text-slate-400 py-6 text-sm"></Button>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Target Action Modal */}
{targetAction && (
<NetdiskPathPickerModal
isOpen
title={targetAction === 'move' ? '移动到' : '复制到'}
confirmLabel={targetAction === 'move' ? '移动至此' : '复制至此'}
onClose={() => setTargetAction(null)}
onConfirm={(path) => void handleMoveToPath(path)}
/>
)}
{/* Rename Modal */}
<AnimatePresence>
{renameModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setRenameModalOpen(false)} />
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="relative w-full max-w-sm glass-panel bg-[#0f172a] border border-white/10 rounded-2xl p-5 z-10 shadow-2xl">
<h3 className="text-lg font-bold text-white mb-4"></h3>
<Input value={newFileName} onChange={(e) => setNewFileName(e.target.value)} className="bg-black/20 text-white mb-2 h-12" placeholder="请输入新名称" />
{renameError && <p className="text-xs text-red-400 mb-4">{renameError}</p>}
<div className="flex gap-3 mt-6">
<Button variant="outline" className="flex-1 bg-white/5 border-white/10 text-white" onClick={() => setRenameModalOpen(false)}></Button>
<Button className="flex-1 bg-[#336EFF] hover:bg-[#2958cc] text-white" onClick={handleRename} disabled={isRenaming}>{isRenaming ? '保存中' : '保存'}</Button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Delete Modal */}
<AnimatePresence>
{deleteModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setDeleteModalOpen(false)} />
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="relative w-full max-w-sm glass-panel bg-[#0f172a] border border-white/10 rounded-2xl p-5 z-10 shadow-2xl">
<h3 className="text-lg font-bold text-white mb-2 flex items-center gap-2"><Trash2 className="text-red-400 w-5 h-5"/></h3>
<p className="text-sm text-slate-300 mb-6 mt-3"> <span className="text-white font-medium break-all">{fileToDelete?.name}</span> </p>
<div className="flex gap-3">
<Button variant="outline" className="flex-1 bg-white/5 border-white/10 text-white" onClick={() => setDeleteModalOpen(false)}></Button>
<Button className="flex-1 bg-red-500 text-white hover:bg-red-600" onClick={handleDelete}></Button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}
function ActionButton({ icon: Icon, label, color, onClick }: any) {
return (
<div className="flex flex-col items-center gap-2 p-2 hover:bg-white/5 rounded-xl transition-colors active:bg-white/10" onClick={onClick}>
<div className={cn("p-3 rounded-full bg-black/20 border border-white/5 shadow-inner", color)}>
<Icon className="w-5 h-5" />
</div>
<span className="text-xs text-slate-300">{label}</span>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import React, { useState } from 'react';
import { motion } from 'motion/react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button';
import { Gamepad2, Cat, Car, ExternalLink, Play } from 'lucide-react';
import { cn } from '@/src/lib/utils';
import { MORE_GAMES_LABEL, MORE_GAMES_URL, resolveGamePlayerPath, type GameId } from '@/src/pages/games-links';
const GAMES: Array<{
id: GameId;
name: string;
description: string;
icon: typeof Cat;
color: string;
category: 'featured';
}> = [
{
id: 'cat',
name: 'CAT',
description: '简单的小猫升级游戏,通过点击获取经验,解锁不同形态的猫咪。',
icon: Cat,
color: 'from-orange-400 to-red-500',
category: 'featured'
},
{
id: 'race',
name: 'RACE',
description: '赛车休闲小游戏,躲避障碍物,挑战最高分记录。',
icon: Car,
color: 'from-blue-400 to-indigo-500',
category: 'featured'
}
];
function MobileGameCard({ game, index }: { game: (typeof GAMES)[number]; index: number }) {
const navigate = useNavigate();
return (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.1 }}>
<Card className="glass-panel overflow-hidden border-white/10 bg-white/[0.03] active:bg-white/[0.06] transition-colors relative">
<div className={cn("absolute top-0 left-0 h-1.5 w-full bg-gradient-to-r", game.color)} />
<CardHeader className="pb-3 px-4 pt-5">
<div className="flex items-start justify-between">
<div className={cn("flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br shadow-lg", game.color)}>
<game.icon className="h-6 w-6 text-white" />
</div>
<span className="rounded-md bg-white/5 border border-white/5 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-slate-400">
{game.category}
</span>
</div>
<CardTitle className="mt-3 text-lg font-bold text-white">{game.name}</CardTitle>
<CardDescription className="mt-1 text-xs text-slate-400 line-clamp-2 leading-relaxed tracking-wide">
{game.description}
</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
<Button type="button" onClick={() => navigate(resolveGamePlayerPath(game.id))} className="w-full h-11 bg-white/10 hover:bg-white/20 text-white rounded-xl font-medium tracking-wide border border-white/5 active:scale-95 transition-transform" >
<Play className="h-4 w-4 mr-2" fill="currentColor" />
</Button>
</CardContent>
</Card>
</motion.div>
);
}
export default function MobileGames() {
const [activeTab, setActiveTab] = useState<'featured' | 'all'>('featured');
return (
<div className="flex flex-col min-h-full bg-[#07101D] text-white">
{/* 沉浸式头部 */}
<div className="relative px-5 pt-12 pb-8 overflow-hidden bg-[url('/noise.png')]">
<div className="absolute top-[-20%] right-[-20%] w-[120%] h-[150%] bg-purple-600 rounded-full mix-blend-screen filter blur-[80px] opacity-20" />
<div className="relative z-10 space-y-3">
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-white/5 border border-white/10 font-medium w-fit">
<Gamepad2 className="w-3.5 h-3.5 text-purple-400" />
<span className="text-[10px] text-slate-300 tracking-wider uppercase">Entertainment</span>
</div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-xs text-slate-400 leading-relaxed max-w-[280px]">
</p>
<a href={MORE_GAMES_URL} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[11px] text-slate-300 active:text-white mt-1 underline underline-offset-2 opacity-70">
<ExternalLink className="h-3 w-3" /> {MORE_GAMES_LABEL}
</a>
</div>
</div>
<div className="flex-1 px-4 pb-10">
{/* 分类切换栏 */}
<div className="flex p-1 rounded-xl bg-white/5 border border-white/5 w-full max-w-[200px] mb-6 shadow-inner">
<button onClick={() => setActiveTab('featured')} className={cn("flex-1 py-1.5 text-xs font-medium rounded-lg transition-all", activeTab === 'featured' ? "bg-white/10 text-white shadow" : "text-slate-400")}>
</button>
<button onClick={() => setActiveTab('all')} className={cn("flex-1 py-1.5 text-xs font-medium rounded-lg transition-all", activeTab === 'all' ? "bg-white/10 text-white shadow" : "text-slate-400")}>
</button>
</div>
{/* 游戏卡片网格 */}
<div className="grid grid-cols-1 gap-4">
{GAMES.map((game, index) => (
<MobileGameCard key={game.id} game={game} index={index} />
))}
</div>
</div>
{/* 留出底部边距给导航栏 */}
<div className="h-6" />
</div>
);
}

View File

@@ -0,0 +1,250 @@
import React, { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { motion, AnimatePresence } from 'motion/react';
import { LogIn, User, Lock, UserPlus, Mail, ArrowLeft, Phone, Send } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button';
import { Input } from '@/src/components/ui/input';
import { apiRequest, ApiError } from '@/src/lib/api';
import { getPostLoginRedirectPath } from '@/src/lib/file-share';
import { cn } from '@/src/lib/utils';
import { createSession, markPostLoginPending, saveStoredSession } from '@/src/lib/session';
import type { AuthResponse } from '@/src/lib/types';
import { buildRegisterPayload, validateRegisterForm } from '@/src/pages/login-state';
const DEV_LOGIN_ENABLED = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEV_LOGIN === 'true';
export default function MobileLogin() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [isLogin, setIsLogin] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// Form states
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [registerUsername, setRegisterUsername] = useState('');
const [registerEmail, setRegisterEmail] = useState('');
const [registerPhoneNumber, setRegisterPhoneNumber] = useState('');
const [registerPassword, setRegisterPassword] = useState('');
const [registerConfirmPassword, setRegisterConfirmPassword] = useState('');
const [registerInviteCode, setRegisterInviteCode] = useState('');
function switchMode(nextIsLogin: boolean) {
setIsLogin(nextIsLogin);
setError('');
setLoading(false);
}
async function handleLoginSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true); setError('');
try {
let auth: AuthResponse;
try {
auth = await apiRequest<AuthResponse>('/auth/login', { method: 'POST', body: { username, password } });
} catch (requestError) {
if (DEV_LOGIN_ENABLED && username.trim() && requestError instanceof ApiError && requestError.status === 401) {
auth = await apiRequest<AuthResponse>(`/auth/dev-login?username=${encodeURIComponent(username.trim())}`, { method: 'POST' });
} else {
throw requestError;
}
}
saveStoredSession(createSession(auth));
markPostLoginPending();
setLoading(false);
navigate(getPostLoginRedirectPath(searchParams.get('next')));
} catch (requestError) {
setLoading(false);
setError(requestError instanceof Error ? requestError.message : '登录失败,请重试');
}
}
async function handleRegisterSubmit(e: React.FormEvent) {
e.preventDefault();
const validationMessage = validateRegisterForm({
username: registerUsername, email: registerEmail, phoneNumber: registerPhoneNumber,
password: registerPassword, confirmPassword: registerConfirmPassword, inviteCode: registerInviteCode,
});
if (validationMessage) { setError(validationMessage); return; }
setLoading(true); setError('');
try {
const auth = await apiRequest<AuthResponse>('/auth/register', {
method: 'POST',
body: buildRegisterPayload({
username: registerUsername, email: registerEmail, phoneNumber: registerPhoneNumber,
password: registerPassword, confirmPassword: registerConfirmPassword, inviteCode: registerInviteCode,
}),
});
saveStoredSession(createSession(auth));
markPostLoginPending();
setLoading(false);
navigate(getPostLoginRedirectPath(searchParams.get('next')));
} catch (requestError) {
setLoading(false);
setError(requestError instanceof Error ? requestError.message : '注册失败,请重试');
}
}
return (
<div className="min-h-[100dvh] flex flex-col bg-[#07101D] relative overflow-hidden">
{/* Background Blobs - customized for mobile sizes */}
<div className="absolute top-10 left-[-20%] w-[80%] h-[40%] bg-[#336EFF] rounded-full mix-blend-screen filter blur-[100px] opacity-25 animate-pulse" />
<div className="absolute bottom-10 right-[-10%] w-[70%] h-[40%] bg-purple-600 rounded-full mix-blend-screen filter blur-[100px] opacity-20" />
{/* Top Graphic Intro area */}
<div className="pt-16 pb-6 px-6 relative z-10 flex flex-col items-center justify-center shrink-0">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-xl shadow-[#336EFF]/20 mb-4">
<span className="text-white font-bold text-3xl leading-none">Y</span>
</div>
<h1 className="text-3xl font-bold tracking-tight text-white mb-2"></h1>
<p className="text-sm text-slate-400 text-center max-w-[280px]">
</p>
</div>
<div className="flex-1 w-full px-4 pb-8 relative z-10">
<Card className="border-white/10 backdrop-blur-2xl bg-white/5 shadow-2xl overflow-hidden w-full max-w-sm mx-auto">
<AnimatePresence mode="wait">
{isLogin ? (
<motion.div
key="login-form"
initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
<CardHeader className="space-y-1 pb-6 pt-6 px-6">
<CardTitle className="text-xl font-bold text-white flex items-center gap-2">
<LogIn className="w-5 h-5 text-[#336EFF]" />
</CardTitle>
<CardDescription className="text-xs text-slate-400"></CardDescription>
</CardHeader>
<CardContent className="px-6 pb-6">
<form onSubmit={handleLoginSubmit} className="space-y-5">
<div className="space-y-4">
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Input
type="text" placeholder="账号 / 用户名 / 学号" required value={username}
onChange={(e) => setUsername(e.target.value)}
className="pl-10 h-12 bg-black/20 border-white/10 focus-visible:ring-[#336EFF] text-base"
/>
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Input
type="password" placeholder="••••••••" required value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10 h-12 bg-black/20 border-white/10 focus-visible:ring-[#336EFF] text-base"
/>
</div>
</div>
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-xs">
{error}
</div>
)}
<div className="space-y-3 pt-2">
<Button type="submit" className="w-full h-12 text-base font-semibold rounded-xl" disabled={loading}>
{loading ? <span className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" /> : '进入系统'}
</Button>
<Button type="button" variant="outline" onClick={() => navigate('/transfer')} className="w-full h-12 border-white/10 bg-white/5 text-slate-300 hover:bg-white/10 rounded-xl">
<Send className="mr-2 h-4 w-4" />
</Button>
<div className="text-center pt-2">
<button type="button" onClick={() => switchMode(false)} className="text-sm text-slate-400 hover:text-white transition-colors p-2">
</button>
</div>
</div>
</form>
</CardContent>
</motion.div>
) : (
<motion.div
key="register-form"
initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
<CardHeader className="space-y-1 pb-4 pt-6 px-6">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-bold text-white flex items-center gap-2">
<UserPlus className="w-5 h-5 text-[#336EFF]" />
</CardTitle>
<button type="button" onClick={() => switchMode(true)} className="p-1.5 rounded-full bg-white/5 text-slate-300">
<ArrowLeft className="w-4 h-4" />
</button>
</div>
</CardHeader>
<CardContent className="px-6 pb-6 h-[50vh] overflow-y-auto custom-scrollbar">
<form onSubmit={handleRegisterSubmit} className="space-y-4">
<div className="space-y-3">
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input type="text" placeholder="设置您的用户名" required minLength={3} maxLength={64}
value={registerUsername} onChange={(e) => setRegisterUsername(e.target.value)}
className="pl-9 h-11 bg-black/20 border-white/10 text-sm" />
</div>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input type="email" placeholder="邮箱 (your@email.com)" required
value={registerEmail} onChange={(e) => setRegisterEmail(e.target.value)}
className="pl-9 h-11 bg-black/20 border-white/10 text-sm" />
</div>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input type="tel" placeholder="11位手机号" required
value={registerPhoneNumber} onChange={(e) => setRegisterPhoneNumber(e.target.value)}
className="pl-9 h-11 bg-black/20 border-white/10 text-sm" />
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input type="password" placeholder="设置密码 (至少 8 位)" required minLength={8} maxLength={64}
value={registerPassword} onChange={(e) => setRegisterPassword(e.target.value)}
className="pl-9 h-11 bg-black/20 border-white/10 text-sm" />
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input type="password" placeholder="验证密码" required minLength={8} maxLength={64}
value={registerConfirmPassword} onChange={(e) => setRegisterConfirmPassword(e.target.value)}
className="pl-9 h-11 bg-black/20 border-white/10 text-sm" />
</div>
<div className="relative">
<UserPlus className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input type="text" placeholder="邀请码" required
value={registerInviteCode} onChange={(e) => setRegisterInviteCode(e.target.value)}
className="pl-9 h-11 bg-black/20 border-white/10 text-sm" />
</div>
</div>
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-xs">
{error}
</div>
)}
<div className="space-y-4 pt-2 pb-4">
<Button type="submit" className="w-full h-11 text-base rounded-xl" disabled={loading}>
{loading ? '注册中...' : '创建账号'}
</Button>
<div className="text-center">
<button type="button" onClick={() => switchMode(true)} className="text-xs text-slate-400 p-2">
</button>
</div>
</div>
</form>
</CardContent>
</motion.div>
)}
</AnimatePresence>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,260 @@
import React, { useEffect, useMemo, useState } from 'react';
import { motion } from 'motion/react';
import { useNavigate } from 'react-router-dom';
import {
ChevronRight,
Clock,
Database,
FileText,
FolderPlus,
Mail,
Send,
Upload,
User,
Zap,
} from 'lucide-react';
import { shouldLoadAvatarWithAuth } from '@/src/components/layout/account-utils';
import { Button } from '@/src/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
import { apiDownload, apiRequest } from '@/src/lib/api';
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { resolveStoredFileType } from '@/src/lib/file-type';
import { getOverviewCacheKey } from '@/src/lib/page-cache';
import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session';
import type { FileMetadata, PageResponse, UserProfile } from '@/src/lib/types';
import { getOverviewLoadErrorMessage } from '@/src/pages/overview-state';
function formatFileSize(size: number) {
if (size <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
const value = size / 1024 ** index;
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function formatRecentTime(value: string) {
const date = new Date(value);
const diffHours = Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60));
if (diffHours < 24) return `${Math.max(diffHours, 0)}小时前`;
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(date);
}
export default function MobileOverview() {
const navigate = useNavigate();
const cachedOverview = readCachedValue<{
profile: UserProfile | null;
recentFiles: FileMetadata[];
rootFiles: FileMetadata[];
}>(getOverviewCacheKey());
const [profile, setProfile] = useState<UserProfile | null>(cachedOverview?.profile ?? readStoredSession()?.user ?? null);
const [recentFiles, setRecentFiles] = useState<FileMetadata[]>(cachedOverview?.recentFiles ?? []);
const [rootFiles, setRootFiles] = useState<FileMetadata[]>(cachedOverview?.rootFiles ?? []);
const [loadingError, setLoadingError] = useState('');
const [retryToken, setRetryToken] = useState(0);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const currentHour = new Date().getHours();
let greeting = '晚上好';
if (currentHour < 6) greeting = '凌晨好';
else if (currentHour < 12) greeting = '早上好';
else if (currentHour < 18) greeting = '下午好';
const currentTime = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
const recentWeekUploads = recentFiles.filter(f => Date.now() - new Date(f.createdAt).getTime() <= 7 * 24 * 60 * 60 * 1000).length;
const usedBytes = useMemo(() => rootFiles.filter(f => !f.directory).reduce((sum, f) => sum + f.size, 0), [rootFiles]);
const storageQuotaBytes = profile?.storageQuotaBytes && profile.storageQuotaBytes > 0 ? profile.storageQuotaBytes : 50 * 1024 * 1024 * 1024;
const usedGb = usedBytes / 1024 / 1024 / 1024;
const storagePercent = Math.min((usedBytes / storageQuotaBytes) * 100, 100);
const latestFile = recentFiles[0] ?? null;
const profileDisplayName = profile?.displayName || profile?.username || '未登录';
const profileAvatarFallback = profileDisplayName.charAt(0).toUpperCase();
useEffect(() => {
let cancelled = false;
async function loadOverview() {
const pendingAfterLogin = hasPostLoginPending();
setLoadingError('');
try {
const [userResult, recentResult, rootResult] = await Promise.allSettled([
apiRequest<UserProfile>('/user/profile'),
apiRequest<FileMetadata[]>('/files/recent'),
apiRequest<PageResponse<FileMetadata>>('/files/list?path=%2F&page=0&size=100'),
]);
const failures = [userResult, recentResult, rootResult].filter(r => r.status === 'rejected');
if (cancelled) return;
const nextProfile = userResult.status === 'fulfilled' ? userResult.value : profile;
const nextRecentFiles = recentResult.status === 'fulfilled' ? recentResult.value : recentFiles;
const nextRootFiles = rootResult.status === 'fulfilled' ? rootResult.value.items : rootFiles;
setProfile(nextProfile); setRecentFiles(nextRecentFiles); setRootFiles(nextRootFiles);
writeCachedValue(getOverviewCacheKey(), { profile: nextProfile, recentFiles: nextRecentFiles, rootFiles: nextRootFiles });
if (failures.length > 0) setLoadingError(getOverviewLoadErrorMessage(pendingAfterLogin));
else clearPostLoginPending();
} catch {
if (!cancelled) setLoadingError(getOverviewLoadErrorMessage(pendingAfterLogin));
}
}
void loadOverview();
return () => { cancelled = true; };
}, [retryToken]);
useEffect(() => {
let active = true;
let objectUrl: string | null = null;
async function loadAvatar() {
if (!profile?.avatarUrl) {
if (active) setAvatarUrl(null);
return;
}
if (!shouldLoadAvatarWithAuth(profile.avatarUrl)) {
if (active) setAvatarUrl(profile.avatarUrl);
return;
}
try {
const response = await apiDownload(profile.avatarUrl);
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
if (active) setAvatarUrl(objectUrl);
} catch {
if (active) setAvatarUrl(null);
}
}
void loadAvatar();
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [profile?.avatarUrl]);
return (
<div className="flex flex-col gap-4 px-4 pb-4">
{/* 头部欢迎区域 */}
<motion.div
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
className="glass-panel p-5 rounded-2xl relative overflow-hidden"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-[#336EFF] rounded-full mix-blend-screen filter blur-[60px] opacity-20" />
<div className="relative z-10">
<h1 className="text-xl font-bold text-white tracking-tight">
{profile?.username ?? '访客'}
</h1>
<p className="text-[#336EFF] font-medium text-xs mt-1">{currentTime} · {greeting}</p>
</div>
</motion.div>
{loadingError ? (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}>
<Card className="border-amber-400/20 bg-amber-500/10 mb-2">
<CardContent className="flex flex-col gap-3 p-4 text-xs text-amber-100">
<span className="leading-tight">{loadingError}</span>
<Button variant="outline" size="sm" className="w-full text-amber-200 border-amber-400/30 hover:bg-amber-400/20" onClick={() => setRetryToken((v) => v + 1)}>
</Button>
</CardContent>
</Card>
</motion.div>
) : null}
{/* 核心指标网格:移动端改为 2x2 等宽 */}
<div className="grid grid-cols-2 gap-3">
<MobileMetricCard title="文件总数" value={`${rootFiles.length}`} icon={FileText} delay={0.1} color="text-amber-400" bg="bg-amber-500/20" />
<MobileMetricCard title="近期上传" value={`${recentWeekUploads}`} icon={Upload} delay={0.15} color="text-emerald-400" bg="bg-emerald-500/20" />
<MobileMetricCard title="快传就绪" value={latestFile ? '使用中' : '待命'} icon={Send} delay={0.2} color="text-[#336EFF]" bg="bg-[#336EFF]/20" />
<MobileMetricCard title="存储占用" value={`${storagePercent.toFixed(1)}%`} icon={Database} delay={0.25} color="text-purple-400" bg="bg-purple-500/20" subtitle={`${formatFileSize(usedBytes)}`} />
</div>
{/* 快捷操作区 */}
<Card className="glass-panel mt-2">
<CardHeader className="py-3 px-4 pb-2 border-b border-white/5">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent className="p-4 grid grid-cols-4 gap-2">
<QuickAction icon={Upload} label="上传" onClick={() => navigate('/files')} />
<QuickAction icon={FolderPlus} label="建目录" onClick={() => navigate('/files')} />
<QuickAction icon={Database} label="网盘" onClick={() => navigate('/files')} />
<QuickAction icon={Send} label="快传" onClick={() => navigate('/transfer')} />
</CardContent>
</Card>
{/* 近期文件 (精简版) */}
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between py-3 px-4 pb-2 border-b border-white/5">
<CardTitle className="text-sm font-medium"></CardTitle>
<button className="text-xs text-slate-400 flex items-center" onClick={() => navigate('/files')}>
<ChevronRight className="w-3 h-3" />
</button>
</CardHeader>
<CardContent className="p-2">
<div className="space-y-1">
{recentFiles.slice(0, 3).map((file, index) => {
const fileType = resolveStoredFileType({ filename: file.filename, contentType: file.contentType, directory: file.directory });
return (
<div key={`${file.id}-${index}`} className="flex items-center justify-between rounded-lg p-2 hover:bg-white/5" onClick={() => navigate('/files')}>
<div className="flex items-center gap-3 overflow-hidden">
<FileTypeIcon type={fileType.kind} size="sm" />
<div className="min-w-0 truncate">
<p className="truncate text-xs font-medium text-white">{file.filename}</p>
<p className="text-[10px] text-slate-400 mt-0.5">{formatRecentTime(file.createdAt)}</p>
</div>
</div>
<span className="ml-2 shrink-0 text-[10px] font-mono text-slate-500">{(file.directory ? '目录' : formatFileSize(file.size))}</span>
</div>
);
})}
{recentFiles.length === 0 && <div className="p-3 text-center text-xs text-slate-500"></div>}
</div>
</CardContent>
</Card>
{/* 快传推荐横幅 */}
<Card className="glass-panel overflow-hidden border-cyan-500/20 relative" onClick={() => navigate('/transfer')}>
<div className="absolute inset-0 bg-gradient-to-r from-cyan-900/40 to-blue-900/40" />
<CardContent className="relative z-10 p-4 flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center gap-1.5 pt-1">
<Zap className="h-4 w-4 text-cyan-400" />
<h3 className="text-sm font-semibold text-white"></h3>
</div>
<p className="text-[10px] text-slate-300"></p>
</div>
<ChevronRight className="h-5 w-5 text-cyan-400 opacity-70" />
</CardContent>
</Card>
{/* 留出底部边距给导航栏 */}
<div className="h-6" />
</div>
);
}
function MobileMetricCard({ title, value, icon: Icon, delay, color, bg, subtitle }: any) {
return (
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay }}>
<div className="glass-panel p-4 rounded-xl flex flex-col justify-between h-full hover:bg-white/5 active:scale-95 transition-all">
<div className="flex items-center gap-2 mb-2">
<div className={`p-1.5 rounded-lg ${bg}`}><Icon className={`w-4 h-4 ${color}`} /></div>
<span className="text-xs text-slate-400">{title}</span>
</div>
<div>
<div className="text-xl font-bold text-white leading-tight">{value}</div>
{subtitle && <div className="text-[10px] text-slate-500 mt-0.5">{subtitle}</div>}
</div>
</div>
</motion.div>
);
}
function QuickAction({ icon: Icon, label, onClick }: any) {
return (
<button onClick={onClick} className="flex flex-col items-center justify-center gap-2 py-3 px-1 rounded-xl bg-white/[0.02] hover:bg-white/[0.06] active:scale-95 transition-all">
<Icon className="w-5 h-5 text-slate-300" />
<span className="text-[10px] tracking-wider text-slate-300">{label}</span>
</button>
);
}

View File

@@ -0,0 +1,564 @@
import React, { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import {
CheckCircle,
ChevronRight,
Clock3,
Copy,
DownloadCloud,
File as FileIcon,
Folder,
FolderPlus,
Link as LinkIcon,
Loader2,
Monitor,
Plus,
Send,
Shield,
Smartphone,
Trash2,
UploadCloud,
X,
LogIn,
} from 'lucide-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '@/src/auth/AuthProvider';
import { Button } from '@/src/components/ui/button';
import { buildTransferShareUrl, getTransferRouterMode } from '@/src/lib/transfer-links';
import {
createTransferFileManifest,
createTransferFileManifestMessage,
createTransferCompleteMessage,
createTransferFileCompleteMessage,
createTransferFileId,
createTransferFileMetaMessage,
type TransferFileDescriptor,
SIGNAL_POLL_INTERVAL_MS,
TRANSFER_CHUNK_SIZE,
} from '@/src/lib/transfer-protocol';
import { waitForTransferChannelDrain } from '@/src/lib/transfer-runtime';
import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling';
import {
DEFAULT_TRANSFER_ICE_SERVERS,
createTransferSession,
listMyOfflineTransferSessions,
pollTransferSignals,
postTransferSignal,
uploadOfflineTransferFile,
} from '@/src/lib/transfer';
import type { TransferMode, TransferSessionResponse } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
// We reuse the state and sub-component
import {
buildQrImageUrl,
canSendTransferFiles,
getAvailableTransferModes,
formatTransferSize,
getOfflineTransferSessionLabel,
getOfflineTransferSessionSize,
getTransferModeSummary,
resolveInitialTransferTab,
} from '@/src/pages/transfer-state';
import TransferReceive from '@/src/pages/TransferReceive';
type SendPhase = 'idle' | 'creating' | 'waiting' | 'connecting' | 'uploading' | 'transferring' | 'completed' | 'error';
function parseJsonPayload<T>(payload: string): T | null {
try { return JSON.parse(payload) as T; } catch { return null; }
}
function getPhaseMessage(mode: TransferMode, phase: SendPhase, errorMessage: string) {
if (mode === 'OFFLINE') {
switch (phase) {
case 'creating': return '正在创建离线会话...';
case 'uploading': return '文件上传中完成后可保留7天。';
case 'completed': return '离线文件已上传完成,可以多次下载。';
case 'error': return errorMessage || '初始化失败,请重试。';
default: return '离线模式会将文件存至服务端保留7天。';
}
}
switch (phase) {
case 'creating': return '正在准备 P2P 连接...';
case 'waiting': return '已生成二维码,等待接收端扫码或访问链接。';
case 'connecting': return '接收端已进入,正在建立连接...';
case 'transferring': return 'P2P 直连已建立,文件发送中...';
case 'completed': return '本次文件发送完成。';
case 'error': return errorMessage || '快传初始化失败,请重试。';
default: return '文件不经过服务器,浏览器直连互传。';
}
}
export default function MobileTransfer() {
const navigate = useNavigate();
const { session: authSession } = useAuth();
const [searchParams] = useSearchParams();
const sessionId = searchParams.get('session');
const isAuthenticated = Boolean(authSession?.token);
const allowSend = canSendTransferFiles(isAuthenticated);
const availableTransferModes = getAvailableTransferModes(isAuthenticated);
const [activeTab, setActiveTab] = useState(() => resolveInitialTransferTab(allowSend, sessionId));
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [transferMode, setTransferMode] = useState<TransferMode>('ONLINE');
const [session, setSession] = useState<TransferSessionResponse | null>(null);
const [sendPhase, setSendPhase] = useState<SendPhase>('idle');
const [sendProgress, setSendProgress] = useState(0);
const [sendError, setSendError] = useState('');
const [copied, setCopied] = useState(false);
const [offlineHistory, setOfflineHistory] = useState<TransferSessionResponse[]>([]);
const [offlineHistoryLoading, setOfflineHistoryLoading] = useState(false);
const [offlineHistoryError, setOfflineHistoryError] = useState('');
const [selectedOfflineSession, setSelectedOfflineSession] = useState<TransferSessionResponse | null>(null);
const [historyCopiedSessionId, setHistoryCopiedSessionId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const copiedTimerRef = useRef<number | null>(null);
const historyCopiedTimerRef = useRef<number | null>(null);
const pollTimerRef = useRef<number | null>(null);
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
const dataChannelRef = useRef<RTCDataChannel | null>(null);
const cursorRef = useRef(0);
const bootstrapIdRef = useRef(0);
const totalBytesRef = useRef(0);
const sentBytesRef = useRef(0);
const sendingStartedRef = useRef(false);
const pendingRemoteCandidatesRef = useRef<RTCIceCandidateInit[]>([]);
const manifestRef = useRef<TransferFileDescriptor[]>([]);
useEffect(() => {
if (folderInputRef.current) {
folderInputRef.current.setAttribute('webkitdirectory', '');
folderInputRef.current.setAttribute('directory', '');
}
return () => {
cleanupCurrentTransfer();
if (copiedTimerRef.current) window.clearTimeout(copiedTimerRef.current);
if (historyCopiedTimerRef.current) window.clearTimeout(historyCopiedTimerRef.current);
};
}, []);
useEffect(() => {
if (!allowSend || sessionId) setActiveTab('receive');
}, [allowSend, sessionId]);
useEffect(() => {
if (!availableTransferModes.includes(transferMode)) setTransferMode('ONLINE');
}, [availableTransferModes, transferMode]);
useEffect(() => {
if (selectedFiles.length > 0) void bootstrapTransfer(selectedFiles);
}, [transferMode]);
useEffect(() => {
if (!isAuthenticated) {
setOfflineHistory([]); setOfflineHistoryError(''); setSelectedOfflineSession(null);
return;
}
void loadOfflineHistory();
}, [isAuthenticated]);
const totalSize = selectedFiles.reduce((sum, file) => sum + file.size, 0);
const shareLink = session ? buildTransferShareUrl(window.location.origin, session.sessionId, getTransferRouterMode()) : '';
const qrImageUrl = shareLink ? buildQrImageUrl(shareLink) : '';
const transferModeSummary = getTransferModeSummary(transferMode);
const selectedOfflineSessionShareLink = selectedOfflineSession ? buildTransferShareUrl(window.location.origin, selectedOfflineSession.sessionId, getTransferRouterMode()) : '';
const selectedOfflineSessionQrImageUrl = selectedOfflineSessionShareLink ? buildQrImageUrl(selectedOfflineSessionShareLink) : '';
function navigateBackToLogin() {
const nextPath = `${window.location.pathname}${window.location.search}`;
navigate(`/login?next=${encodeURIComponent(nextPath)}`);
}
function isOfflineSessionReady(sessionToCheck: TransferSessionResponse) {
return sessionToCheck.files.every((f) => f.uploaded);
}
async function loadOfflineHistory(options?: {silent?: boolean}) {
if (!isAuthenticated) return;
if (!options?.silent) setOfflineHistoryLoading(true);
setOfflineHistoryError('');
try {
const sessions = await listMyOfflineTransferSessions();
setOfflineHistory(sessions);
setSelectedOfflineSession((curr) => curr ? (sessions.find((item) => item.sessionId === curr.sessionId) ?? null) : null);
} catch (e) {
setOfflineHistoryError(e instanceof Error ? e.message : '离线快传记录加载失败');
} finally {
if (!options?.silent) setOfflineHistoryLoading(false);
}
}
function cleanupCurrentTransfer() {
if (pollTimerRef.current) { window.clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
if (dataChannelRef.current) { dataChannelRef.current.close(); dataChannelRef.current = null; }
if (peerConnectionRef.current) { peerConnectionRef.current.close(); peerConnectionRef.current = null; }
cursorRef.current = 0; sendingStartedRef.current = false; pendingRemoteCandidatesRef.current = [];
}
function resetSenderState() {
cleanupCurrentTransfer();
setSession(null); setSelectedFiles([]); setSendPhase('idle'); setSendProgress(0); setSendError('');
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
if (copiedTimerRef.current) window.clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = window.setTimeout(() => setCopied(false), 1800);
} catch { setCopied(false); }
}
function ensureReadyState(nextFiles: File[]) {
setSelectedFiles(nextFiles);
if (nextFiles.length === 0) resetSenderState();
else void bootstrapTransfer(nextFiles);
}
function appendFiles(files: FileList | File[]) {
ensureReadyState([...selectedFiles, ...Array.from(files)]);
}
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files?.length) appendFiles(e.target.files);
e.target.value = '';
}
function removeFile(idx: number) {
ensureReadyState(selectedFiles.filter((_, i) => i !== idx));
}
async function bootstrapTransfer(files: File[]) {
const bootstrapId = bootstrapIdRef.current + 1;
bootstrapIdRef.current = bootstrapId;
cleanupCurrentTransfer();
setSendError(''); setSendPhase('creating'); setSendProgress(0);
manifestRef.current = createTransferFileManifest(files);
totalBytesRef.current = 0; sentBytesRef.current = 0;
try {
const createdSession = await createTransferSession(files, transferMode);
if (bootstrapIdRef.current !== bootstrapId) return;
setSession(createdSession);
if (createdSession.mode === 'OFFLINE') {
void loadOfflineHistory({silent: true});
await uploadOfflineFiles(createdSession, files, bootstrapId);
return;
}
setSendPhase('waiting');
await setupSenderPeer(createdSession, files, bootstrapId);
} catch (e) {
if (bootstrapIdRef.current !== bootstrapId) return;
setSendPhase('error');
setSendError(e instanceof Error ? e.message : '快传会话创建失败');
}
}
async function uploadOfflineFiles(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
setSendPhase('uploading');
totalBytesRef.current = files.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; setSendProgress(0);
for (const [idx, file] of files.entries()) {
if (bootstrapIdRef.current !== bootstrapId) return;
const sessionFile = createdSession.files[idx];
if (!sessionFile?.id) throw new Error('单次传送配置异常,请重新传输。');
let lastLoaded = 0;
await uploadOfflineTransferFile(createdSession.sessionId, sessionFile.id, file, ({ loaded, total }) => {
sentBytesRef.current += (loaded - lastLoaded); lastLoaded = loaded;
if (loaded >= total) sentBytesRef.current = Math.min(totalBytesRef.current, sentBytesRef.current);
if (totalBytesRef.current > 0) setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
});
}
setSendProgress(100); setSendPhase('completed');
void loadOfflineHistory({silent: true});
}
async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
const conn = new RTCPeerConnection({ iceServers: DEFAULT_TRANSFER_ICE_SERVERS });
const channel = conn.createDataChannel('portal-transfer', { ordered: true });
peerConnectionRef.current = conn; dataChannelRef.current = channel; channel.binaryType = 'arraybuffer';
conn.onicecandidate = (e) => {
if (e.candidate) void postTransferSignal(createdSession.sessionId, 'sender', 'ice-candidate', JSON.stringify(e.candidate.toJSON()));
};
conn.onconnectionstatechange = () => {
if (conn.connectionState === 'connected') setSendPhase(cur => (cur === 'transferring' || cur === 'completed' ? cur : 'connecting'));
if (conn.connectionState === 'failed' || conn.connectionState === 'disconnected') { setSendPhase('error'); setSendError('浏览器直连失败'); }
};
channel.onopen = () => channel.send(createTransferFileManifestMessage(manifestRef.current));
channel.onmessage = (e) => {
if (typeof e.data !== 'string') return;
const msg = parseJsonPayload<{type?: string; fileIds?: string[];}>(e.data);
if (!msg || msg.type !== 'receive-request' || !Array.isArray(msg.fileIds) || sendingStartedRef.current) return;
const requestedFiles = manifestRef.current.filter((item) => msg.fileIds?.includes(item.id));
if (requestedFiles.length === 0) return;
sendingStartedRef.current = true;
totalBytesRef.current = requestedFiles.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; setSendProgress(0);
void sendSelectedFiles(channel, files, requestedFiles, bootstrapId);
};
channel.onerror = () => { setSendPhase('error'); setSendError('数据通道建立失败'); };
startSenderPolling(createdSession.sessionId, conn, bootstrapId);
const offer = await conn.createOffer();
await conn.setLocalDescription(offer);
await postTransferSignal(createdSession.sessionId, 'sender', 'offer', JSON.stringify(offer));
}
function startSenderPolling(sessionId: string, conn: RTCPeerConnection, bootstrapId: number) {
let polling = false;
pollTimerRef.current = window.setInterval(() => {
if (polling || bootstrapIdRef.current !== bootstrapId) return;
polling = true;
void pollTransferSignals(sessionId, 'sender', cursorRef.current)
.then(async (res) => {
if (bootstrapIdRef.current !== bootstrapId) return;
cursorRef.current = res.nextCursor;
for (const item of res.items) {
if (item.type === 'peer-joined') {
setSendPhase(cur => (cur === 'waiting' ? 'connecting' : cur));
continue;
}
if (item.type === 'answer' && !conn.currentRemoteDescription) {
const answer = parseJsonPayload<RTCSessionDescriptionInit>(item.payload);
if (answer) {
await conn.setRemoteDescription(answer);
pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates(conn, pendingRemoteCandidatesRef.current);
}
continue;
}
if (item.type === 'ice-candidate') {
const cand = parseJsonPayload<RTCIceCandidateInit>(item.payload);
if (cand) pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate(conn, pendingRemoteCandidatesRef.current, cand);
}
}
})
.catch(e => {
if (bootstrapIdRef.current !== bootstrapId) return;
setSendPhase('error'); setSendError(e instanceof Error ? e.message : '状态轮询失败');
})
.finally(() => polling = false);
}, SIGNAL_POLL_INTERVAL_MS);
}
async function sendSelectedFiles(channel: RTCDataChannel, files: File[], requestedFiles: TransferFileDescriptor[], bootstrapId: number) {
setSendPhase('transferring');
const filesById = new Map(files.map((f) => [createTransferFileId(f), f]));
for (const desc of requestedFiles) {
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') return;
const file = filesById.get(desc.id);
if (!file) continue;
channel.send(createTransferFileMetaMessage(desc));
for (let offset = 0; offset < file.size; offset += TRANSFER_CHUNK_SIZE) {
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') return;
const chunk = await file.slice(offset, offset + TRANSFER_CHUNK_SIZE).arrayBuffer();
await waitForTransferChannelDrain(channel);
channel.send(chunk);
sentBytesRef.current += chunk.byteLength;
if (totalBytesRef.current > 0) setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
}
channel.send(createTransferFileCompleteMessage(desc.id));
}
channel.send(createTransferCompleteMessage());
setSendProgress(100); setSendPhase('completed');
}
async function copyOfflineSessionLink(s: TransferSessionResponse) {
const sl = buildTransferShareUrl(window.location.origin, s.sessionId, getTransferRouterMode());
await navigator.clipboard.writeText(sl);
setHistoryCopiedSessionId(s.sessionId);
if (historyCopiedTimerRef.current) window.clearTimeout(historyCopiedTimerRef.current);
historyCopiedTimerRef.current = window.setTimeout(() => {
setHistoryCopiedSessionId((curr) => (curr === s.sessionId ? null : curr));
}, 1800);
}
return (
<div className="relative flex flex-col min-h-full overflow-hidden bg-[#07101D]">
<div className="pointer-events-none absolute inset-0 z-0">
<div className="absolute top-[-18%] left-[-22%] h-72 w-72 rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[100px] animate-blob" />
<div className="absolute top-[10%] right-[-18%] h-80 w-80 rounded-full bg-cyan-500 opacity-16 mix-blend-screen blur-[96px] animate-blob animation-delay-2000" />
<div className="absolute bottom-[-18%] left-[14%] h-80 w-80 rounded-full bg-purple-600 opacity-18 mix-blend-screen blur-[100px] animate-blob animation-delay-4000" />
</div>
<input type="file" multiple className="hidden" ref={fileInputRef} onChange={handleFileSelect} />
<input type="file" multiple className="hidden" ref={folderInputRef} onChange={handleFileSelect} />
{/* 顶部标题区 */}
<div className="relative z-10 overflow-hidden bg-[url('/noise.png')] px-5 pt-8 pb-4">
<div className="absolute top-[-50%] right-[-10%] h-[150%] w-[120%] rounded-full bg-[#336EFF] opacity-15 mix-blend-screen blur-[80px]" />
<div className="relative z-10 font-bold text-2xl tracking-wide flex items-center">
<Send className="mr-3 w-6 h-6 text-cyan-400" />
</div>
</div>
{allowSend && (
<div className="relative z-10 flex bg-[#0f172a] shadow-md border-b border-white/5 mx-4 mt-2 rounded-2xl overflow-hidden glass-panel shrink-0">
<button
onClick={() => setActiveTab('send')}
className={cn('flex-1 py-3.5 text-sm font-medium transition-colors relative flex items-center justify-center gap-2',
activeTab === 'send' ? 'text-white bg-blue-500/10' : 'text-slate-400')}
>
<UploadCloud className="w-4 h-4" />
</button>
<button
onClick={() => setActiveTab('receive')}
className={cn('flex-1 py-3.5 text-sm font-medium transition-colors relative flex items-center justify-center gap-2',
activeTab === 'receive' ? 'text-white bg-blue-500/10' : 'text-slate-400')}
>
<DownloadCloud className="w-4 h-4" />
</button>
</div>
)}
<div className="relative z-10 flex-1 flex flex-col p-4 min-w-0 pb-24">
{!isAuthenticated && (
<div className="mb-4 flex flex-col gap-2 rounded-xl bg-blue-500/10 px-4 py-3 text-xs text-blue-100/90 border border-blue-400/10">
<p className="leading-relaxed">线线7</p>
<Button variant="outline" size="sm" onClick={navigateBackToLogin} className="w-full bg-white/5 border-white/10 text-white mt-1">
<LogIn className="mr-2 h-3.5 w-3.5" />
</Button>
</div>
)}
<AnimatePresence mode="wait">
{activeTab === 'send' ? (
<motion.div key="send" initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 10 }} className="flex-1 flex flex-col min-w-0">
{selectedFiles.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center mt-2 glass-panel rounded-3xl p-6 border border-white/5 overflow-hidden">
<div className="w-20 h-20 rounded-full bg-blue-500/10 border-4 border-blue-400/10 shadow-xl flex items-center justify-center mb-6">
<Plus className="w-10 h-10 text-blue-400" />
</div>
<h3 className="text-xl font-bold text-white mb-2"></h3>
<p className="text-sm text-slate-400 text-center mb-8 px-4 leading-relaxed">
线P2P链接<br/>线7
</p>
{availableTransferModes.length > 1 && (
<div className="w-full max-w-sm mb-8 bg-black/20 rounded-xl p-1 flex">
{availableTransferModes.map(mode => (
<button key={mode} onClick={() => setTransferMode(mode)} className={cn("flex-1 text-xs py-2 rounded-lg transition-all", transferMode === mode ? "bg-blue-500 text-white font-medium" : "text-slate-400")}>
{mode === 'ONLINE' ? '在线(一次性)' : '离线(存7天)'}
</button>
))}
</div>
)}
<div className="flex gap-4 w-full max-w-sm">
<Button className="flex-1 rounded-xl bg-[#336EFF] hover:bg-blue-600 h-14" onClick={() => fileInputRef.current?.click()}>
<FileIcon className="mr-2 h-5 w-5" />
</Button>
<Button className="flex-1 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 text-white h-14" onClick={() => folderInputRef.current?.click()}>
<Folder className="mr-2 h-5 w-5" />
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<div className="glass-panel p-5 rounded-2xl flex flex-col items-center text-center">
<button onClick={resetSenderState} className="absolute right-6 top-6 p-2 rounded-full bg-black/40 text-slate-400"><X className="w-4 h-4"/></button>
<p className="text-xs text-slate-400 uppercase tracking-widest mb-1.5 flex items-center gap-1">
<LinkIcon className="w-3 h-3"/>
</p>
<div className="text-4xl md:text-5xl font-mono tracking-widest text-[#336EFF] font-bold mb-4 bg-blue-500/10 px-4 py-2 rounded-2xl border border-blue-400/20">{session?.pickupCode ?? '...'}</div>
{qrImageUrl && <img src={qrImageUrl} className="w-32 h-32 rounded-xl border-4 border-white/10 shadow-xl mb-4" />}
<div className="w-full">
<Button size="sm" variant="outline" className="w-full border-white/10 bg-black/40 text-slate-300" onClick={() => copyToClipboard(shareLink)} disabled={!shareLink}>
{copied ? <CheckCircle className="w-4 h-4 mr-2 text-emerald-400"/> : <Copy className="w-4 h-4 mr-2"/> }
{copied ? '链接已复制' : '复制长取件链接'}
</Button>
</div>
</div>
{/* 状态区 */}
<div className={cn("rounded-2xl p-4 flex gap-3 items-center border",
sendPhase === 'error' ? 'bg-rose-500/10 border-rose-500/20 text-rose-300' :
sendPhase === 'completed' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-300' :
'bg-[#336EFF]/10 border-blue-500/20 text-blue-300'
)}>
{sendPhase === 'completed' ? <CheckCircle className="w-6 h-6 shrink-0"/> : <Loader2 className={cn("w-6 h-6 shrink-0", sendPhase === 'error' ? 'text-rose-400' : 'animate-spin')}/>}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium leading-relaxed">{getPhaseMessage(transferMode, sendPhase, sendError)}</p>
{sendPhase !== 'error' && sendPhase !== 'completed' && <div className="mt-2 h-1.5 bg-black/40 rounded-full overflow-hidden"><div className="h-full bg-blue-500" style={{ width: `${sendProgress}%` }}/></div>}
</div>
</div>
{/* 文件列表 */}
<div className="glass-panel rounded-2xl p-2.5 max-h-[40vh] overflow-y-auto">
<p className="text-xs text-slate-500 mb-2 px-2.5 pt-2"> {selectedFiles.length} / {formatTransferSize(totalSize)}</p>
{selectedFiles.map((f, i) => (
<div key={i} className="flex px-2.5 py-2 items-center gap-3 bg-white/[0.03] rounded-xl mb-1 hover:bg-white/5 active:bg-white/10 transition-colors">
<div className="p-1.5 rounded-lg bg-black/20"><FileIcon className="w-4 h-4 text-[#336EFF]" /></div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-slate-200 truncate">{f.name}</p>
<p className="text-[10px] text-slate-500">{formatTransferSize(f.size)}</p>
</div>
<button onClick={() => removeFile(i)} className="p-2 -mr-2 text-slate-500"><Trash2 className="w-3.5 h-3.5"/></button>
</div>
))}
</div>
</div>
)}
{/* 离线区简略版 */}
{isAuthenticated && activeTab === 'send' && selectedFiles.length === 0 && (
<div className="mt-8">
<h3 className="text-sm font-semibold text-slate-200 ml-2 mb-3">线</h3>
<div className="space-y-2">
{offlineHistoryLoading && offlineHistory.length === 0 ? <p className="text-xs text-center text-slate-500 py-4">...</p> :
offlineHistory.length === 0 ? <p className="text-xs text-center text-slate-500 py-4">线</p> :
offlineHistory.map(session => (
<div key={session.sessionId} onClick={() => setSelectedOfflineSession(session)} className="glass-panel p-3 rounded-xl flex items-center justify-between hover:bg-white/5 active:bg-white/10">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-200">{session.pickupCode} <span className="text-xs ml-2 text-slate-500">{getOfflineTransferSessionLabel(session)}</span></p>
<p className="text-[10px] text-slate-500 mt-1">{isOfflineSessionReady(session) ? '可接收' : '处理中'} {session.files.length} {getOfflineTransferSessionSize(session)}</p>
</div>
<ChevronRight className="w-4 h-4 text-slate-500"/>
</div>
))}
</div>
</div>
)}
</motion.div>
) : (
<motion.div key="receive" initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -10 }} className="flex-1 flex flex-col bg-transparent">
<TransferReceive embedded />
</motion.div>
)}
</AnimatePresence>
</div>
{/* Offline History Modal Mobile */}
<AnimatePresence>
{selectedOfflineSession && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 bg-[#0f172a]/95 backdrop-blur flex flex-col p-6 items-center justify-center">
<button onClick={() => setSelectedOfflineSession(null)} className="absolute top-6 right-6 p-2 bg-white/10 rounded-full"><X className="w-5 h-5 text-white"/></button>
<h3 className="text-xs text-slate-400 tracking-[0.3em] font-medium uppercase mb-4"></h3>
<div className="text-6xl font-mono text-cyan-400 font-bold tracking-[0.1em] bg-cyan-900/20 px-6 py-4 rounded-3xl border border-cyan-500/20 mb-8 shadow-[0_0_40px_rgba(34,211,238,0.2)]">
{selectedOfflineSession.pickupCode}
</div>
{selectedOfflineSessionQrImageUrl && <img src={selectedOfflineSessionQrImageUrl} className="w-48 h-48 rounded-2xl mb-8 border-4 border-white shadow-2xl" />}
<Button className="w-full max-w-sm rounded-xl h-14 bg-[#336EFF] text-base" onClick={() => copyOfflineSessionLink(selectedOfflineSession)}>
{historyCopiedSessionId === selectedOfflineSession.sessionId ? '链接已复制' : '复制提取链接'}
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -16,6 +16,7 @@
- 2026-04-02 已统一密码策略为“至少 8 位且包含大写字母”,并补测试确认管理员改密后旧密码失效、新密码生效
- 2026-04-02 已放开未登录直达快传:登录页可直接进入快传,匿名用户可发在线快传和接收在线快传,但离线快传仍要求登录
- 2026-04-02 快传发送页已新增“我的离线快传”区域:登录用户可查看自己未过期的离线快传记录,并点开弹层重新查看取件码、二维码和分享链接
- 2026-04-02 前端主入口已按屏幕宽度自动切换桌面壳与移动壳,宽度小于 768px 时渲染 `MobileApp`
- 根目录 README 已重写为中文公开版 GitHub 风格
- VS Code 工作区已补 `.vscode/settings.json``.vscode/extensions.json``lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor
- 进行中:
@@ -40,6 +41,7 @@
| 密码策略放宽到“至少 8 位且包含大写字母” | 降低注册和管理员改密阻力,同时保留最基础的复杂度门槛 | 继续要求大小写 + 数字 + 特殊字符: 对当前站点用户而言过重,且已导致后台改密体验不一致 |
| 匿名用户仅开放在线快传,不开放离线快传 | 允许登录页直接进入快传,同时避免匿名用户占用站点持久存储 | 匿名也开放离线快传: 会增加滥用风险和存储成本 |
| 已登录用户可以在快传页回看自己的离线快传记录 | 离线快传有效期长达 7 天,用户需要在不重新上传的情况下再次查看取件码和分享链接 | 只在刚创建成功时展示一次取件信息: 用户丢失取件码后无法自助找回 |
| 前端主入口按宽度自动切换到移动壳 | 不需要单独维护 `/m` 路由,用户在小屏设备上直接进入移动端布局 | 独立 `/m` 路由: 需要额外记忆入口且与主站状态分叉 |
## 待解决问题
- [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误
@@ -79,5 +81,6 @@
- 快传接收页: `front/src/pages/TransferReceive.tsx`
- 未登录快传权限: `backend/src/main/java/com/yoyuzh/transfer/TransferController.java``backend/src/main/java/com/yoyuzh/transfer/TransferService.java`
- 离线快传历史与详情弹层: `front/src/pages/Transfer.tsx``front/src/pages/transfer-state.ts`
- 移动端入口切换: `front/src/main.tsx``front/src/MobileApp.tsx``front/src/lib/app-shell.ts`
- 管理员改密接口: `backend/src/main/java/com/yoyuzh/admin/AdminService.java`
- 前端生产 API 基址: `front/.env.production`