dvc890 commited on
Commit
7d2c997
·
verified ·
1 Parent(s): ac199b0

Upload 54 files

Browse files
Files changed (4) hide show
  1. App.tsx +7 -2
  2. components/LiveVoiceFab.tsx +370 -0
  3. server.js +21 -542
  4. services/api.ts +170 -227
App.tsx CHANGED
@@ -1,3 +1,4 @@
 
1
  import React, { useState, useEffect, Suspense } from 'react';
2
  import { Sidebar } from './components/Sidebar';
3
  import { Header } from './components/Header';
@@ -5,6 +6,7 @@ import { Login } from './pages/Login';
5
  import { User, UserRole } from './types';
6
  import { api } from './services/api';
7
  import { AlertTriangle, Loader2 } from 'lucide-react';
 
8
 
9
  // --- Lazy Load Helper with Retry Strategy ---
10
  // This wrapper catches chunk loading errors (common after deployments) and reloads the page once.
@@ -196,13 +198,16 @@ const AppContent: React.FC = () => {
196
  onClose={() => setSidebarOpen(false)}
197
  />
198
 
199
- <div className="flex-1 flex flex-col w-full">
200
  <Header user={currentUser!} title={viewTitles[currentView] || '智慧校园'} onMenuClick={() => setSidebarOpen(true)}/>
201
  <main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-50 p-4 md:p-6 w-full">
202
  <div className="max-w-7xl mx-auto w-full h-full">
203
  <Suspense fallback={<PageLoading />}>{renderContent()}</Suspense>
204
  </div>
205
  </main>
 
 
 
206
  </div>
207
  </div>
208
  );
@@ -216,4 +221,4 @@ const App: React.FC = () => {
216
  );
217
  };
218
 
219
- export default App;
 
1
+
2
  import React, { useState, useEffect, Suspense } from 'react';
3
  import { Sidebar } from './components/Sidebar';
4
  import { Header } from './components/Header';
 
6
  import { User, UserRole } from './types';
7
  import { api } from './services/api';
8
  import { AlertTriangle, Loader2 } from 'lucide-react';
9
+ import { LiveVoiceFab } from './components/LiveVoiceFab'; // Import
10
 
11
  // --- Lazy Load Helper with Retry Strategy ---
12
  // This wrapper catches chunk loading errors (common after deployments) and reloads the page once.
 
198
  onClose={() => setSidebarOpen(false)}
199
  />
200
 
201
+ <div className="flex-1 flex flex-col w-full relative">
202
  <Header user={currentUser!} title={viewTitles[currentView] || '智慧校园'} onMenuClick={() => setSidebarOpen(true)}/>
203
  <main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-50 p-4 md:p-6 w-full">
204
  <div className="max-w-7xl mx-auto w-full h-full">
205
  <Suspense fallback={<PageLoading />}>{renderContent()}</Suspense>
206
  </div>
207
  </main>
208
+
209
+ {/* Global AI Voice Assistant FAB */}
210
+ <LiveVoiceFab />
211
  </div>
212
  </div>
213
  );
 
221
  );
222
  };
223
 
224
+ export default App;
components/LiveVoiceFab.tsx ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useRef, useEffect } from 'react';
3
+ import { Bot, Mic, X, MessageSquare, Loader2, Volume2, Power, Minimize2 } from 'lucide-react';
4
+ import { GoogleGenAI, LiveServerMessage, Modality } from '@google/genai';
5
+ import { api } from '../services/api';
6
+ import { Toast } from './Toast';
7
+
8
+ // --- Audio Utils (Specific for Live API PCM) ---
9
+ const decodeAudioData = async (
10
+ base64String: string,
11
+ ctx: AudioContext,
12
+ sampleRate: number = 24000
13
+ ): Promise<AudioBuffer> => {
14
+ const binaryString = atob(base64String);
15
+ const len = binaryString.length;
16
+ const bytes = new Uint8Array(len);
17
+ for (let i = 0; i < len; i++) {
18
+ bytes[i] = binaryString.charCodeAt(i);
19
+ }
20
+
21
+ // Convert Int16 PCM to Float32
22
+ const int16Data = new Int16Array(bytes.buffer);
23
+ const float32Data = new Float32Array(int16Data.length);
24
+ for (let i = 0; i < int16Data.length; i++) {
25
+ float32Data[i] = int16Data[i] / 32768.0;
26
+ }
27
+
28
+ const buffer = ctx.createBuffer(1, float32Data.length, sampleRate);
29
+ buffer.copyToChannel(float32Data, 0);
30
+ return buffer;
31
+ };
32
+
33
+ const createPCMBlob = (data: Float32Array): { data: string, mimeType: string } => {
34
+ const l = data.length;
35
+ const int16 = new Int16Array(l);
36
+ for (let i = 0; i < l; i++) {
37
+ int16[i] = Math.max(-1, Math.min(1, data[i])) * 32768;
38
+ }
39
+ const bytes = new Uint8Array(int16.buffer);
40
+ let binary = '';
41
+ const len = bytes.byteLength;
42
+ for (let i = 0; i < len; i++) {
43
+ binary += String.fromCharCode(bytes[i]);
44
+ }
45
+ return {
46
+ data: btoa(binary),
47
+ mimeType: 'audio/pcm;rate=16000', // Client input is usually 16k
48
+ };
49
+ };
50
+
51
+ interface ChatMessage {
52
+ id: string;
53
+ role: 'user' | 'model';
54
+ text: string;
55
+ }
56
+
57
+ export const LiveVoiceFab: React.FC = () => {
58
+ const [isOpen, setIsOpen] = useState(false);
59
+ const [isConnected, setIsConnected] = useState(false);
60
+ const [isRecording, setIsRecording] = useState(false);
61
+ const [isAiSpeaking, setIsAiSpeaking] = useState(false);
62
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
63
+ const [error, setError] = useState<string | null>(null);
64
+
65
+ // Refs for Audio & Session
66
+ const sessionRef = useRef<any>(null);
67
+ const audioContextRef = useRef<AudioContext | null>(null);
68
+ const inputContextRef = useRef<AudioContext | null>(null);
69
+ const nextStartTimeRef = useRef<number>(0);
70
+ const sourceNodeRef = useRef<MediaStreamAudioSourceNode | null>(null);
71
+ const processorRef = useRef<ScriptProcessorNode | null>(null);
72
+ const activeSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
73
+ const messagesEndRef = useRef<HTMLDivElement>(null);
74
+
75
+ const currentUser = api.auth.getCurrentUser();
76
+ const hasAccess = currentUser?.aiAccess || currentUser?.role === 'ADMIN';
77
+
78
+ useEffect(() => {
79
+ if (isOpen && !audioContextRef.current) {
80
+ // @ts-ignore
81
+ const AudioCtor = window.AudioContext || window.webkitAudioContext;
82
+ audioContextRef.current = new AudioCtor({ sampleRate: 24000 });
83
+ inputContextRef.current = new AudioCtor({ sampleRate: 16000 });
84
+ }
85
+ // Scroll to bottom
86
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
87
+ }, [isOpen, messages]);
88
+
89
+ // Clean up on unmount
90
+ useEffect(() => {
91
+ return () => {
92
+ disconnectSession();
93
+ };
94
+ }, []);
95
+
96
+ const connectSession = async () => {
97
+ setError(null);
98
+ try {
99
+ const { key } = await api.ai.getKey();
100
+ if (!key) throw new Error("无法获取 AI 配置");
101
+
102
+ const ai = new GoogleGenAI({ apiKey: key });
103
+
104
+ // Use the model mentioned (mapped to the standard preview)
105
+ const model = 'gemini-2.5-flash-native-audio-preview-09-2025';
106
+
107
+ const session = await ai.live.connect({
108
+ model,
109
+ config: {
110
+ responseModalities: [Modality.AUDIO],
111
+ speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } },
112
+ systemInstruction: { parts: [{ text: "你是一位友善的校园助手。请用简短、口语化的中文回答。请在回答中包含文字转录。" }] },
113
+ outputAudioTranscription: { } // Enable text output
114
+ },
115
+ });
116
+
117
+ sessionRef.current = session;
118
+ setIsConnected(true);
119
+
120
+ // Welcome message
121
+ setMessages(prev => [...prev, { id: 'sys-start', role: 'model', text: '已连接!按住按钮说话。' }]);
122
+
123
+ // Listen for messages
124
+ receiveLoop(session);
125
+
126
+ } catch (e: any) {
127
+ console.error(e);
128
+ setError(e.message || "连接失败");
129
+ setIsConnected(false);
130
+ }
131
+ };
132
+
133
+ const disconnectSession = async () => {
134
+ if (sessionRef.current) {
135
+ // Typically session closure is handled by the server or object cleanup
136
+ sessionRef.current = null;
137
+ }
138
+ setIsConnected(false);
139
+ setIsRecording(false);
140
+
141
+ // Stop audio input
142
+ if (sourceNodeRef.current) sourceNodeRef.current.disconnect();
143
+ if (processorRef.current) processorRef.current.disconnect();
144
+
145
+ // Stop audio output
146
+ activeSourcesRef.current.forEach(s => s.stop());
147
+ activeSourcesRef.current.clear();
148
+ };
149
+
150
+ const receiveLoop = async (session: any) => {
151
+ try {
152
+ for await (const msg of session.receive()) {
153
+ const message = msg as LiveServerMessage;
154
+
155
+ // 1. Handle Text (Transcription)
156
+ const transcript = message.serverContent?.modelTurn?.parts?.find(p => p.text)?.text;
157
+ if (transcript) {
158
+ setMessages(prev => {
159
+ const last = prev[prev.length - 1];
160
+ if (last && last.role === 'model' && !last.text.endsWith('\n')) {
161
+ // Append to streaming message
162
+ return [...prev.slice(0, -1), { ...last, text: last.text + transcript }];
163
+ }
164
+ return [...prev, { id: Date.now().toString(), role: 'model', text: transcript }];
165
+ });
166
+ }
167
+
168
+ // 2. Handle User Transcription (Echo)
169
+ if (message.serverContent?.interrupted) {
170
+ // Clear queue if interrupted
171
+ activeSourcesRef.current.forEach(s => s.stop());
172
+ activeSourcesRef.current.clear();
173
+ nextStartTimeRef.current = 0;
174
+ setIsAiSpeaking(false);
175
+ }
176
+
177
+ // 3. Handle Audio Output
178
+ const audioData = message.serverContent?.modelTurn?.parts?.find(p => p.inlineData)?.inlineData?.data;
179
+ if (audioData && audioContextRef.current) {
180
+ setIsAiSpeaking(true);
181
+ const ctx = audioContextRef.current;
182
+ const buffer = await decodeAudioData(audioData, ctx);
183
+
184
+ const source = ctx.createBufferSource();
185
+ source.buffer = buffer;
186
+ source.connect(ctx.destination);
187
+
188
+ // Scheduling
189
+ const currentTime = ctx.currentTime;
190
+ if (nextStartTimeRef.current < currentTime) {
191
+ nextStartTimeRef.current = currentTime;
192
+ }
193
+ source.start(nextStartTimeRef.current);
194
+ nextStartTimeRef.current += buffer.duration;
195
+
196
+ activeSourcesRef.current.add(source);
197
+ source.onended = () => {
198
+ activeSourcesRef.current.delete(source);
199
+ if (activeSourcesRef.current.size === 0) setIsAiSpeaking(false);
200
+ };
201
+ }
202
+ }
203
+ } catch (e) {
204
+ console.log("Session ended or error", e);
205
+ setIsConnected(false);
206
+ }
207
+ };
208
+
209
+ const startRecording = async () => {
210
+ if (!isConnected || !sessionRef.current || !inputContextRef.current) return;
211
+ setIsRecording(true);
212
+
213
+ // Interrupt AI if speaking
214
+ // We can send an empty text to interrupt or rely on VAD, but explicit clear is safer for UI
215
+ activeSourcesRef.current.forEach(s => s.stop());
216
+ activeSourcesRef.current.clear();
217
+ nextStartTimeRef.current = 0;
218
+ setIsAiSpeaking(false);
219
+
220
+ try {
221
+ const ctx = inputContextRef.current;
222
+ if (ctx.state === 'suspended') await ctx.resume();
223
+
224
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: {
225
+ sampleRate: 16000,
226
+ channelCount: 1,
227
+ echoCancellation: true
228
+ }});
229
+
230
+ const source = ctx.createMediaStreamSource(stream);
231
+ // Using ScriptProcessor for raw PCM access (AudioWorklet is better but more complex to setup in a single file)
232
+ const processor = ctx.createScriptProcessor(4096, 1, 1);
233
+
234
+ processor.onaudioprocess = (e) => {
235
+ if (!sessionRef.current) return;
236
+ const inputData = e.inputBuffer.getChannelData(0);
237
+ const pcmBlob = createPCMBlob(inputData);
238
+ sessionRef.current.sendRealtimeInput({ media: pcmBlob });
239
+ };
240
+
241
+ source.connect(processor);
242
+ processor.connect(ctx.destination); // Required for script processor to run
243
+
244
+ sourceNodeRef.current = source;
245
+ processorRef.current = processor;
246
+
247
+ } catch (e) {
248
+ console.error("Mic error", e);
249
+ setError("无法访问麦克风");
250
+ setIsRecording(false);
251
+ }
252
+ };
253
+
254
+ const stopRecording = () => {
255
+ setIsRecording(false);
256
+ if (sourceNodeRef.current) {
257
+ sourceNodeRef.current.disconnect();
258
+ sourceNodeRef.current = null;
259
+ }
260
+ if (processorRef.current) {
261
+ processorRef.current.disconnect();
262
+ processorRef.current = null;
263
+ }
264
+ // Note: We don't explicitly send "End of Turn", the model infers it from silence/VAD in Live API usually,
265
+ // but stopping the stream is sufficient.
266
+ };
267
+
268
+ if (!hasAccess) return null;
269
+
270
+ return (
271
+ <>
272
+ {/* Floating Button */}
273
+ <button
274
+ onClick={() => {
275
+ setIsOpen(!isOpen);
276
+ if (!isOpen && !isConnected) connectSession();
277
+ }}
278
+ className={`fixed bottom-6 right-6 z-[9990] w-14 h-14 rounded-full shadow-2xl flex items-center justify-center transition-all hover:scale-110 active:scale-95 ${isOpen ? 'bg-red-500 rotate-45' : 'bg-gradient-to-tr from-blue-600 to-indigo-600'}`}
279
+ >
280
+ {isOpen ? <X color="white" size={24}/> : <Bot color="white" size={28}/>}
281
+ </button>
282
+
283
+ {/* Floating Window */}
284
+ {isOpen && (
285
+ <div className="fixed bottom-24 right-6 z-[9990] w-80 md:w-96 bg-white rounded-2xl shadow-2xl border border-gray-100 flex flex-col overflow-hidden animate-in slide-in-from-bottom-10 fade-in duration-200" style={{height: '500px'}}>
286
+
287
+ {/* Header */}
288
+ <div className="p-4 bg-gradient-to-r from-blue-600 to-indigo-600 flex justify-between items-center shrink-0">
289
+ <div className="flex items-center gap-2 text-white">
290
+ <div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-400 animate-pulse' : 'bg-red-400'}`}></div>
291
+ <span className="font-bold text-sm">AI 实时语音 (Live)</span>
292
+ </div>
293
+ <div className="flex gap-2">
294
+ <button onClick={disconnectSession} className="text-white/80 hover:text-white" title="重连/刷新">
295
+ <Power size={16}/>
296
+ </button>
297
+ <button onClick={() => setIsOpen(false)} className="text-white/80 hover:text-white">
298
+ <Minimize2 size={16}/>
299
+ </button>
300
+ </div>
301
+ </div>
302
+
303
+ {/* Chat Body */}
304
+ <div className="flex-1 bg-gray-50 overflow-y-auto p-4 space-y-3 custom-scrollbar">
305
+ {messages.length === 0 && isConnected && (
306
+ <div className="text-center text-gray-400 text-xs mt-10">
307
+ <p>已连接 Gemini Native Audio Dialog</p>
308
+ <p>按住下方按钮开始对话</p>
309
+ </div>
310
+ )}
311
+ {!isConnected && !error && (
312
+ <div className="flex flex-col items-center justify-center h-full text-gray-400 gap-2">
313
+ <Loader2 className="animate-spin text-blue-500" size={24}/>
314
+ <span className="text-xs">正在建立加密连接...</span>
315
+ </div>
316
+ )}
317
+ {error && (
318
+ <div className="bg-red-50 text-red-600 p-3 rounded-lg text-xs text-center border border-red-100">
319
+ {error}
320
+ <button onClick={connectSession} className="block mx-auto mt-2 text-blue-600 underline">重试</button>
321
+ </div>
322
+ )}
323
+ {messages.map((msg, idx) => (
324
+ <div key={idx} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
325
+ <div className={`max-w-[85%] px-3 py-2 rounded-xl text-sm ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-white border border-gray-200 text-gray-800 rounded-tl-none shadow-sm'}`}>
326
+ {msg.text}
327
+ </div>
328
+ </div>
329
+ ))}
330
+ {isAiSpeaking && (
331
+ <div className="flex justify-start">
332
+ <div className="bg-white border border-gray-200 px-3 py-2 rounded-xl rounded-tl-none shadow-sm flex items-center gap-1 text-blue-600">
333
+ <Volume2 size={14} className="animate-pulse"/>
334
+ <div className="flex gap-0.5 items-end h-3">
335
+ <div className="w-0.5 bg-blue-500 h-1 animate-[bounce_1s_infinite]"></div>
336
+ <div className="w-0.5 bg-blue-500 h-2 animate-[bounce_1.2s_infinite]"></div>
337
+ <div className="w-0.5 bg-blue-500 h-3 animate-[bounce_0.8s_infinite]"></div>
338
+ </div>
339
+ </div>
340
+ </div>
341
+ )}
342
+ <div ref={messagesEndRef}></div>
343
+ </div>
344
+
345
+ {/* Controls */}
346
+ <div className="p-4 bg-white border-t border-gray-100 shrink-0 flex flex-col items-center gap-2">
347
+ <button
348
+ disabled={!isConnected}
349
+ onMouseDown={startRecording}
350
+ onMouseUp={stopRecording}
351
+ onMouseLeave={stopRecording}
352
+ onTouchStart={startRecording}
353
+ onTouchEnd={stopRecording}
354
+ className={`w-full py-3 rounded-full font-bold text-white shadow-lg transition-all transform active:scale-95 flex items-center justify-center gap-2 ${
355
+ isRecording
356
+ ? 'bg-red-500 scale-105 ring-4 ring-red-100'
357
+ : isConnected ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-300 cursor-not-allowed'
358
+ }`}
359
+ >
360
+ {isRecording ? <><div className="w-3 h-3 bg-white rounded-full animate-ping"></div> 松开发送</> : <><Mic size={18}/> 按住说话</>}
361
+ </button>
362
+ <div className="text-[10px] text-gray-400">
363
+ Model: gemini-2.5-flash-native-audio
364
+ </div>
365
+ </div>
366
+ </div>
367
+ )}
368
+ </>
369
+ );
370
+ };
server.js CHANGED
@@ -1,548 +1,27 @@
1
 
