实现快传,完善快传和网盘的功能,实现文件的互传等一系列功能

This commit is contained in:
yoyuzh
2026-03-20 14:16:18 +08:00
parent 944ab6dbf8
commit 43358e29d7
109 changed files with 5237 additions and 2465 deletions

View File

@@ -1,5 +1,5 @@
{
"name": "Personal Portal",
"description": "A unified personal portal for managing files, school schedules, grades, and games with a glassmorphism design.",
"description": "A unified personal portal for managing files, fast transfer, and games with a glassmorphism design.",
"requestFramePermissions": []
}

View File

@@ -1,19 +1,34 @@
import React, { Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { BrowserRouter, HashRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Layout } from './components/layout/Layout';
import { useAuth } from './auth/AuthProvider';
import Login from './pages/Login';
import Overview from './pages/Overview';
import Files from './pages/Files';
import School from './pages/School';
import Transfer from './pages/Transfer';
import FileShare from './pages/FileShare';
import Games from './pages/Games';
import { FILE_SHARE_ROUTE_PREFIX } from './lib/file-share';
import {
getTransferRouterMode,
LEGACY_PUBLIC_TRANSFER_ROUTE,
PUBLIC_TRANSFER_ROUTE,
} from './lib/transfer-links';
const PortalAdminApp = React.lazy(() => import('./admin/AdminApp'));
function LegacyTransferRedirect() {
const location = useLocation();
return <Navigate to={`${PUBLIC_TRANSFER_ROUTE}${location.search}`} replace />;
}
function AppRoutes() {
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) {
if (!ready && !isPublicTransferRoute && !isPublicFileShareRoute) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#07101D] text-slate-300">
...
@@ -25,6 +40,12 @@ function AppRoutes() {
return (
<Routes>
<Route
path={PUBLIC_TRANSFER_ROUTE}
element={isAuthenticated ? <Layout><Transfer /></Layout> : <Transfer />}
/>
<Route path={`${FILE_SHARE_ROUTE_PREFIX}/:token`} element={<FileShare />} />
<Route path={LEGACY_PUBLIC_TRANSFER_ROUTE} element={<LegacyTransferRedirect />} />
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/overview" replace /> : <Login />}
@@ -36,7 +57,6 @@ function AppRoutes() {
<Route index element={<Navigate to="/overview" replace />} />
<Route path="overview" element={<Overview />} />
<Route path="files" element={<Files />} />
<Route path="school" element={<School />} />
<Route path="games" element={<Games />} />
</Route>
<Route
@@ -66,9 +86,11 @@ function AppRoutes() {
}
export default function App() {
const Router = getTransferRouterMode() === 'hash' ? HashRouter : BrowserRouter;
return (
<BrowserRouter>
<Router>
<AppRoutes />
</BrowserRouter>
</Router>
);
}

View File

@@ -1,6 +1,5 @@
import FolderOutlined from '@mui/icons-material/FolderOutlined';
import GroupsOutlined from '@mui/icons-material/GroupsOutlined';
import SchoolOutlined from '@mui/icons-material/SchoolOutlined';
import { Admin, Resource } from 'react-admin';
import { portalAdminAuthProvider } from './auth-provider';
@@ -8,7 +7,6 @@ import { portalAdminDataProvider } from './data-provider';
import { PortalAdminDashboard } from './dashboard';
import { PortalAdminFilesList } from './files-list';
import { PortalAdminUsersList } from './users-list';
import { PortalAdminSchoolSnapshotsList } from './school-snapshots-list';
export default function PortalAdminApp() {
return (
@@ -35,13 +33,6 @@ export default function PortalAdminApp() {
options={{ label: '文件资源' }}
recordRepresentation="filename"
/>
<Resource
name="schoolSnapshots"
icon={SchoolOutlined}
list={PortalAdminSchoolSnapshotsList}
options={{ label: '教务缓存' }}
recordRepresentation="username"
/>
</Admin>
);
}

View File

@@ -17,12 +17,12 @@ const DASHBOARD_ITEMS = [
},
{
title: '用户管理',
description: '已接入 /api/admin/users可查看用户、邮箱与最近教务缓存标记。',
description: '已接入 /api/admin/users可查看账号、邮箱、手机号与权限状态。',
status: 'connected',
},
{
title: '教务快照',
description: '已接入 /api/admin/school-snapshots可查看最近学号、学期和缓存条数。',
title: '门户运营',
description: '当前后台专注于统一账号和文件资源,保持管理视图聚焦在核心门户能力上。',
status: 'connected',
},
];
@@ -147,9 +147,6 @@ export function PortalAdminDashboard() {
<Typography color="text.secondary">
{state.summary?.totalFiles ?? 0}
</Typography>
<Typography color="text.secondary">
{state.summary?.usersWithSchoolCache ?? 0}
</Typography>
</Stack>
</CardContent>
</Card>

View File

@@ -76,17 +76,6 @@ test('buildAdminListPath maps generic admin resources to backend paging queries'
}),
'/admin/users?page=1&size=20',
);
assert.equal(
buildAdminListPath('schoolSnapshots', {
pagination: {
page: 1,
perPage: 50,
},
filter: {},
}),
'/admin/school-snapshots?page=0&size=50',
);
});
test('buildAdminListPath includes the user search query when present', () => {
@@ -103,3 +92,17 @@ test('buildAdminListPath includes the user search query when present', () => {
'/admin/users?page=0&size=25&query=alice',
);
});
test('buildAdminListPath rejects the removed school snapshots resource', () => {
assert.throws(
() =>
buildAdminListPath('schoolSnapshots', {
pagination: {
page: 1,
perPage: 50,
},
filter: {},
}),
/schoolSnapshots/,
);
});

View File

@@ -3,21 +3,19 @@ import type { DataProvider, GetListParams, GetListResult, Identifier } from 'rea
import { apiRequest } from '@/src/lib/api';
import type {
AdminFile,
AdminSchoolSnapshot,
AdminUser,
PageResponse,
} from '@/src/lib/types';
const FILES_RESOURCE = 'files';
const USERS_RESOURCE = 'users';
const SCHOOL_SNAPSHOTS_RESOURCE = 'schoolSnapshots';
function createUnsupportedError(resource: string, action: string) {
return new Error(`当前管理台暂未为资源 "${resource}" 实现 ${action} 操作`);
}
function ensureSupportedResource(resource: string, action: string) {
if (![FILES_RESOURCE, USERS_RESOURCE, SCHOOL_SNAPSHOTS_RESOURCE].includes(resource)) {
if (![FILES_RESOURCE, USERS_RESOURCE].includes(resource)) {
throw createUnsupportedError(resource, action);
}
}
@@ -35,10 +33,6 @@ export function buildAdminListPath(resource: string, params: Pick<GetListParams,
return `/admin/users?page=${page}&size=${size}${query ? `&query=${encodeURIComponent(query)}` : ''}`;
}
if (resource === SCHOOL_SNAPSHOTS_RESOURCE) {
return `/admin/school-snapshots?page=${page}&size=${size}`;
}
throw createUnsupportedError(resource, 'list');
}
@@ -92,11 +86,7 @@ export const portalAdminDataProvider: DataProvider = {
} as GetListResult;
}
const payload = await apiRequest<PageResponse<AdminSchoolSnapshot>>(buildAdminListPath(resource, params));
return {
data: payload.items,
total: payload.total,
} as GetListResult;
throw createUnsupportedError(resource, 'list');
},
getOne: async (resource) => {
ensureSupportedResource(resource, 'getOne');

View File

@@ -1,22 +0,0 @@
import { Datagrid, List, NumberField, TextField } from 'react-admin';
export function PortalAdminSchoolSnapshotsList() {
return (
<List
perPage={25}
resource="schoolSnapshots"
title="教务缓存"
sort={{ field: 'id', order: 'DESC' }}
>
<Datagrid bulkActionButtons={false} rowClick={false}>
<TextField source="userId" label="用户 ID" />
<TextField source="username" label="用户名" />
<TextField source="email" label="邮箱" />
<TextField source="studentId" label="学号" emptyText="-" />
<TextField source="semester" label="学期" emptyText="-" />
<NumberField source="scheduleCount" label="课表数" />
<NumberField source="gradeCount" label="成绩数" />
</Datagrid>
</List>
);
}

View File

@@ -177,8 +177,6 @@ export function PortalAdminUsersList() {
/>
)}
/>
<TextField source="lastSchoolStudentId" label="最近学号" emptyText="-" />
<TextField source="lastSchoolSemester" label="最近学期" emptyText="-" />
<DateField source="createdAt" label="创建时间" showTime />
<FunctionField<AdminUser> label="操作" render={(record) => <AdminUserActions record={record} />} />
</Datagrid>

View File

@@ -9,7 +9,6 @@ test('fetchAdminAccessStatus returns true when the admin summary request succeed
const request = async () => ({
totalUsers: 1,
totalFiles: 2,
usersWithSchoolCache: 3,
});
await assert.doesNotReject(async () => {

View File

@@ -3,6 +3,14 @@ import test from 'node:test';
import { getVisibleNavItems } from './Layout';
test('getVisibleNavItems exposes the transfer entry instead of the school entry', () => {
const visibleItems = getVisibleNavItems(false);
const visiblePaths: string[] = visibleItems.map((item) => item.path);
assert.equal(visiblePaths.includes('/transfer'), true);
assert.equal(visiblePaths.some((path) => path === '/school'), false);
});
test('getVisibleNavItems hides the admin entry for non-admin users', () => {
assert.equal(getVisibleNavItems(false).some((item) => item.path === '/admin'), false);
});

View File

@@ -1,13 +1,13 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import {
Gamepad2,
FolderOpen,
GraduationCap,
Key,
LayoutDashboard,
LogOut,
Mail,
Send,
Settings,
Shield,
Smartphone,
@@ -28,7 +28,7 @@ import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './acc
const NAV_ITEMS = [
{ name: '总览', path: '/overview', icon: LayoutDashboard },
{ name: '网盘', path: '/files', icon: FolderOpen },
{ name: '教务', path: '/school', icon: GraduationCap },
{ name: '快传', path: '/transfer', icon: Send },
{ name: '游戏', path: '/games', icon: Gamepad2 },
{ name: '后台', path: '/admin', icon: Shield },
] as const;
@@ -39,7 +39,11 @@ export function getVisibleNavItems(isAdmin: boolean) {
return NAV_ITEMS.filter((item) => isAdmin || item.path !== '/admin');
}
export function Layout() {
interface LayoutProps {
children?: ReactNode;
}
export function Layout({ children }: LayoutProps = {}) {
const navigate = useNavigate();
const { isAdmin, logout, refreshProfile, user } = useAuth();
const navItems = getVisibleNavItems(isAdmin);
@@ -328,7 +332,7 @@ export function Layout() {
<div className="absolute bottom-[-20%] left-[20%] w-[60%] h-[60%] rounded-full bg-indigo-600 opacity-20 mix-blend-screen blur-[120px] animate-blob animation-delay-4000" />
</div>
<header className="sticky top-0 z-50 w-full glass-panel border-b border-white/10 bg-[#07101D]/60 backdrop-blur-xl">
<header className="fixed top-0 left-0 right-0 z-50 w-full glass-panel border-b border-white/10 bg-[#07101D]/60 backdrop-blur-xl">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-lg shadow-[#336EFF]/20">
@@ -427,8 +431,8 @@ export function Layout() {
</div>
</header>
<main className="flex-1 container mx-auto px-4 py-8 relative z-10">
<Outlet />
<main className="flex-1 container mx-auto px-4 pt-24 pb-8 relative z-10">
{children ?? <Outlet />}
</main>
<AnimatePresence>

View File

@@ -0,0 +1,234 @@
import React, { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { ChevronLeft, ChevronRight, Folder, Loader2, X } from 'lucide-react';
import { createPortal } from 'react-dom';
import { apiRequest } from '@/src/lib/api';
import { getParentNetdiskPath, joinNetdiskPath, splitNetdiskPath } from '@/src/lib/netdisk-paths';
import type { FileMetadata, PageResponse } from '@/src/lib/types';
import { Button } from './button';
interface NetdiskPathPickerModalProps {
isOpen: boolean;
title: string;
description?: string;
initialPath?: string;
confirmLabel: string;
confirmPathPreview?: (path: string) => string;
onClose: () => void;
onConfirm: (path: string) => Promise<void>;
}
export function NetdiskPathPickerModal({
isOpen,
title,
description,
initialPath = '/',
confirmLabel,
confirmPathPreview,
onClose,
onConfirm,
}: NetdiskPathPickerModalProps) {
const [currentPath, setCurrentPath] = useState(initialPath);
const [folders, setFolders] = useState<FileMetadata[]>([]);
const [loading, setLoading] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!isOpen) {
return;
}
setCurrentPath(initialPath);
setError('');
}, [initialPath, isOpen]);
useEffect(() => {
if (!isOpen) {
return;
}
let active = true;
setLoading(true);
setError('');
void apiRequest<PageResponse<FileMetadata>>(
`/files/list?path=${encodeURIComponent(currentPath)}&page=0&size=100`,
)
.then((response) => {
if (!active) {
return;
}
setFolders(response.items.filter((item) => item.directory));
})
.catch((requestError) => {
if (!active) {
return;
}
setFolders([]);
setError(requestError instanceof Error ? requestError.message : '读取网盘目录失败');
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, [currentPath, isOpen]);
async function handleConfirm() {
setConfirming(true);
setError('');
try {
await onConfirm(currentPath);
onClose();
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '保存目录失败');
} finally {
setConfirming(false);
}
}
const pathSegments = splitNetdiskPath(currentPath);
const previewPath = confirmPathPreview ? confirmPathPreview(currentPath) : currentPath;
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<AnimatePresence>
{isOpen ? (
<div className="fixed inset-0 z-[130] overflow-y-auto bg-black/50 p-4 backdrop-blur-sm sm:p-6">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="mx-auto my-4 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#0f172a] shadow-2xl sm:my-8 max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)]"
>
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-white">{title}</h3>
{description ? <p className="mt-1 text-xs text-slate-400">{description}</p> : null}
</div>
<button
type="button"
onClick={onClose}
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 space-y-4 overflow-y-auto p-5">
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500"></p>
<div className="mt-2 flex flex-wrap items-center gap-1 text-sm text-slate-200">
<button
type="button"
className="rounded px-1 py-0.5 hover:bg-white/10"
onClick={() => setCurrentPath('/')}
>
</button>
{pathSegments.map((segment, index) => (
<React.Fragment key={`${segment}-${index}`}>
<ChevronRight className="h-3.5 w-3.5 text-slate-500" />
<button
type="button"
className="rounded px-1 py-0.5 hover:bg-white/10"
onClick={() => setCurrentPath(joinNetdiskPath(pathSegments.slice(0, index + 1)))}
>
{segment}
</button>
</React.Fragment>
))}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="border-white/10 text-slate-200 hover:bg-white/10"
disabled={currentPath === '/'}
onClick={() => setCurrentPath(getParentNetdiskPath(currentPath))}
>
<ChevronLeft className="mr-1 h-4 w-4" />
</Button>
</div>
<p className="mt-3 text-xs text-emerald-300">: {previewPath}</p>
</div>
<div className="rounded-xl border border-white/10 bg-black/20">
<div className="border-b border-white/10 px-4 py-3 text-sm font-medium text-slate-200"></div>
<div className="max-h-72 overflow-y-auto p-3 sm:max-h-80">
{loading ? (
<div className="flex items-center justify-center gap-2 px-4 py-10 text-sm text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
) : folders.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-slate-500">使</div>
) : (
<div className="space-y-2">
{folders.map((folder) => {
const nextPath = folder.path;
return (
<button
key={folder.id}
type="button"
className="flex w-full items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] px-4 py-3 text-left transition-colors hover:border-white/10 hover:bg-white/[0.06]"
onClick={() => setCurrentPath(nextPath)}
>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-[#336EFF]/10">
<Folder className="h-4 w-4 text-[#336EFF]" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-100">{folder.filename}</p>
<p className="truncate text-xs text-slate-500">{nextPath}</p>
</div>
<ChevronRight className="h-4 w-4 text-slate-500" />
</button>
);
})}
</div>
)}
</div>
</div>
{error ? (
<div className="rounded-xl border border-rose-500/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">{error}</div>
) : null}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" className="border-white/10 text-slate-300 hover:bg-white/10" onClick={onClose} disabled={confirming}>
</Button>
<Button type="button" onClick={() => void handleConfirm()} disabled={confirming || loading}>
{confirming ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
confirmLabel
)}
</Button>
</div>
</div>
</motion.div>
</div>
) : null}
</AnimatePresence>
,
document.body,
);
}

View File

@@ -60,7 +60,7 @@ test('scoped cache key includes current user identity', () => {
},
});
assert.equal(buildScopedCacheKey('school', '2023123456', '2025-spring'), 'portal-cache:user:7:school:2023123456:2025-spring');
assert.equal(buildScopedCacheKey('transfer', 'pickup-code', '849201'), 'portal-cache:user:7:transfer:pickup-code:849201');
});
test('cached values are isolated between users', () => {
@@ -73,9 +73,9 @@ test('cached values are isolated between users', () => {
createdAt: '2026-03-14T12:00:00',
},
});
writeCachedValue(buildScopedCacheKey('school', '2023123456', '2025-spring'), {
writeCachedValue(buildScopedCacheKey('transfer', 'pickup-code', '849201'), {
queried: true,
grades: [95],
sharedFiles: [2],
});
saveStoredSession({
@@ -88,12 +88,12 @@ test('cached values are isolated between users', () => {
},
});
assert.equal(readCachedValue(buildScopedCacheKey('school', '2023123456', '2025-spring')), null);
assert.equal(readCachedValue(buildScopedCacheKey('transfer', 'pickup-code', '849201')), null);
});
test('invalid cached json is ignored safely', () => {
localStorage.setItem('portal-cache:user:7:school:2023123456:2025-spring', '{broken-json');
localStorage.setItem('portal-cache:user:7:transfer:pickup-code:849201', '{broken-json');
assert.equal(readCachedValue('portal-cache:user:7:school:2023123456:2025-spring'), null);
assert.equal(localStorage.getItem('portal-cache:user:7:school:2023123456:2025-spring'), null);
assert.equal(readCachedValue('portal-cache:user:7:transfer:pickup-code:849201'), null);
assert.equal(localStorage.getItem('portal-cache:user:7:transfer:pickup-code:849201'), null);
});

View File

@@ -0,0 +1,12 @@
import { apiRequest } from './api';
import { normalizeNetdiskTargetPath } from './netdisk-upload';
import type { FileMetadata } from './types';
export function copyFileToNetdiskPath(fileId: number, path: string) {
return apiRequest<FileMetadata>(`/files/${fileId}/copy`, {
method: 'POST',
body: {
path: normalizeNetdiskTargetPath(path, '/'),
},
});
}

View File

@@ -0,0 +1,12 @@
import { apiRequest } from './api';
import { normalizeNetdiskTargetPath } from './netdisk-upload';
import type { FileMetadata } from './types';
export function moveFileToNetdiskPath(fileId: number, path: string) {
return apiRequest<FileMetadata>(`/files/${fileId}/move`, {
method: 'PATCH',
body: {
path: normalizeNetdiskTargetPath(path, '/'),
},
});
}

View File

@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildFileShareUrl,
FILE_SHARE_ROUTE_PREFIX,
getPostLoginRedirectPath,
} from './file-share';
test('buildFileShareUrl builds a browser-router share url', () => {
assert.equal(
buildFileShareUrl('https://yoyuzh.xyz', 'share-token-1', 'browser'),
'https://yoyuzh.xyz/share/share-token-1',
);
});
test('buildFileShareUrl builds a hash-router share url', () => {
assert.equal(
buildFileShareUrl('https://yoyuzh.xyz/', 'share-token-1', 'hash'),
'https://yoyuzh.xyz/#/share/share-token-1',
);
});
test('getPostLoginRedirectPath keeps safe in-site paths only', () => {
assert.equal(getPostLoginRedirectPath('/share/share-token-1'), '/share/share-token-1');
assert.equal(getPostLoginRedirectPath('https://evil.example.com'), '/overview');
assert.equal(getPostLoginRedirectPath(null), '/overview');
});
test('FILE_SHARE_ROUTE_PREFIX stays aligned with the public share route', () => {
assert.equal(FILE_SHARE_ROUTE_PREFIX, '/share');
});

View File

@@ -0,0 +1,49 @@
import { apiRequest } from './api';
import { getTransferRouterMode, type TransferRouterMode } from './transfer-links';
import type { CreateFileShareLinkResponse, FileMetadata, FileShareDetailsResponse } from './types';
export const FILE_SHARE_ROUTE_PREFIX = '/share';
export function buildFileShareUrl(
origin: string,
token: string,
routerMode: TransferRouterMode = 'browser',
) {
const normalizedOrigin = origin.replace(/\/+$/, '');
const encodedToken = encodeURIComponent(token);
if (routerMode === 'hash') {
return `${normalizedOrigin}/#${FILE_SHARE_ROUTE_PREFIX}/${encodedToken}`;
}
return `${normalizedOrigin}${FILE_SHARE_ROUTE_PREFIX}/${encodedToken}`;
}
export function getPostLoginRedirectPath(nextPath: string | null, fallback = '/overview') {
if (!nextPath || !nextPath.startsWith('/') || nextPath.startsWith('//')) {
return fallback;
}
return nextPath;
}
export function createFileShareLink(fileId: number) {
return apiRequest<CreateFileShareLinkResponse>(`/files/${fileId}/share-links`, {
method: 'POST',
});
}
export function getFileShareDetails(token: string) {
return apiRequest<FileShareDetailsResponse>(`/files/share-links/${encodeURIComponent(token)}`);
}
export function importSharedFile(token: string, path: string) {
return apiRequest<FileMetadata>(`/files/share-links/${encodeURIComponent(token)}/import`, {
method: 'POST',
body: { path },
});
}
export function getCurrentFileShareUrl(token: string) {
return buildFileShareUrl(window.location.origin, token, getTransferRouterMode());
}

View File

@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getParentNetdiskPath,
joinNetdiskPath,
resolveTransferSaveDirectory,
splitNetdiskPath,
} from './netdisk-paths';
test('splitNetdiskPath normalizes root and nested paths', () => {
assert.deepEqual(splitNetdiskPath('/'), []);
assert.deepEqual(splitNetdiskPath('/下载/旅行/照片'), ['下载', '旅行', '照片']);
assert.deepEqual(splitNetdiskPath('下载//旅行/照片/'), ['下载', '旅行', '照片']);
});
test('joinNetdiskPath rebuilds a normalized absolute path', () => {
assert.equal(joinNetdiskPath([]), '/');
assert.equal(joinNetdiskPath(['下载', '旅行']), '/下载/旅行');
});
test('getParentNetdiskPath returns the previous directory level', () => {
assert.equal(getParentNetdiskPath('/下载/旅行'), '/下载');
assert.equal(getParentNetdiskPath('/下载'), '/');
});
test('resolveTransferSaveDirectory keeps nested transfer folders under the selected root path', () => {
assert.equal(resolveTransferSaveDirectory('相册/旅行/cover.jpg', '/下载'), '/下载/相册/旅行');
assert.equal(resolveTransferSaveDirectory('cover.jpg', '/下载'), '/下载');
});

View File

@@ -0,0 +1,31 @@
export function splitNetdiskPath(path: string | null | undefined) {
const rawPath = path?.trim();
if (!rawPath || rawPath === '/') {
return [] as string[];
}
return rawPath
.replaceAll('\\', '/')
.split('/')
.map((segment) => segment.trim())
.filter((segment) => segment && segment !== '.' && segment !== '..');
}
export function joinNetdiskPath(segments: string[]) {
return segments.length === 0 ? '/' : `/${segments.join('/')}`;
}
export function getParentNetdiskPath(path: string | null | undefined) {
const segments = splitNetdiskPath(path);
return joinNetdiskPath(segments.slice(0, -1));
}
export function resolveTransferSaveDirectory(relativePath: string | null | undefined, rootPath = '/下载') {
const rootSegments = splitNetdiskPath(rootPath);
const relativeSegments = splitNetdiskPath(relativePath);
if (relativeSegments.length <= 1) {
return joinNetdiskPath(rootSegments);
}
return joinNetdiskPath([...rootSegments, ...relativeSegments.slice(0, -1)]);
}

View File

@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeNetdiskTargetPath, resolveNetdiskSaveDirectory } from './netdisk-upload';
test('normalizeNetdiskTargetPath falls back to 下载 for blank paths', () => {
assert.equal(normalizeNetdiskTargetPath(undefined), '/下载');
assert.equal(normalizeNetdiskTargetPath(''), '/下载');
assert.equal(normalizeNetdiskTargetPath(' '), '/下载');
});
test('normalizeNetdiskTargetPath normalizes slash and root input', () => {
assert.equal(normalizeNetdiskTargetPath('/'), '/');
assert.equal(normalizeNetdiskTargetPath('下载/快传'), '/下载/快传');
assert.equal(normalizeNetdiskTargetPath('/下载/快传/'), '/下载/快传');
});
test('resolveNetdiskSaveDirectory keeps nested transfer folders under 下载', () => {
assert.equal(resolveNetdiskSaveDirectory('相册/旅行/cover.jpg'), '/下载/相册/旅行');
assert.equal(resolveNetdiskSaveDirectory('cover.jpg'), '/下载');
});
test('resolveNetdiskSaveDirectory ignores unsafe path segments', () => {
assert.equal(resolveNetdiskSaveDirectory('../相册//旅行/cover.jpg'), '/下载/相册/旅行');
});

View File

@@ -0,0 +1,60 @@
import { apiBinaryUploadRequest, apiRequest, apiUploadRequest, ApiError } from './api';
import { joinNetdiskPath, resolveTransferSaveDirectory, splitNetdiskPath } from './netdisk-paths';
import type { FileMetadata, InitiateUploadResponse } from './types';
export function normalizeNetdiskTargetPath(path: string | null | undefined, fallback = '/下载') {
const rawPath = path?.trim();
if (!rawPath) {
return fallback;
}
return joinNetdiskPath(splitNetdiskPath(rawPath === '/' ? '/' : rawPath)) || fallback;
}
export function resolveNetdiskSaveDirectory(relativePath: string | null | undefined, rootPath = '/下载') {
return normalizeNetdiskTargetPath(resolveTransferSaveDirectory(relativePath, rootPath));
}
export async function saveFileToNetdisk(file: File, path: string) {
const normalizedPath = normalizeNetdiskTargetPath(path);
const initiated = await apiRequest<InitiateUploadResponse>('/files/upload/initiate', {
method: 'POST',
body: {
path: normalizedPath,
filename: file.name,
contentType: file.type || null,
size: file.size,
},
});
if (initiated.direct) {
try {
await apiBinaryUploadRequest(initiated.uploadUrl, {
method: initiated.method,
headers: initiated.headers,
body: file,
});
return await apiRequest<FileMetadata>('/files/upload/complete', {
method: 'POST',
body: {
path: normalizedPath,
filename: file.name,
storageName: initiated.storageName,
contentType: file.type || null,
size: file.size,
},
});
} catch (error) {
if (!(error instanceof ApiError && error.isNetworkError)) {
throw error;
}
}
}
const formData = new FormData();
formData.append('file', file);
return apiUploadRequest<FileMetadata>(`/files/upload?path=${encodeURIComponent(normalizedPath)}`, {
body: formData,
});
}

View File

@@ -1,41 +1,10 @@
import { buildScopedCacheKey, readCachedValue, writeCachedValue } from './cache';
import type { CourseResponse, FileMetadata, GradeResponse, UserProfile } from './types';
export interface SchoolQueryCache {
studentId: string;
semester: string;
}
export interface SchoolResultsCache {
queried: boolean;
schedule: CourseResponse[];
grades: GradeResponse[];
studentId: string;
semester: string;
}
import type { FileMetadata, UserProfile } from './types';
export interface OverviewCache {
profile: UserProfile | null;
recentFiles: FileMetadata[];
rootFiles: FileMetadata[];
schedule: CourseResponse[];
grades: GradeResponse[];
}
function getSchoolQueryCacheKey() {
return buildScopedCacheKey('school-query');
}
export function readStoredSchoolQuery() {
return readCachedValue<SchoolQueryCache>(getSchoolQueryCacheKey());
}
export function writeStoredSchoolQuery(query: SchoolQueryCache) {
writeCachedValue(getSchoolQueryCacheKey(), query);
}
export function getSchoolResultsCacheKey(studentId: string, semester: string) {
return buildScopedCacheKey('school-results', studentId, semester);
}
export function getOverviewCacheKey() {

View File

@@ -1,74 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { CourseResponse } from './types';
import { buildScheduleTable, getScheduleCellHeight, getScheduleDividerOffsets } from './schedule-table';
test('buildScheduleTable creates 12 sections with empty slots preserved', () => {
const schedule: CourseResponse[] = [
{
courseName: 'Advanced Java',
teacher: 'Li',
classroom: 'A101',
dayOfWeek: 1,
startTime: 1,
endTime: 2,
},
{
courseName: 'Networks',
teacher: 'Wang',
classroom: 'B202',
dayOfWeek: 3,
startTime: 5,
endTime: 6,
},
];
const table = buildScheduleTable(schedule);
assert.equal(table.length, 12);
assert.equal(table[0].slots.length, 7);
assert.equal(table[0].section, 1);
assert.equal(table[11].section, 12);
assert.equal(table[0].period, 'morning');
assert.equal(table[4].period, 'noon');
assert.equal(table[5].period, 'afternoon');
assert.equal(table[9].period, 'evening');
assert.equal(table[0].slots[0]?.course?.courseName, 'Advanced Java');
assert.equal(table[1].slots[0]?.type, 'covered');
assert.equal(table[2].slots[0]?.type, 'empty');
assert.equal(table[4].slots[2]?.course?.courseName, 'Networks');
assert.equal(table[5].slots[2]?.type, 'covered');
assert.equal(table[8].slots[4]?.type, 'empty');
assert.equal(table[8].slots[6]?.type, 'empty');
});
test('buildScheduleTable clamps invalid section ranges safely', () => {
const schedule: CourseResponse[] = [
{
courseName: 'Night Studio',
teacher: 'Xu',
classroom: 'C303',
dayOfWeek: 5,
startTime: 11,
endTime: 14,
},
];
const table = buildScheduleTable(schedule);
assert.equal(table[10].slots[4]?.rowSpan, 2);
assert.equal(table[11].slots[4]?.type, 'covered');
});
test('getScheduleCellHeight returns merged visual height for rowspan cells', () => {
assert.equal(getScheduleCellHeight(1), 96);
assert.equal(getScheduleCellHeight(2), 200);
assert.equal(getScheduleCellHeight(4), 408);
});
test('getScheduleDividerOffsets returns internal section boundaries for merged cells', () => {
assert.deepEqual(getScheduleDividerOffsets(1), []);
assert.deepEqual(getScheduleDividerOffsets(2), [100]);
assert.deepEqual(getScheduleDividerOffsets(4), [100, 204, 308]);
});

View File

@@ -1,77 +0,0 @@
import type { CourseResponse } from './types';
export interface ScheduleSlot {
type: 'empty' | 'course' | 'covered';
course?: CourseResponse;
rowSpan?: number;
}
export interface ScheduleRow {
section: number;
period: 'morning' | 'noon' | 'afternoon' | 'evening';
slots: ScheduleSlot[];
}
const SECTION_COUNT = 12;
const WEEKDAY_COUNT = 7;
const SECTION_CELL_HEIGHT = 96;
const SECTION_CELL_GAP = 8;
function getPeriod(section: number): ScheduleRow['period'] {
if (section <= 4) {
return 'morning';
}
if (section === 5) {
return 'noon';
}
if (section <= 8) {
return 'afternoon';
}
return 'evening';
}
export function buildScheduleTable(schedule: CourseResponse[]) {
const rows: ScheduleRow[] = Array.from({ length: SECTION_COUNT }, (_, index) => ({
section: index + 1,
period: getPeriod(index + 1),
slots: Array.from({ length: WEEKDAY_COUNT }, () => ({ type: 'empty' as const })),
}));
for (const course of schedule) {
const dayIndex = (course.dayOfWeek ?? 0) - 1;
if (dayIndex < 0 || dayIndex >= WEEKDAY_COUNT) {
continue;
}
const startSection = Math.max(1, Math.min(SECTION_COUNT, course.startTime ?? 1));
const endSection = Math.max(startSection, Math.min(SECTION_COUNT, course.endTime ?? startSection));
const rowSpan = endSection - startSection + 1;
const startRowIndex = startSection - 1;
rows[startRowIndex].slots[dayIndex] = {
type: 'course',
course,
rowSpan,
};
for (let section = startSection + 1; section <= endSection; section += 1) {
rows[section - 1].slots[dayIndex] = {
type: 'covered',
};
}
}
return rows;
}
export function getScheduleCellHeight(rowSpan: number) {
const safeRowSpan = Math.max(1, rowSpan);
return safeRowSpan * SECTION_CELL_HEIGHT + (safeRowSpan - 1) * SECTION_CELL_GAP;
}
export function getScheduleDividerOffsets(rowSpan: number) {
const safeRowSpan = Math.max(1, rowSpan);
return Array.from({ length: safeRowSpan - 1 }, (_, index) =>
(index + 1) * SECTION_CELL_HEIGHT + index * SECTION_CELL_GAP + SECTION_CELL_GAP / 2,
);
}

View File

@@ -1,22 +0,0 @@
import { apiRequest } from './api';
import { writeCachedValue } from './cache';
import { getSchoolResultsCacheKey, writeStoredSchoolQuery } from './page-cache';
import type { LatestSchoolDataResponse } from './types';
export async function fetchLatestSchoolData() {
return apiRequest<LatestSchoolDataResponse | null>('/cqu/latest');
}
export function cacheLatestSchoolData(latest: LatestSchoolDataResponse) {
writeStoredSchoolQuery({
studentId: latest.studentId,
semester: latest.semester,
});
writeCachedValue(getSchoolResultsCacheKey(latest.studentId, latest.semester), {
queried: true,
studentId: latest.studentId,
semester: latest.semester,
schedule: latest.schedule,
grades: latest.grades,
});
}

View File

@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildTransferArchiveFileName,
createTransferZipArchive,
} from './transfer-archive';
test('buildTransferArchiveFileName always returns a zip filename', () => {
assert.equal(buildTransferArchiveFileName('课堂资料'), '课堂资料.zip');
assert.equal(buildTransferArchiveFileName('课堂资料.zip'), '课堂资料.zip');
});
test('createTransferZipArchive creates a zip payload that keeps nested file paths', async () => {
const archive = await createTransferZipArchive([
{
name: 'report.pdf',
relativePath: '课程资料/report.pdf',
data: new TextEncoder().encode('report'),
},
{
name: 'notes.txt',
relativePath: '课程资料/notes.txt',
data: new TextEncoder().encode('notes'),
},
]);
const bytes = new Uint8Array(await archive.arrayBuffer());
const text = new TextDecoder().decode(bytes);
assert.equal(String.fromCharCode(...bytes.slice(0, 4)), 'PK\u0003\u0004');
assert.match(text, /课程资料\/report\.pdf/);
assert.match(text, /课程资料\/notes\.txt/);
});

View File

@@ -0,0 +1,171 @@
export interface TransferArchiveEntry {
name: string;
relativePath?: string;
data: Uint8Array | ArrayBuffer | Blob;
lastModified?: number;
}
const ZIP_UTF8_FLAG = 0x0800;
const CRC32_TABLE = createCrc32Table();
function createCrc32Table() {
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) === 1 ? (0xEDB88320 ^ (value >>> 1)) : (value >>> 1);
}
table[index] = value >>> 0;
}
return table;
}
function sanitizeArchivePath(entry: TransferArchiveEntry) {
const rawPath = entry.relativePath?.trim() || entry.name;
const normalizedPath = rawPath
.replaceAll('\\', '/')
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
.join('/');
return normalizedPath || entry.name;
}
function crc32(bytes: Uint8Array) {
let value = 0xFFFFFFFF;
for (const byte of bytes) {
value = CRC32_TABLE[(value ^ byte) & 0xFF] ^ (value >>> 8);
}
return (value ^ 0xFFFFFFFF) >>> 0;
}
function toDosDateTime(timestamp: number) {
const date = new Date(timestamp);
const year = Math.max(1980, date.getFullYear());
const month = date.getMonth() + 1;
const day = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = Math.floor(date.getSeconds() / 2);
return {
time: (hours << 11) | (minutes << 5) | seconds,
date: ((year - 1980) << 9) | (month << 5) | day,
};
}
function writeUint16(view: DataView, offset: number, value: number) {
view.setUint16(offset, value, true);
}
function writeUint32(view: DataView, offset: number, value: number) {
view.setUint32(offset, value >>> 0, true);
}
function concatUint8Arrays(chunks: Uint8Array[]) {
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
const output = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
}
async function normalizeArchiveData(data: TransferArchiveEntry['data']) {
if (data instanceof Uint8Array) {
return data;
}
if (data instanceof Blob) {
return new Uint8Array(await data.arrayBuffer());
}
return new Uint8Array(data);
}
export function buildTransferArchiveFileName(baseName: string) {
return baseName.toLowerCase().endsWith('.zip') ? baseName : `${baseName}.zip`;
}
export async function createTransferZipArchive(entries: TransferArchiveEntry[]) {
const encoder = new TextEncoder();
const fileSections: Uint8Array[] = [];
const centralDirectorySections: Uint8Array[] = [];
let offset = 0;
for (const entry of entries) {
const fileName = sanitizeArchivePath(entry);
const fileNameBytes = encoder.encode(fileName);
const fileData = await normalizeArchiveData(entry.data);
const checksum = crc32(fileData);
const {time, date} = toDosDateTime(entry.lastModified ?? Date.now());
const localHeader = new Uint8Array(30);
const localHeaderView = new DataView(localHeader.buffer);
writeUint32(localHeaderView, 0, 0x04034B50);
writeUint16(localHeaderView, 4, 20);
writeUint16(localHeaderView, 6, ZIP_UTF8_FLAG);
writeUint16(localHeaderView, 8, 0);
writeUint16(localHeaderView, 10, time);
writeUint16(localHeaderView, 12, date);
writeUint32(localHeaderView, 14, checksum);
writeUint32(localHeaderView, 18, fileData.byteLength);
writeUint32(localHeaderView, 22, fileData.byteLength);
writeUint16(localHeaderView, 26, fileNameBytes.byteLength);
writeUint16(localHeaderView, 28, 0);
fileSections.push(localHeader, fileNameBytes, fileData);
const centralHeader = new Uint8Array(46);
const centralHeaderView = new DataView(centralHeader.buffer);
writeUint32(centralHeaderView, 0, 0x02014B50);
writeUint16(centralHeaderView, 4, 20);
writeUint16(centralHeaderView, 6, 20);
writeUint16(centralHeaderView, 8, ZIP_UTF8_FLAG);
writeUint16(centralHeaderView, 10, 0);
writeUint16(centralHeaderView, 12, time);
writeUint16(centralHeaderView, 14, date);
writeUint32(centralHeaderView, 16, checksum);
writeUint32(centralHeaderView, 20, fileData.byteLength);
writeUint32(centralHeaderView, 24, fileData.byteLength);
writeUint16(centralHeaderView, 28, fileNameBytes.byteLength);
writeUint16(centralHeaderView, 30, 0);
writeUint16(centralHeaderView, 32, 0);
writeUint16(centralHeaderView, 34, 0);
writeUint16(centralHeaderView, 36, 0);
writeUint32(centralHeaderView, 38, 0);
writeUint32(centralHeaderView, 42, offset);
centralDirectorySections.push(centralHeader, fileNameBytes);
offset += localHeader.byteLength + fileNameBytes.byteLength + fileData.byteLength;
}
const centralDirectory = concatUint8Arrays(centralDirectorySections);
const endRecord = new Uint8Array(22);
const endRecordView = new DataView(endRecord.buffer);
writeUint32(endRecordView, 0, 0x06054B50);
writeUint16(endRecordView, 4, 0);
writeUint16(endRecordView, 6, 0);
writeUint16(endRecordView, 8, entries.length);
writeUint16(endRecordView, 10, entries.length);
writeUint32(endRecordView, 12, centralDirectory.byteLength);
writeUint32(endRecordView, 16, offset);
writeUint16(endRecordView, 20, 0);
return new Blob([
concatUint8Arrays(fileSections),
centralDirectory,
endRecord,
], {
type: 'application/zip',
});
}

View File

@@ -0,0 +1,24 @@
export type TransferRouterMode = 'browser' | 'hash';
export const APP_TRANSFER_ROUTE = '/transfer';
export const PUBLIC_TRANSFER_ROUTE = '/transfer';
export const LEGACY_PUBLIC_TRANSFER_ROUTE = '/t';
export function getTransferRouterMode(mode: string | undefined = import.meta.env?.VITE_ROUTER_MODE): TransferRouterMode {
return mode === 'hash' ? 'hash' : 'browser';
}
export function buildTransferShareUrl(
origin: string,
sessionId: string,
routerMode: TransferRouterMode = 'browser',
) {
const normalizedOrigin = origin.replace(/\/+$/, '');
const encodedSessionId = encodeURIComponent(sessionId);
if (routerMode === 'hash') {
return `${normalizedOrigin}/#${PUBLIC_TRANSFER_ROUTE}?session=${encodedSessionId}`;
}
return `${normalizedOrigin}${PUBLIC_TRANSFER_ROUTE}?session=${encodedSessionId}`;
}

View File

@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createTransferFileManifest,
createTransferCompleteMessage,
createTransferFileCompleteMessage,
createTransferFileId,
createTransferFileManifestMessage,
createTransferFileMetaMessage,
createTransferReceiveRequestMessage,
parseTransferControlMessage,
toTransferChunk,
} from './transfer-protocol';
test('createTransferFileId uses stable file identity parts', () => {
assert.equal(
createTransferFileId({
name: 'report.pdf',
lastModified: 1730000000000,
size: 2048,
}),
'report.pdf-1730000000000-2048',
);
});
test('createTransferFileMetaMessage encodes the control payload for sender and receiver', () => {
const payload = parseTransferControlMessage(
createTransferFileMetaMessage({
id: 'report-1',
name: 'report.pdf',
size: 2048,
contentType: 'application/pdf',
relativePath: '课程资料/report.pdf',
}),
);
assert.deepEqual(payload, {
type: 'file-meta',
id: 'report-1',
name: 'report.pdf',
size: 2048,
contentType: 'application/pdf',
relativePath: '课程资料/report.pdf',
});
});
test('createTransferFileManifest keeps folder relative paths from selected files', () => {
const report = new File(['report'], 'report.pdf', {
type: 'application/pdf',
lastModified: 1730000000000,
});
Object.defineProperty(report, 'webkitRelativePath', {
configurable: true,
value: '课程资料/report.pdf',
});
const manifest = createTransferFileManifest([report]);
assert.deepEqual(manifest, [
{
id: 'report.pdf-1730000000000-6',
name: 'report.pdf',
size: 6,
contentType: 'application/pdf',
relativePath: '课程资料/report.pdf',
},
]);
});
test('createTransferFileManifestMessage and createTransferReceiveRequestMessage stay parseable', () => {
const manifestPayload = parseTransferControlMessage(
createTransferFileManifestMessage([
{
id: 'report-1',
name: 'report.pdf',
size: 2048,
contentType: 'application/pdf',
relativePath: '课程资料/report.pdf',
},
]),
);
assert.deepEqual(manifestPayload, {
type: 'manifest',
files: [
{
id: 'report-1',
name: 'report.pdf',
size: 2048,
contentType: 'application/pdf',
relativePath: '课程资料/report.pdf',
},
],
});
assert.deepEqual(parseTransferControlMessage(createTransferReceiveRequestMessage(['report-1'], true)), {
type: 'receive-request',
fileIds: ['report-1'],
archive: true,
});
});
test('createTransferFileCompleteMessage and createTransferCompleteMessage create parseable control messages', () => {
assert.deepEqual(parseTransferControlMessage(createTransferFileCompleteMessage('report-1')), {
type: 'file-complete',
id: 'report-1',
});
assert.deepEqual(parseTransferControlMessage(createTransferCompleteMessage()), {
type: 'transfer-complete',
});
});
test('parseTransferControlMessage returns null for invalid payloads', () => {
assert.equal(parseTransferControlMessage('{not-json'), null);
});
test('toTransferChunk normalizes ArrayBuffer and Blob data into bytes', async () => {
assert.deepEqual(Array.from(await toTransferChunk(new Uint8Array([1, 2, 3]).buffer)), [1, 2, 3]);
assert.deepEqual(Array.from(await toTransferChunk(new Blob(['hi']))), [104, 105]);
});

