dvc890 commited on
Commit
e52e99f
·
verified ·
1 Parent(s): cdd802a

Upload 29 files

Browse files
Files changed (10) hide show
  1. App.tsx +9 -26
  2. components/Sidebar.tsx +8 -7
  3. metadata.json +1 -1
  4. pages/Dashboard.tsx +34 -82
  5. pages/Games.tsx +333 -0
  6. pages/Reports.tsx +173 -396
  7. pages/StudentDashboard.tsx +107 -0
  8. server.js +188 -205
  9. services/api.ts +19 -7
  10. 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 for performance optimization
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, UserRole.STUDENT] },
22
- { id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
23
- // Update: Allow TEACHER to access reports
24
- { id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN, UserRole.TEACHER] },
25
- // Update: Allow TEACHER to access subjects (limited view)
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": "Copy of 智慧校园管理系统",
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, ArrowRight, Activity, Calendar, X, CheckCircle, Plus, Trash2 } from 'lucide-react';
4
  import { api } from '../services/api';
5
- import { Score, ClassInfo, Subject, Schedule, User, SystemConfig } from '../types';
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
- const targetClassName = editForm.className;
152
-
153
- await api.schedules.save({
154
- className: targetClassName,
155
- dayOfWeek: editingCell.day,
156
- period: editingCell.period,
157
- subject: editForm.subject,
158
- teacherName: editForm.teacherName
159
- });
160
- setEditingCell(null);
161
- fetchSchedules();
 
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, PieChart as RePieChart, Pie, Cell, 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 } 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 [activeTab, setActiveTab] = useState<'overview' | 'grade' | 'trend' | 'matrix' | 'student'>('overview');
 
 
 
 
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>(''); // For Trend/Student
31
- const [selectedSubject, setSelectedSubject] = useState<string>(''); // For Trend
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
- // --- 0. Overview Calculations ---
84
- const normalScores = scores.filter(s => s.status === 'Normal');
85
- const totalAvg = normalScores.length ? normalScores.reduce((a,b)=>a+b.score,0)/normalScores.length : 0;
86
- const totalPass = normalScores.filter(s => s.score >= 60).length;
87
- const passRate = normalScores.length ? (totalPass / normalScores.length)*100 : 0;
88
-
89
- // Grade Ladder (Avg score by grade)
90
- const uniqueGradesList = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
91
- const ladderData = uniqueGradesList.map(g => {
92
- const gradeClasses = classes.filter(c => c.grade === g).map(c => c.grade+c.className);
93
- const gradeStus = students.filter(s => gradeClasses.includes(s.className)).map(s => s.studentNo);
94
- const gradeScores = normalScores.filter(s => gradeStus.includes(s.studentNo));
95
- const gAvg = gradeScores.length ? gradeScores.reduce((a,b)=>a+b.score,0)/gradeScores.length : 0;
96
- return { name: g, 平均分: Number(gAvg.toFixed(1)) };
97
- });
98
-
99
- // Subject Distribution
100
- const subDist = subjects.map(sub => {
101
- const subScores = normalScores.filter(s => s.courseName === sub.name);
102
- const sAvg = subScores.length ? subScores.reduce((a,b)=>a+b.score,0)/subScores.length : 0;
103
- return { name: sub.name, value: Number(sAvg.toFixed(1)), color: sub.color };
104
- });
105
-
106
- setOverviewData({
107
- totalStudents: students.length,
108
- avgScore: Number(totalAvg.toFixed(1)),
109
- passRate: Number(passRate.toFixed(1)),
110
- gradeLadder: ladderData,
111
- subjectDist: subDist
112
- });
113
-
114
-
115
- // --- 1. Grade Analysis (Horizontal Class Comparison) ---
116
- const gradeClasses = classes.filter(c => c.grade === selectedGrade);
117
- const gaData = gradeClasses.map(cls => {
118
- const fullClassName = cls.grade + cls.className;
119
- const classStudentIds = students.filter(s => s.className === fullClassName).map(s => s.studentNo);
120
-
121
- const classScores = scores.filter(s => classStudentIds.includes(s.studentNo) && s.status === 'Normal');
122
-
123
- const totalScore = classScores.reduce((sum, s) => sum + s.score, 0);
124
- const avg = classScores.length ? (totalScore / classScores.length) : 0;
125
-
126
- const passed = classScores.filter(s => s.score >= 60).length;
127
- const passRate = classScores.length ? (passed / classScores.length) * 100 : 0;
128
-
129
- const excellent = classScores.filter(s => {
130
- // Find subject threshold
131
- const sub = subjects.find(sub => sub.name === s.courseName);
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
- // --- 2. Trend Analysis (Time/Exam based) ---
149
- // Group by Exam Name or Type. Sort using Exam Dates if available.
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
- ? gradeExamScores.reduce((a,b)=>a+b.score,0) / gradeExamScores.length
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 List for Focus Tab
272
- const focusStudents = students.filter(s => selectedClass ? s.className === selectedClass : true);
 
 
 
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 flex-col md:flex-row justify-between items-center gap-4">
279
  <div>
280
- <h2 className="text-xl font-bold text-gray-800">教务数据分析中心</h2>
281
- <p className="text-sm text-gray-500">基于 {scores.length} 条成绩数据分析</p>
282
- </div>
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
- {/* Filter Bar */}
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 focus:ring-2 focus:ring-blue-500" value={selectedGrade} onChange={e => setSelectedGrade(e.target.value)}>
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
- {/* --- 0. Overview View --- */}
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
- <linearGradient id="colorAvg" x1="0" y1="0" x2="0" y2="1">
368
- <stop offset="5%" stopColor="#8884d8" stopOpacity={0.8}/>
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
- {/* --- 1. Grade Analysis View --- */}
402
- {activeTab === 'grade' && (
403
- <div className="space-y-10 animate-in fade-in">
404
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
405
- <div className="bg-white p-4 rounded-lg border border-gray-100">
406
- <h3 className="font-bold text-gray-800 mb-4 flex items-center">
407
- <span className="w-1 h-6 bg-blue-500 mr-2 rounded-full"></span>
408
- {selectedGrade} - 各班平均分对比
409
- </h3>
410
- <div className="h-80">
411
- <ResponsiveContainer width="100%" height="100%">
412
- <BarChart data={gradeAnalysisData} layout="vertical" margin={{ left: 20 }}>
413
- <CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false}/>
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
- {/* --- 4. Student Focus View --- */}
508
  {activeTab === 'student' && (
509
  <div className="animate-in fade-in">
510
- <h3 className="font-bold text-gray-800 mb-4">{selectedClass} - 学生个像分析</h3>
511
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
512
- {focusStudents.map(s => {
513
- const trend = getStudentTrend(s.studentNo);
514
- return (
515
- <div
516
- key={s._id || s.id}
517
- onClick={() => setSelectedStudent(s)}
518
- className="bg-white border border-gray-200 p-4 rounded-xl cursor-pointer hover:shadow-lg hover:border-blue-400 transition-all group"
519
- >
520
- <div className="flex items-center space-x-3 mb-3">
521
- <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'}`}>
522
- {s.name[0]}
523
- </div>
524
- <div>
525
- <h4 className="font-bold text-gray-800 group-hover:text-blue-600">{s.name}</h4>
526
- <p className="text-xs text-gray-500 font-mono">{s.studentNo}</p>
527
- </div>
528
- </div>
529
- {/* Mini Sparkline */}
530
- <div className="h-16 w-full">
531
- <ResponsiveContainer width="100%" height="100%">
532
- <LineChart data={trend}>
533
- <Line type="monotone" dataKey="score" stroke="#94a3b8" strokeWidth={2} dot={false} />
534
- </LineChart>
535
- </ResponsiveContainer>
536
- </div>
537
- </div>
538
- );
539
- })}
540
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  </div>
542
  )}
543
  </div>
544
 
545
- {/* Student Details Modal */}
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
- {/* Radar */}
578
- <div className="bg-white border border-gray-100 rounded-xl p-4 shadow-sm">
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' })); // Increased limit for avatar base64
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, // e.g. "数学"
104
- teacherName: String, // e.g. "张三"
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, // 1-5
155
- period: Number // 1-8
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], // Array of semester strings
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, // 'ADMIN' or null for all
177
- targetUserId: String, // Specific user
178
  title: String,
179
  content: String,
180
- type: { type: String, default: 'info' }, // info, success, warning
181
  createTime: { type: Date, default: Date.now },
182
- expiresAt: { type: Date, default: () => new Date(+new Date() + 30*24*60*60*1000) } // 30 days TTL
183
  });
184
- NotificationSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); // Auto delete
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, // Default
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
- console.log('🔄 Checking database initialization...');
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
- defaultSchool = await School.create({ name: '第一实验小学', code: 'EXP01' });
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
- password: 'admin',
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
- systemName: '智慧校园管理系统',
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; // Passed from frontend current user context
305
-
306
  if (InMemoryDB.isFallback) return res.json(InMemoryDB.notifications);
307
-
308
- const query = {
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
- const schools = await School.find({}, 'name code _id');
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
- // Global config
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
- if (InMemoryDB.isFallback) {
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
- if (InMemoryDB.isFallback) {
357
- user = InMemoryDB.users.find(u => u.username === username && u.password === password);
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
- const newUser = await User.create({
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; // Add role filter support
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
- const users = await User.find(filter).sort({ createTime: -1 });
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], ...updateData };
445
  return res.json({ success: true });
446
  }
447
-
448
- const user = await User.findByIdAndUpdate(req.params.id, updateData, { new: true });
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); // Format like "六年级(1)班"
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) return res.json({});
 
 
 
 
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
- const schoolId = req.headers['x-school-id'];
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) }), // NEW
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; // unique identifier
19
  }