2
- const {
3
- School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
4
- ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
5
- AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
6
- WishModel, FeedbackModel, TodoModel
7
- } = require('./models');
8
-
9
- // Import AI Routes
10
- const aiRoutes = require('./ai-routes');
11
-
12
- const express = require('express');
13
- const mongoose = require('mongoose');
14
- const cors = require('cors');
15
- const bodyParser = require('body-parser');
16
- const path = require('path');
17
- const compression = require('compression');
18
-
19
- const PORT = 7860;
20
- const MONGO_URI = 'mongodb+srv://dv890a:db8822723@chatpro.gw3v0v7.mongodb.net/chatpro?retryWrites=true&w=majority&appName=chatpro&authSource=admin';
21
-
22
- const app = express();
23
-
24
- app.use(compression({
25
- filter: (req, res) => {
26
- if (req.originalUrl && req.originalUrl.includes('/api/ai/chat')) {
27
- return false;
28
- }
29
- return compression.filter(req, res);
30
- }
31
- }));
32
-
33
- app.use(cors());
34
- app.use(bodyParser.json({ limit: '50mb' }));
35
- app.use(express.static(path.join(__dirname, 'dist'), {
36
- setHeaders: (res, filePath) => {
37
- if (filePath.endsWith('.html')) {
38
- res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
39
- } else {
40
- res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
41
- }
42
- }
43
- }));
44
-
45
- const InMemoryDB = { schools: [], users: [], isFallback: false };
46
- const connectDB = async () => {
47
- try {
48
- await mongoose.connect(MONGO_URI, { serverSelectionTimeoutMS: 30000 });
49
- console.log('✅ MongoDB 连接成功 (Real Data)');
50
-
51
- // FIX: Drop the restrictive index that prevents multiple schedules per slot
52
- try {
53
- await ScheduleModel.collection.dropIndex('schoolId_1_className_1_dayOfWeek_1_period_1');
54
- console.log('✅ Dropped restrictive schedule index');
55
- } catch (e) {
56
- // Ignore error if index doesn't exist
57
- }
58
-
59
- } catch (err) {
60
- console.error('❌ MongoDB 连接失败:', err.message);
61
- InMemoryDB.isFallback = true;
62
- }
63
- };
64
- connectDB();
65
-
66
- const getQueryFilter = (req) => {
67
- const s = req.headers['x-school-id'];
68
- const role = req.headers['x-user-role'];
69
- if (role === 'PRINCIPAL') {
70
- if (!s) return { _id: null };
71
- return { schoolId: s };
72
- }
73
- if (!s) return {};
74
- return {
75
- $or: [
76
- { schoolId: s },
77
- { schoolId: { $exists: false } },
78
- { schoolId: null }
79
- ]
80
- };
81
- };
82
- const injectSchoolId = (req, b) => ({ ...b, schoolId: req.headers['x-school-id'] });
83
-
84
- const getGameOwnerFilter = async (req) => {
85
- const role = req.headers['x-user-role'];
86
- const username = req.headers['x-user-username'];
87
- if (role === 'TEACHER') {
88
- const user = await User.findOne({ username });
89
- return { ownerId: user ? user._id.toString() : 'unknown' };
90
- }
91
- return {};
92
- };
93
-
94
- const getAutoSemester = () => {
95
- const now = new Date();
96
- const month = now.getMonth() + 1;
97
- const year = now.getFullYear();
98
- if (month >= 8 || month === 1) {
99
- const startYear = month === 1 ? year - 1 : year;
100
- return `${startYear}-${startYear + 1}学年 第一学期`;
101
- } else {
102
- const startYear = year - 1;
103
- return `${startYear}-${startYear + 1}学年 第二学期`;
104
- }
105
- };
106
-
107
- const generateStudentNo = async () => {
108
- const year = new Date().getFullYear();
109
- const random = Math.floor(100000 + Math.random() * 900000);
110
- return `${year}${random}`;
111
- };
112
-
113
- // MOUNT AI ROUTES
114
- app.use('/api/ai', aiRoutes);
115
-
116
- // ... (Rest of Existing Routes) ...
117
-
118
- // --- TODO LIST ENDPOINTS ---
119
- app.get('/api/todos', async (req, res) => {
120
- const username = req.headers['x-user-username'];
121
- if (!username) return res.status(401).json({ error: 'Unauthorized' });
122
- const user = await User.findOne({ username });
123
- if (!user) return res.status(404).json({ error: 'User not found' });
124
- res.json(await TodoModel.find({ userId: user._id.toString() }).sort({ isCompleted: 1, createTime: -1 }));
125
- });
126
-
127
- app.post('/api/todos', async (req, res) => {
128
- const username = req.headers['x-user-username'];
129
- const user = await User.findOne({ username });
130
- if (!user) return res.status(404).json({ error: 'User not found' });
131
- await TodoModel.create({ ...req.body, userId: user._id.toString() });
132
- res.json({ success: true });
133
- });
134
-
135
- app.put('/api/todos/:id', async (req, res) => {
136
- await TodoModel.findByIdAndUpdate(req.params.id, req.body);
137
- res.json({ success: true });
138
- });
139
-
140
- app.delete('/api/todos/:id', async (req, res) => {
141
- await TodoModel.findByIdAndDelete(req.params.id);
142
- res.json({ success: true });
143
- });
144
-
145
- // --- UPDATED SCHEDULE ENDPOINTS ---
146
- app.get('/api/schedules', async (req, res) => {
147
- const query = { ...getQueryFilter(req), ...req.query };
148
- if (query.grade) { query.className = { $regex: '^' + query.grade }; delete query.grade; }
149
- res.json(await ScheduleModel.find(query));
150
- });
151
-
152
- // NEW: Update by ID (Exact Update)
153
- app.put('/api/schedules/:id', async (req, res) => {
154
- try {
155
- await ScheduleModel.findByIdAndUpdate(req.params.id, req.body);
156
- res.json({ success: true });
157
- } catch (e) {
158
- res.status(500).json({ error: e.message });
159
- }
160
- });
161
-
162
- // Create or Update by Logic (Upsert)
163
- app.post('/api/schedules', async (req, res) => {
164
- try {
165
- // Updated Filter: Include weekType to allow separate ODD/EVEN records for same slot
166
- const filter = {
167
- className: req.body.className,
168
- dayOfWeek: req.body.dayOfWeek,
169
- period: req.body.period,
170
- weekType: req.body.weekType || 'ALL'
171
- };
172
- const sId = req.headers['x-school-id'];
173
- if(sId) filter.schoolId = sId;
174
-
175
- await ScheduleModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), {upsert:true});
176
- res.json({});
177
- } catch (e) {
178
- console.error("Save schedule error:", e);
179
- res.status(500).json({ error: e.message });
180
- }
181
- });
182
-
183
- app.delete('/api/schedules', async (req, res) => {
184
- try {
185
- // Support deleting by ID if provided
186
- if (req.query.id) {
187
- await ScheduleModel.findByIdAndDelete(req.query.id);
188
- } else {
189
- await ScheduleModel.deleteOne({...getQueryFilter(req), ...req.query});
190
- }
191
- res.json({});
192
  } catch (e) {
193
  res.status(500).json({ error: e.message });
194
  }
195
  });
196
 
197
- // --- USER MENU ORDER ---
198
- app.put('/api/users/:id/menu-order', async (req, res) => {
199
- const { menuOrder } = req.body;
200
- await User.findByIdAndUpdate(req.params.id, { menuOrder });
201
- res.json({ success: true });
202
- });
203
-
204
- // ... (Rest of existing routes unchanged) ...
205
- app.get('/api/classes/:className/teachers', async (req, res) => {
206
- const { className } = req.params;
207
- const schoolId = req.headers['x-school-id'];
208
- const normalize = (s) => (s || '').replace(/\s+/g, '');
209
- const searchName = normalize(decodeURIComponent(className));
210
- const teacherIds = new Set();
211
- const teacherNamesToResolve = new Set();
212
- try {
213
- const allClasses = await ClassModel.find({ schoolId });
214
- const matchedClass = allClasses.find(c => {
215
- const full = normalize(c.grade + c.className);
216
- const sub = normalize(c.className);
217
- return full === searchName || sub === searchName;
218
- });
219
- if (matchedClass) {
220
- if (matchedClass.homeroomTeacherIds && matchedClass.homeroomTeacherIds.length > 0) {
221
- matchedClass.homeroomTeacherIds.forEach(id => teacherIds.add(id));
222
- }
223
- if (matchedClass.teacherName) { matchedClass.teacherName.split(/[,,]/).forEach(n => { if(n.trim()) teacherNamesToResolve.add(n.trim()); }); }
224
- }
225
- const allCourses = await Course.find({ schoolId });
226
- allCourses.forEach(c => {
227
- if (normalize(c.className) === searchName) {
228
- if (c.teacherId) { teacherIds.add(c.teacherId); } else if (c.teacherName) { teacherNamesToResolve.add(c.teacherName); }
229
- }
230
- });
231
- if (teacherNamesToResolve.size > 0) {
232
- const names = Array.from(teacherNamesToResolve);
233
- const users = await User.find({ schoolId, role: 'TEACHER', $or: [ { trueName: { $in: names } }, { username: { $in: names } } ] });
234
- users.forEach(u => teacherIds.add(u._id.toString()));
235
- }
236
- if (teacherIds.size === 0) return res.json([]);
237
- const teachers = await User.find({ _id: { $in: Array.from(teacherIds) } }, 'trueName username _id teachingSubject');
238
- res.json(teachers);
239
- } catch (e) { res.json([]); }
240
- });
241
-
242
- app.get('/api/wishes', async (req, res) => {
243
- const { teacherId, studentId, status } = req.query;
244
- const filter = getQueryFilter(req);
245
- if (teacherId) filter.teacherId = teacherId;
246
- if (studentId) filter.studentId = studentId;
247
- if (status) filter.status = status;
248
- res.json(await WishModel.find(filter).sort({ status: -1, createTime: -1 }));
249
- });
250
-
251
- app.post('/api/wishes', async (req, res) => {
252
- const data = injectSchoolId(req, req.body);
253
- const existing = await WishModel.findOne({ studentId: data.studentId, status: 'PENDING' });
254
- if (existing) { return res.status(400).json({ error: 'LIMIT_REACHED', message: '您还有一个未实现的愿望,请等待实现后再许愿!' }); }
255
- await WishModel.create(data);
256
- res.json({ success: true });
257
- });
258
-
259
- app.post('/api/wishes/:id/fulfill', async (req, res) => {
260
- await WishModel.findByIdAndUpdate(req.params.id, { status: 'FULFILLED', fulfillTime: new Date() });
261
- res.json({ success: true });
262
- });
263
-
264
- app.post('/api/wishes/random-fulfill', async (req, res) => {
265
- const { teacherId } = req.body;
266
- const pendingWishes = await WishModel.find({ teacherId, status: 'PENDING' });
267
- if (pendingWishes.length === 0) { return res.status(404).json({ error: 'NO_WISHES', message: '暂无待实现的愿望' }); }
268
- const randomWish = pendingWishes[Math.floor(Math.random() * pendingWishes.length)];
269
- await WishModel.findByIdAndUpdate(randomWish._id, { status: 'FULFILLED', fulfillTime: new Date() });
270
- res.json({ success: true, wish: randomWish });
271
- });
272
-
273
- app.get('/api/feedback', async (req, res) => {
274
- const { targetId, creatorId, type, status } = req.query;
275
- const filter = getQueryFilter(req);
276
- if (targetId) filter.targetId = targetId;
277
- if (creatorId) filter.creatorId = creatorId;
278
- if (type) filter.type = type;
279
- if (status) { const statuses = status.split(','); if (statuses.length > 1) { filter.status = { $in: statuses }; } else { filter.status = status; } }
280
- res.json(await FeedbackModel.find(filter).sort({ createTime: -1 }));
281
- });
282
-
283
- app.post('/api/feedback', async (req, res) => {
284
- const data = injectSchoolId(req, req.body);
285
- await FeedbackModel.create(data);
286
- res.json({ success: true });
287
- });
288
-
289
- app.put('/api/feedback/:id', async (req, res) => {
290
- const { id } = req.params; const { status, reply } = req.body;
291
- const updateData = { updateTime: new Date() };
292
- if (status) updateData.status = status;
293
- if (reply !== undefined) updateData.reply = reply;
294
- await FeedbackModel.findByIdAndUpdate(id, updateData);
295
- res.json({ success: true });
296
- });
297
-
298
- app.post('/api/feedback/ignore-all', async (req, res) => {
299
- const { targetId } = req.body;
300
- await FeedbackModel.updateMany( { targetId, status: 'PENDING' }, { status: 'IGNORED', updateTime: new Date() } );
301
- res.json({ success: true });
302
- });
303
-
304
- app.get('/api/games/lucky-config', async (req, res) => {
305
- const filter = getQueryFilter(req);
306
- if (req.query.ownerId) { filter.ownerId = req.query.ownerId; } else { const ownerFilter = await getGameOwnerFilter(req); Object.assign(filter, ownerFilter); }
307
- if (req.query.className) filter.className = req.query.className;
308
- const config = await LuckyDrawConfigModel.findOne(filter);
309
- res.json(config || null);
310
- });
311
- app.get('/api/achievements/config', async (req, res) => {
312
- const { className } = req.query;
313
- const filter = getQueryFilter(req);
314
- if (className) filter.className = className;
315
- res.json(await AchievementConfigModel.findOne(filter));
316
- });
317
- app.post('/api/achievements/config', async (req, res) => {
318
- const data = injectSchoolId(req, req.body);
319
- await AchievementConfigModel.findOneAndUpdate( { className: data.className, ...getQueryFilter(req) }, data, { upsert: true } );
320
- res.json({ success: true });
321
- });
322
- app.get('/api/achievements/teacher-rules', async (req, res) => {
323
- const filter = getQueryFilter(req);
324
- if (req.query.teacherId) { filter.teacherId = req.query.teacherId; } else if (req.headers['x-user-role'] === 'TEACHER') { const user = await User.findOne({ username: req.headers['x-user-username'] }); if (user) filter.teacherId = user._id.toString(); }
325
- if (req.query.teacherIds) { const ids = req.query.teacherIds.split(','); delete filter.teacherId; filter.teacherId = { $in: ids }; const configs = await TeacherExchangeConfigModel.find(filter); return res.json(configs); }
326
- const config = await TeacherExchangeConfigModel.findOne(filter);
327
- res.json(config || { rules: [] });
328
- });
329
- app.post('/api/achievements/teacher-rules', async (req, res) => {
330
- const data = injectSchoolId(req, req.body);
331
- const user = await User.findOne({ username: req.headers['x-user-username'] });
332
- if (!user) return res.status(404).json({ error: 'User not found' });
333
- data.teacherId = user._id.toString();
334
- data.teacherName = user.trueName || user.username;
335
- await TeacherExchangeConfigModel.findOneAndUpdate( { teacherId: data.teacherId, ...getQueryFilter(req) }, data, { upsert: true } );
336
- res.json({ success: true });
337
- });
338
- app.get('/api/achievements/student', async (req, res) => {
339
- const { studentId, semester } = req.query;
340
- const filter = { studentId };
341
- if (semester) filter.semester = semester;
342
- res.json(await StudentAchievementModel.find(filter).sort({ createTime: -1 }));
343
- });
344
- app.post('/api/achievements/grant', async (req, res) => {
345
- const { studentId, achievementId, semester } = req.body;
346
- const sId = req.headers['x-school-id'];
347
- const student = await Student.findById(studentId);
348
- if (!student) return res.status(404).json({ error: 'Student not found' });
349
- const config = await AchievementConfigModel.findOne({ className: student.className, schoolId: sId });
350
- const achievement = config?.achievements.find(a => a.id === achievementId);
351
- if (!achievement) return res.status(404).json({ error: 'Achievement not found' });
352
- await StudentAchievementModel.create({ schoolId: sId, studentId, studentName: student.name, achievementId: achievement.id, achievementName: achievement.name, achievementIcon: achievement.icon, semester, createTime: new Date() });
353
- await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: achievement.points } });
354
- res.json({ success: true });
355
- });
356
- app.post('/api/achievements/exchange', async (req, res) => {
357
- const { studentId, ruleId, teacherId } = req.body;
358
- const sId = req.headers['x-school-id'];
359
- const student = await Student.findById(studentId);
360
- if (!student) return res.status(404).json({ error: 'Student not found' });
361
- let rule = null;
362
- let ownerId = null;
363
- if (teacherId) { const tConfig = await TeacherExchangeConfigModel.findOne({ teacherId, schoolId: sId }); rule = tConfig?.rules.find(r => r.id === ruleId); ownerId = teacherId; } else { const config = await AchievementConfigModel.findOne({ className: student.className, schoolId: sId }); rule = config?.exchangeRules?.find(r => r.id === ruleId); }
364
- if (!rule) return res.status(404).json({ error: 'Rule not found' });
365
- if (student.flowerBalance < rule.cost) { return res.status(400).json({ error: 'INSUFFICIENT_FUNDS', message: '小红花余额不足' }); }
366
- await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: -rule.cost } });
367
- await StudentRewardModel.create({ schoolId: sId, studentId, studentName: student.name, rewardType: rule.rewardType, name: rule.rewardName, count: rule.rewardValue, status: rule.rewardType === 'DRAW_COUNT' ? 'REDEEMED' : 'PENDING', source: '积分兑换', ownerId, createTime: new Date() });
368
- if (rule.rewardType === 'DRAW_COUNT') { await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: rule.rewardValue } }); }
369
- res.json({ success: true });
370
- });
371
- app.get('/api/auth/me', async (req, res) => {
372
- const username = req.headers['x-user-username'];
373
- if (!username) return res.status(401).json({ error: 'Unauthorized' });
374
- const user = await User.findOne({ username });
375
- if (!user) return res.status(404).json({ error: 'User not found' });
376
- res.json(user);
377
- });
378
- app.post('/api/auth/update-profile', async (req, res) => {
379
- const { userId, trueName, phone, avatar, currentPassword, newPassword } = req.body;
380
- try {
381
- const user = await User.findById(userId);
382
- if (!user) return res.status(404).json({ error: 'User not found' });
383
- if (newPassword) { if (user.password !== currentPassword) return res.status(401).json({ error: 'INVALID_PASSWORD', message: '旧密码错误' }); user.password = newPassword; }
384
- if (trueName) user.trueName = trueName; if (phone) user.phone = phone; if (avatar) user.avatar = avatar;
385
- await user.save();
386
- if (user.role === 'STUDENT') await Student.findOneAndUpdate({ studentNo: user.studentNo }, { name: user.trueName || user.username, phone: user.phone });
387
- res.json({ success: true, user });
388
- } catch (e) { res.status(500).json({ error: e.message }); }
389
- });
390
- app.post('/api/auth/register', async (req, res) => {
391
- const { role, username, password, schoolId, trueName, seatNo } = req.body;
392
- const className = req.body.className || req.body.homeroomClass;
393
- try {
394
- if (role === 'STUDENT') {
395
- if (!trueName || !className) return res.status(400).json({ error: 'MISSING_FIELDS', message: '姓名和班级不能为空' });
396
- const cleanName = trueName.trim(); const cleanClass = className.trim();
397
- const existingProfile = await Student.findOne({ schoolId, name: { $regex: new RegExp(`^${cleanName}$`, 'i') }, className: cleanClass });
398
- let finalUsername = '';
399
- if (existingProfile) {
400
- if (existingProfile.studentNo && existingProfile.studentNo.length > 5) finalUsername = existingProfile.studentNo;
401
- else { finalUsername = await generateStudentNo(); existingProfile.studentNo = finalUsername; await existingProfile.save(); }
402
- const userExists = await User.findOne({ username: finalUsername, schoolId });
403
- if (userExists) return res.status(409).json({ error: userExists.status === 'active' ? 'ACCOUNT_EXISTS' : 'ACCOUNT_PENDING', message: '账号已存在' });
404
- } else finalUsername = await generateStudentNo();
405
- await User.create({ username: finalUsername, password, role: 'STUDENT', trueName: cleanName, schoolId, status: 'pending', homeroomClass: cleanClass, studentNo: finalUsername, seatNo: seatNo || '', parentName: req.body.parentName, parentPhone: req.body.parentPhone, address: req.body.address, idCard: req.body.idCard, gender: req.body.gender || 'Male', createTime: new Date() });
406
- return res.json({ username: finalUsername });
407
- }
408
- const existing = await User.findOne({ username });
409
- if (existing) return res.status(409).json({ error: 'USERNAME_EXISTS', message: '用户名已存在' });
410
- await User.create({...req.body, status: 'pending', createTime: new Date()});
411
- res.json({ username });
412
- } catch(e) { res.status(500).json({ error: e.message }); }
413
- });
414
- app.get('/api/users', async (req, res) => {
415
- const filter = getQueryFilter(req);
416
- if (req.headers['x-user-role'] === 'PRINCIPAL') filter.role = { $ne: 'ADMIN' };
417
- if (req.query.role) filter.role = req.query.role;
418
- res.json(await User.find(filter).sort({ createTime: -1 }));
419
- });
420
- app.put('/api/users/:id', async (req, res) => {
421
- const userId = req.params.id; const updates = req.body;
422
- try {
423
- const user = await User.findById(userId);
424
- if (!user) return res.status(404).json({ error: 'User not found' });
425
- if (user.status !== 'active' && updates.status === 'active' && user.role === 'STUDENT') {
426
- await Student.findOneAndUpdate({ studentNo: user.studentNo, schoolId: user.schoolId }, { $set: { schoolId: user.schoolId, studentNo: user.studentNo, seatNo: user.seatNo, name: user.trueName, className: user.homeroomClass, gender: user.gender || 'Male', parentName: user.parentName, parentPhone: user.parentPhone, address: user.address, idCard: user.idCard, status: 'Enrolled', birthday: '2015-01-01' } }, { upsert: true, new: true });
427
- }
428
- await User.findByIdAndUpdate(userId, updates);
429
- res.json({});
430
- } catch (e) { res.status(500).json({ error: e.message }); }
431
- });
432
- app.post('/api/users/class-application', async (req, res) => {
433
- const { userId, type, targetClass, action } = req.body;
434
- const userRole = req.headers['x-user-role']; const schoolId = req.headers['x-school-id'];
435
- if (action === 'APPLY') { try { const user = await User.findById(userId); if(!user) return res.status(404).json({error:'User not found'}); await User.findByIdAndUpdate(userId, { classApplication: { type: type, targetClass: targetClass || '', status: 'PENDING' } }); await NotificationModel.create({ schoolId, targetRole: 'ADMIN', title: '新的班主任任免申请', content: `${user.trueName || user.username} 申请 ${type === 'CLAIM' ? '任教' : '卸任'},请及时处理。`, type: 'warning' }); return res.json({ success: true }); } catch (e) { return res.status(500).json({ error: e.message }); } }
436
- if (userRole === 'ADMIN' || userRole === 'PRINCIPAL') {
437
- const user = await User.findById(userId);
438
- if (!user || !user.classApplication) return res.status(404).json({ error: 'Application not found' });
439
- const appType = user.classApplication.type; const appTarget = user.classApplication.targetClass;
440
- if (action === 'APPROVE') {
441
- const updates = { classApplication: null }; const classes = await ClassModel.find({ schoolId });
442
- if (appType === 'CLAIM') { updates.homeroomClass = appTarget; const matchedClass = classes.find(c => (c.grade + c.className) === appTarget); if (matchedClass) { const teacherIds = matchedClass.homeroomTeacherIds || []; if (!teacherIds.includes(userId)) { teacherIds.push(userId); const teachers = await User.find({ _id: { $in: teacherIds } }); const names = teachers.map(t => t.trueName || t.username).join(', '); await ClassModel.findByIdAndUpdate(matchedClass._id, { homeroomTeacherIds: teacherIds, teacherName: names }); } } } else if (appType === 'RESIGN') { updates.homeroomClass = ''; const matchedClass = classes.find(c => (c.grade + c.className) === user.homeroomClass); if (matchedClass) { const teacherIds = (matchedClass.homeroomTeacherIds || []).filter(id => id !== userId); const teachers = await User.find({ _id: { $in: teacherIds } }); const names = teachers.map(t => t.trueName || t.username).join(', '); await ClassModel.findByIdAndUpdate(matchedClass._id, { homeroomTeacherIds: teacherIds, teacherName: names }); } }
443
- await User.findByIdAndUpdate(userId, updates);
444
- } else await User.findByIdAndUpdate(userId, { 'classApplication.status': 'REJECTED' });
445
- return res.json({ success: true });
446
- }
447
- res.status(403).json({ error: 'Permission denied' });
448
- });
449
- app.post('/api/students/promote', async (req, res) => {
450
- const { teacherFollows } = req.body; const sId = req.headers['x-school-id'];
451
- const GRADE_MAP = { '一年级': '二年级', '二年级': '三年级', '三年级': '四年级', '四年级': '五年级', '五年级': '六年级', '六年级': '毕业', '初一': '初二', '七年级': '八年级', '初二': '初三', '八年级': '九年级', '初三': '毕业', '九年级': '毕业', '高一': '高二', '高二': '高三', '高三': '毕业' };
452
- const classes = await ClassModel.find(getQueryFilter(req)); let promotedCount = 0;
453
- for (const cls of classes) {
454
- const currentGrade = cls.grade; const nextGrade = GRADE_MAP[currentGrade] || currentGrade; const suffix = cls.className;
455
- if (nextGrade === '毕业') { const oldFullClass = cls.grade + cls.className; await Student.updateMany({ className: oldFullClass, ...getQueryFilter(req) }, { status: 'Graduated', className: '已毕业' }); if (teacherFollows && cls.homeroomTeacherIds?.length) { await User.updateMany({ _id: { $in: cls.homeroomTeacherIds }, schoolId: sId }, { homeroomClass: '' }); await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '', homeroomTeacherIds: [] }); } } else { const oldFullClass = cls.grade + cls.className; const newFullClass = nextGrade + suffix; await ClassModel.findOneAndUpdate({ grade: nextGrade, className: suffix, schoolId: sId }, { schoolId: sId, grade: nextGrade, className: suffix, teacherName: teacherFollows ? cls.teacherName : undefined, homeroomTeacherIds: teacherFollows ? cls.homeroomTeacherIds : [] }, { upsert: true }); const result = await Student.updateMany({ className: oldFullClass, status: 'Enrolled', ...getQueryFilter(req) }, { className: newFullClass }); promotedCount += result.modifiedCount; if (teacherFollows && cls.homeroomTeacherIds?.length) { await User.updateMany({ _id: { $in: cls.homeroomTeacherIds }, schoolId: sId, homeroomClass: oldFullClass }, { homeroomClass: newFullClass }); await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '', homeroomTeacherIds: [] }); } }
456
- }
457
- res.json({ success: true, count: promotedCount });
458
- });
459
- app.post('/api/games/lucky-config', async (req, res) => { const data = injectSchoolId(req, req.body); if (req.headers['x-user-role'] === 'TEACHER') { const user = await User.findOne({ username: req.headers['x-user-username'] }); data.ownerId = user ? user._id.toString() : null; } await LuckyDrawConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req), ownerId: data.ownerId }, data, { upsert: true }); res.json({ success: true }); });
460
- app.post('/api/games/lucky-draw', async (req, res) => { const { studentId } = req.body; const schoolId = req.headers['x-school-id']; const userRole = req.headers['x-user-role']; try { const student = await Student.findById(studentId); if (!student) return res.status(404).json({ error: 'Student not found' }); let configFilter = { className: student.className, schoolId }; if (userRole === 'TEACHER') { const user = await User.findOne({ username: req.headers['x-user-username'] }); configFilter.ownerId = user ? user._id.toString() : null; } const config = await LuckyDrawConfigModel.findOne(configFilter); const prizes = config?.prizes || []; const defaultPrize = config?.defaultPrize || '再接再厉'; const dailyLimit = config?.dailyLimit || 3; const consolationWeight = config?.consolationWeight || 0; const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0)); if (availablePrizes.length === 0 && consolationWeight === 0) return res.status(400).json({ error: 'POOL_EMPTY', message: '奖品库存不足' }); if (userRole === 'STUDENT') { if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '您的抽奖次数已用完' }); const today = new Date().toISOString().split('T')[0]; let dailyLog = student.dailyDrawLog || { date: today, count: 0 }; if (dailyLog.date !== today) dailyLog = { date: today, count: 0 }; if (dailyLog.count >= dailyLimit) return res.status(403).json({ error: 'DAILY_LIMIT_REACHED', message: `今日抽奖次数已达上限 (${dailyLimit}次)` }); dailyLog.count += 1; student.drawAttempts -= 1; student.dailyDrawLog = dailyLog; await student.save(); } else { if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '该学生抽奖次数已用完' }); student.drawAttempts -= 1; await student.save(); } let totalWeight = consolationWeight; availablePrizes.forEach(p => totalWeight += (p.probability || 0)); let random = Math.random() * totalWeight; let selectedPrize = defaultPrize; let rewardType = 'CONSOLATION'; let matchedPrize = null; for (const p of availablePrizes) { random -= (p.probability || 0); if (random <= 0) { matchedPrize = p; break; } } if (matchedPrize) { selectedPrize = matchedPrize.name; rewardType = 'ITEM'; if (config._id) await LuckyDrawConfigModel.updateOne({ _id: config._id, "prizes.id": matchedPrize.id }, { $inc: { "prizes.$.count": -1 } }); } let ownerId = config?.ownerId; await StudentRewardModel.create({ schoolId, studentId, studentName: student.name, rewardType, name: selectedPrize, count: 1, status: 'PENDING', source: '幸运大抽奖', ownerId }); res.json({ prize: selectedPrize, rewardType }); } catch (e) { res.status(500).json({ error: e.message }); } });
461
- app.get('/api/games/monster-config', async (req, res) => { const filter = getQueryFilter(req); const ownerFilter = await getGameOwnerFilter(req); if (req.query.className) filter.className = req.query.className; Object.assign(filter, ownerFilter); const config = await GameMonsterConfigModel.findOne(filter); res.json(config || {}); });
462
- app.post('/api/games/monster-config', async (req, res) => { const data = injectSchoolId(req, req.body); if (req.headers['x-user-role'] === 'TEACHER') { const user = await User.findOne({ username: req.headers['x-user-username'] }); data.ownerId = user ? user._id.toString() : null; } await GameMonsterConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req), ownerId: data.ownerId }, data, { upsert: true }); res.json({ success: true }); });
463
- app.get('/api/games/zen-config', async (req, res) => { const filter = getQueryFilter(req); const ownerFilter = await getGameOwnerFilter(req); if (req.query.className) filter.className = req.query.className; Object.assign(filter, ownerFilter); const config = await GameZenConfigModel.findOne(filter); res.json(config || {}); });
464
- app.post('/api/games/zen-config', async (req, res) => { const data = injectSchoolId(req, req.body); if (req.headers['x-user-role'] === 'TEACHER') { const user = await User.findOne({ username: req.headers['x-user-username'] }); data.ownerId = user ? user._id.toString() : null; } await GameZenConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req), ownerId: data.ownerId }, data, { upsert: true }); res.json({ success: true }); });
465
- app.get('/api/games/mountain', async (req, res) => { res.json(await GameSessionModel.findOne({...getQueryFilter(req), className: req.query.className})); });
466
- app.post('/api/games/mountain', async (req, res) => { const { className } = req.body; const sId = req.headers['x-school-id']; const role = req.headers['x-user-role']; const username = req.headers['x-user-username']; if (role === 'TEACHER') { const user = await User.findOne({ username }); const cls = await ClassModel.findOne({ schoolId: sId, $expr: { $eq: [{ $concat: ["$grade", "$className"] }, className] } }); if (!cls) return res.status(404).json({ error: 'Class not found' }); const allowedIds = cls.homeroomTeacherIds || []; if (!allowedIds.includes(user._id.toString())) return res.status(403).json({ error: 'PERMISSION_DENIED', message: '只有班主任可以操作登峰游戏' }); } await GameSessionModel.findOneAndUpdate({ className, ...getQueryFilter(req) }, injectSchoolId(req, req.body), {upsert:true}); res.json({}); });
467
- app.get('/api/rewards', async (req, res) => { const filter = getQueryFilter(req); if (req.headers['x-user-role'] === 'TEACHER') { const user = await User.findOne({ username: req.headers['x-user-username'] }); if (user) filter.ownerId = user._id.toString(); } if(req.query.studentId) filter.studentId = req.query.studentId; if (req.query.className) { const classStudents = await Student.find({ className: req.query.className, ...getQueryFilter(req) }, '_id'); filter.studentId = { $in: classStudents.map(s => s._id.toString()) }; } if (req.query.excludeType) filter.rewardType = { $ne: req.query.excludeType }; const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 20; const skip = (page - 1) * limit; const total = await StudentRewardModel.countDocuments(filter); const list = await StudentRewardModel.find(filter).sort({createTime:-1}).skip(skip).limit(limit); res.json({ list, total }); });
468
- app.post('/api/rewards', async (req, res) => { const data = injectSchoolId(req, req.body); if (!data.count) data.count = 1; if (req.headers['x-user-role'] === 'TEACHER') { const user = await User.findOne({ username: req.headers['x-user-username'] }); data.ownerId = user ? user._id.toString() : null; } if(data.rewardType==='DRAW_COUNT') { data.status='REDEEMED'; await Student.findByIdAndUpdate(data.studentId, {$inc:{drawAttempts:data.count}}); } await StudentRewardModel.create(data); res.json({}); });
469
- app.post('/api/games/grant-reward', async (req, res) => { const { studentId, count, rewardType, name } = req.body; const finalCount = count || 1; const finalName = name || (rewardType === 'DRAW_COUNT' ? '抽奖券' : '奖品'); let ownerId = null; if (req.headers['x-user-role'] === 'TEACHER') { const user = await User.findOne({ username: req.headers['x-user-username'] }); ownerId = user ? user._id.toString() : null; } if (rewardType === 'DRAW_COUNT') await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: finalCount } }); await StudentRewardModel.create({ schoolId: req.headers['x-school-id'], studentId, studentName: (await Student.findById(studentId)).name, rewardType, name: finalName, count: finalCount, status: rewardType === 'DRAW_COUNT' ? 'REDEEMED' : 'PENDING', source: '教师发放', ownerId }); res.json({}); });
470
- app.put('/api/classes/:id', async (req, res) => {
471
- const classId = req.params.id;
472
- const { grade, className, teacherName, homeroomTeacherIds, periodConfig } = req.body;
473
- const sId = req.headers['x-school-id'];
474
- const oldClass = await ClassModel.findById(classId);
475
- if (!oldClass) return res.status(404).json({ error: 'Class not found' });
476
- const newFullClass = grade + className;
477
- const oldFullClass = oldClass.grade + oldClass.className;
478
- const oldTeacherIds = oldClass.homeroomTeacherIds || [];
479
- const newTeacherIds = homeroomTeacherIds || [];
480
- const removedIds = oldTeacherIds.filter(id => !newTeacherIds.includes(id));
481
- if (removedIds.length > 0) await User.updateMany({ _id: { $in: removedIds }, schoolId: sId }, { homeroomClass: '' });
482
- if (newTeacherIds.length > 0) await User.updateMany({ _id: { $in: newTeacherIds }, schoolId: sId }, { homeroomClass: newFullClass });
483
- let displayTeacherName = teacherName;
484
- if (newTeacherIds.length > 0) {
485
- const teachers = await User.find({ _id: { $in: newTeacherIds } });
486
- displayTeacherName = teachers.map(t => t.trueName || t.username).join(', ');
487
- }
488
-
489
- // FIX: Explicitly update periodConfig if present in the body
490
- const updatePayload = { grade, className, teacherName: displayTeacherName, homeroomTeacherIds: newTeacherIds };
491
- if (periodConfig) updatePayload.periodConfig = periodConfig;
492
-
493
- await ClassModel.findByIdAndUpdate(classId, updatePayload);
494
-
495
- if (oldFullClass !== newFullClass) {
496
- await Student.updateMany({ className: oldFullClass, schoolId: sId }, { className: newFullClass });
497
- await User.updateMany({ homeroomClass: oldFullClass, schoolId: sId }, { homeroomClass: newFullClass });
498
- }
499
- res.json({ success: true });
500
- });
501
- app.post('/api/classes', async (req, res) => { const data = injectSchoolId(req, req.body); const { homeroomTeacherIds } = req.body; if (homeroomTeacherIds && homeroomTeacherIds.length > 0) { const teachers = await User.find({ _id: { $in: homeroomTeacherIds } }); data.teacherName = teachers.map(t => t.trueName || t.username).join(', '); } await ClassModel.create(data); if (homeroomTeacherIds && homeroomTeacherIds.length > 0) await User.updateMany({ _id: { $in: homeroomTeacherIds }, schoolId: data.schoolId }, { homeroomClass: data.grade + data.className }); res.json({}); });
502
- app.get('/api/courses', async (req, res) => { const filter = getQueryFilter(req); if (req.query.teacherId) filter.teacherId = req.query.teacherId; res.json(await Course.find(filter)); });
503
- app.post('/api/courses', async (req, res) => { const data = injectSchoolId(req, req.body); try { await Course.create(data); res.json({}); } catch(e) { if (e.code === 11000) return res.status(409).json({ error: 'DUPLICATE', message: '该班级该科目已有任课老师' }); res.status(500).json({ error: e.message }); } });
504
- app.get('/api/public/schools', async (req, res) => { res.json(await School.find({}, 'name code _id')); });
505
- app.get('/api/public/config', async (req, res) => { const currentSem = getAutoSemester(); let config = await ConfigModel.findOne({ key: 'main' }); if (config) { let semesters = config.semesters || []; if (!semesters.includes(currentSem)) { semesters.unshift(currentSem); config.semesters = semesters; config.semester = currentSem; await ConfigModel.updateOne({ key: 'main' }, { semesters, semester: currentSem }); } } else { config = { key: 'main', allowRegister: true, semester: currentSem, semesters: [currentSem] }; } res.json(config); });
506
- app.get('/api/public/meta', async (req, res) => { res.json({ classes: await ClassModel.find({ schoolId: req.query.schoolId }), subjects: await SubjectModel.find({ schoolId: req.query.schoolId }) }); });
507
- app.post('/api/auth/login', async (req, res) => { const { username, password } = req.body; const user = await User.findOne({ username, password }); if (!user) return res.status(401).json({ message: 'Error' }); if (user.status !== 'active') return res.status(403).json({ error: 'PENDING_APPROVAL' }); res.json(user); });
508
- app.get('/api/schools', async (req, res) => { res.json(await School.find()); });
509
- app.post('/api/schools', async (req, res) => { res.json(await School.create(req.body)); });
510
- app.put('/api/schools/:id', async (req, res) => { await School.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
511
- app.delete('/api/schools/:id', async (req, res) => { const schoolId = req.params.id; try { await School.findByIdAndDelete(schoolId); await User.deleteMany({ schoolId }); await Student.deleteMany({ schoolId }); await ClassModel.deleteMany({ schoolId }); await SubjectModel.deleteMany({ schoolId }); await Course.deleteMany({ schoolId }); await Score.deleteMany({ schoolId }); await ExamModel.deleteMany({ schoolId }); await ScheduleModel.deleteMany({ schoolId }); await NotificationModel.deleteMany({ schoolId }); await AttendanceModel.deleteMany({ schoolId }); await LeaveRequestModel.deleteMany({ schoolId }); await GameSessionModel.deleteMany({ schoolId }); await StudentRewardModel.deleteMany({ schoolId }); await LuckyDrawConfigModel.deleteMany({ schoolId }); await GameMonsterConfigModel.deleteMany({ schoolId }); await GameZenConfigModel.deleteMany({ schoolId }); await AchievementConfigModel.deleteMany({ schoolId }); await StudentAchievementModel.deleteMany({ schoolId }); await SchoolCalendarModel.deleteMany({ schoolId }); await WishModel.deleteMany({ schoolId }); await FeedbackModel.deleteMany({ schoolId }); await TodoModel.deleteMany({}); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } });
512
- app.delete('/api/users/:id', async (req, res) => { const requesterRole = req.headers['x-user-role']; if (requesterRole === 'PRINCIPAL') { const user = await User.findById(req.params.id); if (!user || user.schoolId !== req.headers['x-school-id']) return res.status(403).json({error: 'Permission denied'}); if (user.role === 'ADMIN') return res.status(403).json({error: 'Cannot delete admin'}); } await User.findByIdAndDelete(req.params.id); res.json({}); });
513
- app.get('/api/students', async (req, res) => { res.json(await Student.find(getQueryFilter(req))); });
514
- app.post('/api/students', async (req, res) => { const data = injectSchoolId(req, req.body); if (data.studentNo === '') delete data.studentNo; try { const existing = await Student.findOne({ schoolId: data.schoolId, name: data.name, className: data.className }); if (existing) { Object.assign(existing, data); if (!existing.studentNo) { existing.studentNo = await generateStudentNo(); } await existing.save(); } else { if (!data.studentNo) { data.studentNo = await generateStudentNo(); } await Student.create(data); } res.json({ success: true }); } catch (e) { if (e.code === 11000) return res.status(409).json({ error: 'DUPLICATE', message: '保存失败:存在重复的系统ID' }); res.status(500).json({ error: e.message }); } });
515
- app.put('/api/students/:id', async (req, res) => { await Student.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
516
- app.delete('/api/students/:id', async (req, res) => { await Student.findByIdAndDelete(req.params.id); res.json({}); });
517
- app.get('/api/classes', async (req, res) => { const filter = getQueryFilter(req); const cls = await ClassModel.find(filter); const resData = await Promise.all(cls.map(async c => { const count = await Student.countDocuments({ className: c.grade + c.className, status: 'Enrolled', ...filter }); return { ...c.toObject(), studentCount: count }; })); res.json(resData); });
518
- app.delete('/api/classes/:id', async (req, res) => { const cls = await ClassModel.findById(req.params.id); if (cls && cls.homeroomTeacherIds) await User.updateMany({ _id: { $in: cls.homeroomTeacherIds }, schoolId: cls.schoolId }, { homeroomClass: '' }); await ClassModel.findByIdAndDelete(req.params.id); res.json({}); });
519
- app.get('/api/subjects', async (req, res) => { res.json(await SubjectModel.find(getQueryFilter(req))); });
520
- app.post('/api/subjects', async (req, res) => { await SubjectModel.create(injectSchoolId(req, req.body)); res.json({}); });
521
- app.put('/api/subjects/:id', async (req, res) => { await SubjectModel.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
522
- app.delete('/api/subjects/:id', async (req, res) => { await SubjectModel.findByIdAndDelete(req.params.id); res.json({}); });
523
- app.put('/api/courses/:id', async (req, res) => { await Course.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
524
- app.delete('/api/courses/:id', async (req, res) => { await Course.findByIdAndDelete(req.params.id); res.json({}); });
525
- app.get('/api/scores', async (req, res) => { res.json(await Score.find(getQueryFilter(req))); });
526
- app.post('/api/scores', async (req, res) => { await Score.create(injectSchoolId(req, req.body)); res.json({}); });
527
- app.put('/api/scores/:id', async (req, res) => { await Score.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
528
- app.delete('/api/scores/:id', async (req, res) => { await Score.findByIdAndDelete(req.params.id); res.json({}); });
529
- app.get('/api/exams', async (req, res) => { res.json(await ExamModel.find(getQueryFilter(req))); });
530
- app.post('/api/exams', async (req, res) => { await ExamModel.findOneAndUpdate({name:req.body.name}, injectSchoolId(req, req.body), {upsert:true}); res.json({}); });
531
- app.get('/api/stats', async (req, res) => { const filter = getQueryFilter(req); const studentCount = await Student.countDocuments(filter); const courseCount = await Course.countDocuments(filter); const scores = await Score.find({...filter, status: 'Normal'}); let avgScore = 0; let excellentRate = '0%'; if (scores.length > 0) { const total = scores.reduce((sum, s) => sum + s.score, 0); avgScore = parseFloat((total / scores.length).toFixed(1)); const excellent = scores.filter(s => s.score >= 90).length; excellentRate = Math.round((excellent / scores.length) * 100) + '%'; } res.json({ studentCount, courseCount, avgScore, excellentRate }); });
532
- app.get('/api/config', async (req, res) => { const currentSem = getAutoSemester(); let config = await ConfigModel.findOne({key:'main'}); if (config) { let semesters = config.semesters || []; if (!semesters.includes(currentSem)) { semesters.unshift(currentSem); config.semesters = semesters; config.semester = currentSem; await ConfigModel.updateOne({ key: 'main' }, { semesters, semester: currentSem }); } } else { config = { key: 'main', allowRegister: true, semester: currentSem, semesters: [currentSem] }; } res.json(config); });
533
- app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); });
534
- app.put('/api/rewards/:id', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
535
- app.delete('/api/rewards/:id', async (req, res) => { const reward = await StudentRewardModel.findById(req.params.id); if (!reward) return res.status(404).json({error: 'Not found'}); if (reward.rewardType === 'DRAW_COUNT') { const student = await Student.findById(reward.studentId); if (student && student.drawAttempts < reward.count) return res.status(400).json({ error: 'FAILED_REVOKE', message: '修改失败,次数已被使用' }); await Student.findByIdAndUpdate(reward.studentId, { $inc: { drawAttempts: -reward.count } }); } await StudentRewardModel.findByIdAndDelete(req.params.id); res.json({}); });
536
- app.post('/api/rewards/:id/redeem', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, {status:'REDEEMED'}); res.json({}); });
537
- app.get('/api/attendance', async (req, res) => { const { className, date, studentId } = req.query; const filter = getQueryFilter(req); if(className) filter.className = className; if(date) filter.date = date; if(studentId) filter.studentId = studentId; res.json(await AttendanceModel.find(filter)); });
538
- app.post('/api/attendance/check-in', async (req, res) => { const { studentId, date, status } = req.body; const exists = await AttendanceModel.findOne({ studentId, date }); if (exists) return res.status(400).json({ error: 'ALREADY_CHECKED_IN', message: '今日已打卡' }); const student = await Student.findById(studentId); await AttendanceModel.create({ schoolId: req.headers['x-school-id'], studentId, studentName: student.name, className: student.className, date, status: status || 'Present', checkInTime: new Date() }); res.json({ success: true }); });
539
- app.post('/api/attendance/batch', async (req, res) => { const { className, date, status } = req.body; const students = await Student.find({ className, ...getQueryFilter(req) }); const ops = students.map(s => ({ updateOne: { filter: { studentId: s._id, date }, update: { $setOnInsert: { schoolId: req.headers['x-school-id'], studentId: s._id, studentName: s.name, className: s.className, date, status: status || 'Present', checkInTime: new Date() } }, upsert: true } })); if (ops.length > 0) await AttendanceModel.bulkWrite(ops); res.json({ success: true, count: ops.length }); });
540
- app.put('/api/attendance/update', async (req, res) => { const { studentId, date, status } = req.body; const student = await Student.findById(studentId); await AttendanceModel.findOneAndUpdate({ studentId, date }, { schoolId: req.headers['x-school-id'], studentId, studentName: student.name, className: student.className, date, status, checkInTime: new Date() }, { upsert: true }); res.json({ success: true }); });
541
- app.post('/api/leave', async (req, res) => { await LeaveRequestModel.create(injectSchoolId(req, req.body)); const { studentId, startDate } = req.body; const student = await Student.findById(studentId); if (student) await AttendanceModel.findOneAndUpdate({ studentId, date: startDate }, { schoolId: req.headers['x-school-id'], studentId, studentName: student.name, className: student.className, date: startDate, status: 'Leave', checkInTime: new Date() }, { upsert: true }); res.json({ success: true }); });
542
- app.get('/api/attendance/calendar', async (req, res) => { const { className } = req.query; const filter = getQueryFilter(req); const query = { $and: [ filter, { $or: [{ className: { $exists: false } }, { className: null }, { className }] } ] }; res.json(await SchoolCalendarModel.find(query)); });
543
- app.post('/api/attendance/calendar', async (req, res) => { await SchoolCalendarModel.create(injectSchoolId(req, req.body)); res.json({ success: true }); });
544
- app.delete('/api/attendance/calendar/:id', async (req, res) => { await SchoolCalendarModel.findByIdAndDelete(req.params.id); res.json({}); });
545
- app.post('/api/batch-delete', async (req, res) => { if(req.body.type==='student') await Student.deleteMany({_id:{$in:req.body.ids}}); if(req.body.type==='score') await Score.deleteMany({_id:{$in:req.body.ids}}); res.json({}); });
546
-
547
- app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); });
548
- app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
 
1
 
2
+ // ... existing code ...
3
+ const AIUsageSchema = new mongoose.Schema({
4
+ date: String, // Format: YYYY-MM-DD
5
+ model: String,
6
+ provider: String,
7
+ count: { type: Number, default: 0 }
8
+ });
9
+ // Optimize lookups by date and model
10
+ AIUsageSchema.index({ date: 1, model: 1, provider: 1 }, { unique: true });
11
+ const AIUsageModel = mongoose.model('AIUsage', AIUsageSchema);
12
+
13
+ // NEW: Endpoint to get a valid Gemini Key for Client-side Live API
14
+ router.get('/config/key', checkAIAccess, async (req, res) => {
15
+ try {
16
+ const keys = await getKeyPool('gemini');
17
+ if (keys.length === 0) return res.status(404).json({ error: 'No API keys configured' });
18
+ // Return a random key to load balance slightly, or just the first one
19
+ const key = keys[0];
20
+ res.json({ key });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  } catch (e) {
22
  res.status(500).json({ error: e.message });
23
  }
24
  });
25
 
26
+ module.exports = router;
27
+ // ... existing code ...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/api.ts CHANGED
@@ -1,284 +1,227 @@
1
-
2
- // ... existing imports
3
- import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig, Attendance, LeaveRequest, AchievementConfig, SchoolCalendarEntry, TeacherExchangeConfig, Wish, Feedback, AIChatMessage, Todo } from '../types';
4
-
5
- // ... existing getBaseUrl ...
6
- const getBaseUrl = () => {
7
- let isProd = false;
8
- try {
9
- // @ts-ignore
10
- if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.PROD) {
11
- isProd = true;
12
- }
13
- } catch (e) {}
14
-
15
- if (isProd || (typeof window !== 'undefined' && window.location.port === '7860')) {
16
- return '/api';
 
17
  }
18
- return 'http://localhost:7860/api';
19
- };
20
 
21
- const API_BASE_URL = getBaseUrl();
 
 
 
22
 
23
- async function request(endpoint: string, options: RequestInit = {}) {
24
- const headers: any = { 'Content-Type': 'application/json', ...options.headers };
25
-
26
- if (typeof window !== 'undefined') {
27
- const currentUser = JSON.parse(localStorage.getItem('user') || 'null');
28
- const selectedSchoolId = localStorage.getItem('admin_view_school_id');
29
-
30
- if (currentUser?.role === 'ADMIN' && selectedSchoolId) {
31
- headers['x-school-id'] = selectedSchoolId;
32
- } else if (currentUser?.schoolId) {
33
- headers['x-school-id'] = currentUser.schoolId;
34
- }
35
-
36
- if (currentUser?.role) {
37
- headers['x-user-role'] = currentUser.role;
38
- headers['x-user-username'] = currentUser.username;
39
- }
40
  }
41
 
42
- const res = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers });
43
-
44
- if (!res.ok) {
45
- if (res.status === 401) throw new Error('AUTH_FAILED');
46
- const errorData = await res.json().catch(() => ({}));
47
- const errorMessage = errorData.error || errorData.message || `Server Error: ${res.status}`;
48
- if (errorData.error === 'PENDING_APPROVAL') throw new Error('PENDING_APPROVAL');
49
- if (errorData.error === 'BANNED') throw new Error('BANNED');
50
- if (errorData.error === 'CONFLICT') throw new Error(errorData.message);
51
- if (errorData.error === 'INVALID_PASSWORD') throw new Error('INVALID_PASSWORD');
52
- throw new Error(errorMessage);
53
  }
