Spaces:
Sleeping
Sleeping
| 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; | |