View File

@@ -0,0 +1,117 @@
export const TRANSFER_CHUNK_SIZE = 64 * 1024;
export const SIGNAL_POLL_INTERVAL_MS = 1000;
interface TransferFileIdentity {
name: string;
lastModified: number;
size: number;
}
export interface TransferFileDescriptor {
id: string;
name: string;
size: number;
contentType: string;
relativePath: string;
}
export type TransferControlMessage =
{
type: 'manifest';
files: TransferFileDescriptor[];
}
| {
type: 'receive-request';
fileIds: string[];
archive: boolean;
}
| ({
type: 'file-meta';
} & TransferFileDescriptor)
| {
type: 'file-complete';
id: string;
}
| {
type: 'transfer-complete';
};
export function createTransferFileId(file: TransferFileIdentity) {
return `${file.name}-${file.lastModified}-${file.size}`;
}
export function getTransferFileRelativePath(file: File) {
const rawRelativePath = ('webkitRelativePath' in file && typeof file.webkitRelativePath === 'string' && file.webkitRelativePath)
? file.webkitRelativePath
: file.name;
const normalizedPath = rawRelativePath
.replaceAll('\\', '/')
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
.join('/');
return normalizedPath || file.name;
}
export function createTransferFileManifest(files: File[]): TransferFileDescriptor[] {
return files.map((file) => ({
id: createTransferFileId(file),
name: file.name,
size: file.size,
contentType: file.type || 'application/octet-stream',
relativePath: getTransferFileRelativePath(file),
}));
}
export function createTransferFileManifestMessage(files: TransferFileDescriptor[]) {
return JSON.stringify({
type: 'manifest',
files,
} satisfies TransferControlMessage);
}
export function createTransferReceiveRequestMessage(fileIds: string[], archive: boolean) {
return JSON.stringify({
type: 'receive-request',
fileIds,
archive,
} satisfies TransferControlMessage);
}
export function createTransferFileMetaMessage(payload: TransferFileDescriptor) {
return JSON.stringify({
type: 'file-meta',
...payload,
} satisfies TransferControlMessage);
}
export function createTransferFileCompleteMessage(id: string) {
return JSON.stringify({
type: 'file-complete',
id,
} satisfies TransferControlMessage);
}
export function createTransferCompleteMessage() {
return JSON.stringify({
type: 'transfer-complete',
} satisfies TransferControlMessage);
}
export function parseTransferControlMessage(payload: string): TransferControlMessage | null {
try {
return JSON.parse(payload) as TransferControlMessage;
} catch {
return null;
}
}
export async function toTransferChunk(data: ArrayBuffer | Blob) {
if (data instanceof Blob) {
return new Uint8Array(await data.arrayBuffer());
}
return new Uint8Array(data);
}

