feat(front): 覆盖 front 并完善登录快传入口与中文文案

This commit is contained in:
yoyuzh
2026-04-10 01:09:06 +08:00
parent 99e00cd7f7
commit 12005cc606
210 changed files with 4860 additions and 23900 deletions

View File

@@ -0,0 +1,73 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'dark' | 'light' | 'system';
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'vite-ui-theme',
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error('useTheme must be used within a ThemeProvider');
return context;
};

View File

@@ -0,0 +1,17 @@
import { Moon, Sun } from 'lucide-react';
import { useTheme } from './ThemeProvider';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
className="p-2 rounded-xl glass-panel hover:bg-white/40 dark:hover:bg-white/10 transition-all border border-white/20"
aria-label="Toggle theme"
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100 top-2 left-2" />
</button>
);
}

View File

@@ -1,20 +0,0 @@
import assert from 'node:assert/strict';
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);
});
test('getVisibleNavItems keeps the admin entry for admin users', () => {
assert.equal(getVisibleNavItems(true).some((item) => item.path === '/admin'), true);
});

View File

@@ -1,627 +1,121 @@
import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom';
import {
Gamepad2,
FolderOpen,
Key,
HardDrive,
LayoutDashboard,
ListTodo,
LogOut,
Mail,
Send,
Settings,
Shield,
Smartphone,
X,
Share2,
Trash2,
Sun,
Moon,
} from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { useAuth } from '@/src/auth/AuthProvider';
import { apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api';
import { createSession, readStoredSession, saveStoredSession } from '@/src/lib/session';
import type { AuthResponse, InitiateUploadResponse, UserProfile } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
import { Button } from '@/src/components/ui/button';
import { Input } from '@/src/components/ui/input';
import { logout } from '@/src/lib/auth';
import { getSession, type PortalSession } from '@/src/lib/session';
import { useTheme } from '../ThemeProvider';
import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './account-utils';
import { UploadProgressPanel } from './UploadProgressPanel';
const NAV_ITEMS = [
{ name: '总览', path: '/overview', icon: LayoutDashboard },
{ name: '网盘', path: '/files', icon: FolderOpen },
{ name: '快传', path: '/transfer', icon: Send },
{ name: '游戏', path: '/games', icon: Gamepad2 },
{ name: '后台', path: '/admin', icon: Shield },
] as const;
type ActiveModal = 'security' | 'settings' | null;
export function getVisibleNavItems(isAdmin: boolean) {
return NAV_ITEMS.filter((item) => isAdmin || item.path !== '/admin');
}
interface LayoutProps {
children?: ReactNode;
}
export function Layout({ children }: LayoutProps = {}) {
export default function Layout() {
const location = useLocation();
const navigate = useNavigate();
const { isAdmin, logout, refreshProfile, user } = useAuth();
const navItems = getVisibleNavItems(isAdmin);
const fileInputRef = useRef<HTMLInputElement>(null);
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null);
const [selectedAvatarFile, setSelectedAvatarFile] = useState<File | null>(null);
const [avatarSourceUrl, setAvatarSourceUrl] = useState<string | null>(user?.avatarUrl ?? null);
const [profileDraft, setProfileDraft] = useState(() =>
buildAccountDraft(
user ?? {
id: 0,
username: '',
email: '',
createdAt: '',
},
),
);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [profileMessage, setProfileMessage] = useState('');
const [passwordMessage, setPasswordMessage] = useState('');
const [profileError, setProfileError] = useState('');
const [passwordError, setPasswordError] = useState('');
const [profileSubmitting, setProfileSubmitting] = useState(false);
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
const [session, setSession] = useState<PortalSession | null>(() => getSession());
const { theme, setTheme } = useTheme();
useEffect(() => {
if (!user) {
return;
}
setProfileDraft(buildAccountDraft(user));
}, [user]);
useEffect(() => {
if (!avatarPreviewUrl) {
return undefined;
}
return () => {
URL.revokeObjectURL(avatarPreviewUrl);
const handleSessionChange = (event: Event) => {
const customEvent = event as CustomEvent<PortalSession | null>;
setSession(customEvent.detail ?? getSession());
};
}, [avatarPreviewUrl]);
window.addEventListener('portal-session-changed', handleSessionChange);
return () => window.removeEventListener('portal-session-changed', handleSessionChange);
}, []);
useEffect(() => {
let active = true;
let objectUrl: string | null = null;
async function syncAvatar() {
if (!user?.avatarUrl) {
if (active) {
setAvatarSourceUrl(null);
}
return;
}
if (!shouldLoadAvatarWithAuth(user.avatarUrl)) {
if (active) {
setAvatarSourceUrl(user.avatarUrl);
}
return;
}
try {
const response = await apiDownload(user.avatarUrl);
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
if (active) {
setAvatarSourceUrl(objectUrl);
}
} catch {
if (active) {
setAvatarSourceUrl(null);
}
}
if (!session && location.pathname !== '/transfer') {
navigate('/login', { replace: true });
}
}, [location.pathname, navigate, session]);
void syncAvatar();
return () => {
active = false;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [user?.avatarUrl]);
const displayName = useMemo(() => {
if (!user) {
return '账户';
}
return user.displayName || user.username;
}, [user]);
const email = user?.email || '暂无邮箱';
const phoneNumber = user?.phoneNumber || '未设置手机号';
const roleLabel = getRoleLabel(user?.role);
const avatarFallback = (displayName || 'Y').charAt(0).toUpperCase();
const displayedAvatarUrl = avatarPreviewUrl || avatarSourceUrl;
const handleLogout = () => {
logout();
navigate('/login');
};
const handleAvatarClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
setSelectedAvatarFile(file);
setAvatarPreviewUrl((current) => {
if (current) {
URL.revokeObjectURL(current);
}
return URL.createObjectURL(file);
});
};
const handleProfileDraftChange = (field: keyof typeof profileDraft, value: string) => {
setProfileDraft((current) => ({
...current,
[field]: value,
}));
};
const closeModal = () => {
setActiveModal(null);
setProfileMessage('');
setProfileError('');
setPasswordMessage('');
setPasswordError('');
};
const persistSessionUser = (nextProfile: UserProfile) => {
const currentSession = readStoredSession();
if (!currentSession) {
return;
}
saveStoredSession({
...currentSession,
user: nextProfile,
});
};
const uploadAvatar = async (file: File) => {
const initiated = await apiRequest<InitiateUploadResponse>('/user/avatar/upload/initiate', {
method: 'POST',
body: {
filename: file.name,
contentType: file.type || 'image/png',
size: file.size,
},
});
if (initiated.direct) {
try {
await apiBinaryUploadRequest(initiated.uploadUrl, {
method: initiated.method,
headers: initiated.headers,
body: file,
});
} catch {
const formData = new FormData();
formData.append('file', file);
await apiUploadRequest<void>(`/user/avatar/upload?storageName=${encodeURIComponent(initiated.storageName)}`, {
body: formData,
});
}
} else {
const formData = new FormData();
formData.append('file', file);
await apiUploadRequest<void>(initiated.uploadUrl, {
body: formData,
method: initiated.method === 'PUT' ? 'PUT' : 'POST',
headers: initiated.headers,
});
}
const nextProfile = await apiRequest<UserProfile>('/user/avatar/upload/complete', {
method: 'POST',
body: {
filename: file.name,
contentType: file.type || 'image/png',
size: file.size,
storageName: initiated.storageName,
},
});
persistSessionUser(nextProfile);
return nextProfile;
};
const handleSaveProfile = async () => {
setProfileSubmitting(true);
setProfileMessage('');
setProfileError('');
try {
if (selectedAvatarFile) {
await uploadAvatar(selectedAvatarFile);
}
const nextProfile = await apiRequest<UserProfile>('/user/profile', {
method: 'PUT',
body: {
displayName: profileDraft.displayName.trim(),
email: profileDraft.email.trim(),
phoneNumber: profileDraft.phoneNumber.trim(),
bio: profileDraft.bio,
preferredLanguage: profileDraft.preferredLanguage,
},
});
persistSessionUser(nextProfile);
await refreshProfile();
setSelectedAvatarFile(null);
setAvatarPreviewUrl((current) => {
if (current) {
URL.revokeObjectURL(current);
}
return null;
});
setProfileMessage('账户资料已保存');
} catch (error) {
setProfileError(error instanceof Error ? error.message : '账户资料保存失败');
} finally {
setProfileSubmitting(false);
}
};
const handleChangePassword = async () => {
setPasswordMessage('');
setPasswordError('');
if (newPassword !== confirmPassword) {
setPasswordError('两次输入的新密码不一致');
return;
}
setPasswordSubmitting(true);
try {
const auth = await apiRequest<AuthResponse>('/user/password', {
method: 'POST',
body: {
currentPassword,
newPassword,
},
});
const currentSession = readStoredSession();
if (currentSession) {
saveStoredSession({
...currentSession,
...createSession(auth),
user: auth.user,
});
}
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setPasswordMessage('密码已更新,当前登录态已同步刷新');
} catch (error) {
setPasswordError(error instanceof Error ? error.message : '密码修改失败');
} finally {
setPasswordSubmitting(false);
}
};
const navItems = [
{ to: '/overview', icon: LayoutDashboard, label: '概览' },
{ to: '/files', icon: HardDrive, label: '网盘' },
{ to: '/tasks', icon: ListTodo, label: '任务' },
{ to: '/shares', icon: Share2, label: '分享' },
{ to: '/recycle-bin', icon: Trash2, label: '回收站' },
{ to: '/transfer', icon: Send, label: '快传' },
...(session?.user.role === 'ADMIN'
? [{ to: '/admin/dashboard', icon: Settings, label: '后台' }]
: []),
];
return (
<div className="flex bg-[#07101D] text-white overflow-hidden w-full h-screen">
<aside className="h-full w-16 md:w-56 flex flex-col shrink-0 border-r border-white/10 bg-[#0f172a]/50">
<div className="h-14 flex items-center md:px-4 justify-center md:justify-start border-b border-white/10">
<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 shrink-0">
<span className="text-white font-bold text-lg leading-none">Y</span>
<div className="flex h-screen w-full bg-aurora text-gray-900 dark:text-gray-100 overflow-hidden">
{/* Sidebar */}
<aside className="w-68 flex-shrink-0 border-r border-white/20 dark:border-white/10 bg-white/40 dark:bg-black/40 backdrop-blur-2xl flex flex-col z-20 shadow-xl">
<div className="h-24 flex items-center justify-between px-8 border-b border-white/10">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center text-white font-black shadow-lg text-lg tracking-tighter">P</div>
<span className="text-2xl font-black tracking-tight uppercase"></span>
</div>
<div className="hidden md:flex flex-col ml-3">
<span className="text-white font-bold text-sm tracking-wider">YOYUZH.XYZ</span>
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2.5 rounded-lg glass-panel hover:bg-white/50 transition-all font-bold"
>
{theme === 'dark' ? <Sun className="w-5 h-5 text-yellow-300" /> : <Moon className="w-5 h-5 text-gray-700" />}
</button>
</div>
<div className="border-b border-white/10 px-8 py-6">
<div className="text-sm font-black uppercase tracking-[0.2em] opacity-70 mb-1"></div>
<div className="text-sm font-black truncate">
{session?.user.displayName || session?.user.username || '游客用户'}
</div>
<div className="truncate text-sm font-bold opacity-80 dark:opacity-90 flex items-center gap-1.5 mt-2 uppercase tracking-tight">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-500 shadow-[0_0_10px_rgba(34,197,94,0.6)] animate-pulse"></span>
{session?.user.email || '未登录'}
</div>
</div>
<nav className="flex-1 flex flex-col gap-2 p-2 relative overflow-y-auto overflow-x-hidden">
<nav className="flex-1 overflow-y-auto py-8 px-5 space-y-1.5">
<div className="px-3 mb-2 text-xs font-black uppercase tracking-[0.3em] opacity-70"></div>
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-0 md:px-4 justify-center md:justify-start h-10 rounded-xl text-sm font-medium transition-all duration-200 relative overflow-hidden group',
isActive ? 'text-white shadow-md shadow-[#336EFF]/20' : 'text-slate-400 hover:text-white hover:bg-white/5',
"flex items-center gap-3 px-4 py-3.5 rounded-lg text-sm font-black uppercase tracking-widest transition-all duration-300 group",
isActive
? "glass-panel-no-hover bg-white/60 dark:bg-white/10 shadow-lg text-blue-600 dark:text-blue-400 border-white/40"
: "text-gray-700 dark:text-gray-200 hover:bg-white/30 dark:hover:bg-white/5 hover:translate-x-1"
)
}
>
{({ isActive }) => (
<>
{isActive && <div className="absolute inset-0 bg-[#336EFF] opacity-100 z-0" />}
<item.icon className="w-[18px] h-[18px] relative z-10 shrink-0" />
<span className="relative z-10 hidden md:block">{item.name}</span>
</>
)}
<item.icon className={cn("h-4 w-4 transition-colors group-hover:text-blue-500")} />
{item.label}
</NavLink>
))}
</nav>
<div className="p-4 border-t border-white/10 shrink-0 flex flex-col gap-2 relative">
<div className="border-t border-white/10 p-6">
<button
onClick={() => setActiveModal('settings')}
className="w-full flex items-center justify-center md:justify-start gap-3 p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
type="button"
onClick={() => {
logout();
navigate('/login');
}}
className="flex w-full items-center gap-3 rounded-lg px-4 py-4 text-sm font-black uppercase tracking-[0.2em] text-gray-700 dark:text-gray-200 hover:text-red-500 transition-all hover:bg-white/20 dark:hover:bg-white/5"
>
<div className="w-8 h-8 rounded-full border border-white/10 flex items-center justify-center bg-slate-800 text-slate-300 relative z-10 overflow-hidden shrink-0">
{displayedAvatarUrl ? (
<img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span className="text-xs font-semibold">{avatarFallback}</span>
)}
</div>
<div className="hidden md:block flex-1 min-w-0 text-left">
<p className="text-sm font-medium text-white truncate">{displayName}</p>
<p className="text-xs text-slate-400 truncate">{email}</p>
</div>
</button>
<button
onClick={handleLogout}
className="w-full flex items-center justify-center md:justify-start gap-3 md:px-4 h-10 rounded-xl text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors"
>
<LogOut className="w-[18px] h-[18px]" />
<span className="hidden md:block font-medium">退</span>
<LogOut className="h-4 w-4 opacity-60" />
退
</button>
</div>
</aside>
<main className="flex-1 flex flex-col min-w-0 h-full relative overflow-y-auto">
{children ?? <Outlet />}
<main className="relative flex min-w-0 flex-1 flex-col overflow-hidden z-10">
<Outlet />
</main>
<UploadProgressPanel />
<AnimatePresence>
{activeModal === 'security' && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<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="bg-[#0f172a] border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]"
>
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-white/5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Shield className="w-5 h-5 text-emerald-400" />
</h3>
<button onClick={closeModal} className="text-slate-400 hover:text-white transition-colors p-1 rounded-md hover:bg-white/10">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 overflow-y-auto space-y-6">
<div className="space-y-4">
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
<Key className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-slate-400 mt-0.5">使 refresh token </p>
</div>
</div>
<div className="grid gap-3">
<Input
type="password"
placeholder="当前密码"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
className="bg-black/20 border-white/10"
/>
<Input
type="password"
placeholder="新密码"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
className="bg-black/20 border-white/10"
/>
<Input
type="password"
placeholder="确认新密码"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
className="bg-black/20 border-white/10"
/>
<div className="flex justify-end">
<Button variant="outline" disabled={passwordSubmitting} onClick={() => void handleChangePassword()}>
{passwordSubmitting ? '保存中...' : '修改'}
</Button>
</div>
</div>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center">
<Smartphone className="w-5 h-5 text-emerald-400" />
</div>
<div>
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-slate-400 mt-0.5">{phoneNumber}</p>
</div>
</div>
<Button
variant="outline"
className="border-white/10 hover:bg-white/10 text-slate-300"
onClick={() => setActiveModal('settings')}
>
</Button>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
<Mail className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-slate-400 mt-0.5">{email}</p>
</div>
</div>
<Button
variant="outline"
className="border-white/10 hover:bg-white/10 text-slate-300"
onClick={() => setActiveModal('settings')}
>
</Button>
</div>
</div>
{passwordError && <p className="text-sm text-rose-300">{passwordError}</p>}
{passwordMessage && <p className="text-sm text-emerald-300">{passwordMessage}</p>}
</div>
</motion.div>
</div>
)}
{activeModal === 'settings' && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<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="bg-[#0f172a] border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]"
>
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-white/5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Settings className="w-5 h-5 text-[#336EFF]" />
</h3>
<button onClick={closeModal} className="text-slate-400 hover:text-white transition-colors p-1 rounded-md hover:bg-white/10">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 overflow-y-auto space-y-6">
<div className="flex items-center gap-6 pb-6 border-b border-white/10">
<div className="relative group cursor-pointer" onClick={handleAvatarClick}>
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center text-2xl font-bold text-white shadow-lg overflow-hidden">
{displayedAvatarUrl ? <img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" /> : avatarFallback}
</div>
<div className="absolute inset-0 bg-black/50 rounded-full opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
<span className="text-xs text-white">{selectedAvatarFile ? '等待保存' : '更换头像'}</span>
</div>
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" />
</div>
<div className="flex-1 space-y-1">
<h4 className="text-lg font-medium text-white">{displayName}</h4>
<p className="text-sm text-slate-400">{roleLabel}</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<Input
value={profileDraft.displayName}
onChange={(event) => handleProfileDraftChange('displayName', event.target.value)}
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<Input
type="email"
value={profileDraft.email}
onChange={(event) => handleProfileDraftChange('email', event.target.value)}
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<Input
type="tel"
value={profileDraft.phoneNumber}
onChange={(event) => handleProfileDraftChange('phoneNumber', event.target.value)}
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<textarea
className="w-full min-h-[100px] rounded-md bg-black/20 border border-white/10 text-white p-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] resize-none"
value={profileDraft.bio}
onChange={(event) => handleProfileDraftChange('bio', event.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<select
className="w-full rounded-md bg-black/20 border border-white/10 text-white p-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] appearance-none"
value={profileDraft.preferredLanguage}
onChange={(event) => handleProfileDraftChange('preferredLanguage', event.target.value)}
>
<option value="zh-CN"></option>
<option value="en-US">English</option>
</select>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
<Key className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-slate-400 mt-0.5"></p>
</div>
</div>
<Button
variant="outline"
className="border-white/10 hover:bg-white/10 text-slate-300"
onClick={() => {
setActiveModal('security');
}}
>
</Button>
</div>
</div>
{profileError && <p className="text-sm text-rose-300">{profileError}</p>}
{profileMessage && <p className="text-sm text-emerald-300">{profileMessage}</p>}
<div className="pt-4 flex justify-end gap-3">
<Button variant="outline" onClick={closeModal} className="border-white/10 hover:bg-white/10 text-slate-300">
</Button>
<Button variant="default" disabled={profileSubmitting} onClick={() => void handleSaveProfile()}>
{profileSubmitting ? '保存中...' : '保存更改'}
</Button>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,38 +0,0 @@
import assert from 'node:assert/strict';
import { afterEach, test } from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { createUploadTask } from '@/src/pages/files-upload';
import {
clearFilesUploads,
replaceFilesUploads,
resetFilesUploadStoreForTests,
setFilesUploadPanelOpen,
} from '@/src/pages/files-upload-store';
import { UploadProgressPanel } from './UploadProgressPanel';
afterEach(() => {
resetFilesUploadStoreForTests();
});
test('mobile upload progress panel renders as a top summary card instead of a bottom desktop panel', () => {
replaceFilesUploads([
createUploadTask(new File(['demo'], 'demo.txt', { type: 'text/plain' }), []),
]);
setFilesUploadPanelOpen(false);
const html = renderToStaticMarkup(
React.createElement(UploadProgressPanel, {
variant: 'mobile',
className: 'top-offset-anchor',
}),
);
clearFilesUploads();
assert.match(html, /top-offset-anchor/);
assert.match(html, /已在后台上传 1 项/);
assert.doesNotMatch(html, /bottom-6/);
});

View File

@@ -1,220 +0,0 @@
import { AnimatePresence, motion } from 'motion/react';
import { Ban, CheckCircle2, ChevronDown, ChevronUp, FileUp, TriangleAlert, UploadCloud, X } from 'lucide-react';
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
import { ellipsizeFileName } from '@/src/lib/file-name';
import { cn } from '@/src/lib/utils';
import {
cancelFilesUploadTask,
clearFilesUploads,
toggleFilesUploadPanelOpen,
useFilesUploadStore,
} from '@/src/pages/files-upload-store';
import type { UploadTask } from '@/src/pages/files-upload';
export type UploadProgressPanelVariant = 'desktop' | 'mobile';
export function getUploadProgressSummary(uploads: UploadTask[]) {
const uploadingCount = uploads.filter((task) => task.status === 'uploading').length;
const completedCount = uploads.filter((task) => task.status === 'completed').length;
const errorCount = uploads.filter((task) => task.status === 'error').length;
const cancelledCount = uploads.filter((task) => task.status === 'cancelled').length;
const uploadingTasks = uploads.filter((task) => task.status === 'uploading');
const activeProgress = uploadingTasks.length > 0
? Math.round(uploadingTasks.reduce((sum, task) => sum + task.progress, 0) / uploadingTasks.length)
: uploads.length > 0 && completedCount === uploads.length
? 100
: 0;
if (uploadingCount > 0) {
return {
title: `已在后台上传 ${uploadingCount}`,
detail: `${completedCount}/${uploads.length} 已完成 · ${activeProgress}%`,
progress: activeProgress,
};
}
if (errorCount > 0) {
return {
title: `上传结束,${errorCount} 项失败`,
detail: `${completedCount}/${uploads.length} 已完成`,
progress: activeProgress,
};
}
if (cancelledCount > 0) {
return {
title: '上传已停止',
detail: `${completedCount}/${uploads.length} 已完成`,
progress: activeProgress,
};
}
return {
title: `上传已完成 ${completedCount}`,
detail: `${completedCount}/${uploads.length} 已完成`,
progress: activeProgress,
};
}
interface UploadProgressPanelProps {
className?: string;
variant?: UploadProgressPanelVariant;
}
export function UploadProgressPanel({
className,
variant = 'desktop',
}: UploadProgressPanelProps = {}) {
const { uploads, isUploadPanelOpen } = useFilesUploadStore();
if (uploads.length === 0) {
return null;
}
const summary = getUploadProgressSummary(uploads);
const isMobile = variant === 'mobile';
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 50, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 50, scale: 0.95 }}
className={cn(
'z-50 flex flex-col overflow-hidden border border-white/10 bg-[#0f172a]/95 backdrop-blur-xl',
isMobile
? 'w-full rounded-2xl shadow-[0_16px_40px_rgba(15,23,42,0.28)]'
: 'fixed bottom-6 right-6 w-[min(24rem,calc(100vw-2rem))] rounded-xl shadow-2xl',
className,
)}
>
<div
className="flex cursor-pointer items-center justify-between border-b border-white/10 bg-white/5 px-4 py-3 transition-colors hover:bg-white/10"
onClick={() => toggleFilesUploadPanelOpen()}
>
<div className="flex items-center gap-2">
<UploadCloud className="h-4 w-4 text-[#336EFF]" />
<div className="flex min-w-0 flex-col">
<span className="text-sm font-medium text-white">
{isMobile ? summary.title : `上传进度 (${uploads.filter((task) => task.status === 'completed').length}/${uploads.length})`}
</span>
{isMobile ? (
<span className="text-[11px] text-slate-400">{summary.detail}</span>
) : null}
</div>
</div>
<div className="flex items-center gap-1">
{isMobile ? (
<span className="rounded-full bg-[#336EFF]/15 px-2 py-1 text-[11px] font-medium text-[#8fb0ff]">
{summary.progress}%
</span>
) : null}
<button type="button" className="rounded p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white">
{isUploadPanelOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</button>
<button
type="button"
className="rounded p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={(event) => {
event.stopPropagation();
clearFilesUploads();
}}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<AnimatePresence initial={false}>
{isUploadPanelOpen && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
className={cn(isMobile ? 'max-h-64 overflow-y-auto' : 'max-h-80 overflow-y-auto')}
>
<div className="space-y-1 p-2">
{uploads.map((task) => (
<div
key={task.id}
className={cn(
'group relative overflow-hidden rounded-lg p-3 transition-colors hover:bg-white/5',
task.status === 'error' && 'bg-rose-500/5',
task.status === 'cancelled' && 'bg-amber-500/5',
)}
>
{task.status === 'uploading' && (
<div
className="absolute inset-y-0 left-0 bg-[#336EFF]/10 transition-all duration-300 ease-out"
style={{ width: `${task.progress}%` }}
/>
)}
<div className="relative z-10 flex items-start gap-3">
<FileTypeIcon type={task.type} size="sm" className="mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<p className="truncate text-sm font-medium text-slate-200" title={task.fileName}>
{ellipsizeFileName(task.fileName, 30)}
</p>
<div className="shrink-0 flex items-center gap-2">
{task.status === 'uploading' ? (
<button
type="button"
className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-200 transition-colors hover:bg-amber-500/20"
onClick={() => {
cancelFilesUploadTask(task.id);
}}
>
</button>
) : null}
{task.status === 'completed' ? (
<CheckCircle2 className="h-[18px] w-[18px] text-emerald-400" />
) : task.status === 'cancelled' ? (
<Ban className="h-[18px] w-[18px] text-amber-300" />
) : task.status === 'error' ? (
<TriangleAlert className="h-[18px] w-[18px] text-rose-400" />
) : (
<FileUp className="h-[18px] w-[18px] animate-pulse text-[#78A1FF]" />
)}
</div>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span className={cn('rounded-full px-2 py-1 font-medium', getFileTypeTheme(task.type).badgeClassName)}>
{task.typeLabel}
</span>
<span className="truncate text-slate-500">: {task.destination}</span>
</div>
{task.noticeMessage && (
<p className="mt-2 truncate text-xs text-amber-300">{task.noticeMessage}</p>
)}
{task.status === 'uploading' && (
<div className="mt-2 flex items-center justify-between text-xs">
<span className="font-medium text-[#336EFF]">{Math.round(task.progress)}%</span>
<span className="font-mono text-slate-400">{task.speed}</span>
</div>
)}
{task.status === 'completed' && (
<p className="mt-2 text-xs text-emerald-400"></p>
)}
{task.status === 'cancelled' && (
<p className="mt-2 text-xs text-amber-300"></p>
)}
{task.status === 'error' && (
<p className="mt-2 truncate text-xs text-rose-400">{task.errorMessage ?? '上传失败,请稍后重试'}</p>
)}
</div>
</div>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -1,39 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { UserProfile } from '@/src/lib/types';
import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './account-utils';
test('buildAccountDraft prefers display name and fills fallback values', () => {
const profile: UserProfile = {
id: 1,
username: 'alice',
displayName: 'Alice',
email: 'alice@example.com',
bio: null,
preferredLanguage: null,
role: 'USER',
createdAt: '2026-03-19T17:00:00',
};
assert.deepEqual(buildAccountDraft(profile), {
displayName: 'Alice',
email: 'alice@example.com',
phoneNumber: '',
bio: '',
preferredLanguage: 'zh-CN',
});
});
test('getRoleLabel maps backend roles to readable chinese labels', () => {
assert.equal(getRoleLabel('ADMIN'), '管理员');
assert.equal(getRoleLabel('MODERATOR'), '协管员');
assert.equal(getRoleLabel('USER'), '普通用户');
});
test('shouldLoadAvatarWithAuth only treats relative avatar urls as protected resources', () => {
assert.equal(shouldLoadAvatarWithAuth('/api/user/avatar/content?v=1'), true);
assert.equal(shouldLoadAvatarWithAuth('https://cdn.example.com/avatar.png?sig=1'), false);
assert.equal(shouldLoadAvatarWithAuth(null), false);
});

View File

@@ -1,34 +0,0 @@
import type { AdminUserRole, UserProfile } from '@/src/lib/types';
export interface AccountDraft {
displayName: string;
email: string;
phoneNumber: string;
bio: string;
preferredLanguage: string;
}
export function buildAccountDraft(profile: UserProfile): AccountDraft {
return {
displayName: profile.displayName || profile.username,
email: profile.email,
phoneNumber: profile.phoneNumber || '',
bio: profile.bio || '',
preferredLanguage: profile.preferredLanguage || 'zh-CN',
};
}
export function getRoleLabel(role: AdminUserRole | undefined) {
switch (role) {
case 'ADMIN':
return '管理员';
case 'MODERATOR':
return '协管员';
default:
return '普通用户';
}
}
export function shouldLoadAvatarWithAuth(avatarUrl: string | null | undefined) {
return Boolean(avatarUrl && avatarUrl.startsWith('/'));
}

View File

@@ -1,42 +0,0 @@
import React, { ReactNode } from 'react';
import { cn } from '@/src/lib/utils';
interface AppPageShellProps {
toolbar: ReactNode;
rail?: ReactNode;
inspector?: ReactNode;
children: ReactNode;
}
export function AppPageShell({ toolbar, rail, inspector, children }: AppPageShellProps) {
return (
<div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden relative z-10 w-full bg-[#07101D]">
{/* Top Toolbar */}
<header className="h-14 shrink-0 border-b border-white/10 bg-[#0f172a]/70 flex items-center px-4 w-full z-20 backdrop-blur-xl">
{toolbar}
</header>
{/* 3-Zone Content Segment */}
<div className="flex-1 flex min-h-0 w-full overflow-hidden">
{/* Nav Rail (e.g. Directory Tree) */}
{rail && (
<div className="w-64 shrink-0 border-r border-white/10 bg-[#0f172a]/20 h-full overflow-y-auto">
{rail}
</div>
)}
{/* Center Main Pane */}
<main className="flex-1 min-w-0 h-full overflow-y-auto bg-transparent relative">
{children}
</main>
{/* Inspector Panel (e.g. File Details) */}
{inspector && (
<div className="w-72 shrink-0 border-l border-white/10 bg-[#0f172a]/20 h-full overflow-y-auto hidden lg:block">
{inspector}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,177 +0,0 @@
import type { LucideIcon } from 'lucide-react';
import {
AppWindow,
BookOpenText,
Database,
FileArchive,
FileAudio2,
FileBadge2,
FileCode2,
FileImage,
FileSpreadsheet,
FileText,
FileVideoCamera,
Folder,
Presentation,
SwatchBook,
Type,
} from 'lucide-react';
import type { FileTypeKind } from '@/src/lib/file-type';
import { cn } from '@/src/lib/utils';
type FileTypeIconSize = 'sm' | 'md' | 'lg';
interface FileTypeTheme {
badgeClassName: string;
icon: LucideIcon;
iconClassName: string;
surfaceClassName: string;
}
const FILE_TYPE_THEMES: Record<FileTypeKind, FileTypeTheme> = {
folder: {
icon: Folder,
iconClassName: 'text-[#78A1FF]',
surfaceClassName: 'border border-[#336EFF]/25 bg-[linear-gradient(135deg,rgba(51,110,255,0.24),rgba(15,23,42,0.2))] shadow-[0_16px_30px_-22px_rgba(51,110,255,0.95)]',
badgeClassName: 'border border-[#336EFF]/20 bg-[#336EFF]/10 text-[#93B4FF]',
},
image: {
icon: FileImage,
iconClassName: 'text-cyan-300',
surfaceClassName: 'border border-cyan-400/20 bg-[linear-gradient(135deg,rgba(34,211,238,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(34,211,238,0.8)]',
badgeClassName: 'border border-cyan-400/15 bg-cyan-400/10 text-cyan-200',
},
pdf: {
icon: FileBadge2,
iconClassName: 'text-rose-300',
surfaceClassName: 'border border-rose-400/20 bg-[linear-gradient(135deg,rgba(251,113,133,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(251,113,133,0.78)]',
badgeClassName: 'border border-rose-400/15 bg-rose-400/10 text-rose-200',
},
word: {
icon: FileText,
iconClassName: 'text-sky-300',
surfaceClassName: 'border border-sky-400/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(56,189,248,0.8)]',
badgeClassName: 'border border-sky-400/15 bg-sky-400/10 text-sky-200',
},
spreadsheet: {
icon: FileSpreadsheet,
iconClassName: 'text-emerald-300',
surfaceClassName: 'border border-emerald-400/20 bg-[linear-gradient(135deg,rgba(52,211,153,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(52,211,153,0.82)]',
badgeClassName: 'border border-emerald-400/15 bg-emerald-400/10 text-emerald-200',
},
presentation: {
icon: Presentation,
iconClassName: 'text-amber-300',
surfaceClassName: 'border border-amber-400/20 bg-[linear-gradient(135deg,rgba(251,191,36,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(251,191,36,0.82)]',
badgeClassName: 'border border-amber-400/15 bg-amber-400/10 text-amber-100',
},
archive: {
icon: FileArchive,
iconClassName: 'text-orange-300',
surfaceClassName: 'border border-orange-400/20 bg-[linear-gradient(135deg,rgba(251,146,60,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(251,146,60,0.8)]',
badgeClassName: 'border border-orange-400/15 bg-orange-400/10 text-orange-100',
},
video: {
icon: FileVideoCamera,
iconClassName: 'text-fuchsia-300',
surfaceClassName: 'border border-fuchsia-400/20 bg-[linear-gradient(135deg,rgba(232,121,249,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(232,121,249,0.78)]',
badgeClassName: 'border border-fuchsia-400/15 bg-fuchsia-400/10 text-fuchsia-100',
},
audio: {
icon: FileAudio2,
iconClassName: 'text-teal-300',
surfaceClassName: 'border border-teal-400/20 bg-[linear-gradient(135deg,rgba(45,212,191,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(45,212,191,0.8)]',
badgeClassName: 'border border-teal-400/15 bg-teal-400/10 text-teal-100',
},
design: {
icon: SwatchBook,
iconClassName: 'text-pink-300',
surfaceClassName: 'border border-pink-400/20 bg-[linear-gradient(135deg,rgba(244,114,182,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(244,114,182,0.8)]',
badgeClassName: 'border border-pink-400/15 bg-pink-400/10 text-pink-100',
},
font: {
icon: Type,
iconClassName: 'text-lime-300',
surfaceClassName: 'border border-lime-400/20 bg-[linear-gradient(135deg,rgba(163,230,53,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(163,230,53,0.8)]',
badgeClassName: 'border border-lime-400/15 bg-lime-400/10 text-lime-100',
},
application: {
icon: AppWindow,
iconClassName: 'text-violet-300',
surfaceClassName: 'border border-violet-400/20 bg-[linear-gradient(135deg,rgba(167,139,250,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(167,139,250,0.82)]',
badgeClassName: 'border border-violet-400/15 bg-violet-400/10 text-violet-100',
},
ebook: {
icon: BookOpenText,
iconClassName: 'text-yellow-200',
surfaceClassName: 'border border-yellow-300/20 bg-[linear-gradient(135deg,rgba(253,224,71,0.16),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(253,224,71,0.7)]',
badgeClassName: 'border border-yellow-300/15 bg-yellow-300/10 text-yellow-100',
},
code: {
icon: FileCode2,
iconClassName: 'text-cyan-200',
surfaceClassName: 'border border-cyan-300/20 bg-[linear-gradient(135deg,rgba(103,232,249,0.16),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(103,232,249,0.72)]',
badgeClassName: 'border border-cyan-300/15 bg-cyan-300/10 text-cyan-100',
},
text: {
icon: FileText,
iconClassName: 'text-slate-200',
surfaceClassName: 'border border-slate-400/20 bg-[linear-gradient(135deg,rgba(148,163,184,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(148,163,184,0.55)]',
badgeClassName: 'border border-slate-400/15 bg-slate-400/10 text-slate-200',
},
data: {
icon: Database,
iconClassName: 'text-indigo-300',
surfaceClassName: 'border border-indigo-400/20 bg-[linear-gradient(135deg,rgba(129,140,248,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(129,140,248,0.8)]',
badgeClassName: 'border border-indigo-400/15 bg-indigo-400/10 text-indigo-100',
},
document: {
icon: FileText,
iconClassName: 'text-slate-100',
surfaceClassName: 'border border-white/10 bg-[linear-gradient(135deg,rgba(148,163,184,0.14),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(15,23,42,0.9)]',
badgeClassName: 'border border-white/10 bg-white/10 text-slate-200',
},
};
const CONTAINER_SIZES: Record<FileTypeIconSize, string> = {
sm: 'h-10 w-10 rounded-xl',
md: 'h-12 w-12 rounded-2xl',
lg: 'h-16 w-16 rounded-[1.35rem]',
};
const ICON_SIZES: Record<FileTypeIconSize, string> = {
sm: 'h-[18px] w-[18px]',
md: 'h-[22px] w-[22px]',
lg: 'h-8 w-8',
};
export function getFileTypeTheme(type: FileTypeKind): FileTypeTheme {
return FILE_TYPE_THEMES[type] ?? FILE_TYPE_THEMES.document;
}
export function FileTypeIcon({
type,
size = 'md',
className,
}: {
type: FileTypeKind;
size?: FileTypeIconSize;
className?: string;
}) {
const theme = getFileTypeTheme(type);
const Icon = theme.icon;
return (
<div
className={cn(
'flex shrink-0 items-center justify-center backdrop-blur-sm',
CONTAINER_SIZES[size],
theme.surfaceClassName,
className,
)}
>
<Icon className={cn(ICON_SIZES[size], theme.iconClassName)} />
</div>
);
}

View File

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

View File

@@ -1,25 +0,0 @@
import React, { ReactNode } from 'react';
interface PageToolbarProps {
title: ReactNode;
actions?: ReactNode;
}
export function PageToolbar({ title, actions }: PageToolbarProps) {
return (
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
{typeof title === 'string' ? (
<h2 className="text-lg font-semibold text-white tracking-tight">{title}</h2>
) : (
title
)}
</div>
{actions && (
<div className="flex items-center gap-2">
{actions}
</div>
)}
</div>
);
}

View File

@@ -1,36 +0,0 @@
import * as React from "react"
import { cn } from "@/src/lib/utils"
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "outline" | "ghost" | "glass"
size?: "default" | "sm" | "lg" | "icon"
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "default", ...props }, ref) => {
return (
<button
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-xl text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
"bg-[#336EFF] text-white hover:bg-[#2958cc] shadow-md shadow-[#336EFF]/20": variant === "default",
"border border-white/20 bg-transparent hover:bg-white/10 text-white": variant === "outline",
"hover:bg-white/10 text-white": variant === "ghost",
"glass-panel hover:bg-white/10 text-white": variant === "glass",
"h-10 px-4 py-2": size === "default",
"h-9 rounded-lg px-3": size === "sm",
"h-11 rounded-xl px-8": size === "lg",
"h-10 w-10": size === "icon",
},
className
)}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button }

View File

@@ -1,11 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { Card } from './card';
test('Card applies the shared elevated shadow styling', () => {
const html = renderToStaticMarkup(<Card>demo</Card>);
assert.match(html, /shadow-\[0_12px_32px_rgba\(15,23,42,0\.18\)\]/);
});

View File

@@ -1,78 +0,0 @@
import * as React from "react"
import { cn } from "@/src/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"glass-panel rounded-2xl text-white shadow-[0_12px_32px_rgba(15,23,42,0.18)]",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-slate-400", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -1,24 +0,0 @@
import * as React from "react"
import { cn } from "@/src/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-11 w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#336EFF] disabled:cursor-not-allowed disabled:opacity-50 transition-colors",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }