Spaces:
Sleeping
Sleeping
Upload 29 files
Browse files- App.tsx +9 -26
- components/Sidebar.tsx +8 -7
- metadata.json +1 -1
- pages/Dashboard.tsx +34 -82
- pages/Games.tsx +333 -0
- pages/Reports.tsx +173 -396
- pages/StudentDashboard.tsx +107 -0
- server.js +188 -205
- services/api.ts +19 -7
- types.ts +86 -36
App.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
import React, { useState, useEffect, Suspense } from 'react';
|
| 3 |
import { Sidebar } from './components/Sidebar';
|
| 4 |
import { Header } from './components/Header';
|
| 5 |
-
// Lazy load pages
|
| 6 |
const Dashboard = React.lazy(() => import('./pages/Dashboard').then(module => ({ default: module.Dashboard })));
|
| 7 |
const StudentList = React.lazy(() => import('./pages/StudentList').then(module => ({ default: module.StudentList })));
|
| 8 |
const CourseList = React.lazy(() => import('./pages/CourseList').then(module => ({ default: module.CourseList })));
|
|
@@ -13,13 +13,13 @@ const Reports = React.lazy(() => import('./pages/Reports').then(module => ({ def
|
|
| 13 |
const SubjectList = React.lazy(() => import('./pages/SubjectList').then(module => ({ default: module.SubjectList })));
|
| 14 |
const UserList = React.lazy(() => import('./pages/UserList').then(module => ({ default: module.UserList })));
|
| 15 |
const SchoolList = React.lazy(() => import('./pages/SchoolList').then(module => ({ default: module.SchoolList })));
|
|
|
|
| 16 |
|
| 17 |
import { Login } from './pages/Login';
|
| 18 |
import { User, UserRole } from './types';
|
| 19 |
import { api } from './services/api';
|
| 20 |
import { AlertTriangle, Loader2 } from 'lucide-react';
|
| 21 |
|
| 22 |
-
// Error Boundary Component
|
| 23 |
class ErrorBoundary extends React.Component<{children: React.ReactNode}, {hasError: boolean, error: Error | null}> {
|
| 24 |
constructor(props: any) {
|
| 25 |
super(props);
|
|
@@ -44,28 +44,15 @@ class ErrorBoundary extends React.Component<{children: React.ReactNode}, {hasErr
|
|
| 44 |
</div>
|
| 45 |
<h2 className="text-2xl font-bold text-gray-900 mb-2">哎呀,页面出错了</h2>
|
| 46 |
<p className="text-gray-500 mb-6">系统遇到了一些非预期的问题。通常这可能是网络波动或缓存问题导致的。</p>
|
| 47 |
-
|
| 48 |
<div className="bg-gray-50 p-4 rounded text-left text-xs text-red-500 overflow-auto max-h-40 mb-6 border border-red-100 font-mono">
|
| 49 |
{this.state.error?.message || 'Unknown Error'}
|
| 50 |
</div>
|
| 51 |
-
|
| 52 |
-
<button
|
| 53 |
-
onClick={() => window.location.reload()}
|
| 54 |
-
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
| 55 |
-
>
|
| 56 |
-
刷新页面重试
|
| 57 |
-
</button>
|
| 58 |
-
<button
|
| 59 |
-
onClick={() => { localStorage.clear(); window.location.reload(); }}
|
| 60 |
-
className="mt-3 w-full text-sm text-gray-400 hover:text-gray-600 underline"
|
| 61 |
-
>
|
| 62 |
-
清除缓存并重置 (解决白屏)
|
| 63 |
-
</button>
|
| 64 |
</div>
|
| 65 |
</div>
|
| 66 |
);
|
| 67 |
}
|
| 68 |
-
|
| 69 |
return this.props.children;
|
| 70 |
}
|
| 71 |
}
|
|
@@ -120,6 +107,7 @@ const AppContent: React.FC = () => {
|
|
| 120 |
case 'subjects': return <SubjectList />;
|
| 121 |
case 'users': return <UserList />;
|
| 122 |
case 'schools': return <SchoolList />;
|
|
|
|
| 123 |
default: return <Dashboard onNavigate={(view) => setCurrentView(view)} />;
|
| 124 |
}
|
| 125 |
};
|
|
@@ -134,7 +122,8 @@ const AppContent: React.FC = () => {
|
|
| 134 |
reports: '统计报表',
|
| 135 |
subjects: '学科设置',
|
| 136 |
users: '用户权限管理',
|
| 137 |
-
schools: '学校维度管理'
|
|
|
|
| 138 |
};
|
| 139 |
|
| 140 |
if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50 text-blue-600">加载中...</div>;
|
|
@@ -153,16 +142,10 @@ const AppContent: React.FC = () => {
|
|
| 153 |
/>
|
| 154 |
|
| 155 |
<div className="flex-1 flex flex-col w-full">
|
| 156 |
-
<Header
|
| 157 |
-
user={currentUser!}
|
| 158 |
-
title={viewTitles[currentView] || '智慧校园'}
|
| 159 |
-
onMenuClick={() => setSidebarOpen(true)}
|
| 160 |
-
/>
|
| 161 |
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-50 p-4 md:p-6 w-full">
|
| 162 |
<div className="max-w-7xl mx-auto w-full">
|
| 163 |
-
<Suspense fallback={<PageLoading />}>
|
| 164 |
-
{renderContent()}
|
| 165 |
-
</Suspense>
|
| 166 |
</div>
|
| 167 |
</main>
|
| 168 |
</div>
|
|
|
|
| 2 |
import React, { useState, useEffect, Suspense } from 'react';
|
| 3 |
import { Sidebar } from './components/Sidebar';
|
| 4 |
import { Header } from './components/Header';
|
| 5 |
+
// Lazy load pages
|
| 6 |
const Dashboard = React.lazy(() => import('./pages/Dashboard').then(module => ({ default: module.Dashboard })));
|
| 7 |
const StudentList = React.lazy(() => import('./pages/StudentList').then(module => ({ default: module.StudentList })));
|
| 8 |
const CourseList = React.lazy(() => import('./pages/CourseList').then(module => ({ default: module.CourseList })));
|
|
|
|
| 13 |
const SubjectList = React.lazy(() => import('./pages/SubjectList').then(module => ({ default: module.SubjectList })));
|
| 14 |
const UserList = React.lazy(() => import('./pages/UserList').then(module => ({ default: module.UserList })));
|
| 15 |
const SchoolList = React.lazy(() => import('./pages/SchoolList').then(module => ({ default: module.SchoolList })));
|
| 16 |
+
const Games = React.lazy(() => import('./pages/Games').then(module => ({ default: module.Games })));
|
| 17 |
|
| 18 |
import { Login } from './pages/Login';
|
| 19 |
import { User, UserRole } from './types';
|
| 20 |
import { api } from './services/api';
|
| 21 |
import { AlertTriangle, Loader2 } from 'lucide-react';
|
| 22 |
|
|
|
|
| 23 |
class ErrorBoundary extends React.Component<{children: React.ReactNode}, {hasError: boolean, error: Error | null}> {
|
| 24 |
constructor(props: any) {
|
| 25 |
super(props);
|
|
|
|
| 44 |
</div>
|
| 45 |
<h2 className="text-2xl font-bold text-gray-900 mb-2">哎呀,页面出错了</h2>
|
| 46 |
<p className="text-gray-500 mb-6">系统遇到了一些非预期的问题。通常这可能是网络波动或缓存问题导致的。</p>
|
|
|
|
| 47 |
<div className="bg-gray-50 p-4 rounded text-left text-xs text-red-500 overflow-auto max-h-40 mb-6 border border-red-100 font-mono">
|
| 48 |
{this.state.error?.message || 'Unknown Error'}
|
| 49 |
</div>
|
| 50 |
+
<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>
|
| 51 |
+
<button onClick={() => { localStorage.clear(); window.location.reload(); }} className="mt-3 w-full text-sm text-gray-400 hover:text-gray-600 underline">清除缓存并重置 (解决白屏)</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
</div>
|
| 53 |
</div>
|
| 54 |
);
|
| 55 |
}
|
|
|
|
| 56 |
return this.props.children;
|
| 57 |
}
|
| 58 |
}
|
|
|
|
| 107 |
case 'subjects': return <SubjectList />;
|
| 108 |
case 'users': return <UserList />;
|
| 109 |
case 'schools': return <SchoolList />;
|
| 110 |
+
case 'games': return <Games />; // New Route
|
| 111 |
default: return <Dashboard onNavigate={(view) => setCurrentView(view)} />;
|
| 112 |
}
|
| 113 |
};
|
|
|
|
| 122 |
reports: '统计报表',
|
| 123 |
subjects: '学科设置',
|
| 124 |
users: '用户权限管理',
|
| 125 |
+
schools: '学校维度管理',
|
| 126 |
+
games: '互动教学中心'
|
| 127 |
};
|
| 128 |
|
| 129 |
if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50 text-blue-600">加载中...</div>;
|
|
|
|
| 142 |
/>
|
| 143 |
|
| 144 |
<div className="flex-1 flex flex-col w-full">
|
| 145 |
+
<Header user={currentUser!} title={viewTitles[currentView] || '智慧校园'} onMenuClick={() => setSidebarOpen(true)}/>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-50 p-4 md:p-6 w-full">
|
| 147 |
<div className="max-w-7xl mx-auto w-full">
|
| 148 |
+
<Suspense fallback={<PageLoading />}>{renderContent()}</Suspense>
|
|
|
|
|
|
|
| 149 |
</div>
|
| 150 |
</main>
|
| 151 |
</div>
|
components/Sidebar.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
|
| 2 |
import React from 'react';
|
| 3 |
-
import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building } from 'lucide-react';
|
| 4 |
import { UserRole } from '../types';
|
| 5 |
|
| 6 |
interface SidebarProps {
|
|
@@ -13,16 +13,17 @@ interface SidebarProps {
|
|
| 13 |
}
|
| 14 |
|
| 15 |
export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, userRole, onLogout, isOpen, onClose }) => {
|
|
|
|
| 16 |
const menuItems = [
|
| 17 |
-
{ id: 'dashboard', label: '工作台', icon: LayoutDashboard, roles: [UserRole.ADMIN, UserRole.TEACHER] },
|
| 18 |
{ id: 'students', label: '学生管理', icon: Users, roles: [UserRole.ADMIN, UserRole.TEACHER] },
|
| 19 |
{ id: 'classes', label: '班级管理', icon: School, roles: [UserRole.ADMIN] },
|
| 20 |
{ id: 'schools', label: '学校管理', icon: Building, roles: [UserRole.ADMIN] },
|
| 21 |
-
{ id: 'courses', label: '课程管理', icon: BookOpen, roles: [UserRole.ADMIN, UserRole.TEACHER
|
| 22 |
-
{ id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.TEACHER
|
| 23 |
-
//
|
| 24 |
-
{ id: '
|
| 25 |
-
|
| 26 |
{ id: 'subjects', label: '学科设置', icon: Palette, roles: [UserRole.ADMIN, UserRole.TEACHER] },
|
| 27 |
{ id: 'users', label: '用户管理', icon: UserCog, roles: [UserRole.ADMIN] },
|
| 28 |
{ id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN] },
|
|
|
|
| 1 |
|
| 2 |
import React from 'react';
|
| 3 |
+
import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building, Gamepad2 } from 'lucide-react';
|
| 4 |
import { UserRole } from '../types';
|
| 5 |
|
| 6 |
interface SidebarProps {
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, userRole, onLogout, isOpen, onClose }) => {
|
| 16 |
+
// Define menu items with explicit roles
|
| 17 |
const menuItems = [
|
| 18 |
+
{ id: 'dashboard', label: '工作台', icon: LayoutDashboard, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
|
| 19 |
{ id: 'students', label: '学生管理', icon: Users, roles: [UserRole.ADMIN, UserRole.TEACHER] },
|
| 20 |
{ id: 'classes', label: '班级管理', icon: School, roles: [UserRole.ADMIN] },
|
| 21 |
{ id: 'schools', label: '学校管理', icon: Building, roles: [UserRole.ADMIN] },
|
| 22 |
+
{ id: 'courses', label: '课程管理', icon: BookOpen, roles: [UserRole.ADMIN, UserRole.TEACHER] },
|
| 23 |
+
{ id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.TEACHER] },
|
| 24 |
+
// New: Interactive Games
|
| 25 |
+
{ id: 'games', label: '互动教学', icon: Gamepad2, roles: [UserRole.TEACHER, UserRole.STUDENT] },
|
| 26 |
+
{ id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
|
| 27 |
{ id: 'subjects', label: '学科设置', icon: Palette, roles: [UserRole.ADMIN, UserRole.TEACHER] },
|
| 28 |
{ id: 'users', label: '用户管理', icon: UserCog, roles: [UserRole.ADMIN] },
|
| 29 |
{ id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN] },
|
metadata.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
"description": "一个综合性的学生管理系统仪表板,具有基于角色的访问控制、学生档案、课程管理和绩效分析功能。",
|
| 4 |
"requestFramePermissions": []
|
| 5 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "智慧校园管理系统",
|
| 3 |
"description": "一个综合性的学生管理系统仪表板,具有基于角色的访问控制、学生档案、课程管理和绩效分析功能。",
|
| 4 |
"requestFramePermissions": []
|
| 5 |
}
|
pages/Dashboard.tsx
CHANGED
|
@@ -1,15 +1,15 @@
|
|
| 1 |
|
| 2 |
import React, { useEffect, useState } from 'react';
|
| 3 |
-
import { Users, BookOpen, GraduationCap, TrendingUp, AlertTriangle,
|
| 4 |
import { api } from '../services/api';
|
| 5 |
-
import { Score, ClassInfo, Subject, Schedule, User
|
| 6 |
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
|
|
|
| 7 |
|
| 8 |
interface DashboardProps {
|
| 9 |
onNavigate: (view: string) => void;
|
| 10 |
}
|
| 11 |
|
| 12 |
-
// 优化后的年级排序:支持 K12 全学段中文习惯排序
|
| 13 |
export const gradeOrder: Record<string, number> = {
|
| 14 |
'一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6,
|
| 15 |
'初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9,
|
|
@@ -17,12 +17,10 @@ export const gradeOrder: Record<string, number> = {
|
|
| 17 |
};
|
| 18 |
export const sortGrades = (a: string, b: string) => (gradeOrder[a] || 99) - (gradeOrder[b] || 99);
|
| 19 |
export const sortClasses = (a: string, b: string) => {
|
| 20 |
-
// 提取年级部分
|
| 21 |
const getGrade = (s: string) => Object.keys(gradeOrder).find(g => s.startsWith(g)) || '';
|
| 22 |
const gradeA = getGrade(a);
|
| 23 |
const gradeB = getGrade(b);
|
| 24 |
if (gradeA !== gradeB) return sortGrades(gradeA, gradeB);
|
| 25 |
-
// 年级相同,比较班级号 (假设格式如 (1)班, 1班)
|
| 26 |
const getNum = (s: string) => parseInt(s.replace(/[^0-9]/g, '')) || 0;
|
| 27 |
return getNum(a) - getNum(b);
|
| 28 |
};
|
|
@@ -40,18 +38,23 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 40 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 41 |
const [teachers, setTeachers] = useState<User[]>([]);
|
| 42 |
|
| 43 |
-
|
| 44 |
-
const [viewGrade, setViewGrade] = useState(''); // 管理员年级视角
|
| 45 |
-
|
| 46 |
const [editingCell, setEditingCell] = useState<{day: number, period: number} | null>(null);
|
| 47 |
const [editForm, setEditForm] = useState({ className: '', subject: '', teacherName: '' });
|
| 48 |
|
| 49 |
-
// Chart Data
|
| 50 |
const [trendData, setTrendData] = useState<any[]>([]);
|
| 51 |
|
| 52 |
const currentUser = api.auth.getCurrentUser();
|
| 53 |
const isAdmin = currentUser?.role === 'ADMIN';
|
| 54 |
const isTeacher = currentUser?.role === 'TEACHER';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
useEffect(() => {
|
| 57 |
const loadData = async () => {
|
|
@@ -69,7 +72,6 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 69 |
setSubjects(subs);
|
| 70 |
setTeachers(userList);
|
| 71 |
|
| 72 |
-
// 如果是管理员,且没有选年级,默认选中第一个有数据的年级
|
| 73 |
if (isAdmin && classes.length > 0) {
|
| 74 |
const grades = Array.from(new Set((classes as ClassInfo[]).map((c: ClassInfo) => c.grade))).sort(sortGrades);
|
| 75 |
if (grades.length > 0) {
|
|
@@ -77,7 +79,6 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 77 |
}
|
| 78 |
}
|
| 79 |
|
| 80 |
-
// Generate Warnings (Simplified)
|
| 81 |
const newWarnings: string[] = [];
|
| 82 |
subs.forEach((sub: Subject) => {
|
| 83 |
const subScores = (scores as Score[]).filter((s: Score) => s.courseName === sub.name && s.status === 'Normal');
|
|
@@ -88,7 +89,6 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 88 |
});
|
| 89 |
setWarnings(newWarnings);
|
| 90 |
|
| 91 |
-
// Trend Data
|
| 92 |
const examGroups: Record<string, number[]> = {};
|
| 93 |
(scores as Score[]).filter((s: Score) => s.status==='Normal').forEach((s: Score) => {
|
| 94 |
const key = s.examName || s.type;
|
|
@@ -114,17 +114,14 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 114 |
const fetchSchedules = async () => {
|
| 115 |
try {
|
| 116 |
const params: any = {};
|
| 117 |
-
|
| 118 |
-
// 逻辑:如果是管理员,只看年级视图;如果是老师,看自己的
|
| 119 |
if (isAdmin) {
|
| 120 |
-
if (!viewGrade) return;
|
| 121 |
params.grade = viewGrade;
|
| 122 |
} else {
|
| 123 |
if (currentUser?.role === 'TEACHER') {
|
| 124 |
params.teacherName = currentUser.trueName || currentUser.username;
|
| 125 |
}
|
| 126 |
}
|
| 127 |
-
|
| 128 |
const data = await api.schedules.get(params);
|
| 129 |
setSchedules(data);
|
| 130 |
} catch(e) { console.error(e); }
|
|
@@ -133,37 +130,35 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 133 |
const handleOpenAddModal = (day: number, period: number) => {
|
| 134 |
setEditingCell({ day, period });
|
| 135 |
setEditForm({
|
| 136 |
-
className: '',
|
| 137 |
-
subject: isTeacher ? (currentUser?.teachingSubject || '') : '',
|
| 138 |
-
teacherName: isTeacher ? (currentUser?.trueName || currentUser?.username || '') : ''
|
| 139 |
});
|
| 140 |
};
|
| 141 |
|
| 142 |
const handleSaveSchedule = async () => {
|
| 143 |
if (!editingCell) return;
|
| 144 |
-
|
| 145 |
-
// 校验
|
| 146 |
if (!editForm.className) return alert('请选择班级');
|
| 147 |
if (!editForm.subject) return alert('请选择科目');
|
| 148 |
if (!editForm.teacherName) return alert('请选择任课教师');
|
| 149 |
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
|
|
|
| 162 |
};
|
| 163 |
|
| 164 |
const handleDeleteSchedule = async (schedule: Schedule) => {
|
| 165 |
if (!confirm(`确定删除 ${schedule.className} 的 ${schedule.subject} 课?`)) return;
|
| 166 |
-
|
| 167 |
await api.schedules.delete({
|
| 168 |
className: schedule.className,
|
| 169 |
dayOfWeek: schedule.dayOfWeek,
|
|
@@ -172,12 +167,7 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 172 |
fetchSchedules();
|
| 173 |
};
|
| 174 |
|
| 175 |
-
// 排序后的年级选项 (Admin Only)
|
| 176 |
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort(sortGrades);
|
| 177 |
-
|
| 178 |
-
// 弹窗中可选的班级列表:
|
| 179 |
-
// 1. 如果是管理员,显示当前选中年级 viewGrade 的班级 (保持上下文一致)
|
| 180 |
-
// 2. 如果是老师,显示全校所有班级 (因为老师可能跨年级授课)
|
| 181 |
const modalClassOptions = isAdmin
|
| 182 |
? classList.filter(c => c.grade === viewGrade).map(c => c.grade + c.className).sort(sortClasses)
|
| 183 |
: classList.map(c => c.grade + c.className).sort(sortClasses);
|
|
@@ -206,7 +196,6 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 206 |
</div>
|
| 207 |
</div>
|
| 208 |
|
| 209 |
-
{/* KPI Cards */}
|
| 210 |
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 211 |
{cards.map((card, index) => (
|
| 212 |
<div key={index} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex items-start justify-between hover:shadow-md transition-shadow relative overflow-hidden group">
|
|
@@ -225,9 +214,7 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 225 |
))}
|
| 226 |
</div>
|
| 227 |
|
| 228 |
-
{/* Charts & Widgets */}
|
| 229 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 230 |
-
{/* Main Chart */}
|
| 231 |
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
| 232 |
<div className="flex items-center justify-between mb-6">
|
| 233 |
<h3 className="text-lg font-bold text-gray-800">全校考试成绩走势</h3>
|
|
@@ -248,7 +235,6 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 248 |
</div>
|
| 249 |
</div>
|
| 250 |
|
| 251 |
-
{/* Warnings & Quick Start */}
|
| 252 |
<div className="space-y-6">
|
| 253 |
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
| 254 |
<div className="flex items-center justify-between mb-4">
|
|
@@ -276,7 +262,6 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 276 |
</div>
|
| 277 |
</div>
|
| 278 |
|
| 279 |
-
{/* Timetable Modal */}
|
| 280 |
{showSchedule && (
|
| 281 |
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 282 |
<div className="bg-white rounded-xl w-full max-w-6xl h-[90vh] p-6 relative animate-in fade-in flex flex-col shadow-2xl">
|
|
@@ -284,7 +269,6 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 284 |
<div className="flex flex-wrap items-center gap-4">
|
| 285 |
<h3 className="text-xl font-bold flex items-center"><Calendar className="mr-2 text-blue-600"/> 智能课程表</h3>
|
| 286 |
|
| 287 |
-
{/* Admin Controls */}
|
| 288 |
{isAdmin && (
|
| 289 |
<div className="flex bg-gray-100 rounded-lg p-1">
|
| 290 |
<select
|
|
@@ -320,18 +304,12 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 320 |
<td className="p-4 border-b border-r font-bold text-gray-400 bg-gray-50/50">第{period}节</td>
|
| 321 |
{[1,2,3,4,5].map(day => {
|
| 322 |
const slotItems = schedules.filter(s => s.dayOfWeek === day && s.period === period);
|
| 323 |
-
// Update: Admins OR Teachers can add/edit
|
| 324 |
-
// Teacher can add slots for themselves
|
| 325 |
const canAdd = isAdmin || isTeacher;
|
| 326 |
|
| 327 |
return (
|
| 328 |
-
<td
|
| 329 |
-
key={day}
|
| 330 |
-
className="p-2 border-b h-28 align-top transition-colors relative group"
|
| 331 |
-
>
|
| 332 |
<div className="flex flex-wrap gap-2 content-start h-full overflow-y-auto custom-scrollbar pb-6">
|
| 333 |
{slotItems.map(item => {
|
| 334 |
-
// Teacher can only delete their own lessons
|
| 335 |
const canDelete = isAdmin || (isTeacher && (item.teacherName === currentUser.trueName || item.teacherName === currentUser.username));
|
| 336 |
return (
|
| 337 |
<div key={item._id} className="text-xs text-left bg-white border border-blue-200 rounded-md p-2 w-full shadow-sm relative group/item hover:border-blue-400 hover:shadow-md transition-all">
|
|
@@ -348,7 +326,6 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 348 |
)}
|
| 349 |
</div>
|
| 350 |
<div className="flex justify-between items-center text-gray-600">
|
| 351 |
-
{/* 显示班级名 */}
|
| 352 |
<span className="bg-blue-50 text-blue-600 px-1.5 rounded font-mono text-[10px] font-bold">
|
| 353 |
{item.className.replace(viewGrade, '')}
|
| 354 |
</span>
|
|
@@ -357,14 +334,9 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 357 |
</div>
|
| 358 |
);
|
| 359 |
})}
|
| 360 |
-
|
| 361 |
-
{/* Add Button */}
|
| 362 |
{canAdd && (
|
| 363 |
<div className="absolute bottom-1 right-1 left-1 flex justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
| 364 |
-
<button
|
| 365 |
-
onClick={() => handleOpenAddModal(day, period)}
|
| 366 |
-
className="w-full bg-blue-50 border border-blue-200 text-blue-500 rounded py-1 hover:bg-blue-100 flex items-center justify-center shadow-sm"
|
| 367 |
-
>
|
| 368 |
<Plus size={14}/>
|
| 369 |
</button>
|
| 370 |
</div>
|
|
@@ -380,7 +352,6 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 380 |
</div>
|
| 381 |
</div>
|
| 382 |
|
| 383 |
-
{/* 排课弹窗 */}
|
| 384 |
{editingCell && (
|
| 385 |
<div className="absolute inset-0 bg-black/20 flex items-center justify-center backdrop-blur-[1px] z-50">
|
| 386 |
<div className="bg-white p-6 rounded-xl shadow-2xl w-80 animate-in zoom-in-95 border border-gray-100">
|
|
@@ -389,45 +360,27 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 389 |
<span className="text-sm font-normal text-gray-500">周{['一','二','三','四','五'][editingCell.day-1]} 第{editingCell.period}节</span>
|
| 390 |
</h4>
|
| 391 |
<div className="space-y-4">
|
| 392 |
-
{/* 班级选择: Admin 选择 viewGrade 下的班级,Teacher 可以选全校班级 */}
|
| 393 |
<div>
|
| 394 |
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">班级</label>
|
| 395 |
-
<select
|
| 396 |
-
className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
| 397 |
-
value={editForm.className}
|
| 398 |
-
onChange={e=>setEditForm({...editForm, className: e.target.value})}
|
| 399 |
-
>
|
| 400 |
<option value="">-- 请选择班级 --</option>
|
| 401 |
{modalClassOptions.map(c => <option key={c} value={c}>{c}</option>)}
|
| 402 |
</select>
|
| 403 |
</div>
|
| 404 |
-
|
| 405 |
<div>
|
| 406 |
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">科目</label>
|
| 407 |
-
<select
|
| 408 |
-
className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none disabled:bg-gray-100 disabled:text-gray-500"
|
| 409 |
-
value={editForm.subject}
|
| 410 |
-
onChange={e=>setEditForm({...editForm, subject: e.target.value})}
|
| 411 |
-
disabled={isTeacher && !!currentUser?.teachingSubject} // 老师如果有任教科目则锁定
|
| 412 |
-
>
|
| 413 |
<option value="">-- 请选择科目 --</option>
|
| 414 |
{subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
|
| 415 |
</select>
|
| 416 |
</div>
|
| 417 |
-
|
| 418 |
<div>
|
| 419 |
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">任课教师</label>
|
| 420 |
-
<select
|
| 421 |
-
className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none disabled:bg-gray-100 disabled:text-gray-500"
|
| 422 |
-
value={editForm.teacherName}
|
| 423 |
-
onChange={e=>setEditForm({...editForm, teacherName: e.target.value})}
|
| 424 |
-
disabled={isTeacher} // 老师只能给自己排课
|
| 425 |
-
>
|
| 426 |
<option value="">-- 请选择教师 --</option>
|
| 427 |
{teachers.map(t=><option key={t._id} value={t.trueName || t.username}>{t.trueName} ({t.teachingSubject || '无科目'})</option>)}
|
| 428 |
</select>
|
| 429 |
</div>
|
| 430 |
-
|
| 431 |
<div className="flex gap-3 pt-2">
|
| 432 |
<button onClick={handleSaveSchedule} className="flex-1 bg-blue-600 text-white py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">确认添加</button>
|
| 433 |
<button onClick={()=>setEditingCell(null)} className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg text-sm font-medium hover:bg-gray-200 transition-colors">取消</button>
|
|
@@ -439,7 +392,6 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 439 |
</div>
|
| 440 |
)}
|
| 441 |
|
| 442 |
-
{/* System Status Modal */}
|
| 443 |
{showStatus && (
|
| 444 |
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 445 |
<div className="bg-white rounded-xl w-full max-w-sm p-6 relative animate-in fade-in zoom-in-95">
|
|
|
|
| 1 |
|
| 2 |
import React, { useEffect, useState } from 'react';
|
| 3 |
+
import { Users, BookOpen, GraduationCap, TrendingUp, AlertTriangle, Activity, Calendar, X, CheckCircle, Plus } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
+
import { Score, ClassInfo, Subject, Schedule, User } from '../types';
|
| 6 |
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
| 7 |
+
import { StudentDashboard } from './StudentDashboard';
|
| 8 |
|
| 9 |
interface DashboardProps {
|
| 10 |
onNavigate: (view: string) => void;
|
| 11 |
}
|
| 12 |
|
|
|
|
| 13 |
export const gradeOrder: Record<string, number> = {
|
| 14 |
'一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6,
|
| 15 |
'初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9,
|
|
|
|
| 17 |
};
|
| 18 |
export const sortGrades = (a: string, b: string) => (gradeOrder[a] || 99) - (gradeOrder[b] || 99);
|
| 19 |
export const sortClasses = (a: string, b: string) => {
|
|
|
|
| 20 |
const getGrade = (s: string) => Object.keys(gradeOrder).find(g => s.startsWith(g)) || '';
|
| 21 |
const gradeA = getGrade(a);
|
| 22 |
const gradeB = getGrade(b);
|
| 23 |
if (gradeA !== gradeB) return sortGrades(gradeA, gradeB);
|
|
|
|
| 24 |
const getNum = (s: string) => parseInt(s.replace(/[^0-9]/g, '')) || 0;
|
| 25 |
return getNum(a) - getNum(b);
|
| 26 |
};
|
|
|
|
| 38 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 39 |
const [teachers, setTeachers] = useState<User[]>([]);
|
| 40 |
|
| 41 |
+
const [viewGrade, setViewGrade] = useState('');
|
|
|
|
|
|
|
| 42 |
const [editingCell, setEditingCell] = useState<{day: number, period: number} | null>(null);
|
| 43 |
const [editForm, setEditForm] = useState({ className: '', subject: '', teacherName: '' });
|
| 44 |
|
|
|
|
| 45 |
const [trendData, setTrendData] = useState<any[]>([]);
|
| 46 |
|
| 47 |
const currentUser = api.auth.getCurrentUser();
|
| 48 |
const isAdmin = currentUser?.role === 'ADMIN';
|
| 49 |
const isTeacher = currentUser?.role === 'TEACHER';
|
| 50 |
+
const isStudent = currentUser?.role === 'STUDENT';
|
| 51 |
+
|
| 52 |
+
// If Student, Render Student Dashboard
|
| 53 |
+
if (isStudent) {
|
| 54 |
+
return <StudentDashboard />;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// --- Admin/Teacher Logic Below ---
|
| 58 |
|
| 59 |
useEffect(() => {
|
| 60 |
const loadData = async () => {
|
|
|
|
| 72 |
setSubjects(subs);
|
| 73 |
setTeachers(userList);
|
| 74 |
|
|
|
|
| 75 |
if (isAdmin && classes.length > 0) {
|
| 76 |
const grades = Array.from(new Set((classes as ClassInfo[]).map((c: ClassInfo) => c.grade))).sort(sortGrades);
|
| 77 |
if (grades.length > 0) {
|
|
|
|
| 79 |
}
|
| 80 |
}
|
| 81 |
|
|
|
|
| 82 |
const newWarnings: string[] = [];
|
| 83 |
subs.forEach((sub: Subject) => {
|
| 84 |
const subScores = (scores as Score[]).filter((s: Score) => s.courseName === sub.name && s.status === 'Normal');
|
|
|
|
| 89 |
});
|
| 90 |
setWarnings(newWarnings);
|
| 91 |
|
|
|
|
| 92 |
const examGroups: Record<string, number[]> = {};
|
| 93 |
(scores as Score[]).filter((s: Score) => s.status==='Normal').forEach((s: Score) => {
|
| 94 |
const key = s.examName || s.type;
|
|
|
|
| 114 |
const fetchSchedules = async () => {
|
| 115 |
try {
|
| 116 |
const params: any = {};
|
|
|
|
|
|
|
| 117 |
if (isAdmin) {
|
| 118 |
+
if (!viewGrade) return;
|
| 119 |
params.grade = viewGrade;
|
| 120 |
} else {
|
| 121 |
if (currentUser?.role === 'TEACHER') {
|
| 122 |
params.teacherName = currentUser.trueName || currentUser.username;
|
| 123 |
}
|
| 124 |
}
|
|
|
|
| 125 |
const data = await api.schedules.get(params);
|
| 126 |
setSchedules(data);
|
| 127 |
} catch(e) { console.error(e); }
|
|
|
|
| 130 |
const handleOpenAddModal = (day: number, period: number) => {
|
| 131 |
setEditingCell({ day, period });
|
| 132 |
setEditForm({
|
| 133 |
+
className: '',
|
| 134 |
+
subject: isTeacher ? (currentUser?.teachingSubject || '') : '',
|
| 135 |
+
teacherName: isTeacher ? (currentUser?.trueName || currentUser?.username || '') : ''
|
| 136 |
});
|
| 137 |
};
|
| 138 |
|
| 139 |
const handleSaveSchedule = async () => {
|
| 140 |
if (!editingCell) return;
|
|
|
|
|
|
|
| 141 |
if (!editForm.className) return alert('请选择班级');
|
| 142 |
if (!editForm.subject) return alert('请选择科目');
|
| 143 |
if (!editForm.teacherName) return alert('请选择任课教师');
|
| 144 |
|
| 145 |
+
try {
|
| 146 |
+
await api.schedules.save({
|
| 147 |
+
className: editForm.className,
|
| 148 |
+
dayOfWeek: editingCell.day,
|
| 149 |
+
period: editingCell.period,
|
| 150 |
+
subject: editForm.subject,
|
| 151 |
+
teacherName: editForm.teacherName
|
| 152 |
+
});
|
| 153 |
+
setEditingCell(null);
|
| 154 |
+
fetchSchedules();
|
| 155 |
+
} catch (err: any) {
|
| 156 |
+
alert('排课失败:' + (err.message || '未知错误'));
|
| 157 |
+
}
|
| 158 |
};
|
| 159 |
|
| 160 |
const handleDeleteSchedule = async (schedule: Schedule) => {
|
| 161 |
if (!confirm(`确定删除 ${schedule.className} 的 ${schedule.subject} 课?`)) return;
|
|
|
|
| 162 |
await api.schedules.delete({
|
| 163 |
className: schedule.className,
|
| 164 |
dayOfWeek: schedule.dayOfWeek,
|
|
|
|
| 167 |
fetchSchedules();
|
| 168 |
};
|
| 169 |
|
|
|
|
| 170 |
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort(sortGrades);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
const modalClassOptions = isAdmin
|
| 172 |
? classList.filter(c => c.grade === viewGrade).map(c => c.grade + c.className).sort(sortClasses)
|
| 173 |
: classList.map(c => c.grade + c.className).sort(sortClasses);
|
|
|
|
| 196 |
</div>
|
| 197 |
</div>
|
| 198 |
|
|
|
|
| 199 |
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 200 |
{cards.map((card, index) => (
|
| 201 |
<div key={index} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex items-start justify-between hover:shadow-md transition-shadow relative overflow-hidden group">
|
|
|
|
| 214 |
))}
|
| 215 |
</div>
|
| 216 |
|
|
|
|
| 217 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
|
|
| 218 |
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
| 219 |
<div className="flex items-center justify-between mb-6">
|
| 220 |
<h3 className="text-lg font-bold text-gray-800">全校考试成绩走势</h3>
|
|
|
|
| 235 |
</div>
|
| 236 |
</div>
|
| 237 |
|
|
|
|
| 238 |
<div className="space-y-6">
|
| 239 |
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
| 240 |
<div className="flex items-center justify-between mb-4">
|
|
|
|
| 262 |
</div>
|
| 263 |
</div>
|
| 264 |
|
|
|
|
| 265 |
{showSchedule && (
|
| 266 |
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 267 |
<div className="bg-white rounded-xl w-full max-w-6xl h-[90vh] p-6 relative animate-in fade-in flex flex-col shadow-2xl">
|
|
|
|
| 269 |
<div className="flex flex-wrap items-center gap-4">
|
| 270 |
<h3 className="text-xl font-bold flex items-center"><Calendar className="mr-2 text-blue-600"/> 智能课程表</h3>
|
| 271 |
|
|
|
|
| 272 |
{isAdmin && (
|
| 273 |
<div className="flex bg-gray-100 rounded-lg p-1">
|
| 274 |
<select
|
|
|
|
| 304 |
<td className="p-4 border-b border-r font-bold text-gray-400 bg-gray-50/50">第{period}节</td>
|
| 305 |
{[1,2,3,4,5].map(day => {
|
| 306 |
const slotItems = schedules.filter(s => s.dayOfWeek === day && s.period === period);
|
|
|
|
|
|
|
| 307 |
const canAdd = isAdmin || isTeacher;
|
| 308 |
|
| 309 |
return (
|
| 310 |
+
<td key={day} className="p-2 border-b h-28 align-top transition-colors relative group">
|
|
|
|
|
|
|
|
|
|
| 311 |
<div className="flex flex-wrap gap-2 content-start h-full overflow-y-auto custom-scrollbar pb-6">
|
| 312 |
{slotItems.map(item => {
|
|
|
|
| 313 |
const canDelete = isAdmin || (isTeacher && (item.teacherName === currentUser.trueName || item.teacherName === currentUser.username));
|
| 314 |
return (
|
| 315 |
<div key={item._id} className="text-xs text-left bg-white border border-blue-200 rounded-md p-2 w-full shadow-sm relative group/item hover:border-blue-400 hover:shadow-md transition-all">
|
|
|
|
| 326 |
)}
|
| 327 |
</div>
|
| 328 |
<div className="flex justify-between items-center text-gray-600">
|
|
|
|
| 329 |
<span className="bg-blue-50 text-blue-600 px-1.5 rounded font-mono text-[10px] font-bold">
|
| 330 |
{item.className.replace(viewGrade, '')}
|
| 331 |
</span>
|
|
|
|
| 334 |
</div>
|
| 335 |
);
|
| 336 |
})}
|
|
|
|
|
|
|
| 337 |
{canAdd && (
|
| 338 |
<div className="absolute bottom-1 right-1 left-1 flex justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
| 339 |
+
<button onClick={() => handleOpenAddModal(day, period)} className="w-full bg-blue-50 border border-blue-200 text-blue-500 rounded py-1 hover:bg-blue-100 flex items-center justify-center shadow-sm">
|
|
|
|
|
|
|
|
|
|
| 340 |
<Plus size={14}/>
|
| 341 |
</button>
|
| 342 |
</div>
|
|
|
|
| 352 |
</div>
|
| 353 |
</div>
|
| 354 |
|
|
|
|
| 355 |
{editingCell && (
|
| 356 |
<div className="absolute inset-0 bg-black/20 flex items-center justify-center backdrop-blur-[1px] z-50">
|
| 357 |
<div className="bg-white p-6 rounded-xl shadow-2xl w-80 animate-in zoom-in-95 border border-gray-100">
|
|
|
|
| 360 |
<span className="text-sm font-normal text-gray-500">周{['一','二','三','四','五'][editingCell.day-1]} 第{editingCell.period}节</span>
|
| 361 |
</h4>
|
| 362 |
<div className="space-y-4">
|
|
|
|
| 363 |
<div>
|
| 364 |
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">班级</label>
|
| 365 |
+
<select className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none" value={editForm.className} onChange={e=>setEditForm({...editForm, className: e.target.value})}>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
<option value="">-- 请选择班级 --</option>
|
| 367 |
{modalClassOptions.map(c => <option key={c} value={c}>{c}</option>)}
|
| 368 |
</select>
|
| 369 |
</div>
|
|
|
|
| 370 |
<div>
|
| 371 |
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">科目</label>
|
| 372 |
+
<select className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none disabled:bg-gray-100 disabled:text-gray-500" value={editForm.subject} onChange={e=>setEditForm({...editForm, subject: e.target.value})} disabled={isTeacher && !!currentUser?.teachingSubject}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
<option value="">-- 请选择科目 --</option>
|
| 374 |
{subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
|
| 375 |
</select>
|
| 376 |
</div>
|
|
|
|
| 377 |
<div>
|
| 378 |
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">任课教师</label>
|
| 379 |
+
<select className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none disabled:bg-gray-100 disabled:text-gray-500" value={editForm.teacherName} onChange={e=>setEditForm({...editForm, teacherName: e.target.value})} disabled={isTeacher}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
<option value="">-- 请选择教师 --</option>
|
| 381 |
{teachers.map(t=><option key={t._id} value={t.trueName || t.username}>{t.trueName} ({t.teachingSubject || '无科目'})</option>)}
|
| 382 |
</select>
|
| 383 |
</div>
|
|
|
|
| 384 |
<div className="flex gap-3 pt-2">
|
| 385 |
<button onClick={handleSaveSchedule} className="flex-1 bg-blue-600 text-white py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">确认添加</button>
|
| 386 |
<button onClick={()=>setEditingCell(null)} className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg text-sm font-medium hover:bg-gray-200 transition-colors">取消</button>
|
|
|
|
| 392 |
</div>
|
| 393 |
)}
|
| 394 |
|
|
|
|
| 395 |
{showStatus && (
|
| 396 |
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 397 |
<div className="bg-white rounded-xl w-full max-w-sm p-6 relative animate-in fade-in zoom-in-95">
|
pages/Games.tsx
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { api } from '../services/api';
|
| 4 |
+
import { GameSession, GameTeam, Student, StudentReward, LuckyDrawConfig } from '../types';
|
| 5 |
+
import { Trophy, Gift, Lock, Settings, Plus, Minus, Users, RefreshCw, Star, ArrowRight, Loader2 } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
// --- Mountain Game Sub-Components ---
|
| 8 |
+
|
| 9 |
+
const MountainStage = ({ team, index, rewardsConfig, maxSteps }: { team: GameTeam, index: number, rewardsConfig: any[], maxSteps: number }) => {
|
| 10 |
+
const percentage = Math.min(Math.max(team.score, 0), maxSteps) / maxSteps;
|
| 11 |
+
const bottomPos = 5 + (percentage * 85);
|
| 12 |
+
|
| 13 |
+
return (
|
| 14 |
+
<div className="relative flex flex-col items-center justify-end h-[400px] w-48 mx-4 flex-shrink-0 select-none group">
|
| 15 |
+
<div className="absolute -top-12 text-center w-[140%] z-20">
|
| 16 |
+
<h3 className="text-lg font-black text-slate-800 bg-white/90 px-3 py-1 rounded-xl shadow-sm border border-white/60">
|
| 17 |
+
{team.name}
|
| 18 |
+
</h3>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
{/* Mountain SVG Background */}
|
| 22 |
+
<div className="absolute bottom-0 left-0 w-full h-full z-0 overflow-visible filter drop-shadow-md">
|
| 23 |
+
<svg viewBox="0 0 200 500" preserveAspectRatio="none" className="w-full h-full">
|
| 24 |
+
<path d="M100 20 L 190 500 L 10 500 Z" fill={index % 2 === 0 ? '#4ade80' : '#22c55e'} stroke="#15803d" strokeWidth="2" />
|
| 25 |
+
<path d="M100 20 L 125 150 L 110 130 L 100 160 L 90 130 L 75 150 Z" fill="white" opacity="0.8" />
|
| 26 |
+
</svg>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
{/* Ladder & Rewards */}
|
| 30 |
+
<div className="absolute bottom-0 w-10 h-[90%] z-10 flex flex-col-reverse justify-between items-center py-4">
|
| 31 |
+
{Array.from({ length: maxSteps + 1 }).map((_, i) => {
|
| 32 |
+
const reward = rewardsConfig.find(r => r.scoreThreshold === i);
|
| 33 |
+
const isUnlocked = team.score >= i;
|
| 34 |
+
return (
|
| 35 |
+
<div key={i} className="relative w-full h-full flex items-center justify-center">
|
| 36 |
+
<div className="w-full h-1 bg-amber-700/50 rounded-sm"></div>
|
| 37 |
+
{reward && (
|
| 38 |
+
<div className={`absolute left-full ml-2 px-2 py-1 rounded text-[10px] font-bold whitespace-nowrap border ${isUnlocked ? 'bg-yellow-100 border-yellow-300 text-yellow-700' : 'bg-gray-100 border-gray-200 text-gray-400'}`}>
|
| 39 |
+
{reward.rewardType === 'DRAW_COUNT' ? '🎲' : '🎁'} {reward.rewardName}
|
| 40 |
+
</div>
|
| 41 |
+
)}
|
| 42 |
+
</div>
|
| 43 |
+
);
|
| 44 |
+
})}
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
{/* Climber */}
|
| 48 |
+
<div className="absolute z-30 transition-all duration-700 ease-out flex flex-col items-center" style={{ bottom: `${bottomPos}%` }}>
|
| 49 |
+
<div className="w-12 h-12 bg-white rounded-full border-4 shadow-lg flex items-center justify-center transform hover:scale-110 transition-transform" style={{ borderColor: team.color }}>
|
| 50 |
+
<span className="text-2xl">{team.avatar || '🚩'}</span>
|
| 51 |
+
<div className="absolute -top-2 -right-2 bg-red-500 text-white text-[10px] font-bold w-5 h-5 rounded-full flex items-center justify-center border border-white">
|
| 52 |
+
{team.score}
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
);
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
// --- Lucky Wheel Sub-Components ---
|
| 61 |
+
|
| 62 |
+
const LuckyGrid = ({ prizes, onDraw, remaining, isSpinning }: { prizes: string[], onDraw: () => void, remaining: number, isSpinning: boolean }) => {
|
| 63 |
+
return (
|
| 64 |
+
<div className="relative w-full max-w-md mx-auto aspect-square bg-red-600 rounded-3xl p-4 shadow-2xl border-4 border-yellow-400">
|
| 65 |
+
<div className="grid grid-cols-3 gap-2 h-full">
|
| 66 |
+
{/* 8 items around, center is button */}
|
| 67 |
+
{[0, 1, 2, 7, -1, 3, 6, 5, 4].map((idx, pos) => {
|
| 68 |
+
if (idx === -1) {
|
| 69 |
+
return (
|
| 70 |
+
<button
|
| 71 |
+
key="btn"
|
| 72 |
+
onClick={onDraw}
|
| 73 |
+
disabled={isSpinning || remaining <= 0}
|
| 74 |
+
className="bg-yellow-400 hover:bg-yellow-300 active:scale-95 disabled:opacity-50 disabled:scale-100 transition-all rounded-xl flex flex-col items-center justify-center shadow-inner border-b-4 border-yellow-600"
|
| 75 |
+
>
|
| 76 |
+
<span className="text-2xl font-black text-red-700">抽奖</span>
|
| 77 |
+
<span className="text-xs font-bold text-red-800">剩 {remaining} 次</span>
|
| 78 |
+
</button>
|
| 79 |
+
);
|
| 80 |
+
}
|
| 81 |
+
return (
|
| 82 |
+
<div key={pos} className="bg-red-50 rounded-xl flex items-center justify-center text-center p-1 shadow-sm border border-red-100">
|
| 83 |
+
<span className="text-sm font-bold text-red-800 break-words w-full">{prizes[idx] || '谢谢参与'}</span>
|
| 84 |
+
</div>
|
| 85 |
+
);
|
| 86 |
+
})}
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
);
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
// --- Main Page ---
|
| 93 |
+
|
| 94 |
+
export const Games: React.FC = () => {
|
| 95 |
+
const [activeGame, setActiveGame] = useState<'mountain' | 'lucky'>('mountain');
|
| 96 |
+
const [session, setSession] = useState<GameSession | null>(null);
|
| 97 |
+
const [luckyConfig, setLuckyConfig] = useState<LuckyDrawConfig | null>(null);
|
| 98 |
+
const [myRewards, setMyRewards] = useState<StudentReward[]>([]);
|
| 99 |
+
const [studentInfo, setStudentInfo] = useState<Student | null>(null);
|
| 100 |
+
const [loading, setLoading] = useState(true);
|
| 101 |
+
|
| 102 |
+
// Teacher Controls
|
| 103 |
+
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
| 104 |
+
const [students, setStudents] = useState<Student[]>([]);
|
| 105 |
+
|
| 106 |
+
const currentUser = api.auth.getCurrentUser();
|
| 107 |
+
const isTeacher = currentUser?.role === 'TEACHER';
|
| 108 |
+
|
| 109 |
+
useEffect(() => {
|
| 110 |
+
loadData();
|
| 111 |
+
}, [activeGame]);
|
| 112 |
+
|
| 113 |
+
const loadData = async () => {
|
| 114 |
+
setLoading(true);
|
| 115 |
+
try {
|
| 116 |
+
// Load context based on user
|
| 117 |
+
let targetClass = '';
|
| 118 |
+
if (isTeacher && currentUser.homeroomClass) targetClass = currentUser.homeroomClass;
|
| 119 |
+
else if (currentUser.role === 'STUDENT') {
|
| 120 |
+
const stus = await api.students.getAll();
|
| 121 |
+
const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
|
| 122 |
+
if (me) {
|
| 123 |
+
setStudentInfo(me);
|
| 124 |
+
targetClass = me.className;
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
if (targetClass) {
|
| 129 |
+
// Load Mountain Data
|
| 130 |
+
const sess = await api.games.getMountainSession(targetClass);
|
| 131 |
+
if (sess) setSession(sess);
|
| 132 |
+
else if (isTeacher) {
|
| 133 |
+
// Init default session for teacher
|
| 134 |
+
const newSess = {
|
| 135 |
+
schoolId: currentUser.schoolId!,
|
| 136 |
+
className: targetClass,
|
| 137 |
+
subject: '综合',
|
| 138 |
+
isEnabled: true,
|
| 139 |
+
maxSteps: 10,
|
| 140 |
+
teams: [
|
| 141 |
+
{ id: '1', name: '探索队', score: 0, avatar: '🚀', color: '#ef4444', members: [] },
|
| 142 |
+
{ id: '2', name: '雄鹰队', score: 0, avatar: '🦅', color: '#3b82f6', members: [] }
|
| 143 |
+
],
|
| 144 |
+
rewardsConfig: [
|
| 145 |
+
{ scoreThreshold: 5, rewardType: 'DRAW_COUNT', rewardName: '抽奖券', rewardValue: 1 },
|
| 146 |
+
{ scoreThreshold: 10, rewardType: 'ITEM', rewardName: '神秘大奖', rewardValue: 1 }
|
| 147 |
+
]
|
| 148 |
+
};
|
| 149 |
+
setSession(newSess);
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// Load Lucky Config
|
| 154 |
+
const lCfg = await api.games.getLuckyConfig();
|
| 155 |
+
setLuckyConfig(lCfg);
|
| 156 |
+
|
| 157 |
+
// Load Rewards if Student
|
| 158 |
+
if (currentUser.role === 'STUDENT' && studentInfo) {
|
| 159 |
+
const rews = await api.rewards.getMyRewards(studentInfo._id!);
|
| 160 |
+
setMyRewards(rews);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
if (isTeacher) {
|
| 164 |
+
setStudents(await api.students.getAll());
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
} catch (e) { console.error(e); }
|
| 168 |
+
finally { setLoading(false); }
|
| 169 |
+
};
|
| 170 |
+
|
| 171 |
+
const handleScoreChange = async (teamId: string, delta: number) => {
|
| 172 |
+
if (!session || !isTeacher) return;
|
| 173 |
+
const newTeams = session.teams.map(t => {
|
| 174 |
+
if (t.id !== teamId) return t;
|
| 175 |
+
const newScore = Math.max(0, Math.min(session.maxSteps, t.score + delta));
|
| 176 |
+
|
| 177 |
+
// Check for Reward Trigger (Scaling up)
|
| 178 |
+
if (delta > 0) {
|
| 179 |
+
const reward = session.rewardsConfig.find(r => r.scoreThreshold === newScore);
|
| 180 |
+
if (reward) {
|
| 181 |
+
// Distribute Reward to all members
|
| 182 |
+
t.members.forEach(stuId => {
|
| 183 |
+
const stu = students.find(s => (s._id || s.id) == stuId);
|
| 184 |
+
if (stu) {
|
| 185 |
+
api.rewards.addReward({
|
| 186 |
+
schoolId: session.schoolId,
|
| 187 |
+
studentId: stu._id || String(stu.id),
|
| 188 |
+
studentName: stu.name,
|
| 189 |
+
rewardType: reward.rewardType as any,
|
| 190 |
+
name: reward.rewardName,
|
| 191 |
+
status: 'PENDING',
|
| 192 |
+
source: `群岳争锋 - ${t.name} ${newScore}分奖励`
|
| 193 |
+
});
|
| 194 |
+
}
|
| 195 |
+
});
|
| 196 |
+
alert(`🎉 ${t.name} 达到 ${newScore} 分!已发放 [${reward.rewardName}] 给 ${t.members.length} 名组员!`);
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
return { ...t, score: newScore };
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
const newSession = { ...session, teams: newTeams };
|
| 203 |
+
setSession(newSession);
|
| 204 |
+
await api.games.saveMountainSession(newSession);
|
| 205 |
+
};
|
| 206 |
+
|
| 207 |
+
// Lucky Draw Logic
|
| 208 |
+
const handleDraw = async () => {
|
| 209 |
+
if (!studentInfo || !luckyConfig) return;
|
| 210 |
+
if ((studentInfo.drawAttempts || 0) <= 0) return alert('抽奖次数不足!请通过“群岳争锋”游戏获取。');
|
| 211 |
+
|
| 212 |
+
// 1. Consume Attempt
|
| 213 |
+
await api.rewards.consumeDraw(studentInfo._id!);
|
| 214 |
+
setStudentInfo({ ...studentInfo, drawAttempts: (studentInfo.drawAttempts || 0) - 1 });
|
| 215 |
+
|
| 216 |
+
// 2. Random Logic
|
| 217 |
+
const rand = Math.random();
|
| 218 |
+
const prizeIndex = Math.floor(rand * luckyConfig.prizes.length);
|
| 219 |
+
const prize = luckyConfig.prizes[prizeIndex] || luckyConfig.defaultPrize;
|
| 220 |
+
|
| 221 |
+
// 3. Record Prize
|
| 222 |
+
await api.rewards.addReward({
|
| 223 |
+
schoolId: luckyConfig.schoolId,
|
| 224 |
+
studentId: studentInfo._id,
|
| 225 |
+
studentName: studentInfo.name,
|
| 226 |
+
rewardType: 'ITEM',
|
| 227 |
+
name: prize,
|
| 228 |
+
status: 'PENDING',
|
| 229 |
+
source: '幸运大抽奖'
|
| 230 |
+
});
|
| 231 |
+
|
| 232 |
+
alert(`🎁 恭喜你抽中了:${prize}!请联系老师兑换。`);
|
| 233 |
+
loadData(); // Reload rewards
|
| 234 |
+
};
|
| 235 |
+
|
| 236 |
+
if (loading) return <div className="p-10 text-center"><Loader2 className="animate-spin inline"/></div>;
|
| 237 |
+
|
| 238 |
+
if (!isTeacher && !session?.isEnabled) {
|
| 239 |
+
return <div className="p-10 text-center text-gray-400">老师尚未开启互动教学功能</div>;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
return (
|
| 243 |
+
<div className="space-y-6">
|
| 244 |
+
{/* Game Switcher */}
|
| 245 |
+
<div className="flex justify-center space-x-4 mb-6">
|
| 246 |
+
<button
|
| 247 |
+
onClick={() => setActiveGame('mountain')}
|
| 248 |
+
className={`px-6 py-3 rounded-2xl font-bold flex items-center transition-all ${activeGame === 'mountain' ? 'bg-gradient-to-r from-green-500 to-emerald-600 text-white shadow-lg scale-105' : 'bg-white text-gray-500 hover:bg-gray-50'}`}
|
| 249 |
+
>
|
| 250 |
+
<Trophy className="mr-2"/> 群岳争锋
|
| 251 |
+
</button>
|
| 252 |
+
<button
|
| 253 |
+
onClick={() => setActiveGame('lucky')}
|
| 254 |
+
className={`px-6 py-3 rounded-2xl font-bold flex items-center transition-all ${activeGame === 'lucky' ? 'bg-gradient-to-r from-red-500 to-pink-600 text-white shadow-lg scale-105' : 'bg-white text-gray-500 hover:bg-gray-50'}`}
|
| 255 |
+
>
|
| 256 |
+
<Gift className="mr-2"/> 幸运红包
|
| 257 |
+
</button>
|
| 258 |
+
</div>
|
| 259 |
+
|
| 260 |
+
{/* --- MOUNTAIN GAME --- */}
|
| 261 |
+
{activeGame === 'mountain' && session && (
|
| 262 |
+
<div className="animate-in fade-in">
|
| 263 |
+
{isTeacher && (
|
| 264 |
+
<div className="flex justify-end mb-4">
|
| 265 |
+
<button onClick={() => setIsSettingsOpen(true)} className="flex items-center text-sm text-gray-600 bg-white px-3 py-1.5 rounded-lg border hover:bg-gray-50"><Settings size={16} className="mr-1"/> 游戏设置</button>
|
| 266 |
+
</div>
|
| 267 |
+
)}
|
| 268 |
+
|
| 269 |
+
<div className="bg-gradient-to-b from-sky-200 to-white rounded-3xl p-8 overflow-x-auto min-h-[500px] border border-sky-100 shadow-inner relative">
|
| 270 |
+
<div className="flex items-end min-w-max mx-auto justify-center gap-8 pb-10">
|
| 271 |
+
{session.teams.map((team, idx) => (
|
| 272 |
+
<div key={team.id} className="relative group">
|
| 273 |
+
<MountainStage
|
| 274 |
+
team={team}
|
| 275 |
+
index={idx}
|
| 276 |
+
rewardsConfig={session.rewardsConfig}
|
| 277 |
+
maxSteps={session.maxSteps}
|
| 278 |
+
/>
|
| 279 |
+
{/* Teacher Controls */}
|
| 280 |
+
{isTeacher && (
|
| 281 |
+
<div className="absolute -bottom-12 left-1/2 -translate-x-1/2 flex items-center bg-white rounded-full shadow-lg p-1 border border-gray-100 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 282 |
+
<button onClick={() => handleScoreChange(team.id, -1)} className="p-2 hover:bg-gray-100 rounded-full text-gray-500"><Minus size={16}/></button>
|
| 283 |
+
<span className="w-8 text-center font-bold text-gray-700">{team.score}</span>
|
| 284 |
+
<button onClick={() => handleScoreChange(team.id, 1)} className="p-2 hover:bg-blue-50 rounded-full text-blue-600"><Plus size={16}/></button>
|
| 285 |
+
</div>
|
| 286 |
+
)}
|
| 287 |
+
</div>
|
| 288 |
+
))}
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
</div>
|
| 292 |
+
)}
|
| 293 |
+
|
| 294 |
+
{/* --- LUCKY DRAW GAME --- */}
|
| 295 |
+
{activeGame === 'lucky' && luckyConfig && (
|
| 296 |
+
<div className="animate-in fade-in grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 297 |
+
<div className="flex flex-col items-center justify-center">
|
| 298 |
+
<div className="bg-white p-6 rounded-2xl shadow-sm border w-full max-w-md mb-6 text-center">
|
| 299 |
+
<h3 className="text-xl font-bold text-gray-800 mb-2">我的抽奖券</h3>
|
| 300 |
+
<div className="text-4xl font-black text-amber-500 mb-2">{studentInfo?.drawAttempts || 0}</div>
|
| 301 |
+
<p className="text-xs text-gray-400">每日上限 {luckyConfig.dailyLimit} 次</p>
|
| 302 |
+
</div>
|
| 303 |
+
<LuckyGrid
|
| 304 |
+
prizes={luckyConfig.prizes}
|
| 305 |
+
onDraw={handleDraw}
|
| 306 |
+
remaining={studentInfo?.drawAttempts || 0}
|
| 307 |
+
isSpinning={false}
|
| 308 |
+
/>
|
| 309 |
+
</div>
|
| 310 |
+
|
| 311 |
+
{/* Rewards List */}
|
| 312 |
+
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
| 313 |
+
<h3 className="font-bold text-gray-800 mb-4 flex items-center"><Star className="text-yellow-400 mr-2"/> 我的战利品</h3>
|
| 314 |
+
<div className="space-y-3 max-h-[500px] overflow-y-auto">
|
| 315 |
+
{myRewards.length > 0 ? myRewards.map(r => (
|
| 316 |
+
<div key={r._id} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg border border-gray-100">
|
| 317 |
+
<div>
|
| 318 |
+
<div className="font-bold text-gray-800">{r.name}</div>
|
| 319 |
+
<div className="text-xs text-gray-400">{r.source}</div>
|
| 320 |
+
</div>
|
| 321 |
+
{r.status === 'REDEEMED'
|
| 322 |
+
? <span className="text-xs bg-gray-200 text-gray-500 px-2 py-1 rounded">已兑换</span>
|
| 323 |
+
: <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded cursor-pointer">未兑换</span>
|
| 324 |
+
}
|
| 325 |
+
</div>
|
| 326 |
+
)) : <div className="text-center text-gray-400 py-10">暂无奖品,去登山吧!</div>}
|
| 327 |
+
</div>
|
| 328 |
+
</div>
|
| 329 |
+
</div>
|
| 330 |
+
)}
|
| 331 |
+
</div>
|
| 332 |
+
);
|
| 333 |
+
};
|
pages/Reports.tsx
CHANGED
|
@@ -2,13 +2,12 @@
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import {
|
| 4 |
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
| 5 |
-
LineChart, Line, AreaChart, Area,
|
| 6 |
} from 'recharts';
|
| 7 |
import { api } from '../services/api';
|
| 8 |
-
import { Loader2, Download, Filter, TrendingUp, Grid, BarChart2, User, PieChart as PieChartIcon } from 'lucide-react';
|
| 9 |
import { Score, Student, ClassInfo, Subject, Exam } from '../types';
|
| 10 |
|
| 11 |
-
// Reuse sort logic
|
| 12 |
const localSortGrades = (a: string, b: string) => {
|
| 13 |
const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
|
| 14 |
return (order[a] || 99) - (order[b] || 99);
|
|
@@ -16,35 +15,27 @@ const localSortGrades = (a: string, b: string) => {
|
|
| 16 |
|
| 17 |
export const Reports: React.FC = () => {
|
| 18 |
const [loading, setLoading] = useState(true);
|
| 19 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
// Data
|
| 22 |
const [scores, setScores] = useState<Score[]>([]);
|
| 23 |
const [students, setStudents] = useState<Student[]>([]);
|
| 24 |
const [classes, setClasses] = useState<ClassInfo[]>([]);
|
| 25 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 26 |
const [exams, setExams] = useState<Exam[]>([]);
|
| 27 |
|
| 28 |
-
// Filters
|
| 29 |
const [selectedGrade, setSelectedGrade] = useState<string>('六年级');
|
| 30 |
-
const [selectedClass, setSelectedClass] = useState<string>('');
|
| 31 |
-
const [selectedSubject, setSelectedSubject] = useState<string>('');
|
| 32 |
|
| 33 |
-
// Computed Data
|
| 34 |
const [gradeAnalysisData, setGradeAnalysisData] = useState<any[]>([]);
|
| 35 |
const [trendData, setTrendData] = useState<any[]>([]);
|
| 36 |
const [matrixData, setMatrixData] = useState<any[]>([]);
|
|
|
|
| 37 |
|
| 38 |
-
// Overview Data
|
| 39 |
-
const [overviewData, setOverviewData] = useState<{
|
| 40 |
-
totalStudents: number;
|
| 41 |
-
avgScore: number;
|
| 42 |
-
passRate: number;
|
| 43 |
-
gradeLadder: any[];
|
| 44 |
-
subjectDist: any[];
|
| 45 |
-
}>({ totalStudents: 0, avgScore: 0, passRate: 0, gradeLadder: [], subjectDist: [] });
|
| 46 |
-
|
| 47 |
-
// Student Focus State
|
| 48 |
const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
|
| 49 |
|
| 50 |
useEffect(() => {
|
|
@@ -64,9 +55,17 @@ export const Reports: React.FC = () => {
|
|
| 64 |
setSubjects(subs);
|
| 65 |
setExams(exs);
|
| 66 |
|
| 67 |
-
// Set Defaults
|
| 68 |
if (cls.length > 0) setSelectedClass(cls[0].grade + cls[0].className);
|
| 69 |
if (subs.length > 0) setSelectedSubject(subs[0].name);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
} catch (e) {
|
| 71 |
console.error(e);
|
| 72 |
} finally {
|
|
@@ -76,164 +75,83 @@ export const Reports: React.FC = () => {
|
|
| 76 |
loadData();
|
| 77 |
}, []);
|
| 78 |
|
| 79 |
-
// Compute Metrics whenever filters or data change
|
| 80 |
useEffect(() => {
|
| 81 |
if (scores.length === 0 || students.length === 0) return;
|
| 82 |
|
| 83 |
-
// ---
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
return s.score >= (sub?.excellenceThreshold || 90);
|
| 133 |
-
}).length;
|
| 134 |
-
const excellentRate = classScores.length ? (excellent / classScores.length) * 100 : 0;
|
| 135 |
-
|
| 136 |
-
return {
|
| 137 |
-
name: cls.className, // Label X-axis with just class name to save space
|
| 138 |
-
fullName: fullClassName,
|
| 139 |
-
平均分: Number(avg.toFixed(1)),
|
| 140 |
-
及格率: Number(passRate.toFixed(1)),
|
| 141 |
-
优秀率: Number(excellentRate.toFixed(1)),
|
| 142 |
-
studentCount: classStudentIds.length
|
| 143 |
-
};
|
| 144 |
-
});
|
| 145 |
-
setGradeAnalysisData(gaData);
|
| 146 |
-
|
| 147 |
|
| 148 |
-
// ---
|
| 149 |
-
|
| 150 |
-
if (selectedClass && selectedSubject) {
|
| 151 |
const classStudentIds = students.filter(s => s.className === selectedClass).map(s => s.studentNo);
|
| 152 |
-
|
| 153 |
const uniqueExamNames = Array.from(new Set(scores.map(s => s.examName || s.type)));
|
| 154 |
-
|
| 155 |
-
// Sort exams by date
|
| 156 |
uniqueExamNames.sort((a, b) => {
|
| 157 |
const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
|
| 158 |
const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
|
| 159 |
return dateA.localeCompare(dateB);
|
| 160 |
});
|
| 161 |
-
|
| 162 |
const tData = uniqueExamNames.map(exam => {
|
| 163 |
-
const examScores = scores.filter(s =>
|
| 164 |
-
(s.examName === exam || s.type === exam) &&
|
| 165 |
-
s.courseName === selectedSubject &&
|
| 166 |
-
s.status === 'Normal'
|
| 167 |
-
);
|
| 168 |
-
|
| 169 |
-
// Class Avg
|
| 170 |
const classExamScores = examScores.filter(s => classStudentIds.includes(s.studentNo));
|
| 171 |
-
const classAvg = classExamScores.length
|
| 172 |
-
? classExamScores.reduce((a,b)=>a+b.score,0) / classExamScores.length
|
| 173 |
-
: 0;
|
| 174 |
-
|
| 175 |
-
// Grade Avg (for comparison)
|
| 176 |
const currentGrade = classes.find(c => c.grade + c.className === selectedClass)?.grade || '';
|
| 177 |
const gradeStudentIds = students.filter(s => s.className.startsWith(currentGrade)).map(s => s.studentNo);
|
| 178 |
const gradeExamScores = examScores.filter(s => gradeStudentIds.includes(s.studentNo));
|
| 179 |
-
const gradeAvg = gradeExamScores.length
|
| 180 |
-
|
| 181 |
-
: 0;
|
| 182 |
-
|
| 183 |
-
return {
|
| 184 |
-
name: exam,
|
| 185 |
-
班级平均: Number(classAvg.toFixed(1)),
|
| 186 |
-
年级平均: Number(gradeAvg.toFixed(1))
|
| 187 |
-
};
|
| 188 |
});
|
| 189 |
setTrendData(tData);
|
| 190 |
}
|
| 191 |
-
|
| 192 |
-
// --- 3. Subject Matrix (Heatmap Table) ---
|
| 193 |
-
const mData = gradeClasses.map(cls => {
|
| 194 |
-
const fullClassName = cls.grade + cls.className;
|
| 195 |
-
const classStudentIds = students.filter(s => s.className === fullClassName).map(s => s.studentNo);
|
| 196 |
-
|
| 197 |
-
const row: any = { className: cls.className, fullName: fullClassName };
|
| 198 |
-
|
| 199 |
-
subjects.forEach(sub => {
|
| 200 |
-
const subScores = scores.filter(s =>
|
| 201 |
-
s.courseName === sub.name &&
|
| 202 |
-
classStudentIds.includes(s.studentNo) &&
|
| 203 |
-
s.status === 'Normal'
|
| 204 |
-
);
|
| 205 |
-
const avg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
|
| 206 |
-
row[sub.name] = Number(avg.toFixed(1));
|
| 207 |
-
});
|
| 208 |
-
return row;
|
| 209 |
-
});
|
| 210 |
-
setMatrixData(mData);
|
| 211 |
-
|
| 212 |
}, [scores, students, classes, subjects, exams, selectedGrade, selectedClass, selectedSubject]);
|
| 213 |
|
| 214 |
-
|
| 215 |
-
const exportExcel = () => {
|
| 216 |
-
// @ts-ignore
|
| 217 |
-
if (!window.XLSX) return alert('Excel 组件未加载');
|
| 218 |
-
// @ts-ignore
|
| 219 |
-
const XLSX = window.XLSX;
|
| 220 |
-
|
| 221 |
-
// Export current active tab data
|
| 222 |
-
let dataToExport: any[] = [];
|
| 223 |
-
if (activeTab === 'grade') dataToExport = gradeAnalysisData;
|
| 224 |
-
else if (activeTab === 'trend') dataToExport = trendData;
|
| 225 |
-
else if (activeTab === 'matrix') dataToExport = matrixData;
|
| 226 |
-
else dataToExport = gradeAnalysisData; // Default
|
| 227 |
-
|
| 228 |
-
const ws = XLSX.utils.json_to_sheet(dataToExport);
|
| 229 |
-
const wb = XLSX.utils.book_new();
|
| 230 |
-
XLSX.utils.book_append_sheet(wb, ws, "Report");
|
| 231 |
-
XLSX.writeFile(wb, `Report_${activeTab}_${new Date().toISOString().slice(0,10)}.xlsx`);
|
| 232 |
-
};
|
| 233 |
-
|
| 234 |
-
// Helper for Student Focus
|
| 235 |
const getStudentTrend = (studentNo: string) => {
|
| 236 |
-
// Get all scores for this student, grouped by exam, sorted by date
|
| 237 |
const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
|
| 238 |
const uniqueExamNames = Array.from(new Set(stuScores.map(s => s.examName || s.type)));
|
| 239 |
uniqueExamNames.sort((a, b) => {
|
|
@@ -241,7 +159,6 @@ export const Reports: React.FC = () => {
|
|
| 241 |
const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
|
| 242 |
return dateA.localeCompare(dateB);
|
| 243 |
});
|
| 244 |
-
|
| 245 |
return uniqueExamNames.map(exam => {
|
| 246 |
const s = stuScores.find(s => (s.examName || s.type) === exam);
|
| 247 |
return { name: exam, score: s ? s.score : 0 };
|
|
@@ -259,35 +176,32 @@ export const Reports: React.FC = () => {
|
|
| 259 |
|
| 260 |
const getStudentAttendance = (studentNo: string) => {
|
| 261 |
const all = scores.filter(s => s.studentNo === studentNo);
|
| 262 |
-
return {
|
| 263 |
-
absent: all.filter(s => s.status === 'Absent').length,
|
| 264 |
-
leave: all.filter(s => s.status === 'Leave').length
|
| 265 |
-
};
|
| 266 |
};
|
| 267 |
|
| 268 |
const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
|
| 269 |
const allClasses = classes.map(c => c.grade + c.className);
|
| 270 |
|
| 271 |
-
// Student
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
| 273 |
|
| 274 |
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 275 |
|
| 276 |
return (
|
| 277 |
<div className="space-y-6">
|
| 278 |
-
<div className="flex
|
| 279 |
<div>
|
| 280 |
-
<h2 className="text-xl font-bold text-gray-800"
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
<div className="flex gap-2">
|
| 284 |
-
<button onClick={exportExcel} className="flex items-center space-x-2 px-4 py-2 bg-emerald-50 text-emerald-600 border border-emerald-200 rounded-lg text-sm hover:bg-emerald-100 transition-colors">
|
| 285 |
-
<Download size={16}/><span>导出报表</span>
|
| 286 |
-
</button>
|
| 287 |
</div>
|
| 288 |
</div>
|
| 289 |
|
| 290 |
-
{/* Tabs */}
|
|
|
|
| 291 |
<div className="flex space-x-1 bg-gray-100 p-1 rounded-xl w-full md:w-auto overflow-x-auto">
|
| 292 |
{[
|
| 293 |
{ id: 'overview', label: '全校概览', icon: PieChartIcon },
|
|
@@ -308,27 +222,23 @@ export const Reports: React.FC = () => {
|
|
| 308 |
</button>
|
| 309 |
))}
|
| 310 |
</div>
|
|
|
|
| 311 |
|
| 312 |
-
{/* Content Area */}
|
| 313 |
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 min-h-[500px]">
|
| 314 |
-
|
| 315 |
-
{
|
| 316 |
-
{activeTab !== 'overview' && (
|
| 317 |
<div className="flex flex-wrap gap-4 mb-8 items-center bg-gray-50 p-4 rounded-lg">
|
| 318 |
<div className="flex items-center text-sm font-bold text-gray-500"><Filter size={16} className="mr-2"/> 筛选维度:</div>
|
| 319 |
-
|
| 320 |
{(activeTab === 'grade' || activeTab === 'matrix') && (
|
| 321 |
-
<select className="border border-gray-300 p-2 rounded text-sm text-gray-800
|
| 322 |
{uniqueGrades.map(g => <option key={g} value={g}>{g}</option>)}
|
| 323 |
</select>
|
| 324 |
)}
|
| 325 |
-
|
| 326 |
{(activeTab === 'trend' || activeTab === 'student') && (
|
| 327 |
<select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedClass} onChange={e => setSelectedClass(e.target.value)}>
|
| 328 |
{allClasses.map(c => <option key={c} value={c}>{c}</option>)}
|
| 329 |
</select>
|
| 330 |
)}
|
| 331 |
-
|
| 332 |
{activeTab === 'trend' && (
|
| 333 |
<select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedSubject} onChange={e => setSelectedSubject(e.target.value)}>
|
| 334 |
{subjects.map(s => <option key={s._id} value={s.name}>{s.name}</option>)}
|
|
@@ -337,10 +247,9 @@ export const Reports: React.FC = () => {
|
|
| 337 |
</div>
|
| 338 |
)}
|
| 339 |
|
| 340 |
-
{/*
|
| 341 |
-
{activeTab === 'overview' && (
|
| 342 |
<div className="animate-in fade-in space-y-8">
|
| 343 |
-
{/* KPI Cards */}
|
| 344 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 345 |
<div className="bg-blue-50 p-6 rounded-xl border border-blue-100">
|
| 346 |
<p className="text-gray-500 text-sm font-medium mb-1">全校总人数</p>
|
|
@@ -355,195 +264,104 @@ export const Reports: React.FC = () => {
|
|
| 355 |
<h3 className="text-3xl font-bold text-emerald-700">{overviewData.passRate}%</h3>
|
| 356 |
</div>
|
| 357 |
</div>
|
| 358 |
-
|
| 359 |
-
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
| 360 |
-
{/* Grade Ladder */}
|
| 361 |
-
<div className="bg-white p-4 rounded-lg border border-gray-100 shadow-sm">
|
| 362 |
<h3 className="font-bold text-gray-800 mb-4 text-center">各年级平均分阶梯</h3>
|
| 363 |
<div className="h-72">
|
| 364 |
<ResponsiveContainer width="100%" height="100%">
|
| 365 |
<AreaChart data={overviewData.gradeLadder}>
|
| 366 |
-
<defs>
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
<stop offset="95%" stopColor="#8884d8" stopOpacity={0}/>
|
| 370 |
-
</linearGradient>
|
| 371 |
-
</defs>
|
| 372 |
-
<XAxis dataKey="name" />
|
| 373 |
-
<YAxis domain={[0, 100]}/>
|
| 374 |
-
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
| 375 |
-
<Tooltip />
|
| 376 |
<Area type="monotone" dataKey="平均分" stroke="#8884d8" fillOpacity={1} fill="url(#colorAvg)" />
|
| 377 |
</AreaChart>
|
| 378 |
</ResponsiveContainer>
|
| 379 |
</div>
|
| 380 |
</div>
|
| 381 |
-
|
| 382 |
-
{/* Subject Distribution */}
|
| 383 |
-
<div className="bg-white p-4 rounded-lg border border-gray-100 shadow-sm">
|
| 384 |
-
<h3 className="font-bold text-gray-800 mb-4 text-center">全校学科平均分雷达</h3>
|
| 385 |
-
<div className="h-72">
|
| 386 |
-
<ResponsiveContainer width="100%" height="100%">
|
| 387 |
-
<RadarChart cx="50%" cy="50%" outerRadius="80%" data={overviewData.subjectDist}>
|
| 388 |
-
<PolarGrid />
|
| 389 |
-
<PolarAngleAxis dataKey="name" />
|
| 390 |
-
<PolarRadiusAxis angle={30} domain={[0, 100]} />
|
| 391 |
-
<Radar name="平均分" dataKey="value" stroke="#82ca9d" fill="#82ca9d" fillOpacity={0.6} />
|
| 392 |
-
<Tooltip />
|
| 393 |
-
</RadarChart>
|
| 394 |
-
</ResponsiveContainer>
|
| 395 |
-
</div>
|
| 396 |
-
</div>
|
| 397 |
-
</div>
|
| 398 |
</div>
|
| 399 |
)}
|
| 400 |
|
| 401 |
-
{/*
|
| 402 |
-
{activeTab === 'grade' && (
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
<XAxis type="number" domain={[0, 100]} hide/>
|
| 415 |
-
<YAxis dataKey="name" type="category" width={80} tick={{fontSize: 12}}/>
|
| 416 |
-
<Tooltip cursor={{fill: '#f3f4f6'}} contentStyle={{borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)'}}/>
|
| 417 |
-
<Bar dataKey="平均分" fill="#3b82f6" radius={[0, 4, 4, 0]} barSize={20} label={{ position: 'right', fill: '#666', fontSize: 12 }}/>
|
| 418 |
-
</BarChart>
|
| 419 |
-
</ResponsiveContainer>
|
| 420 |
-
</div>
|
| 421 |
-
</div>
|
| 422 |
-
|
| 423 |
-
<div className="bg-white p-4 rounded-lg border border-gray-100">
|
| 424 |
-
<h3 className="font-bold text-gray-800 mb-4 flex items-center">
|
| 425 |
-
<span className="w-1 h-6 bg-emerald-500 mr-2 rounded-full"></span>
|
| 426 |
-
{selectedGrade} - 优良率 vs 及格率
|
| 427 |
-
</h3>
|
| 428 |
-
<div className="h-80">
|
| 429 |
-
<ResponsiveContainer width="100%" height="100%">
|
| 430 |
-
<BarChart data={gradeAnalysisData}>
|
| 431 |
-
<CartesianGrid strokeDasharray="3 3" vertical={false}/>
|
| 432 |
-
<XAxis dataKey="name" tick={{fontSize: 12}}/>
|
| 433 |
-
<YAxis domain={[0, 100]}/>
|
| 434 |
-
<Tooltip contentStyle={{borderRadius: '8px'}}/>
|
| 435 |
-
<Legend />
|
| 436 |
-
<Bar dataKey="及格率" fill="#10b981" radius={[4, 4, 0, 0]} />
|
| 437 |
-
<Bar dataKey="优秀率" fill="#f59e0b" radius={[4, 4, 0, 0]} />
|
| 438 |
-
</BarChart>
|
| 439 |
-
</ResponsiveContainer>
|
| 440 |
-
</div>
|
| 441 |
-
</div>
|
| 442 |
-
</div>
|
| 443 |
-
</div>
|
| 444 |
-
)}
|
| 445 |
-
|
| 446 |
-
{/* --- 2. Trend Analysis View --- */}
|
| 447 |
-
{activeTab === 'trend' && (
|
| 448 |
-
<div className="animate-in fade-in space-y-6">
|
| 449 |
-
<div className="bg-white p-4 rounded-lg border border-gray-100">
|
| 450 |
-
<h3 className="font-bold text-gray-800 mb-6 text-center">
|
| 451 |
-
{selectedClass} {selectedSubject} - 成绩成长轨迹
|
| 452 |
-
</h3>
|
| 453 |
-
<div className="h-96">
|
| 454 |
-
<ResponsiveContainer width="100%" height="100%">
|
| 455 |
-
<LineChart data={trendData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
|
| 456 |
-
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0"/>
|
| 457 |
-
<XAxis dataKey="name" axisLine={false} tickLine={false} dy={10}/>
|
| 458 |
-
<YAxis domain={[0, 100]} axisLine={false} tickLine={false}/>
|
| 459 |
-
<Tooltip contentStyle={{borderRadius: '8px', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'}}/>
|
| 460 |
-
<Legend verticalAlign="top" height={36}/>
|
| 461 |
-
<Line type="monotone" dataKey="班级平均" stroke="#3b82f6" strokeWidth={3} dot={{r: 6}} activeDot={{r: 8}} />
|
| 462 |
-
<Line type="monotone" dataKey="年级平均" stroke="#94a3b8" strokeWidth={2} strokeDasharray="5 5" dot={{r: 4}} />
|
| 463 |
-
</LineChart>
|
| 464 |
-
</ResponsiveContainer>
|
| 465 |
-
</div>
|
| 466 |
-
</div>
|
| 467 |
-
</div>
|
| 468 |
-
)}
|
| 469 |
-
|
| 470 |
-
{/* --- 3. Subject Matrix View --- */}
|
| 471 |
-
{activeTab === 'matrix' && (
|
| 472 |
-
<div className="animate-in fade-in overflow-x-auto">
|
| 473 |
-
<h3 className="font-bold text-gray-800 mb-6">
|
| 474 |
-
{selectedGrade} - 学科质量透视矩阵
|
| 475 |
-
</h3>
|
| 476 |
-
<table className="w-full text-sm text-left">
|
| 477 |
-
<thead className="bg-gray-50 text-gray-500 uppercase">
|
| 478 |
-
<tr>
|
| 479 |
-
<th className="px-6 py-4 rounded-tl-lg">班级</th>
|
| 480 |
-
{subjects.map(s => <th key={s._id} className="px-6 py-4 font-bold text-gray-700">{s.name}</th>)}
|
| 481 |
-
</tr>
|
| 482 |
-
</thead>
|
| 483 |
-
<tbody className="divide-y divide-gray-100">
|
| 484 |
-
{matrixData.map((row, idx) => (
|
| 485 |
-
<tr key={idx} className="hover:bg-gray-50 transition-colors">
|
| 486 |
-
<td className="px-6 py-4 font-bold text-gray-800 bg-gray-50/50">{row.className}</td>
|
| 487 |
-
{subjects.map(sub => {
|
| 488 |
-
const val = row[sub.name];
|
| 489 |
-
let colorClass = 'text-gray-800';
|
| 490 |
-
let bgClass = '';
|
| 491 |
-
if (val >= (sub.excellenceThreshold || 90)) { colorClass = 'text-green-700 font-bold'; bgClass = 'bg-green-50'; }
|
| 492 |
-
else if (val < 60) { colorClass = 'text-red-600 font-bold'; bgClass = 'bg-red-50'; }
|
| 493 |
-
|
| 494 |
-
return (
|
| 495 |
-
<td key={sub.name} className={`px-6 py-4 ${bgClass}`}>
|
| 496 |
-
<span className={colorClass}>{val}</span>
|
| 497 |
-
</td>
|
| 498 |
-
);
|
| 499 |
-
})}
|
| 500 |
-
</tr>
|
| 501 |
-
))}
|
| 502 |
-
</tbody>
|
| 503 |
-
</table>
|
| 504 |
-
</div>
|
| 505 |
)}
|
| 506 |
|
| 507 |
-
{/*
|
| 508 |
{activeTab === 'student' && (
|
| 509 |
<div className="animate-in fade-in">
|
| 510 |
-
<
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
</div>
|
| 542 |
)}
|
| 543 |
</div>
|
| 544 |
|
| 545 |
-
{/*
|
| 546 |
-
{selectedStudent && (
|
| 547 |
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
|
| 548 |
<div className="bg-white rounded-2xl p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto shadow-2xl">
|
| 549 |
<div className="flex justify-between items-start mb-6">
|
|
@@ -555,56 +373,15 @@ export const Reports: React.FC = () => {
|
|
| 555 |
<h2 className="text-2xl font-bold text-gray-900">{selectedStudent.name}</h2>
|
| 556 |
<div className="flex items-center space-x-3 text-sm text-gray-500 mt-1">
|
| 557 |
<span className="bg-gray-100 px-2 py-0.5 rounded text-gray-700">{selectedStudent.className}</span>
|
| 558 |
-
<span className="font-mono">{selectedStudent.studentNo}</span>
|
| 559 |
</div>
|
| 560 |
</div>
|
| 561 |
</div>
|
| 562 |
<button onClick={() => setSelectedStudent(null)} className="p-2 hover:bg-gray-100 rounded-full transition-colors"><Grid size={20}/></button>
|
| 563 |
</div>
|
| 564 |
-
|
| 565 |
-
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
| 566 |
-
<div className="bg-blue-50 p-4 rounded-xl border border-blue-100">
|
| 567 |
-
<p className="text-blue-600 text-sm font-bold mb-1">考勤状况</p>
|
| 568 |
-
<div className="flex justify-between items-end">
|
| 569 |
-
<span className="text-3xl font-bold text-blue-800">{getStudentAttendance(selectedStudent.studentNo).absent}</span>
|
| 570 |
-
<span className="text-xs text-blue-400 mb-1">次缺考</span>
|
| 571 |
-
</div>
|
| 572 |
-
</div>
|
| 573 |
-
{/* Add more stats here if needed */}
|
| 574 |
-
</div>
|
| 575 |
-
|
| 576 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 577 |
-
{
|
| 578 |
-
<div className="
|
| 579 |
-
<h3 className="font-bold text-gray-800 mb-4 text-center">学科能力模型</h3>
|
| 580 |
-
<div className="h-64">
|
| 581 |
-
<ResponsiveContainer width="100%" height="100%">
|
| 582 |
-
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={getStudentRadar(selectedStudent.studentNo)}>
|
| 583 |
-
<PolarGrid />
|
| 584 |
-
<PolarAngleAxis dataKey="subject" />
|
| 585 |
-
<PolarRadiusAxis angle={30} domain={[0, 100]} />
|
| 586 |
-
<Radar name={selectedStudent.name} dataKey="score" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6} />
|
| 587 |
-
<Tooltip />
|
| 588 |
-
</RadarChart>
|
| 589 |
-
</ResponsiveContainer>
|
| 590 |
-
</div>
|
| 591 |
-
</div>
|
| 592 |
-
|
| 593 |
-
{/* Trend */}
|
| 594 |
-
<div className="bg-white border border-gray-100 rounded-xl p-4 shadow-sm">
|
| 595 |
-
<h3 className="font-bold text-gray-800 mb-4 text-center">综合成绩走势</h3>
|
| 596 |
-
<div className="h-64">
|
| 597 |
-
<ResponsiveContainer width="100%" height="100%">
|
| 598 |
-
<LineChart data={getStudentTrend(selectedStudent.studentNo)}>
|
| 599 |
-
<CartesianGrid strokeDasharray="3 3" vertical={false}/>
|
| 600 |
-
<XAxis dataKey="name" />
|
| 601 |
-
<YAxis domain={[0, 100]}/>
|
| 602 |
-
<Tooltip />
|
| 603 |
-
<Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={3} />
|
| 604 |
-
</LineChart>
|
| 605 |
-
</ResponsiveContainer>
|
| 606 |
-
</div>
|
| 607 |
-
</div>
|
| 608 |
</div>
|
| 609 |
</div>
|
| 610 |
</div>
|
|
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import {
|
| 4 |
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
| 5 |
+
LineChart, Line, AreaChart, Area, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis
|
| 6 |
} from 'recharts';
|
| 7 |
import { api } from '../services/api';
|
| 8 |
+
import { Loader2, Download, Filter, TrendingUp, Grid, BarChart2, User, PieChart as PieChartIcon, Lock } from 'lucide-react';
|
| 9 |
import { Score, Student, ClassInfo, Subject, Exam } from '../types';
|
| 10 |
|
|
|
|
| 11 |
const localSortGrades = (a: string, b: string) => {
|
| 12 |
const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
|
| 13 |
return (order[a] || 99) - (order[b] || 99);
|
|
|
|
| 15 |
|
| 16 |
export const Reports: React.FC = () => {
|
| 17 |
const [loading, setLoading] = useState(true);
|
| 18 |
+
const currentUser = api.auth.getCurrentUser();
|
| 19 |
+
const isStudent = currentUser?.role === 'STUDENT';
|
| 20 |
+
|
| 21 |
+
// Force 'student' tab for Student Role
|
| 22 |
+
const [activeTab, setActiveTab] = useState<'overview' | 'grade' | 'trend' | 'matrix' | 'student'>(isStudent ? 'student' : 'overview');
|
| 23 |
|
|
|
|
| 24 |
const [scores, setScores] = useState<Score[]>([]);
|
| 25 |
const [students, setStudents] = useState<Student[]>([]);
|
| 26 |
const [classes, setClasses] = useState<ClassInfo[]>([]);
|
| 27 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 28 |
const [exams, setExams] = useState<Exam[]>([]);
|
| 29 |
|
|
|
|
| 30 |
const [selectedGrade, setSelectedGrade] = useState<string>('六年级');
|
| 31 |
+
const [selectedClass, setSelectedClass] = useState<string>('');
|
| 32 |
+
const [selectedSubject, setSelectedSubject] = useState<string>('');
|
| 33 |
|
|
|
|
| 34 |
const [gradeAnalysisData, setGradeAnalysisData] = useState<any[]>([]);
|
| 35 |
const [trendData, setTrendData] = useState<any[]>([]);
|
| 36 |
const [matrixData, setMatrixData] = useState<any[]>([]);
|
| 37 |
+
const [overviewData, setOverviewData] = useState<any>({});
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
|
| 40 |
|
| 41 |
useEffect(() => {
|
|
|
|
| 55 |
setSubjects(subs);
|
| 56 |
setExams(exs);
|
| 57 |
|
|
|
|
| 58 |
if (cls.length > 0) setSelectedClass(cls[0].grade + cls[0].className);
|
| 59 |
if (subs.length > 0) setSelectedSubject(subs[0].name);
|
| 60 |
+
|
| 61 |
+
// If Student, auto select self
|
| 62 |
+
if (isStudent) {
|
| 63 |
+
const me = stus.find(s => s.name === (currentUser.trueName || currentUser.username));
|
| 64 |
+
if (me) {
|
| 65 |
+
setSelectedClass(me.className);
|
| 66 |
+
setSelectedStudent(me);
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
} catch (e) {
|
| 70 |
console.error(e);
|
| 71 |
} finally {
|
|
|
|
| 75 |
loadData();
|
| 76 |
}, []);
|
| 77 |
|
|
|
|
| 78 |
useEffect(() => {
|
| 79 |
if (scores.length === 0 || students.length === 0) return;
|
| 80 |
|
| 81 |
+
// --- Overview ---
|
| 82 |
+
if (!isStudent) {
|
| 83 |
+
const normalScores = scores.filter(s => s.status === 'Normal');
|
| 84 |
+
const totalAvg = normalScores.length ? normalScores.reduce((a,b)=>a+b.score,0)/normalScores.length : 0;
|
| 85 |
+
const totalPass = normalScores.filter(s => s.score >= 60).length;
|
| 86 |
+
const passRate = normalScores.length ? (totalPass / normalScores.length)*100 : 0;
|
| 87 |
+
|
| 88 |
+
const uniqueGradesList = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
|
| 89 |
+
const ladderData = uniqueGradesList.map(g => {
|
| 90 |
+
const gradeClasses = classes.filter(c => c.grade === g).map(c => c.grade+c.className);
|
| 91 |
+
const gradeStus = students.filter(s => gradeClasses.includes(s.className)).map(s => s.studentNo);
|
| 92 |
+
const gradeScores = normalScores.filter(s => gradeStus.includes(s.studentNo));
|
| 93 |
+
const gAvg = gradeScores.length ? gradeScores.reduce((a,b)=>a+b.score,0)/gradeScores.length : 0;
|
| 94 |
+
return { name: g, 平均分: Number(gAvg.toFixed(1)) };
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
const subDist = subjects.map(sub => {
|
| 98 |
+
const subScores = normalScores.filter(s => s.courseName === sub.name);
|
| 99 |
+
const sAvg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
|
| 100 |
+
return { name: sub.name, value: Number(sAvg.toFixed(1)), color: sub.color };
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
setOverviewData({
|
| 104 |
+
totalStudents: students.length,
|
| 105 |
+
avgScore: Number(totalAvg.toFixed(1)),
|
| 106 |
+
passRate: Number(passRate.toFixed(1)),
|
| 107 |
+
gradeLadder: ladderData,
|
| 108 |
+
subjectDist: subDist
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
// --- Grade Analysis ---
|
| 112 |
+
const gradeClasses = classes.filter(c => c.grade === selectedGrade);
|
| 113 |
+
const gaData = gradeClasses.map(cls => {
|
| 114 |
+
const fullClassName = cls.grade + cls.className;
|
| 115 |
+
const classStudentIds = students.filter(s => s.className === fullClassName).map(s => s.studentNo);
|
| 116 |
+
const classScores = scores.filter(s => classStudentIds.includes(s.studentNo) && s.status === 'Normal');
|
| 117 |
+
const totalScore = classScores.reduce((sum, s) => sum + s.score, 0);
|
| 118 |
+
const avg = classScores.length ? (totalScore / classScores.length) : 0;
|
| 119 |
+
const passed = classScores.filter(s => s.score >= 60).length;
|
| 120 |
+
const passRate = classScores.length ? (passed / classScores.length) * 100 : 0;
|
| 121 |
+
const excellent = classScores.filter(s => {
|
| 122 |
+
const sub = subjects.find(sub => sub.name === s.courseName);
|
| 123 |
+
return s.score >= (sub?.excellenceThreshold || 90);
|
| 124 |
+
}).length;
|
| 125 |
+
const excellentRate = classScores.length ? (excellent / classScores.length) * 100 : 0;
|
| 126 |
+
return { name: cls.className, fullName: fullClassName, 平均分: Number(avg.toFixed(1)), 及格率: Number(passRate.toFixed(1)), 优秀率: Number(excellentRate.toFixed(1)) };
|
| 127 |
+
});
|
| 128 |
+
setGradeAnalysisData(gaData);
|
| 129 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
+
// --- Trend Analysis ---
|
| 132 |
+
if (selectedClass && selectedSubject && !isStudent) {
|
|
|
|
| 133 |
const classStudentIds = students.filter(s => s.className === selectedClass).map(s => s.studentNo);
|
|
|
|
| 134 |
const uniqueExamNames = Array.from(new Set(scores.map(s => s.examName || s.type)));
|
|
|
|
|
|
|
| 135 |
uniqueExamNames.sort((a, b) => {
|
| 136 |
const dateA = exams.find(e => e.name === a)?.date || '9999-99-99';
|
| 137 |
const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
|
| 138 |
return dateA.localeCompare(dateB);
|
| 139 |
});
|
|
|
|
| 140 |
const tData = uniqueExamNames.map(exam => {
|
| 141 |
+
const examScores = scores.filter(s => (s.examName === exam || s.type === exam) && s.courseName === selectedSubject && s.status === 'Normal');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
const classExamScores = examScores.filter(s => classStudentIds.includes(s.studentNo));
|
| 143 |
+
const classAvg = classExamScores.length ? classExamScores.reduce((a,b)=>a+b.score,0) / classExamScores.length : 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
const currentGrade = classes.find(c => c.grade + c.className === selectedClass)?.grade || '';
|
| 145 |
const gradeStudentIds = students.filter(s => s.className.startsWith(currentGrade)).map(s => s.studentNo);
|
| 146 |
const gradeExamScores = examScores.filter(s => gradeStudentIds.includes(s.studentNo));
|
| 147 |
+
const gradeAvg = gradeExamScores.length ? gradeExamScores.reduce((a,b)=>a+b.score,0) / gradeExamScores.length : 0;
|
| 148 |
+
return { name: exam, 班级平均: Number(classAvg.toFixed(1)), 年级平均: Number(gradeAvg.toFixed(1)) };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
});
|
| 150 |
setTrendData(tData);
|
| 151 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
}, [scores, students, classes, subjects, exams, selectedGrade, selectedClass, selectedSubject]);
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
const getStudentTrend = (studentNo: string) => {
|
|
|
|
| 155 |
const stuScores = scores.filter(s => s.studentNo === studentNo && s.status === 'Normal');
|
| 156 |
const uniqueExamNames = Array.from(new Set(stuScores.map(s => s.examName || s.type)));
|
| 157 |
uniqueExamNames.sort((a, b) => {
|
|
|
|
| 159 |
const dateB = exams.find(e => e.name === b)?.date || '9999-99-99';
|
| 160 |
return dateA.localeCompare(dateB);
|
| 161 |
});
|
|
|
|
| 162 |
return uniqueExamNames.map(exam => {
|
| 163 |
const s = stuScores.find(s => (s.examName || s.type) === exam);
|
| 164 |
return { name: exam, score: s ? s.score : 0 };
|
|
|
|
| 176 |
|
| 177 |
const getStudentAttendance = (studentNo: string) => {
|
| 178 |
const all = scores.filter(s => s.studentNo === studentNo);
|
| 179 |
+
return { absent: all.filter(s => s.status === 'Absent').length };
|
|
|
|
|
|
|
|
|
|
| 180 |
};
|
| 181 |
|
| 182 |
const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
|
| 183 |
const allClasses = classes.map(c => c.grade + c.className);
|
| 184 |
|
| 185 |
+
// Student Focus List
|
| 186 |
+
// If Student Role, only show SELF.
|
| 187 |
+
const focusStudents = isStudent
|
| 188 |
+
? (selectedStudent ? [selectedStudent] : [])
|
| 189 |
+
: students.filter(s => selectedClass ? s.className === selectedClass : true);
|
| 190 |
|
| 191 |
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 192 |
|
| 193 |
return (
|
| 194 |
<div className="space-y-6">
|
| 195 |
+
<div className="flex justify-between items-center">
|
| 196 |
<div>
|
| 197 |
+
<h2 className="text-xl font-bold text-gray-800">
|
| 198 |
+
{isStudent ? '我的成绩分析' : '教务数据分析中心'}
|
| 199 |
+
</h2>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
</div>
|
| 201 |
</div>
|
| 202 |
|
| 203 |
+
{/* Tabs - Hidden for Students */}
|
| 204 |
+
{!isStudent && (
|
| 205 |
<div className="flex space-x-1 bg-gray-100 p-1 rounded-xl w-full md:w-auto overflow-x-auto">
|
| 206 |
{[
|
| 207 |
{ id: 'overview', label: '全校概览', icon: PieChartIcon },
|
|
|
|
| 222 |
</button>
|
| 223 |
))}
|
| 224 |
</div>
|
| 225 |
+
)}
|
| 226 |
|
|
|
|
| 227 |
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 min-h-[500px]">
|
| 228 |
+
{/* Filters */}
|
| 229 |
+
{!isStudent && activeTab !== 'overview' && (
|
|
|
|
| 230 |
<div className="flex flex-wrap gap-4 mb-8 items-center bg-gray-50 p-4 rounded-lg">
|
| 231 |
<div className="flex items-center text-sm font-bold text-gray-500"><Filter size={16} className="mr-2"/> 筛选维度:</div>
|
|
|
|
| 232 |
{(activeTab === 'grade' || activeTab === 'matrix') && (
|
| 233 |
+
<select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedGrade} onChange={e => setSelectedGrade(e.target.value)}>
|
| 234 |
{uniqueGrades.map(g => <option key={g} value={g}>{g}</option>)}
|
| 235 |
</select>
|
| 236 |
)}
|
|
|
|
| 237 |
{(activeTab === 'trend' || activeTab === 'student') && (
|
| 238 |
<select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedClass} onChange={e => setSelectedClass(e.target.value)}>
|
| 239 |
{allClasses.map(c => <option key={c} value={c}>{c}</option>)}
|
| 240 |
</select>
|
| 241 |
)}
|
|
|
|
| 242 |
{activeTab === 'trend' && (
|
| 243 |
<select className="border border-gray-300 p-2 rounded text-sm text-gray-800" value={selectedSubject} onChange={e => setSelectedSubject(e.target.value)}>
|
| 244 |
{subjects.map(s => <option key={s._id} value={s.name}>{s.name}</option>)}
|
|
|
|
| 247 |
</div>
|
| 248 |
)}
|
| 249 |
|
| 250 |
+
{/* Overview Tab */}
|
| 251 |
+
{activeTab === 'overview' && !isStudent && (
|
| 252 |
<div className="animate-in fade-in space-y-8">
|
|
|
|
| 253 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 254 |
<div className="bg-blue-50 p-6 rounded-xl border border-blue-100">
|
| 255 |
<p className="text-gray-500 text-sm font-medium mb-1">全校总人数</p>
|
|
|
|
| 264 |
<h3 className="text-3xl font-bold text-emerald-700">{overviewData.passRate}%</h3>
|
| 265 |
</div>
|
| 266 |
</div>
|
| 267 |
+
<div className="bg-white p-4 rounded-lg border border-gray-100 shadow-sm">
|
|
|
|
|
|
|
|
|
|
| 268 |
<h3 className="font-bold text-gray-800 mb-4 text-center">各年级平均分阶梯</h3>
|
| 269 |
<div className="h-72">
|
| 270 |
<ResponsiveContainer width="100%" height="100%">
|
| 271 |
<AreaChart data={overviewData.gradeLadder}>
|
| 272 |
+
<defs><linearGradient id="colorAvg" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#8884d8" stopOpacity={0.8}/><stop offset="95%" stopColor="#8884d8" stopOpacity={0}/></linearGradient></defs>
|
| 273 |
+
<XAxis dataKey="name" /><YAxis domain={[0, 100]}/>
|
| 274 |
+
<CartesianGrid strokeDasharray="3 3" vertical={false} /><Tooltip />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
<Area type="monotone" dataKey="平均分" stroke="#8884d8" fillOpacity={1} fill="url(#colorAvg)" />
|
| 276 |
</AreaChart>
|
| 277 |
</ResponsiveContainer>
|
| 278 |
</div>
|
| 279 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
</div>
|
| 281 |
)}
|
| 282 |
|
| 283 |
+
{/* Grade Tab */}
|
| 284 |
+
{activeTab === 'grade' && !isStudent && (
|
| 285 |
+
<div className="h-80">
|
| 286 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 287 |
+
<BarChart data={gradeAnalysisData} layout="vertical">
|
| 288 |
+
<CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false}/>
|
| 289 |
+
<XAxis type="number" domain={[0, 100]} hide/>
|
| 290 |
+
<YAxis dataKey="name" type="category" width={80}/>
|
| 291 |
+
<Tooltip/>
|
| 292 |
+
<Bar dataKey="平均分" fill="#3b82f6" radius={[0, 4, 4, 0]} barSize={20}/>
|
| 293 |
+
</BarChart>
|
| 294 |
+
</ResponsiveContainer>
|
| 295 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
)}
|
| 297 |
|
| 298 |
+
{/* Student Focus / Student View */}
|
| 299 |
{activeTab === 'student' && (
|
| 300 |
<div className="animate-in fade-in">
|
| 301 |
+
{isStudent && <div className="mb-6 bg-blue-50 text-blue-700 p-4 rounded-lg flex items-center"><Lock className="mr-2" size={16}/> 您正在查看自己的成绩分析档案。</div>}
|
| 302 |
+
|
| 303 |
+
{isStudent && selectedStudent ? (
|
| 304 |
+
// Expanded Single Student View for Student Role
|
| 305 |
+
<div className="space-y-8">
|
| 306 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 307 |
+
<div className="bg-white border border-gray-100 rounded-xl p-4 shadow-sm">
|
| 308 |
+
<h3 className="font-bold text-gray-800 mb-4 text-center">我的学科能力模型</h3>
|
| 309 |
+
<div className="h-64">
|
| 310 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 311 |
+
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={getStudentRadar(selectedStudent.studentNo)}>
|
| 312 |
+
<PolarGrid />
|
| 313 |
+
<PolarAngleAxis dataKey="subject" />
|
| 314 |
+
<PolarRadiusAxis angle={30} domain={[0, 100]} />
|
| 315 |
+
<Radar name={selectedStudent.name} dataKey="score" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6} />
|
| 316 |
+
<Tooltip />
|
| 317 |
+
</RadarChart>
|
| 318 |
+
</ResponsiveContainer>
|
| 319 |
+
</div>
|
| 320 |
+
</div>
|
| 321 |
+
<div className="bg-white border border-gray-100 rounded-xl p-4 shadow-sm">
|
| 322 |
+
<h3 className="font-bold text-gray-800 mb-4 text-center">我的综合成绩走势</h3>
|
| 323 |
+
<div className="h-64">
|
| 324 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 325 |
+
<LineChart data={getStudentTrend(selectedStudent.studentNo)}>
|
| 326 |
+
<CartesianGrid strokeDasharray="3 3" vertical={false}/>
|
| 327 |
+
<XAxis dataKey="name" />
|
| 328 |
+
<YAxis domain={[0, 100]}/>
|
| 329 |
+
<Tooltip />
|
| 330 |
+
<Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={3} />
|
| 331 |
+
</LineChart>
|
| 332 |
+
</ResponsiveContainer>
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
</div>
|
| 336 |
+
</div>
|
| 337 |
+
) : (
|
| 338 |
+
// List for Admin/Teacher
|
| 339 |
+
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-4 gap-4">
|
| 340 |
+
{focusStudents.map(s => (
|
| 341 |
+
<div
|
| 342 |
+
key={s._id}
|
| 343 |
+
onClick={() => setSelectedStudent(s)}
|
| 344 |
+
className="bg-white border border-gray-200 p-4 rounded-xl cursor-pointer hover:shadow-lg hover:border-blue-400 transition-all group"
|
| 345 |
+
>
|
| 346 |
+
<div className="flex items-center space-x-3 mb-3">
|
| 347 |
+
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-white ${s.gender === 'Female' ? 'bg-pink-400' : 'bg-blue-400'}`}>
|
| 348 |
+
{s.name[0]}
|
| 349 |
+
</div>
|
| 350 |
+
<div>
|
| 351 |
+
<h4 className="font-bold text-gray-800 group-hover:text-blue-600">{s.name}</h4>
|
| 352 |
+
<p className="text-xs text-gray-500 font-mono">{s.studentNo}</p>
|
| 353 |
+
</div>
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
))}
|
| 357 |
+
</div>
|
| 358 |
+
)}
|
| 359 |
</div>
|
| 360 |
)}
|
| 361 |
</div>
|
| 362 |
|
| 363 |
+
{/* Admin/Teacher Modal for Detail */}
|
| 364 |
+
{!isStudent && selectedStudent && (
|
| 365 |
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
|
| 366 |
<div className="bg-white rounded-2xl p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto shadow-2xl">
|
| 367 |
<div className="flex justify-between items-start mb-6">
|
|
|
|
| 373 |
<h2 className="text-2xl font-bold text-gray-900">{selectedStudent.name}</h2>
|
| 374 |
<div className="flex items-center space-x-3 text-sm text-gray-500 mt-1">
|
| 375 |
<span className="bg-gray-100 px-2 py-0.5 rounded text-gray-700">{selectedStudent.className}</span>
|
|
|
|
| 376 |
</div>
|
| 377 |
</div>
|
| 378 |
</div>
|
| 379 |
<button onClick={() => setSelectedStudent(null)} className="p-2 hover:bg-gray-100 rounded-full transition-colors"><Grid size={20}/></button>
|
| 380 |
</div>
|
| 381 |
+
{/* Re-use charts logic for modal */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 383 |
+
<div className="h-64"><ResponsiveContainer><RadarChart data={getStudentRadar(selectedStudent.studentNo)}><PolarGrid/><PolarAngleAxis dataKey="subject"/><PolarRadiusAxis angle={30} domain={[0,100]}/><Radar dataKey="score" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6}/><Tooltip/></RadarChart></ResponsiveContainer></div>
|
| 384 |
+
<div className="h-64"><ResponsiveContainer><LineChart data={getStudentTrend(selectedStudent.studentNo)}><CartesianGrid vertical={false}/><XAxis dataKey="name"/><YAxis domain={[0,100]}/><Tooltip/><Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={3}/></LineChart></ResponsiveContainer></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
</div>
|
| 386 |
</div>
|
| 387 |
</div>
|
pages/StudentDashboard.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useEffect, useState } from 'react';
|
| 3 |
+
import { api } from '../services/api';
|
| 4 |
+
import { Schedule, Student } from '../types';
|
| 5 |
+
import { Calendar, CheckCircle, Clock, Coffee, FileText, MapPin } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
export const StudentDashboard: React.FC = () => {
|
| 8 |
+
const [student, setStudent] = useState<Student | null>(null);
|
| 9 |
+
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
| 10 |
+
const [checkedIn, setCheckedIn] = useState(false);
|
| 11 |
+
const [currentTime, setCurrentTime] = useState(new Date());
|
| 12 |
+
|
| 13 |
+
const currentUser = api.auth.getCurrentUser();
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
const timer = setInterval(() => setCurrentTime(new Date()), 60000);
|
| 17 |
+
loadData();
|
| 18 |
+
return () => clearInterval(timer);
|
| 19 |
+
}, []);
|
| 20 |
+
|
| 21 |
+
const loadData = async () => {
|
| 22 |
+
try {
|
| 23 |
+
const students = await api.students.getAll();
|
| 24 |
+
const me = students.find((s: Student) => s.name === (currentUser?.trueName || currentUser?.username));
|
| 25 |
+
if (me) {
|
| 26 |
+
setStudent(me);
|
| 27 |
+
const sched = await api.schedules.get({ className: me.className });
|
| 28 |
+
setSchedules(sched);
|
| 29 |
+
}
|
| 30 |
+
} catch (e) { console.error(e); }
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const handleCheckIn = () => {
|
| 34 |
+
setCheckedIn(true);
|
| 35 |
+
alert('打卡成功!已记录考勤。');
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
const today = new Date().getDay(); // 0-6
|
| 39 |
+
const weekDays = ['周日','周一','周二','周三','周四','周五','周六'];
|
| 40 |
+
const todaySchedules = schedules.filter(s => s.dayOfWeek === today).sort((a,b) => a.period - b.period);
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<div className="space-y-6">
|
| 44 |
+
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 rounded-2xl p-8 text-white shadow-lg">
|
| 45 |
+
<h1 className="text-3xl font-bold mb-2">你好, {currentUser?.trueName || currentUser?.username} 👋</h1>
|
| 46 |
+
<p className="opacity-90">今天是 {currentTime.toLocaleDateString()} {weekDays[today]} | {student?.className || '加载中...'}</p>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 50 |
+
{/* Attendance Card */}
|
| 51 |
+
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex flex-col items-center justify-center text-center">
|
| 52 |
+
<div className="mb-4 bg-blue-50 p-4 rounded-full">
|
| 53 |
+
<MapPin size={32} className="text-blue-600" />
|
| 54 |
+
</div>
|
| 55 |
+
<h3 className="text-lg font-bold text-gray-800 mb-2">每日考勤</h3>
|
| 56 |
+
<p className="text-sm text-gray-500 mb-6">记录你的在校状态</p>
|
| 57 |
+
<button
|
| 58 |
+
onClick={handleCheckIn}
|
| 59 |
+
disabled={checkedIn}
|
| 60 |
+
className={`w-full py-3 rounded-xl font-bold transition-all ${checkedIn ? 'bg-green-100 text-green-700 cursor-default' : 'bg-blue-600 text-white hover:bg-blue-700 shadow-md'}`}
|
| 61 |
+
>
|
| 62 |
+
{checkedIn ? '已签到' : '立即打卡'}
|
| 63 |
+
</button>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
{/* Today's Schedule */}
|
| 67 |
+
<div className="md:col-span-2 bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 68 |
+
<div className="flex justify-between items-center mb-6">
|
| 69 |
+
<h3 className="font-bold text-gray-800 flex items-center"><Calendar className="mr-2 text-indigo-600"/> 今日课表</h3>
|
| 70 |
+
<span className="text-xs bg-indigo-50 text-indigo-600 px-2 py-1 rounded-full">{todaySchedules.length} 节课</span>
|
| 71 |
+
</div>
|
| 72 |
+
<div className="space-y-3">
|
| 73 |
+
{todaySchedules.length > 0 ? todaySchedules.map(s => (
|
| 74 |
+
<div key={s._id} className="flex items-center p-3 bg-gray-50 rounded-lg border border-gray-100">
|
| 75 |
+
<div className="w-12 text-center font-bold text-gray-400 text-sm">第{s.period}节</div>
|
| 76 |
+
<div className="w-px h-8 bg-gray-200 mx-4"></div>
|
| 77 |
+
<div className="flex-1">
|
| 78 |
+
<div className="font-bold text-gray-800">{s.subject}</div>
|
| 79 |
+
<div className="text-xs text-gray-500">{s.teacherName}</div>
|
| 80 |
+
</div>
|
| 81 |
+
{/* Highlight if current time matches period (Mock logic) */}
|
| 82 |
+
<Clock size={16} className="text-gray-300"/>
|
| 83 |
+
</div>
|
| 84 |
+
)) : (
|
| 85 |
+
<div className="text-center py-10 text-gray-400">
|
| 86 |
+
<Coffee size={32} className="mx-auto mb-2 opacity-50"/>
|
| 87 |
+
<p>今天没有课程安排,好好休息!</p>
|
| 88 |
+
</div>
|
| 89 |
+
)}
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
{/* Quick Actions */}
|
| 95 |
+
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 96 |
+
<h3 className="font-bold text-gray-800 mb-4">常用功能</h3>
|
| 97 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
| 98 |
+
<button className="p-4 bg-orange-50 rounded-xl text-orange-700 flex flex-col items-center hover:bg-orange-100 transition-colors">
|
| 99 |
+
<FileText className="mb-2"/>
|
| 100 |
+
<span className="text-sm font-medium">请假申请</span>
|
| 101 |
+
</button>
|
| 102 |
+
{/* Add more student actions here */}
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
);
|
| 107 |
+
};
|
server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
const express = require('express');
|
| 2 |
const mongoose = require('mongoose');
|
| 3 |
const cors = require('cors');
|
|
@@ -13,7 +14,7 @@ const MONGO_URI = 'mongodb+srv://dv890a:db8822723@chatpro.gw3v0v7.mongodb.net/ch
|
|
| 13 |
const app = express();
|
| 14 |
|
| 15 |
app.use(cors());
|
| 16 |
-
app.use(bodyParser.json({ limit: '10mb' }));
|
| 17 |
app.use(express.static(path.join(__dirname, 'dist')));
|
| 18 |
|
| 19 |
// ==========================================
|
|
@@ -31,6 +32,9 @@ const InMemoryDB = {
|
|
| 31 |
exams: [],
|
| 32 |
schedules: [],
|
| 33 |
notifications: [],
|
|
|
|
|
|
|
|
|
|
| 34 |
config: {},
|
| 35 |
isFallback: false
|
| 36 |
};
|
|
@@ -88,20 +92,20 @@ const StudentSchema = new mongoose.Schema({
|
|
| 88 |
phone: String,
|
| 89 |
className: String,
|
| 90 |
status: { type: String, default: 'Enrolled' },
|
| 91 |
-
// Extended Fields
|
| 92 |
parentName: String,
|
| 93 |
parentPhone: String,
|
| 94 |
-
address: String
|
|
|
|
|
|
|
| 95 |
});
|
| 96 |
-
// Composite index for uniqueness within school
|
| 97 |
StudentSchema.index({ schoolId: 1, studentNo: 1 }, { unique: true });
|
| 98 |
const Student = mongoose.model('Student', StudentSchema);
|
| 99 |
|
| 100 |
const CourseSchema = new mongoose.Schema({
|
| 101 |
schoolId: String,
|
| 102 |
courseCode: String,
|
| 103 |
-
courseName: String,
|
| 104 |
-
teacherName: String,
|
| 105 |
credits: Number,
|
| 106 |
capacity: Number,
|
| 107 |
enrolled: { type: Number, default: 0 }
|
|
@@ -151,19 +155,17 @@ const ScheduleSchema = new mongoose.Schema({
|
|
| 151 |
className: String,
|
| 152 |
teacherName: String,
|
| 153 |
subject: String,
|
| 154 |
-
dayOfWeek: Number,
|
| 155 |
-
period: Number
|
| 156 |
});
|
| 157 |
-
// Ensure unique schedule slot per class
|
| 158 |
ScheduleSchema.index({ schoolId: 1, className: 1, dayOfWeek: 1, period: 1 }, { unique: true });
|
| 159 |
const ScheduleModel = mongoose.model('Schedule', ScheduleSchema);
|
| 160 |
|
| 161 |
const ConfigSchema = new mongoose.Schema({
|
| 162 |
-
// Global config, no schoolId
|
| 163 |
key: { type: String, default: 'main', unique: true },
|
| 164 |
systemName: String,
|
| 165 |
semester: String,
|
| 166 |
-
semesters: [String],
|
| 167 |
allowRegister: Boolean,
|
| 168 |
allowAdminRegister: { type: Boolean, default: false },
|
| 169 |
maintenanceMode: Boolean,
|
|
@@ -173,17 +175,61 @@ const ConfigModel = mongoose.model('Config', ConfigSchema);
|
|
| 173 |
|
| 174 |
const NotificationSchema = new mongoose.Schema({
|
| 175 |
schoolId: String,
|
| 176 |
-
targetRole: String,
|
| 177 |
-
targetUserId: String,
|
| 178 |
title: String,
|
| 179 |
content: String,
|
| 180 |
-
type: { type: String, default: 'info' },
|
| 181 |
createTime: { type: Date, default: Date.now },
|
| 182 |
-
expiresAt: { type: Date, default: () => new Date(+new Date() + 30*24*60*60*1000) }
|
| 183 |
});
|
| 184 |
-
NotificationSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
| 185 |
const NotificationModel = mongoose.model('Notification', NotificationSchema);
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
// Helper: Create Notification
|
| 188 |
const notify = async (schoolId, title, content, targetRole = null, targetUserId = null) => {
|
| 189 |
try {
|
|
@@ -202,93 +248,56 @@ const notify = async (schoolId, title, content, targetRole = null, targetUserId
|
|
| 202 |
// Helper: Sync Teacher to Course
|
| 203 |
const syncTeacherToCourse = async (user) => {
|
| 204 |
if (!user.teachingSubject || !user.schoolId || user.role !== 'TEACHER') return;
|
| 205 |
-
|
| 206 |
try {
|
| 207 |
const teacherName = user.trueName || user.username;
|
| 208 |
-
// Check if course already exists for this teacher and subject
|
| 209 |
const exists = await Course.findOne({
|
| 210 |
schoolId: user.schoolId,
|
| 211 |
courseName: user.teachingSubject,
|
| 212 |
teacherName: teacherName
|
| 213 |
});
|
| 214 |
-
|
| 215 |
if (!exists) {
|
| 216 |
-
console.log(`🔄 Syncing course for ${teacherName}: ${user.teachingSubject}`);
|
| 217 |
await Course.create({
|
| 218 |
schoolId: user.schoolId,
|
| 219 |
courseName: user.teachingSubject,
|
| 220 |
teacherName: teacherName,
|
| 221 |
-
credits: 4,
|
| 222 |
capacity: 45
|
| 223 |
});
|
| 224 |
}
|
| 225 |
-
} catch (e) {
|
| 226 |
-
console.error('Auto-sync Course Error:', e);
|
| 227 |
-
}
|
| 228 |
};
|
| 229 |
|
| 230 |
-
// Init Data
|
| 231 |
const initData = async () => {
|
| 232 |
if (InMemoryDB.isFallback) return;
|
| 233 |
try {
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
// Drop legacy indexes
|
| 237 |
-
try {
|
| 238 |
-
await mongoose.connection.collection('subjects').dropIndex('name_1');
|
| 239 |
-
} catch (e) {}
|
| 240 |
-
|
| 241 |
-
// 1. Default School
|
| 242 |
let defaultSchool = await School.findOne({ code: 'EXP01' });
|
| 243 |
-
if (!defaultSchool) {
|
| 244 |
-
|
| 245 |
-
console.log('✅ Initialized Default School');
|
| 246 |
-
}
|
| 247 |
-
|
| 248 |
-
// 2. Admin
|
| 249 |
const adminExists = await User.findOne({ username: 'admin' });
|
| 250 |
if (!adminExists) {
|
| 251 |
await User.create({
|
| 252 |
-
username: 'admin',
|
| 253 |
-
|
| 254 |
-
role: 'ADMIN',
|
| 255 |
-
status: 'active',
|
| 256 |
-
schoolId: defaultSchool._id.toString(),
|
| 257 |
-
trueName: '超级管理员',
|
| 258 |
-
email: 'admin@system.com'
|
| 259 |
});
|
| 260 |
-
console.log('✅ Initialized Admin User');
|
| 261 |
}
|
| 262 |
-
|
| 263 |
-
// 3. Global Config
|
| 264 |
const configExists = await ConfigModel.findOne({ key: 'main' });
|
| 265 |
if (!configExists) {
|
| 266 |
await ConfigModel.create({
|
| 267 |
-
key: 'main',
|
| 268 |
-
|
| 269 |
-
semester: '2023-2024学年 第一学期',
|
| 270 |
-
semesters: ['2023-2024学年 第一学期', '2023-2024学年 第二学期'],
|
| 271 |
-
allowRegister: true,
|
| 272 |
-
allowAdminRegister: false
|
| 273 |
});
|
| 274 |
-
console.log('✅ Initialized Global Config');
|
| 275 |
}
|
| 276 |
-
|
| 277 |
-
} catch (err) {
|
| 278 |
-
console.error('❌ Init Data Error', err);
|
| 279 |
-
}
|
| 280 |
};
|
| 281 |
mongoose.connection.once('open', initData);
|
| 282 |
|
| 283 |
-
//
|
| 284 |
-
// Helper: Get School Context
|
| 285 |
-
// ==========================================
|
| 286 |
const getQueryFilter = (req) => {
|
| 287 |
const schoolId = req.headers['x-school-id'];
|
| 288 |
if (!schoolId) return {};
|
| 289 |
return { schoolId };
|
| 290 |
};
|
| 291 |
-
|
| 292 |
const injectSchoolId = (req, body) => {
|
| 293 |
const schoolId = req.headers['x-school-id'];
|
| 294 |
return { ...body, schoolId };
|
|
@@ -301,79 +310,45 @@ const injectSchoolId = (req, body) => {
|
|
| 301 |
// --- Notifications ---
|
| 302 |
app.get('/api/notifications', async (req, res) => {
|
| 303 |
const schoolId = req.headers['x-school-id'];
|
| 304 |
-
const { role, userId } = req.query;
|
| 305 |
-
|
| 306 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.notifications);
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
schoolId: schoolId,
|
| 310 |
-
$or: [
|
| 311 |
-
{ targetRole: null, targetUserId: null }, // Global school msg
|
| 312 |
-
{ targetRole: role }, // Role specific
|
| 313 |
-
{ targetUserId: userId } // User specific
|
| 314 |
-
]
|
| 315 |
-
};
|
| 316 |
-
|
| 317 |
-
const list = await NotificationModel.find(query).sort({ createTime: -1 }).limit(20);
|
| 318 |
-
res.json(list);
|
| 319 |
});
|
| 320 |
|
| 321 |
// --- Public Routes ---
|
| 322 |
app.get('/api/public/schools', async (req, res) => {
|
| 323 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.schools);
|
| 324 |
-
|
| 325 |
-
res.json(schools);
|
| 326 |
});
|
| 327 |
-
|
| 328 |
app.get('/api/public/config', async (req, res) => {
|
| 329 |
if (InMemoryDB.isFallback) return res.json({ allowRegister: true, allowAdminRegister: true });
|
| 330 |
-
|
| 331 |
-
const config = await ConfigModel.findOne({ key: 'main' }) || { allowRegister: true, allowAdminRegister: false };
|
| 332 |
-
res.json(config);
|
| 333 |
});
|
| 334 |
-
|
| 335 |
app.get('/api/public/meta', async (req, res) => {
|
| 336 |
const { schoolId } = req.query;
|
| 337 |
if (!schoolId) return res.json({ classes: [], subjects: [] });
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
return res.json({
|
| 341 |
-
classes: InMemoryDB.classes.filter(c => c.schoolId === schoolId),
|
| 342 |
-
subjects: InMemoryDB.subjects.filter(s => s.schoolId === schoolId)
|
| 343 |
-
});
|
| 344 |
-
}
|
| 345 |
-
|
| 346 |
-
const classes = await ClassModel.find({ schoolId });
|
| 347 |
-
const subjects = await SubjectModel.find({ schoolId });
|
| 348 |
-
res.json({ classes, subjects });
|
| 349 |
});
|
| 350 |
|
| 351 |
// --- Auth ---
|
| 352 |
app.post('/api/auth/login', async (req, res) => {
|
| 353 |
const { username, password } = req.body;
|
| 354 |
try {
|
| 355 |
-
let user
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
} else {
|
| 359 |
-
user = await User.findOne({ username, password });
|
| 360 |
-
}
|
| 361 |
-
|
| 362 |
if (!user) return res.status(401).json({ message: '用户名或密码错误' });
|
| 363 |
if (user.status === 'pending') return res.status(403).json({ error: 'PENDING_APPROVAL', message: '账号待审核' });
|
| 364 |
if (user.status === 'banned') return res.status(403).json({ error: 'BANNED', message: '账号已被停用' });
|
| 365 |
-
|
| 366 |
res.json(user);
|
| 367 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 368 |
});
|
| 369 |
|
| 370 |
app.post('/api/auth/register', async (req, res) => {
|
| 371 |
-
const {
|
| 372 |
-
username, password, role, schoolId, trueName, phone, email, avatar,
|
| 373 |
-
teachingSubject, homeroomClass
|
| 374 |
-
} = req.body;
|
| 375 |
const status = 'pending';
|
| 376 |
-
|
| 377 |
try {
|
| 378 |
if (InMemoryDB.isFallback) {
|
| 379 |
if (InMemoryDB.users.find(u => u.username === username)) return res.status(400).json({ error: 'Existed' });
|
|
@@ -381,23 +356,11 @@ app.post('/api/auth/register', async (req, res) => {
|
|
| 381 |
InMemoryDB.users.push(newUser);
|
| 382 |
return res.json(newUser);
|
| 383 |
}
|
| 384 |
-
|
| 385 |
const existing = await User.findOne({ username });
|
| 386 |
if (existing) return res.status(400).json({ error: 'Existed' });
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
username, password, role, status, schoolId, trueName, phone, email, avatar,
|
| 390 |
-
teachingSubject, homeroomClass
|
| 391 |
-
});
|
| 392 |
-
|
| 393 |
-
// Auto Sync Course if user is a teacher and has subject
|
| 394 |
-
if (role === 'TEACHER' && teachingSubject) {
|
| 395 |
-
await syncTeacherToCourse(newUser);
|
| 396 |
-
}
|
| 397 |
-
|
| 398 |
-
// Notify Admins
|
| 399 |
await notify(schoolId, '新用户注册申请', `${trueName || username} 申请注册为 ${role === 'TEACHER' ? '教师' : '管理员'}`, 'ADMIN');
|
| 400 |
-
|
| 401 |
res.json(newUser);
|
| 402 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 403 |
});
|
|
@@ -408,11 +371,7 @@ app.get('/api/schools', async (req, res) => {
|
|
| 408 |
res.json(await School.find());
|
| 409 |
});
|
| 410 |
app.post('/api/schools', async (req, res) => {
|
| 411 |
-
if (InMemoryDB.isFallback) {
|
| 412 |
-
const newSchool = { ...req.body, _id: String(Date.now()) };
|
| 413 |
-
InMemoryDB.schools.push(newSchool);
|
| 414 |
-
return res.json(newSchool);
|
| 415 |
-
}
|
| 416 |
res.json(await School.create(req.body));
|
| 417 |
});
|
| 418 |
app.put('/api/schools/:id', async (req, res) => {
|
|
@@ -423,58 +382,39 @@ app.put('/api/schools/:id', async (req, res) => {
|
|
| 423 |
|
| 424 |
// --- Users ---
|
| 425 |
app.get('/api/users', async (req, res) => {
|
| 426 |
-
const { global, role } = req.query;
|
| 427 |
let filter = global === 'true' ? {} : getQueryFilter(req);
|
| 428 |
-
|
| 429 |
if (role) filter.role = role;
|
| 430 |
-
|
| 431 |
if (InMemoryDB.isFallback) {
|
| 432 |
if (global === 'true') return res.json(InMemoryDB.users);
|
| 433 |
return res.json(InMemoryDB.users.filter(u => (!filter.schoolId || u.schoolId === filter.schoolId) && (!role || u.role === role)));
|
| 434 |
}
|
| 435 |
-
|
| 436 |
-
res.json(users);
|
| 437 |
});
|
| 438 |
app.put('/api/users/:id', async (req, res) => {
|
| 439 |
try {
|
| 440 |
-
const updateData = req.body;
|
| 441 |
-
|
| 442 |
if (InMemoryDB.isFallback) {
|
| 443 |
const idx = InMemoryDB.users.findIndex(u => u._id == req.params.id);
|
| 444 |
-
if(idx>=0) InMemoryDB.users[idx] = { ...InMemoryDB.users[idx], ...
|
| 445 |
return res.json({ success: true });
|
| 446 |
}
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
if (updateData.status === 'active') {
|
| 451 |
-
// Notify User
|
| 452 |
await notify(user.schoolId, '账号审核通过', `您的账号 ${user.username} 已通过审核,欢迎使用!`, null, user._id.toString());
|
| 453 |
-
|
| 454 |
-
// Sync Course if needed
|
| 455 |
if (user.role === 'TEACHER') await syncTeacherToCourse(user);
|
| 456 |
-
|
| 457 |
if (user.role === 'TEACHER' && user.homeroomClass) {
|
| 458 |
const classes = await ClassModel.find({ schoolId: user.schoolId });
|
| 459 |
const targetClass = classes.find(c => (c.grade + c.className) === user.homeroomClass);
|
| 460 |
-
if (targetClass) {
|
| 461 |
-
await ClassModel.findByIdAndUpdate(targetClass._id, { teacherName: user.trueName || user.username });
|
| 462 |
-
}
|
| 463 |
}
|
| 464 |
}
|
| 465 |
res.json({ success: true });
|
| 466 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 467 |
});
|
| 468 |
app.delete('/api/users/:id', async (req, res) => {
|
| 469 |
-
if (InMemoryDB.isFallback) {
|
| 470 |
-
InMemoryDB.users = InMemoryDB.users.filter(u => u._id != req.params.id);
|
| 471 |
-
return res.json({ success: true });
|
| 472 |
-
}
|
| 473 |
const user = await User.findById(req.params.id);
|
| 474 |
-
if (user) {
|
| 475 |
-
await User.findByIdAndDelete(req.params.id);
|
| 476 |
-
await notify(user.schoolId, '用户被删除', `用户 ${user.trueName || user.username} 已被管理员删除`, 'ADMIN');
|
| 477 |
-
}
|
| 478 |
res.json({ success: true });
|
| 479 |
});
|
| 480 |
|
|
@@ -508,10 +448,7 @@ app.get('/api/exams', async (req, res) => {
|
|
| 508 |
app.post('/api/exams', async (req, res) => {
|
| 509 |
const { name, date, semester } = req.body;
|
| 510 |
const schoolId = req.headers['x-school-id'];
|
| 511 |
-
if (InMemoryDB.isFallback) {
|
| 512 |
-
InMemoryDB.exams.push({ name, date, semester, schoolId, _id: String(Date.now()) });
|
| 513 |
-
return res.json({ success: true });
|
| 514 |
-
}
|
| 515 |
await ExamModel.findOneAndUpdate({ name, schoolId }, { date, semester, schoolId }, { upsert: true });
|
| 516 |
res.json({ success: true });
|
| 517 |
});
|
|
@@ -520,56 +457,52 @@ app.post('/api/exams', async (req, res) => {
|
|
| 520 |
app.get('/api/schedules', async (req, res) => {
|
| 521 |
const { className, teacherName, grade } = req.query;
|
| 522 |
const filter = getQueryFilter(req);
|
| 523 |
-
|
| 524 |
if (InMemoryDB.isFallback) return res.json([]);
|
| 525 |
-
|
| 526 |
if (grade) {
|
| 527 |
-
// Admin Grade View: Find all classes in this grade, then find their schedules
|
| 528 |
-
// IMPORTANT: We must match the grade string exactly or via partial match if frontend sends "六年级"
|
| 529 |
const classes = await ClassModel.find({ ...filter, grade: grade });
|
| 530 |
-
const classNames = classes.map(c => c.grade + c.className);
|
| 531 |
-
|
| 532 |
if (classNames.length === 0) return res.json([]);
|
| 533 |
-
|
| 534 |
-
// Find schedules where className is in our list
|
| 535 |
-
const schedules = await ScheduleModel.find({ ...filter, className: { $in: classNames } });
|
| 536 |
-
return res.json(schedules);
|
| 537 |
}
|
| 538 |
-
|
| 539 |
if (className) filter.className = className;
|
| 540 |
if (teacherName) filter.teacherName = teacherName;
|
| 541 |
-
|
| 542 |
res.json(await ScheduleModel.find(filter));
|
| 543 |
});
|
|
|
|
|
|
|
| 544 |
app.post('/api/schedules', async (req, res) => {
|
| 545 |
const data = injectSchoolId(req, req.body);
|
| 546 |
-
const { schoolId, className, dayOfWeek, period } = data;
|
| 547 |
-
|
| 548 |
if (InMemoryDB.isFallback) {
|
|
|
|
|
|
|
|
|
|
| 549 |
const idx = InMemoryDB.schedules.findIndex(s => s.schoolId === schoolId && s.className === className && s.dayOfWeek === dayOfWeek && s.period === period);
|
| 550 |
if (idx >= 0) InMemoryDB.schedules[idx] = { ...data, _id: String(Date.now()) };
|
| 551 |
else InMemoryDB.schedules.push({ ...data, _id: String(Date.now()) });
|
| 552 |
return res.json({ success: true });
|
| 553 |
}
|
| 554 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
await ScheduleModel.findOneAndUpdate(
|
| 556 |
{ schoolId, className, dayOfWeek, period },
|
| 557 |
data,
|
| 558 |
{ upsert: true }
|
| 559 |
);
|
| 560 |
-
|
| 561 |
await notify(schoolId, '课程表变更', `${className} 周${dayOfWeek}第${period}节 课程已更新`, 'ADMIN');
|
| 562 |
res.json({ success: true });
|
| 563 |
});
|
|
|
|
| 564 |
app.delete('/api/schedules', async (req, res) => {
|
| 565 |
const { className, dayOfWeek, period } = req.query;
|
| 566 |
const schoolId = req.headers['x-school-id'];
|
| 567 |
-
if (InMemoryDB.isFallback) {
|
| 568 |
-
InMemoryDB.schedules = InMemoryDB.schedules.filter(s =>
|
| 569 |
-
!(s.schoolId === schoolId && s.className === className && s.dayOfWeek == dayOfWeek && s.period == period)
|
| 570 |
-
);
|
| 571 |
-
return res.json({ success: true });
|
| 572 |
-
}
|
| 573 |
await ScheduleModel.deleteOne({ schoolId, className, dayOfWeek, period });
|
| 574 |
res.json({ success: true });
|
| 575 |
});
|
|
@@ -584,27 +517,22 @@ app.post('/api/students', async (req, res) => {
|
|
| 584 |
const data = injectSchoolId(req, req.body);
|
| 585 |
try {
|
| 586 |
if (InMemoryDB.isFallback) {
|
| 587 |
-
// Mock upsert behavior
|
| 588 |
const idx = InMemoryDB.students.findIndex(s => s.studentNo === data.studentNo && s.schoolId === data.schoolId);
|
| 589 |
if (idx >= 0) InMemoryDB.students[idx] = { ...InMemoryDB.students[idx], ...data };
|
| 590 |
else InMemoryDB.students.push({ ...data, _id: String(Date.now()) });
|
| 591 |
return res.json({});
|
| 592 |
}
|
| 593 |
-
|
| 594 |
-
// Fix: Use Upsert to prevent duplicate key errors (500)
|
| 595 |
-
await Student.findOneAndUpdate(
|
| 596 |
-
{ schoolId: data.schoolId, studentNo: data.studentNo },
|
| 597 |
-
data,
|
| 598 |
-
{ upsert: true, new: true }
|
| 599 |
-
);
|
| 600 |
await notify(data.schoolId, '学生档案更新', `学生 ${data.name} (${data.studentNo}) 档案已更新`, 'ADMIN');
|
| 601 |
res.json({});
|
| 602 |
-
} catch (e) {
|
| 603 |
-
res.status(500).json({ error: e.message });
|
| 604 |
-
}
|
| 605 |
});
|
| 606 |
app.put('/api/students/:id', async (req, res) => {
|
| 607 |
-
if (InMemoryDB.isFallback)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 608 |
await Student.findByIdAndUpdate(req.params.id, req.body);
|
| 609 |
res.json({ success: true });
|
| 610 |
});
|
|
@@ -617,7 +545,6 @@ app.delete('/api/students/:id', async (req, res) => {
|
|
| 617 |
app.get('/api/classes', async (req, res) => {
|
| 618 |
const filter = getQueryFilter(req);
|
| 619 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.classes.filter(c => !filter.schoolId || c.schoolId === filter.schoolId));
|
| 620 |
-
|
| 621 |
const classes = await ClassModel.find(filter);
|
| 622 |
const result = await Promise.all(classes.map(async (c) => {
|
| 623 |
const count = await Student.countDocuments({ className: c.grade + c.className, schoolId: filter.schoolId });
|
|
@@ -696,34 +623,90 @@ app.get('/api/stats', async (req, res) => {
|
|
| 696 |
const excellentRate = validScores.length > 0 ? Math.round((excellentCount / validScores.length) * 100) + '%' : '0%';
|
| 697 |
res.json({ studentCount, courseCount, avgScore, excellentRate });
|
| 698 |
});
|
| 699 |
-
|
| 700 |
-
// Global Config (No school filter)
|
| 701 |
app.get('/api/config', async (req, res) => {
|
| 702 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.config);
|
| 703 |
res.json(await ConfigModel.findOne({ key: 'main' }) || {});
|
| 704 |
});
|
| 705 |
app.post('/api/config', async (req, res) => {
|
| 706 |
-
// Global config update
|
| 707 |
if (InMemoryDB.isFallback) { InMemoryDB.config = req.body; return res.json({}); }
|
| 708 |
res.json(await ConfigModel.findOneAndUpdate({ key: 'main' }, req.body, { upsert: true, new: true }));
|
| 709 |
});
|
| 710 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 711 |
app.post('/api/batch-delete', async (req, res) => {
|
| 712 |
const { type, ids } = req.body;
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
if (type === 'student') {
|
| 716 |
-
await Student.deleteMany({ _id: { $in: ids } });
|
| 717 |
-
await notify(schoolId, '批量删除', `批量删除了 ${ids.length} 名学生`, 'ADMIN');
|
| 718 |
-
}
|
| 719 |
if (type === 'score') await Score.deleteMany({ _id: { $in: ids } });
|
| 720 |
if (type === 'user') await User.deleteMany({ _id: { $in: ids } });
|
| 721 |
res.json({ success: true });
|
| 722 |
});
|
| 723 |
|
| 724 |
-
// Frontend
|
| 725 |
app.get('*', (req, res) => {
|
| 726 |
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
| 727 |
});
|
| 728 |
|
| 729 |
-
app.listen(PORT, () => console.log(`🚀 Service running on port ${PORT}`));
|
|
|
|
| 1 |
+
|
| 2 |
const express = require('express');
|
| 3 |
const mongoose = require('mongoose');
|
| 4 |
const cors = require('cors');
|
|
|
|
| 14 |
const app = express();
|
| 15 |
|
| 16 |
app.use(cors());
|
| 17 |
+
app.use(bodyParser.json({ limit: '10mb' }));
|
| 18 |
app.use(express.static(path.join(__dirname, 'dist')));
|
| 19 |
|
| 20 |
// ==========================================
|
|
|
|
| 32 |
exams: [],
|
| 33 |
schedules: [],
|
| 34 |
notifications: [],
|
| 35 |
+
gameSessions: [],
|
| 36 |
+
rewards: [],
|
| 37 |
+
luckyConfig: {},
|
| 38 |
config: {},
|
| 39 |
isFallback: false
|
| 40 |
};
|
|
|
|
| 92 |
phone: String,
|
| 93 |
className: String,
|
| 94 |
status: { type: String, default: 'Enrolled' },
|
|
|
|
| 95 |
parentName: String,
|
| 96 |
parentPhone: String,
|
| 97 |
+
address: String,
|
| 98 |
+
teamId: String, // Game Team ID
|
| 99 |
+
drawAttempts: { type: Number, default: 0 } // Game Lucky Draw
|
| 100 |
});
|
|
|
|
| 101 |
StudentSchema.index({ schoolId: 1, studentNo: 1 }, { unique: true });
|
| 102 |
const Student = mongoose.model('Student', StudentSchema);
|
| 103 |
|
| 104 |
const CourseSchema = new mongoose.Schema({
|
| 105 |
schoolId: String,
|
| 106 |
courseCode: String,
|
| 107 |
+
courseName: String,
|
| 108 |
+
teacherName: String,
|
| 109 |
credits: Number,
|
| 110 |
capacity: Number,
|
| 111 |
enrolled: { type: Number, default: 0 }
|
|
|
|
| 155 |
className: String,
|
| 156 |
teacherName: String,
|
| 157 |
subject: String,
|
| 158 |
+
dayOfWeek: Number,
|
| 159 |
+
period: Number
|
| 160 |
});
|
|
|
|
| 161 |
ScheduleSchema.index({ schoolId: 1, className: 1, dayOfWeek: 1, period: 1 }, { unique: true });
|
| 162 |
const ScheduleModel = mongoose.model('Schedule', ScheduleSchema);
|
| 163 |
|
| 164 |
const ConfigSchema = new mongoose.Schema({
|
|
|
|
| 165 |
key: { type: String, default: 'main', unique: true },
|
| 166 |
systemName: String,
|
| 167 |
semester: String,
|
| 168 |
+
semesters: [String],
|
| 169 |
allowRegister: Boolean,
|
| 170 |
allowAdminRegister: { type: Boolean, default: false },
|
| 171 |
maintenanceMode: Boolean,
|
|
|
|
| 175 |
|
| 176 |
const NotificationSchema = new mongoose.Schema({
|
| 177 |
schoolId: String,
|
| 178 |
+
targetRole: String,
|
| 179 |
+
targetUserId: String,
|
| 180 |
title: String,
|
| 181 |
content: String,
|
| 182 |
+
type: { type: String, default: 'info' },
|
| 183 |
createTime: { type: Date, default: Date.now },
|
| 184 |
+
expiresAt: { type: Date, default: () => new Date(+new Date() + 30*24*60*60*1000) }
|
| 185 |
});
|
| 186 |
+
NotificationSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
| 187 |
const NotificationModel = mongoose.model('Notification', NotificationSchema);
|
| 188 |
|
| 189 |
+
// --- NEW GAME SCHEMAS ---
|
| 190 |
+
|
| 191 |
+
const GameSessionSchema = new mongoose.Schema({
|
| 192 |
+
schoolId: String,
|
| 193 |
+
className: String,
|
| 194 |
+
isEnabled: Boolean,
|
| 195 |
+
maxSteps: { type: Number, default: 10 },
|
| 196 |
+
teams: [{
|
| 197 |
+
id: String,
|
| 198 |
+
name: String,
|
| 199 |
+
score: Number,
|
| 200 |
+
avatar: String,
|
| 201 |
+
color: String,
|
| 202 |
+
members: [String] // Array of Student IDs
|
| 203 |
+
}],
|
| 204 |
+
rewardsConfig: [{
|
| 205 |
+
scoreThreshold: Number,
|
| 206 |
+
rewardType: String, // ITEM, DRAW_COUNT
|
| 207 |
+
rewardName: String,
|
| 208 |
+
rewardValue: Number
|
| 209 |
+
}]
|
| 210 |
+
});
|
| 211 |
+
const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
|
| 212 |
+
|
| 213 |
+
const StudentRewardSchema = new mongoose.Schema({
|
| 214 |
+
schoolId: String,
|
| 215 |
+
studentId: String,
|
| 216 |
+
studentName: String,
|
| 217 |
+
rewardType: String,
|
| 218 |
+
name: String,
|
| 219 |
+
status: { type: String, default: 'PENDING' }, // PENDING, REDEEMED
|
| 220 |
+
source: String,
|
| 221 |
+
createTime: { type: Date, default: Date.now }
|
| 222 |
+
});
|
| 223 |
+
const StudentRewardModel = mongoose.model('StudentReward', StudentRewardSchema);
|
| 224 |
+
|
| 225 |
+
const LuckyDrawConfigSchema = new mongoose.Schema({
|
| 226 |
+
schoolId: String,
|
| 227 |
+
prizes: [String],
|
| 228 |
+
dailyLimit: { type: Number, default: 3 },
|
| 229 |
+
defaultPrize: { type: String, default: '谢谢参与' }
|
| 230 |
+
});
|
| 231 |
+
const LuckyDrawConfigModel = mongoose.model('LuckyDrawConfig', LuckyDrawConfigSchema);
|
| 232 |
+
|
| 233 |
// Helper: Create Notification
|
| 234 |
const notify = async (schoolId, title, content, targetRole = null, targetUserId = null) => {
|
| 235 |
try {
|
|
|
|
| 248 |
// Helper: Sync Teacher to Course
|
| 249 |
const syncTeacherToCourse = async (user) => {
|
| 250 |
if (!user.teachingSubject || !user.schoolId || user.role !== 'TEACHER') return;
|
|
|
|
| 251 |
try {
|
| 252 |
const teacherName = user.trueName || user.username;
|
|
|
|
| 253 |
const exists = await Course.findOne({
|
| 254 |
schoolId: user.schoolId,
|
| 255 |
courseName: user.teachingSubject,
|
| 256 |
teacherName: teacherName
|
| 257 |
});
|
|
|
|
| 258 |
if (!exists) {
|
|
|
|
| 259 |
await Course.create({
|
| 260 |
schoolId: user.schoolId,
|
| 261 |
courseName: user.teachingSubject,
|
| 262 |
teacherName: teacherName,
|
| 263 |
+
credits: 4,
|
| 264 |
capacity: 45
|
| 265 |
});
|
| 266 |
}
|
| 267 |
+
} catch (e) { console.error('Auto-sync Course Error:', e); }
|
|
|
|
|
|
|
| 268 |
};
|
| 269 |
|
|
|
|
| 270 |
const initData = async () => {
|
| 271 |
if (InMemoryDB.isFallback) return;
|
| 272 |
try {
|
| 273 |
+
try { await mongoose.connection.collection('subjects').dropIndex('name_1'); } catch (e) {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
let defaultSchool = await School.findOne({ code: 'EXP01' });
|
| 275 |
+
if (!defaultSchool) defaultSchool = await School.create({ name: '第一实验小学', code: 'EXP01' });
|
| 276 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
const adminExists = await User.findOne({ username: 'admin' });
|
| 278 |
if (!adminExists) {
|
| 279 |
await User.create({
|
| 280 |
+
username: 'admin', password: 'admin', role: 'ADMIN', status: 'active',
|
| 281 |
+
schoolId: defaultSchool._id.toString(), trueName: '超级管理员', email: 'admin@system.com'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
});
|
|
|
|
| 283 |
}
|
|
|
|
|
|
|
| 284 |
const configExists = await ConfigModel.findOne({ key: 'main' });
|
| 285 |
if (!configExists) {
|
| 286 |
await ConfigModel.create({
|
| 287 |
+
key: 'main', systemName: '智慧校园管理系统', semester: '2023-2024学年 第一学期',
|
| 288 |
+
semesters: ['2023-2024学年 第一学期', '2023-2024学年 第二学期'], allowRegister: true, allowAdminRegister: false
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
});
|
|
|
|
| 290 |
}
|
| 291 |
+
} catch (err) { console.error('❌ Init Data Error', err); }
|
|
|
|
|
|
|
|
|
|
| 292 |
};
|
| 293 |
mongoose.connection.once('open', initData);
|
| 294 |
|
| 295 |
+
// Helpers
|
|
|
|
|
|
|
| 296 |
const getQueryFilter = (req) => {
|
| 297 |
const schoolId = req.headers['x-school-id'];
|
| 298 |
if (!schoolId) return {};
|
| 299 |
return { schoolId };
|
| 300 |
};
|
|
|
|
| 301 |
const injectSchoolId = (req, body) => {
|
| 302 |
const schoolId = req.headers['x-school-id'];
|
| 303 |
return { ...body, schoolId };
|
|
|
|
| 310 |
// --- Notifications ---
|
| 311 |
app.get('/api/notifications', async (req, res) => {
|
| 312 |
const schoolId = req.headers['x-school-id'];
|
| 313 |
+
const { role, userId } = req.query;
|
|
|
|
| 314 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.notifications);
|
| 315 |
+
const query = { schoolId, $or: [ { targetRole: null, targetUserId: null }, { targetRole: role }, { targetUserId: userId } ] };
|
| 316 |
+
res.json(await NotificationModel.find(query).sort({ createTime: -1 }).limit(20));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
});
|
| 318 |
|
| 319 |
// --- Public Routes ---
|
| 320 |
app.get('/api/public/schools', async (req, res) => {
|
| 321 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.schools);
|
| 322 |
+
res.json(await School.find({}, 'name code _id'));
|
|
|
|
| 323 |
});
|
|
|
|
| 324 |
app.get('/api/public/config', async (req, res) => {
|
| 325 |
if (InMemoryDB.isFallback) return res.json({ allowRegister: true, allowAdminRegister: true });
|
| 326 |
+
res.json(await ConfigModel.findOne({ key: 'main' }) || { allowRegister: true, allowAdminRegister: false });
|
|
|
|
|
|
|
| 327 |
});
|
|
|
|
| 328 |
app.get('/api/public/meta', async (req, res) => {
|
| 329 |
const { schoolId } = req.query;
|
| 330 |
if (!schoolId) return res.json({ classes: [], subjects: [] });
|
| 331 |
+
if (InMemoryDB.isFallback) return res.json({ classes: InMemoryDB.classes.filter(c => c.schoolId === schoolId), subjects: InMemoryDB.subjects.filter(s => s.schoolId === schoolId) });
|
| 332 |
+
res.json({ classes: await ClassModel.find({ schoolId }), subjects: await SubjectModel.find({ schoolId }) });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
});
|
| 334 |
|
| 335 |
// --- Auth ---
|
| 336 |
app.post('/api/auth/login', async (req, res) => {
|
| 337 |
const { username, password } = req.body;
|
| 338 |
try {
|
| 339 |
+
let user = InMemoryDB.isFallback
|
| 340 |
+
? InMemoryDB.users.find(u => u.username === username && u.password === password)
|
| 341 |
+
: await User.findOne({ username, password });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
if (!user) return res.status(401).json({ message: '用户名或密码错误' });
|
| 343 |
if (user.status === 'pending') return res.status(403).json({ error: 'PENDING_APPROVAL', message: '账号待审核' });
|
| 344 |
if (user.status === 'banned') return res.status(403).json({ error: 'BANNED', message: '账号已被停用' });
|
|
|
|
| 345 |
res.json(user);
|
| 346 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 347 |
});
|
| 348 |
|
| 349 |
app.post('/api/auth/register', async (req, res) => {
|
| 350 |
+
const { username, password, role, schoolId, trueName, phone, email, avatar, teachingSubject, homeroomClass } = req.body;
|
|
|
|
|
|
|
|
|
|
| 351 |
const status = 'pending';
|
|
|
|
| 352 |
try {
|
| 353 |
if (InMemoryDB.isFallback) {
|
| 354 |
if (InMemoryDB.users.find(u => u.username === username)) return res.status(400).json({ error: 'Existed' });
|
|
|
|
| 356 |
InMemoryDB.users.push(newUser);
|
| 357 |
return res.json(newUser);
|
| 358 |
}
|
|
|
|
| 359 |
const existing = await User.findOne({ username });
|
| 360 |
if (existing) return res.status(400).json({ error: 'Existed' });
|
| 361 |
+
const newUser = await User.create({ username, password, role, status, schoolId, trueName, phone, email, avatar, teachingSubject, homeroomClass });
|
| 362 |
+
if (role === 'TEACHER' && teachingSubject) await syncTeacherToCourse(newUser);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
await notify(schoolId, '新用户注册申请', `${trueName || username} 申请注册为 ${role === 'TEACHER' ? '教师' : '管理员'}`, 'ADMIN');
|
|
|
|
| 364 |
res.json(newUser);
|
| 365 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 366 |
});
|
|
|
|
| 371 |
res.json(await School.find());
|
| 372 |
});
|
| 373 |
app.post('/api/schools', async (req, res) => {
|
| 374 |
+
if (InMemoryDB.isFallback) { const ns = { ...req.body, _id: String(Date.now()) }; InMemoryDB.schools.push(ns); return res.json(ns); }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
res.json(await School.create(req.body));
|
| 376 |
});
|
| 377 |
app.put('/api/schools/:id', async (req, res) => {
|
|
|
|
| 382 |
|
| 383 |
// --- Users ---
|
| 384 |
app.get('/api/users', async (req, res) => {
|
| 385 |
+
const { global, role } = req.query;
|
| 386 |
let filter = global === 'true' ? {} : getQueryFilter(req);
|
|
|
|
| 387 |
if (role) filter.role = role;
|
|
|
|
| 388 |
if (InMemoryDB.isFallback) {
|
| 389 |
if (global === 'true') return res.json(InMemoryDB.users);
|
| 390 |
return res.json(InMemoryDB.users.filter(u => (!filter.schoolId || u.schoolId === filter.schoolId) && (!role || u.role === role)));
|
| 391 |
}
|
| 392 |
+
res.json(await User.find(filter).sort({ createTime: -1 }));
|
|
|
|
| 393 |
});
|
| 394 |
app.put('/api/users/:id', async (req, res) => {
|
| 395 |
try {
|
|
|
|
|
|
|
| 396 |
if (InMemoryDB.isFallback) {
|
| 397 |
const idx = InMemoryDB.users.findIndex(u => u._id == req.params.id);
|
| 398 |
+
if(idx>=0) InMemoryDB.users[idx] = { ...InMemoryDB.users[idx], ...req.body };
|
| 399 |
return res.json({ success: true });
|
| 400 |
}
|
| 401 |
+
const user = await User.findByIdAndUpdate(req.params.id, req.body, { new: true });
|
| 402 |
+
if (req.body.status === 'active') {
|
|
|
|
|
|
|
|
|
|
| 403 |
await notify(user.schoolId, '账号审核通过', `您的账号 ${user.username} 已通过审核,欢迎使用!`, null, user._id.toString());
|
|
|
|
|
|
|
| 404 |
if (user.role === 'TEACHER') await syncTeacherToCourse(user);
|
|
|
|
| 405 |
if (user.role === 'TEACHER' && user.homeroomClass) {
|
| 406 |
const classes = await ClassModel.find({ schoolId: user.schoolId });
|
| 407 |
const targetClass = classes.find(c => (c.grade + c.className) === user.homeroomClass);
|
| 408 |
+
if (targetClass) await ClassModel.findByIdAndUpdate(targetClass._id, { teacherName: user.trueName || user.username });
|
|
|
|
|
|
|
| 409 |
}
|
| 410 |
}
|
| 411 |
res.json({ success: true });
|
| 412 |
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 413 |
});
|
| 414 |
app.delete('/api/users/:id', async (req, res) => {
|
| 415 |
+
if (InMemoryDB.isFallback) { InMemoryDB.users = InMemoryDB.users.filter(u => u._id != req.params.id); return res.json({ success: true }); }
|
|
|
|
|
|
|
|
|
|
| 416 |
const user = await User.findById(req.params.id);
|
| 417 |
+
if (user) { await User.findByIdAndDelete(req.params.id); await notify(user.schoolId, '用户被删除', `用户 ${user.trueName || user.username} 已被管理员删除`, 'ADMIN'); }
|
|
|
|
|
|
|
|
|
|
| 418 |
res.json({ success: true });
|
| 419 |
});
|
| 420 |
|
|
|
|
| 448 |
app.post('/api/exams', async (req, res) => {
|
| 449 |
const { name, date, semester } = req.body;
|
| 450 |
const schoolId = req.headers['x-school-id'];
|
| 451 |
+
if (InMemoryDB.isFallback) { InMemoryDB.exams.push({ name, date, semester, schoolId, _id: String(Date.now()) }); return res.json({ success: true }); }
|
|
|
|
|
|
|
|
|
|
| 452 |
await ExamModel.findOneAndUpdate({ name, schoolId }, { date, semester, schoolId }, { upsert: true });
|
| 453 |
res.json({ success: true });
|
| 454 |
});
|
|
|
|
| 457 |
app.get('/api/schedules', async (req, res) => {
|
| 458 |
const { className, teacherName, grade } = req.query;
|
| 459 |
const filter = getQueryFilter(req);
|
|
|
|
| 460 |
if (InMemoryDB.isFallback) return res.json([]);
|
|
|
|
| 461 |
if (grade) {
|
|
|
|
|
|
|
| 462 |
const classes = await ClassModel.find({ ...filter, grade: grade });
|
| 463 |
+
const classNames = classes.map(c => c.grade + c.className);
|
|
|
|
| 464 |
if (classNames.length === 0) return res.json([]);
|
| 465 |
+
return res.json(await ScheduleModel.find({ ...filter, className: { $in: classNames } }));
|
|
|
|
|
|
|
|
|
|
| 466 |
}
|
|
|
|
| 467 |
if (className) filter.className = className;
|
| 468 |
if (teacherName) filter.teacherName = teacherName;
|
|
|
|
| 469 |
res.json(await ScheduleModel.find(filter));
|
| 470 |
});
|
| 471 |
+
|
| 472 |
+
// UPDATED: Schedule Conflict Check
|
| 473 |
app.post('/api/schedules', async (req, res) => {
|
| 474 |
const data = injectSchoolId(req, req.body);
|
| 475 |
+
const { schoolId, className, dayOfWeek, period, teacherName } = data;
|
| 476 |
+
|
| 477 |
if (InMemoryDB.isFallback) {
|
| 478 |
+
const conflict = InMemoryDB.schedules.find(s => s.schoolId === schoolId && s.teacherName === teacherName && s.dayOfWeek === dayOfWeek && s.period === period);
|
| 479 |
+
if (conflict && conflict.className !== className) return res.status(409).json({ error: 'CONFLICT', message: `教师 ${teacherName} 在周${dayOfWeek}第${period}节已有课程 (${conflict.className})` });
|
| 480 |
+
|
| 481 |
const idx = InMemoryDB.schedules.findIndex(s => s.schoolId === schoolId && s.className === className && s.dayOfWeek === dayOfWeek && s.period === period);
|
| 482 |
if (idx >= 0) InMemoryDB.schedules[idx] = { ...data, _id: String(Date.now()) };
|
| 483 |
else InMemoryDB.schedules.push({ ...data, _id: String(Date.now()) });
|
| 484 |
return res.json({ success: true });
|
| 485 |
}
|
| 486 |
+
|
| 487 |
+
// Real DB Check
|
| 488 |
+
const conflict = await ScheduleModel.findOne({ schoolId, teacherName, dayOfWeek, period });
|
| 489 |
+
if (conflict && conflict.className !== className) {
|
| 490 |
+
return res.status(409).json({ error: 'CONFLICT', message: `教师 ${teacherName} 在周${dayOfWeek}第${period}节已有课程 (${conflict.className})` });
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
await ScheduleModel.findOneAndUpdate(
|
| 494 |
{ schoolId, className, dayOfWeek, period },
|
| 495 |
data,
|
| 496 |
{ upsert: true }
|
| 497 |
);
|
|
|
|
| 498 |
await notify(schoolId, '课程表变更', `${className} 周${dayOfWeek}第${period}节 课程已更新`, 'ADMIN');
|
| 499 |
res.json({ success: true });
|
| 500 |
});
|
| 501 |
+
|
| 502 |
app.delete('/api/schedules', async (req, res) => {
|
| 503 |
const { className, dayOfWeek, period } = req.query;
|
| 504 |
const schoolId = req.headers['x-school-id'];
|
| 505 |
+
if (InMemoryDB.isFallback) { InMemoryDB.schedules = InMemoryDB.schedules.filter(s => !(s.schoolId === schoolId && s.className === className && s.dayOfWeek == dayOfWeek && s.period == period)); return res.json({ success: true }); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
await ScheduleModel.deleteOne({ schoolId, className, dayOfWeek, period });
|
| 507 |
res.json({ success: true });
|
| 508 |
});
|
|
|
|
| 517 |
const data = injectSchoolId(req, req.body);
|
| 518 |
try {
|
| 519 |
if (InMemoryDB.isFallback) {
|
|
|
|
| 520 |
const idx = InMemoryDB.students.findIndex(s => s.studentNo === data.studentNo && s.schoolId === data.schoolId);
|
| 521 |
if (idx >= 0) InMemoryDB.students[idx] = { ...InMemoryDB.students[idx], ...data };
|
| 522 |
else InMemoryDB.students.push({ ...data, _id: String(Date.now()) });
|
| 523 |
return res.json({});
|
| 524 |
}
|
| 525 |
+
await Student.findOneAndUpdate({ schoolId: data.schoolId, studentNo: data.studentNo }, data, { upsert: true, new: true });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
await notify(data.schoolId, '学生档案更新', `学生 ${data.name} (${data.studentNo}) 档案已更新`, 'ADMIN');
|
| 527 |
res.json({});
|
| 528 |
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
|
|
|
|
|
| 529 |
});
|
| 530 |
app.put('/api/students/:id', async (req, res) => {
|
| 531 |
+
if (InMemoryDB.isFallback) {
|
| 532 |
+
const idx = InMemoryDB.students.findIndex(s => s._id == req.params.id);
|
| 533 |
+
if(idx>=0) InMemoryDB.students[idx] = { ...InMemoryDB.students[idx], ...req.body };
|
| 534 |
+
return res.json({ success: true });
|
| 535 |
+
}
|
| 536 |
await Student.findByIdAndUpdate(req.params.id, req.body);
|
| 537 |
res.json({ success: true });
|
| 538 |
});
|
|
|
|
| 545 |
app.get('/api/classes', async (req, res) => {
|
| 546 |
const filter = getQueryFilter(req);
|
| 547 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.classes.filter(c => !filter.schoolId || c.schoolId === filter.schoolId));
|
|
|
|
| 548 |
const classes = await ClassModel.find(filter);
|
| 549 |
const result = await Promise.all(classes.map(async (c) => {
|
| 550 |
const count = await Student.countDocuments({ className: c.grade + c.className, schoolId: filter.schoolId });
|
|
|
|
| 623 |
const excellentRate = validScores.length > 0 ? Math.round((excellentCount / validScores.length) * 100) + '%' : '0%';
|
| 624 |
res.json({ studentCount, courseCount, avgScore, excellentRate });
|
| 625 |
});
|
|
|
|
|
|
|
| 626 |
app.get('/api/config', async (req, res) => {
|
| 627 |
if (InMemoryDB.isFallback) return res.json(InMemoryDB.config);
|
| 628 |
res.json(await ConfigModel.findOne({ key: 'main' }) || {});
|
| 629 |
});
|
| 630 |
app.post('/api/config', async (req, res) => {
|
|
|
|
| 631 |
if (InMemoryDB.isFallback) { InMemoryDB.config = req.body; return res.json({}); }
|
| 632 |
res.json(await ConfigModel.findOneAndUpdate({ key: 'main' }, req.body, { upsert: true, new: true }));
|
| 633 |
});
|
| 634 |
|
| 635 |
+
// --- NEW: Games & Rewards ---
|
| 636 |
+
app.get('/api/games/mountain', async (req, res) => {
|
| 637 |
+
const { className } = req.query;
|
| 638 |
+
const filter = { ...getQueryFilter(req), className };
|
| 639 |
+
if (InMemoryDB.isFallback) return res.json(InMemoryDB.gameSessions.find(g => g.schoolId === filter.schoolId && g.className === className));
|
| 640 |
+
const session = await GameSessionModel.findOne(filter);
|
| 641 |
+
res.json(session);
|
| 642 |
+
});
|
| 643 |
+
app.post('/api/games/mountain', async (req, res) => {
|
| 644 |
+
const data = injectSchoolId(req, req.body);
|
| 645 |
+
if (InMemoryDB.isFallback) {
|
| 646 |
+
const idx = InMemoryDB.gameSessions.findIndex(g => g.schoolId === data.schoolId && g.className === data.className);
|
| 647 |
+
if (idx >= 0) InMemoryDB.gameSessions[idx] = { ...data };
|
| 648 |
+
else InMemoryDB.gameSessions.push({ ...data, _id: String(Date.now()) });
|
| 649 |
+
return res.json({ success: true });
|
| 650 |
+
}
|
| 651 |
+
await GameSessionModel.findOneAndUpdate({ schoolId: data.schoolId, className: data.className }, data, { upsert: true });
|
| 652 |
+
res.json({ success: true });
|
| 653 |
+
});
|
| 654 |
+
app.get('/api/games/lucky-config', async (req, res) => {
|
| 655 |
+
const filter = getQueryFilter(req);
|
| 656 |
+
if (InMemoryDB.isFallback) return res.json(InMemoryDB.luckyConfig);
|
| 657 |
+
res.json(await LuckyDrawConfigModel.findOne(filter) || { prizes: ['免作业券', '文具套装'], dailyLimit: 3 });
|
| 658 |
+
});
|
| 659 |
+
app.post('/api/games/lucky-config', async (req, res) => {
|
| 660 |
+
const data = injectSchoolId(req, req.body);
|
| 661 |
+
if (InMemoryDB.isFallback) { InMemoryDB.luckyConfig = data; return res.json({}); }
|
| 662 |
+
await LuckyDrawConfigModel.findOneAndUpdate({ schoolId: data.schoolId }, data, { upsert: true });
|
| 663 |
+
res.json({ success: true });
|
| 664 |
+
});
|
| 665 |
+
|
| 666 |
+
app.get('/api/rewards', async (req, res) => {
|
| 667 |
+
const { studentId } = req.query;
|
| 668 |
+
const filter = { ...getQueryFilter(req), studentId };
|
| 669 |
+
if (InMemoryDB.isFallback) return res.json(InMemoryDB.rewards.filter(r => r.studentId === studentId));
|
| 670 |
+
res.json(await StudentRewardModel.find(filter).sort({ createTime: -1 }));
|
| 671 |
+
});
|
| 672 |
+
app.post('/api/rewards', async (req, res) => {
|
| 673 |
+
const data = injectSchoolId(req, req.body);
|
| 674 |
+
if (InMemoryDB.isFallback) { InMemoryDB.rewards.push({ ...data, _id: String(Date.now()) }); return res.json({}); }
|
| 675 |
+
await StudentRewardModel.create(data);
|
| 676 |
+
|
| 677 |
+
// If reward is DRAW_COUNT, increment student attempts
|
| 678 |
+
if (data.rewardType === 'DRAW_COUNT') {
|
| 679 |
+
await Student.findByIdAndUpdate(data.studentId, { $inc: { drawAttempts: 1 } });
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
res.json({ success: true });
|
| 683 |
+
});
|
| 684 |
+
app.post('/api/rewards/:id/redeem', async (req, res) => {
|
| 685 |
+
if (InMemoryDB.isFallback) {
|
| 686 |
+
const r = InMemoryDB.rewards.find(r => r._id == req.params.id);
|
| 687 |
+
if(r) r.status = 'REDEEMED';
|
| 688 |
+
return res.json({ success: true });
|
| 689 |
+
}
|
| 690 |
+
await StudentRewardModel.findByIdAndUpdate(req.params.id, { status: 'REDEEMED' });
|
| 691 |
+
res.json({ success: true });
|
| 692 |
+
});
|
| 693 |
+
app.post('/api/rewards/consume-draw', async (req, res) => {
|
| 694 |
+
const { studentId } = req.body;
|
| 695 |
+
if (InMemoryDB.isFallback) return res.json({ success: true });
|
| 696 |
+
await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: -1 } });
|
| 697 |
+
res.json({ success: true });
|
| 698 |
+
});
|
| 699 |
+
|
| 700 |
app.post('/api/batch-delete', async (req, res) => {
|
| 701 |
const { type, ids } = req.body;
|
| 702 |
+
if (type === 'student') await Student.deleteMany({ _id: { $in: ids } });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 703 |
if (type === 'score') await Score.deleteMany({ _id: { $in: ids } });
|
| 704 |
if (type === 'user') await User.deleteMany({ _id: { $in: ids } });
|
| 705 |
res.json({ success: true });
|
| 706 |
});
|
| 707 |
|
|
|
|
| 708 |
app.get('*', (req, res) => {
|
| 709 |
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
| 710 |
});
|
| 711 |
|
| 712 |
+
app.listen(PORT, () => console.log(`🚀 Service running on port ${PORT}`));
|
services/api.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
|
|
| 1 |
/// <reference types="vite/client" />
|
| 2 |
-
import { User, ClassInfo, SystemConfig, Subject, School, Schedule } from '../types';
|
| 3 |
|
| 4 |
const getBaseUrl = () => {
|
| 5 |
let isProd = false;
|
|
@@ -21,16 +22,13 @@ const API_BASE_URL = getBaseUrl();
|
|
| 21 |
async function request(endpoint: string, options: RequestInit = {}) {
|
| 22 |
const headers: any = { 'Content-Type': 'application/json', ...options.headers };
|
| 23 |
|
| 24 |
-
// Inject School Context
|
| 25 |
if (typeof window !== 'undefined') {
|
| 26 |
const currentUser = JSON.parse(localStorage.getItem('user') || 'null');
|
| 27 |
const selectedSchoolId = localStorage.getItem('admin_view_school_id');
|
| 28 |
|
| 29 |
-
// If admin has selected a specific school, use that
|
| 30 |
if (currentUser?.role === 'ADMIN' && selectedSchoolId) {
|
| 31 |
headers['x-school-id'] = selectedSchoolId;
|
| 32 |
} else if (currentUser?.schoolId) {
|
| 33 |
-
// Otherwise use user's own school
|
| 34 |
headers['x-school-id'] = currentUser.schoolId;
|
| 35 |
}
|
| 36 |
}
|
|
@@ -43,6 +41,7 @@ async function request(endpoint: string, options: RequestInit = {}) {
|
|
| 43 |
const errorMessage = errorData.error || errorData.message || `Server Error: ${res.status}`;
|
| 44 |
if (errorData.error === 'PENDING_APPROVAL') throw new Error('PENDING_APPROVAL');
|
| 45 |
if (errorData.error === 'BANNED') throw new Error('BANNED');
|
|
|
|
| 46 |
throw new Error(errorMessage);
|
| 47 |
}
|
| 48 |
return res.json();
|
|
@@ -56,7 +55,6 @@ export const api = {
|
|
| 56 |
const user = await request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
|
| 57 |
if (typeof window !== 'undefined') {
|
| 58 |
localStorage.setItem('user', JSON.stringify(user));
|
| 59 |
-
// Reset admin view on login
|
| 60 |
localStorage.removeItem('admin_view_school_id');
|
| 61 |
}
|
| 62 |
return user;
|
|
@@ -104,7 +102,7 @@ export const api = {
|
|
| 104 |
students: {
|
| 105 |
getAll: () => request('/students'),
|
| 106 |
add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
|
| 107 |
-
update: (id: string, data: any) => request(`/students/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 108 |
delete: (id: string | number) => request(`/students/${id}`, { method: 'DELETE' })
|
| 109 |
},
|
| 110 |
|
|
@@ -166,7 +164,21 @@ export const api = {
|
|
| 166 |
getAll: (userId: string, role: string) => request(`/notifications?userId=${userId}&role=${role}`),
|
| 167 |
},
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
|
| 170 |
return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
|
| 171 |
}
|
| 172 |
-
};
|
|
|
|
| 1 |
+
|
| 2 |
/// <reference types="vite/client" />
|
| 3 |
+
import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig } from '../types';
|
| 4 |
|
| 5 |
const getBaseUrl = () => {
|
| 6 |
let isProd = false;
|
|
|
|
| 22 |
async function request(endpoint: string, options: RequestInit = {}) {
|
| 23 |
const headers: any = { 'Content-Type': 'application/json', ...options.headers };
|
| 24 |
|
|
|
|
| 25 |
if (typeof window !== 'undefined') {
|
| 26 |
const currentUser = JSON.parse(localStorage.getItem('user') || 'null');
|
| 27 |
const selectedSchoolId = localStorage.getItem('admin_view_school_id');
|
| 28 |
|
|
|
|
| 29 |
if (currentUser?.role === 'ADMIN' && selectedSchoolId) {
|
| 30 |
headers['x-school-id'] = selectedSchoolId;
|
| 31 |
} else if (currentUser?.schoolId) {
|
|
|
|
| 32 |
headers['x-school-id'] = currentUser.schoolId;
|
| 33 |
}
|
| 34 |
}
|
|
|
|
| 41 |
const errorMessage = errorData.error || errorData.message || `Server Error: ${res.status}`;
|
| 42 |
if (errorData.error === 'PENDING_APPROVAL') throw new Error('PENDING_APPROVAL');
|
| 43 |
if (errorData.error === 'BANNED') throw new Error('BANNED');
|
| 44 |
+
if (errorData.error === 'CONFLICT') throw new Error(errorData.message); // Custom conflict error
|
| 45 |
throw new Error(errorMessage);
|
| 46 |
}
|
| 47 |
return res.json();
|
|
|
|
| 55 |
const user = await request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
|
| 56 |
if (typeof window !== 'undefined') {
|
| 57 |
localStorage.setItem('user', JSON.stringify(user));
|
|
|
|
| 58 |
localStorage.removeItem('admin_view_school_id');
|
| 59 |
}
|
| 60 |
return user;
|
|
|
|
| 102 |
students: {
|
| 103 |
getAll: () => request('/students'),
|
| 104 |
add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
|
| 105 |
+
update: (id: string, data: any) => request(`/students/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 106 |
delete: (id: string | number) => request(`/students/${id}`, { method: 'DELETE' })
|
| 107 |
},
|
| 108 |
|
|
|
|
| 164 |
getAll: (userId: string, role: string) => request(`/notifications?userId=${userId}&role=${role}`),
|
| 165 |
},
|
| 166 |
|
| 167 |
+
// --- NEW: Games & Rewards ---
|
| 168 |
+
games: {
|
| 169 |
+
getMountainSession: (className: string) => request(`/games/mountain?className=${className}`),
|
| 170 |
+
saveMountainSession: (data: GameSession) => request('/games/mountain', { method: 'POST', body: JSON.stringify(data) }),
|
| 171 |
+
getLuckyConfig: () => request('/games/lucky-config'),
|
| 172 |
+
saveLuckyConfig: (data: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(data) }),
|
| 173 |
+
},
|
| 174 |
+
rewards: {
|
| 175 |
+
getMyRewards: (studentId: string) => request(`/rewards?studentId=${studentId}`),
|
| 176 |
+
addReward: (data: Partial<StudentReward>) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
|
| 177 |
+
redeem: (id: string) => request(`/rewards/${id}/redeem`, { method: 'POST' }),
|
| 178 |
+
consumeDraw: (studentId: string) => request(`/rewards/consume-draw`, { method: 'POST', body: JSON.stringify({ studentId }) })
|
| 179 |
+
},
|
| 180 |
+
|
| 181 |
batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
|
| 182 |
return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
|
| 183 |
}
|
| 184 |
+
};
|
types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
export enum UserRole {
|
| 2 |
ADMIN = 'ADMIN',
|
| 3 |
TEACHER = 'TEACHER',
|
|
@@ -15,51 +16,49 @@ export interface School {
|
|
| 15 |
id?: number;
|
| 16 |
_id?: string;
|
| 17 |
name: string;
|
| 18 |
-
code: string;
|
| 19 |
}
|
| 20 |
|
| 21 |
export interface User {
|
| 22 |
id?: number;
|
| 23 |
-
_id?: string;
|
| 24 |
username: string;
|
| 25 |
-
trueName?: string;
|
| 26 |
-
phone?: string;
|
| 27 |
email?: string;
|
| 28 |
-
schoolId?: string;
|
| 29 |
role: UserRole;
|
| 30 |
status: UserStatus;
|
| 31 |
avatar?: string;
|
| 32 |
createTime?: string;
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
homeroomClass?: string; // e.g. "六年级(1)班" - Class Name string linkage
|
| 36 |
}
|
| 37 |
|
| 38 |
export interface ClassInfo {
|
| 39 |
id?: number;
|
| 40 |
_id?: string;
|
| 41 |
-
schoolId?: string;
|
| 42 |
grade: string;
|
| 43 |
-
className: string;
|
| 44 |
teacherName?: string;
|
| 45 |
-
studentCount?: number;
|
| 46 |
}
|
| 47 |
|
| 48 |
export interface Subject {
|
| 49 |
id?: number;
|
| 50 |
_id?: string;
|
| 51 |
-
schoolId?: string;
|
| 52 |
-
name: string;
|
| 53 |
-
code: string;
|
| 54 |
-
color: string;
|
| 55 |
-
excellenceThreshold?: number;
|
| 56 |
}
|
| 57 |
|
| 58 |
export interface SystemConfig {
|
| 59 |
-
// Global config, no schoolId
|
| 60 |
systemName: string;
|
| 61 |
-
semester: string;
|
| 62 |
-
semesters?: string[];
|
| 63 |
allowRegister: boolean;
|
| 64 |
allowAdminRegister: boolean;
|
| 65 |
maintenanceMode: boolean;
|
|
@@ -68,8 +67,8 @@ export interface SystemConfig {
|
|
| 68 |
|
| 69 |
export interface Student {
|
| 70 |
id?: number;
|
| 71 |
-
_id?: string;
|
| 72 |
-
schoolId?: string;
|
| 73 |
studentNo: string;
|
| 74 |
name: string;
|
| 75 |
gender: 'Male' | 'Female' | 'Other';
|
|
@@ -78,16 +77,18 @@ export interface Student {
|
|
| 78 |
phone: string;
|
| 79 |
className: string;
|
| 80 |
status: 'Enrolled' | 'Graduated' | 'Suspended';
|
| 81 |
-
// Extended info
|
| 82 |
parentName?: string;
|
| 83 |
parentPhone?: string;
|
| 84 |
address?: string;
|
|
|
|
|
|
|
|
|
|
| 85 |
}
|
| 86 |
|
| 87 |
export interface Course {
|
| 88 |
id?: number;
|
| 89 |
_id?: string;
|
| 90 |
-
schoolId?: string;
|
| 91 |
courseCode: string;
|
| 92 |
courseName: string;
|
| 93 |
teacherName: string;
|
|
@@ -101,23 +102,23 @@ export type ExamStatus = 'Normal' | 'Absent' | 'Leave' | 'Cheat';
|
|
| 101 |
export interface Score {
|
| 102 |
id?: number;
|
| 103 |
_id?: string;
|
| 104 |
-
schoolId?: string;
|
| 105 |
studentName: string;
|
| 106 |
studentNo: string;
|
| 107 |
courseName: string;
|
| 108 |
score: number;
|
| 109 |
semester: string;
|
| 110 |
type: 'Midterm' | 'Final' | 'Quiz';
|
| 111 |
-
examName?: string;
|
| 112 |
-
status?: ExamStatus;
|
| 113 |
}
|
| 114 |
|
| 115 |
export interface Exam {
|
| 116 |
id?: number;
|
| 117 |
_id?: string;
|
| 118 |
-
schoolId?: string;
|
| 119 |
-
name: string;
|
| 120 |
-
date: string;
|
| 121 |
semester?: string;
|
| 122 |
}
|
| 123 |
|
|
@@ -125,23 +126,23 @@ export interface Schedule {
|
|
| 125 |
id?: number;
|
| 126 |
_id?: string;
|
| 127 |
schoolId?: string;
|
| 128 |
-
className: string;
|
| 129 |
-
teacherName: string;
|
| 130 |
subject: string;
|
| 131 |
-
dayOfWeek: number;
|
| 132 |
-
period: number;
|
| 133 |
}
|
| 134 |
|
| 135 |
export interface Notification {
|
| 136 |
id?: number;
|
| 137 |
_id?: string;
|
| 138 |
schoolId?: string;
|
| 139 |
-
targetRole?: UserRole;
|
| 140 |
-
targetUserId?: string;
|
| 141 |
title: string;
|
| 142 |
content: string;
|
| 143 |
type: 'info' | 'success' | 'warning' | 'error';
|
| 144 |
-
isRead?: boolean;
|
| 145 |
createTime: string;
|
| 146 |
}
|
| 147 |
|
|
@@ -150,4 +151,53 @@ export interface ApiResponse<T> {
|
|
| 150 |
message: string;
|
| 151 |
data: T;
|
| 152 |
timestamp: number;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
}
|
|
|
|
| 1 |
+
|
| 2 |
export enum UserRole {
|
| 3 |
ADMIN = 'ADMIN',
|
| 4 |
TEACHER = 'TEACHER',
|
|
|
|
| 16 |
id?: number;
|
| 17 |
_id?: string;
|
| 18 |
name: string;
|
| 19 |
+
code: string;
|
| 20 |
}
|
| 21 |
|
| 22 |
export interface User {
|
| 23 |
id?: number;
|
| 24 |
+
_id?: string;
|
| 25 |
username: string;
|
| 26 |
+
trueName?: string;
|
| 27 |
+
phone?: string;
|
| 28 |
email?: string;
|
| 29 |
+
schoolId?: string;
|
| 30 |
role: UserRole;
|
| 31 |
status: UserStatus;
|
| 32 |
avatar?: string;
|
| 33 |
createTime?: string;
|
| 34 |
+
teachingSubject?: string;
|
| 35 |
+
homeroomClass?: string;
|
|
|
|
| 36 |
}
|
| 37 |
|
| 38 |
export interface ClassInfo {
|
| 39 |
id?: number;
|
| 40 |
_id?: string;
|
| 41 |
+
schoolId?: string;
|
| 42 |
grade: string;
|
| 43 |
+
className: string;
|
| 44 |
teacherName?: string;
|
| 45 |
+
studentCount?: number;
|
| 46 |
}
|
| 47 |
|
| 48 |
export interface Subject {
|
| 49 |
id?: number;
|
| 50 |
_id?: string;
|
| 51 |
+
schoolId?: string;
|
| 52 |
+
name: string;
|
| 53 |
+
code: string;
|
| 54 |
+
color: string;
|
| 55 |
+
excellenceThreshold?: number;
|
| 56 |
}
|
| 57 |
|
| 58 |
export interface SystemConfig {
|
|
|
|
| 59 |
systemName: string;
|
| 60 |
+
semester: string;
|
| 61 |
+
semesters?: string[];
|
| 62 |
allowRegister: boolean;
|
| 63 |
allowAdminRegister: boolean;
|
| 64 |
maintenanceMode: boolean;
|
|
|
|
| 67 |
|
| 68 |
export interface Student {
|
| 69 |
id?: number;
|
| 70 |
+
_id?: string;
|
| 71 |
+
schoolId?: string;
|
| 72 |
studentNo: string;
|
| 73 |
name: string;
|
| 74 |
gender: 'Male' | 'Female' | 'Other';
|
|
|
|
| 77 |
phone: string;
|
| 78 |
className: string;
|
| 79 |
status: 'Enrolled' | 'Graduated' | 'Suspended';
|
|
|
|
| 80 |
parentName?: string;
|
| 81 |
parentPhone?: string;
|
| 82 |
address?: string;
|
| 83 |
+
// Game related
|
| 84 |
+
teamId?: string; // For Mountain Game
|
| 85 |
+
drawAttempts?: number; // For Lucky Draw
|
| 86 |
}
|
| 87 |
|
| 88 |
export interface Course {
|
| 89 |
id?: number;
|
| 90 |
_id?: string;
|
| 91 |
+
schoolId?: string;
|
| 92 |
courseCode: string;
|
| 93 |
courseName: string;
|
| 94 |
teacherName: string;
|
|
|
|
| 102 |
export interface Score {
|
| 103 |
id?: number;
|
| 104 |
_id?: string;
|
| 105 |
+
schoolId?: string;
|
| 106 |
studentName: string;
|
| 107 |
studentNo: string;
|
| 108 |
courseName: string;
|
| 109 |
score: number;
|
| 110 |
semester: string;
|
| 111 |
type: 'Midterm' | 'Final' | 'Quiz';
|
| 112 |
+
examName?: string;
|
| 113 |
+
status?: ExamStatus;
|
| 114 |
}
|
| 115 |
|
| 116 |
export interface Exam {
|
| 117 |
id?: number;
|
| 118 |
_id?: string;
|
| 119 |
+
schoolId?: string;
|
| 120 |
+
name: string;
|
| 121 |
+
date: string;
|
| 122 |
semester?: string;
|
| 123 |
}
|
| 124 |
|
|
|
|
| 126 |
id?: number;
|
| 127 |
_id?: string;
|
| 128 |
schoolId?: string;
|
| 129 |
+
className: string;
|
| 130 |
+
teacherName: string;
|
| 131 |
subject: string;
|
| 132 |
+
dayOfWeek: number;
|
| 133 |
+
period: number;
|
| 134 |
}
|
| 135 |
|
| 136 |
export interface Notification {
|
| 137 |
id?: number;
|
| 138 |
_id?: string;
|
| 139 |
schoolId?: string;
|
| 140 |
+
targetRole?: UserRole;
|
| 141 |
+
targetUserId?: string;
|
| 142 |
title: string;
|
| 143 |
content: string;
|
| 144 |
type: 'info' | 'success' | 'warning' | 'error';
|
| 145 |
+
isRead?: boolean;
|
| 146 |
createTime: string;
|
| 147 |
}
|
| 148 |
|
|
|
|
| 151 |
message: string;
|
| 152 |
data: T;
|
| 153 |
timestamp: number;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
// --- Game Types ---
|
| 157 |
+
|
| 158 |
+
export interface GameTeam {
|
| 159 |
+
id: string;
|
| 160 |
+
name: string;
|
| 161 |
+
score: number;
|
| 162 |
+
avatar: string; // Emoji or URL
|
| 163 |
+
color: string;
|
| 164 |
+
members: string[]; // Student IDs
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
export interface GameRewardConfig {
|
| 168 |
+
scoreThreshold: number; // e.g., Reach 10 points
|
| 169 |
+
rewardType: 'ITEM' | 'DRAW_COUNT';
|
| 170 |
+
rewardName: string; // "Notebook" or "1 Draw"
|
| 171 |
+
rewardValue: number; // 1 (count)
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
export interface GameSession {
|
| 175 |
+
_id?: string;
|
| 176 |
+
schoolId: string;
|
| 177 |
+
className: string; // The class this game belongs to
|
| 178 |
+
subject: string; // The subject context (optional, or generic)
|
| 179 |
+
isEnabled: boolean;
|
| 180 |
+
teams: GameTeam[];
|
| 181 |
+
rewardsConfig: GameRewardConfig[];
|
| 182 |
+
maxSteps: number; // For mountain height
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
export interface StudentReward {
|
| 186 |
+
_id?: string;
|
| 187 |
+
schoolId?: string;
|
| 188 |
+
studentId: string; // Link to Student
|
| 189 |
+
studentName: string;
|
| 190 |
+
rewardType: 'ITEM' | 'DRAW_COUNT';
|
| 191 |
+
name: string;
|
| 192 |
+
status: 'PENDING' | 'REDEEMED';
|
| 193 |
+
source: string; // "Mountain Game"
|
| 194 |
+
createTime: string;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
export interface LuckyDrawConfig {
|
| 198 |
+
_id?: string;
|
| 199 |
+
schoolId: string;
|
| 200 |
+
prizes: string[]; // List of prize names
|
| 201 |
+
dailyLimit: number;
|
| 202 |
+
defaultPrize: string; // "Thank you"
|
| 203 |
}
|