54
- return res.json();
55
- }
56
 
57
- export const api = {
58
- init: () => console.log('🔗 API:', API_BASE_URL),
 
 
59
 
 
 
 
 
60
  auth: {
61
- login: async (username: string, password: string): Promise<User> => {
62
- const user = await request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
63
- if (typeof window !== 'undefined') {
64
- localStorage.setItem('user', JSON.stringify(user));
65
- localStorage.removeItem('admin_view_school_id');
66
- }
67
- return user;
68
- },
69
- refreshSession: async (): Promise<User | null> => {
70
- try {
71
- const user = await request('/auth/me');
72
- if (typeof window !== 'undefined' && user) {
73
- localStorage.setItem('user', JSON.stringify(user));
74
- }
75
- return user;
76
- } catch (e) { return null; }
77
- },
78
- register: async (data: any): Promise<User> => {
79
- return await request('/auth/register', { method: 'POST', body: JSON.stringify(data) });
80
- },
81
- updateProfile: async (data: any): Promise<any> => {
82
- return await request('/auth/update-profile', { method: 'POST', body: JSON.stringify(data) });
83
  },
 
84
  logout: () => {
85
- if (typeof window !== 'undefined') {
86
- localStorage.removeItem('user');
87
- localStorage.removeItem('admin_view_school_id');
88
- }
89
  },
90
  getCurrentUser: (): User | null => {
91
- if (typeof window !== 'undefined') {
 
 
 
92
  try {
93
- const stored = localStorage.getItem('user');
94
- if (stored) return JSON.parse(stored);
95
- } catch (e) {
96
- localStorage.removeItem('user');
97
- }
98
- }
99
- return null;
100
- }
101
- },
102
-
103
- schools: {
104
- getPublic: () => request('/public/schools'),
105
- getAll: () => request('/schools'),
106
- add: (data: School) => request('/schools', { method: 'POST', body: JSON.stringify(data) }),
107
- update: (id: string, data: Partial<School>) => request(`/schools/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
108
- delete: (id: string) => request(`/schools/${id}`, { method: 'DELETE' })
109
- },
110
-
111
- users: {
112
- getAll: (options?: { global?: boolean; role?: string }) => {
113
- const params = new URLSearchParams();
114
- if (options?.global) params.append('global', 'true');
115
- if (options?.role) params.append('role', options.role);
116
- return request(`/users?${params.toString()}`);
117
  },
118
- update: (id: string, data: Partial<User>) => request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
119
- delete: (id: string) => request(`/users/${id}`, { method: 'DELETE' }),
120
- applyClass: (data: { userId: string, type: 'CLAIM'|'RESIGN', targetClass?: string, action: 'APPLY'|'APPROVE'|'REJECT' }) =>
121
- request('/users/class-application', { method: 'POST', body: JSON.stringify(data) }),
122
- getTeachersForClass: (className: string) => request(`/classes/${encodeURIComponent(className)}/teachers`),
123
- saveMenuOrder: (userId: string, order: string[]) => request(`/users/${userId}/menu-order`, { method: 'PUT', body: JSON.stringify({ menuOrder: order }) }), // NEW
124
  },
125
-
126
  students: {
127
  getAll: () => request('/students'),
128
  add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
129
  update: (id: string, data: any) => request(`/students/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
130
- delete: (id: string | number) => request(`/students/${id}`, { method: 'DELETE' }),
 
131
  promote: (data: { teacherFollows: boolean }) => request('/students/promote', { method: 'POST', body: JSON.stringify(data) }),
132
- transfer: (data: { studentId: string, targetClass: string }) => request('/students/transfer', { method: 'POST', body: JSON.stringify(data) })
133
  },
134
-
135
  classes: {
136
  getAll: () => request('/classes'),
137
- add: (data: ClassInfo) => request('/classes', { method: 'POST', body: JSON.stringify(data) }),
138
- delete: (id: string | number) => request(`/classes/${id}`, { method: 'DELETE' })
139
  },
140
-
141
- subjects: {
142
- getAll: () => request('/subjects'),
143
- add: (data: Subject) => request('/subjects', { method: 'POST', body: JSON.stringify(data) }),
144
- update: (id: string | number, data: Partial<Subject>) => request(`/subjects/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
145
- delete: (id: string | number) => request(`/subjects/${id}`, { method: 'DELETE' })
146
- },
147
-
148
- exams: {
149
- getAll: () => request('/exams'),
150
- save: (data: any) => request('/exams', { method: 'POST', body: JSON.stringify(data) })
151
- },
152
-
153
  courses: {
154
  getAll: () => request('/courses'),
155
  add: (data: any) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
156
- update: (id: string | number, data: any) => request(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
157
- delete: (id: string | number) => request(`/courses/${id}`, { method: 'DELETE' })
158
  },
159
-
160
  scores: {
161
  getAll: () => request('/scores'),
162
  add: (data: any) => request('/scores', { method: 'POST', body: JSON.stringify(data) }),
163
- update: (id: string | number, data: any) => request(`/scores/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
164
- delete: (id: string | number) => request(`/scores/${id}`, { method: 'DELETE' })
165
  },
166
-
167
- schedules: {
168
- get: (params: { className?: string; teacherName?: string; grade?: string }) => {
169
- const qs = new URLSearchParams(params as any).toString();
170
- return request(`/schedules?${qs}`);
171
- },
172
- save: (data: Schedule) => request('/schedules', { method: 'POST', body: JSON.stringify(data) }),
173
- delete: (params: { className: string; dayOfWeek: number; period: number }) => {
174
- const qs = new URLSearchParams(params as any).toString();
175
- return request(`/schedules?${qs}`, { method: 'DELETE' });
176
- }
177
  },
178
-
179
- attendance: {
180
- checkIn: (data: { studentId: string, date: string, status?: string }) => request('/attendance/check-in', { method: 'POST', body: JSON.stringify(data) }),
181
- get: (params: { className?: string, date?: string, studentId?: string }) => {
182
- const qs = new URLSearchParams(params as any).toString();
183
- return request(`/attendance?${qs}`);
184
  },
185
- batch: (data: { className: string, date: string, status?: string }) => request('/attendance/batch', { method: 'POST', body: JSON.stringify(data) }),
186
- update: (data: { studentId: string, date: string, status: string }) => request('/attendance/update', { method: 'PUT', body: JSON.stringify(data) }),
187
- applyLeave: (data: { studentId: string, studentName: string, className: string, reason: string, startDate: string, endDate: string }) => request('/leave', { method: 'POST', body: JSON.stringify(data) }),
 
 
188
  },
189
-
190
- calendar: {
191
- get: (className: string) => request(`/attendance/calendar?className=${className}`),
192
- add: (data: SchoolCalendarEntry) => request('/attendance/calendar', { method: 'POST', body: JSON.stringify(data) }),
193
- delete: (id: string) => request(`/attendance/calendar/${id}`, { method: 'DELETE' })
 
 
 
 
194
  },
195
-
196
  stats: {
197
- getSummary: () => request('/stats')
198
  },
199
-
200
  config: {
201
- get: () => request('/config'),
202
  getPublic: () => request('/public/config'),
203
- save: (data: SystemConfig) => request('/config', { method: 'POST', body: JSON.stringify(data) })
204
  },
205
-
206
- notifications: {
207
- getAll: (userId: string, role: string) => request(`/notifications?userId=${userId}&role=${role}`),
208
  },
209
-
210
- games: {
211
- getMountainSession: (className: string) => request(`/games/mountain?className=${className}`),
212
- saveMountainSession: (data: GameSession) => request('/games/mountain', { method: 'POST', body: JSON.stringify(data) }),
213
- getLuckyConfig: (className?: string, ownerId?: string) => request(`/games/lucky-config?className=${className || ''}${ownerId ? `&ownerId=${ownerId}` : ''}`),
214
- saveLuckyConfig: (data: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(data) }),
215
- drawLucky: (studentId: string) => request('/games/lucky-draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
216
- grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
217
- getMonsterConfig: (className: string) => request(`/games/monster-config?className=${className}`),
218
- saveMonsterConfig: (data: any) => request('/games/monster-config', { method: 'POST', body: JSON.stringify(data) }),
219
- getZenConfig: (className: string) => request(`/games/zen-config?className=${className}`),
220
- saveZenConfig: (data: any) => request('/games/zen-config', { method: 'POST', body: JSON.stringify(data) }),
221
  },
222
-
223
- achievements: {
224
- getConfig: (className: string) => request(`/achievements/config?className=${className}`),
225
- saveConfig: (data: AchievementConfig) => request('/achievements/config', { method: 'POST', body: JSON.stringify(data) }),
226
- getStudentAchievements: (studentId: string, semester?: string) => request(`/achievements/student?studentId=${studentId}${semester ? `&semester=${semester}` : ''}`),
227
- grant: (data: { studentId: string, achievementId: string, semester: string }) => request('/achievements/grant', { method: 'POST', body: JSON.stringify(data) }),
228
- exchange: (data: { studentId: string, ruleId: string, teacherId?: string }) => request('/achievements/exchange', { method: 'POST', body: JSON.stringify(data) }),
229
- getMyRules: () => request('/achievements/teacher-rules'),
230
- saveMyRules: (data: TeacherExchangeConfig) => request('/achievements/teacher-rules', { method: 'POST', body: JSON.stringify(data) }),
231
- getRulesByTeachers: (teacherIds: string[]) => request(`/achievements/teacher-rules?teacherIds=${teacherIds.join(',')}`),
 
232
  },
233
-
234
  rewards: {
235
- getMyRewards: (studentId: string, page = 1, limit = 20) => request(`/rewards?studentId=${studentId}&page=${page}&limit=${limit}&excludeType=CONSOLATION`),
236
- getClassRewards: (page = 1, limit = 20, className?: string) => {
237
- let qs = `scope=class&page=${page}&limit=${limit}&excludeType=CONSOLATION`;
238
- if (className) qs += `&className=${className}`;
239
- return request(`/rewards?${qs}`);
240
- },
241
- addReward: (data: Partial<StudentReward>) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
242
- update: (id: string, data: Partial<StudentReward>) => request(`/rewards/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
243
- delete: (id: string) => request(`/rewards/${id}`, { method: 'DELETE' }),
244
  redeem: (id: string) => request(`/rewards/${id}/redeem`, { method: 'POST' }),
 
 
245
  },
246
-
247
- batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
248
- return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  },
250
-
251
  wishes: {
252
- getAll: (params: { teacherId?: string, studentId?: string, status?: string }) => {
253
- const qs = new URLSearchParams(params as any).toString();
254
- return request(`/wishes?${qs}`);
255
- },
256
- create: (data: Partial<Wish>) => request('/wishes', { method: 'POST', body: JSON.stringify(data) }),
257
- fulfill: (id: string) => request(`/wishes/${id}/fulfill`, { method: 'POST' }),
258
- randomFulfill: (teacherId: string) => request('/wishes/random-fulfill', { method: 'POST', body: JSON.stringify({ teacherId }) }),
259
  },
260
-
261
  feedback: {
262
- getAll: (params: { targetId?: string, creatorId?: string, type?: string, status?: string }) => {
263
- const qs = new URLSearchParams(params as any).toString();
264
- return request(`/feedback?${qs}`);
265
- },
266
- create: (data: Partial<Feedback>) => request('/feedback', { method: 'POST', body: JSON.stringify(data) }),
267
- update: (id: string, data: { status?: string, reply?: string }) => request(`/feedback/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
268
- ignoreAll: (targetId: string) => request('/feedback/ignore-all', { method: 'POST', body: JSON.stringify({ targetId }) }),
 
 
 
 
 
 
269
  },
270
-
271
  ai: {
272
  chat: (data: { text?: string, audio?: string, history?: { role: string, text?: string }[] }) => request('/ai/chat', { method: 'POST', body: JSON.stringify(data) }),
273
- evaluate: (data: { question: string, audio?: string, image?: string }) => request('/ai/evaluate', { method: 'POST', body: JSON.stringify(data) }),
274
  resetPool: () => request('/ai/reset-pool', { method: 'POST' }),
275
- getStats: () => request('/ai/stats'), // NEW Detailed Stats
 
276
  },
277
-
278
- todos: { // NEW
279
- getAll: () => request('/todos'),
280
- add: (content: string) => request('/todos', { method: 'POST', body: JSON.stringify({ content }) }),
281
- update: (id: string, data: Partial<Todo>) => request(`/todos/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
282
- delete: (id: string) => request(`/todos/${id}`, { method: 'DELETE' }),
283
- }
284
- };
 
1
+ import { User } from '../types';
2
+
3
+ const API_BASE = '/api';
4
+
5
+ const request = async (endpoint: string, options?: RequestInit) => {
6
+ const token = localStorage.getItem('token');
7
+ const headers = {
8
+ 'Content-Type': 'application/json',
9
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
10
+ ...options?.headers,
11
+ };
12
+
13
+ // Add school context header if available (for admin view)
14
+ const schoolId = localStorage.getItem('admin_view_school_id');
15
+ if (schoolId) {
16
+ // @ts-ignore
17
+ headers['x-school-id'] = schoolId;
18
  }
 
 
19
 
20
+ const response = await fetch(`${API_BASE}${endpoint}`, {
21
+ ...options,
22
+ headers,
23
+ });
24
 
25
+ if (response.status === 401) {
26
+ localStorage.removeItem('token');
27
+ localStorage.removeItem('user');
28
+ window.location.href = '/';
29
+ throw new Error('Unauthorized');
 
 
 
 
 
 
 
 
 
 
 
 
30
  }
31
 
32
+ if (!response.ok) {
33
+ const errorData = await response.json().catch(() => ({}));
34
+ throw new Error(errorData.message || errorData.error || 'API Request Failed');
 
 
 
 
 
 
 
 
35
  }
 
 
36
 
37
+ // Handle empty responses
38
+ const text = await response.text();
39
+ return text ? JSON.parse(text) : {};
40
+ };
41
 
42
+ export const api = {
43
+ init: () => {
44
+ // Any initialization logic if needed
45
+ },
46
  auth: {
47
+ login: async (username: string, password?: string) => {
48
+ const res = await request('/auth/login', {
49
+ method: 'POST',
50
+ body: JSON.stringify({ username, password }),
51
+ });
52
+ localStorage.setItem('token', res.token);
53
+ localStorage.setItem('user', JSON.stringify(res.user));
54
+ return res.user;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  },
56
+ register: async (data: any) => request('/auth/register', { method: 'POST', body: JSON.stringify(data) }),
57
  logout: () => {
58
+ localStorage.removeItem('token');
59
+ localStorage.removeItem('user');
60
+ window.location.reload();
 
61
  },
62
  getCurrentUser: (): User | null => {
63
+ const userStr = localStorage.getItem('user');
64
+ return userStr ? JSON.parse(userStr) : null;
65
+ },
66
+ refreshSession: async () => {
67
  try {
68
+ const user = await request('/auth/me');
69
+ localStorage.setItem('user', JSON.stringify(user));
70
+ return user;
71
+ } catch (e) { return null; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  },
73
+ updateProfile: (data: any) => request('/auth/profile', { method: 'PUT', body: JSON.stringify(data) }),
 
 
 
 
 
74
  },
 
75
  students: {
76
  getAll: () => request('/students'),
77
  add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
78
  update: (id: string, data: any) => request(`/students/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
79
+ delete: (id: string) => request(`/students/${id}`, { method: 'DELETE' }),
80
+ transfer: (data: { studentId: string, targetClass: string }) => request('/students/transfer', { method: 'POST', body: JSON.stringify(data) }),
81
  promote: (data: { teacherFollows: boolean }) => request('/students/promote', { method: 'POST', body: JSON.stringify(data) }),
 
82
  },
 
83
  classes: {
84
  getAll: () => request('/classes'),
85
+ add: (data: any) => request('/classes', { method: 'POST', body: JSON.stringify(data) }),
86
+ delete: (id: string | number) => request(`/classes/${id}`, { method: 'DELETE' }),
87
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  courses: {
89
  getAll: () => request('/courses'),
90
  add: (data: any) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
91
+ update: (id: string, data: any) => request(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
92
+ delete: (id: string) => request(`/courses/${id}`, { method: 'DELETE' }),
93
  },
 
94
  scores: {
95
  getAll: () => request('/scores'),
96
  add: (data: any) => request('/scores', { method: 'POST', body: JSON.stringify(data) }),
97
+ update: (id: string, data: any) => request(`/scores/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
98
+ delete: (id: string) => request(`/scores/${id}`, { method: 'DELETE' }),
99
  },
100
+ subjects: {
101
+ getAll: () => request('/subjects'),
102
+ add: (data: any) => request('/subjects', { method: 'POST', body: JSON.stringify(data) }),
103
+ update: (id: string, data: any) => request(`/subjects/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
104
+ delete: (id: string) => request(`/subjects/${id}`, { method: 'DELETE' }),
 
 
 
 
 
 
105
  },
106
+ users: {
107
+ getAll: (params?: any) => {
108
+ const qs = new URLSearchParams(params).toString();
109
+ return request(`/users?${qs}`);
 
 
110
  },
111
+ saveMenuOrder: (userId: string, order: string[]) => request(`/users/${userId}/menu`, { method: 'PUT', body: JSON.stringify({ menuOrder: order }) }),
112
+ applyClass: (data: any) => request('/users/apply-class', { method: 'POST', body: JSON.stringify(data) }),
113
+ getTeachersForClass: (className: string) => request(`/users/teachers?className=${encodeURIComponent(className)}`),
114
+ update: (id: string, data: any) => request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
115
+ delete: (id: string) => request(`/users/${id}`, { method: 'DELETE' }),
116
  },
117
+ schools: {
118
+ getAll: () => request('/schools'),
119
+ getPublic: () => request('/public/schools'),
120
+ add: (data: any) => request('/schools', { method: 'POST', body: JSON.stringify(data) }),
121
+ update: (id: string, data: any) => request(`/schools/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
122
+ delete: (id: string) => request(`/schools/${id}`, { method: 'DELETE' }),
123
+ },
124
+ notifications: {
125
+ getAll: (userId: string, role: string) => request(`/notifications?userId=${userId}&role=${role}`),
126
  },
 
127
  stats: {
128
+ getSummary: () => request('/stats/summary'),
129
  },
 
130
  config: {
131
+ get: () => request('/config'),
132
  getPublic: () => request('/public/config'),
133
+ save: (data: any) => request('/config', { method: 'POST', body: JSON.stringify(data) }),
134
  },
135
+ exams: {
136
+ getAll: () => request('/exams'),
137
+ save: (data: any) => request('/exams', { method: 'POST', body: JSON.stringify(data) }),
138
  },
139
+ schedules: {
140
+ get: (params: any) => {
141
+ const qs = new URLSearchParams(params).toString();
142
+ return request(`/schedules?${qs}`);
143
+ },
144
+ save: (data: any) => request('/schedules', { method: 'POST', body: JSON.stringify(data) }),
145
+ delete: (params: any) => {
146
+ const qs = new URLSearchParams(params).toString();
147
+ return request(`/schedules?${qs}`, { method: 'DELETE' });
148
+ }
 
 
149
  },
150
+ games: {
151
+ grantReward: (data: any) => request('/games/grant', { method: 'POST', body: JSON.stringify(data) }),
152
+ getMountainSession: (className: string) => request(`/games/mountain?className=${encodeURIComponent(className)}`),
153
+ saveMountainSession: (data: any) => request('/games/mountain', { method: 'POST', body: JSON.stringify(data) }),
154
+ getLuckyConfig: (className: string, ownerId: string) => request(`/games/lucky/config?className=${encodeURIComponent(className)}&ownerId=${ownerId}`),
155
+ saveLuckyConfig: (data: any) => request('/games/lucky/config', { method: 'POST', body: JSON.stringify(data) }),
156
+ drawLucky: (studentId: string) => request('/games/lucky/draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
157
+ getMonsterConfig: (className: string) => request(`/games/monster/config?className=${encodeURIComponent(className)}`),
158
+ saveMonsterConfig: (data: any) => request('/games/monster/config', { method: 'POST', body: JSON.stringify(data) }),
159
+ getZenConfig: (className: string) => request(`/games/zen/config?className=${encodeURIComponent(className)}`),
160
+ saveZenConfig: (data: any) => request('/games/zen/config', { method: 'POST', body: JSON.stringify(data) }),
161
  },
 
162
  rewards: {
163
+ getMyRewards: (studentId: string, page: number, pageSize: number) => request(`/rewards/my?studentId=${studentId}&page=${page}&limit=${pageSize}`),
164
+ getClassRewards: (page: number, pageSize: number, className: string) => request(`/rewards/class?page=${page}&limit=${pageSize}&className=${encodeURIComponent(className)}`),
165
+ addReward: (data: any) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
 
 
 
 
 
 
166
  redeem: (id: string) => request(`/rewards/${id}/redeem`, { method: 'POST' }),
167
+ update: (id: string, data: any) => request(`/rewards/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
168
+ delete: (id: string) => request(`/rewards/${id}`, { method: 'DELETE' }),
169
  },
170
+ achievements: {
171
+ getConfig: (className: string) => request(`/achievements/config?className=${encodeURIComponent(className)}`),
172
+ getMyRules: () => request('/achievements/my-rules'),
173
+ saveConfig: (data: any) => request('/achievements/config', { method: 'POST', body: JSON.stringify(data) }),
174
+ saveMyRules: (data: any) => request('/achievements/my-rules', { method: 'POST', body: JSON.stringify(data) }),
175
+ grant: (data: { studentId: string, achievementId: string, semester: string }) => request('/achievements/grant', { method: 'POST', body: JSON.stringify(data) }),
176
+ exchange: (data: { studentId: string, ruleId: string, teacherId?: string }) => request('/achievements/exchange', { method: 'POST', body: JSON.stringify(data) }),
177
+ getStudentAchievements: (studentId: string, semester: string) => request(`/achievements/student/${studentId}?semester=${encodeURIComponent(semester)}`),
178
+ getRulesByTeachers: (teacherIds: string[]) => request('/achievements/rules-by-teachers', { method: 'POST', body: JSON.stringify({ teacherIds }) }),
179
+ },
180
+ attendance: {
181
+ get: (params: any) => {
182
+ const qs = new URLSearchParams(params).toString();
183
+ return request(`/attendance?${qs}`);
184
+ },
185
+ checkIn: (data: { studentId: string, date: string }) => request('/attendance/checkin', { method: 'POST', body: JSON.stringify(data) }),
186
+ applyLeave: (data: any) => request('/attendance/leave', { method: 'POST', body: JSON.stringify(data) }),
187
+ batch: (data: { className: string, date: string }) => request('/attendance/batch', { method: 'POST', body: JSON.stringify(data) }),
188
+ update: (data: any) => request('/attendance/update', { method: 'POST', body: JSON.stringify(data) }),
189
+ },
190
+ calendar: {
191
+ get: (className: string) => request(`/calendar?className=${encodeURIComponent(className)}`),
192
+ add: (data: any) => request('/calendar', { method: 'POST', body: JSON.stringify(data) }),
193
+ delete: (id: string) => request(`/calendar/${id}`, { method: 'DELETE' }),
194
  },
 
195
  wishes: {
196
+ getAll: (params: any) => {
197
+ const qs = new URLSearchParams(params).toString();
198
+ return request(`/wishes?${qs}`);
199
+ },
200
+ create: (data: any) => request('/wishes', { method: 'POST', body: JSON.stringify(data) }),
201
+ fulfill: (id: string) => request(`/wishes/${id}/fulfill`, { method: 'POST' }),
202
+ randomFulfill: (teacherId: string) => request(`/wishes/random?teacherId=${teacherId}`, { method: 'POST' }),
203
  },
 
204
  feedback: {
205
+ getAll: (params: any) => {
206
+ const qs = new URLSearchParams(params).toString();
207
+ return request(`/feedback?${qs}`);
208
+ },
209
+ create: (data: any) => request('/feedback', { method: 'POST', body: JSON.stringify(data) }),
210
+ update: (id: string, data: any) => request(`/feedback/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
211
+ ignoreAll: (userId: string) => request(`/feedback/ignore-all`, { method: 'POST', body: JSON.stringify({ userId }) }),
212
+ },
213
+ todos: {
214
+ getAll: () => request('/todos'),
215
+ add: (content: string) => request('/todos', { method: 'POST', body: JSON.stringify({ content }) }),
216
+ update: (id: string, data: any) => request(`/todos/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
217
+ delete: (id: string) => request(`/todos/${id}`, { method: 'DELETE' }),
218
  },
 
219
  ai: {
220
  chat: (data: { text?: string, audio?: string, history?: { role: string, text?: string }[] }) => request('/ai/chat', { method: 'POST', body: JSON.stringify(data) }),
221
+ evaluate: (data: { question: string, audio?: string, images?: string[] }) => request('/ai/evaluate', { method: 'POST', body: JSON.stringify(data) }),
222
  resetPool: () => request('/ai/reset-pool', { method: 'POST' }),
223
+ getStats: () => request('/ai/stats'),
224
+ getKey: () => request('/ai/config/key'),
225
  },
226
+ batchDelete: (resource: string, ids: string[]) => request(`/batch/${resource}`, { method: 'POST', body: JSON.stringify({ ids }) }),
227
+ };