20
 
21
  export interface User {
22
  id?: number;
23
- _id?: string; // MongoDB ID
24
  username: string;
25
- trueName?: string; // New: Real Name
26
- phone?: string; // New: Phone
27
  email?: string;
28
- schoolId?: string; // New: Multi-tenancy
29
  role: UserRole;
30
  status: UserStatus;
31
  avatar?: string;
32
  createTime?: string;
33
- // Teacher Specific
34
- teachingSubject?: string; // e.g. "语文"
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; // New
42
  grade: string;
43
- className: string; // e.g. "(1)班"
44
  teacherName?: string;
45
- studentCount?: number; // Calculated field
46
  }
47
 
48
  export interface Subject {
49
  id?: number;
50
  _id?: string;
51
- schoolId?: string; // New
52
- name: string; // e.g. "语文"
53
- code: string; // e.g. "CHI"
54
- color: string; // Hex color for charts
55
- excellenceThreshold?: number; // Score required to be considered "Excellent" (default 90)
56
  }
57
 
58
  export interface SystemConfig {
59
- // Global config, no schoolId
60
  systemName: string;
61
- semester: string; // Current active semester
62
- semesters?: string[]; // List of available semesters
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; // MongoDB ID
72
- schoolId?: string; // New
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; // New
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; // New
105
  studentName: string;
106
  studentNo: string;
107
  courseName: string;
108
  score: number;
109
  semester: string;
110
  type: 'Midterm' | 'Final' | 'Quiz';
111
- examName?: string; // e.g. "期中限时练习"
112
- status?: ExamStatus; // Default: Normal
113
  }
114
 
115
  export interface Exam {
116
  id?: number;
117
  _id?: string;
118
- schoolId?: string; // New
119
- name: string; // e.g. "期中考试"
120
- date: string; // e.g. "2023-11-01"
121
  semester?: string;
122
  }
123
 
@@ -125,23 +126,23 @@ export interface Schedule {
125
  id?: number;
126
  _id?: string;
127
  schoolId?: string;
128
- className: string; // Linked to ClassInfo
129
- teacherName: string; // Linked to User
130
  subject: string;
131
- dayOfWeek: number; // 1 (Mon) - 5 (Fri)
132
- period: number; // 1 - 8
133
  }
134
 
135
  export interface Notification {
136
  id?: number;
137
  _id?: string;
138
  schoolId?: string;
139
- targetRole?: UserRole; // If set, only users with this role see it (e.g. ADMIN)
140
- targetUserId?: string; // If set, only this specific user sees it
141
  title: string;
142
  content: string;
143
  type: 'info' | 'success' | 'warning' | 'error';
144
- isRead?: boolean; // Frontend state mostly, backend can store read status per user but simpler to just show recent
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
  }