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