feat(front): 覆盖 front 并完善登录快传入口与中文文案
This commit is contained in:
73
front/src/components/ThemeProvider.tsx
Normal file
73
front/src/components/ThemeProvider.tsx
Normal 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;
|
||||
};
|
||||
17
front/src/components/ThemeToggle.tsx
Normal file
17
front/src/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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('/'));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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\)\]/);
|
||||
});
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user