chore: initial import to Nexus_Edu

This commit is contained in:
SpecialX
2025-11-28 19:23:19 +08:00
commit 38244630a7
153 changed files with 22541 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
"use client";
import React, { useState } from 'react';
import { useAuth } from '@/lib/auth-context';
import { AnimatePresence, motion } from 'framer-motion';
import { TeacherAssignmentList } from '@/features/assignment/components/TeacherAssignmentList';
import { StudentAssignmentList } from '@/features/assignment/components/StudentAssignmentList';
import { CreateAssignmentModal } from '@/features/assignment/components/CreateAssignmentModal';
import { AssignmentStats } from '@/features/assignment/components/AssignmentStats';
import { useRouter } from 'next/navigation';
export default function AssignmentsPage() {
const { user } = useAuth();
const router = useRouter();
const [isCreating, setIsCreating] = useState(false);
const [analyzingId, setAnalyzingId] = useState<string | null>(null);
if (!user) return null;
const isStudent = user.role === 'Student';
const handleNavigateToGrading = (id: string) => {
// Teachers go to grading tool, Students view results (though usually handled by onViewResult)
router.push(`/grading/${id}`);
};
const handleNavigateToPreview = (id: string) => {
router.push(`/student-exam/${id}`);
};
const handleViewResult = (id: string) => {
router.push(`/student-result/${id}`);
};
if (analyzingId && !isStudent) {
return (
// Fix: cast props to any to avoid framer-motion type errors
<motion.div
{...({
initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -20 }
} as any)}
className="h-[calc(100vh-100px)]"
>
<AssignmentStats
assignmentId={analyzingId}
onBack={() => setAnalyzingId(null)}
/>
</motion.div>
);
}
return (
<>
<div className="space-y-6">
{isStudent ? (
<StudentAssignmentList
onStartExam={handleNavigateToPreview}
onViewResult={handleViewResult}
/>
) : (
<TeacherAssignmentList
onNavigateToGrading={handleNavigateToGrading}
onNavigateToPreview={handleNavigateToPreview}
onAnalyze={setAnalyzingId}
setIsCreating={setIsCreating}
/>
)}
</div>
<AnimatePresence>
{isCreating && (
<CreateAssignmentModal
onClose={() => setIsCreating(false)}
onSuccess={() => {
setIsCreating(false);
}}
/>
)}
</AnimatePresence>
</>
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import React from 'react';
import { ClassManagement } from '@/views/ClassManagement';
import { useAuth } from '@/lib/auth-context';
export default function ClassesPage() {
const { user } = useAuth();
if (!user) return null;
return <ClassManagement currentUser={user} />;
}

View File

@@ -0,0 +1,9 @@
"use client";
import React from 'react';
import { ConsoleConfig } from '@/views/ConsoleConfig';
export default function ConsoleConfigPage() {
return <ConsoleConfig />;
}

View File

@@ -0,0 +1,8 @@
"use client";
import React from 'react';
import { Curriculum } from '@/views/Curriculum';
export default function CurriculumPage() {
return <Curriculum />;
}

View File

@@ -0,0 +1,76 @@
"use client";
import React from 'react';
import { CalendarClock, ListTodo, Plus } from 'lucide-react';
import { TeacherDashboard } from '@/features/dashboard/components/TeacherDashboard';
import { StudentDashboard } from '@/features/dashboard/components/StudentDashboard';
import { useAuth } from '@/lib/auth-context';
import { useRouter } from 'next/navigation';
// Helper for adapting onNavigate to useRouter
const useNavigationAdapter = () => {
const router = useRouter();
return (view: string) => {
// Map old string views to new routes
const routeMap: Record<string, string> = {
'dashboard': '/dashboard',
'assignments': '/assignments',
'classes': '/classes',
'exams': '/exams',
'curriculum': '/curriculum',
'settings': '/settings',
'messages': '/messages',
'schedule': '/schedule',
'student-exam': '/student-exam', // might need ID handling
'student-result': '/student-result'
};
router.push(routeMap[view] || '/dashboard');
};
};
export default function DashboardPage() {
const { user } = useAuth();
const navigate = useNavigationAdapter();
const router = useRouter();
const today = new Date().toLocaleDateString('zh-CN', { weekday: 'long', month: 'long', day: 'numeric' });
if (!user) return null;
return (
<div className="space-y-8 pb-8">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900">, {user.realName} {user.role === 'Student' ? '同学' : '老师'} 👋</h2>
<p className="text-gray-500 mt-1 font-medium flex items-center gap-2">
<CalendarClock size={16} className="text-blue-500" />
{today}
</p>
</div>
{user.role === 'Teacher' && (
<div className="flex gap-3">
<button
onClick={() => router.push('/assignments')}
className="flex items-center gap-2 bg-white text-gray-700 px-4 py-2.5 rounded-xl text-sm font-bold shadow-sm hover:bg-gray-50 transition-all border border-gray-100"
>
<ListTodo size={18} />
(3)
</button>
<button
onClick={() => router.push('/exams')}
className="flex items-center gap-2 bg-gray-900 text-white px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg hover:bg-black hover:-translate-y-0.5 transition-all"
>
<Plus size={18} />
</button>
</div>
)}
</div>
{user.role === 'Student' ? (
<StudentDashboard user={user} onNavigate={navigate} />
) : (
<TeacherDashboard user={user} onNavigate={navigate} />
)}
</div>
);
}

View File

@@ -0,0 +1,8 @@
"use client";
import React from 'react';
import { ExamEngine } from '@/views/ExamEngine';
export default function ExamsPage() {
return <ExamEngine />;
}

View File

@@ -0,0 +1,87 @@
"use client";
import React from 'react';
import { Sidebar } from '@/components/Sidebar';
import { Bell, Search } from 'lucide-react';
import { useAuth } from '@/lib/auth-context';
import { usePathname, useRouter } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { ErrorBoundary } from '@/components/ErrorBoundary'; // Import ErrorBoundary
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const pathname = usePathname();
const router = useRouter();
if (loading) {
return <div className="h-screen flex items-center justify-center"><Loader2 className="animate-spin" /></div>;
}
if (!user) return null; // Middleware or AuthProvider will redirect
const getHeaderTitle = (path: string) => {
if (path.includes('/dashboard')) return '教学概览';
if (path.includes('/curriculum')) return '课程标准';
if (path.includes('/exams')) return '考试引擎';
if (path.includes('/assignments')) return '作业发布';
if (path.includes('/questions')) return '题库资源';
if (path.includes('/classes')) return '班级管理';
if (path.includes('/settings')) return '系统设置';
if (path.includes('/messages')) return '消息中心';
if (path.includes('/schedule')) return '课程表';
return 'EduNexus';
};
const roleName = user.role === 'Teacher' ? '教师' : (user.role === 'Admin' ? '管理员' : '学生');
return (
<div className="min-h-screen flex bg-[#F2F2F7] font-sans text-gray-900">
<Sidebar />
<main className="flex-1 lg:ml-72 p-4 md:p-8 overflow-y-auto h-screen scroll-smooth">
{/* Top Bar */}
<header className="flex justify-between items-center mb-8 sticky top-0 z-40 py-4 -mx-4 px-4 md:-mx-8 md:px-8 bg-[#F2F2F7]/80 backdrop-blur-xl transition-all">
<div className="flex flex-col ml-12 lg:ml-0">
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 tracking-tight line-clamp-1">
{getHeaderTitle(pathname || '')}
</h1>
<p className="text-xs md:text-sm text-gray-500 font-medium mt-0.5">2024-2025 </p>
</div>
<div className="flex items-center gap-3 md:gap-5">
<div className="relative hidden md:block group">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-blue-500 transition-colors" size={18} />
<input
type="text"
placeholder="全局搜索..."
className="pl-10 pr-4 py-2.5 bg-white rounded-full border-none shadow-sm text-sm focus:ring-2 focus:ring-blue-500/20 outline-none w-48 lg:w-64 transition-all placeholder:text-gray-400"
/>
</div>
<button
onClick={() => router.push('/messages')}
className="w-10 h-10 md:w-11 md:h-11 bg-white rounded-full flex items-center justify-center shadow-sm text-gray-500 hover:text-blue-600 hover:shadow-md transition-all relative"
>
<Bell size={20} />
<span className="absolute top-3 right-3.5 w-2 h-2 bg-red-500 rounded-full border-2 border-white"></span>
</button>
<div className="flex items-center gap-3 bg-white pr-4 pl-1.5 py-1.5 rounded-full shadow-sm hover:shadow-md transition-all cursor-pointer">
<img
src={user.avatarUrl}
alt="Profile"
className="w-8 h-8 md:w-9 md:h-9 rounded-full border border-gray-100 object-cover"
/>
<div className="flex flex-col hidden md:flex">
<span className="text-sm font-semibold text-gray-900 leading-none">{user.realName}</span>
<span className="text-[11px] text-gray-500 font-medium mt-0.5 uppercase tracking-wide">{roleName}</span>
</div>
</div>
</div>
</header>
<ErrorBoundary>
{children}
</ErrorBoundary>
</main>
</div>
);
}