View File

@@ -0,0 +1,19 @@
export const MAX_TRANSFER_BUFFERED_AMOUNT = 1024 * 1024;
export async function waitForTransferChannelDrain(
channel: RTCDataChannel,
maxBufferedAmount = MAX_TRANSFER_BUFFERED_AMOUNT,
) {
if (channel.bufferedAmount <= maxBufferedAmount) {
return;
}
await new Promise<void>((resolve) => {
const timer = window.setInterval(() => {
if (channel.readyState !== 'open' || channel.bufferedAmount <= maxBufferedAmount) {
window.clearInterval(timer);
resolve();
}
}, 40);
});
}

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
flushPendingRemoteIceCandidates,
handleRemoteIceCandidate,
} from './transfer-signaling';
test('handleRemoteIceCandidate defers candidates until the remote description exists', async () => {
const appliedCandidates: RTCIceCandidateInit[] = [];
const connection = {
remoteDescription: null,
addIceCandidate: async (candidate: RTCIceCandidateInit) => {
appliedCandidates.push(candidate);
},
};
const candidate: RTCIceCandidateInit = {
candidate: 'candidate:1 1 udp 2122260223 10.0.0.2 54321 typ host',
sdpMid: '0',
sdpMLineIndex: 0,
};
const pendingCandidates = await handleRemoteIceCandidate(connection, [], candidate);
assert.deepEqual(appliedCandidates, []);
assert.deepEqual(pendingCandidates, [candidate]);
});
test('flushPendingRemoteIceCandidates applies queued candidates after the remote description is set', async () => {
const appliedCandidates: RTCIceCandidateInit[] = [];
const connection = {
remoteDescription: { type: 'answer' } as RTCSessionDescription,
addIceCandidate: async (candidate: RTCIceCandidateInit) => {
appliedCandidates.push(candidate);
},
};
const pendingCandidates: RTCIceCandidateInit[] = [
{
candidate: 'candidate:1 1 udp 2122260223 10.0.0.2 54321 typ host',
sdpMid: '0',
sdpMLineIndex: 0,
},
{
candidate: 'candidate:2 1 udp 2122260223 10.0.0.3 54322 typ host',
sdpMid: '0',
sdpMLineIndex: 0,
},
];
const remainingCandidates = await flushPendingRemoteIceCandidates(connection, pendingCandidates);
assert.deepEqual(appliedCandidates, pendingCandidates);
assert.deepEqual(remainingCandidates, []);
});

View File

@@ -0,0 +1,32 @@
interface RemoteIceCapableConnection {
remoteDescription: RTCSessionDescription | null;
addIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
}
export async function handleRemoteIceCandidate(
connection: RemoteIceCapableConnection,
pendingCandidates: RTCIceCandidateInit[],
candidate: RTCIceCandidateInit,
) {
if (!connection.remoteDescription) {
return [...pendingCandidates, candidate];
}
await connection.addIceCandidate(candidate);
return pendingCandidates;
}
export async function flushPendingRemoteIceCandidates(
connection: RemoteIceCapableConnection,
pendingCandidates: RTCIceCandidateInit[],
) {
if (!connection.remoteDescription || pendingCandidates.length === 0) {
return pendingCandidates;
}
for (const candidate of pendingCandidates) {
await connection.addIceCandidate(candidate);
}
return [];
}

56
front/src/lib/transfer.ts Normal file
View File

@@ -0,0 +1,56 @@
import { apiRequest } from './api';
import type {
LookupTransferSessionResponse,
PollTransferSignalsResponse,
TransferSessionResponse,
} from './types';
export const DEFAULT_TRANSFER_ICE_SERVERS: RTCIceServer[] = [
{urls: 'stun:stun.cloudflare.com:3478'},
{urls: 'stun:stun.l.google.com:19302'},
];
export function toTransferFilePayload(files: File[]) {
return files.map((file) => ({
name: file.name,
size: file.size,
contentType: file.type || 'application/octet-stream',
}));
}
export function createTransferSession(files: File[]) {
return apiRequest<TransferSessionResponse>('/transfer/sessions', {
method: 'POST',
body: {
files: toTransferFilePayload(files),
},
});
}
export function lookupTransferSession(pickupCode: string) {
return apiRequest<LookupTransferSessionResponse>(
`/transfer/sessions/lookup?pickupCode=${encodeURIComponent(pickupCode)}`,
);
}
export function joinTransferSession(sessionId: string) {
return apiRequest<TransferSessionResponse>(`/transfer/sessions/${encodeURIComponent(sessionId)}/join`, {
method: 'POST',
});
}
export function postTransferSignal(sessionId: string, role: 'sender' | 'receiver', type: string, payload: string) {
return apiRequest<void>(`/transfer/sessions/${encodeURIComponent(sessionId)}/signals?role=${role}`, {
method: 'POST',
body: {
type,
payload,
},
});
}
export function pollTransferSignals(sessionId: string, role: 'sender' | 'receiver', after: number) {
return apiRequest<PollTransferSignalsResponse>(
`/transfer/sessions/${encodeURIComponent(sessionId)}/signals?role=${role}&after=${after}`,
);
}

View File

