Add offline transfer history and mobile app support
This commit is contained in:
25
.playwright-cli/page-2026-04-02T08-52-19-684Z.yml
Normal file
25
.playwright-cli/page-2026-04-02T08-52-19-684Z.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
- generic [ref=e3]:
|
||||||
|
- generic [ref=e6]:
|
||||||
|
- generic [ref=e8]: "Y"
|
||||||
|
- heading "优立云盘" [level=1] [ref=e9]
|
||||||
|
- paragraph [ref=e10]: 集中管理网盘文件,跨设备快传,随时体验轻游戏。
|
||||||
|
- generic [ref=e13]:
|
||||||
|
- generic [ref=e14]:
|
||||||
|
- heading "登录" [level=3] [ref=e15]:
|
||||||
|
- img [ref=e16]
|
||||||
|
- text: 登录
|
||||||
|
- paragraph [ref=e19]: 登入您的账号以继续
|
||||||
|
- generic [ref=e21]:
|
||||||
|
- generic [ref=e22]:
|
||||||
|
- generic [ref=e23]:
|
||||||
|
- img [ref=e24]
|
||||||
|
- textbox "账号 / 用户名 / 学号" [ref=e27]
|
||||||
|
- generic [ref=e28]:
|
||||||
|
- img [ref=e29]
|
||||||
|
- textbox "••••••••" [ref=e32]
|
||||||
|
- generic [ref=e33]:
|
||||||
|
- button "进入系统" [ref=e34]
|
||||||
|
- button "直接进入快传" [ref=e35]:
|
||||||
|
- img [ref=e36]
|
||||||
|
- text: 直接进入快传
|
||||||
|
- button "还没有账号?立即注册" [ref=e40]
|
||||||
BIN
.playwright-cli/page-2026-04-02T08-53-25-621Z.png
Normal file
BIN
.playwright-cli/page-2026-04-02T08-53-25-621Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
@@ -37,6 +37,8 @@
|
|||||||
关键入口:
|
关键入口:
|
||||||
|
|
||||||
- `front/src/App.tsx`
|
- `front/src/App.tsx`
|
||||||
|
- `front/src/MobileApp.tsx`
|
||||||
|
- `front/src/main.tsx`
|
||||||
- `front/src/lib/api.ts`
|
- `front/src/lib/api.ts`
|
||||||
- `front/src/components/layout/Layout.tsx`
|
- `front/src/components/layout/Layout.tsx`
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@
|
|||||||
- `front/src/pages/Transfer.tsx`
|
- `front/src/pages/Transfer.tsx`
|
||||||
- `front/src/pages/TransferReceive.tsx`
|
- `front/src/pages/TransferReceive.tsx`
|
||||||
- `front/src/pages/FileShare.tsx`
|
- `front/src/pages/FileShare.tsx`
|
||||||
|
- `front/src/mobile-pages/*`
|
||||||
|
|
||||||
### 2.2 后端
|
### 2.2 后端
|
||||||
|
|
||||||
@@ -191,6 +194,11 @@
|
|||||||
|
|
||||||
## 4. 关键业务流程
|
## 4. 关键业务流程
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
- 前端主入口会在 `main.tsx` 按屏幕宽度选择桌面壳或移动壳
|
||||||
|
- 当前规则为:宽度小于 `768px` 时渲染 `MobileApp`,否则渲染桌面 `App`
|
||||||
|
|
||||||
### 4.1 登录流程
|
### 4.1 登录流程
|
||||||
|
|
||||||
1. 前端登录页调用 `/api/auth/login`
|
1. 前端登录页调用 `/api/auth/login`
|
||||||
|
|||||||
83
front/src/MobileApp.tsx
Normal file
83
front/src/MobileApp.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import React, { Suspense } from 'react';
|
||||||
|
import { BrowserRouter, HashRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useAuth } from '@/src/auth/AuthProvider';
|
||||||
|
import { FILE_SHARE_ROUTE_PREFIX } from '@/src/lib/file-share';
|
||||||
|
import { getTransferRouterMode, LEGACY_PUBLIC_TRANSFER_ROUTE, PUBLIC_TRANSFER_ROUTE } from '@/src/lib/transfer-links';
|
||||||
|
|
||||||
|
import { MobileLayout } from './mobile-components/MobileLayout';
|
||||||
|
import MobileLogin from './mobile-pages/MobileLogin';
|
||||||
|
import MobileOverview from './mobile-pages/MobileOverview';
|
||||||
|
import MobileFiles from './mobile-pages/MobileFiles';
|
||||||
|
import MobileTransfer from './mobile-pages/MobileTransfer';
|
||||||
|
import MobileFileShare from './mobile-pages/MobileFileShare';
|
||||||
|
|
||||||
|
function LegacyTransferRedirect() {
|
||||||
|
const location = useLocation();
|
||||||
|
return <Navigate to={`${PUBLIC_TRANSFER_ROUTE}${location.search}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileAppRoutes() {
|
||||||
|
const { ready, session } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
const isPublicTransferRoute = location.pathname === PUBLIC_TRANSFER_ROUTE || location.pathname === LEGACY_PUBLIC_TRANSFER_ROUTE;
|
||||||
|
const isPublicFileShareRoute = location.pathname.startsWith(`${FILE_SHARE_ROUTE_PREFIX}/`);
|
||||||
|
|
||||||
|
if (!ready && !isPublicTransferRoute && !isPublicFileShareRoute) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[100dvh] flex items-center justify-center bg-[#07101D] text-slate-300 flex-col gap-4">
|
||||||
|
<span className="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
||||||
|
<span className="text-sm">正在检查登录状态...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthenticated = Boolean(session?.token);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path={PUBLIC_TRANSFER_ROUTE}
|
||||||
|
element={isAuthenticated ? <MobileLayout><MobileTransfer /></MobileLayout> : <MobileTransfer />}
|
||||||
|
/>
|
||||||
|
<Route path={`${FILE_SHARE_ROUTE_PREFIX}/:token`} element={<MobileFileShare />} />
|
||||||
|
<Route path={LEGACY_PUBLIC_TRANSFER_ROUTE} element={<LegacyTransferRedirect />} />
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={isAuthenticated ? <Navigate to="/overview" replace /> : <MobileLogin />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={isAuthenticated ? <MobileLayout /> : <Navigate to="/login" replace />}
|
||||||
|
>
|
||||||
|
<Route index element={<Navigate to="/overview" replace />} />
|
||||||
|
<Route path="overview" element={<MobileOverview />} />
|
||||||
|
<Route path="files" element={<MobileFiles />} />
|
||||||
|
<Route path="games" element={<Navigate to="/overview" replace />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/games/:gameId" element={<Navigate to={isAuthenticated ? '/overview' : '/login'} replace />} />
|
||||||
|
|
||||||
|
{/* Admin dashboard is not mobile-optimized in this phase yet, redirect to overview or login */}
|
||||||
|
<Route
|
||||||
|
path="/admin/*"
|
||||||
|
element={isAuthenticated ? <Navigate to="/overview" replace /> : <Navigate to="/login" replace />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={<Navigate to={isAuthenticated ? '/overview' : '/login'} replace />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileApp() {
|
||||||
|
const Router = getTransferRouterMode() === 'hash' ? HashRouter : BrowserRouter;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<MobileAppRoutes />
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
front/src/lib/app-shell.test.ts
Normal file
10
front/src/lib/app-shell.test.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { MOBILE_APP_MAX_WIDTH, shouldUseMobileApp } from './app-shell';
|
||||||
|
|
||||||
|
test('shouldUseMobileApp enables the mobile shell below the width breakpoint', () => {
|
||||||
|
assert.equal(shouldUseMobileApp(MOBILE_APP_MAX_WIDTH - 1), true);
|
||||||
|
assert.equal(shouldUseMobileApp(MOBILE_APP_MAX_WIDTH), false);
|
||||||
|
assert.equal(shouldUseMobileApp(1280), false);
|
||||||
|
});
|
||||||
5
front/src/lib/app-shell.ts
Normal file
5
front/src/lib/app-shell.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const MOBILE_APP_MAX_WIDTH = 768;
|
||||||
|
|
||||||
|
export function shouldUseMobileApp(width: number) {
|
||||||
|
return width < MOBILE_APP_MAX_WIDTH;
|
||||||
|
}
|
||||||
@@ -1,13 +1,30 @@
|
|||||||
import {StrictMode} from 'react';
|
import {StrictMode, useEffect, useState} from 'react';
|
||||||
import {createRoot} from 'react-dom/client';
|
import {createRoot} from 'react-dom/client';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
|
import MobileApp from './MobileApp.tsx';
|
||||||
import {AuthProvider} from './auth/AuthProvider.tsx';
|
import {AuthProvider} from './auth/AuthProvider.tsx';
|
||||||
|
import {shouldUseMobileApp} from './lib/app-shell.ts';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
|
function ResponsiveApp() {
|
||||||
|
const [isMobileApp, setIsMobileApp] = useState(() => shouldUseMobileApp(window.innerWidth));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function syncAppShell() {
|
||||||
|
setIsMobileApp(shouldUseMobileApp(window.innerWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', syncAppShell);
|
||||||
|
return () => window.removeEventListener('resize', syncAppShell);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobileApp ? <MobileApp /> : <App />;
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<App />
|
<ResponsiveApp />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
11
front/src/mobile-components/MobileLayout.test.ts
Normal file
11
front/src/mobile-components/MobileLayout.test.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { getVisibleNavItems } from './MobileLayout';
|
||||||
|
|
||||||
|
test('mobile navigation hides the games entry', () => {
|
||||||
|
const visiblePaths = getVisibleNavItems(false).map((item) => item.path as string);
|
||||||
|
|
||||||
|
assert.equal(visiblePaths.includes('/games'), false);
|
||||||
|
assert.deepEqual(visiblePaths, ['/overview', '/files', '/transfer']);
|
||||||
|
});
|
||||||
424
front/src/mobile-components/MobileLayout.tsx
Normal file
424
front/src/mobile-components/MobileLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
front/src/mobile-pages/GamePlayerMobile.tsx
Normal file
51
front/src/mobile-pages/GamePlayerMobile.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { ArrowLeft, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/src/components/ui/button';
|
||||||
|
import { GAME_EXIT_PATH, isGameId, resolveGameHref } from '@/src/pages/games-links';
|
||||||
|
|
||||||
|
export default function GamePlayerMobile() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { gameId } = useParams<{ gameId: string }>();
|
||||||
|
|
||||||
|
if (!gameId || !isGameId(gameId)) {
|
||||||
|
return <Navigate to={GAME_EXIT_PATH} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameHref = resolveGameHref(gameId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-[100dvh] bg-black">
|
||||||
|
{/* 沉浸式顶部返回栏 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-gradient-to-b from-black/80 to-transparent fixed top-0 left-0 right-0 z-20">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(GAME_EXIT_PATH)}
|
||||||
|
className="bg-black/40 hover:bg-black/60 text-white rounded-full p-2 h-10 w-10 backdrop-blur-md border border-white/10"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
<div className="text-white font-bold tracking-widest uppercase text-sm drop-shadow-md">
|
||||||
|
{gameId}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.open(gameHref, '_blank', 'noopener,noreferrer')}
|
||||||
|
className="bg-black/40 hover:bg-black/60 text-white rounded-full p-2 h-10 w-10 backdrop-blur-md border border-white/10"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 沉浸式全屏播放器 */}
|
||||||
|
<div className="flex-1 w-full h-full relative z-10 pt-16">
|
||||||
|
<iframe
|
||||||
|
title={`${gameId} game`}
|
||||||
|
src={gameHref}
|
||||||
|
className="w-full h-full border-0 rounded-t-3xl shadow-[0_0_40px_rgba(51,110,255,0.15)] bg-black"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
front/src/mobile-pages/MobileFileShare.tsx
Normal file
165
front/src/mobile-pages/MobileFileShare.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { CheckCircle2, DownloadCloud, Link2, Loader2, LogIn, Save, X } from 'lucide-react';
|
||||||
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useAuth } from '@/src/auth/AuthProvider';
|
||||||
|
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
|
||||||
|
import { Button } from '@/src/components/ui/button';
|
||||||
|
import { getFileShareDetails, importSharedFile } from '@/src/lib/file-share';
|
||||||
|
import { normalizeNetdiskTargetPath } from '@/src/lib/netdisk-upload';
|
||||||
|
import type { FileMetadata, FileShareDetailsResponse } from '@/src/lib/types';
|
||||||
|
import { cn } from '@/src/lib/utils';
|
||||||
|
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
|
||||||
|
import { resolveStoredFileType } from '@/src/lib/file-type';
|
||||||
|
|
||||||
|
function formatFileSize(size: number) {
|
||||||
|
if (size <= 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const unitIndex = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
|
||||||
|
const value = size / 1024 ** unitIndex;
|
||||||
|
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileFileShare() {
|
||||||
|
const { token } = useParams();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { session } = useAuth();
|
||||||
|
|
||||||
|
const [details, setDetails] = useState<FileShareDetailsResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [path, setPath] = useState('/下载');
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [importedFile, setImportedFile] = useState<FileMetadata | null>(null);
|
||||||
|
const [pathPickerOpen, setPathPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setLoading(false); setError('分享链接无效'); return;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
setLoading(true); setError(''); setImportedFile(null);
|
||||||
|
|
||||||
|
void getFileShareDetails(token)
|
||||||
|
.then((res) => { if (active) setDetails(res); })
|
||||||
|
.catch((err) => { if (active) setError(err instanceof Error ? err.message : '无法读取分享详情'); })
|
||||||
|
.finally(() => { if (active) setLoading(false); });
|
||||||
|
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
async function handleImportToPath(nextPath: string) {
|
||||||
|
setPath(normalizeNetdiskTargetPath(nextPath));
|
||||||
|
await handleImportAtPath(nextPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportAtPath(nextPath: string) {
|
||||||
|
if (!token) return;
|
||||||
|
setImporting(true); setError('');
|
||||||
|
try {
|
||||||
|
const normalizedPath = normalizeNetdiskTargetPath(nextPath);
|
||||||
|
const savedFile = await importSharedFile(token, normalizedPath);
|
||||||
|
setPath(normalizedPath);
|
||||||
|
setImportedFile(savedFile);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '导入失败');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-[100dvh] bg-[#07101D] text-white">
|
||||||
|
{/* 顶部插画背景 */}
|
||||||
|
<div className="relative pt-12 pb-8 px-6 bg-gradient-to-b from-blue-900/30 to-[#07101D] flex flex-col items-center">
|
||||||
|
<div className="absolute top-0 right-[-10%] w-[80%] h-[150%] bg-[#336EFF] rounded-full mix-blend-screen filter blur-[80px] opacity-20" />
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#336EFF] to-cyan-400 flex items-center justify-center shadow-[0_10px_40px_rgba(51,110,255,0.3)] mb-4">
|
||||||
|
<Link2 className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight z-10 text-center">网盘提取</h1>
|
||||||
|
<p className="text-xs text-slate-400 text-center mt-2 max-w-[240px] z-10">
|
||||||
|
打开分享链接,直接导入到自己网盘
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 px-4 pb-12 flex flex-col items-center relative z-10">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
{loading ? (
|
||||||
|
<div className="glass-panel p-10 rounded-3xl flex flex-col items-center gap-4 text-slate-400">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
|
||||||
|
<span className="text-sm">读取中...</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="glass-panel p-6 rounded-3xl bg-rose-500/5 border border-rose-500/20 flex flex-col items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-rose-500/10 flex items-center justify-center">
|
||||||
|
<X className="w-6 h-6 text-rose-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-rose-300 font-medium">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : details ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="glass-panel p-5 rounded-3xl flex flex-col items-center shadow-lg border border-white/5 bg-white/[0.02]">
|
||||||
|
<div className="p-4 rounded-full bg-black/20 border border-white/5 mb-3 shadow-inner">
|
||||||
|
<FileTypeIcon type={resolveStoredFileType({ filename: details.filename, contentType: details.contentType, directory: false }).kind} size="lg" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-bold text-white text-center break-all w-full leading-tight">{details.filename}</h2>
|
||||||
|
<div className="flex items-center gap-2 mt-3 text-xs text-slate-400">
|
||||||
|
<span className="bg-white/5 px-2 py-0.5 rounded-full">{details.ownerUsername} 的分享</span>
|
||||||
|
<span>{formatFileSize(details.size)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-500 mt-2">
|
||||||
|
{new Date(details.createdAt).toLocaleDateString('zh-CN')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!session?.token ? (
|
||||||
|
<div className="glass-panel p-5 rounded-3xl bg-amber-500/5 border border-amber-400/20 text-center">
|
||||||
|
<p className="text-xs text-amber-200/90 mb-4">你需要登录才能保存他人分享的文件</p>
|
||||||
|
<Button className="w-full h-12 rounded-xl bg-gradient-to-r from-amber-500 to-orange-400 hover:from-amber-600 hover:to-orange-500 text-white shadow-lg"
|
||||||
|
onClick={() => navigate(`/login?next=${encodeURIComponent(location.pathname + location.search)}`)}>
|
||||||
|
<LogIn className="mr-2 w-4 h-4" /> 登录以保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="glass-panel p-5 rounded-3xl border border-white/5 bg-white/[0.02]">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<p className="text-xs text-slate-400">将保存至网盘目录:<span className="text-emerald-400 font-medium">{path}</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importedFile ? (
|
||||||
|
<div className="flex flex-col items-center gap-4 pt-2">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-emerald-300">保存成功!</p>
|
||||||
|
<Button className="w-full h-12 rounded-xl bg-white/10 hover:bg-white/15 text-white mt-2" onClick={() => navigate('/files')}>
|
||||||
|
进入我的网盘
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button className="w-full h-12 rounded-xl bg-[#336EFF] hover:bg-blue-600 text-white shadow-xl text-base"
|
||||||
|
disabled={importing} onClick={() => setPathPickerOpen(true)}>
|
||||||
|
{importing ? <><Loader2 className="w-5 h-5 mr-2 animate-spin"/>正在保存...</> : <><Save className="w-5 h-5 mr-2"/>一键保存到网盘</>}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NetdiskPathPickerModal
|
||||||
|
isOpen={pathPickerOpen}
|
||||||
|
title="选择保存位置"
|
||||||
|
description="选择你要把文件保存到哪个文件夹"
|
||||||
|
initialPath={path}
|
||||||
|
confirmLabel="确认保存"
|
||||||
|
onClose={() => setPathPickerOpen(false)}
|
||||||
|
onConfirm={handleImportToPath}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
609
front/src/mobile-pages/MobileFiles.tsx
Normal file
609
front/src/mobile-pages/MobileFiles.tsx
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
Folder,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
Plus,
|
||||||
|
MoreVertical,
|
||||||
|
Copy,
|
||||||
|
Share2,
|
||||||
|
X,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
FolderPlus,
|
||||||
|
ChevronLeft
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
|
||||||
|
import { Button } from '@/src/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
|
||||||
|
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
|
||||||
|
import { Input } from '@/src/components/ui/input';
|
||||||
|
import { ApiError, apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api';
|
||||||
|
import { copyFileToNetdiskPath } from '@/src/lib/file-copy';
|
||||||
|
import { moveFileToNetdiskPath } from '@/src/lib/file-move';
|
||||||
|
import { resolveStoredFileType, type FileTypeKind } from '@/src/lib/file-type';
|
||||||
|
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
|
||||||
|
import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share';
|
||||||
|
import { ellipsizeFileName } from '@/src/lib/file-name';
|
||||||
|
import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache';
|
||||||
|
import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types';
|
||||||
|
import { cn } from '@/src/lib/utils';
|
||||||
|
|
||||||
|
// Imports directly from the original pages directories
|
||||||
|
import {
|
||||||
|
buildUploadProgressSnapshot,
|
||||||
|
cancelUploadTask,
|
||||||
|
createUploadMeasurement,
|
||||||
|
createUploadTasks,
|
||||||
|
completeUploadTask,
|
||||||
|
failUploadTask,
|
||||||
|
prepareUploadTaskForCompletion,
|
||||||
|
prepareFolderUploadEntries,
|
||||||
|
prepareUploadFile,
|
||||||
|
shouldUploadEntriesSequentially,
|
||||||
|
type PendingUploadEntry,
|
||||||
|
type UploadMeasurement,
|
||||||
|
type UploadTask,
|
||||||
|
} from '@/src/pages/files-upload';
|
||||||
|
import {
|
||||||
|
registerFilesUploadTaskCanceler,
|
||||||
|
replaceFilesUploads,
|
||||||
|
setFilesUploadPanelOpen,
|
||||||
|
unregisterFilesUploadTaskCanceler,
|
||||||
|
updateFilesUploadTask,
|
||||||
|
} from '@/src/pages/files-upload-store';
|
||||||
|
import {
|
||||||
|
clearSelectionIfDeleted,
|
||||||
|
getNextAvailableName,
|
||||||
|
getActionErrorMessage,
|
||||||
|
removeUiFile,
|
||||||
|
replaceUiFile,
|
||||||
|
syncSelectedFile,
|
||||||
|
} from '@/src/pages/files-state';
|
||||||
|
import {
|
||||||
|
toDirectoryPath,
|
||||||
|
} from '@/src/pages/files-tree';
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBackendPath(pathParts: string[]) {
|
||||||
|
return toDirectoryPath(pathParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(size: number) {
|
||||||
|
if (size <= 0) return '—';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
|
||||||
|
const value = size / 1024 ** index;
|
||||||
|
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
return `${date.getMonth() + 1}-${date.getDate()} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUiFile(file: FileMetadata) {
|
||||||
|
const resolvedType = resolveStoredFileType({
|
||||||
|
filename: file.filename,
|
||||||
|
contentType: file.contentType,
|
||||||
|
directory: file.directory,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: file.id,
|
||||||
|
name: file.filename,
|
||||||
|
type: resolvedType.kind,
|
||||||
|
typeLabel: resolvedType.label,
|
||||||
|
size: file.directory ? '—' : formatFileSize(file.size),
|
||||||
|
originalSize: file.directory ? 0 : file.size,
|
||||||
|
modified: formatDateTime(file.createdAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UiFile {
|
||||||
|
id: FileMetadata['id'];
|
||||||
|
modified: string;
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
originalSize: number;
|
||||||
|
type: FileTypeKind;
|
||||||
|
typeLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NetdiskTargetAction = 'move' | 'copy';
|
||||||
|
|
||||||
|
export default function MobileFiles() {
|
||||||
|
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
|
||||||
|
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const directoryInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const uploadMeasurementsRef = useRef(new Map<string, UploadMeasurement>());
|
||||||
|
|
||||||
|
const [currentPath, setCurrentPath] = useState<string[]>(initialPath);
|
||||||
|
const currentPathRef = useRef(currentPath);
|
||||||
|
|
||||||
|
const [currentFiles, setCurrentFiles] = useState<UiFile[]>(initialCachedFiles.map(toUiFile));
|
||||||
|
const [selectedFile, setSelectedFile] = useState<UiFile | null>(null);
|
||||||
|
|
||||||
|
// Modals inside mobile action sheet
|
||||||
|
const [actionSheetOpen, setActionSheetOpen] = useState(false);
|
||||||
|
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
||||||
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
|
const [fileToRename, setFileToRename] = useState<UiFile | null>(null);
|
||||||
|
const [fileToDelete, setFileToDelete] = useState<UiFile | null>(null);
|
||||||
|
const [targetActionFile, setTargetActionFile] = useState<UiFile | null>(null);
|
||||||
|
const [targetAction, setTargetAction] = useState<NetdiskTargetAction | null>(null);
|
||||||
|
const [newFileName, setNewFileName] = useState('');
|
||||||
|
|
||||||
|
const [renameError, setRenameError] = useState('');
|
||||||
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
|
const [shareStatus, setShareStatus] = useState('');
|
||||||
|
|
||||||
|
// Floating Action Button
|
||||||
|
const [fabOpen, setFabOpen] = useState(false);
|
||||||
|
|
||||||
|
const loadCurrentPath = async (pathParts: string[]) => {
|
||||||
|
const response = await apiRequest<PageResponse<FileMetadata>>(
|
||||||
|
`/files/list?path=${encodeURIComponent(toBackendPath(pathParts))}&page=0&size=100`
|
||||||
|
);
|
||||||
|
writeCachedValue(getFilesListCacheKey(toBackendPath(pathParts)), response.items);
|
||||||
|
writeCachedValue(getFilesLastPathCacheKey(), pathParts);
|
||||||
|
setCurrentFiles(response.items.map(toUiFile));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
currentPathRef.current = currentPath;
|
||||||
|
const cachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(currentPath)));
|
||||||
|
writeCachedValue(getFilesLastPathCacheKey(), currentPath);
|
||||||
|
|
||||||
|
if (cachedFiles) {
|
||||||
|
setCurrentFiles(cachedFiles.map(toUiFile));
|
||||||
|
}
|
||||||
|
loadCurrentPath(currentPath).catch(() => {
|
||||||
|
if (!cachedFiles) setCurrentFiles([]);
|
||||||
|
});
|
||||||
|
}, [currentPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (directoryInputRef.current) {
|
||||||
|
directoryInputRef.current.setAttribute('webkitdirectory', '');
|
||||||
|
directoryInputRef.current.setAttribute('directory', '');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBreadcrumbClick = (index: number) => {
|
||||||
|
setCurrentPath(currentPath.slice(0, index + 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackClick = () => {
|
||||||
|
if (currentPath.length > 0) {
|
||||||
|
setCurrentPath(currentPath.slice(0, -1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFolderClick = (file: UiFile) => {
|
||||||
|
if (file.type === 'folder') {
|
||||||
|
setCurrentPath([...currentPath, file.name]);
|
||||||
|
} else {
|
||||||
|
openActionSheet(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openActionSheet = (file: UiFile) => {
|
||||||
|
setSelectedFile(file);
|
||||||
|
setActionSheetOpen(true);
|
||||||
|
setShareStatus('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeActionSheet = () => {
|
||||||
|
setActionSheetOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRenameModal = (file: UiFile) => {
|
||||||
|
setFileToRename(file);
|
||||||
|
setNewFileName(file.name);
|
||||||
|
setRenameError('');
|
||||||
|
setRenameModalOpen(true);
|
||||||
|
closeActionSheet();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeleteModal = (file: UiFile) => {
|
||||||
|
setFileToDelete(file);
|
||||||
|
setDeleteModalOpen(true);
|
||||||
|
closeActionSheet();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openTargetActionModal = (file: UiFile, action: NetdiskTargetAction) => {
|
||||||
|
setTargetAction(action);
|
||||||
|
setTargetActionFile(file);
|
||||||
|
closeActionSheet();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload Logic (Identical to reference)
|
||||||
|
const runUploadEntries = async (entries: PendingUploadEntry[]) => {
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
setFilesUploadPanelOpen(true);
|
||||||
|
uploadMeasurementsRef.current.clear();
|
||||||
|
|
||||||
|
const batchTasks = createUploadTasks(entries);
|
||||||
|
replaceFilesUploads(batchTasks);
|
||||||
|
|
||||||
|
const runSingleUpload = async ({file: uploadFile, pathParts: uploadPathParts}: PendingUploadEntry, uploadTask: UploadTask) => {
|
||||||
|
const uploadPath = toBackendPath(uploadPathParts);
|
||||||
|
const uploadAbortController = new AbortController();
|
||||||
|
registerFilesUploadTaskCanceler(uploadTask.id, () => uploadAbortController.abort());
|
||||||
|
uploadMeasurementsRef.current.set(uploadTask.id, createUploadMeasurement(Date.now()));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updateProgress = ({loaded, total}: {loaded: number; total: number}) => {
|
||||||
|
const snapshot = buildUploadProgressSnapshot({
|
||||||
|
loaded, total, now: Date.now(), previous: uploadMeasurementsRef.current.get(uploadTask.id),
|
||||||
|
});
|
||||||
|
uploadMeasurementsRef.current.set(uploadTask.id, snapshot.measurement);
|
||||||
|
updateFilesUploadTask(uploadTask.id, (task) => ({ ...task, progress: snapshot.progress, speed: snapshot.speed }));
|
||||||
|
};
|
||||||
|
|
||||||
|
let initiated: InitiateUploadResponse | null = null;
|
||||||
|
try {
|
||||||
|
initiated = await apiRequest<InitiateUploadResponse>('/files/upload/initiate', {
|
||||||
|
method: 'POST', body: { path: uploadPath, filename: uploadFile.name, contentType: uploadFile.type || null, size: uploadFile.size },
|
||||||
|
});
|
||||||
|
} catch (e) { if (!(e instanceof ApiError && e.status === 404)) throw e; }
|
||||||
|
|
||||||
|
let uploadedFile: FileMetadata;
|
||||||
|
if (initiated?.direct) {
|
||||||
|
try {
|
||||||
|
await apiBinaryUploadRequest(initiated.uploadUrl, { method: initiated.method, headers: initiated.headers, body: uploadFile, onProgress: updateProgress, signal: uploadAbortController.signal });
|
||||||
|
uploadedFile = await apiRequest<FileMetadata>('/files/upload/complete', { method: 'POST', signal: uploadAbortController.signal, body: { path: uploadPath, filename: uploadFile.name, storageName: initiated.storageName, contentType: uploadFile.type || null, size: uploadFile.size } });
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ApiError && error.isNetworkError)) throw error;
|
||||||
|
const formData = new FormData(); formData.append('file', uploadFile);
|
||||||
|
uploadedFile = await apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(uploadPath)}`, { body: formData, onProgress: updateProgress, signal: uploadAbortController.signal });
|
||||||
|
}
|
||||||
|
} else if (initiated) {
|
||||||
|
const formData = new FormData(); formData.append('file', uploadFile);
|
||||||
|
uploadedFile = await apiUploadRequest<FileMetadata>(initiated.uploadUrl, { body: formData, method: initiated.method, headers: initiated.headers, onProgress: updateProgress, signal: uploadAbortController.signal });
|
||||||
|
} else {
|
||||||
|
const formData = new FormData(); formData.append('file', uploadFile);
|
||||||
|
uploadedFile = await apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(uploadPath)}`, { body: formData, onProgress: updateProgress, signal: uploadAbortController.signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFilesUploadTask(uploadTask.id, (task) => prepareUploadTaskForCompletion(task));
|
||||||
|
await sleep(120);
|
||||||
|
updateFilesUploadTask(uploadTask.id, (task) => completeUploadTask(task));
|
||||||
|
return uploadedFile;
|
||||||
|
} catch (error) {
|
||||||
|
if (uploadAbortController.signal.aborted) { updateFilesUploadTask(uploadTask.id, (task) => cancelUploadTask(task)); return null; }
|
||||||
|
updateFilesUploadTask(uploadTask.id, (task) => failUploadTask(task, error instanceof Error && error.message ? error.message : '上传失败'));
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
uploadMeasurementsRef.current.delete(uploadTask.id);
|
||||||
|
unregisterFilesUploadTaskCanceler(uploadTask.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shouldUploadEntriesSequentially(entries)) {
|
||||||
|
let previousPromise = Promise.resolve<Array<FileMetadata | null>>([]);
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
previousPromise = previousPromise.then(async (prev) => {
|
||||||
|
const current = await runSingleUpload(entries[i], batchTasks[i]);
|
||||||
|
return [...prev, current];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const results = await previousPromise;
|
||||||
|
if (results.some(Boolean)) await loadCurrentPath(currentPathRef.current).catch(() => {});
|
||||||
|
} else {
|
||||||
|
const results = await Promise.all(entries.map((entry, index) => runSingleUpload(entry, batchTasks[index])));
|
||||||
|
if (results.some(Boolean)) await loadCurrentPath(currentPathRef.current).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFabOpen(false);
|
||||||
|
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
|
||||||
|
event.target.value = '';
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
const reservedNames = new Set<string>(currentFiles.map((file) => file.name));
|
||||||
|
const entries: PendingUploadEntry[] = files.map((file) => {
|
||||||
|
const preparedUpload = prepareUploadFile(file, reservedNames);
|
||||||
|
reservedNames.add(preparedUpload.file.name);
|
||||||
|
return { file: preparedUpload.file, pathParts: [...currentPath], source: 'file', noticeMessage: preparedUpload.noticeMessage };
|
||||||
|
});
|
||||||
|
await runUploadEntries(entries);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFolderChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFabOpen(false);
|
||||||
|
const files = event.target.files ? (Array.from(event.target.files) as File[]) : [];
|
||||||
|
event.target.value = '';
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
const entries = prepareFolderUploadEntries(files, [...currentPath], currentFiles.map((file) => file.name));
|
||||||
|
await runUploadEntries(entries);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateFolder = async () => {
|
||||||
|
setFabOpen(false);
|
||||||
|
const folderName = window.prompt('请输入新文件夹名称');
|
||||||
|
if (!folderName?.trim()) return;
|
||||||
|
|
||||||
|
const normalizedFolderName = folderName.trim();
|
||||||
|
const nextFolderName = getNextAvailableName(normalizedFolderName, new Set(currentFiles.filter(f => f.type === 'folder').map(f => f.name)));
|
||||||
|
if (nextFolderName !== normalizedFolderName) window.alert(`名称冲突,重命名为 ${nextFolderName}`);
|
||||||
|
|
||||||
|
const basePath = toBackendPath(currentPath).replace(/\/$/, '');
|
||||||
|
const fullPath = `${basePath}/${nextFolderName}` || '/';
|
||||||
|
|
||||||
|
await apiRequest('/files/mkdir', {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams({ path: fullPath.startsWith('/') ? fullPath : `/${fullPath}` }),
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
|
||||||
|
});
|
||||||
|
await loadCurrentPath(currentPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
if (!fileToRename || !newFileName.trim() || isRenaming) return;
|
||||||
|
setIsRenaming(true); setRenameError('');
|
||||||
|
try {
|
||||||
|
const renamedFile = await apiRequest<FileMetadata>(`/files/${fileToRename.id}/rename`, {
|
||||||
|
method: 'PATCH', body: { filename: newFileName.trim() },
|
||||||
|
});
|
||||||
|
const nextUiFile = toUiFile(renamedFile);
|
||||||
|
setCurrentFiles((prev) => replaceUiFile(prev, nextUiFile));
|
||||||
|
setSelectedFile((prev) => syncSelectedFile(prev, nextUiFile));
|
||||||
|
setRenameModalOpen(false); setFileToRename(null); setNewFileName('');
|
||||||
|
await loadCurrentPath(currentPath).catch(() => {});
|
||||||
|
} catch (error) {
|
||||||
|
setRenameError(getActionErrorMessage(error, '重命名失败'));
|
||||||
|
} finally { setIsRenaming(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!fileToDelete) return;
|
||||||
|
await apiRequest(`/files/${fileToDelete.id}`, { method: 'DELETE' });
|
||||||
|
setCurrentFiles((prev) => removeUiFile(prev, fileToDelete.id));
|
||||||
|
setSelectedFile((prev) => clearSelectionIfDeleted(prev, fileToDelete.id));
|
||||||
|
setDeleteModalOpen(false); setFileToDelete(null);
|
||||||
|
await loadCurrentPath(currentPath).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveToPath = async (path: string) => {
|
||||||
|
if (!targetActionFile || !targetAction) return;
|
||||||
|
if (targetAction === 'move') {
|
||||||
|
await moveFileToNetdiskPath(targetActionFile.id, path);
|
||||||
|
setSelectedFile((prev) => clearSelectionIfDeleted(prev, targetActionFile.id));
|
||||||
|
} else {
|
||||||
|
await copyFileToNetdiskPath(targetActionFile.id, path);
|
||||||
|
}
|
||||||
|
setTargetAction(null); setTargetActionFile(null);
|
||||||
|
await loadCurrentPath(currentPath).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (targetFile: UiFile | null = selectedFile) => {
|
||||||
|
const actFile = targetFile || selectedFile;
|
||||||
|
if (!actFile) return;
|
||||||
|
|
||||||
|
if (actFile.type === 'folder') {
|
||||||
|
const response = await apiDownload(`/files/download/${actFile.id}`);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url; link.download = `${actFile.name}.zip`; link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiRequest<DownloadUrlResponse>(`/files/download/${actFile.id}/url`);
|
||||||
|
const link = document.createElement('a'); link.href = response.url; link.download = actFile.name; link.rel = 'noreferrer'; link.target = '_blank';
|
||||||
|
link.click(); return;
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ApiError && error.status === 404)) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiDownload(`/files/download/${actFile.id}`);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a'); link.href = url; link.download = actFile.name; link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = async (targetFile: UiFile) => {
|
||||||
|
try {
|
||||||
|
const response = await createFileShareLink(targetFile.id);
|
||||||
|
const shareUrl = getCurrentFileShareUrl(response.token);
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
setShareStatus('链接已复制到剪贴板,快发送给朋友吧');
|
||||||
|
} catch {
|
||||||
|
setShareStatus(`可全选复制链接:${shareUrl}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setShareStatus(error instanceof Error ? error.message : '分享失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-[calc(100vh-3.5rem)] relative overflow-hidden text-white bg-[#07101D]">
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-0">
|
||||||
|
<div className="absolute top-[-12%] left-[-24%] h-72 w-72 rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[100px] animate-blob" />
|
||||||
|
<div className="absolute top-[22%] right-[-20%] h-80 w-80 rounded-full bg-purple-600 opacity-20 mix-blend-screen blur-[100px] animate-blob animation-delay-2000" />
|
||||||
|
<div className="absolute bottom-[-18%] left-[8%] h-80 w-80 rounded-full bg-indigo-600 opacity-20 mix-blend-screen blur-[100px] animate-blob animation-delay-4000" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="file" multiple ref={fileInputRef} className="hidden" onChange={handleFileChange} />
|
||||||
|
<input type="file" ref={directoryInputRef} className="hidden" onChange={handleFolderChange} />
|
||||||
|
|
||||||
|
{/* Top Header - Path navigation */}
|
||||||
|
<div className="flex-none px-4 py-3 bg-[#0f172a]/80 border-b border-white/5 sticky top-0 z-20 shadow-md backdrop-blur-xl">
|
||||||
|
<div className="flex flex-nowrap items-center text-sm overflow-x-auto custom-scrollbar whitespace-nowrap">
|
||||||
|
{currentPath.length > 0 && (
|
||||||
|
<button className="mr-3 p-1.5 rounded-full bg-white/5 text-slate-300 active:bg-white/10" onClick={handleBackClick}>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="text-slate-400 hover:text-white" onClick={() => handleBreadcrumbClick(-1)}>根目录</button>
|
||||||
|
{currentPath.map((pathItem, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<ChevronRight className="w-3 h-3 mx-1 text-slate-600 shrink-0" />
|
||||||
|
<button onClick={() => handleBreadcrumbClick(index)} className={cn(index === currentPath.length - 1 ? 'text-white font-medium' : 'text-slate-400', 'shrink-0')}>{pathItem}</button>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File List */}
|
||||||
|
<div className="relative z-10 flex-1 overflow-y-auto px-3 py-2 space-y-1.5 pb-24">
|
||||||
|
{currentFiles.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-48 text-slate-500">
|
||||||
|
<FolderPlus className="w-10 h-10 mb-3 opacity-20" />
|
||||||
|
<p className="text-sm">文件夹是空的</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
currentFiles.map((file) => (
|
||||||
|
<div key={file.id} className="glass-panel w-full rounded-xl p-3 flex flex-row items-center gap-3 active:bg-white/5 select-none" onClick={() => handleFolderClick(file)}>
|
||||||
|
<div className="shrink-0 p-1.5 rounded-xl bg-black/20 border border-white/5">
|
||||||
|
<FileTypeIcon type={file.type} size="md" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col justify-center">
|
||||||
|
<span className="text-sm text-white truncate w-full block">{file.name}</span>
|
||||||
|
<div className="flex items-center text-[10px] text-slate-400 mt-0.5 gap-2">
|
||||||
|
<span className={cn('px-1.5 py-0.5 rounded text-[9px] font-medium', getFileTypeTheme(file.type).badgeClassName)}>{file.typeLabel}</span>
|
||||||
|
<span>{file.modified}</span>
|
||||||
|
{file.type !== 'folder' && <span>{file.size}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{file.type !== 'folder' && (
|
||||||
|
<button className="p-2 shrink-0 text-slate-400 hover:text-white" onClick={(e) => { e.stopPropagation(); openActionSheet(file); }}>
|
||||||
|
<MoreVertical className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Action Button (FAB) + Menu */}
|
||||||
|
<div className="fixed bottom-20 right-6 z-30 flex flex-col items-end gap-3 pointer-events-none">
|
||||||
|
<AnimatePresence>
|
||||||
|
{fabOpen && (
|
||||||
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} className="flex flex-col gap-3 pointer-events-auto items-end mr-1">
|
||||||
|
<button onClick={() => { fileInputRef.current?.click(); setFabOpen(false); }} className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-blue-500 text-white shadow-lg active:scale-95 text-sm font-medium">
|
||||||
|
<Upload className="w-4 h-4"/> 上传文件
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { directoryInputRef.current?.click(); setFabOpen(false); }} className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-emerald-500 text-white shadow-lg active:scale-95 text-sm font-medium">
|
||||||
|
<FolderPlus className="w-4 h-4"/> 上传文件夹
|
||||||
|
</button>
|
||||||
|
<button onClick={handleCreateFolder} className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-purple-500 text-white shadow-lg active:scale-95 text-sm font-medium">
|
||||||
|
<Plus className="w-4 h-4"/> 新建文件夹
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<button onClick={() => setFabOpen(!fabOpen)} className={cn("pointer-events-auto flex items-center justify-center w-14 h-14 rounded-full shadow-2xl transition-transform active:scale-95", fabOpen ? "bg-[#0f172a] border border-white/10 rotate-45" : "bg-[#336EFF]")}>
|
||||||
|
<Plus className="w-6 h-6 text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FAB Backdrop */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{fabOpen && <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-20 bg-black/40 backdrop-blur-sm" onClick={() => setFabOpen(false)} />}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Action Sheet */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{actionSheetOpen && selectedFile && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end">
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={closeActionSheet} />
|
||||||
|
<motion.div initial={{ y: '100%' }} animate={{ y: 0 }} exit={{ y: '100%' }} transition={{ type: "spring", damping: 25, stiffness: 200 }} className="relative w-full bg-[#0f172a] rounded-t-3xl border-t border-white/10 pt-4 pb-8 px-4 flex flex-col z-10 glass-panel">
|
||||||
|
<div className="w-12 h-1 bg-white/20 rounded-full mx-auto mb-4" />
|
||||||
|
<div className="flex border-b border-white/10 pb-4 mb-4 gap-4 items-center px-2">
|
||||||
|
<FileTypeIcon type={selectedFile.type} size="md" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold truncate text-white">{selectedFile.name}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{selectedFile.size} • {selectedFile.modified}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2 mb-4 px-2">
|
||||||
|
<ActionButton icon={Download} label="下载" onClick={() => { handleDownload(); closeActionSheet(); }} color="text-amber-400" />
|
||||||
|
<ActionButton icon={Share2} label="分享" onClick={() => handleShare(selectedFile)} color="text-emerald-400" />
|
||||||
|
<ActionButton icon={Copy} label="复制" onClick={() => openTargetActionModal(selectedFile, 'copy')} color="text-blue-400" />
|
||||||
|
<ActionButton icon={Folder} label="移动" onClick={() => openTargetActionModal(selectedFile, 'move')} color="text-indigo-400" />
|
||||||
|
<ActionButton icon={Edit2} label="重命名" onClick={() => openRenameModal(selectedFile)} color="text-slate-300" />
|
||||||
|
<ActionButton icon={Trash2} label="删除" onClick={() => openDeleteModal(selectedFile)} color="text-red-400" />
|
||||||
|
</div>
|
||||||
|
{shareStatus && <div className="mx-2 mt-2 p-3 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-[10px] text-emerald-400 break-all">{shareStatus}</div>}
|
||||||
|
<Button variant="ghost" onClick={closeActionSheet} className="mt-4 text-slate-400 py-6 text-sm">取消</Button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Target Action Modal */}
|
||||||
|
{targetAction && (
|
||||||
|
<NetdiskPathPickerModal
|
||||||
|
isOpen
|
||||||
|
title={targetAction === 'move' ? '移动到' : '复制到'}
|
||||||
|
confirmLabel={targetAction === 'move' ? '移动至此' : '复制至此'}
|
||||||
|
onClose={() => setTargetAction(null)}
|
||||||
|
onConfirm={(path) => void handleMoveToPath(path)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rename Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{renameModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setRenameModalOpen(false)} />
|
||||||
|
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="relative w-full max-w-sm glass-panel bg-[#0f172a] border border-white/10 rounded-2xl p-5 z-10 shadow-2xl">
|
||||||
|
<h3 className="text-lg font-bold text-white mb-4">重命名文件</h3>
|
||||||
|
<Input value={newFileName} onChange={(e) => setNewFileName(e.target.value)} className="bg-black/20 text-white mb-2 h-12" placeholder="请输入新名称" />
|
||||||
|
{renameError && <p className="text-xs text-red-400 mb-4">{renameError}</p>}
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<Button variant="outline" className="flex-1 bg-white/5 border-white/10 text-white" onClick={() => setRenameModalOpen(false)}>取消</Button>
|
||||||
|
<Button className="flex-1 bg-[#336EFF] hover:bg-[#2958cc] text-white" onClick={handleRename} disabled={isRenaming}>{isRenaming ? '保存中' : '保存'}</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{deleteModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setDeleteModalOpen(false)} />
|
||||||
|
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="relative w-full max-w-sm glass-panel bg-[#0f172a] border border-white/10 rounded-2xl p-5 z-10 shadow-2xl">
|
||||||
|
<h3 className="text-lg font-bold text-white mb-2 flex items-center gap-2"><Trash2 className="text-red-400 w-5 h-5"/>确认删除</h3>
|
||||||
|
<p className="text-sm text-slate-300 mb-6 mt-3">你确实要彻底删除 <span className="text-white font-medium break-all">{fileToDelete?.name}</span> 吗?</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="outline" className="flex-1 bg-white/5 border-white/10 text-white" onClick={() => setDeleteModalOpen(false)}>取消</Button>
|
||||||
|
<Button className="flex-1 bg-red-500 text-white hover:bg-red-600" onClick={handleDelete}>删除</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({ icon: Icon, label, color, onClick }: any) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-2 p-2 hover:bg-white/5 rounded-xl transition-colors active:bg-white/10" onClick={onClick}>
|
||||||
|
<div className={cn("p-3 rounded-full bg-black/20 border border-white/5 shadow-inner", color)}>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-300">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
front/src/mobile-pages/MobileGames.tsx
Normal file
113
front/src/mobile-pages/MobileGames.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
|
||||||
|
import { Button } from '@/src/components/ui/button';
|
||||||
|
import { Gamepad2, Cat, Car, ExternalLink, Play } from 'lucide-react';
|
||||||
|
import { cn } from '@/src/lib/utils';
|
||||||
|
import { MORE_GAMES_LABEL, MORE_GAMES_URL, resolveGamePlayerPath, type GameId } from '@/src/pages/games-links';
|
||||||
|
|
||||||
|
const GAMES: Array<{
|
||||||
|
id: GameId;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: typeof Cat;
|
||||||
|
color: string;
|
||||||
|
category: 'featured';
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: 'cat',
|
||||||
|
name: 'CAT',
|
||||||
|
description: '简单的小猫升级游戏,通过点击获取经验,解锁不同形态的猫咪。',
|
||||||
|
icon: Cat,
|
||||||
|
color: 'from-orange-400 to-red-500',
|
||||||
|
category: 'featured'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'race',
|
||||||
|
name: 'RACE',
|
||||||
|
description: '赛车休闲小游戏,躲避障碍物,挑战最高分记录。',
|
||||||
|
icon: Car,
|
||||||
|
color: 'from-blue-400 to-indigo-500',
|
||||||
|
category: 'featured'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function MobileGameCard({ game, index }: { game: (typeof GAMES)[number]; index: number }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.1 }}>
|
||||||
|
<Card className="glass-panel overflow-hidden border-white/10 bg-white/[0.03] active:bg-white/[0.06] transition-colors relative">
|
||||||
|
<div className={cn("absolute top-0 left-0 h-1.5 w-full bg-gradient-to-r", game.color)} />
|
||||||
|
<CardHeader className="pb-3 px-4 pt-5">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className={cn("flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br shadow-lg", game.color)}>
|
||||||
|
<game.icon className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="rounded-md bg-white/5 border border-white/5 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-slate-400">
|
||||||
|
{game.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="mt-3 text-lg font-bold text-white">{game.name}</CardTitle>
|
||||||
|
<CardDescription className="mt-1 text-xs text-slate-400 line-clamp-2 leading-relaxed tracking-wide">
|
||||||
|
{game.description}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4">
|
||||||
|
<Button type="button" onClick={() => navigate(resolveGamePlayerPath(game.id))} className="w-full h-11 bg-white/10 hover:bg-white/20 text-white rounded-xl font-medium tracking-wide border border-white/5 active:scale-95 transition-transform" >
|
||||||
|
<Play className="h-4 w-4 mr-2" fill="currentColor" /> 开始游玩
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileGames() {
|
||||||
|
const [activeTab, setActiveTab] = useState<'featured' | 'all'>('featured');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-full bg-[#07101D] text-white">
|
||||||
|
{/* 沉浸式头部 */}
|
||||||
|
<div className="relative px-5 pt-12 pb-8 overflow-hidden bg-[url('/noise.png')]">
|
||||||
|
<div className="absolute top-[-20%] right-[-20%] w-[120%] h-[150%] bg-purple-600 rounded-full mix-blend-screen filter blur-[80px] opacity-20" />
|
||||||
|
<div className="relative z-10 space-y-3">
|
||||||
|
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-white/5 border border-white/10 font-medium w-fit">
|
||||||
|
<Gamepad2 className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
<span className="text-[10px] text-slate-300 tracking-wider uppercase">Entertainment</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">游戏大厅</h1>
|
||||||
|
<p className="text-xs text-slate-400 leading-relaxed max-w-[280px]">
|
||||||
|
随时随地体验轻量级小游戏,即点即玩。
|
||||||
|
</p>
|
||||||
|
<a href={MORE_GAMES_URL} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[11px] text-slate-300 active:text-white mt-1 underline underline-offset-2 opacity-70">
|
||||||
|
<ExternalLink className="h-3 w-3" /> {MORE_GAMES_LABEL}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 px-4 pb-10">
|
||||||
|
{/* 分类切换栏 */}
|
||||||
|
<div className="flex p-1 rounded-xl bg-white/5 border border-white/5 w-full max-w-[200px] mb-6 shadow-inner">
|
||||||
|
<button onClick={() => setActiveTab('featured')} className={cn("flex-1 py-1.5 text-xs font-medium rounded-lg transition-all", activeTab === 'featured' ? "bg-white/10 text-white shadow" : "text-slate-400")}>
|
||||||
|
精选好玩
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setActiveTab('all')} className={cn("flex-1 py-1.5 text-xs font-medium rounded-lg transition-all", activeTab === 'all' ? "bg-white/10 text-white shadow" : "text-slate-400")}>
|
||||||
|
全部内容
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 游戏卡片网格 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{GAMES.map((game, index) => (
|
||||||
|
<MobileGameCard key={game.id} game={game} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 留出底部边距给导航栏 */}
|
||||||
|
<div className="h-6" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
250
front/src/mobile-pages/MobileLogin.tsx
Normal file
250
front/src/mobile-pages/MobileLogin.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import { LogIn, User, Lock, UserPlus, Mail, ArrowLeft, Phone, Send } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
|
||||||
|
import { Button } from '@/src/components/ui/button';
|
||||||
|
import { Input } from '@/src/components/ui/input';
|
||||||
|
import { apiRequest, ApiError } from '@/src/lib/api';
|
||||||
|
import { getPostLoginRedirectPath } from '@/src/lib/file-share';
|
||||||
|
import { cn } from '@/src/lib/utils';
|
||||||
|
import { createSession, markPostLoginPending, saveStoredSession } from '@/src/lib/session';
|
||||||
|
import type { AuthResponse } from '@/src/lib/types';
|
||||||
|
import { buildRegisterPayload, validateRegisterForm } from '@/src/pages/login-state';
|
||||||
|
|
||||||
|
const DEV_LOGIN_ENABLED = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEV_LOGIN === 'true';
|
||||||
|
|
||||||
|
export default function MobileLogin() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const [registerUsername, setRegisterUsername] = useState('');
|
||||||
|
const [registerEmail, setRegisterEmail] = useState('');
|
||||||
|
const [registerPhoneNumber, setRegisterPhoneNumber] = useState('');
|
||||||
|
const [registerPassword, setRegisterPassword] = useState('');
|
||||||
|
const [registerConfirmPassword, setRegisterConfirmPassword] = useState('');
|
||||||
|
const [registerInviteCode, setRegisterInviteCode] = useState('');
|
||||||
|
|
||||||
|
function switchMode(nextIsLogin: boolean) {
|
||||||
|
setIsLogin(nextIsLogin);
|
||||||
|
setError('');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLoginSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true); setError('');
|
||||||
|
try {
|
||||||
|
let auth: AuthResponse;
|
||||||
|
try {
|
||||||
|
auth = await apiRequest<AuthResponse>('/auth/login', { method: 'POST', body: { username, password } });
|
||||||
|
} catch (requestError) {
|
||||||
|
if (DEV_LOGIN_ENABLED && username.trim() && requestError instanceof ApiError && requestError.status === 401) {
|
||||||
|
auth = await apiRequest<AuthResponse>(`/auth/dev-login?username=${encodeURIComponent(username.trim())}`, { method: 'POST' });
|
||||||
|
} else {
|
||||||
|
throw requestError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveStoredSession(createSession(auth));
|
||||||
|
markPostLoginPending();
|
||||||
|
setLoading(false);
|
||||||
|
navigate(getPostLoginRedirectPath(searchParams.get('next')));
|
||||||
|
} catch (requestError) {
|
||||||
|
setLoading(false);
|
||||||
|
setError(requestError instanceof Error ? requestError.message : '登录失败,请重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegisterSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const validationMessage = validateRegisterForm({
|
||||||
|
username: registerUsername, email: registerEmail, phoneNumber: registerPhoneNumber,
|
||||||
|
password: registerPassword, confirmPassword: registerConfirmPassword, inviteCode: registerInviteCode,
|
||||||
|
});
|
||||||
|
if (validationMessage) { setError(validationMessage); return; }
|
||||||
|
|
||||||
|
setLoading(true); setError('');
|
||||||
|
try {
|
||||||
|
const auth = await apiRequest<AuthResponse>('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: buildRegisterPayload({
|
||||||
|
username: registerUsername, email: registerEmail, phoneNumber: registerPhoneNumber,
|
||||||
|
password: registerPassword, confirmPassword: registerConfirmPassword, inviteCode: registerInviteCode,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
saveStoredSession(createSession(auth));
|
||||||
|
markPostLoginPending();
|
||||||
|
setLoading(false);
|
||||||
|
navigate(getPostLoginRedirectPath(searchParams.get('next')));
|
||||||
|
} catch (requestError) {
|
||||||
|
setLoading(false);
|
||||||
|
setError(requestError instanceof Error ? requestError.message : '注册失败,请重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[100dvh] flex flex-col bg-[#07101D] relative overflow-hidden">
|
||||||
|
{/* Background Blobs - customized for mobile sizes */}
|
||||||
|
<div className="absolute top-10 left-[-20%] w-[80%] h-[40%] bg-[#336EFF] rounded-full mix-blend-screen filter blur-[100px] opacity-25 animate-pulse" />
|
||||||
|
<div className="absolute bottom-10 right-[-10%] w-[70%] h-[40%] bg-purple-600 rounded-full mix-blend-screen filter blur-[100px] opacity-20" />
|
||||||
|
|
||||||
|
{/* Top Graphic Intro area */}
|
||||||
|
<div className="pt-16 pb-6 px-6 relative z-10 flex flex-col items-center justify-center shrink-0">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-xl shadow-[#336EFF]/20 mb-4">
|
||||||
|
<span className="text-white font-bold text-3xl leading-none">Y</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-white mb-2">优立云盘</h1>
|
||||||
|
<p className="text-sm text-slate-400 text-center max-w-[280px]">
|
||||||
|
集中管理网盘文件,跨设备快传,随时体验轻游戏。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 w-full px-4 pb-8 relative z-10">
|
||||||
|
<Card className="border-white/10 backdrop-blur-2xl bg-white/5 shadow-2xl overflow-hidden w-full max-w-sm mx-auto">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{isLogin ? (
|
||||||
|
<motion.div
|
||||||
|
key="login-form"
|
||||||
|
initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<CardHeader className="space-y-1 pb-6 pt-6 px-6">
|
||||||
|
<CardTitle className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<LogIn className="w-5 h-5 text-[#336EFF]" /> 登录
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs text-slate-400">登入您的账号以继续</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-6 pb-6">
|
||||||
|
<form onSubmit={handleLoginSubmit} className="space-y-5">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
|
<Input
|
||||||
|
type="text" placeholder="账号 / 用户名 / 学号" required value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="pl-10 h-12 bg-black/20 border-white/10 focus-visible:ring-[#336EFF] text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
|
<Input
|
||||||
|
type="password" placeholder="••••••••" required value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="pl-10 h-12 bg-black/20 border-white/10 focus-visible:ring-[#336EFF] text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-xs">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<Button type="submit" className="w-full h-12 text-base font-semibold rounded-xl" disabled={loading}>
|
||||||
|
{loading ? <span className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" /> : '进入系统'}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => navigate('/transfer')} className="w-full h-12 border-white/10 bg-white/5 text-slate-300 hover:bg-white/10 rounded-xl">
|
||||||
|
<Send className="mr-2 h-4 w-4" /> 直接进入快传
|
||||||
|
</Button>
|
||||||
|
<div className="text-center pt-2">
|
||||||
|
<button type="button" onClick={() => switchMode(false)} className="text-sm text-slate-400 hover:text-white transition-colors p-2">
|
||||||
|
还没有账号?立即注册
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="register-form"
|
||||||
|
initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<CardHeader className="space-y-1 pb-4 pt-6 px-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<UserPlus className="w-5 h-5 text-[#336EFF]" /> 注册账号
|
||||||
|
</CardTitle>
|
||||||
|
<button type="button" onClick={() => switchMode(true)} className="p-1.5 rounded-full bg-white/5 text-slate-300">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-6 pb-6 h-[50vh] overflow-y-auto custom-scrollbar">
|
||||||
|
<form onSubmit={handleRegisterSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<Input type="text" placeholder="设置您的用户名" required minLength={3} maxLength={64}
|
||||||
|
value={registerUsername} onChange={(e) => setRegisterUsername(e.target.value)}
|
||||||
|
className="pl-9 h-11 bg-black/20 border-white/10 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<Input type="email" placeholder="邮箱 (your@email.com)" required
|
||||||
|
value={registerEmail} onChange={(e) => setRegisterEmail(e.target.value)}
|
||||||
|
className="pl-9 h-11 bg-black/20 border-white/10 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<Input type="tel" placeholder="11位手机号" required
|
||||||
|
value={registerPhoneNumber} onChange={(e) => setRegisterPhoneNumber(e.target.value)}
|
||||||
|
className="pl-9 h-11 bg-black/20 border-white/10 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<Input type="password" placeholder="设置密码 (至少 8 位)" required minLength={8} maxLength={64}
|
||||||
|
value={registerPassword} onChange={(e) => setRegisterPassword(e.target.value)}
|
||||||
|
className="pl-9 h-11 bg-black/20 border-white/10 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<Input type="password" placeholder="验证密码" required minLength={8} maxLength={64}
|
||||||
|
value={registerConfirmPassword} onChange={(e) => setRegisterConfirmPassword(e.target.value)}
|
||||||
|
className="pl-9 h-11 bg-black/20 border-white/10 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<UserPlus className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<Input type="text" placeholder="邀请码" required
|
||||||
|
value={registerInviteCode} onChange={(e) => setRegisterInviteCode(e.target.value)}
|
||||||
|
className="pl-9 h-11 bg-black/20 border-white/10 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-xs">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4 pt-2 pb-4">
|
||||||
|
<Button type="submit" className="w-full h-11 text-base rounded-xl" disabled={loading}>
|
||||||
|
{loading ? '注册中...' : '创建账号'}
|
||||||
|
</Button>
|
||||||
|
<div className="text-center">
|
||||||
|
<button type="button" onClick={() => switchMode(true)} className="text-xs text-slate-400 p-2">
|
||||||
|
已有账号?返回登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
260
front/src/mobile-pages/MobileOverview.tsx
Normal file
260
front/src/mobile-pages/MobileOverview.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
|
Database,
|
||||||
|
FileText,
|
||||||
|
FolderPlus,
|
||||||
|
Mail,
|
||||||
|
Send,
|
||||||
|
Upload,
|
||||||
|
User,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { shouldLoadAvatarWithAuth } from '@/src/components/layout/account-utils';
|
||||||
|
import { Button } from '@/src/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
|
||||||
|
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
|
||||||
|
import { apiDownload, apiRequest } from '@/src/lib/api';
|
||||||
|
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
|
||||||
|
import { resolveStoredFileType } from '@/src/lib/file-type';
|
||||||
|
import { getOverviewCacheKey } from '@/src/lib/page-cache';
|
||||||
|
import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session';
|
||||||
|
import type { FileMetadata, PageResponse, UserProfile } from '@/src/lib/types';
|
||||||
|
|
||||||
|
import { getOverviewLoadErrorMessage } from '@/src/pages/overview-state';
|
||||||
|
|
||||||
|
function formatFileSize(size: number) {
|
||||||
|
if (size <= 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
|
||||||
|
const value = size / 1024 ** index;
|
||||||
|
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRecentTime(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
const diffHours = Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60));
|
||||||
|
if (diffHours < 24) return `${Math.max(diffHours, 0)}小时前`;
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileOverview() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const cachedOverview = readCachedValue<{
|
||||||
|
profile: UserProfile | null;
|
||||||
|
recentFiles: FileMetadata[];
|
||||||
|
rootFiles: FileMetadata[];
|
||||||
|
}>(getOverviewCacheKey());
|
||||||
|
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(cachedOverview?.profile ?? readStoredSession()?.user ?? null);
|
||||||
|
const [recentFiles, setRecentFiles] = useState<FileMetadata[]>(cachedOverview?.recentFiles ?? []);
|
||||||
|
const [rootFiles, setRootFiles] = useState<FileMetadata[]>(cachedOverview?.rootFiles ?? []);
|
||||||
|
const [loadingError, setLoadingError] = useState('');
|
||||||
|
const [retryToken, setRetryToken] = useState(0);
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const currentHour = new Date().getHours();
|
||||||
|
let greeting = '晚上好';
|
||||||
|
if (currentHour < 6) greeting = '凌晨好';
|
||||||
|
else if (currentHour < 12) greeting = '早上好';
|
||||||
|
else if (currentHour < 18) greeting = '下午好';
|
||||||
|
|
||||||
|
const currentTime = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
const recentWeekUploads = recentFiles.filter(f => Date.now() - new Date(f.createdAt).getTime() <= 7 * 24 * 60 * 60 * 1000).length;
|
||||||
|
const usedBytes = useMemo(() => rootFiles.filter(f => !f.directory).reduce((sum, f) => sum + f.size, 0), [rootFiles]);
|
||||||
|
const storageQuotaBytes = profile?.storageQuotaBytes && profile.storageQuotaBytes > 0 ? profile.storageQuotaBytes : 50 * 1024 * 1024 * 1024;
|
||||||
|
const usedGb = usedBytes / 1024 / 1024 / 1024;
|
||||||
|
const storagePercent = Math.min((usedBytes / storageQuotaBytes) * 100, 100);
|
||||||
|
const latestFile = recentFiles[0] ?? null;
|
||||||
|
const profileDisplayName = profile?.displayName || profile?.username || '未登录';
|
||||||
|
const profileAvatarFallback = profileDisplayName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadOverview() {
|
||||||
|
const pendingAfterLogin = hasPostLoginPending();
|
||||||
|
setLoadingError('');
|
||||||
|
try {
|
||||||
|
const [userResult, recentResult, rootResult] = await Promise.allSettled([
|
||||||
|
apiRequest<UserProfile>('/user/profile'),
|
||||||
|
apiRequest<FileMetadata[]>('/files/recent'),
|
||||||
|
apiRequest<PageResponse<FileMetadata>>('/files/list?path=%2F&page=0&size=100'),
|
||||||
|
]);
|
||||||
|
const failures = [userResult, recentResult, rootResult].filter(r => r.status === 'rejected');
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const nextProfile = userResult.status === 'fulfilled' ? userResult.value : profile;
|
||||||
|
const nextRecentFiles = recentResult.status === 'fulfilled' ? recentResult.value : recentFiles;
|
||||||
|
const nextRootFiles = rootResult.status === 'fulfilled' ? rootResult.value.items : rootFiles;
|
||||||
|
|
||||||
|
setProfile(nextProfile); setRecentFiles(nextRecentFiles); setRootFiles(nextRootFiles);
|
||||||
|
writeCachedValue(getOverviewCacheKey(), { profile: nextProfile, recentFiles: nextRecentFiles, rootFiles: nextRootFiles });
|
||||||
|
|
||||||
|
if (failures.length > 0) setLoadingError(getOverviewLoadErrorMessage(pendingAfterLogin));
|
||||||
|
else clearPostLoginPending();
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setLoadingError(getOverviewLoadErrorMessage(pendingAfterLogin));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadOverview();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [retryToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
async function loadAvatar() {
|
||||||
|
if (!profile?.avatarUrl) {
|
||||||
|
if (active) setAvatarUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!shouldLoadAvatarWithAuth(profile.avatarUrl)) {
|
||||||
|
if (active) setAvatarUrl(profile.avatarUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await apiDownload(profile.avatarUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
if (active) setAvatarUrl(objectUrl);
|
||||||
|
} catch {
|
||||||
|
if (active) setAvatarUrl(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadAvatar();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}, [profile?.avatarUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 px-4 pb-4">
|
||||||
|
{/* 头部欢迎区域 */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="glass-panel p-5 rounded-2xl relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-[#336EFF] rounded-full mix-blend-screen filter blur-[60px] opacity-20" />
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h1 className="text-xl font-bold text-white tracking-tight">
|
||||||
|
欢迎,{profile?.username ?? '访客'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#336EFF] font-medium text-xs mt-1">{currentTime} · {greeting}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{loadingError ? (
|
||||||
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
|
<Card className="border-amber-400/20 bg-amber-500/10 mb-2">
|
||||||
|
<CardContent className="flex flex-col gap-3 p-4 text-xs text-amber-100">
|
||||||
|
<span className="leading-tight">{loadingError}</span>
|
||||||
|
<Button variant="outline" size="sm" className="w-full text-amber-200 border-amber-400/30 hover:bg-amber-400/20" onClick={() => setRetryToken((v) => v + 1)}>
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 核心指标网格:移动端改为 2x2 等宽 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<MobileMetricCard title="文件总数" value={`${rootFiles.length}`} icon={FileText} delay={0.1} color="text-amber-400" bg="bg-amber-500/20" />
|
||||||
|
<MobileMetricCard title="近期上传" value={`${recentWeekUploads}`} icon={Upload} delay={0.15} color="text-emerald-400" bg="bg-emerald-500/20" />
|
||||||
|
<MobileMetricCard title="快传就绪" value={latestFile ? '使用中' : '待命'} icon={Send} delay={0.2} color="text-[#336EFF]" bg="bg-[#336EFF]/20" />
|
||||||
|
<MobileMetricCard title="存储占用" value={`${storagePercent.toFixed(1)}%`} icon={Database} delay={0.25} color="text-purple-400" bg="bg-purple-500/20" subtitle={`${formatFileSize(usedBytes)}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷操作区 */}
|
||||||
|
<Card className="glass-panel mt-2">
|
||||||
|
<CardHeader className="py-3 px-4 pb-2 border-b border-white/5">
|
||||||
|
<CardTitle className="text-sm font-medium">快捷操作</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 grid grid-cols-4 gap-2">
|
||||||
|
<QuickAction icon={Upload} label="上传" onClick={() => navigate('/files')} />
|
||||||
|
<QuickAction icon={FolderPlus} label="建目录" onClick={() => navigate('/files')} />
|
||||||
|
<QuickAction icon={Database} label="网盘" onClick={() => navigate('/files')} />
|
||||||
|
<QuickAction icon={Send} label="快传" onClick={() => navigate('/transfer')} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 近期文件 (精简版) */}
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between py-3 px-4 pb-2 border-b border-white/5">
|
||||||
|
<CardTitle className="text-sm font-medium">最近文件</CardTitle>
|
||||||
|
<button className="text-xs text-slate-400 flex items-center" onClick={() => navigate('/files')}>
|
||||||
|
全部 <ChevronRight className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{recentFiles.slice(0, 3).map((file, index) => {
|
||||||
|
const fileType = resolveStoredFileType({ filename: file.filename, contentType: file.contentType, directory: file.directory });
|
||||||
|
return (
|
||||||
|
<div key={`${file.id}-${index}`} className="flex items-center justify-between rounded-lg p-2 hover:bg-white/5" onClick={() => navigate('/files')}>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<FileTypeIcon type={fileType.kind} size="sm" />
|
||||||
|
<div className="min-w-0 truncate">
|
||||||
|
<p className="truncate text-xs font-medium text-white">{file.filename}</p>
|
||||||
|
<p className="text-[10px] text-slate-400 mt-0.5">{formatRecentTime(file.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="ml-2 shrink-0 text-[10px] font-mono text-slate-500">{(file.directory ? '目录' : formatFileSize(file.size))}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{recentFiles.length === 0 && <div className="p-3 text-center text-xs text-slate-500">暂无动态</div>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 快传推荐横幅 */}
|
||||||
|
<Card className="glass-panel overflow-hidden border-cyan-500/20 relative" onClick={() => navigate('/transfer')}>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-cyan-900/40 to-blue-900/40" />
|
||||||
|
<CardContent className="relative z-10 p-4 flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 pt-1">
|
||||||
|
<Zap className="h-4 w-4 text-cyan-400" />
|
||||||
|
<h3 className="text-sm font-semibold text-white">跨设备局域网快传</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-300">生成取件码或直接扫码,免压缩快传。</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-5 w-5 text-cyan-400 opacity-70" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 留出底部边距给导航栏 */}
|
||||||
|
<div className="h-6" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileMetricCard({ title, value, icon: Icon, delay, color, bg, subtitle }: any) {
|
||||||
|
return (
|
||||||
|
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay }}>
|
||||||
|
<div className="glass-panel p-4 rounded-xl flex flex-col justify-between h-full hover:bg-white/5 active:scale-95 transition-all">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className={`p-1.5 rounded-lg ${bg}`}><Icon className={`w-4 h-4 ${color}`} /></div>
|
||||||
|
<span className="text-xs text-slate-400">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold text-white leading-tight">{value}</div>
|
||||||
|
{subtitle && <div className="text-[10px] text-slate-500 mt-0.5">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickAction({ icon: Icon, label, onClick }: any) {
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} className="flex flex-col items-center justify-center gap-2 py-3 px-1 rounded-xl bg-white/[0.02] hover:bg-white/[0.06] active:scale-95 transition-all">
|
||||||
|
<Icon className="w-5 h-5 text-slate-300" />
|
||||||
|
<span className="text-[10px] tracking-wider text-slate-300">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
564
front/src/mobile-pages/MobileTransfer.tsx
Normal file
564
front/src/mobile-pages/MobileTransfer.tsx
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
ChevronRight,
|
||||||
|
Clock3,
|
||||||
|
Copy,
|
||||||
|
DownloadCloud,
|
||||||
|
File as FileIcon,
|
||||||
|
Folder,
|
||||||
|
FolderPlus,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Loader2,
|
||||||
|
Monitor,
|
||||||
|
Plus,
|
||||||
|
Send,
|
||||||
|
Shield,
|
||||||
|
Smartphone,
|
||||||
|
Trash2,
|
||||||
|
UploadCloud,
|
||||||
|
X,
|
||||||
|
LogIn,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useAuth } from '@/src/auth/AuthProvider';
|
||||||
|
import { Button } from '@/src/components/ui/button';
|
||||||
|
import { buildTransferShareUrl, getTransferRouterMode } from '@/src/lib/transfer-links';
|
||||||
|
import {
|
||||||
|
createTransferFileManifest,
|
||||||
|
createTransferFileManifestMessage,
|
||||||
|
createTransferCompleteMessage,
|
||||||
|
createTransferFileCompleteMessage,
|
||||||
|
createTransferFileId,
|
||||||
|
createTransferFileMetaMessage,
|
||||||
|
type TransferFileDescriptor,
|
||||||
|
SIGNAL_POLL_INTERVAL_MS,
|
||||||
|
TRANSFER_CHUNK_SIZE,
|
||||||
|
} from '@/src/lib/transfer-protocol';
|
||||||
|
import { waitForTransferChannelDrain } from '@/src/lib/transfer-runtime';
|
||||||
|
import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling';
|
||||||
|
import {
|
||||||
|
DEFAULT_TRANSFER_ICE_SERVERS,
|
||||||
|
createTransferSession,
|
||||||
|
listMyOfflineTransferSessions,
|
||||||
|
pollTransferSignals,
|
||||||
|
postTransferSignal,
|
||||||
|
uploadOfflineTransferFile,
|
||||||
|
} from '@/src/lib/transfer';
|
||||||
|
import type { TransferMode, TransferSessionResponse } from '@/src/lib/types';
|
||||||
|
import { cn } from '@/src/lib/utils';
|
||||||
|
|
||||||
|
// We reuse the state and sub-component
|
||||||
|
import {
|
||||||
|
buildQrImageUrl,
|
||||||
|
canSendTransferFiles,
|
||||||
|
getAvailableTransferModes,
|
||||||
|
formatTransferSize,
|
||||||
|
getOfflineTransferSessionLabel,
|
||||||
|
getOfflineTransferSessionSize,
|
||||||
|
getTransferModeSummary,
|
||||||
|
resolveInitialTransferTab,
|
||||||
|
} from '@/src/pages/transfer-state';
|
||||||
|
import TransferReceive from '@/src/pages/TransferReceive';
|
||||||
|
|
||||||
|
type SendPhase = 'idle' | 'creating' | 'waiting' | 'connecting' | 'uploading' | 'transferring' | 'completed' | 'error';
|
||||||
|
|
||||||
|
function parseJsonPayload<T>(payload: string): T | null {
|
||||||
|
try { return JSON.parse(payload) as T; } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPhaseMessage(mode: TransferMode, phase: SendPhase, errorMessage: string) {
|
||||||
|
if (mode === 'OFFLINE') {
|
||||||
|
switch (phase) {
|
||||||
|
case 'creating': return '正在创建离线会话...';
|
||||||
|
case 'uploading': return '文件上传中,完成后可保留7天。';
|
||||||
|
case 'completed': return '离线文件已上传完成,可以多次下载。';
|
||||||
|
case 'error': return errorMessage || '初始化失败,请重试。';
|
||||||
|
default: return '离线模式会将文件存至服务端保留7天。';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (phase) {
|
||||||
|
case 'creating': return '正在准备 P2P 连接...';
|
||||||
|
case 'waiting': return '已生成二维码,等待接收端扫码或访问链接。';
|
||||||
|
case 'connecting': return '接收端已进入,正在建立连接...';
|
||||||
|
case 'transferring': return 'P2P 直连已建立,文件发送中...';
|
||||||
|
case 'completed': return '本次文件发送完成。';
|
||||||
|
case 'error': return errorMessage || '快传初始化失败,请重试。';
|
||||||
|
default: return '文件不经过服务器,浏览器直连互传。';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileTransfer() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { session: authSession } = useAuth();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const sessionId = searchParams.get('session');
|
||||||
|
const isAuthenticated = Boolean(authSession?.token);
|
||||||
|
const allowSend = canSendTransferFiles(isAuthenticated);
|
||||||
|
const availableTransferModes = getAvailableTransferModes(isAuthenticated);
|
||||||
|
const [activeTab, setActiveTab] = useState(() => resolveInitialTransferTab(allowSend, sessionId));
|
||||||
|
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
|
const [transferMode, setTransferMode] = useState<TransferMode>('ONLINE');
|
||||||
|
const [session, setSession] = useState<TransferSessionResponse | null>(null);
|
||||||
|
const [sendPhase, setSendPhase] = useState<SendPhase>('idle');
|
||||||
|
const [sendProgress, setSendProgress] = useState(0);
|
||||||
|
const [sendError, setSendError] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [offlineHistory, setOfflineHistory] = useState<TransferSessionResponse[]>([]);
|
||||||
|
const [offlineHistoryLoading, setOfflineHistoryLoading] = useState(false);
|
||||||
|
const [offlineHistoryError, setOfflineHistoryError] = useState('');
|
||||||
|
const [selectedOfflineSession, setSelectedOfflineSession] = useState<TransferSessionResponse | null>(null);
|
||||||
|
const [historyCopiedSessionId, setHistoryCopiedSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const copiedTimerRef = useRef<number | null>(null);
|
||||||
|
const historyCopiedTimerRef = useRef<number | null>(null);
|
||||||
|
const pollTimerRef = useRef<number | null>(null);
|
||||||
|
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
|
||||||
|
const dataChannelRef = useRef<RTCDataChannel | null>(null);
|
||||||
|
const cursorRef = useRef(0);
|
||||||
|
const bootstrapIdRef = useRef(0);
|
||||||
|
const totalBytesRef = useRef(0);
|
||||||
|
const sentBytesRef = useRef(0);
|
||||||
|
const sendingStartedRef = useRef(false);
|
||||||
|
const pendingRemoteCandidatesRef = useRef<RTCIceCandidateInit[]>([]);
|
||||||
|
const manifestRef = useRef<TransferFileDescriptor[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (folderInputRef.current) {
|
||||||
|
folderInputRef.current.setAttribute('webkitdirectory', '');
|
||||||
|
folderInputRef.current.setAttribute('directory', '');
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
cleanupCurrentTransfer();
|
||||||
|
if (copiedTimerRef.current) window.clearTimeout(copiedTimerRef.current);
|
||||||
|
if (historyCopiedTimerRef.current) window.clearTimeout(historyCopiedTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allowSend || sessionId) setActiveTab('receive');
|
||||||
|
}, [allowSend, sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!availableTransferModes.includes(transferMode)) setTransferMode('ONLINE');
|
||||||
|
}, [availableTransferModes, transferMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFiles.length > 0) void bootstrapTransfer(selectedFiles);
|
||||||
|
}, [transferMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setOfflineHistory([]); setOfflineHistoryError(''); setSelectedOfflineSession(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void loadOfflineHistory();
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
const totalSize = selectedFiles.reduce((sum, file) => sum + file.size, 0);
|
||||||
|
const shareLink = session ? buildTransferShareUrl(window.location.origin, session.sessionId, getTransferRouterMode()) : '';
|
||||||
|
const qrImageUrl = shareLink ? buildQrImageUrl(shareLink) : '';
|
||||||
|
const transferModeSummary = getTransferModeSummary(transferMode);
|
||||||
|
const selectedOfflineSessionShareLink = selectedOfflineSession ? buildTransferShareUrl(window.location.origin, selectedOfflineSession.sessionId, getTransferRouterMode()) : '';
|
||||||
|
const selectedOfflineSessionQrImageUrl = selectedOfflineSessionShareLink ? buildQrImageUrl(selectedOfflineSessionShareLink) : '';
|
||||||
|
|
||||||
|
function navigateBackToLogin() {
|
||||||
|
const nextPath = `${window.location.pathname}${window.location.search}`;
|
||||||
|
navigate(`/login?next=${encodeURIComponent(nextPath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOfflineSessionReady(sessionToCheck: TransferSessionResponse) {
|
||||||
|
return sessionToCheck.files.every((f) => f.uploaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOfflineHistory(options?: {silent?: boolean}) {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
if (!options?.silent) setOfflineHistoryLoading(true);
|
||||||
|
setOfflineHistoryError('');
|
||||||
|
try {
|
||||||
|
const sessions = await listMyOfflineTransferSessions();
|
||||||
|
setOfflineHistory(sessions);
|
||||||
|
setSelectedOfflineSession((curr) => curr ? (sessions.find((item) => item.sessionId === curr.sessionId) ?? null) : null);
|
||||||
|
} catch (e) {
|
||||||
|
setOfflineHistoryError(e instanceof Error ? e.message : '离线快传记录加载失败');
|
||||||
|
} finally {
|
||||||
|
if (!options?.silent) setOfflineHistoryLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupCurrentTransfer() {
|
||||||
|
if (pollTimerRef.current) { window.clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
|
||||||
|
if (dataChannelRef.current) { dataChannelRef.current.close(); dataChannelRef.current = null; }
|
||||||
|
if (peerConnectionRef.current) { peerConnectionRef.current.close(); peerConnectionRef.current = null; }
|
||||||
|
cursorRef.current = 0; sendingStartedRef.current = false; pendingRemoteCandidatesRef.current = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSenderState() {
|
||||||
|
cleanupCurrentTransfer();
|
||||||
|
setSession(null); setSelectedFiles([]); setSendPhase('idle'); setSendProgress(0); setSendError('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(text: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
if (copiedTimerRef.current) window.clearTimeout(copiedTimerRef.current);
|
||||||
|
copiedTimerRef.current = window.setTimeout(() => setCopied(false), 1800);
|
||||||
|
} catch { setCopied(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureReadyState(nextFiles: File[]) {
|
||||||
|
setSelectedFiles(nextFiles);
|
||||||
|
if (nextFiles.length === 0) resetSenderState();
|
||||||
|
else void bootstrapTransfer(nextFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendFiles(files: FileList | File[]) {
|
||||||
|
ensureReadyState([...selectedFiles, ...Array.from(files)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
if (e.target.files?.length) appendFiles(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(idx: number) {
|
||||||
|
ensureReadyState(selectedFiles.filter((_, i) => i !== idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrapTransfer(files: File[]) {
|
||||||
|
const bootstrapId = bootstrapIdRef.current + 1;
|
||||||
|
bootstrapIdRef.current = bootstrapId;
|
||||||
|
|
||||||
|
cleanupCurrentTransfer();
|
||||||
|
setSendError(''); setSendPhase('creating'); setSendProgress(0);
|
||||||
|
manifestRef.current = createTransferFileManifest(files);
|
||||||
|
totalBytesRef.current = 0; sentBytesRef.current = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const createdSession = await createTransferSession(files, transferMode);
|
||||||
|
if (bootstrapIdRef.current !== bootstrapId) return;
|
||||||
|
|
||||||
|
setSession(createdSession);
|
||||||
|
if (createdSession.mode === 'OFFLINE') {
|
||||||
|
void loadOfflineHistory({silent: true});
|
||||||
|
await uploadOfflineFiles(createdSession, files, bootstrapId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSendPhase('waiting');
|
||||||
|
await setupSenderPeer(createdSession, files, bootstrapId);
|
||||||
|
} catch (e) {
|
||||||
|
if (bootstrapIdRef.current !== bootstrapId) return;
|
||||||
|
setSendPhase('error');
|
||||||
|
setSendError(e instanceof Error ? e.message : '快传会话创建失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadOfflineFiles(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
|
||||||
|
setSendPhase('uploading');
|
||||||
|
totalBytesRef.current = files.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; setSendProgress(0);
|
||||||
|
for (const [idx, file] of files.entries()) {
|
||||||
|
if (bootstrapIdRef.current !== bootstrapId) return;
|
||||||
|
const sessionFile = createdSession.files[idx];
|
||||||
|
if (!sessionFile?.id) throw new Error('单次传送配置异常,请重新传输。');
|
||||||
|
|
||||||
|
let lastLoaded = 0;
|
||||||
|
await uploadOfflineTransferFile(createdSession.sessionId, sessionFile.id, file, ({ loaded, total }) => {
|
||||||
|
sentBytesRef.current += (loaded - lastLoaded); lastLoaded = loaded;
|
||||||
|
if (loaded >= total) sentBytesRef.current = Math.min(totalBytesRef.current, sentBytesRef.current);
|
||||||
|
if (totalBytesRef.current > 0) setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSendProgress(100); setSendPhase('completed');
|
||||||
|
void loadOfflineHistory({silent: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
|
||||||
|
const conn = new RTCPeerConnection({ iceServers: DEFAULT_TRANSFER_ICE_SERVERS });
|
||||||
|
const channel = conn.createDataChannel('portal-transfer', { ordered: true });
|
||||||
|
peerConnectionRef.current = conn; dataChannelRef.current = channel; channel.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
conn.onicecandidate = (e) => {
|
||||||
|
if (e.candidate) void postTransferSignal(createdSession.sessionId, 'sender', 'ice-candidate', JSON.stringify(e.candidate.toJSON()));
|
||||||
|
};
|
||||||
|
|
||||||
|
conn.onconnectionstatechange = () => {
|
||||||
|
if (conn.connectionState === 'connected') setSendPhase(cur => (cur === 'transferring' || cur === 'completed' ? cur : 'connecting'));
|
||||||
|
if (conn.connectionState === 'failed' || conn.connectionState === 'disconnected') { setSendPhase('error'); setSendError('浏览器直连失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.onopen = () => channel.send(createTransferFileManifestMessage(manifestRef.current));
|
||||||
|
channel.onmessage = (e) => {
|
||||||
|
if (typeof e.data !== 'string') return;
|
||||||
|
const msg = parseJsonPayload<{type?: string; fileIds?: string[];}>(e.data);
|
||||||
|
if (!msg || msg.type !== 'receive-request' || !Array.isArray(msg.fileIds) || sendingStartedRef.current) return;
|
||||||
|
|
||||||
|
const requestedFiles = manifestRef.current.filter((item) => msg.fileIds?.includes(item.id));
|
||||||
|
if (requestedFiles.length === 0) return;
|
||||||
|
|
||||||
|
sendingStartedRef.current = true;
|
||||||
|
totalBytesRef.current = requestedFiles.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; setSendProgress(0);
|
||||||
|
void sendSelectedFiles(channel, files, requestedFiles, bootstrapId);
|
||||||
|
};
|
||||||
|
channel.onerror = () => { setSendPhase('error'); setSendError('数据通道建立失败'); };
|
||||||
|
startSenderPolling(createdSession.sessionId, conn, bootstrapId);
|
||||||
|
|
||||||
|
const offer = await conn.createOffer();
|
||||||
|
await conn.setLocalDescription(offer);
|
||||||
|
await postTransferSignal(createdSession.sessionId, 'sender', 'offer', JSON.stringify(offer));
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSenderPolling(sessionId: string, conn: RTCPeerConnection, bootstrapId: number) {
|
||||||
|
let polling = false;
|
||||||
|
pollTimerRef.current = window.setInterval(() => {
|
||||||
|
if (polling || bootstrapIdRef.current !== bootstrapId) return;
|
||||||
|
polling = true;
|
||||||
|
void pollTransferSignals(sessionId, 'sender', cursorRef.current)
|
||||||
|
.then(async (res) => {
|
||||||
|
if (bootstrapIdRef.current !== bootstrapId) return;
|
||||||
|
cursorRef.current = res.nextCursor;
|
||||||
|
for (const item of res.items) {
|
||||||
|
if (item.type === 'peer-joined') {
|
||||||
|
setSendPhase(cur => (cur === 'waiting' ? 'connecting' : cur));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.type === 'answer' && !conn.currentRemoteDescription) {
|
||||||
|
const answer = parseJsonPayload<RTCSessionDescriptionInit>(item.payload);
|
||||||
|
if (answer) {
|
||||||
|
await conn.setRemoteDescription(answer);
|
||||||
|
pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates(conn, pendingRemoteCandidatesRef.current);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.type === 'ice-candidate') {
|
||||||
|
const cand = parseJsonPayload<RTCIceCandidateInit>(item.payload);
|
||||||
|
if (cand) pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate(conn, pendingRemoteCandidatesRef.current, cand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
if (bootstrapIdRef.current !== bootstrapId) return;
|
||||||
|
setSendPhase('error'); setSendError(e instanceof Error ? e.message : '状态轮询失败');
|
||||||
|
})
|
||||||
|
.finally(() => polling = false);
|
||||||
|
}, SIGNAL_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSelectedFiles(channel: RTCDataChannel, files: File[], requestedFiles: TransferFileDescriptor[], bootstrapId: number) {
|
||||||
|
setSendPhase('transferring');
|
||||||
|
const filesById = new Map(files.map((f) => [createTransferFileId(f), f]));
|
||||||
|
|
||||||
|
for (const desc of requestedFiles) {
|
||||||
|
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') return;
|
||||||
|
const file = filesById.get(desc.id);
|
||||||
|
if (!file) continue;
|
||||||
|
|
||||||
|
channel.send(createTransferFileMetaMessage(desc));
|
||||||
|
for (let offset = 0; offset < file.size; offset += TRANSFER_CHUNK_SIZE) {
|
||||||
|
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') return;
|
||||||
|
const chunk = await file.slice(offset, offset + TRANSFER_CHUNK_SIZE).arrayBuffer();
|
||||||
|
await waitForTransferChannelDrain(channel);
|
||||||
|
channel.send(chunk);
|
||||||
|
sentBytesRef.current += chunk.byteLength;
|
||||||
|
if (totalBytesRef.current > 0) setSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100)));
|
||||||
|
}
|
||||||
|
channel.send(createTransferFileCompleteMessage(desc.id));
|
||||||
|
}
|
||||||
|
channel.send(createTransferCompleteMessage());
|
||||||
|
setSendProgress(100); setSendPhase('completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyOfflineSessionLink(s: TransferSessionResponse) {
|
||||||
|
const sl = buildTransferShareUrl(window.location.origin, s.sessionId, getTransferRouterMode());
|
||||||
|
await navigator.clipboard.writeText(sl);
|
||||||
|
setHistoryCopiedSessionId(s.sessionId);
|
||||||
|
if (historyCopiedTimerRef.current) window.clearTimeout(historyCopiedTimerRef.current);
|
||||||
|
historyCopiedTimerRef.current = window.setTimeout(() => {
|
||||||
|
setHistoryCopiedSessionId((curr) => (curr === s.sessionId ? null : curr));
|
||||||
|
}, 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col min-h-full overflow-hidden bg-[#07101D]">
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-0">
|
||||||
|
<div className="absolute top-[-18%] left-[-22%] h-72 w-72 rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[100px] animate-blob" />
|
||||||
|
<div className="absolute top-[10%] right-[-18%] h-80 w-80 rounded-full bg-cyan-500 opacity-16 mix-blend-screen blur-[96px] animate-blob animation-delay-2000" />
|
||||||
|
<div className="absolute bottom-[-18%] left-[14%] h-80 w-80 rounded-full bg-purple-600 opacity-18 mix-blend-screen blur-[100px] animate-blob animation-delay-4000" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="file" multiple className="hidden" ref={fileInputRef} onChange={handleFileSelect} />
|
||||||
|
<input type="file" multiple className="hidden" ref={folderInputRef} onChange={handleFileSelect} />
|
||||||
|
|
||||||
|
{/* 顶部标题区 */}
|
||||||
|
<div className="relative z-10 overflow-hidden bg-[url('/noise.png')] px-5 pt-8 pb-4">
|
||||||
|
<div className="absolute top-[-50%] right-[-10%] h-[150%] w-[120%] rounded-full bg-[#336EFF] opacity-15 mix-blend-screen blur-[80px]" />
|
||||||
|
<div className="relative z-10 font-bold text-2xl tracking-wide flex items-center">
|
||||||
|
<Send className="mr-3 w-6 h-6 text-cyan-400" />
|
||||||
|
快传
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allowSend && (
|
||||||
|
<div className="relative z-10 flex bg-[#0f172a] shadow-md border-b border-white/5 mx-4 mt-2 rounded-2xl overflow-hidden glass-panel shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('send')}
|
||||||
|
className={cn('flex-1 py-3.5 text-sm font-medium transition-colors relative flex items-center justify-center gap-2',
|
||||||
|
activeTab === 'send' ? 'text-white bg-blue-500/10' : 'text-slate-400')}
|
||||||
|
>
|
||||||
|
<UploadCloud className="w-4 h-4" /> 发送
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('receive')}
|
||||||
|
className={cn('flex-1 py-3.5 text-sm font-medium transition-colors relative flex items-center justify-center gap-2',
|
||||||
|
activeTab === 'receive' ? 'text-white bg-blue-500/10' : 'text-slate-400')}
|
||||||
|
>
|
||||||
|
<DownloadCloud className="w-4 h-4" /> 接收
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative z-10 flex-1 flex flex-col p-4 min-w-0 pb-24">
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<div className="mb-4 flex flex-col gap-2 rounded-xl bg-blue-500/10 px-4 py-3 text-xs text-blue-100/90 border border-blue-400/10">
|
||||||
|
<p className="leading-relaxed">无需登录仅支持在线模式。离线模式可保留文件7天,需登录后可用。</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={navigateBackToLogin} className="w-full bg-white/5 border-white/10 text-white mt-1">
|
||||||
|
<LogIn className="mr-2 h-3.5 w-3.5" /> 去登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{activeTab === 'send' ? (
|
||||||
|
<motion.div key="send" initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 10 }} className="flex-1 flex flex-col min-w-0">
|
||||||
|
|
||||||
|
{selectedFiles.length === 0 ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center mt-2 glass-panel rounded-3xl p-6 border border-white/5 overflow-hidden">
|
||||||
|
<div className="w-20 h-20 rounded-full bg-blue-500/10 border-4 border-blue-400/10 shadow-xl flex items-center justify-center mb-6">
|
||||||
|
<Plus className="w-10 h-10 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-white mb-2">选择要发送的文件</h3>
|
||||||
|
<p className="text-sm text-slate-400 text-center mb-8 px-4 leading-relaxed">
|
||||||
|
选择在线模式建立一次性P2P链接<br/>离线模式可将文件上传至服务端保存7天
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{availableTransferModes.length > 1 && (
|
||||||
|
<div className="w-full max-w-sm mb-8 bg-black/20 rounded-xl p-1 flex">
|
||||||
|
{availableTransferModes.map(mode => (
|
||||||
|
<button key={mode} onClick={() => setTransferMode(mode)} className={cn("flex-1 text-xs py-2 rounded-lg transition-all", transferMode === mode ? "bg-blue-500 text-white font-medium" : "text-slate-400")}>
|
||||||
|
{mode === 'ONLINE' ? '在线(一次性)' : '离线(存7天)'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4 w-full max-w-sm">
|
||||||
|
<Button className="flex-1 rounded-xl bg-[#336EFF] hover:bg-blue-600 h-14" onClick={() => fileInputRef.current?.click()}>
|
||||||
|
<FileIcon className="mr-2 h-5 w-5" /> 文件
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 text-white h-14" onClick={() => folderInputRef.current?.click()}>
|
||||||
|
<Folder className="mr-2 h-5 w-5" /> 文件夹
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="glass-panel p-5 rounded-2xl flex flex-col items-center text-center">
|
||||||
|
<button onClick={resetSenderState} className="absolute right-6 top-6 p-2 rounded-full bg-black/40 text-slate-400"><X className="w-4 h-4"/></button>
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest mb-1.5 flex items-center gap-1">
|
||||||
|
<LinkIcon className="w-3 h-3"/> 取件码
|
||||||
|
</p>
|
||||||
|
<div className="text-4xl md:text-5xl font-mono tracking-widest text-[#336EFF] font-bold mb-4 bg-blue-500/10 px-4 py-2 rounded-2xl border border-blue-400/20">{session?.pickupCode ?? '...'}</div>
|
||||||
|
|
||||||
|
{qrImageUrl && <img src={qrImageUrl} className="w-32 h-32 rounded-xl border-4 border-white/10 shadow-xl mb-4" />}
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<Button size="sm" variant="outline" className="w-full border-white/10 bg-black/40 text-slate-300" onClick={() => copyToClipboard(shareLink)} disabled={!shareLink}>
|
||||||
|
{copied ? <CheckCircle className="w-4 h-4 mr-2 text-emerald-400"/> : <Copy className="w-4 h-4 mr-2"/> }
|
||||||
|
{copied ? '链接已复制' : '复制长取件链接'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态区 */}
|
||||||
|
<div className={cn("rounded-2xl p-4 flex gap-3 items-center border",
|
||||||
|
sendPhase === 'error' ? 'bg-rose-500/10 border-rose-500/20 text-rose-300' :
|
||||||
|
sendPhase === 'completed' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-300' :
|
||||||
|
'bg-[#336EFF]/10 border-blue-500/20 text-blue-300'
|
||||||
|
)}>
|
||||||
|
{sendPhase === 'completed' ? <CheckCircle className="w-6 h-6 shrink-0"/> : <Loader2 className={cn("w-6 h-6 shrink-0", sendPhase === 'error' ? 'text-rose-400' : 'animate-spin')}/>}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium leading-relaxed">{getPhaseMessage(transferMode, sendPhase, sendError)}</p>
|
||||||
|
{sendPhase !== 'error' && sendPhase !== 'completed' && <div className="mt-2 h-1.5 bg-black/40 rounded-full overflow-hidden"><div className="h-full bg-blue-500" style={{ width: `${sendProgress}%` }}/></div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文件列表 */}
|
||||||
|
<div className="glass-panel rounded-2xl p-2.5 max-h-[40vh] overflow-y-auto">
|
||||||
|
<p className="text-xs text-slate-500 mb-2 px-2.5 pt-2">共 {selectedFiles.length} 项 / {formatTransferSize(totalSize)}</p>
|
||||||
|
{selectedFiles.map((f, i) => (
|
||||||
|
<div key={i} className="flex px-2.5 py-2 items-center gap-3 bg-white/[0.03] rounded-xl mb-1 hover:bg-white/5 active:bg-white/10 transition-colors">
|
||||||
|
<div className="p-1.5 rounded-lg bg-black/20"><FileIcon className="w-4 h-4 text-[#336EFF]" /></div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-slate-200 truncate">{f.name}</p>
|
||||||
|
<p className="text-[10px] text-slate-500">{formatTransferSize(f.size)}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => removeFile(i)} className="p-2 -mr-2 text-slate-500"><Trash2 className="w-3.5 h-3.5"/></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 离线区简略版 */}
|
||||||
|
{isAuthenticated && activeTab === 'send' && selectedFiles.length === 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-200 ml-2 mb-3">最近的离线快传</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{offlineHistoryLoading && offlineHistory.length === 0 ? <p className="text-xs text-center text-slate-500 py-4">加载中...</p> :
|
||||||
|
offlineHistory.length === 0 ? <p className="text-xs text-center text-slate-500 py-4">暂无离线快传记录</p> :
|
||||||
|
offlineHistory.map(session => (
|
||||||
|
<div key={session.sessionId} onClick={() => setSelectedOfflineSession(session)} className="glass-panel p-3 rounded-xl flex items-center justify-between hover:bg-white/5 active:bg-white/10">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-200">{session.pickupCode} <span className="text-xs ml-2 text-slate-500">{getOfflineTransferSessionLabel(session)}</span></p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-1">{isOfflineSessionReady(session) ? '可接收' : '处理中'} • {session.files.length}个文件 • {getOfflineTransferSessionSize(session)}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-slate-500"/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div key="receive" initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -10 }} className="flex-1 flex flex-col bg-transparent">
|
||||||
|
<TransferReceive embedded />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Offline History Modal Mobile */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedOfflineSession && (
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 bg-[#0f172a]/95 backdrop-blur flex flex-col p-6 items-center justify-center">
|
||||||
|
<button onClick={() => setSelectedOfflineSession(null)} className="absolute top-6 right-6 p-2 bg-white/10 rounded-full"><X className="w-5 h-5 text-white"/></button>
|
||||||
|
<h3 className="text-xs text-slate-400 tracking-[0.3em] font-medium uppercase mb-4">取件码</h3>
|
||||||
|
<div className="text-6xl font-mono text-cyan-400 font-bold tracking-[0.1em] bg-cyan-900/20 px-6 py-4 rounded-3xl border border-cyan-500/20 mb-8 shadow-[0_0_40px_rgba(34,211,238,0.2)]">
|
||||||
|
{selectedOfflineSession.pickupCode}
|
||||||
|
</div>
|
||||||
|
{selectedOfflineSessionQrImageUrl && <img src={selectedOfflineSessionQrImageUrl} className="w-48 h-48 rounded-2xl mb-8 border-4 border-white shadow-2xl" />}
|
||||||
|
|
||||||
|
<Button className="w-full max-w-sm rounded-xl h-14 bg-[#336EFF] text-base" onClick={() => copyOfflineSessionLink(selectedOfflineSession)}>
|
||||||
|
{historyCopiedSessionId === selectedOfflineSession.sessionId ? '链接已复制' : '复制提取链接'}
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
- 2026-04-02 已统一密码策略为“至少 8 位且包含大写字母”,并补测试确认管理员改密后旧密码失效、新密码生效
|
- 2026-04-02 已统一密码策略为“至少 8 位且包含大写字母”,并补测试确认管理员改密后旧密码失效、新密码生效
|
||||||
- 2026-04-02 已放开未登录直达快传:登录页可直接进入快传,匿名用户可发在线快传和接收在线快传,但离线快传仍要求登录
|
- 2026-04-02 已放开未登录直达快传:登录页可直接进入快传,匿名用户可发在线快传和接收在线快传,但离线快传仍要求登录
|
||||||
- 2026-04-02 快传发送页已新增“我的离线快传”区域:登录用户可查看自己未过期的离线快传记录,并点开弹层重新查看取件码、二维码和分享链接
|
- 2026-04-02 快传发送页已新增“我的离线快传”区域:登录用户可查看自己未过期的离线快传记录,并点开弹层重新查看取件码、二维码和分享链接
|
||||||
|
- 2026-04-02 前端主入口已按屏幕宽度自动切换桌面壳与移动壳,宽度小于 768px 时渲染 `MobileApp`
|
||||||
- 根目录 README 已重写为中文公开版 GitHub 风格
|
- 根目录 README 已重写为中文公开版 GitHub 风格
|
||||||
- VS Code 工作区已补 `.vscode/settings.json`、`.vscode/extensions.json`、`lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor
|
- VS Code 工作区已补 `.vscode/settings.json`、`.vscode/extensions.json`、`lombok.config`,并在 `backend/pom.xml` 显式声明了 Lombok annotation processor
|
||||||
- 进行中:
|
- 进行中:
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
| 密码策略放宽到“至少 8 位且包含大写字母” | 降低注册和管理员改密阻力,同时保留最基础的复杂度门槛 | 继续要求大小写 + 数字 + 特殊字符: 对当前站点用户而言过重,且已导致后台改密体验不一致 |
|
| 密码策略放宽到“至少 8 位且包含大写字母” | 降低注册和管理员改密阻力,同时保留最基础的复杂度门槛 | 继续要求大小写 + 数字 + 特殊字符: 对当前站点用户而言过重,且已导致后台改密体验不一致 |
|
||||||
| 匿名用户仅开放在线快传,不开放离线快传 | 允许登录页直接进入快传,同时避免匿名用户占用站点持久存储 | 匿名也开放离线快传: 会增加滥用风险和存储成本 |
|
| 匿名用户仅开放在线快传,不开放离线快传 | 允许登录页直接进入快传,同时避免匿名用户占用站点持久存储 | 匿名也开放离线快传: 会增加滥用风险和存储成本 |
|
||||||
| 已登录用户可以在快传页回看自己的离线快传记录 | 离线快传有效期长达 7 天,用户需要在不重新上传的情况下再次查看取件码和分享链接 | 只在刚创建成功时展示一次取件信息: 用户丢失取件码后无法自助找回 |
|
| 已登录用户可以在快传页回看自己的离线快传记录 | 离线快传有效期长达 7 天,用户需要在不重新上传的情况下再次查看取件码和分享链接 | 只在刚创建成功时展示一次取件信息: 用户丢失取件码后无法自助找回 |
|
||||||
|
| 前端主入口按宽度自动切换到移动壳 | 不需要单独维护 `/m` 路由,用户在小屏设备上直接进入移动端布局 | 独立 `/m` 路由: 需要额外记忆入口且与主站状态分叉 |
|
||||||
|
|
||||||
## 待解决问题
|
## 待解决问题
|
||||||
- [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误
|
- [ ] VS Code 若仍报 `final 字段未在构造器初始化` 之类错误,优先判断为 Lombok / Java Language Server 误报,而不是源码真实错误
|
||||||
@@ -79,5 +81,6 @@
|
|||||||
- 快传接收页: `front/src/pages/TransferReceive.tsx`
|
- 快传接收页: `front/src/pages/TransferReceive.tsx`
|
||||||
- 未登录快传权限: `backend/src/main/java/com/yoyuzh/transfer/TransferController.java`、`backend/src/main/java/com/yoyuzh/transfer/TransferService.java`
|
- 未登录快传权限: `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/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`
|
- 管理员改密接口: `backend/src/main/java/com/yoyuzh/admin/AdminService.java`
|
||||||
- 前端生产 API 基址: `front/.env.production`
|
- 前端生产 API 基址: `front/.env.production`
|
||||||
|
|||||||
Reference in New Issue
Block a user