Spaces:
Sleeping
Sleeping
Upload 54 files
Browse files- App.tsx +7 -2
- components/LiveVoiceFab.tsx +370 -0
- server.js +21 -542
- 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
//
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
const
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
const
|
| 20 |
-
|
| 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 |
-
|
| 198 |
-
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
const
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
if (
|
| 16 |
-
|
|
|
|
| 17 |
}
|
| 18 |
-
return 'http://localhost:7860/api';
|
| 19 |
-
};
|
| 20 |
|
| 21 |
-
const
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 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 |
-
|
| 43 |
-
|
| 44 |
-
|
| 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 |
-
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
auth: {
|
| 61 |
-
login: async (username: string, password
|
| 62 |
-
const
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 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 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
}
|
| 89 |
},
|
| 90 |
getCurrentUser: (): User | null => {
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
| 92 |
try {
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 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 |
-
|
| 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
|
|
|
|
| 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:
|
| 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
|
| 157 |
-
delete: (id: string
|
| 158 |
},
|
| 159 |
-
|
| 160 |
scores: {
|
| 161 |
getAll: () => request('/scores'),
|
| 162 |
add: (data: any) => request('/scores', { method: 'POST', body: JSON.stringify(data) }),
|
| 163 |
-
update: (id: string
|
| 164 |
-
delete: (id: string
|
| 165 |
},
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 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 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
const qs = new URLSearchParams(params as any).toString();
|
| 183 |
-
return request(`/attendance?${qs}`);
|
| 184 |
},
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
| 188 |
},
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
},
|
| 195 |
-
|
| 196 |
stats: {
|
| 197 |
-
getSummary: () => request('/stats')
|
| 198 |
},
|
| 199 |
-
|
| 200 |
config: {
|
| 201 |
-
get: () => request('/config'),
|
| 202 |
getPublic: () => request('/public/config'),
|
| 203 |
-
save: (data:
|
| 204 |
},
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
},
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 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 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
|
|
|
| 232 |
},
|
| 233 |
-
|
| 234 |
rewards: {
|
| 235 |
-
getMyRewards: (studentId: string, page
|
| 236 |
-
getClassRewards: (page
|
| 237 |
-
|
| 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 |
-
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
},
|
| 250 |
-
|
| 251 |
wishes: {
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
},
|
| 260 |
-
|
| 261 |
feedback: {
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 274 |
resetPool: () => request('/ai/reset-pool', { method: 'POST' }),
|
| 275 |
-
getStats: () => request('/ai/stats'),
|
|
|
|
| 276 |
},
|
| 277 |
-
|
| 278 |
-
|
| 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 |
+
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|