View File

@@ -0,0 +1,11 @@
"use client";
import React from 'react';
import { Messages } from '@/views/Messages';
import { useAuth } from '@/lib/auth-context';
export default function MessagesPage() {
const { user } = useAuth();
if (!user) return null;
return <Messages currentUser={user} />;
}

View File

@@ -0,0 +1,8 @@
"use client";
import React from 'react';
import { QuestionBank } from '@/views/QuestionBank';
export default function QuestionsPage() {
return <QuestionBank />;
}

View File

@@ -0,0 +1,10 @@
"use client";
import React from 'react';
import { Schedule } from '@/views/Schedule';
import { useAuth } from '@/lib/auth-context';
export default function SchedulePage() {
const { user } = useAuth();
return <Schedule currentUser={user || undefined} />;
}

View File

@@ -0,0 +1,8 @@
"use client";
import React from 'react';
import { Settings } from '@/views/Settings';
export default function SettingsPage() {
return <Settings />;
}

View File

@@ -0,0 +1,16 @@
"use client";
import React from 'react';
import { StudentResult } from '@/views/StudentResult';
import { useRouter } from 'next/navigation';
export default function StudentResultPage({ params }: { params: { id: string } }) {
const router = useRouter();
return (
<StudentResult
assignmentId={params.id}
onBack={() => router.back()}
/>
);
}

