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 */} +
+
+
+
+ Y +
+ 优立云盘 +
+ +
+ +
+
+
+ + + {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}

} +
+ +
+
手机绑定
+
{phoneNumber}
+
+
+
+ )} + + {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" /> +
+
+ +