stud-manager / App.tsx
dvc890's picture
Upload 67 files
151e61b verified
import React, { useState, useEffect, useRef } from 'react';
import { Sidebar } from './components/Sidebar';
import { Header } from './components/Header';
import { Login } from './pages/Login';
import { User, UserRole } from './types';
import { api } from './services/api';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { LiveAssistant } from './components/LiveAssistant';
// --- Page Loading Component ---
const PageLoading = () => (
<div className="flex h-full w-full items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center space-y-3">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
<p className="text-sm text-gray-400">正在加载资源...</p>
</div>
</div>
);
// --- Manual Async Component Helper (Replaces React.lazy) ---
// This avoids Suspense getting stuck by managing loading state explicitly.
const lazyLoad = (importFn: () => Promise<any>, name: string) => {
return (props: any) => {
const [Component, setComponent] = useState<React.ComponentType<any> | null>(null);
const [error, setError] = useState<Error | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
importFn()
.then(module => {
if (!mountedRef.current) return;
// Support both named export (priority) and default export
const Comp = module[name] || module.default;
if (Comp) {
// Use callback to set function component to state
setComponent(() => Comp);
} else {
throw new Error(`Export "${name}" not found in module.`);
}
})
.catch(err => {
if (!mountedRef.current) return;
console.error(`Error loading page ${name}:`, err);
setError(err);
// Retry logic for chunks could go here, but usually reload is best
if (err.message && (err.message.includes('fetch') || err.message.includes('chunk'))) {
console.warn('Chunk load error, suggesting reload.');
}
});
return () => { mountedRef.current = false; };
}, []);
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full text-red-500 gap-4">
<p>页面加载失败: {error.message}</p>
<button onClick={() => window.location.reload()} className="px-4 py-2 bg-blue-600 text-white rounded">刷新页面</button>
</div>
);
}
if (!Component) return <PageLoading />;
return <Component {...props} />;
};
};
// Lazy load pages using the manual helper
const Dashboard = lazyLoad(() => import('./pages/Dashboard'), 'Dashboard');
const StudentList = lazyLoad(() => import('./pages/StudentList'), 'StudentList');
const CourseList = lazyLoad(() => import('./pages/CourseList'), 'CourseList');
const ScoreList = lazyLoad(() => import('./pages/ScoreList'), 'ScoreList');
const ClassList = lazyLoad(() => import('./pages/ClassList'), 'ClassList');
const Settings = lazyLoad(() => import('./pages/Settings'), 'Settings');
const Reports = lazyLoad(() => import('./pages/Reports'), 'Reports');
const SubjectList = lazyLoad(() => import('./pages/SubjectList'), 'SubjectList');
const UserList = lazyLoad(() => import('./pages/UserList'), 'UserList');
const SchoolList = lazyLoad(() => import('./pages/SchoolList'), 'SchoolList');
const Games = lazyLoad(() => import('./pages/Games'), 'Games');
const AttendancePage = lazyLoad(() => import('./pages/Attendance'), 'AttendancePage');
const Profile = lazyLoad(() => import('./pages/Profile'), 'Profile');
const WishesAndFeedback = lazyLoad(() => import('./pages/WishesAndFeedback'), 'WishesAndFeedback');
const AIAssistant = lazyLoad(() => import('./pages/AIAssistant'), 'AIAssistant');
const MyClass = lazyLoad(() => import('./pages/MyClass'), 'MyClass');
class ErrorBoundary extends React.Component<{children: React.ReactNode}, {hasError: boolean, error: Error | null}> {
constructor(props: any) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("Uncaught error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-lg w-full text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-red-100 mb-4">
<AlertTriangle className="h-8 w-8 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">页面运行遇到问题</h2>
<p className="text-gray-500 mb-6">请尝试刷新页面。</p>
<button onClick={() => window.location.reload()} className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium">刷新页面</button>
</div>
</div>
);
}
return this.props.children;
}
}
const AppContent: React.FC = () => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [currentView, setCurrentView] = useState('dashboard');
const [loading, setLoading] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(false);
// --- Auto-Update Logic ---
useEffect(() => {
// 1. Force reload page when Service Worker updates (controller change)
// This happens automatically due to 'registerType: autoUpdate' in vite config,
// but the window needs to reload to use the new SW.
const handleControllerChange = () => {
console.log("🔄 App updated, reloading...");
window.location.reload();
};
// 2. Check for updates on visibility change (e.g. user comes back to tab)
const checkForUpdates = () => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.update();
}).catch(() => {});
}
};
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('controllerchange', handleControllerChange);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
checkForUpdates();
}
});
// Initial check
checkForUpdates();
}
return () => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('controllerchange', handleControllerChange);
}
};
}, []);
useEffect(() => {
const initApp = async () => {
api.init();
let storedUser = api.auth.getCurrentUser();
if (storedUser) {
try {
const refreshedUser = await api.auth.refreshSession();
if (refreshedUser) storedUser = refreshedUser;
} catch (e) {
console.warn('Session refresh failed');
}
setCurrentUser(storedUser);
setIsAuthenticated(true);
}
setLoading(false);
};
initApp();
}, []);
const handleLogin = (user: User) => {
setCurrentUser(user);
setIsAuthenticated(true);
setCurrentView('dashboard');
};
const handleLogout = () => {
api.auth.logout();
setIsAuthenticated(false);
setCurrentUser(null);
};
const renderContent = () => {
switch (currentView) {
case 'dashboard': return <Dashboard onNavigate={(view: string) => setCurrentView(view)} />;
case 'students': return <StudentList />;
case 'classes': return <ClassList />;
case 'courses': return <CourseList />;
case 'grades': return <ScoreList />;
case 'settings': return <Settings />;
case 'reports': return <Reports />;
case 'subjects': return <SubjectList />;
case 'users': return <UserList />;
case 'schools': return <SchoolList />;
case 'games': return <Games />;
case 'attendance': return <AttendancePage />;
case 'wishes': return <WishesAndFeedback />;
case 'ai-assistant': return <AIAssistant />;
case 'profile': return <Profile />;
case 'my-class': return <MyClass />;
default: return <Dashboard onNavigate={(view: string) => setCurrentView(view)} />;
}
};
const viewTitles: Record<string, string> = {
dashboard: '工作台',
students: '学生档案管理',
classes: '班级管理',
courses: '课程安排',
grades: '成绩管理',
settings: '系统设置',
reports: '统计报表',
subjects: '学科设置',
users: '用户权限管理',
schools: '学校维度管理',
games: '互动教学中心',
attendance: '考勤管理',
wishes: '心愿与反馈',
'ai-assistant': 'AI 智能助教',
profile: '个人中心',
'my-class': '我的班级 (班主任)'
};
if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50 text-blue-600">加载中...</div>;
if (!isAuthenticated) return <Login onLogin={handleLogin} />;
const showLiveAssistant = currentUser && (currentUser.role === UserRole.ADMIN || currentUser.aiAccess);
// Layout Logic:
// For 'ai-assistant' and 'games', we want full height without internal padding from Main,
// so the component can manage its own scroll/flex layout (sticky headers/footers).
// For other pages, we use the standard scrolling Main container.
const isAppLikePage = currentView === 'ai-assistant' || currentView === 'games';
const mainClasses = isAppLikePage
? "flex-1 overflow-hidden relative bg-slate-50 w-full"
: "flex-1 overflow-x-hidden overflow-y-auto bg-gray-50 p-4 md:p-6 w-full relative";
return (
// Fixed inset-0 prevents body scroll, everything happens inside
<div className="fixed inset-0 flex bg-gray-50 overflow-hidden">
<Sidebar
currentView={currentView}
onChangeView={(view) => { setCurrentView(view); setSidebarOpen(false); }}
userRole={(currentUser?.role as UserRole) || UserRole.USER}
onLogout={handleLogout}
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/>
<div className="flex-1 flex flex-col w-full relative h-full min-w-0">
<Header user={currentUser!} title={viewTitles[currentView] || '智慧校园'} onMenuClick={() => setSidebarOpen(true)}/>
<main className={mainClasses}>
{isAppLikePage ? (
renderContent()
) : (
<div className="max-w-7xl mx-auto w-full min-h-full">
{renderContent()}
</div>
)}
</main>
{showLiveAssistant && <LiveAssistant />}
</div>
</div>
);
};
const App: React.FC = () => {
return (
<ErrorBoundary>
<AppContent />
</ErrorBoundary>
);
};
export default App;