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

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

@@ -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('/'));
}