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

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

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

View File

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

View File

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