dvc890 commited on
Commit
8eb1b37
·
verified ·
1 Parent(s): 3c8b5da

Upload 54 files

Browse files
Files changed (5) hide show
  1. App.tsx +7 -4
  2. ai-routes.js +36 -26
  3. components/LiveAssistant.tsx +362 -0
  4. server.js +9 -38
  5. services/api.ts +222 -186
App.tsx CHANGED
@@ -6,7 +6,7 @@ import { Login } from './pages/Login';
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.
@@ -187,6 +187,10 @@ const AppContent: React.FC = () => {
187
 
188
  if (!isAuthenticated) return <Login onLogin={handleLogin} />;
189
 
 
 
 
 
190
  return (
191
  <div className="flex h-screen bg-gray-50 overflow-hidden">
192
  <Sidebar
@@ -205,9 +209,8 @@ const AppContent: React.FC = () => {
205
  <Suspense fallback={<PageLoading />}>{renderContent()}</Suspense>
206
  </div>
207
  </main>
208
-
209
- {/* Global AI Voice Assistant FAB */}
210
- <LiveVoiceFab />
211
  </div>
212
  </div>
213
  );
 
6
  import { User, UserRole } from './types';
7
  import { api } from './services/api';
8
  import { AlertTriangle, Loader2 } from 'lucide-react';
9
+ import { LiveAssistant } from './components/LiveAssistant';
10
 
11
  // --- Lazy Load Helper with Retry Strategy ---
12
  // This wrapper catches chunk loading errors (common after deployments) and reloads the page once.
 
187
 
188
  if (!isAuthenticated) return <Login onLogin={handleLogin} />;
189
 
190
+ // Permission Check for Live Assistant
191
+ // Admins always have it. Teachers need aiAccess flag.
192
+ const showLiveAssistant = currentUser && (currentUser.role === UserRole.ADMIN || currentUser.aiAccess);
193
+
194
  return (
195
  <div className="flex h-screen bg-gray-50 overflow-hidden">
196
  <Sidebar
 
209
  <Suspense fallback={<PageLoading />}>{renderContent()}</Suspense>
210
  </div>
211
  </main>
212
+ {/* Global Floating AI Assistant */}
213
+ {showLiveAssistant && <LiveAssistant />}
 
214
  </div>
215
  </div>
216
  );
ai-routes.js CHANGED
@@ -4,6 +4,7 @@ const router = express.Router();
4
  const OpenAI = require('openai');
5
  const { ConfigModel, User, AIUsageModel } = require('./models');
6
 
 
7
  // Fetch keys from DB + merge with ENV variables
8
  async function getKeyPool(type) {
9
  const config = await ConfigModel.findOne({ key: 'main' });
@@ -16,32 +17,6 @@ async function getKeyPool(type) {
16
  return pool;
17
  }
18
 
19
- const checkAIAccess = async (req, res, next) => {
20
- const username = req.headers['x-user-username'];
21
- const role = req.headers['x-user-role'];
22
- if (!username) return res.status(401).json({ error: 'Unauthorized' });
23
- const config = await ConfigModel.findOne({ key: 'main' });
24
- if (config && config.enableAI === false && role !== 'ADMIN') return res.status(503).json({ error: 'MAINTENANCE', message: 'AI 服务维护中' });
25
- if (role === 'ADMIN') return next();
26
- const user = await User.findOne({ username });
27
- if (!user || (!user.aiAccess && role !== 'ADMIN')) return res.status(403).json({ error: 'Permission denied' });
28
- next();
29
- };
30
-
31
- // NEW: Endpoint to get a valid Gemini Key for Client-side Live API
32
- router.get('/config/key', checkAIAccess, async (req, res) => {
33
- try {
34
- const keys = await getKeyPool('gemini');
35
- if (keys.length === 0) return res.status(404).json({ error: 'No API keys configured' });
36
- // Return a random key to load balance slightly, or just the first one
37
- const key = keys[0];
38
- res.json({ key });
39
- } catch (e) {
40
- res.status(500).json({ error: e.message });
41
- }
42
- });
43
-
44
- // ... Existing helper functions ...
45
  async function recordUsage(model, provider) {
46
  try {
47
  const today = new Date().toISOString().split('T')[0];
@@ -50,6 +25,18 @@ async function recordUsage(model, provider) {
50
  } catch (e) { console.error("Failed to record AI usage stats:", e); }
51
  }
52
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  function convertGeminiToOpenAI(baseParams) {
54
  const messages = [];
55
  if (baseParams.config?.systemInstruction) messages.push({ role: 'system', content: baseParams.config.systemInstruction });
@@ -192,6 +179,29 @@ async function streamContentWithSmartFallback(baseParams, res) {
192
  throw finalError || new Error('All streaming models unavailable.');
193
  }
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  router.get('/stats', checkAIAccess, async (req, res) => {
196
  try {
197
  const config = await ConfigModel.findOne({ key: 'main' });
 
4
  const OpenAI = require('openai');
5
  const { ConfigModel, User, AIUsageModel } = require('./models');
6
 
7
+ // ... (Key Management, Usage Tracking, Helpers, Provider Management functions remain same as before)
8
  // Fetch keys from DB + merge with ENV variables
9
  async function getKeyPool(type) {
10
  const config = await ConfigModel.findOne({ key: 'main' });
 
17
  return pool;
18
  }
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  async function recordUsage(model, provider) {
21
  try {
22
  const today = new Date().toISOString().split('T')[0];
 
25
  } catch (e) { console.error("Failed to record AI usage stats:", e); }
26
  }
27
 
28
+ const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
29
+ async function callAIWithRetry(aiModelCall, retries = 1) {
30
+ for (let i = 0; i < retries; i++) {
31
+ try { return await aiModelCall(); }
32
+ catch (e) {
33
+ if (e.status === 400 || e.status === 401 || e.status === 403) throw e;
34
+ if (i < retries - 1) { await wait(1000 * Math.pow(2, i)); continue; }
35
+ throw e;
36
+ }
37
+ }
38
+ }
39
+
40
  function convertGeminiToOpenAI(baseParams) {
41
  const messages = [];
42
  if (baseParams.config?.systemInstruction) messages.push({ role: 'system', content: baseParams.config.systemInstruction });
 
179
  throw finalError || new Error('All streaming models unavailable.');
180
  }
181
 
182
+ const checkAIAccess = async (req, res, next) => {
183
+ const username = req.headers['x-user-username'];
184
+ const role = req.headers['x-user-role'];
185
+ if (!username) return res.status(401).json({ error: 'Unauthorized' });
186
+ const config = await ConfigModel.findOne({ key: 'main' });
187
+ if (config && config.enableAI === false && role !== 'ADMIN') return res.status(503).json({ error: 'MAINTENANCE', message: 'AI 服务维护中' });
188
+ if (role === 'ADMIN') return next();
189
+ const user = await User.findOne({ username });
190
+ if (!user || (!user.aiAccess && role !== 'ADMIN')) return res.status(403).json({ error: 'Permission denied' });
191
+ next();
192
+ };
193
+
194
+ // NEW: Endpoint to provide a temporary key for Client-Side Live API
195
+ router.get('/live-access', checkAIAccess, async (req, res) => {
196
+ try {
197
+ const keys = await getKeyPool('gemini');
198
+ if (keys.length === 0) return res.status(503).json({ error: 'No API keys available' });
199
+ // Return the first available key. In a real prod environment, you might issue a short-lived proxy token.
200
+ // For this architecture, we return the key to allow direct WebSocket connection.
201
+ res.json({ key: keys[0] });
202
+ } catch (e) { res.status(500).json({ error: e.message }); }
203
+ });
204
+
205
  router.get('/stats', checkAIAccess, async (req, res) => {
206
  try {
207
  const config = await ConfigModel.findOne({ key: 'main' });
components/LiveAssistant.tsx ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import { GoogleGenAI, LiveServerMessage, Modality } from "@google/genai";
3
+ import { Mic, X, MessageCircle, Volume2, Power, Play, Square, Loader2, Bot, ChevronDown, RefreshCw } from 'lucide-react';
4
+ import { api } from '../services/api';
5
+
6
+ // --- Helper Functions for Audio Processing ---
7
+ function decode(base64: string) {
8
+ const binaryString = atob(base64);
9
+ const len = binaryString.length;
10
+ const bytes = new Uint8Array(len);
11
+ for (let i = 0; i < len; i++) {
12
+ bytes[i] = binaryString.charCodeAt(i);
13
+ }
14
+ return bytes;
15
+ }
16
+
17
+ async function decodeAudioData(
18
+ data: Uint8Array,
19
+ ctx: AudioContext,
20
+ sampleRate: number,
21
+ numChannels: number,
22
+ ): Promise<AudioBuffer> {
23
+ const dataInt16 = new Int16Array(data.buffer);
24
+ const frameCount = dataInt16.length / numChannels;
25
+ const buffer = ctx.createBuffer(numChannels, frameCount, sampleRate);
26
+
27
+ for (let channel = 0; channel < numChannels; channel++) {
28
+ const channelData = buffer.getChannelData(channel);
29
+ for (let i = 0; i < frameCount; i++) {
30
+ channelData[i] = dataInt16[i * numChannels + channel] / 32768.0;
31
+ }
32
+ }
33
+ return buffer;
34
+ }
35
+
36
+ function createBlob(data: Float32Array): { data: string; mimeType: string } {
37
+ const l = data.length;
38
+ const int16 = new Int16Array(l);
39
+ for (let i = 0; i < l; i++) {
40
+ int16[i] = data[i] * 32768;
41
+ }
42
+
43
+ // Custom encode function instead of js-base64
44
+ let binary = '';
45
+ const bytes = new Uint8Array(int16.buffer);
46
+ const len = bytes.byteLength;
47
+ for (let i = 0; i < len; i++) {
48
+ binary += String.fromCharCode(bytes[i]);
49
+ }
50
+ const base64 = btoa(binary);
51
+
52
+ return {
53
+ data: base64,
54
+ mimeType: 'audio/pcm;rate=16000',
55
+ };
56
+ }
57
+
58
+ export const LiveAssistant: React.FC = () => {
59
+ const [isOpen, setIsOpen] = useState(false);
60
+ const [isConnected, setIsConnected] = useState(false);
61
+ const [isMicOn, setIsMicOn] = useState(false); // Toggle for "Hold to Talk" simulation
62
+ const [isSpeaking, setIsSpeaking] = useState(false); // Model speaking
63
+ const [logs, setLogs] = useState<{role: 'user'|'model', text: string}[]>([]);
64
+ const [apiKey, setApiKey] = useState('');
65
+ const [isInitializing, setIsInitializing] = useState(false);
66
+
67
+ // Audio Refs
68
+ const audioContextRef = useRef<AudioContext | null>(null);
69
+ const audioStreamRef = useRef<MediaStream | null>(null);
70
+ const inputProcessorRef = useRef<ScriptProcessorNode | null>(null);
71
+ const inputSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
72
+ const outputNodeRef = useRef<GainNode | null>(null);
73
+ const nextStartTimeRef = useRef<number>(0);
74
+ const activeSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
75
+
76
+ // Session Ref
77
+ const sessionPromiseRef = useRef<Promise<any> | null>(null);
78
+
79
+ // 1. Get Key on Mount (if allowed)
80
+ useEffect(() => {
81
+ // Only fetch key if user opens the widget to save resources
82
+ if (isOpen && !apiKey && !isInitializing) {
83
+ setIsInitializing(true);
84
+ fetch('/api/ai/live-access', {
85
+ headers: {
86
+ 'x-user-username': api.auth.getCurrentUser()?.username || '',
87
+ 'x-user-role': api.auth.getCurrentUser()?.role || ''
88
+ }
89
+ })
90
+ .then(res => res.json())
91
+ .then(data => {
92
+ if (data.key) setApiKey(data.key);
93
+ setIsInitializing(false);
94
+ })
95
+ .catch(() => setIsInitializing(false));
96
+ }
97
+ }, [isOpen]);
98
+
99
+ const connect = async () => {
100
+ if (!apiKey) return;
101
+
102
+ try {
103
+ setIsInitializing(true);
104
+ // Setup Audio Context
105
+ // @ts-ignore
106
+ const AudioCtor = window.AudioContext || window.webkitAudioContext;
107
+ const ctx = new AudioCtor({sampleRate: 24000}); // Output rate usually 24k
108
+ audioContextRef.current = ctx;
109
+ outputNodeRef.current = ctx.createGain();
110
+ outputNodeRef.current.connect(ctx.destination);
111
+
112
+ // Setup Input (Mic) - But don't connect processor yet until "Mic On"
113
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: {
114
+ sampleRate: 16000,
115
+ channelCount: 1,
116
+ echoCancellation: true
117
+ }});
118
+ audioStreamRef.current = stream;
119
+
120
+ // Initialize Gemini Client
121
+ const client = new GoogleGenAI({ apiKey });
122
+
123
+ const sessionPromise = client.live.connect({
124
+ model: 'gemini-2.5-flash-native-audio-preview-09-2025',
125
+ config: {
126
+ responseModalities: [Modality.AUDIO],
127
+ speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } },
128
+ systemInstruction: { parts: [{ text: "你是一位乐于助人的校园AI助手。请用简短、自然的中文进行语音对话。" }] },
129
+ outputAudioTranscription: { model: true } // Enable transcription to show text
130
+ },
131
+ callbacks: {
132
+ onopen: () => {
133
+ setIsConnected(true);
134
+ setIsInitializing(false);
135
+ setLogs(prev => [...prev, {role: 'model', text: '已连接,请点击麦克风说话。'}]);
136
+ },
137
+ onmessage: async (msg: LiveServerMessage) => {
138
+ // Handle Audio Output
139
+ const audioData = msg.serverContent?.modelTurn?.parts?.[0]?.inlineData?.data;
140
+ if (audioData && audioContextRef.current && outputNodeRef.current) {
141
+ setIsSpeaking(true);
142
+ const ctx = audioContextRef.current;
143
+ const buffer = await decodeAudioData(decode(audioData), ctx, 24000, 1);
144
+
145
+ const source = ctx.createBufferSource();
146
+ source.buffer = buffer;
147
+ source.connect(outputNodeRef.current);
148
+
149
+ // Scheduling
150
+ const now = ctx.currentTime;
151
+ const startTime = Math.max(now, nextStartTimeRef.current);
152
+ source.start(startTime);
153
+ nextStartTimeRef.current = startTime + buffer.duration;
154
+
155
+ activeSourcesRef.current.add(source);
156
+ source.onended = () => {
157
+ activeSourcesRef.current.delete(source);
158
+ if (activeSourcesRef.current.size === 0) setIsSpeaking(false);
159
+ };
160
+ }
161
+
162
+ // Handle Text Transcription
163
+ const transcript = msg.serverContent?.modelTurn?.parts?.[0]?.text;
164
+ if (transcript) {
165
+ // Update last model log or add new
166
+ setLogs(prev => {
167
+ const last = prev[prev.length - 1];
168
+ if (last && last.role === 'model' && !last.text.endsWith('\n')) {
169
+ // Append to existing turn (simplified logic)
170
+ return [...prev.slice(0, -1), { ...last, text: last.text + transcript }];
171
+ }
172
+ return [...prev, { role: 'model', text: transcript }];
173
+ });
174
+ }
175
+
176
+ // Handle Transcription of User Input (Echo)
177
+ // @ts-ignore - types might be missing in some SDK versions
178
+ const userTranscript = msg.serverContent?.outputAudioTranscription?.text || msg.serverContent?.turnComplete && "User input processed";
179
+ // Note: Standard API usually doesn't echo user transcript in serverContent easily without config, relying on model turn.
180
+ },
181
+ onclose: () => {
182
+ setIsConnected(false);
183
+ setLogs(prev => [...prev, {role: 'model', text: '连接已断开'}]);
184
+ },
185
+ onerror: (e) => {
186
+ console.error("Live API Error", e);
187
+ setIsConnected(false);
188
+ }
189
+ }
190
+ });
191
+
192
+ sessionPromiseRef.current = sessionPromise;
193
+
194
+ } catch (e) {
195
+ console.error("Connection failed", e);
196
+ setIsInitializing(false);
197
+ }
198
+ };
199
+
200
+ const disconnect = () => {
201
+ // Close Session
202
+ if (sessionPromiseRef.current) {
203
+ sessionPromiseRef.current.then(s => s.close());
204
+ sessionPromiseRef.current = null;
205
+ }
206
+
207
+ // Cleanup Audio
208
+ if (audioStreamRef.current) audioStreamRef.current.getTracks().forEach(t => t.stop());
209
+ if (inputProcessorRef.current) inputProcessorRef.current.disconnect();
210
+ if (inputSourceRef.current) inputSourceRef.current.disconnect();
211
+ if (audioContextRef.current) audioContextRef.current.close();
212
+
213
+ setIsConnected(false);
214
+ setIsMicOn(false);
215
+ setLogs([]);
216
+ };
217
+
218
+ const toggleMic = async () => {
219
+ if (!isConnected || !audioContextRef.current || !sessionPromiseRef.current || !audioStreamRef.current) return;
220
+
221
+ const newMicState = !isMicOn;
222
+ setIsMicOn(newMicState);
223
+
224
+ if (newMicState) {
225
+ // START SENDING
226
+ const ctx = audioContextRef.current;
227
+ // Input context sample rate usually needs to match stream, but we resample manually or rely on createScriptProcessor logic
228
+ // Simple approach: Use 16k context for input if possible, or downsample.
229
+ // Here we assume ctx is created at 24k (output), so input might need resampling or just sending as is if API tolerates.
230
+ // Gemini API expects 16k for input usually.
231
+
232
+ const inputCtx = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: 16000 });
233
+ const source = inputCtx.createMediaStreamSource(audioStreamRef.current);
234
+ const processor = inputCtx.createScriptProcessor(4096, 1, 1);
235
+
236
+ processor.onaudioprocess = (e) => {
237
+ if (!newMicState) return; // Guard
238
+ const inputData = e.inputBuffer.getChannelData(0);
239
+ const blob = createBlob(inputData);
240
+ sessionPromiseRef.current?.then(session => {
241
+ session.sendRealtimeInput({ media: { mimeType: 'audio/pcm;rate=16000', data: blob.data } });
242
+ });
243
+ };
244
+
245
+ source.connect(processor);
246
+ processor.connect(inputCtx.destination);
247
+
248
+ // Store refs to disconnect later
249
+ // @ts-ignore
250
+ inputProcessorRef.current = processor;
251
+ // @ts-ignore
252
+ inputSourceRef.current = source;
253
+ // Store input context to close? Usually separate from output context to handle diff sample rates easily.
254
+
255
+ } else {
256
+ // STOP SENDING
257
+ if (inputProcessorRef.current) {
258
+ inputProcessorRef.current.disconnect();
259
+ inputProcessorRef.current = null;
260
+ }
261
+ if (inputSourceRef.current) {
262
+ inputSourceRef.current.disconnect();
263
+ inputSourceRef.current = null;
264
+ }
265
+ }
266
+ };
267
+
268
+ // Auto-disconnect when closing modal
269
+ useEffect(() => {
270
+ if (!isOpen && isConnected) disconnect();
271
+ }, [isOpen]);
272
+
273
+ if (!api.auth.getCurrentUser()) return null; // Safety check
274
+
275
+ return (
276
+ <div className="fixed bottom-6 right-6 z-[9999]">
277
+ {/* Floating Bubble */}
278
+ {!isOpen && (
279
+ <button
280
+ onClick={() => setIsOpen(true)}
281
+ className="w-14 h-14 rounded-full bg-gradient-to-br from-indigo-600 to-purple-600 text-white shadow-2xl flex items-center justify-center hover:scale-110 transition-transform cursor-pointer border-2 border-white/20 animate-in zoom-in"
282
+ >
283
+ <Bot size={28} />
284
+ </button>
285
+ )}
286
+
287
+ {/* Expanded Interface */}
288
+ {isOpen && (
289
+ <div className="bg-white w-80 md:w-96 rounded-2xl shadow-2xl border border-gray-200 overflow-hidden flex flex-col animate-in slide-in-from-bottom-5 fade-in duration-300" style={{maxHeight: '600px', height: '80vh'}}>
290
+ {/* Header */}
291
+ <div className="bg-gradient-to-r from-indigo-600 to-purple-600 p-4 flex justify-between items-center text-white shrink-0">
292
+ <div className="flex items-center gap-2">
293
+ <Bot size={20}/>
294
+ <span className="font-bold">AI 语音助理</span>
295
+ </div>
296
+ <div className="flex items-center gap-2">
297
+ <button onClick={disconnect} title="重置" className="hover:bg-white/20 p-1.5 rounded-full"><RefreshCw size={16}/></button>
298
+ <button onClick={() => setIsOpen(false)} title="最小化" className="hover:bg-white/20 p-1.5 rounded-full"><ChevronDown size={20}/></button>
299
+ </div>
300
+ </div>
301
+
302
+ {/* Content / Logs */}
303
+ <div className="flex-1 bg-gray-50 p-4 overflow-y-auto space-y-3 custom-scrollbar">
304
+ {logs.length === 0 && isConnected && (
305
+ <div className="text-center text-gray-400 mt-10 text-sm">
306
+ <p>点击下方麦克风开始说话</p>
307
+ <p className="text-xs mt-2 opacity-70">Gemini 2.5 Flash (Native Audio)</p>
308
+ </div>
309
+ )}
310
+ {logs.map((log, i) => (
311
+ <div key={i} className={`flex ${log.role === 'user' ? 'justify-end' : 'justify-start'}`}>
312
+ <div className={`max-w-[85%] p-3 rounded-2xl text-sm ${log.role === 'user' ? 'bg-indigo-600 text-white rounded-tr-none' : 'bg-white border border-gray-200 text-gray-800 rounded-tl-none shadow-sm'}`}>
313
+ {log.text}
314
+ </div>
315
+ </div>
316
+ ))}
317
+ {isSpeaking && (
318
+ <div className="flex justify-start">
319
+ <div className="bg-white border border-gray-200 px-4 py-2 rounded-full shadow-sm flex items-center gap-2">
320
+ <span className="flex gap-1 h-3 items-end">
321
+ <span className="w-1 bg-indigo-500 animate-[bounce_1s_infinite] h-2"></span>
322
+ <span className="w-1 bg-indigo-500 animate-[bounce_1.2s_infinite] h-3"></span>
323
+ <span className="w-1 bg-indigo-500 animate-[bounce_0.8s_infinite] h-1"></span>
324
+ </span>
325
+ <span className="text-xs text-indigo-600 font-bold">正在说话...</span>
326
+ </div>
327
+ </div>
328
+ )}
329
+ </div>
330
+
331
+ {/* Controls */}
332
+ <div className="p-4 bg-white border-t border-gray-100 shrink-0">
333
+ {!isConnected ? (
334
+ <button
335
+ onClick={connect}
336
+ disabled={isInitializing || !apiKey}
337
+ className="w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-bold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
338
+ >
339
+ {isInitializing ? <Loader2 className="animate-spin"/> : <Power size={18}/>}
340
+ {isInitializing ? '正在连接...' : '开启语音会话'}
341
+ </button>
342
+ ) : (
343
+ <div className="flex flex-col gap-3">
344
+ <div className="flex items-center justify-center">
345
+ <button
346
+ onClick={toggleMic}
347
+ className={`w-16 h-16 rounded-full flex items-center justify-center shadow-lg transition-all transform active:scale-95 ${isMicOn ? 'bg-red-500 text-white animate-pulse ring-4 ring-red-100' : 'bg-indigo-100 text-indigo-600 hover:bg-indigo-200'}`}
348
+ >
349
+ {isMicOn ? <Square fill="currentColor" size={24}/> : <Mic size={28}/>}
350
+ </button>
351
+ </div>
352
+ <p className="text-center text-xs text-gray-400 font-medium">
353
+ {isMicOn ? '正在聆听... 点击停止发送' : '点击麦克风开始说话'}
354
+ </p>
355
+ </div>
356
+ )}
357
+ </div>
358
+ </div>
359
+ )}
360
+ </div>
361
+ );
362
+ };
server.js CHANGED
@@ -3,7 +3,7 @@ const {
3
  School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
4
  ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
5
  AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
6
- WishModel, FeedbackModel, TodoModel, AIUsageModel
7
  } = require('./models');
8
 
9
  // Import AI Routes
@@ -113,6 +113,8 @@ const generateStudentNo = async () => {
113
  // MOUNT AI ROUTES
114
  app.use('/api/ai', aiRoutes);
115
 
 
 
116
  // --- TODO LIST ENDPOINTS ---
117
  app.get('/api/todos', async (req, res) => {
118
  const username = req.headers['x-user-username'];
@@ -199,6 +201,7 @@ app.put('/api/users/:id/menu-order', async (req, res) => {
199
  res.json({ success: true });
200
  });
201
 
 
202
  app.get('/api/classes/:className/teachers', async (req, res) => {
203
  const { className } = req.params;
204
  const schoolId = req.headers['x-school-id'];
@@ -365,36 +368,13 @@ app.post('/api/achievements/exchange', async (req, res) => {
365
  if (rule.rewardType === 'DRAW_COUNT') { await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: rule.rewardValue } }); }
366
  res.json({ success: true });
367
  });
368
-
369
- // --- UPDATED AUTH/ME LOGIC ---
370
  app.get('/api/auth/me', async (req, res) => {
371
- // 1. Try header (for internal AI calls if needed)
372
  const username = req.headers['x-user-username'];
373
- if (username) {
374
- const user = await User.findOne({ username });
375
- if (user) return res.json(user);
376
- }
377
-
378
- // 2. Try Authorization Token (Standard way)
379
- const authHeader = req.headers.authorization;
380
- if (authHeader) {
381
- const token = authHeader.split(' ')[1]; // Bearer <token>
382
- if (token && token.startsWith('mock-token-')) {
383
- const userId = token.replace('mock-token-', '');
384
- try {
385
- // Simplified lookup without strict regex to support migrated/mock data
386
- const user = await User.findById(userId);
387
- if (user) return res.json(user);
388
- } catch (e) {
389
- // Ignore cast errors or invalid ID formats
390
- console.log('Session lookup error:', e.message);
391
- }
392
- }
393
- }
394
-
395
- return res.status(401).json({ error: 'Unauthorized' });
396
  });
397
-
398
  app.post('/api/auth/update-profile', async (req, res) => {
399
  const { userId, trueName, phone, avatar, currentPassword, newPassword } = req.body;
400
  try {
@@ -524,16 +504,7 @@ app.post('/api/courses', async (req, res) => { const data = injectSchoolId(req,
524
  app.get('/api/public/schools', async (req, res) => { res.json(await School.find({}, 'name code _id')); });
525
  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); });
526
  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 }) }); });
527
-
528
- // --- UPDATED LOGIN: RETURN { token, user } ---
529
- app.post('/api/auth/login', async (req, res) => {
530
- const { username, password } = req.body;
531
- const user = await User.findOne({ username, password });
532
- if (!user) return res.status(401).json({ message: 'Error' });
533
- if (user.status !== 'active') return res.status(403).json({ error: 'PENDING_APPROVAL' });
534
- res.json({ token: 'mock-token-' + user._id.toString(), user });
535
- });
536
-
537
  app.get('/api/schools', async (req, res) => { res.json(await School.find()); });
538
  app.post('/api/schools', async (req, res) => { res.json(await School.create(req.body)); });
539
  app.put('/api/schools/:id', async (req, res) => { await School.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
 
3
  School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
4
  ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
5
  AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
6
+ WishModel, FeedbackModel, TodoModel
7
  } = require('./models');
8
 
9
  // Import AI Routes
 
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'];
 
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'];
 
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 {
 
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({}); });
services/api.ts CHANGED
@@ -1,248 +1,284 @@
1
 
2
- import { User } from '../types';
3
-
4
- const API_BASE = '/api';
5
-
6
- const request = async (endpoint: string, options?: RequestInit & { skipRedirect?: boolean }) => {
7
- const token = localStorage.getItem('token');
8
- const headers = {
9
- 'Content-Type': 'application/json',
10
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
11
- ...options?.headers,
12
- };
13
-
14
- // Add school context header if available (for admin view)
15
- const schoolId = localStorage.getItem('admin_view_school_id');
16
- if (schoolId) {
17
- // @ts-ignore
18
- headers['x-school-id'] = schoolId;
19
  }
 
 
20
 
21
- const response = await fetch(`${API_BASE}${endpoint}`, {
22
- ...options,
23
- headers,
24
- });
25
 
26
- if (response.status === 401) {
27
- if (!options?.skipRedirect) {
28
- localStorage.removeItem('token');
29
- localStorage.removeItem('user');
30
- window.location.href = '/';
 
 
 
 
 
 
 
 
 
 
 
31
  }
32
- throw new Error('Unauthorized');
33
  }
34
 
35
- if (!response.ok) {
36
- const errorData = await response.json().catch(() => ({}));
37
- throw new Error(errorData.message || errorData.error || 'API Request Failed');
38
- }
39
 
40
- // Handle empty responses
41
- const text = await response.text();
42
- return text ? JSON.parse(text) : {};
43
- };
 
 
 
 
 
 
 
 
44
 
45
  export const api = {
46
- init: () => {
47
- // Any initialization logic if needed
48
- },
49
  auth: {
50
- login: async (username: string, password?: string) => {
51
- const res = await request('/auth/login', {
52
- method: 'POST',
53
- body: JSON.stringify({ username, password }),
54
- });
55
- localStorage.setItem('token', res.token);
56
- localStorage.setItem('user', JSON.stringify(res.user));
57
- return res.user;
58
  },
59
- register: async (data: any) => request('/auth/register', { method: 'POST', body: JSON.stringify(data) }),
60
- logout: () => {
61
- localStorage.removeItem('token');
62
- localStorage.removeItem('user');
63
- window.location.reload();
 
 
 
64
  },
65
- getCurrentUser: (): User | null => {
66
- try {
67
- const userStr = localStorage.getItem('user');
68
- // Handle explicit "undefined" string or null
69
- if (!userStr || userStr === 'undefined' || userStr === 'null') {
70
- if(userStr) localStorage.removeItem('user');
71
- return null;
72
- }
73
- return JSON.parse(userStr);
74
- } catch (e) {
75
- console.warn('Failed to parse user session, clearing cache.');
76
  localStorage.removeItem('user');
77
- return null;
78
  }
79
  },
80
- refreshSession: async () => {
 
81
  try {
82
- // Pass skipRedirect: true to prevent infinite reload loop if server returns 401
83
- // @ts-ignore
84
- const user = await request('/auth/me', { skipRedirect: true });
85
- localStorage.setItem('user', JSON.stringify(user));
86
- return user;
87
- } catch (e) {
88
- // If refresh fails (e.g. 401), clear local user to force re-login on next check, but don't hard reload
89
- localStorage.removeItem('user');
90
- localStorage.removeItem('token');
91
- return null;
92
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  },
94
- updateProfile: (data: any) => request('/auth/profile', { method: 'PUT', body: JSON.stringify(data) }),
 
 
 
 
 
95
  },
 
96
  students: {
97
  getAll: () => request('/students'),
98
  add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
99
  update: (id: string, data: any) => request(`/students/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
100
- delete: (id: string) => request(`/students/${id}`, { method: 'DELETE' }),
101
- transfer: (data: { studentId: string, targetClass: string }) => request('/students/transfer', { method: 'POST', body: JSON.stringify(data) }),
102
  promote: (data: { teacherFollows: boolean }) => request('/students/promote', { method: 'POST', body: JSON.stringify(data) }),
 
103
  },
 
104
  classes: {
105
  getAll: () => request('/classes'),
106
- add: (data: any) => request('/classes', { method: 'POST', body: JSON.stringify(data) }),
107
- delete: (id: string | number) => request(`/classes/${id}`, { method: 'DELETE' }),
108
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  courses: {
110
  getAll: () => request('/courses'),
111
  add: (data: any) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
112
- update: (id: string, data: any) => request(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
113
- delete: (id: string) => request(`/courses/${id}`, { method: 'DELETE' }),
114
  },
 
115
  scores: {
116
  getAll: () => request('/scores'),
117
  add: (data: any) => request('/scores', { method: 'POST', body: JSON.stringify(data) }),
118
- update: (id: string, data: any) => request(`/scores/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
119
- delete: (id: string) => request(`/scores/${id}`, { method: 'DELETE' }),
120
- },
121
- subjects: {
122
- getAll: () => request('/subjects'),
123
- add: (data: any) => request('/subjects', { method: 'POST', body: JSON.stringify(data) }),
124
- update: (id: string, data: any) => request(`/subjects/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
125
- delete: (id: string) => request(`/subjects/${id}`, { method: 'DELETE' }),
126
  },
127
- users: {
128
- getAll: (params?: any) => {
129
- const qs = new URLSearchParams(params).toString();
130
- return request(`/users?${qs}`);
 
131
  },
132
- saveMenuOrder: (userId: string, order: string[]) => request(`/users/${userId}/menu`, { method: 'PUT', body: JSON.stringify({ menuOrder: order }) }),
133
- applyClass: (data: any) => request('/users/apply-class', { method: 'POST', body: JSON.stringify(data) }),
134
- getTeachersForClass: (className: string) => request(`/users/teachers?className=${encodeURIComponent(className)}`),
135
- update: (id: string, data: any) => request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
136
- delete: (id: string) => request(`/users/${id}`, { method: 'DELETE' }),
137
  },
138
- schools: {
139
- getAll: () => request('/schools'),
140
- getPublic: () => request('/public/schools'),
141
- add: (data: any) => request('/schools', { method: 'POST', body: JSON.stringify(data) }),
142
- update: (id: string, data: any) => request(`/schools/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
143
- delete: (id: string) => request(`/schools/${id}`, { method: 'DELETE' }),
 
 
 
 
144
  },
145
- notifications: {
146
- getAll: (userId: string, role: string) => request(`/notifications?userId=${userId}&role=${role}`),
 
 
 
147
  },
 
148
  stats: {
149
- getSummary: () => request('/stats/summary'),
150
  },
 
151
  config: {
152
- get: () => request('/config'),
153
  getPublic: () => request('/public/config'),
154
- save: (data: any) => request('/config', { method: 'POST', body: JSON.stringify(data) }),
155
  },
156
- exams: {
157
- getAll: () => request('/exams'),
158
- save: (data: any) => request('/exams', { method: 'POST', body: JSON.stringify(data) }),
159
- },
160
- schedules: {
161
- get: (params: any) => {
162
- const qs = new URLSearchParams(params).toString();
163
- return request(`/schedules?${qs}`);
164
- },
165
- save: (data: any) => request('/schedules', { method: 'POST', body: JSON.stringify(data) }),
166
- delete: (params: any) => {
167
- const qs = new URLSearchParams(params).toString();
168
- return request(`/schedules?${qs}`, { method: 'DELETE' });
169
- }
170
  },
 
171
  games: {
172
- grantReward: (data: any) => request('/games/grant', { method: 'POST', body: JSON.stringify(data) }),
173
- getMountainSession: (className: string) => request(`/games/mountain?className=${encodeURIComponent(className)}`),
174
- saveMountainSession: (data: any) => request('/games/mountain', { method: 'POST', body: JSON.stringify(data) }),
175
- getLuckyConfig: (className: string, ownerId: string) => request(`/games/lucky/config?className=${encodeURIComponent(className)}&ownerId=${ownerId}`),
176
- saveLuckyConfig: (data: any) => request('/games/lucky/config', { method: 'POST', body: JSON.stringify(data) }),
177
- drawLucky: (studentId: string) => request('/games/lucky/draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
178
- getMonsterConfig: (className: string) => request(`/games/monster/config?className=${encodeURIComponent(className)}`),
179
- saveMonsterConfig: (data: any) => request('/games/monster/config', { method: 'POST', body: JSON.stringify(data) }),
180
- getZenConfig: (className: string) => request(`/games/zen/config?className=${encodeURIComponent(className)}`),
181
- saveZenConfig: (data: any) => request('/games/zen/config', { method: 'POST', body: JSON.stringify(data) }),
182
- },
183
- rewards: {
184
- getMyRewards: (studentId: string, page: number, pageSize: number) => request(`/rewards/my?studentId=${studentId}&page=${page}&limit=${pageSize}`),
185
- getClassRewards: (page: number, pageSize: number, className: string) => request(`/rewards/class?page=${page}&limit=${pageSize}&className=${encodeURIComponent(className)}`),
186
- addReward: (data: any) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
187
- redeem: (id: string) => request(`/rewards/${id}/redeem`, { method: 'POST' }),
188
- update: (id: string, data: any) => request(`/rewards/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
189
- delete: (id: string) => request(`/rewards/${id}`, { method: 'DELETE' }),
190
  },
 
191
  achievements: {
192
- getConfig: (className: string) => request(`/achievements/config?className=${encodeURIComponent(className)}`),
193
- getMyRules: () => request('/achievements/my-rules'),
194
- saveConfig: (data: any) => request('/achievements/config', { method: 'POST', body: JSON.stringify(data) }),
195
- saveMyRules: (data: any) => request('/achievements/my-rules', { method: 'POST', body: JSON.stringify(data) }),
196
- grant: (data: { studentId: string, achievementId: string, semester: string }) => request('/achievements/grant', { method: 'POST', body: JSON.stringify(data) }),
197
- exchange: (data: { studentId: string, ruleId: string, teacherId?: string }) => request('/achievements/exchange', { method: 'POST', body: JSON.stringify(data) }),
198
- getStudentAchievements: (studentId: string, semester: string) => request(`/achievements/student/${studentId}?semester=${encodeURIComponent(semester)}`),
199
- getRulesByTeachers: (teacherIds: string[]) => request('/achievements/rules-by-teachers', { method: 'POST', body: JSON.stringify({ teacherIds }) }),
200
  },
201
- attendance: {
202
- get: (params: any) => {
203
- const qs = new URLSearchParams(params).toString();
204
- return request(`/attendance?${qs}`);
205
- },
206
- checkIn: (data: { studentId: string, date: string }) => request('/attendance/checkin', { method: 'POST', body: JSON.stringify(data) }),
207
- applyLeave: (data: any) => request('/attendance/leave', { method: 'POST', body: JSON.stringify(data) }),
208
- batch: (data: { className: string, date: string }) => request('/attendance/batch', { method: 'POST', body: JSON.stringify(data) }),
209
- update: (data: any) => request('/attendance/update', { method: 'POST', body: JSON.stringify(data) }),
 
 
 
210
  },
211
- calendar: {
212
- get: (className: string) => request(`/calendar?className=${encodeURIComponent(className)}`),
213
- add: (data: any) => request('/calendar', { method: 'POST', body: JSON.stringify(data) }),
214
- delete: (id: string) => request(`/calendar/${id}`, { method: 'DELETE' }),
215
  },
 
216
  wishes: {
217
- getAll: (params: any) => {
218
- const qs = new URLSearchParams(params).toString();
219
- return request(`/wishes?${qs}`);
220
- },
221
- create: (data: any) => request('/wishes', { method: 'POST', body: JSON.stringify(data) }),
222
- fulfill: (id: string) => request(`/wishes/${id}/fulfill`, { method: 'POST' }),
223
- randomFulfill: (teacherId: string) => request(`/wishes/random?teacherId=${teacherId}`, { method: 'POST' }),
224
  },
 
225
  feedback: {
226
- getAll: (params: any) => {
227
- const qs = new URLSearchParams(params).toString();
228
- return request(`/feedback?${qs}`);
229
- },
230
- create: (data: any) => request('/feedback', { method: 'POST', body: JSON.stringify(data) }),
231
- update: (id: string, data: any) => request(`/feedback/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
232
- ignoreAll: (userId: string) => request(`/feedback/ignore-all`, { method: 'POST', body: JSON.stringify({ userId }) }),
233
- },
234
- todos: {
235
- getAll: () => request('/todos'),
236
- add: (content: string) => request('/todos', { method: 'POST', body: JSON.stringify({ content }) }),
237
- update: (id: string, data: any) => request(`/todos/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
238
- delete: (id: string) => request(`/todos/${id}`, { method: 'DELETE' }),
239
  },
 
240
  ai: {
241
  chat: (data: { text?: string, audio?: string, history?: { role: string, text?: string }[] }) => request('/ai/chat', { method: 'POST', body: JSON.stringify(data) }),
242
- evaluate: (data: { question: string, audio?: string, images?: string[] }) => request('/ai/evaluate', { method: 'POST', body: JSON.stringify(data) }),
243
  resetPool: () => request('/ai/reset-pool', { method: 'POST' }),
244
- getStats: () => request('/ai/stats'),
245
- getKey: () => request('/ai/config/key'),
246
  },
247
- batchDelete: (resource: string, ids: string[]) => request(`/batch/${resource}`, { method: 'POST', body: JSON.stringify({ ids }) }),
 
 
 
 
 
 
248
  };
 
1
 
2
+ // ... existing imports
3
+ import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig, Attendance, LeaveRequest, AchievementConfig, SchoolCalendarEntry, TeacherExchangeConfig, Wish, Feedback, AIChatMessage, Todo } from '../types';
4
+
5
+ // ... existing getBaseUrl ...
6
+ const getBaseUrl = () => {
7
+ let isProd = false;
8
+ try {
9
+ // @ts-ignore
10
+ if (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.PROD) {
11
+ isProd = true;
12
+ }
13
+ } catch (e) {}
14
+
15
+ if (isProd || (typeof window !== 'undefined' && window.location.port === '7860')) {
16
+ return '/api';
 
 
17
  }
18
+ return 'http://localhost:7860/api';
19
+ };
20
 
21
+ const API_BASE_URL = getBaseUrl();
 
 
 
22
 
23
+ async function request(endpoint: string, options: RequestInit = {}) {
24
+ const headers: any = { 'Content-Type': 'application/json', ...options.headers };
25
+
26
+ if (typeof window !== 'undefined') {
27
+ const currentUser = JSON.parse(localStorage.getItem('user') || 'null');
28
+ const selectedSchoolId = localStorage.getItem('admin_view_school_id');
29
+
30
+ if (currentUser?.role === 'ADMIN' && selectedSchoolId) {
31
+ headers['x-school-id'] = selectedSchoolId;
32
+ } else if (currentUser?.schoolId) {
33
+ headers['x-school-id'] = currentUser.schoolId;
34
+ }
35
+
36
+ if (currentUser?.role) {
37
+ headers['x-user-role'] = currentUser.role;
38
+ headers['x-user-username'] = currentUser.username;
39
  }
 
40
  }
41
 
42
+ const res = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers });
 
 
 
43
 
44
+ if (!res.ok) {
45
+ if (res.status === 401) throw new Error('AUTH_FAILED');
46
+ const errorData = await res.json().catch(() => ({}));
47
+ const errorMessage = errorData.error || errorData.message || `Server Error: ${res.status}`;
48
+ if (errorData.error === 'PENDING_APPROVAL') throw new Error('PENDING_APPROVAL');
49
+ if (errorData.error === 'BANNED') throw new Error('BANNED');
50
+ if (errorData.error === 'CONFLICT') throw new Error(errorData.message);
51
+ if (errorData.error === 'INVALID_PASSWORD') throw new Error('INVALID_PASSWORD');
52
+ throw new Error(errorMessage);
53
+ }
54
+ return res.json();
55
+ }
56
 
57
  export const api = {
58
+ init: () => console.log('🔗 API:', API_BASE_URL),
59
+
 
60
  auth: {
61
+ login: async (username: string, password: string): Promise<User> => {
62
+ const user = await request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
63
+ if (typeof window !== 'undefined') {
64
+ localStorage.setItem('user', JSON.stringify(user));
65
+ localStorage.removeItem('admin_view_school_id');
66
+ }
67
+ return user;
 
68
  },
69
+ refreshSession: async (): Promise<User | null> => {
70
+ try {
71
+ const user = await request('/auth/me');
72
+ if (typeof window !== 'undefined' && user) {
73
+ localStorage.setItem('user', JSON.stringify(user));
74
+ }
75
+ return user;
76
+ } catch (e) { return null; }
77
  },
78
+ register: async (data: any): Promise<User> => {
79
+ return await request('/auth/register', { method: 'POST', body: JSON.stringify(data) });
80
+ },
81
+ updateProfile: async (data: any): Promise<any> => {
82
+ return await request('/auth/update-profile', { method: 'POST', body: JSON.stringify(data) });
83
+ },
84
+ logout: () => {
85
+ if (typeof window !== 'undefined') {
 
 
 
86
  localStorage.removeItem('user');
87
+ localStorage.removeItem('admin_view_school_id');
88
  }
89
  },
90
+ getCurrentUser: (): User | null => {
91
+ if (typeof window !== 'undefined') {
92
  try {
93
+ const stored = localStorage.getItem('user');
94
+ if (stored) return JSON.parse(stored);
95
+ } catch (e) {
96
+ localStorage.removeItem('user');
 
 
 
 
 
 
97
  }
98
+ }
99
+ return null;
100
+ }
101
+ },
102
+
103
+ schools: {
104
+ getPublic: () => request('/public/schools'),
105
+ getAll: () => request('/schools'),
106
+ add: (data: School) => request('/schools', { method: 'POST', body: JSON.stringify(data) }),
107
+ update: (id: string, data: Partial<School>) => request(`/schools/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
108
+ delete: (id: string) => request(`/schools/${id}`, { method: 'DELETE' })
109
+ },
110
+
111
+ users: {
112
+ getAll: (options?: { global?: boolean; role?: string }) => {
113
+ const params = new URLSearchParams();
114
+ if (options?.global) params.append('global', 'true');
115
+ if (options?.role) params.append('role', options.role);
116
+ return request(`/users?${params.toString()}`);
117
  },
118
+ update: (id: string, data: Partial<User>) => request(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
119
+ delete: (id: string) => request(`/users/${id}`, { method: 'DELETE' }),
120
+ applyClass: (data: { userId: string, type: 'CLAIM'|'RESIGN', targetClass?: string, action: 'APPLY'|'APPROVE'|'REJECT' }) =>
121
+ request('/users/class-application', { method: 'POST', body: JSON.stringify(data) }),
122
+ getTeachersForClass: (className: string) => request(`/classes/${encodeURIComponent(className)}/teachers`),
123
+ saveMenuOrder: (userId: string, order: string[]) => request(`/users/${userId}/menu-order`, { method: 'PUT', body: JSON.stringify({ menuOrder: order }) }), // NEW
124
  },
125
+
126
  students: {
127
  getAll: () => request('/students'),
128
  add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
129
  update: (id: string, data: any) => request(`/students/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
130
+ delete: (id: string | number) => request(`/students/${id}`, { method: 'DELETE' }),
 
131
  promote: (data: { teacherFollows: boolean }) => request('/students/promote', { method: 'POST', body: JSON.stringify(data) }),
132
+ transfer: (data: { studentId: string, targetClass: string }) => request('/students/transfer', { method: 'POST', body: JSON.stringify(data) })
133
  },
134
+
135
  classes: {
136
  getAll: () => request('/classes'),
137
+ add: (data: ClassInfo) => request('/classes', { method: 'POST', body: JSON.stringify(data) }),
138
+ delete: (id: string | number) => request(`/classes/${id}`, { method: 'DELETE' })
139
  },
140
+
141
+ subjects: {
142
+ getAll: () => request('/subjects'),
143
+ add: (data: Subject) => request('/subjects', { method: 'POST', body: JSON.stringify(data) }),
144
+ update: (id: string | number, data: Partial<Subject>) => request(`/subjects/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
145
+ delete: (id: string | number) => request(`/subjects/${id}`, { method: 'DELETE' })
146
+ },
147
+
148
+ exams: {
149
+ getAll: () => request('/exams'),
150
+ save: (data: any) => request('/exams', { method: 'POST', body: JSON.stringify(data) })
151
+ },
152
+
153
  courses: {
154
  getAll: () => request('/courses'),
155
  add: (data: any) => request('/courses', { method: 'POST', body: JSON.stringify(data) }),
156
+ update: (id: string | number, data: any) => request(`/courses/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
157
+ delete: (id: string | number) => request(`/courses/${id}`, { method: 'DELETE' })
158
  },
159
+
160
  scores: {
161
  getAll: () => request('/scores'),
162
  add: (data: any) => request('/scores', { method: 'POST', body: JSON.stringify(data) }),
163
+ update: (id: string | number, data: any) => request(`/scores/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
164
+ delete: (id: string | number) => request(`/scores/${id}`, { method: 'DELETE' })
 
 
 
 
 
 
165
  },
166
+
167
+ schedules: {
168
+ get: (params: { className?: string; teacherName?: string; grade?: string }) => {
169
+ const qs = new URLSearchParams(params as any).toString();
170
+ return request(`/schedules?${qs}`);
171
  },
172
+ save: (data: Schedule) => request('/schedules', { method: 'POST', body: JSON.stringify(data) }),
173
+ delete: (params: { className: string; dayOfWeek: number; period: number }) => {
174
+ const qs = new URLSearchParams(params as any).toString();
175
+ return request(`/schedules?${qs}`, { method: 'DELETE' });
176
+ }
177
  },
178
+
179
+ attendance: {
180
+ checkIn: (data: { studentId: string, date: string, status?: string }) => request('/attendance/check-in', { method: 'POST', body: JSON.stringify(data) }),
181
+ get: (params: { className?: string, date?: string, studentId?: string }) => {
182
+ const qs = new URLSearchParams(params as any).toString();
183
+ return request(`/attendance?${qs}`);
184
+ },
185
+ batch: (data: { className: string, date: string, status?: string }) => request('/attendance/batch', { method: 'POST', body: JSON.stringify(data) }),
186
+ update: (data: { studentId: string, date: string, status: string }) => request('/attendance/update', { method: 'PUT', body: JSON.stringify(data) }),
187
+ applyLeave: (data: { studentId: string, studentName: string, className: string, reason: string, startDate: string, endDate: string }) => request('/leave', { method: 'POST', body: JSON.stringify(data) }),
188
  },
189
+
190
+ calendar: {
191
+ get: (className: string) => request(`/attendance/calendar?className=${className}`),
192
+ add: (data: SchoolCalendarEntry) => request('/attendance/calendar', { method: 'POST', body: JSON.stringify(data) }),
193
+ delete: (id: string) => request(`/attendance/calendar/${id}`, { method: 'DELETE' })
194
  },
195
+
196
  stats: {
197
+ getSummary: () => request('/stats')
198
  },
199
+
200
  config: {
201
+ get: () => request('/config'),
202
  getPublic: () => request('/public/config'),
203
+ save: (data: SystemConfig) => request('/config', { method: 'POST', body: JSON.stringify(data) })
204
  },
205
+
206
+ notifications: {
207
+ getAll: (userId: string, role: string) => request(`/notifications?userId=${userId}&role=${role}`),
 
 
 
 
 
 
 
 
 
 
 
208
  },
209
+
210
  games: {
211
+ getMountainSession: (className: string) => request(`/games/mountain?className=${className}`),
212
+ saveMountainSession: (data: GameSession) => request('/games/mountain', { method: 'POST', body: JSON.stringify(data) }),
213
+ getLuckyConfig: (className?: string, ownerId?: string) => request(`/games/lucky-config?className=${className || ''}${ownerId ? `&ownerId=${ownerId}` : ''}`),
214
+ saveLuckyConfig: (data: LuckyDrawConfig) => request('/games/lucky-config', { method: 'POST', body: JSON.stringify(data) }),
215
+ drawLucky: (studentId: string) => request('/games/lucky-draw', { method: 'POST', body: JSON.stringify({ studentId }) }),
216
+ grantReward: (data: { studentId: string, count: number, rewardType: string, name?: string }) => request('/games/grant-reward', { method: 'POST', body: JSON.stringify(data) }),
217
+ getMonsterConfig: (className: string) => request(`/games/monster-config?className=${className}`),
218
+ saveMonsterConfig: (data: any) => request('/games/monster-config', { method: 'POST', body: JSON.stringify(data) }),
219
+ getZenConfig: (className: string) => request(`/games/zen-config?className=${className}`),
220
+ saveZenConfig: (data: any) => request('/games/zen-config', { method: 'POST', body: JSON.stringify(data) }),
 
 
 
 
 
 
 
 
221
  },
222
+
223
  achievements: {
224
+ getConfig: (className: string) => request(`/achievements/config?className=${className}`),
225
+ saveConfig: (data: AchievementConfig) => request('/achievements/config', { method: 'POST', body: JSON.stringify(data) }),
226
+ getStudentAchievements: (studentId: string, semester?: string) => request(`/achievements/student?studentId=${studentId}${semester ? `&semester=${semester}` : ''}`),
227
+ grant: (data: { studentId: string, achievementId: string, semester: string }) => request('/achievements/grant', { method: 'POST', body: JSON.stringify(data) }),
228
+ exchange: (data: { studentId: string, ruleId: string, teacherId?: string }) => request('/achievements/exchange', { method: 'POST', body: JSON.stringify(data) }),
229
+ getMyRules: () => request('/achievements/teacher-rules'),
230
+ saveMyRules: (data: TeacherExchangeConfig) => request('/achievements/teacher-rules', { method: 'POST', body: JSON.stringify(data) }),
231
+ getRulesByTeachers: (teacherIds: string[]) => request(`/achievements/teacher-rules?teacherIds=${teacherIds.join(',')}`),
232
  },
233
+
234
+ rewards: {
235
+ getMyRewards: (studentId: string, page = 1, limit = 20) => request(`/rewards?studentId=${studentId}&page=${page}&limit=${limit}&excludeType=CONSOLATION`),
236
+ getClassRewards: (page = 1, limit = 20, className?: string) => {
237
+ let qs = `scope=class&page=${page}&limit=${limit}&excludeType=CONSOLATION`;
238
+ if (className) qs += `&className=${className}`;
239
+ return request(`/rewards?${qs}`);
240
+ },
241
+ addReward: (data: Partial<StudentReward>) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
242
+ update: (id: string, data: Partial<StudentReward>) => request(`/rewards/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
243
+ delete: (id: string) => request(`/rewards/${id}`, { method: 'DELETE' }),
244
+ redeem: (id: string) => request(`/rewards/${id}/redeem`, { method: 'POST' }),
245
  },
246
+
247
+ batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
248
+ return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
 
249
  },
250
+
251
  wishes: {
252
+ getAll: (params: { teacherId?: string, studentId?: string, status?: string }) => {
253
+ const qs = new URLSearchParams(params as any).toString();
254
+ return request(`/wishes?${qs}`);
255
+ },
256
+ create: (data: Partial<Wish>) => request('/wishes', { method: 'POST', body: JSON.stringify(data) }),
257
+ fulfill: (id: string) => request(`/wishes/${id}/fulfill`, { method: 'POST' }),
258
+ randomFulfill: (teacherId: string) => request('/wishes/random-fulfill', { method: 'POST', body: JSON.stringify({ teacherId }) }),
259
  },
260
+
261
  feedback: {
262
+ getAll: (params: { targetId?: string, creatorId?: string, type?: string, status?: string }) => {
263
+ const qs = new URLSearchParams(params as any).toString();
264
+ return request(`/feedback?${qs}`);
265
+ },
266
+ create: (data: Partial<Feedback>) => request('/feedback', { method: 'POST', body: JSON.stringify(data) }),
267
+ update: (id: string, data: { status?: string, reply?: string }) => request(`/feedback/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
268
+ ignoreAll: (targetId: string) => request('/feedback/ignore-all', { method: 'POST', body: JSON.stringify({ targetId }) }),
 
 
 
 
 
 
269
  },
270
+
271
  ai: {
272
  chat: (data: { text?: string, audio?: string, history?: { role: string, text?: string }[] }) => request('/ai/chat', { method: 'POST', body: JSON.stringify(data) }),
273
+ evaluate: (data: { question: string, audio?: string, image?: string }) => request('/ai/evaluate', { method: 'POST', body: JSON.stringify(data) }),
274
  resetPool: () => request('/ai/reset-pool', { method: 'POST' }),
275
+ getStats: () => request('/ai/stats'), // NEW Detailed Stats
 
276
  },
277
+
278
+ todos: { // NEW
279
+ getAll: () => request('/todos'),
280
+ add: (content: string) => request('/todos', { method: 'POST', body: JSON.stringify({ content }) }),
281
+ update: (id: string, data: Partial<Todo>) => request(`/todos/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
282
+ delete: (id: string) => request(`/todos/${id}`, { method: 'DELETE' }),
283
+ }
284
  };