View File

@@ -0,0 +1,16 @@
"use client";
import React from 'react';
import { Grading } from '@/views/Grading';
import { useRouter } from 'next/navigation';
export default function GradingPage({ params }: { params: { id: string } }) {
const router = useRouter();
return (
<Grading
assignmentId={params.id}
onBack={() => router.back()}
/>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import React from 'react';
import { Loader2 } from 'lucide-react';
import { useAuth } from '@/lib/auth-context';
export default function FullscreenLayout({ children }: { children: React.ReactNode }) {
const { loading } = useAuth();
if (loading) {
return <div className="h-screen flex items-center justify-center"><Loader2 className="animate-spin" /></div>;
}
return (
<div className="min-h-screen bg-[#F2F2F7] font-sans text-gray-900">
{children}
</div>
);
}

View File

@@ -0,0 +1,16 @@
"use client";
import React from 'react';
import { StudentExamRunner } from '@/views/StudentExamRunner';
import { useRouter } from 'next/navigation';
export default function ExamRunnerPage({ params }: { params: { id: string } }) {
const router = useRouter();
return (
<StudentExamRunner
assignmentId={params.id}
onExit={() => router.push('/assignments')}
/>
);
}

View File

@@ -0,0 +1,48 @@
import { NextRequest } from 'next/server';
import { successResponse, errorResponse, dbDelay } from '@/lib/server-utils';
export async function POST(request: NextRequest) {
await dbDelay();
try {
const body = await request.json();
const { username, password } = body;
// Simple mock validation
if (!username) {
return errorResponse('Username is required');
}
let role = 'Teacher';
let name = '李明';
let id = 'u-tea-1';
if (username === 'student' || username.startsWith('s')) {
role = 'Student';
name = '王小明';
id = 'u-stu-1';
} else if (username === 'admin') {
role = 'Admin';
name = '系统管理员';
id = 'u-adm-1';
}
const token = `mock-jwt-token-${id}-${Date.now()}`;
return successResponse({
token,
user: {
id,
realName: name,
studentId: username,
avatarUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${name}`,
gender: 'Male',
schoolId: 's-1',
role
}
});
} catch (e) {
return errorResponse('Invalid request body');
}
}

View File

@@ -0,0 +1,28 @@
import { NextRequest } from 'next/server';
import { successResponse, errorResponse, extractToken, dbDelay } from '@/lib/server-utils';
export async function GET(request: NextRequest) {
await dbDelay();
const token = extractToken(request);
if (!token) {
return errorResponse('Unauthorized', 401);
}
// In a real app, verify JWT here.
// For mock, we return a default user or parse the mock token if it contained info.
return successResponse({
id: "u-1",
realName: "李明 (Real API)",
studentId: "T2024001",
avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alex",
gender: "Male",
schoolId: "s-1",
role: "Teacher",
email: 'liming@school.edu',
phone: '13800138000',
bio: '来自真实 API 的数据'
});
}

View File

@@ -0,0 +1,27 @@
import { NextRequest } from 'next/server';
import { successResponse, errorResponse } from '@/lib/server-utils';
import { db } from '@/lib/db';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { host, port, user, password, database } = body;
if (!host || !user) {
return errorResponse('Missing required fields');
}
await db.testConnection({
host,
port: Number(port),
user,
password,
database
});
return successResponse({ message: 'Connection successful' });
} catch (e: any) {
return errorResponse(e.message || 'Connection failed', 500);
}
}

View File

@@ -0,0 +1,23 @@
import { NextRequest } from 'next/server';
import { successResponse, dbDelay } from '@/lib/server-utils';
export async function GET(request: NextRequest) {
await dbDelay();
const { searchParams } = new URL(request.url);
const role = searchParams.get('role');
let classes = [
{ id: 'c-1', name: '高一 (10) 班', gradeName: '高一年级', teacherName: '李明', studentCount: 32, inviteCode: 'X7K9P' },
{ id: 'c-2', name: '高一 (12) 班', gradeName: '高一年级', teacherName: '张伟', studentCount: 28, inviteCode: 'M2L4Q' },
{ id: 'c-3', name: 'AP 微积分先修班', gradeName: '高三年级', teacherName: '李明', studentCount: 15, inviteCode: 'Z9J1W' },
{ id: 'c-4', name: '物理奥赛集训队', gradeName: '高二年级', teacherName: '王博士', studentCount: 20, inviteCode: 'H4R8T' },
];
if (role === 'Student') {
classes = classes.slice(0, 1);
}
return successResponse(classes);
}

52
src/app/globals.css Normal file
View File

@@ -0,0 +1,52 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 242, 242, 247;
--background-end-rgb: 242, 242, 247;
}
body {
color: rgb(var(--foreground-rgb));
background: #F2F2F7;
-webkit-font-smoothing: antialiased;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #C1C1C1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #A8A8A8;
}
/* Utility to hide scrollbar but keep functionality */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.1);
border-radius: 10px;
}
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.2);
}
/* Recharts Tooltip Fix */
.recharts-tooltip-wrapper {
z-index: 100;
outline: none;
}

30
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/lib/auth-context";
import { ToastProvider } from "@/components/ui/Toast";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
export const metadata: Metadata = {
title: "EduNexus Pro",
description: "Enterprise Education Management System",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body className={inter.className}>
<ToastProvider>
<AuthProvider>
{children}
</AuthProvider>
</ToastProvider>
</body>
</html>
);
}

12
src/app/loading.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { Loader2 } from 'lucide-react';
export default function Loading() {
return (
<div className="h-screen w-full flex items-center justify-center bg-[#F2F2F7]/50 backdrop-blur-sm z-50">
<div className="bg-white/80 p-8 rounded-3xl shadow-xl flex flex-col items-center backdrop-blur-xl border border-white/50">
<Loader2 className="animate-spin text-blue-600 mb-4" size={32} />
<p className="text-sm font-bold text-gray-500">EduNexus Loading...</p>
</div>
</div>
);
}

82
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,82 @@
"use client";
import React, { useState } from 'react';
import { LoginForm } from '@/features/auth/components/LoginForm';
import { RegisterForm } from '@/features/auth/components/RegisterForm';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '@/lib/auth-context';
export default function LoginPage() {
const [mode, setMode] = useState<'login' | 'register'>('login');
const { login, register } = useAuth();
// Adapt the legacy onLoginSuccess prop to use the Context method
const handleLoginSuccess = (user: any) => {
// 登录成功后token 已存储在 localStorage
// 使用 window.location.href 强制跳转,触发 AuthContext 重新初始化并验证 token
window.location.href = '/dashboard';
};
// We need to wrap the context calls to match the signature expected by existing components
// Ideally, modify components to use useAuth() directly, but this is a migration adapter.
return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-[#F5F5F7]">
{/* Fix: cast props to any to avoid framer-motion type errors */}
<motion.div
{...({
animate: { scale: [1, 1.2, 1], rotate: [0, 90, 0] },
transition: { duration: 20, repeat: Infinity, ease: "linear" }
} as any)}
className="absolute -top-[30%] -left-[10%] w-[800px] h-[800px] rounded-full bg-blue-400/20 blur-[100px]"
/>
{/* Fix: cast props to any to avoid framer-motion type errors */}
<motion.div
{...({
animate: { scale: [1, 1.5, 1], rotate: [0, -60, 0] },
transition: { duration: 15, repeat: Infinity, ease: "linear" }
} as any)}
className="absolute top-[20%] -right-[20%] w-[600px] h-[600px] rounded-full bg-purple-400/20 blur-[100px]"
/>
<div className="w-full max-w-md z-10 mx-4 perspective-1000">
<AnimatePresence mode="wait">
{mode === 'login' ? (
// Fix: cast props to any to avoid framer-motion type errors
<motion.div
key="login"
{...({
initial: { opacity: 0, rotateY: -90 },
animate: { opacity: 1, rotateY: 0 },
exit: { opacity: 0, rotateY: 90 },
transition: { duration: 0.4 }
} as any)}
>
<LoginForm
onLoginSuccess={handleLoginSuccess}
onSwitch={() => setMode('register')}
/>
</motion.div>
) : (
// Fix: cast props to any to avoid framer-motion type errors
<motion.div
key="register"
{...({
initial: { opacity: 0, rotateY: 90 },
animate: { opacity: 1, rotateY: 0 },
exit: { opacity: 0, rotateY: -90 },
transition: { duration: 0.4 }
} as any)}
>
<RegisterForm
onLoginSuccess={handleLoginSuccess}
onSwitch={() => setMode('login')}
/>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}

23
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,23 @@
import Link from 'next/link';
import { AlertTriangle, Home } from 'lucide-react';
export default function NotFound() {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-[#F2F2F7] text-center px-4">
<div className="w-20 h-20 bg-gray-100 rounded-3xl flex items-center justify-center mb-6 shadow-sm">
<AlertTriangle size={40} className="text-gray-400" />
</div>
<h2 className="text-3xl font-bold text-gray-900 mb-2"></h2>
<p className="text-gray-500 mb-8 max-w-md">
访
</p>
<Link
href="/dashboard"
className="flex items-center gap-2 bg-blue-600 text-white px-6 py-3 rounded-xl font-bold shadow-lg shadow-blue-500/30 hover:bg-blue-700 transition-all"
>
<Home size={18} />
</Link>
</div>
);
}

27
src/app/page.tsx Normal file
View File

@@ -0,0 +1,27 @@
"use client";
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
import { Loader2 } from 'lucide-react';
export default function RootPage() {
const router = useRouter();
const { user, loading } = useAuth();
useEffect(() => {
if (!loading) {
if (user) {
router.replace('/dashboard');
} else {
router.replace('/login');
}
}
}, [user, loading, router]);
return (
<div className="h-screen flex items-center justify-center bg-[#F2F2F7]">
<Loader2 className="animate-spin text-blue-600" size={40} />
</div>
);
}