@@ -16,7 +16,6 @@ export type AdminUserRole = 'USER' | 'MODERATOR' | 'ADMIN';
export interface AdminSummary {
totalUsers: number;
totalFiles: number;
usersWithSchoolCache: number;
}
export interface AdminUser {
@@ -25,8 +24,6 @@ export interface AdminUser {
email: string;
phoneNumber: string | null;
createdAt: string;
lastSchoolStudentId: string | null;
lastSchoolSemester: string | null;
role: AdminUserRole;
banned: boolean;
}
@@ -44,17 +41,6 @@ export interface AdminFile {
ownerEmail: string;
}
export interface AdminSchoolSnapshot {
id: number;
userId: number;
username: string;
email: string;
studentId: string | null;
semester: string | null;
scheduleCount: number;
gradeCount: number;
}
export interface AdminPasswordResetResponse {
temporaryPassword: string;
}
@@ -101,24 +87,50 @@ export interface DownloadUrlResponse {
url: string;
}
export interface CourseResponse {
courseName: string;
teacher: string | null;
classroom: string | null;
dayOfWeek: number | null;
startTime: number | null;
endTime: number | null;
export interface CreateFileShareLinkResponse {
token: string;
filename: string;
size: number;
contentType: string | null;
createdAt: string;
}
export interface GradeResponse {
courseName: string;
grade: number | null;
semester: string | null;
export interface FileShareDetailsResponse {
token: string;
ownerUsername: string;
filename: string;
size: number;
contentType: string | null;
directory: boolean;
createdAt: string;
}
export interface LatestSchoolDataResponse {
studentId: string;
semester: string;
schedule: CourseResponse[];
grades: GradeResponse[];
export interface TransferFileItem {
name: string;
size: number;
contentType: string;
}
export interface TransferSessionResponse {
sessionId: string;
pickupCode: string;
expiresAt: string;
files: TransferFileItem[];
}
export interface LookupTransferSessionResponse {
sessionId: string;
pickupCode: string;
expiresAt: string;
}
export interface TransferSignalEnvelope {
cursor: number;
type: string;
payload: string;
}
export interface PollTransferSignalsResponse {
items: TransferSignalEnvelope[];
nextCursor: number;
}

View File

@@ -0,0 +1,209 @@
import React, { useEffect, useState } from 'react';
import { CheckCircle2, DownloadCloud, Link2, Loader2, LogIn, Save } 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';
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 FileShare() {
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((response) => {
if (!active) {
return;
}
setDetails(response);
})
.catch((requestError) => {
if (!active) {
return;
}
setError(requestError instanceof Error ? requestError.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 (requestError) {
setError(requestError instanceof Error ? requestError.message : '导入共享文件失败');
throw requestError;
} finally {
setImporting(false);
}
}
return (
<div className="min-h-screen bg-[#07101D] px-4 py-10 text-white">
<div className="mx-auto w-full max-w-3xl">
<div className="mb-10 text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-cyan-500 via-sky-500 to-blue-500 shadow-lg shadow-cyan-500/20">
<Link2 className="h-8 w-8 text-white" />
</div>
<h1 className="text-3xl font-bold"></h1>
<p className="mt-3 text-slate-400"></p>
</div>
<div className="rounded-3xl border border-white/10 bg-[#0f172a]/80 p-8 shadow-2xl backdrop-blur-xl">
{loading ? (
<div className="flex items-center justify-center gap-3 py-20 text-slate-300">
<Loader2 className="h-5 w-5 animate-spin" />
...
</div>
) : error ? (
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/10 px-5 py-4 text-sm text-rose-200">
{error}
</div>
) : details ? (
<div className="space-y-6">
<div className="rounded-2xl border border-white/5 bg-black/20 p-6">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-cyan-500/10">
<DownloadCloud className="h-6 w-6 text-cyan-300" />
</div>
<div className="min-w-0 flex-1">
<h2 className="truncate text-xl font-semibold text-white">{details.filename}</h2>
<p className="mt-2 text-sm text-slate-400">
<span className="text-slate-200">{details.ownerUsername}</span> · {formatFileSize(details.size)}
</p>
<p className="mt-1 text-xs text-slate-500">
{new Date(details.createdAt).toLocaleString('zh-CN')}
</p>
</div>
</div>
</div>
{!session?.token ? (
<div className="rounded-2xl border border-amber-400/20 bg-amber-500/10 p-6">
<p className="text-sm text-amber-100"></p>
<Button
className="mt-4 bg-[#336EFF] hover:bg-blue-600 text-white"
onClick={() => navigate(`/login?next=${encodeURIComponent(location.pathname + location.search)}`)}
>
<LogIn className="mr-2 h-4 w-4" />
</Button>
</div>
) : (
<div className="rounded-2xl border border-white/5 bg-black/20 p-6">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4">
<p className="text-sm font-medium text-slate-200"></p>
<p className="mt-2 text-sm text-emerald-300">{path}</p>
<p className="mt-1 text-xs text-slate-500"></p>
</div>
{importedFile ? (
<div className="mt-5 rounded-2xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-4 text-sm text-emerald-100">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-emerald-300" />
{importedFile.path}/{importedFile.filename}
</div>
<Button
variant="outline"
className="mt-4 border-white/10 text-slate-100 hover:bg-white/10"
onClick={() => navigate('/files')}
>
</Button>
</div>
) : (
<Button
className="mt-5 bg-emerald-500 hover:bg-emerald-600 text-white"
disabled={importing}
onClick={() => setPathPickerOpen(true)}
>
{importing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
</>
)}
</Button>
)}
</div>
)}
</div>
) : null}
</div>
</div>
<NetdiskPathPickerModal
isOpen={pathPickerOpen}
title="选择导入位置"
description="选择这个分享文件要导入到你网盘中的哪个目录。"
initialPath={path}
confirmLabel="导入到这里"
onClose={() => setPathPickerOpen(false)}
onConfirm={handleImportToPath}
/>
</div>
);
}

View File

@@ -18,17 +18,23 @@ import {
LayoutGrid,
List,
MoreVertical,
Copy,
Share2,
TriangleAlert,
X,
Edit2,
Trash2,
} 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 { 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 { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { createFileShareLink, getCurrentFileShareUrl } from '@/src/lib/file-share';
import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache';
import type { DownloadUrlResponse, FileMetadata, InitiateUploadResponse, PageResponse } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
@@ -122,6 +128,7 @@ function toUiFile(file: FileMetadata) {
}
type UiFile = ReturnType<typeof toUiFile>;
type NetdiskTargetAction = 'move' | 'copy';
export default function Files() {
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
@@ -139,11 +146,14 @@ export default function Files() {
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 [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<'list' | 'grid'>('list');
const [renameError, setRenameError] = useState('');
const [isRenaming, setIsRenaming] = useState(false);
const [shareStatus, setShareStatus] = useState('');
const loadCurrentPath = async (pathParts: string[]) => {
const response = await apiRequest<PageResponse<FileMetadata>>(
@@ -210,6 +220,12 @@ export default function Files() {
setDeleteModalOpen(true);
};
const openTargetActionModal = (file: UiFile, action: NetdiskTargetAction) => {
setTargetAction(action);
setTargetActionFile(file);
setActiveDropdown(null);
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
@@ -478,6 +494,23 @@ export default function Files() {
await loadCurrentPath(currentPath).catch(() => undefined);
};
const handleMoveToPath = async (path: string) => {
if (!targetActionFile || !targetAction) {
return;
}
if (targetAction === 'move') {
await moveFileToNetdiskPath(targetActionFile.id, path);
setSelectedFile((previous) => clearSelectionIfDeleted(previous, targetActionFile.id));
} else {
await copyFileToNetdiskPath(targetActionFile.id, path);
}
setTargetAction(null);
setTargetActionFile(null);
await loadCurrentPath(currentPath).catch(() => undefined);
};
const handleDownload = async (targetFile: UiFile | null = selectedFile) => {
if (!targetFile) {
return;
@@ -526,6 +559,21 @@ export default function Files() {
setUploads([]);
};
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 lg:flex-row gap-6 h-[calc(100vh-8rem)]">
{/* Left Sidebar */}
@@ -591,6 +639,9 @@ export default function Files() {
</React.Fragment>
))}
</div>
{shareStatus ? (
<div className="hidden max-w-xs truncate text-xs text-emerald-300 md:block">{shareStatus}</div>
) : null}
<div className="flex items-center gap-2 bg-black/20 p-1 rounded-lg">
<button
onClick={() => setViewMode('list')}
@@ -665,6 +716,9 @@ export default function Files() {
activeDropdown={activeDropdown}
onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))}
onDownload={handleDownload}
onShare={handleShare}
onMove={(targetFile) => openTargetActionModal(targetFile, 'move')}
onCopy={(targetFile) => openTargetActionModal(targetFile, 'copy')}
onRename={openRenameModal}
onDelete={openDeleteModal}
onClose={() => setActiveDropdown(null)}
@@ -694,6 +748,9 @@ export default function Files() {
activeDropdown={activeDropdown}
onToggle={(fileId) => setActiveDropdown((previous) => (previous === fileId ? null : fileId))}
onDownload={handleDownload}
onShare={handleShare}
onMove={(file) => openTargetActionModal(file, 'move')}
onCopy={(file) => openTargetActionModal(file, 'copy')}
onRename={openRenameModal}
onDelete={openDeleteModal}
onClose={() => setActiveDropdown(null)}
@@ -772,9 +829,20 @@ export default function Files() {
<div className="pt-4 space-y-3 border-t border-white/10">
<div className="grid grid-cols-2 gap-3">
{selectedFile.type !== 'folder' ? (
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => void handleShare(selectedFile)}>
<Share2 className="w-4 h-4" />
</Button>
) : null}
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => openRenameModal(selectedFile)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => openTargetActionModal(selectedFile, 'move')}>
<Folder className="w-4 h-4" />
</Button>
<Button variant="outline" className="w-full gap-2 bg-white/5 border-white/10 hover:bg-white/10" onClick={() => openTargetActionModal(selectedFile, 'copy')}>
<Copy className="w-4 h-4" />
</Button>
<Button
variant="outline"
className="w-full gap-2 border-red-500/20 bg-red-500/5 text-red-400 hover:bg-red-500/10 hover:text-red-300"
@@ -798,6 +866,11 @@ export default function Files() {
<Download className="w-4 h-4" />
</Button>
)}
{shareStatus && selectedFile.type !== 'folder' ? (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-200">
{shareStatus}
</div>
) : null}
</div>
</CardContent>
</Card>
@@ -1024,6 +1097,23 @@ export default function Files() {
</div>
)}
</AnimatePresence>
<NetdiskPathPickerModal
isOpen={Boolean(targetActionFile && targetAction)}
title={targetAction === 'copy' ? '选择复制目标' : '选择移动目标'}
description={
targetAction === 'copy'
? '选择要把当前文件或文件夹复制到哪个目录。'
: '选择要把当前文件或文件夹移动到哪个目录。'
}
initialPath={toBackendPath(currentPath)}
confirmLabel={targetAction === 'copy' ? '复制到这里' : '移动到这里'}
onClose={() => {
setTargetAction(null);
setTargetActionFile(null);
}}
onConfirm={handleMoveToPath}
/>
</div>
);
}
@@ -1042,6 +1132,9 @@ function FileActionMenu({
activeDropdown,
onToggle,
onDownload,
onShare,
onMove,
onCopy,
onRename,
onDelete,
onClose,
@@ -1050,6 +1143,9 @@ function FileActionMenu({
activeDropdown: number | null;
onToggle: (fileId: number) => void;
onDownload: (file: UiFile) => Promise<void>;
onShare: (file: UiFile) => Promise<void>;
onMove: (file: UiFile) => void;
onCopy: (file: UiFile) => void;
onRename: (file: UiFile) => void;
onDelete: (file: UiFile) => void;
onClose: () => void;
@@ -1093,6 +1189,38 @@ function FileActionMenu({
>
<Download className="w-4 h-4" /> {file.type === 'folder' ? '下载文件夹' : '下载文件'}
</button>
{file.type !== 'folder' ? (
<button
onClick={(event) => {
event.stopPropagation();
void onShare(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Share2 className="w-4 h-4" />
</button>
) : null}
<button
onClick={(event) => {
event.stopPropagation();
onMove(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Folder className="w-4 h-4" />
</button>
<button
onClick={(event) => {
event.stopPropagation();
onCopy(file);
onClose();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/10 hover:text-white"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={(event) => {
event.stopPropagation();

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { motion, AnimatePresence } from 'motion/react';
import { LogIn, User, Lock, UserPlus, Mail, ArrowLeft, Phone } from 'lucide-react';
@@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src
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';
@@ -15,6 +16,7 @@ const DEV_LOGIN_ENABLED = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEV
export default function Login() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [isLogin, setIsLogin] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -63,7 +65,7 @@ export default function Login() {
saveStoredSession(createSession(auth));
markPostLoginPending();
setLoading(false);
navigate('/overview');
navigate(getPostLoginRedirectPath(searchParams.get('next')));
} catch (requestError) {
setLoading(false);
setError(requestError instanceof Error ? requestError.message : '登录失败,请稍后重试');
@@ -89,7 +91,7 @@ export default function Login() {
saveStoredSession(createSession(auth));
markPostLoginPending();
setLoading(false);
navigate('/overview');
navigate(getPostLoginRedirectPath(searchParams.get('next')));
} catch (requestError) {
setLoading(false);
setError(requestError instanceof Error ? requestError.message : '注册失败,请稍后重试');
@@ -127,7 +129,7 @@ export default function Login() {
</div>
<p className="text-lg text-slate-400 leading-relaxed">
YOYUZH
YOYUZH 使
</p>
</motion.div>
)}

View File

@@ -2,27 +2,27 @@ import React, { useEffect, useMemo, useState } from 'react';
import { motion } from 'motion/react';
import { useNavigate } from 'react-router-dom';
import {
FileText,
Upload,
FolderPlus,
Database,
GraduationCap,
BookOpen,
Clock,
User,
Mail,
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 { apiDownload, apiRequest } from '@/src/lib/api';
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { shouldLoadAvatarWithAuth } from '@/src/components/layout/account-utils';
import { getOverviewCacheKey, getSchoolResultsCacheKey, readStoredSchoolQuery, writeStoredSchoolQuery } from '@/src/lib/page-cache';
import { cacheLatestSchoolData, fetchLatestSchoolData } from '@/src/lib/school';
import { getOverviewCacheKey } from '@/src/lib/page-cache';
import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session';
import type { CourseResponse, FileMetadata, GradeResponse, PageResponse, UserProfile } from '@/src/lib/types';
import type { FileMetadata, PageResponse, UserProfile } from '@/src/lib/types';
import { getOverviewLoadErrorMessage } from './overview-state';
function formatFileSize(size: number) {
@@ -53,26 +53,14 @@ function formatRecentTime(value: string) {
export default function Overview() {
const navigate = useNavigate();
const storedSchoolQuery = readStoredSchoolQuery();
const cachedSchoolResults =
storedSchoolQuery?.studentId && storedSchoolQuery?.semester
? readCachedValue<{
schedule: CourseResponse[];
grades: GradeResponse[];
}>(getSchoolResultsCacheKey(storedSchoolQuery.studentId, storedSchoolQuery.semester))
: null;
const cachedOverview = readCachedValue<{
profile: UserProfile | null;
recentFiles: FileMetadata[];
rootFiles: FileMetadata[];
schedule: CourseResponse[];
grades: GradeResponse[];
}>(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 [schedule, setSchedule] = useState<CourseResponse[]>(cachedOverview?.schedule ?? cachedSchoolResults?.schedule ?? []);
const [grades, setGrades] = useState<GradeResponse[]>(cachedOverview?.grades ?? cachedSchoolResults?.grades ?? []);
const [loadingError, setLoadingError] = useState('');
const [retryToken, setRetryToken] = useState(0);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
@@ -85,14 +73,17 @@ export default function Overview() {
const currentTime = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
const recentWeekUploads = recentFiles.filter(
(file) => Date.now() - new Date(file.createdAt).getTime() <= 7 * 24 * 60 * 60 * 1000
(file) => Date.now() - new Date(file.createdAt).getTime() <= 7 * 24 * 60 * 60 * 1000,
).length;
const usedBytes = useMemo(
() => rootFiles.filter((file) => !file.directory).reduce((sum, file) => sum + file.size, 0),
[rootFiles]
[rootFiles],
);
const usedGb = usedBytes / 1024 / 1024 / 1024;
const storagePercent = Math.min((usedGb / 50) * 100, 100);
const latestFile = recentFiles[0] ?? null;
const profileDisplayName = profile?.displayName || profile?.username || '未登录';
const profileAvatarFallback = profileDisplayName.charAt(0).toUpperCase();
useEffect(() => {
let cancelled = false;
@@ -108,114 +99,39 @@ export default function Overview() {
apiRequest<PageResponse<FileMetadata>>('/files/list?path=%2F&page=0&size=100'),
]);
const primaryFailures = [userResult, recentResult, rootResult].filter(
(result) => result.status === 'rejected'
);
const failures = [userResult, recentResult, rootResult].filter((result) => result.status === 'rejected');
if (cancelled) {
return;
}
if (userResult.status === 'fulfilled') {
setProfile(userResult.value);
}
if (recentResult.status === 'fulfilled') {
setRecentFiles(recentResult.value);
}
if (rootResult.status === 'fulfilled') {
setRootFiles(rootResult.value.items);
}
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;
let scheduleData: CourseResponse[] = [];
let gradesData: GradeResponse[] = [];
const schoolQuery = readStoredSchoolQuery();
let schoolFailed = false;
setProfile(nextProfile);
setRecentFiles(nextRecentFiles);
setRootFiles(nextRootFiles);
writeCachedValue(getOverviewCacheKey(), {
profile: nextProfile,
recentFiles: nextRecentFiles,
rootFiles: nextRootFiles,
});
if (schoolQuery?.studentId && schoolQuery?.semester) {
const queryString = new URLSearchParams({
studentId: schoolQuery.studentId,
semester: schoolQuery.semester,
}).toString();
const [scheduleResult, gradesResult] = await Promise.allSettled([
apiRequest<CourseResponse[]>(`/cqu/schedule?${queryString}`),
apiRequest<GradeResponse[]>(`/cqu/grades?${queryString}`),
]);
if (scheduleResult.status === 'fulfilled') {
scheduleData = scheduleResult.value;
} else {
schoolFailed = true;
}
if (gradesResult.status === 'fulfilled') {
gradesData = gradesResult.value;
} else {
schoolFailed = true;
}
if (failures.length > 0) {
setLoadingError(getOverviewLoadErrorMessage(pendingAfterLogin));
} else {
try {
const latest = await fetchLatestSchoolData();
if (latest) {
cacheLatestSchoolData(latest);
writeStoredSchoolQuery({
studentId: latest.studentId,
semester: latest.semester,
});
scheduleData = latest.schedule;
gradesData = latest.grades;
}
} catch {
schoolFailed = true;
}
}
if (!cancelled) {
setSchedule(scheduleData);
setGrades(gradesData);
writeCachedValue(getOverviewCacheKey(), {
profile:
userResult.status === 'fulfilled'
? userResult.value
: profile,
recentFiles:
recentResult.status === 'fulfilled'
? recentResult.value
: recentFiles,
rootFiles:
rootResult.status === 'fulfilled'
? rootResult.value.items
: rootFiles,
schedule: scheduleData,
grades: gradesData,
});
if (primaryFailures.length > 0 || schoolFailed) {
setLoadingError(getOverviewLoadErrorMessage(pendingAfterLogin));
} else {
clearPostLoginPending();
}
clearPostLoginPending();
}
} catch {
const schoolQuery = readStoredSchoolQuery();
if (!cancelled && schoolQuery?.studentId && schoolQuery?.semester) {
const cachedSchoolResults = readCachedValue<{
schedule: CourseResponse[];
grades: GradeResponse[];
}>(getSchoolResultsCacheKey(schoolQuery.studentId, schoolQuery.semester));
if (cachedSchoolResults) {
setSchedule(cachedSchoolResults.schedule);
setGrades(cachedSchoolResults.grades);
}
}
if (!cancelled) {
setLoadingError(getOverviewLoadErrorMessage(pendingAfterLogin));
}
}
}
loadOverview();
void loadOverview();
return () => {
cancelled = true;
};
@@ -264,14 +180,8 @@ export default function Overview() {
};
}, [profile?.avatarUrl]);
const latestSemester = grades[0]?.semester ?? '--';
const previewCourses = schedule.slice(0, 3);
const profileDisplayName = profile?.displayName || profile?.username || '未登录';
const profileAvatarFallback = profileDisplayName.charAt(0).toUpperCase();
return (
<div className="space-y-6">
{/* Hero Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@@ -284,12 +194,12 @@ export default function Overview() {
</h1>
<p className="text-[#336EFF] font-medium"> {currentTime} · {greeting}</p>
<p className="text-sm text-slate-400 mt-4 max-w-xl leading-relaxed">
</p>
</div>
</motion.div>
{loadingError && (
{loadingError ? (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }}>
<Card className="border-amber-400/20 bg-amber-500/10">
<CardContent className="flex flex-col gap-3 p-4 text-sm text-amber-100 md:flex-row md:items-center md:justify-between">
@@ -300,38 +210,35 @@ export default function Overview() {
</CardContent>
</Card>
</motion.div>
)}
) : null}
{/* Metrics Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard title="网盘文件总数" value={`${rootFiles.length}`} desc="当前根目录统计" icon={FileText} delay={0.1} />
<MetricCard
title="最近 7 天上传"
value={`${recentWeekUploads}`}
desc={recentFiles[0] ? `最新更新于 ${formatRecentTime(recentFiles[0].createdAt)}` : '暂无最近上传'}
desc={latestFile ? `最新更新于 ${formatRecentTime(latestFile.createdAt)}` : '暂无最近上传'}
icon={Upload}
delay={0.2}
/>
<MetricCard
title="本周课程"
value={`${schedule.length}`}
desc={schedule.length > 0 ? `当前已同步 ${schedule.length} 节课` : '请先前往教务页查询'}
icon={BookOpen}
title="快传入口"
value={latestFile ? '就绪' : '待命'}
desc="可随时生成临时取件码分享文件"
icon={Send}
delay={0.3}
/>
<MetricCard
title="已录入成绩"
value={`${grades.length}`}
desc={`最近学期:${latestSemester}`}
icon={GraduationCap}
title="存储占用"
value={`${storagePercent.toFixed(1)}%`}
desc={`${usedGb.toFixed(2)} GB / 50 GB`}
icon={Database}
delay={0.4}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column */}
<div className="lg:col-span-2 space-y-6">
{/* Recent Files */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle></CardTitle>
@@ -341,8 +248,12 @@ export default function Overview() {
</CardHeader>
<CardContent>
<div className="space-y-2">
{recentFiles.slice(0, 3).map((file, i) => (
<div key={i} className="flex items-center justify-between p-3 rounded-xl hover:bg-white/5 transition-colors cursor-pointer group" onClick={() => navigate('/files')}>
{recentFiles.slice(0, 3).map((file, index) => (
<div
key={`${file.id}-${index}`}
className="flex items-center justify-between p-3 rounded-xl hover:bg-white/5 transition-colors cursor-pointer group"
onClick={() => navigate('/files')}
>
<div className="flex items-center gap-4 overflow-hidden">
<div className="w-10 h-10 rounded-xl bg-[#336EFF]/10 flex items-center justify-center shrink-0 group-hover:bg-[#336EFF]/20 transition-colors">
<FileText className="w-5 h-5 text-[#336EFF]" />
@@ -355,52 +266,47 @@ export default function Overview() {
<span className="text-xs text-slate-500 font-mono shrink-0 ml-4">{formatFileSize(file.size)}</span>
</div>
))}
{recentFiles.length === 0 && (
{recentFiles.length === 0 ? (
<div className="p-3 rounded-xl border border-dashed border-white/10 text-sm text-slate-500">
</div>
)}
) : null}
</div>
</CardContent>
</Card>
{/* Schedule */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle> / </CardTitle>
<div className="flex bg-black/20 rounded-lg p-1">
<button className="px-3 py-1 text-xs font-medium rounded-md bg-[#336EFF] text-white shadow-sm transition-colors"></button>
<button className="px-3 py-1 text-xs font-medium rounded-md text-slate-400 hover:text-white transition-colors"></button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{previewCourses.map((course, i) => (
<div key={i} className="flex items-center gap-4 p-4 rounded-xl border border-white/5 bg-white/[0.02] hover:bg-white/[0.04] transition-colors">
<div className="w-28 shrink-0 text-sm font-mono text-[#336EFF] bg-[#336EFF]/10 px-2 py-1 rounded-md text-center">
{course.startTime ?? '--'} - {course.endTime ?? '--'}
<Card className="overflow-hidden">
<CardContent className="p-0">
<div className="relative overflow-hidden rounded-2xl bg-[radial-gradient(circle_at_top_left,rgba(51,110,255,0.22),transparent_45%),linear-gradient(135deg,rgba(15,23,42,0.94),rgba(15,23,42,0.8))] p-6">
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-cyan-400/10 blur-2xl" />
<div className="relative z-10 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="space-y-3">
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-400/20 bg-cyan-400/10 px-3 py-1 text-xs font-medium text-cyan-100">
<Zap className="h-3.5 w-3.5" />
</div>
<div className="flex-1 truncate">
<p className="text-sm font-medium text-white truncate">{course.courseName}</p>
<p className="text-xs text-slate-400 flex items-center gap-1.5 mt-1">
<Clock className="w-3.5 h-3.5" /> {course.classroom ?? '教室待定'}
<div>
<h3 className="text-2xl font-semibold text-white">P2P </h3>
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-300">
</p>
</div>
<div className="flex flex-wrap gap-3 text-xs text-slate-400">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1"></span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1"></span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1"></span>
</div>
</div>
))}
{previewCourses.length === 0 && (
<div className="p-4 rounded-xl border border-dashed border-white/10 text-sm text-slate-500">
</div>
)}
<Button className="shrink-0" onClick={() => navigate('/transfer')}>
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Right Column */}
<div className="space-y-6">
{/* Quick Actions */}
<Card>
<CardHeader className="pb-4">
<CardTitle></CardTitle>
@@ -410,12 +316,11 @@ export default function Overview() {
<QuickAction icon={Upload} label="上传文件" onClick={() => navigate('/files')} />
<QuickAction icon={FolderPlus} label="新建文件夹" onClick={() => navigate('/files')} />
<QuickAction icon={Database} label="进入网盘" onClick={() => navigate('/files')} />
<QuickAction icon={GraduationCap} label="查询成绩" onClick={() => navigate('/school')} />
<QuickAction icon={Send} label="打开快传" onClick={() => navigate('/transfer')} />
</div>
</CardContent>
</Card>
{/* Storage */}
<Card>
<CardHeader className="pb-4">
<CardTitle></CardTitle>
@@ -436,25 +341,34 @@ export default function Overview() {
</CardContent>
</Card>
{/* Account Info */}
<Card>
<CardHeader className="pb-4">
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 p-4 rounded-xl bg-white/[0.02] border border-white/5">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center text-white font-bold text-xl shadow-lg overflow-hidden">
{avatarUrl ? (
<img src={avatarUrl} alt="Avatar" className="w-full h-full object-cover" />
) : (
profileAvatarFallback
)}
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-indigo-500 to-cyan-500 flex items-center justify-center text-white font-bold text-xl shadow-lg overflow-hidden">
{avatarUrl ? <img src={avatarUrl} alt="Avatar" className="w-full h-full object-cover" /> : profileAvatarFallback}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-white truncate">{profileDisplayName}</p>
<p className="text-xs text-slate-400 truncate mt-0.5">{profile?.email ?? '暂无邮箱'}</p>
</div>
</div>
<div className="mt-4 space-y-2 text-sm text-slate-400">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-slate-500" />
<span>{profile?.username ?? '未登录'}</span>
</div>
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-slate-500" />
<span>{profile?.email ?? '暂无邮箱'}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-slate-500" />
<span>{latestFile ? `最近一次文件更新:${formatRecentTime(latestFile.createdAt)}` : '最近还没有文件变动'}</span>
</div>
</div>
</CardContent>
</Card>
</div>
@@ -463,13 +377,21 @@ export default function Overview() {
);
}
function MetricCard({ title, value, desc, icon: Icon, delay }: any) {
function MetricCard({
title,
value,
desc,
icon: Icon,
delay,
}: {
title: string;
value: string;
desc: string;
icon: React.ComponentType<{ className?: string }>;
delay: number;
}) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay }}
>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay }}>
<Card className="h-full hover:bg-white/[0.04] transition-colors">
<CardContent className="p-6 flex flex-col gap-4">
<div className="flex justify-between items-start">
@@ -488,7 +410,15 @@ function MetricCard({ title, value, desc, icon: Icon, delay }: any) {
);
}
function QuickAction({ icon: Icon, label, onClick }: any) {
function QuickAction({
icon: Icon,
label,
onClick,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
}) {
return (
<button
onClick={onClick}

View File

@@ -1,596 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { motion } from 'motion/react';
import { Award, BookOpen, Calendar, Lock, MapPin, Search, User } from 'lucide-react';
import { Button } from '@/src/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/src/components/ui/card';
import { Input } from '@/src/components/ui/input';
import { apiRequest } from '@/src/lib/api';
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { getSchoolResultsCacheKey, readStoredSchoolQuery, writeStoredSchoolQuery } from '@/src/lib/page-cache';
import { cacheLatestSchoolData, fetchLatestSchoolData } from '@/src/lib/school';
import { buildScheduleTable } from '@/src/lib/schedule-table';
import type { CourseResponse, GradeResponse, LatestSchoolDataResponse } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
function formatSections(startTime?: number | null, endTime?: number | null) {
if (!startTime || !endTime) {
return '节次待定';
}
return `${startTime}-${endTime}`;
}
function getCourseTheme(courseName?: string) {
const themes = [
{
panel: 'bg-gradient-to-br from-[#336EFF]/26 via-[#4D7FFF]/18 to-[#7AA2FF]/12',
border: 'border-[#5E88FF]/45',
accent: 'bg-[#5D8BFF]',
title: 'text-blue-50',
meta: 'text-blue-100/80',
badge: 'bg-[#336EFF]/22 text-blue-100',
shadow: 'shadow-[0_10px_30px_rgba(51,110,255,0.18)]',
},
{
panel: 'bg-gradient-to-br from-cyan-500/24 via-sky-500/18 to-blue-500/10',
border: 'border-cyan-400/40',
accent: 'bg-cyan-400',
title: 'text-cyan-50',
meta: 'text-cyan-100/80',
badge: 'bg-cyan-500/18 text-cyan-100',
shadow: 'shadow-[0_10px_30px_rgba(34,211,238,0.16)]',
},
{
panel: 'bg-gradient-to-br from-indigo-500/24 via-blue-500/18 to-slate-500/8',
border: 'border-indigo-400/40',
accent: 'bg-indigo-400',
title: 'text-indigo-50',
meta: 'text-indigo-100/80',
badge: 'bg-indigo-500/18 text-indigo-100',
shadow: 'shadow-[0_10px_30px_rgba(99,102,241,0.16)]',
},
{
panel: 'bg-gradient-to-br from-sky-500/24 via-blue-500/16 to-violet-500/10',
border: 'border-sky-400/40',
accent: 'bg-sky-400',
title: 'text-sky-50',
meta: 'text-sky-100/80',
badge: 'bg-sky-500/18 text-sky-100',
shadow: 'shadow-[0_10px_30px_rgba(14,165,233,0.16)]',
},
{
panel: 'bg-gradient-to-br from-violet-500/22 via-indigo-500/16 to-blue-500/10',
border: 'border-violet-400/38',
accent: 'bg-violet-400',
title: 'text-violet-50',
meta: 'text-violet-100/80',
badge: 'bg-violet-500/18 text-violet-100',
shadow: 'shadow-[0_10px_30px_rgba(139,92,246,0.14)]',
},
{
panel: 'bg-gradient-to-br from-teal-500/22 via-cyan-500/16 to-sky-500/10',
border: 'border-teal-400/38',
accent: 'bg-teal-400',
title: 'text-teal-50',
meta: 'text-teal-100/80',
badge: 'bg-teal-500/18 text-teal-100',
shadow: 'shadow-[0_10px_30px_rgba(45,212,191,0.14)]',
},
];
if (!courseName) {
return themes[0];
}
let hash = 0;
for (let index = 0; index < courseName.length; index += 1) {
hash = courseName.charCodeAt(index) + ((hash << 5) - hash);
}
return themes[Math.abs(hash) % themes.length];
}
export default function School() {
const storedQuery = readStoredSchoolQuery();
const initialStudentId = storedQuery?.studentId ?? '2023123456';
const initialSemester = storedQuery?.semester ?? '2025-spring';
const initialCachedResults = readCachedValue<{
queried: boolean;
schedule: CourseResponse[];
grades: GradeResponse[];
}>(getSchoolResultsCacheKey(initialStudentId, initialSemester));
const [activeTab, setActiveTab] = useState<'schedule' | 'grades'>('schedule');
const [studentId, setStudentId] = useState(initialStudentId);
const [password, setPassword] = useState('password123');
const [semester, setSemester] = useState(initialSemester);
const [loading, setLoading] = useState(false);
const [queried, setQueried] = useState(initialCachedResults?.queried ?? false);
const [schedule, setSchedule] = useState<CourseResponse[]>(initialCachedResults?.schedule ?? []);
const [grades, setGrades] = useState<GradeResponse[]>(initialCachedResults?.grades ?? []);
const applySchoolResults = (results: LatestSchoolDataResponse) => {
setStudentId(results.studentId);
setSemester(results.semester);
setQueried(true);
setSchedule(results.schedule);
setGrades(results.grades);
cacheLatestSchoolData(results);
};
const averageGrade = useMemo(() => {
if (grades.length === 0) {
return '0.0';
}
const sum = grades.reduce((total, item) => total + (item.grade ?? 0), 0);
return (sum / grades.length).toFixed(1);
}, [grades]);
const loadSchoolData = async (
nextStudentId: string,
nextSemester: string,
options: { background?: boolean; refresh?: boolean } = {},
) => {
const cacheKey = getSchoolResultsCacheKey(nextStudentId, nextSemester);
const cachedResults = readCachedValue<{
queried: boolean;
schedule: CourseResponse[];
grades: GradeResponse[];
}>(cacheKey);
if (!options.background) {
setLoading(true);
}
writeStoredSchoolQuery({
studentId: nextStudentId,
semester: nextSemester,
});
try {
const queryString = new URLSearchParams({
studentId: nextStudentId,
semester: nextSemester,
refresh: options.refresh ? 'true' : 'false',
}).toString();
const [scheduleData, gradeData] = await Promise.all([
apiRequest<CourseResponse[]>(`/cqu/schedule?${queryString}`),
apiRequest<GradeResponse[]>(`/cqu/grades?${queryString}`),
]);
setQueried(true);
setSchedule(scheduleData);
setGrades(gradeData);
writeCachedValue(cacheKey, {
queried: true,
studentId: nextStudentId,
semester: nextSemester,
schedule: scheduleData,
grades: gradeData,
});
} catch {
if (!cachedResults) {
setQueried(false);
setSchedule([]);
setGrades([]);
}
} finally {
if (!options.background) {
setLoading(false);
}
}
};
useEffect(() => {
let cancelled = false;
async function loadInitialSchoolData() {
if (storedQuery) {
await loadSchoolData(storedQuery.studentId, storedQuery.semester, {
background: true,
});
return;
}
const latest = await fetchLatestSchoolData();
if (!latest || cancelled) {
return;
}
applySchoolResults(latest);
}
loadInitialSchoolData().catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
const handleQuery = async (event: React.FormEvent) => {
event.preventDefault();
await loadSchoolData(studentId, semester, { refresh: true });
};
return (
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="w-5 h-5 text-[#336EFF]" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleQuery} className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 ml-1"></label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input value={studentId} onChange={(event) => setStudentId(event.target.value)} className="pl-9 bg-black/20" required />
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 ml-1"></label>
<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" value={password} onChange={(event) => setPassword(event.target.value)} className="pl-9 bg-black/20" required />
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 ml-1"></label>
<select
value={semester}
onChange={(event) => setSemester(event.target.value)}
className="flex h-11 w-full rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#336EFF]"
>
<option value="2025-spring">2025 </option>
<option value="2024-fall">2024 </option>
<option value="2024-spring">2024 </option>
</select>
</div>
<div className="grid grid-cols-2 gap-3 pt-2">
<Button type="submit" disabled={loading} className="w-full">
{loading ? '查询中...' : '查询课表'}
</Button>
<Button
type="submit"
variant="outline"
disabled={loading}
className="w-full"
onClick={() => setActiveTab('grades')}
>
{loading ? '查询中...' : '查询成绩'}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DatabaseIcon className="w-5 h-5 text-[#336EFF]" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{queried ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SummaryItem label="当前账号" value={studentId} icon={User} />
<SummaryItem label="当前学期" value={semester} icon={Calendar} />
<SummaryItem label="平均成绩" value={`${averageGrade}`} icon={Award} />
</div>
) : (
<div className="h-40 flex flex-col items-center justify-center text-slate-500 space-y-3 border border-dashed border-white/10 rounded-xl bg-white/[0.01]">
<Search className="w-8 h-8 opacity-50" />
<p className="text-sm"></p>
</div>
)}
</CardContent>
</Card>
</div>
<div className="flex bg-black/20 p-1 rounded-xl w-fit">
<button
onClick={() => setActiveTab('schedule')}
className={cn(
'px-6 py-2 text-sm font-medium rounded-lg transition-all',
activeTab === 'schedule' ? 'bg-[#336EFF] text-white shadow-md' : 'text-slate-400 hover:text-white',
)}
>
</button>
<button
onClick={() => setActiveTab('grades')}
className={cn(
'px-6 py-2 text-sm font-medium rounded-lg transition-all',
activeTab === 'grades' ? 'bg-[#336EFF] text-white shadow-md' : 'text-slate-400 hover:text-white',
)}
>
</button>
</div>
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{activeTab === 'schedule' ? <ScheduleView queried={queried} schedule={schedule} /> : <GradesView queried={queried} grades={grades} />}
</motion.div>
</div>
);
}
function DatabaseIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
<path d="M3 12A9 3 0 0 0 21 12" />
</svg>
);
}
function SummaryItem({
label,
value,
icon: Icon,
}: {
label: string;
value: string;
icon: React.ComponentType<{ className?: string }>;
}) {
return (
<div className="p-4 rounded-xl bg-white/[0.02] border border-white/5 flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-[#336EFF]/10 flex items-center justify-center shrink-0">
<Icon className="w-5 h-5 text-[#336EFF]" />
</div>
<div>
<p className="text-xs text-slate-400 mb-0.5">{label}</p>
<p className="text-sm font-medium text-white">{value}</p>
</div>
</div>
);
}
function ScheduleView({ queried, schedule }: { queried: boolean; schedule: CourseResponse[] }) {
if (!queried) {
return (
<Card>
<CardContent className="flex h-64 flex-col items-center justify-center text-slate-500">
<BookOpen className="mb-4 h-12 w-12 opacity-20" />
<p></p>
</CardContent>
</Card>
);
}
const days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const periodLabels: Record<'morning' | 'noon' | 'afternoon' | 'evening', string> = {
morning: '上午',
noon: '中午',
afternoon: '下午',
evening: '晚上',
};
const periodOrder = ['morning', 'noon', 'afternoon', 'evening'] as const;
const rows = buildScheduleTable(schedule);
return (
<Card className="overflow-hidden border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.9),rgba(15,23,42,0.72))]">
<CardHeader className="border-b border-white/8 bg-white/[0.02]">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-xl"></CardTitle>
<CardDescription></CardDescription>
</div>
<div className="flex flex-wrap gap-2 text-xs text-slate-300">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1"> 1-4 </span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1"> 5 </span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1"> 6-9 </span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1"> 10-12 </span>
</div>
</div>
</CardHeader>
<CardContent className="p-4 md:p-5">
<div className="overflow-x-auto">
<div
className="grid min-w-[1180px] gap-2"
style={{
gridTemplateColumns: '88px 96px repeat(7, minmax(138px, 1fr))',
gridTemplateRows: '48px repeat(12, 96px)',
}}
>
<div className="rounded-2xl bg-white/[0.04] px-3 py-3 text-left text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">
</div>
<div className="rounded-2xl bg-white/[0.04] px-3 py-3 text-left text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">
</div>
{days.map((day) => (
<div key={day} className="rounded-2xl bg-white/[0.04] px-3 py-3 text-center text-sm font-medium text-slate-200">
{day}
</div>
))}
{periodOrder.map((period, index) => (
<div
key={period}
style={{
gridColumn: 1,
gridRow:
period === 'morning'
? '2 / span 4'
: period === 'noon'
? '6 / span 1'
: period === 'afternoon'
? '7 / span 4'
: '11 / span 3',
}}
className="flex h-full rounded-2xl border border-white/8 bg-white/[0.03] px-3 py-4"
>
<div className="flex flex-1 items-center justify-center rounded-xl bg-black/20 text-sm font-semibold tracking-[0.25em] text-slate-300 [writing-mode:vertical-rl]">
{periodLabels[period]}
</div>
</div>
))}
{rows.map((row) => (
<div
key={`section-${row.section}`}
style={{ gridColumn: 2, gridRow: row.section + 1 }}
className="flex h-full flex-col justify-center rounded-2xl border border-white/8 bg-white/[0.03] px-3"
>
<span className="text-[11px] uppercase tracking-[0.22em] text-slate-500">Section</span>
<span className="mt-1 text-lg font-semibold text-white">{row.section}</span>
</div>
))}
{rows.flatMap((row) =>
row.slots.map((slot, columnIndex) => {
if (slot.type !== 'empty') {
return null;
}
return (
<div
key={`empty-${row.section}-${columnIndex}`}
style={{ gridColumn: columnIndex + 3, gridRow: row.section + 1 }}
className="rounded-2xl border border-dashed border-white/8 bg-white/[0.015]"
/>
);
}),
)}
{rows.flatMap((row) =>
row.slots.map((slot, columnIndex) => {
if (slot.type !== 'course') {
return null;
}
const theme = getCourseTheme(slot.course?.courseName);
const rowSpan = slot.rowSpan ?? 1;
return (
<div
key={`course-${row.section}-${columnIndex}`}
style={{ gridColumn: columnIndex + 3, gridRow: `${row.section + 1} / span ${rowSpan}` }}
className={cn(
'group relative z-10 flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border p-3 transition duration-200 hover:-translate-y-0.5 hover:brightness-110',
theme.panel,
theme.border,
theme.shadow,
)}
>
<div className={cn('absolute inset-x-0 top-0 h-1.5', theme.accent)} />
<div className="flex items-start justify-between gap-2">
<p className={cn('text-sm font-semibold leading-5', theme.title)}>
{slot.course?.courseName}
</p>
<span className={cn('shrink-0 rounded-full px-2 py-1 text-[10px] font-medium', theme.badge)}>
{formatSections(slot.course?.startTime, slot.course?.endTime)}
</span>
</div>
<div className="mt-3 space-y-2">
<p className={cn('flex items-center gap-1.5 text-xs', theme.meta)}>
<MapPin className="h-3.5 w-3.5" />
<span>{slot.course?.classroom ?? '教室待定'}</span>
</p>
<p className={cn('flex items-center gap-1.5 text-xs', theme.meta)}>
<User className="h-3.5 w-3.5" />
<span>{slot.course?.teacher ?? '教师待定'}</span>
</p>
</div>
</div>
);
}),
)}
</div>
</div>
</CardContent>
</Card>
);
}
function GradesView({ queried, grades }: { queried: boolean; grades: GradeResponse[] }) {
if (!queried) {
return (
<Card>
<CardContent className="h-64 flex flex-col items-center justify-center text-slate-500">
<Award className="w-12 h-12 mb-4 opacity-20" />
<p></p>
</CardContent>
</Card>
);
}
const terms = grades.reduce<Record<string, number[]>>((accumulator, grade) => {
const semester = grade.semester ?? '未分类';
if (!accumulator[semester]) {
accumulator[semester] = [];
}
accumulator[semester].push(grade.grade ?? 0);
return accumulator;
}, {});
const getScoreStyle = (score: number) => {
if (score >= 95) return 'bg-[#336EFF]/50 text-white';
if (score >= 90) return 'bg-[#336EFF]/40 text-white/90';
if (score >= 85) return 'bg-[#336EFF]/30 text-white/80';
if (score >= 80) return 'bg-slate-700/60 text-white/70';
if (score >= 75) return 'bg-slate-700/40 text-white/60';
return 'bg-slate-800/60 text-white/50';
};
return (
<Card className="bg-[#0f172a]/80 backdrop-blur-sm border-slate-800/50">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-medium text-white"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{Object.entries(terms).map(([term, scores]) => (
<div key={term} className="flex flex-col">
<h3 className="mb-4 border-b border-white/5 pb-3 text-sm font-bold text-white">{term}</h3>
<div className="flex flex-col gap-2">
{scores.map((score, index) => (
<div
key={`${term}-${index}`}
className={cn(
'w-full rounded-full py-1.5 text-center text-xs font-mono font-medium transition-colors',
getScoreStyle(score),
)}
>
{score}
</div>
))}
</div>
</div>
))}
{Object.keys(terms).length === 0 ? <div className="text-sm text-slate-500"></div> : null}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,691 @@
import React, { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import {
CheckCircle,
Copy,
DownloadCloud,
File as FileIcon,
Folder,
FolderPlus,
Link as LinkIcon,
Loader2,
Monitor,
Plus,
Send,
Shield,
Smartphone,
Trash2,
UploadCloud,
X,
} from 'lucide-react';
import { 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, pollTransferSignals, postTransferSignal } from '@/src/lib/transfer';
import type { TransferSessionResponse } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
import {
buildQrImageUrl,
canSendTransferFiles,
formatTransferSize,
resolveInitialTransferTab,
} from './transfer-state';
import TransferReceive from './TransferReceive';
type SendPhase = 'idle' | 'creating' | 'waiting' | 'connecting' | 'transferring' | 'completed' | 'error';
function parseJsonPayload<T>(payload: string): T | null {
try {
return JSON.parse(payload) as T;
} catch {
return null;
}
}
function getPhaseMessage(phase: SendPhase, errorMessage: string) {
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 Transfer() {
const { session: authSession } = useAuth();
const [searchParams] = useSearchParams();
const sessionId = searchParams.get('session');
const allowSend = canSendTransferFiles(Boolean(authSession?.token));
const [activeTab, setActiveTab] = useState(() => resolveInitialTransferTab(allowSend, sessionId));
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
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 fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const copiedTimerRef = 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) {
return;
}
folderInputRef.current.setAttribute('webkitdirectory', '');
folderInputRef.current.setAttribute('directory', '');
}, []);
useEffect(() => {
return () => {
cleanupCurrentTransfer();
if (copiedTimerRef.current) {
window.clearTimeout(copiedTimerRef.current);
}
};
}, []);
useEffect(() => {
if (!allowSend || sessionId) {
setActiveTab('receive');
}
}, [allowSend, sessionId]);
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) : '';
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();
return;
}
void bootstrapTransfer(nextFiles);
}
function appendFiles(files: FileList | File[]) {
const nextFiles = [...selectedFiles, ...Array.from(files)];
ensureReadyState(nextFiles);
}
function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) {
if (event.target.files?.length) {
appendFiles(event.target.files);
}
event.target.value = '';
}
function handleDragOver(event: React.DragEvent) {
event.preventDefault();
}
function handleDrop(event: React.DragEvent) {
event.preventDefault();
if (event.dataTransfer.files?.length) {
appendFiles(event.dataTransfer.files);
}
}
function removeFile(indexToRemove: number) {
ensureReadyState(selectedFiles.filter((_, index) => index !== indexToRemove));
}
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);
if (bootstrapIdRef.current !== bootstrapId) {
return;
}
setSession(createdSession);
setSendPhase('waiting');
await setupSenderPeer(createdSession, files, bootstrapId);
} catch (error) {
if (bootstrapIdRef.current !== bootstrapId) {
return;
}
setSendPhase('error');
setSendError(error instanceof Error ? error.message : '快传会话创建失败');
}
}
async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) {
const connection = new RTCPeerConnection({
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
});
const channel = connection.createDataChannel('portal-transfer', {
ordered: true,
});
peerConnectionRef.current = connection;
dataChannelRef.current = channel;
channel.binaryType = 'arraybuffer';
connection.onicecandidate = (event) => {
if (!event.candidate) {
return;
}
void postTransferSignal(
createdSession.sessionId,
'sender',
'ice-candidate',
JSON.stringify(event.candidate.toJSON()),
);
};
connection.onconnectionstatechange = () => {
if (connection.connectionState === 'connected') {
setSendPhase((current) => (current === 'transferring' || current === 'completed' ? current : 'connecting'));
}
if (connection.connectionState === 'failed' || connection.connectionState === 'disconnected') {
setSendPhase('error');
setSendError('浏览器直连失败,请重新生成分享链接再试一次。');
}
};
channel.onopen = () => {
channel.send(createTransferFileManifestMessage(manifestRef.current));
};
channel.onmessage = (event) => {
if (typeof event.data !== 'string') {
return;
}
const message = parseJsonPayload<{type?: string; fileIds?: string[];}>(event.data);
if (!message || message.type !== 'receive-request' || !Array.isArray(message.fileIds)) {
return;
}
if (sendingStartedRef.current) {
return;
}
const requestedFiles = manifestRef.current.filter((item) => message.fileIds?.includes(item.id));
if (requestedFiles.length === 0) {
return;
}
sendingStartedRef.current = true;
totalBytesRef.current = requestedFiles.reduce((sum, file) => sum + file.size, 0);
sentBytesRef.current = 0;
setSendProgress(0);
void sendSelectedFiles(channel, files, requestedFiles, bootstrapId);
};
channel.onerror = () => {
setSendPhase('error');
setSendError('数据通道建立失败,请重新开始本次快传。');
};
startSenderPolling(createdSession.sessionId, connection, bootstrapId);
const offer = await connection.createOffer();
await connection.setLocalDescription(offer);
await postTransferSignal(createdSession.sessionId, 'sender', 'offer', JSON.stringify(offer));
}
function startSenderPolling(sessionId: string, connection: 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 (response) => {
if (bootstrapIdRef.current !== bootstrapId) {
return;
}
cursorRef.current = response.nextCursor;
for (const item of response.items) {
if (item.type === 'peer-joined') {
setSendPhase((current) => (current === 'waiting' ? 'connecting' : current));
continue;
}
if (item.type === 'answer' && !connection.currentRemoteDescription) {
const answer = parseJsonPayload<RTCSessionDescriptionInit>(item.payload);
if (answer) {
await connection.setRemoteDescription(answer);
pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates(
connection,
pendingRemoteCandidatesRef.current,
);
}
continue;
}
if (item.type === 'ice-candidate') {
const candidate = parseJsonPayload<RTCIceCandidateInit>(item.payload);
if (candidate) {
pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate(
connection,
pendingRemoteCandidatesRef.current,
candidate,
);
}
}
}
})
.catch((error) => {
if (bootstrapIdRef.current !== bootstrapId) {
return;
}
setSendPhase('error');
setSendError(error instanceof Error ? error.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((file) => [createTransferFileId(file), file]));
for (const descriptor of requestedFiles) {
if (bootstrapIdRef.current !== bootstrapId || channel.readyState !== 'open') {
return;
}
const file = filesById.get(descriptor.id);
if (!file) {
continue;
}
channel.send(createTransferFileMetaMessage(descriptor));
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(descriptor.id));
}
channel.send(createTransferCompleteMessage());
setSendProgress(100);
setSendPhase('completed');
}
return (
<div className="flex-1 flex flex-col items-center py-6 md:py-10">
<div className="w-full max-w-4xl">
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-[#336EFF] via-blue-500 to-cyan-400 shadow-lg shadow-[#336EFF]/20 mb-6">
<Send className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-3">P2P </h1>
<p className="text-slate-400"> P2P </p>
</div>
<div className="glass-panel border border-white/10 rounded-3xl overflow-hidden bg-[#0f172a]/80 backdrop-blur-xl shadow-2xl">
{allowSend ? (
<div className="flex border-b border-white/10">
<button
onClick={() => setActiveTab('send')}
className={cn(
'flex-1 py-5 text-center font-medium transition-colors relative',
activeTab === 'send' ? 'text-white' : 'text-slate-400 hover:text-slate-200 hover:bg-white/5',
)}
>
<div className="flex items-center justify-center gap-2">
<UploadCloud className="w-5 h-5" />
</div>
{activeTab === 'send' ? (
<motion.div layoutId="activeTransferTab" className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#336EFF]" />
) : null}
</button>
<button
onClick={() => setActiveTab('receive')}
className={cn(
'flex-1 py-5 text-center font-medium transition-colors relative',
activeTab === 'receive' ? 'text-white' : 'text-slate-400 hover:text-slate-200 hover:bg-white/5',
)}
>
<div className="flex items-center justify-center gap-2">
<DownloadCloud className="w-5 h-5" />
</div>
{activeTab === 'receive' ? (
<motion.div layoutId="activeTransferTab" className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#336EFF]" />
) : null}
</button>
</div>
) : null}
<div className="p-8 min-h-[420px] flex flex-col relative min-w-0">
<AnimatePresence mode="wait">
{activeTab === 'send' ? (
<motion.div
key="send"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.2 }}
className="flex-1 flex flex-col h-full min-w-0"
>
{selectedFiles.length === 0 ? (
<div
className="flex-1 border-2 border-dashed border-white/10 rounded-2xl flex flex-col items-center justify-center p-10 transition-colors hover:border-[#336EFF]/50 hover:bg-[#336EFF]/5"
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="w-20 h-20 rounded-full bg-blue-500/10 flex items-center justify-center mb-6">
<UploadCloud className="w-10 h-10 text-[#336EFF]" />
</div>
<h3 className="text-xl font-medium text-white mb-2"></h3>
<p className="text-slate-400 mb-8 text-center max-w-md">
P2P
</p>
<div className="flex flex-col sm:flex-row items-center gap-4">
<Button onClick={() => fileInputRef.current?.click()} className="bg-[#336EFF] hover:bg-blue-600 text-white px-8">
<FileIcon className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={() => folderInputRef.current?.click()}
variant="outline"
className="border-white/10 hover:bg-white/10 text-slate-300 px-8"
>
<Folder className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
) : (
<div className="flex-1 flex flex-col md:flex-row gap-8">
<div className="flex-1 flex flex-col items-center justify-center bg-black/20 rounded-2xl p-8 border border-white/5 relative min-w-0">
<button onClick={resetSenderState} className="absolute top-4 right-4 text-slate-500 hover:text-white transition-colors" aria-label="取消发送">
<X className="w-5 h-5" />
</button>
<h3 className="text-slate-400 text-sm font-medium mb-2 uppercase tracking-widest"></h3>
<div className="text-5xl md:text-6xl font-bold text-white tracking-[0.2em] mb-8 font-mono">
{session?.pickupCode ?? '......'}
</div>
{qrImageUrl ? (
<div className="bg-white p-4 rounded-2xl mb-6 shadow-[0_18px_48px_rgba(15,23,42,0.18)]">
<img src={qrImageUrl} alt="快传分享二维码" className="w-44 h-44 rounded-xl" />
</div>
) : null}
<div className="w-full max-w-xs rounded-2xl border border-white/10 bg-black/30 p-3 mb-4">
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-slate-500 mb-2">
<LinkIcon className="w-3.5 h-3.5" />
</div>
<div className="text-sm text-slate-200 font-mono truncate">{shareLink || '会话创建中...'}</div>
</div>
<Button
variant="outline"
className="w-full max-w-xs border-white/10 hover:bg-white/10 text-slate-200"
onClick={() => void 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 className="flex-1 flex flex-col min-w-0">
<div className="flex items-center justify-between mb-4 gap-4">
<div>
<h3 className="text-lg font-medium text-white"></h3>
<span className="text-sm text-slate-400">{selectedFiles.length} {formatTransferSize(totalSize)}</span>
</div>
<Button
size="sm"
variant="outline"
className="h-8 border-white/10 hover:bg-white/10 text-slate-300 px-2 shrink-0"
onClick={() => folderInputRef.current?.click()}
>
<FolderPlus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-3 max-h-[300px] mb-4">
{selectedFiles.map((file, index) => (
<div key={`${file.name}-${index}`} className="flex items-center gap-3 bg-white/5 border border-white/5 rounded-xl p-3 group transition-colors hover:bg-white/10">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center shrink-0">
<FileIcon className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-200 truncate">{file.name}</p>
<p className="text-xs text-slate-500">{formatTransferSize(file.size)}</p>
</div>
<button
onClick={() => removeFile(index)}
className="p-2 text-slate-500 hover:text-red-400 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
title="移除文件"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
<button
onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 py-4 border-2 border-dashed border-white/10 rounded-xl text-slate-400 hover:text-white hover:border-white/30 hover:bg-white/5 transition-colors mb-6 shrink-0"
>
<Plus className="w-5 h-5" />
<span className="font-medium"></span>
</button>
<div className={cn(
'mt-auto rounded-xl p-4 flex items-start gap-4 border',
sendPhase === 'error'
? 'bg-rose-500/10 border-rose-500/20'
: sendPhase === 'completed'
? 'bg-emerald-500/10 border-emerald-500/20'
: 'bg-blue-500/10 border-blue-500/20',
)}>
{sendPhase === 'completed' ? (
<CheckCircle className="w-6 h-6 text-emerald-400 shrink-0" />
) : (
<Loader2 className={cn(
'w-6 h-6 shrink-0',
sendPhase === 'error' ? 'text-rose-400' : 'text-blue-400 animate-spin',
)} />
)}
<div className="min-w-0">
<p className={cn(
'text-sm font-medium',
sendPhase === 'error'
? 'text-rose-300'
: sendPhase === 'completed'
? 'text-emerald-300'
: 'text-blue-300',
)}>
{getPhaseMessage(sendPhase, sendError)}
</p>
<p className="text-xs text-slate-400 mt-1">
{sendProgress}%{session ? ` · 会话有效期至 ${new Date(session.expiresAt).toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'})}` : ''}
</p>
</div>
</div>
</div>
</div>
)}
<input type="file" multiple className="hidden" ref={fileInputRef} onChange={handleFileSelect} />
<input type="file" multiple className="hidden" ref={folderInputRef} onChange={handleFileSelect} />
</motion.div>
) : (
<motion.div
key="receive"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
className="flex-1 flex flex-col h-full min-w-0 w-full"
>
<TransferReceive embedded />
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12">
<div className="flex items-start gap-4 p-4 rounded-2xl bg-white/[0.02] border border-white/5">
<div className="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center shrink-0">
<Smartphone className="w-5 h-5 text-blue-400" />
</div>
<div>
<h4 className="text-sm font-medium text-slate-200 mb-1"></h4>
<p className="text-xs text-slate-500 leading-relaxed"></p>
</div>
</div>
<div className="flex items-start gap-4 p-4 rounded-2xl bg-white/[0.02] border border-white/5">
<div className="w-10 h-10 rounded-full bg-emerald-500/10 flex items-center justify-center shrink-0">
<Shield className="w-5 h-5 text-emerald-400" />
</div>
<div>
<h4 className="text-sm font-medium text-slate-200 mb-1"> P2P </h4>
<p className="text-xs text-slate-500 leading-relaxed"> WebRTC DataChannel </p>
</div>
</div>
<div className="flex items-start gap-4 p-4 rounded-2xl bg-white/[0.02] border border-white/5">
<div className="w-10 h-10 rounded-full bg-cyan-500/10 flex items-center justify-center shrink-0">
<Monitor className="w-5 h-5 text-cyan-400" />
</div>
<div>
<h4 className="text-sm font-medium text-slate-200 mb-1"></h4>
<p className="text-xs text-slate-500 leading-relaxed"></p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,879 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Archive,
CheckCircle,
CheckSquare,
DownloadCloud,
File as FileIcon,
Loader2,
RefreshCcw,
Shield,
Square,
} from 'lucide-react';
import { useSearchParams } 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 { Input } from '@/src/components/ui/input';
import { buildTransferArchiveFileName, createTransferZipArchive } from '@/src/lib/transfer-archive';
import { resolveNetdiskSaveDirectory, saveFileToNetdisk } from '@/src/lib/netdisk-upload';
import {
createTransferReceiveRequestMessage,
parseTransferControlMessage,
SIGNAL_POLL_INTERVAL_MS,
toTransferChunk,
type TransferFileDescriptor,
} from '@/src/lib/transfer-protocol';
import { flushPendingRemoteIceCandidates, handleRemoteIceCandidate } from '@/src/lib/transfer-signaling';
import { DEFAULT_TRANSFER_ICE_SERVERS, joinTransferSession, lookupTransferSession, pollTransferSignals, postTransferSignal } from '@/src/lib/transfer';
import type { TransferSessionResponse } from '@/src/lib/types';
import { canArchiveTransferSelection, formatTransferSize, sanitizeReceiveCode } from './transfer-state';
type ReceivePhase = 'idle' | 'joining' | 'waiting' | 'connecting' | 'receiving' | 'completed' | 'error';
interface DownloadableFile extends TransferFileDescriptor {
progress: number;
selected: boolean;
requested: boolean;
downloadUrl?: string;
savedToNetdisk?: boolean;
}
interface IncomingTransferFile extends TransferFileDescriptor {
chunks: Uint8Array[];
receivedBytes: number;
}
function parseJsonPayload<T>(payload: string): T | null {
try {
return JSON.parse(payload) as T;
} catch {
return null;
}
}
interface TransferReceiveProps {
embedded?: boolean;
}
export default function TransferReceive({ embedded = false }: TransferReceiveProps) {
const { session: authSession } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const [receiveCode, setReceiveCode] = useState(searchParams.get('code') ?? '');
const [transferSession, setTransferSession] = useState<TransferSessionResponse | null>(null);
const [files, setFiles] = useState<DownloadableFile[]>([]);
const [phase, setPhase] = useState<ReceivePhase>('idle');
const [errorMessage, setErrorMessage] = useState('');
const [overallProgress, setOverallProgress] = useState(0);
const [lookupBusy, setLookupBusy] = useState(false);
const [requestSubmitted, setRequestSubmitted] = useState(false);
const [archiveRequested, setArchiveRequested] = useState(false);
const [archiveName, setArchiveName] = useState(buildTransferArchiveFileName('快传文件'));
const [archiveUrl, setArchiveUrl] = useState<string | null>(null);
const [savingFileId, setSavingFileId] = useState<string | null>(null);
const [saveMessage, setSaveMessage] = useState('');
const [savePathPickerFileId, setSavePathPickerFileId] = useState<string | null>(null);
const [saveRootPath, setSaveRootPath] = useState('/下载');
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
const dataChannelRef = useRef<RTCDataChannel | null>(null);
const pollTimerRef = useRef<number | null>(null);
const cursorRef = useRef(0);
const lifecycleIdRef = useRef(0);
const currentFileIdRef = useRef<string | null>(null);
const totalBytesRef = useRef(0);
const receivedBytesRef = useRef(0);
const downloadUrlsRef = useRef<string[]>([]);
const requestedFileIdsRef = useRef<string[]>([]);
const pendingRemoteCandidatesRef = useRef<RTCIceCandidateInit[]>([]);
const archiveBuiltRef = useRef(false);
const completedFilesRef = useRef(new Map<string, {
name: string;
relativePath: string;
blob: Blob;
contentType: string;
}>());
const incomingFilesRef = useRef(new Map<string, IncomingTransferFile>());
useEffect(() => {
return () => {
cleanupReceiver();
};
}, []);
useEffect(() => {
const sessionId = searchParams.get('session');
if (!sessionId) {
setTransferSession(null);
setFiles([]);
setPhase('idle');
setOverallProgress(0);
setRequestSubmitted(false);
setArchiveRequested(false);
setArchiveUrl(null);
return;
}
void startReceivingSession(sessionId);
}, [searchParams]);
function cleanupReceiver() {
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;
}
for (const url of downloadUrlsRef.current) {
URL.revokeObjectURL(url);
}
downloadUrlsRef.current = [];
completedFilesRef.current.clear();
incomingFilesRef.current.clear();
currentFileIdRef.current = null;
cursorRef.current = 0;
receivedBytesRef.current = 0;
totalBytesRef.current = 0;
requestedFileIdsRef.current = [];
pendingRemoteCandidatesRef.current = [];
archiveBuiltRef.current = false;
}
async function startReceivingSession(sessionId: string) {
const lifecycleId = lifecycleIdRef.current + 1;
lifecycleIdRef.current = lifecycleId;
cleanupReceiver();
setPhase('joining');
setErrorMessage('');
setFiles([]);
setOverallProgress(0);
setRequestSubmitted(false);
setArchiveRequested(false);
setArchiveName(buildTransferArchiveFileName('快传文件'));
setArchiveUrl(null);
setSavingFileId(null);
setSaveMessage('');
try {
const joinedSession = await joinTransferSession(sessionId);
if (lifecycleIdRef.current !== lifecycleId) {
return;
}
setTransferSession(joinedSession);
setArchiveName(buildTransferArchiveFileName(`快传-${joinedSession.pickupCode}`));
const connection = new RTCPeerConnection({
iceServers: DEFAULT_TRANSFER_ICE_SERVERS,
});
peerConnectionRef.current = connection;
connection.onicecandidate = (event) => {
if (!event.candidate) {
return;
}
void postTransferSignal(
joinedSession.sessionId,
'receiver',
'ice-candidate',
JSON.stringify(event.candidate.toJSON()),
);
};
connection.onconnectionstatechange = () => {
if (connection.connectionState === 'connected') {
setPhase((current) => (current === 'completed' ? current : 'connecting'));
}
if (connection.connectionState === 'failed' || connection.connectionState === 'disconnected') {
setPhase('error');
setErrorMessage('浏览器之间的直连失败,请重新打开分享链接。');
}
};
connection.ondatachannel = (event) => {
const channel = event.channel;
dataChannelRef.current = channel;
channel.binaryType = 'arraybuffer';
channel.onopen = () => {
setPhase((current) => (current === 'completed' ? current : 'connecting'));
};
channel.onmessage = (messageEvent) => {
void handleIncomingMessage(messageEvent.data);
};
};
startReceiverPolling(joinedSession.sessionId, connection, lifecycleId);
setPhase('waiting');
} catch (error) {
if (lifecycleIdRef.current !== lifecycleId) {
return;
}
setPhase('error');
setErrorMessage(error instanceof Error ? error.message : '快传会话打开失败');
}
}
function startReceiverPolling(sessionId: string, connection: RTCPeerConnection, lifecycleId: number) {
let polling = false;
pollTimerRef.current = window.setInterval(() => {
if (polling || lifecycleIdRef.current !== lifecycleId) {
return;
}
polling = true;
void pollTransferSignals(sessionId, 'receiver', cursorRef.current)
.then(async (response) => {
if (lifecycleIdRef.current !== lifecycleId) {
return;
}
cursorRef.current = response.nextCursor;
for (const item of response.items) {
if (item.type === 'offer') {
const offer = parseJsonPayload<RTCSessionDescriptionInit>(item.payload);
if (!offer) {
continue;
}
setPhase('connecting');
await connection.setRemoteDescription(offer);
pendingRemoteCandidatesRef.current = await flushPendingRemoteIceCandidates(
connection,
pendingRemoteCandidatesRef.current,
);
const answer = await connection.createAnswer();
await connection.setLocalDescription(answer);
await postTransferSignal(sessionId, 'receiver', 'answer', JSON.stringify(answer));
continue;
}
if (item.type === 'ice-candidate') {
const candidate = parseJsonPayload<RTCIceCandidateInit>(item.payload);
if (candidate) {
pendingRemoteCandidatesRef.current = await handleRemoteIceCandidate(
connection,
pendingRemoteCandidatesRef.current,
candidate,
);
}
}
}
})
.catch((error) => {
if (lifecycleIdRef.current !== lifecycleId) {
return;
}
setPhase('error');
setErrorMessage(error instanceof Error ? error.message : '轮询传输信令失败');
})
.finally(() => {
polling = false;
});
}, SIGNAL_POLL_INTERVAL_MS);
}
async function finalizeArchiveDownload() {
if (!archiveRequested || archiveBuiltRef.current || requestedFileIdsRef.current.length === 0) {
return;
}
const archiveEntries = requestedFileIdsRef.current.map((fileId) => completedFilesRef.current.get(fileId)).filter(Boolean);
if (archiveEntries.length !== requestedFileIdsRef.current.length) {
return;
}
const archive = await createTransferZipArchive(
archiveEntries.map((entry) => ({
name: entry.name,
relativePath: entry.relativePath,
data: entry.blob,
})),
);
const nextArchiveUrl = URL.createObjectURL(archive);
downloadUrlsRef.current.push(nextArchiveUrl);
archiveBuiltRef.current = true;
setArchiveUrl(nextArchiveUrl);
}
async function handleIncomingMessage(data: string | ArrayBuffer | Blob) {
if (typeof data === 'string') {
const message = parseTransferControlMessage(data);
if (!message) {
return;
}
if (message.type === 'manifest') {
setFiles(message.files.map((file) => ({
...file,
progress: 0,
selected: true,
requested: false,
savedToNetdisk: false,
})));
setPhase((current) => (current === 'receiving' || current === 'completed' ? current : 'waiting'));
return;
}
if (message.type === 'file-meta') {
currentFileIdRef.current = message.id;
incomingFilesRef.current.set(message.id, {
...message,
chunks: [],
receivedBytes: 0,
});
setFiles((current) =>
current.map((file) =>
file.id === message.id
? {
...file,
requested: true,
progress: 0,
}
: file,
),
);
return;
}
if (message.type === 'file-complete' && message.id) {
finalizeDownloadableFile(message.id);
currentFileIdRef.current = null;
await finalizeArchiveDownload();
return;
}
if (message.type === 'transfer-complete') {
await finalizeArchiveDownload();
setOverallProgress(100);
setPhase('completed');
}
return;
}
const activeFileId = currentFileIdRef.current;
if (!activeFileId) {
return;
}
const targetFile = incomingFilesRef.current.get(activeFileId);
if (!targetFile) {
return;
}
const chunk = await toTransferChunk(data);
targetFile.chunks.push(chunk);
targetFile.receivedBytes += chunk.byteLength;
receivedBytesRef.current += chunk.byteLength;
setPhase('receiving');
if (totalBytesRef.current > 0) {
setOverallProgress(Math.min(99, Math.round((receivedBytesRef.current / totalBytesRef.current) * 100)));
}
setFiles((current) =>
current.map((file) =>
file.id === activeFileId
? {
...file,
progress: Math.min(99, Math.round((targetFile.receivedBytes / Math.max(targetFile.size, 1)) * 100)),
}
: file,
),
);
}
function finalizeDownloadableFile(fileId: string) {
const targetFile = incomingFilesRef.current.get(fileId);
if (!targetFile) {
return;
}
const blob = new Blob(targetFile.chunks, {
type: targetFile.contentType,
});
const downloadUrl = URL.createObjectURL(blob);
downloadUrlsRef.current.push(downloadUrl);
completedFilesRef.current.set(fileId, {
name: targetFile.name,
relativePath: targetFile.relativePath,
blob,
contentType: targetFile.contentType,
});
setFiles((current) =>
current.map((file) =>
file.id === fileId
? {
...file,
progress: 100,
requested: true,
downloadUrl,
savedToNetdisk: false,
}
: file,
),
);
}
async function saveCompletedFile(fileId: string, rootPath: string) {
const completedFile = completedFilesRef.current.get(fileId);
if (!completedFile) {
return;
}
setSavingFileId(fileId);
setSaveMessage('');
try {
const netdiskFile = new File([completedFile.blob], completedFile.name, {
type: completedFile.contentType || completedFile.blob.type || 'application/octet-stream',
});
const targetPath = resolveNetdiskSaveDirectory(completedFile.relativePath, rootPath);
const savedFile = await saveFileToNetdisk(netdiskFile, targetPath);
setFiles((current) =>
current.map((file) =>
file.id === fileId
? {
...file,
savedToNetdisk: true,
}
: file,
),
);
setSaveMessage(`${savedFile.filename} 已存入网盘 ${savedFile.path}`);
} catch (requestError) {
setErrorMessage(requestError instanceof Error ? requestError.message : '存入网盘失败');
throw requestError;
} finally {
setSavingFileId(null);
}
}
function toggleFileSelection(fileId: string) {
if (requestSubmitted) {
return;
}
setFiles((current) =>
current.map((file) =>
file.id === fileId
? {
...file,
selected: !file.selected,
}
: file,
),
);
}
function toggleSelectAll(nextSelected: boolean) {
if (requestSubmitted) {
return;
}
setFiles((current) =>
current.map((file) => ({
...file,
selected: nextSelected,
})),
);
}
async function submitReceiveRequest(archive: boolean, fileIds?: string[]) {
const channel = dataChannelRef.current;
if (!channel || channel.readyState !== 'open') {
setPhase('error');
setErrorMessage('P2P 通道尚未准备好,请稍后再试。');
return;
}
const requestedIds = fileIds ?? files.filter((file) => file.selected).map((file) => file.id);
if (requestedIds.length === 0) {
setErrorMessage('请先选择至少一个文件。');
return;
}
const requestedSet = new Set(requestedIds);
const requestedBytes = files
.filter((file) => requestedSet.has(file.id))
.reduce((sum, file) => sum + file.size, 0);
requestedFileIdsRef.current = requestedIds;
totalBytesRef.current = requestedBytes;
receivedBytesRef.current = 0;
archiveBuiltRef.current = false;
setOverallProgress(0);
setArchiveRequested(archive);
setArchiveUrl(null);
setRequestSubmitted(true);
setErrorMessage('');
setFiles((current) =>
current.map((file) => ({
...file,
selected: requestedSet.has(file.id),
requested: requestedSet.has(file.id),
progress: requestedSet.has(file.id) ? 0 : file.progress,
})),
);
channel.send(createTransferReceiveRequestMessage(requestedIds, archive));
setPhase('waiting');
}
async function handleLookupByCode() {
setLookupBusy(true);
setErrorMessage('');
try {
const result = await lookupTransferSession(receiveCode);
setSearchParams({
session: result.sessionId,
});
} catch (error) {
setPhase('error');
setErrorMessage(error instanceof Error ? error.message : '取件码无效或会话已过期');
} finally {
setLookupBusy(false);
}
}
const sessionId = searchParams.get('session');
const selectedFiles = files.filter((file) => file.selected);
const requestedFiles = files.filter((file) => file.requested);
const selectedSize = selectedFiles.reduce((sum, file) => sum + file.size, 0);
const canZipAllFiles = canArchiveTransferSelection(files);
const hasSelectableFiles = selectedFiles.length > 0;
const canSubmitSelection = Boolean(dataChannelRef.current && dataChannelRef.current.readyState === 'open' && hasSelectableFiles);
const panelContent = (
<>
{!embedded ? (
<div className="text-center mb-10">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 via-teal-500 to-cyan-400 shadow-lg shadow-emerald-500/20">
<DownloadCloud className="h-8 w-8 text-white" />
</div>
<h1 className="text-3xl font-bold mb-3"></h1>
<p className="text-slate-400"> P2P </p>
</div>
) : null}
<div className={embedded ? '' : 'glass-panel rounded-3xl border border-white/10 bg-[#0f172a]/80 shadow-2xl overflow-hidden'}>
<div className={embedded ? '' : 'p-8'}>
{!sessionId ? (
<div className="mx-auto flex max-w-sm flex-col items-center">
<div className="mb-8 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-500/10">
<DownloadCloud className="h-10 w-10 text-emerald-400" />
</div>
<h2 className="mb-6 text-xl font-medium"></h2>
<div className="w-full mb-6">
<Input
value={receiveCode}
onChange={(event) => setReceiveCode(sanitizeReceiveCode(event.target.value))}
placeholder="例如: 849201"
className="h-16 bg-black/20 border-white/10 text-center text-3xl tracking-[0.5em] font-mono text-white"
/>
</div>
<Button
className="w-full h-12 text-lg bg-emerald-500 hover:bg-emerald-600 text-white"
disabled={receiveCode.length !== 6 || lookupBusy}
onClick={() => void handleLookupByCode()}
>
{lookupBusy ? '正在查找...' : '进入接收会话'}
</Button>
{errorMessage ? (
<div className="mt-4 w-full rounded-xl border border-rose-500/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
{errorMessage}
</div>
) : null}
</div>
) : (
<div className="grid gap-8 md:grid-cols-[1.08fr_0.92fr]">
<div className="rounded-2xl border border-white/5 bg-black/20 p-6">
<div className="flex items-center justify-between gap-4 mb-6">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-slate-500"></p>
<h2 className="text-2xl font-semibold mt-2">{transferSession?.pickupCode ?? '连接中...'}</h2>
</div>
<Button
variant="outline"
className="border-white/10 text-slate-200 hover:bg-white/10"
onClick={() => {
if (sessionId) {
void startReceivingSession(sessionId);
}
}}
>
<RefreshCcw className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="mb-4 flex items-center gap-3">
{phase === 'completed' ? (
<CheckCircle className="h-6 w-6 text-emerald-400" />
) : (
<Loader2 className="h-6 w-6 animate-spin text-emerald-400" />
)}
<div>
<p className="text-sm font-medium text-white">
{phase === 'joining' && '正在加入快传会话...'}
{phase === 'waiting' && (files.length === 0
? 'P2P 已连通,正在同步文件清单...'
: requestSubmitted
? '已提交接收请求,等待发送端开始推送...'
: '文件清单已同步,请勾选要接收的文件。')}
{phase === 'connecting' && 'P2P 通道协商中...'}
{phase === 'receiving' && '文件正在接收...'}
{phase === 'completed' && (archiveUrl ? '接收完成ZIP 已准备好下载' : '接收完成,下面可以下载文件')}
{phase === 'error' && '接收失败'}
</p>
<p className="text-xs text-slate-400 mt-1">
{errorMessage || `总进度 ${overallProgress}%`}
</p>
</div>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-black/40">
<div className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-cyan-400" style={{width: `${overallProgress}%`}} />
</div>
</div>
{archiveUrl ? (
<div className="mt-5 rounded-2xl border border-cyan-400/20 bg-cyan-500/10 p-4">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-cyan-500/15">
<Archive className="h-5 w-5 text-cyan-300" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white"> ZIP </p>
<p className="mt-1 text-xs text-slate-300">{archiveName}</p>
</div>
<a
href={archiveUrl}
download={archiveName}
className="rounded-lg border border-white/10 px-3 py-2 text-xs text-slate-100 transition-colors hover:bg-white/10"
>
ZIP
</a>
</div>
</div>
) : null}
{saveMessage ? (
<div className="mt-5 rounded-2xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
{saveMessage}
</div>
) : null}
</div>
<div className="rounded-2xl border border-white/5 bg-black/20 p-6">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-medium"></h3>
<p className="mt-1 text-xs text-slate-500">
{requestSubmitted
? `已请求 ${requestedFiles.length}`
: `已选择 ${selectedFiles.length} 项 · ${formatTransferSize(selectedSize)}`}
</p>
</div>
{!requestSubmitted && files.length > 0 ? (
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
className="border-white/10 text-slate-200 hover:bg-white/10"
onClick={() => toggleSelectAll(true)}
>
</Button>
<Button
size="sm"
variant="outline"
className="border-white/10 text-slate-200 hover:bg-white/10"
onClick={() => toggleSelectAll(false)}
>
</Button>
</div>
) : null}
</div>
{!requestSubmitted && files.length > 0 ? (
<div className="mb-4 flex flex-wrap gap-2">
<Button
className="bg-emerald-500 hover:bg-emerald-600 text-white"
disabled={!canSubmitSelection}
onClick={() => void submitReceiveRequest(false)}
>
</Button>
{canZipAllFiles ? (
<Button
variant="outline"
className="border-cyan-400/20 bg-cyan-500/10 text-cyan-100 hover:bg-cyan-500/15"
disabled={!dataChannelRef.current || dataChannelRef.current.readyState !== 'open'}
onClick={() => void submitReceiveRequest(true, files.map((file) => file.id))}
>
<Archive className="mr-2 h-4 w-4" />
ZIP
</Button>
) : null}
</div>
) : null}
<div className="space-y-3">
{files.length === 0 ? (
<div className="rounded-xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-5 text-sm text-slate-500">
</div>
) : (
files.map((file) => (
<div key={file.id} className="rounded-xl border border-white/5 bg-white/[0.03] p-4">
<div className="flex items-start gap-3">
{!requestSubmitted ? (
<button
type="button"
className="mt-0.5 text-slate-300 hover:text-white"
onClick={() => toggleFileSelection(file.id)}
aria-label={file.selected ? '取消选择文件' : '选择文件'}
>
{file.selected ? <CheckSquare className="h-5 w-5 text-emerald-400" /> : <Square className="h-5 w-5" />}
</button>
) : null}
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/10 shrink-0">
<FileIcon className="h-5 w-5 text-emerald-400" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-100">{file.name}</p>
<p className="truncate text-xs text-slate-500 mt-1">
{file.relativePath !== file.name ? `${file.relativePath} · ` : ''}
{formatTransferSize(file.size)}
</p>
</div>
{requestSubmitted ? (
file.requested ? (
file.downloadUrl ? (
<div className="flex items-center gap-2">
<a
href={file.downloadUrl}
download={file.name}
className="rounded-lg border border-white/10 px-3 py-2 text-xs text-slate-200 transition-colors hover:bg-white/10"
>
</a>
{authSession?.token ? (
<Button
size="sm"
variant="outline"
className="border-white/10 text-slate-200 hover:bg-white/10"
disabled={savingFileId === file.id || file.savedToNetdisk}
onClick={() => setSavePathPickerFileId(file.id)}
>
{file.savedToNetdisk ? '已存入网盘' : savingFileId === file.id ? '存入中...' : '存入网盘'}
</Button>
) : null}
</div>
) : (
<span className="text-xs text-emerald-300">{file.progress}%</span>
)
) : (
<span className="text-xs text-slate-500"></span>
)
) : null}
</div>
{requestSubmitted && file.requested ? (
<div className="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-black/40">
<div className="h-full rounded-full bg-emerald-400" style={{width: `${file.progress}%`}} />
</div>
) : null}
</div>
))
)}
</div>
</div>
</div>
)}
</div>
</div>
{!embedded ? (
<div className="mt-10 grid gap-6 md:grid-cols-2">
<div className="rounded-2xl border border-white/5 bg-white/[0.02] p-5">
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-emerald-500/10">
<Shield className="h-5 w-5 text-emerald-400" />
</div>
<h4 className="text-sm font-medium text-slate-100 mb-1"></h4>
<p className="text-xs leading-6 text-slate-500"> offeranswer ICE candidate</p>
</div>
<div className="rounded-2xl border border-white/5 bg-white/[0.02] p-5">
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-cyan-500/10">
<Archive className="h-5 w-5 text-cyan-400" />
</div>
<h4 className="text-sm font-medium text-slate-100 mb-1"></h4>
<p className="text-xs leading-6 text-slate-500"> ZIP</p>
</div>
</div>
) : null}
<NetdiskPathPickerModal
isOpen={Boolean(savePathPickerFileId)}
title="选择存入位置"
description="选择保存到网盘的根目录,快传里的相对目录结构会继续保留。"
initialPath={saveRootPath}
confirmLabel="存入这里"
confirmPathPreview={(path) => {
const completedFile = savePathPickerFileId ? completedFilesRef.current.get(savePathPickerFileId) : null;
return completedFile ? resolveNetdiskSaveDirectory(completedFile.relativePath, path) : path;
}}
onClose={() => setSavePathPickerFileId(null)}
onConfirm={async (path) => {
if (!savePathPickerFileId) {
return;
}
setSaveRootPath(path);
await saveCompletedFile(savePathPickerFileId, path);
setSavePathPickerFileId(null);
}}
/>
</>
);
if (embedded) {
return panelContent;
}
return (
<div className="min-h-screen bg-[#07101D] px-4 py-8 text-white">
<div className="mx-auto w-full max-w-4xl">
{panelContent}
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildTransferShareUrl } from '../lib/transfer-links';
import {
canArchiveTransferSelection,
buildQrImageUrl,
canSendTransferFiles,
createMockTransferCode,
formatTransferSize,
resolveInitialTransferTab,
sanitizeReceiveCode,
} from './transfer-state';
test('createMockTransferCode returns a six digit numeric code', () => {
const code = createMockTransferCode();
assert.match(code, /^\d{6}$/);
});
test('sanitizeReceiveCode keeps only the first six digits', () => {
assert.equal(sanitizeReceiveCode(' 98a76-54321 '), '987654');
});
test('formatTransferSize uses readable units', () => {
assert.equal(formatTransferSize(0), '0 B');
assert.equal(formatTransferSize(2048), '2 KB');
assert.equal(formatTransferSize(2.5 * 1024 * 1024), '2.5 MB');
});
test('buildTransferShareUrl builds a browser-router receive url', () => {
assert.equal(
buildTransferShareUrl('https://yoyuzh.xyz', '849201', 'browser'),
'https://yoyuzh.xyz/transfer?session=849201',
);
});
test('buildTransferShareUrl builds a hash-router receive url', () => {
assert.equal(
buildTransferShareUrl('https://yoyuzh.xyz/', '849201', 'hash'),
'https://yoyuzh.xyz/#/transfer?session=849201',
);
});
test('buildQrImageUrl encodes the share url as a QR image endpoint', () => {
assert.equal(
buildQrImageUrl(buildTransferShareUrl('https://yoyuzh.xyz', '849201', 'browser')),
'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=https%3A%2F%2Fyoyuzh.xyz%2Ftransfer%3Fsession%3D849201',
);
});
test('resolveInitialTransferTab prefers receive mode for public visitors and shared sessions', () => {
assert.equal(resolveInitialTransferTab(false, null), 'receive');
assert.equal(resolveInitialTransferTab(true, '849201'), 'receive');
assert.equal(resolveInitialTransferTab(true, null), 'send');
});
test('canSendTransferFiles requires an authenticated session', () => {
assert.equal(canSendTransferFiles(true), true);
assert.equal(canSendTransferFiles(false), false);
});
test('canArchiveTransferSelection is enabled for multi-file or folder downloads', () => {
assert.equal(canArchiveTransferSelection([
{
relativePath: 'report.pdf',
},
]), false);
assert.equal(canArchiveTransferSelection([
{
relativePath: '课程资料/report.pdf',
},
]), true);
assert.equal(canArchiveTransferSelection([
{
relativePath: 'report.pdf',
},
{
relativePath: 'notes.txt',
},
]), true);
});

View File

@@ -0,0 +1,52 @@
import type { TransferFileDescriptor } from '../lib/transfer-protocol';
export type TransferTab = 'send' | 'receive';
export function createMockTransferCode() {
return Math.floor(100000 + Math.random() * 900000).toString();
}
export function sanitizeReceiveCode(value: string) {
return value.replace(/\D/g, '').slice(0, 6);
}
export function formatTransferSize(bytes: number) {
if (bytes <= 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
const displayValue = value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1);
return `${displayValue.replace(/\.0$/, '')} ${units[unitIndex]}`;
}
export function buildQrImageUrl(shareUrl: string) {
return `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(shareUrl)}`;
}
export function canSendTransferFiles(isAuthenticated: boolean) {
return isAuthenticated;
}
export function resolveInitialTransferTab(
isAuthenticated: boolean,
sessionId: string | null,
): TransferTab {
if (!canSendTransferFiles(isAuthenticated) || sessionId) {
return 'receive';
}
return 'send';
}
export function canArchiveTransferSelection(files: Pick<TransferFileDescriptor, 'relativePath'>[]) {
return files.length > 1 || files.some((file) => file.relativePath.includes('/'));
}