diff --git a/.playwright-cli/page-2026-04-02T08-52-19-684Z.yml b/.playwright-cli/page-2026-04-02T08-52-19-684Z.yml
new file mode 100644
index 0000000..f4367ed
--- /dev/null
+++ b/.playwright-cli/page-2026-04-02T08-52-19-684Z.yml
@@ -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]
\ No newline at end of file
diff --git a/.playwright-cli/page-2026-04-02T08-53-25-621Z.png b/.playwright-cli/page-2026-04-02T08-53-25-621Z.png
new file mode 100644
index 0000000..c274c97
Binary files /dev/null and b/.playwright-cli/page-2026-04-02T08-53-25-621Z.png differ
diff --git a/docs/architecture.md b/docs/architecture.md
index e740456..3d17d19 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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`
diff --git a/front/src/MobileApp.tsx b/front/src/MobileApp.tsx
new file mode 100644
index 0000000..60a922a
--- /dev/null
+++ b/front/src/MobileApp.tsx
@@ -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 ;
+}
+
+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 (
+
+
+ 正在检查登录状态...
+
+ );
+ }
+
+ const isAuthenticated = Boolean(session?.token);
+
+ return (
+
+ : }
+ />
+ } />
+ } />
+ : }
+ />
+ : }
+ >
+ } />
+ } />
+ } />
+ } />
+
+
+ } />
+
+ {/* Admin dashboard is not mobile-optimized in this phase yet, redirect to overview or login */}
+ : }
+ />
+
+ }
+ />
+
+ );
+}
+
+export default function MobileApp() {
+ const Router = getTransferRouterMode() === 'hash' ? HashRouter : BrowserRouter;
+
+ return (
+
+
+
+ );
+}
diff --git a/front/src/lib/app-shell.test.ts b/front/src/lib/app-shell.test.ts
new file mode 100644
index 0000000..c517c9c
--- /dev/null
+++ b/front/src/lib/app-shell.test.ts
@@ -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);
+});
diff --git a/front/src/lib/app-shell.ts b/front/src/lib/app-shell.ts
new file mode 100644
index 0000000..6f3cabb
--- /dev/null
+++ b/front/src/lib/app-shell.ts
@@ -0,0 +1,5 @@
+export const MOBILE_APP_MAX_WIDTH = 768;
+
+export function shouldUseMobileApp(width: number) {
+ return width < MOBILE_APP_MAX_WIDTH;
+}
diff --git a/front/src/main.tsx b/front/src/main.tsx
index 4b899c2..f005b93 100644
--- a/front/src/main.tsx
+++ b/front/src/main.tsx
@@ -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 ? : ;
+}
+
createRoot(document.getElementById('root')!).render(
-
+
,
);
diff --git a/front/src/mobile-components/MobileLayout.test.ts b/front/src/mobile-components/MobileLayout.test.ts
new file mode 100644
index 0000000..8704b8b
--- /dev/null
+++ b/front/src/mobile-components/MobileLayout.test.ts
@@ -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']);
+});
diff --git a/front/src/mobile-components/MobileLayout.tsx b/front/src/mobile-components/MobileLayout.tsx
new file mode 100644
index 0000000..fc97ed2
--- /dev/null
+++ b/front/src/mobile-components/MobileLayout.tsx
@@ -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(null);
+
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [activeModal, setActiveModal] = useState(null);
+ const [avatarPreviewUrl, setAvatarPreviewUrl] = useState(null);
+ const [selectedAvatarFile, setSelectedAvatarFile] = useState(null);
+ const [avatarSourceUrl, setAvatarSourceUrl] = useState(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) => {
+ 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('/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(`/user/avatar/upload?storageName=${encodeURIComponent(initiated.storageName)}`, { body: formData });
+ }
+ } else {
+ const formData = new FormData();
+ formData.append('file', file);
+ await apiUploadRequest(initiated.uploadUrl, { body: formData, method: initiated.method === 'PUT' ? 'PUT' : 'POST', headers: initiated.headers });
+ }
+ const nextProfile = await apiRequest('/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('/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('/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 (
+
+ {/* Background Animated Blobs */}
+
+
+ {/* Top App Bar */}
+
+
+
+
+
+
+
+
+
+
+
+ {isDropdownOpen && (
+ <>
+ setIsDropdownOpen(false)}
+ />
+
+
+
+
+
+ {displayedAvatarUrl ?

:
{avatarFallback}
}
+
+
+
{displayName}
+
{email}
+
{roleLabel}
+
+
+
+
+
+
+ {isAdmin && (
+
+ )}
+
+
+
+ >
+ )}
+
+
+ {/* Main Content Area */}
+
+ {children ?? }
+
+
+ {/* Upload Panel (Floating above bottom bar) */}
+
+
+ {/* Bottom Navigation Bar */}
+
+
+ {/* Support Modals (Settings & Security) */}
+
+ {activeModal === 'security' && (
+
+
+
+ {/* Similar to original but vertically stacked without hover constraints */}
+
+
+ 修改密码
+
+
setCurrentPassword(e.target.value)} className="bg-black/20" />
+
setNewPassword(e.target.value)} className="bg-black/20" />
+
setConfirmPassword(e.target.value)} className="bg-black/20" />
+
+ {passwordError &&
{passwordError}
}
+ {passwordMessage &&
{passwordMessage}
}
+
+
+
+
+
+ )}
+
+ {activeModal === 'settings' && (
+
+
+
+
+
+ {displayedAvatarUrl ?

:
{avatarFallback}
}
+
更换
+
+
+
+
{displayName}
+
{roleLabel}
+
+
+
+
+
+
+ handleProfileDraftChange('displayName', e.target.value)} className="bg-black/20 mt-1" />
+
+
+
+ handleProfileDraftChange('email', e.target.value)} className="bg-black/20 mt-1" />
+
+
+
+ handleProfileDraftChange('phoneNumber', e.target.value)} className="bg-black/20 mt-1" />
+
+
+
+
+
+
+ {profileError &&
{profileError}
}
+ {profileMessage &&
{profileMessage}
}
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/front/src/mobile-pages/GamePlayerMobile.tsx b/front/src/mobile-pages/GamePlayerMobile.tsx
new file mode 100644
index 0000000..eff1f26
--- /dev/null
+++ b/front/src/mobile-pages/GamePlayerMobile.tsx
@@ -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 ;
+ }
+
+ const gameHref = resolveGameHref(gameId);
+
+ return (
+
+ {/* 沉浸式顶部返回栏 */}
+
+
+
+ {gameId}
+
+
+
+
+ {/* 沉浸式全屏播放器 */}
+
+
+
+
+ );
+}
diff --git a/front/src/mobile-pages/MobileFileShare.tsx b/front/src/mobile-pages/MobileFileShare.tsx
new file mode 100644
index 0000000..80490df
--- /dev/null
+++ b/front/src/mobile-pages/MobileFileShare.tsx
@@ -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(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [path, setPath] = useState('/下载');
+ const [importing, setImporting] = useState(false);
+ const [importedFile, setImportedFile] = useState(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 (
+
+ {/* 顶部插画背景 */}
+
+
+
+
+
+
网盘提取
+
+ 打开分享链接,直接导入到自己网盘
+
+
+
+
+
+ {loading ? (
+
+
+ 读取中...
+
+ ) : error ? (
+
+ ) : details ? (
+
+
+
+
+
+
{details.filename}
+
+ {details.ownerUsername} 的分享
+ {formatFileSize(details.size)}
+
+
+ {new Date(details.createdAt).toLocaleDateString('zh-CN')}
+
+
+
+ {!session?.token ? (
+
+
你需要登录才能保存他人分享的文件
+
+
+ ) : (
+
+
+
+ {importedFile ? (
+
+
+
+
+
保存成功!
+
+
+ ) : (
+
+ )}
+
+ )}
+
+ ) : null}
+
+
+
+
setPathPickerOpen(false)}
+ onConfirm={handleImportToPath}
+ />
+
+ );
+}
diff --git a/front/src/mobile-pages/MobileFiles.tsx b/front/src/mobile-pages/MobileFiles.tsx
new file mode 100644
index 0000000..5e584bc
--- /dev/null
+++ b/front/src/mobile-pages/MobileFiles.tsx
@@ -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(getFilesLastPathCacheKey()) ?? [];
+ const initialCachedFiles = readCachedValue(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
+
+ const fileInputRef = useRef(null);
+ const directoryInputRef = useRef(null);
+ const uploadMeasurementsRef = useRef(new Map());
+
+ const [currentPath, setCurrentPath] = useState(initialPath);
+ const currentPathRef = useRef(currentPath);
+
+ const [currentFiles, setCurrentFiles] = useState(initialCachedFiles.map(toUiFile));
+ const [selectedFile, setSelectedFile] = useState(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(null);
+ const [fileToDelete, setFileToDelete] = useState(null);
+ const [targetActionFile, setTargetActionFile] = useState(null);
+ const [targetAction, setTargetAction] = useState(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>(
+ `/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(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('/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('/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(`/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(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(`/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>([]);
+ 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) => {
+ 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(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) => {
+ 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(`/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(`/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 (
+
+
+
+
+
+
+ {/* Top Header - Path navigation */}
+
+
+ {currentPath.length > 0 && (
+
+ )}
+
+ {currentPath.map((pathItem, index) => (
+
+
+
+
+ ))}
+
+
+
+ {/* File List */}
+
+ {currentFiles.length === 0 ? (
+
+ ) : (
+ currentFiles.map((file) => (
+
handleFolderClick(file)}>
+
+
+
+
+
{file.name}
+
+ {file.typeLabel}
+ {file.modified}
+ {file.type !== 'folder' && {file.size}}
+
+
+ {file.type !== 'folder' && (
+
+ )}
+
+ ))
+ )}
+
+
+ {/* Floating Action Button (FAB) + Menu */}
+
+
+ {fabOpen && (
+
+
+
+
+
+ )}
+
+
+
+
+ {/* FAB Backdrop */}
+
+ {fabOpen && setFabOpen(false)} />}
+
+
+ {/* Action Sheet */}
+
+ {actionSheetOpen && selectedFile && (
+
+
+
+
+
+
+
+
{selectedFile.name}
+
{selectedFile.size} • {selectedFile.modified}
+
+
+
+
{ handleDownload(); closeActionSheet(); }} color="text-amber-400" />
+ handleShare(selectedFile)} color="text-emerald-400" />
+ openTargetActionModal(selectedFile, 'copy')} color="text-blue-400" />
+ openTargetActionModal(selectedFile, 'move')} color="text-indigo-400" />
+ openRenameModal(selectedFile)} color="text-slate-300" />
+ openDeleteModal(selectedFile)} color="text-red-400" />
+
+ {shareStatus && {shareStatus}
}
+
+
+
+ )}
+
+
+ {/* Target Action Modal */}
+ {targetAction && (
+
setTargetAction(null)}
+ onConfirm={(path) => void handleMoveToPath(path)}
+ />
+ )}
+
+ {/* Rename Modal */}
+
+ {renameModalOpen && (
+
+
setRenameModalOpen(false)} />
+
+ 重命名文件
+ setNewFileName(e.target.value)} className="bg-black/20 text-white mb-2 h-12" placeholder="请输入新名称" />
+ {renameError && {renameError}
}
+
+
+
+
+
+
+ )}
+
+
+ {/* Delete Modal */}
+
+ {deleteModalOpen && (
+
+
setDeleteModalOpen(false)} />
+
+ 确认删除
+ 你确实要彻底删除 {fileToDelete?.name} 吗?
+
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+function ActionButton({ icon: Icon, label, color, onClick }: any) {
+ return (
+
+ );
+}
diff --git a/front/src/mobile-pages/MobileGames.tsx b/front/src/mobile-pages/MobileGames.tsx
new file mode 100644
index 0000000..1dc6d49
--- /dev/null
+++ b/front/src/mobile-pages/MobileGames.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+ {game.category}
+
+
+ {game.name}
+
+ {game.description}
+
+
+
+
+
+
+
+ );
+}
+
+export default function MobileGames() {
+ const [activeTab, setActiveTab] = useState<'featured' | 'all'>('featured');
+
+ return (
+
+ {/* 沉浸式头部 */}
+
+
+
+ {/* 分类切换栏 */}
+
+
+
+
+
+ {/* 游戏卡片网格 */}
+
+ {GAMES.map((game, index) => (
+
+ ))}
+
+
+
+ {/* 留出底部边距给导航栏 */}
+
+
+ );
+}
diff --git a/front/src/mobile-pages/MobileLogin.tsx b/front/src/mobile-pages/MobileLogin.tsx
new file mode 100644
index 0000000..621c041
--- /dev/null
+++ b/front/src/mobile-pages/MobileLogin.tsx
@@ -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('/auth/login', { method: 'POST', body: { username, password } });
+ } catch (requestError) {
+ if (DEV_LOGIN_ENABLED && username.trim() && requestError instanceof ApiError && requestError.status === 401) {
+ auth = await apiRequest(`/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('/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 (
+
+ {/* Background Blobs - customized for mobile sizes */}
+
+
+
+ {/* Top Graphic Intro area */}
+
+
+ Y
+
+
优立云盘
+
+ 集中管理网盘文件,跨设备快传,随时体验轻游戏。
+
+
+
+
+
+
+ {isLogin ? (
+
+
+
+ 登录
+
+ 登入您的账号以继续
+
+
+
+
+
+ ) : (
+
+
+
+
+ 注册账号
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/front/src/mobile-pages/MobileOverview.tsx b/front/src/mobile-pages/MobileOverview.tsx
new file mode 100644
index 0000000..0c6eb17
--- /dev/null
+++ b/front/src/mobile-pages/MobileOverview.tsx
@@ -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(cachedOverview?.profile ?? readStoredSession()?.user ?? null);
+ const [recentFiles, setRecentFiles] = useState(cachedOverview?.recentFiles ?? []);
+ const [rootFiles, setRootFiles] = useState(cachedOverview?.rootFiles ?? []);
+ const [loadingError, setLoadingError] = useState('');
+ const [retryToken, setRetryToken] = useState(0);
+ const [avatarUrl, setAvatarUrl] = useState(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('/user/profile'),
+ apiRequest('/files/recent'),
+ apiRequest>('/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 (
+
+ {/* 头部欢迎区域 */}
+
+
+
+
+ 欢迎,{profile?.username ?? '访客'}
+
+
{currentTime} · {greeting}
+
+
+
+ {loadingError ? (
+
+
+
+ {loadingError}
+
+
+
+
+ ) : null}
+
+ {/* 核心指标网格:移动端改为 2x2 等宽 */}
+
+
+
+
+
+
+
+ {/* 快捷操作区 */}
+
+
+ 快捷操作
+
+
+ navigate('/files')} />
+ navigate('/files')} />
+ navigate('/files')} />
+ navigate('/transfer')} />
+
+
+
+ {/* 近期文件 (精简版) */}
+
+
+ 最近文件
+
+
+
+
+ {recentFiles.slice(0, 3).map((file, index) => {
+ const fileType = resolveStoredFileType({ filename: file.filename, contentType: file.contentType, directory: file.directory });
+ return (
+
navigate('/files')}>
+
+
+
+
{file.filename}
+
{formatRecentTime(file.createdAt)}
+
+
+
{(file.directory ? '目录' : formatFileSize(file.size))}
+
+ );
+ })}
+ {recentFiles.length === 0 &&
暂无动态
}
+
+
+
+
+ {/* 快传推荐横幅 */}
+
navigate('/transfer')}>
+
+
+
+
+
+
跨设备局域网快传
+
+
生成取件码或直接扫码,免压缩快传。
+
+
+
+
+
+ {/* 留出底部边距给导航栏 */}
+
+
+ );
+}
+
+function MobileMetricCard({ title, value, icon: Icon, delay, color, bg, subtitle }: any) {
+ return (
+
+
+
+
+
{value}
+ {subtitle &&
{subtitle}
}
+
+
+
+ );
+}
+
+function QuickAction({ icon: Icon, label, onClick }: any) {
+ return (
+
+ );
+}
diff --git a/front/src/mobile-pages/MobileTransfer.tsx b/front/src/mobile-pages/MobileTransfer.tsx
new file mode 100644
index 0000000..ce2aa5f
--- /dev/null
+++ b/front/src/mobile-pages/MobileTransfer.tsx
@@ -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(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([]);
+ const [transferMode, setTransferMode] = useState('ONLINE');
+ const [session, setSession] = useState(null);
+ const [sendPhase, setSendPhase] = useState('idle');
+ const [sendProgress, setSendProgress] = useState(0);
+ const [sendError, setSendError] = useState('');
+ const [copied, setCopied] = useState(false);
+ const [offlineHistory, setOfflineHistory] = useState([]);
+ const [offlineHistoryLoading, setOfflineHistoryLoading] = useState(false);
+ const [offlineHistoryError, setOfflineHistoryError] = useState('');
+ const [selectedOfflineSession, setSelectedOfflineSession] = useState(null);
+ const [historyCopiedSessionId, setHistoryCopiedSessionId] = useState(null);
+
+ const fileInputRef = useRef(null);
+ const folderInputRef = useRef(null);
+ const copiedTimerRef = useRef(null);
+ const historyCopiedTimerRef = useRef(null);
+ const pollTimerRef = useRef(null);
+ const peerConnectionRef = useRef(null);
+ const dataChannelRef = useRef(null);
+ const cursorRef = useRef(0);
+ const bootstrapIdRef = useRef(0);
+ const totalBytesRef = useRef(0);
+ const sentBytesRef = useRef(0);
+ const sendingStartedRef = useRef(false);
+ const pendingRemoteCandidatesRef = useRef([]);
+ const manifestRef = useRef([]);
+
+ 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) {
+ 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(item.payload);
+ if (answer) {
+ await conn.setRemoteDescription(answer);
+ pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates(conn, pendingRemoteCandidatesRef.current);
+ }
+ continue;
+ }
+ if (item.type === 'ice-candidate') {
+ const cand = parseJsonPayload(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 (
+
+
+
+
+
+
+ {/* 顶部标题区 */}
+
+
+ {allowSend && (
+
+
+
+
+ )}
+
+
+ {!isAuthenticated && (
+
+
无需登录仅支持在线模式。离线模式可保留文件7天,需登录后可用。
+
+
+ )}
+
+
+ {activeTab === 'send' ? (
+
+
+ {selectedFiles.length === 0 ? (
+
+
+
选择要发送的文件
+
+ 选择在线模式建立一次性P2P链接
离线模式可将文件上传至服务端保存7天
+
+
+ {availableTransferModes.length > 1 && (
+
+ {availableTransferModes.map(mode => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ ) : (
+
+
+
+
+ 取件码
+
+
{session?.pickupCode ?? '...'}
+
+ {qrImageUrl &&

}
+
+
+
+
+
+
+ {/* 状态区 */}
+
+ {sendPhase === 'completed' ?
:
}
+
+
{getPhaseMessage(transferMode, sendPhase, sendError)}
+ {sendPhase !== 'error' && sendPhase !== 'completed' &&
}
+
+
+
+ {/* 文件列表 */}
+
+
共 {selectedFiles.length} 项 / {formatTransferSize(totalSize)}
+ {selectedFiles.map((f, i) => (
+
+
+
+
{f.name}
+
{formatTransferSize(f.size)}
+
+
+
+ ))}
+
+
+ )}
+
+ {/* 离线区简略版 */}
+ {isAuthenticated && activeTab === 'send' && selectedFiles.length === 0 && (
+
+
最近的离线快传
+
+ {offlineHistoryLoading && offlineHistory.length === 0 ?
加载中...
:
+ offlineHistory.length === 0 ?
暂无离线快传记录
:
+ offlineHistory.map(session => (
+
setSelectedOfflineSession(session)} className="glass-panel p-3 rounded-xl flex items-center justify-between hover:bg-white/5 active:bg-white/10">
+
+
{session.pickupCode} {getOfflineTransferSessionLabel(session)}
+
{isOfflineSessionReady(session) ? '可接收' : '处理中'} • {session.files.length}个文件 • {getOfflineTransferSessionSize(session)}
+
+
+
+ ))}
+
+
+ )}
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {/* Offline History Modal Mobile */}
+
+ {selectedOfflineSession && (
+
+
+ 取件码
+
+ {selectedOfflineSession.pickupCode}
+
+ {selectedOfflineSessionQrImageUrl &&
}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/memory.md b/memory.md
index ed09248..53a47ea 100644
--- a/memory.md
+++ b/memory.md
@@ -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`