dvc890 commited on
Commit
3d889df
·
verified ·
1 Parent(s): 36325f8

Upload 53 files

Browse files
Files changed (4) hide show
  1. ai-routes.js +102 -8
  2. models.js +14 -5
  3. pages/AIAssistant.tsx +79 -16
  4. services/api.ts +2 -1
ai-routes.js CHANGED
@@ -2,7 +2,7 @@
2
  const express = require('express');
3
  const router = express.Router();
4
  const OpenAI = require('openai');
5
- const { ConfigModel, User } = require('./models');
6
 
7
  // --- Key Management & Rotation ---
8
 
@@ -29,6 +29,23 @@ async function getKeyPool(type) {
29
  return pool;
30
  }
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  // --- Helpers ---
33
 
34
  const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
@@ -147,7 +164,12 @@ async function callGeminiProvider(baseParams) {
147
  try {
148
  console.log(`🚀 [AI Debug] Calling Gemini non-stream: ${modelName} (Key ends: ${apiKey.slice(-4)})`);
149
  const currentParams = { ...baseParams, model: modelName };
150
- return await callAIWithRetry(() => client.models.generateContent(currentParams), 1);
 
 
 
 
 
151
  } catch (e) {
152
  lastError = e;
153
  console.error(`⚠️ [AI Debug] Gemini ${modelName} Error with key ${apiKey.slice(-4)}:`, e.message);
@@ -201,14 +223,16 @@ async function callOpenRouterProvider(baseParams) {
201
  if (!completion || !completion.choices || !completion.choices[0]) {
202
  throw new Error(`Invalid response structure from ${modelName}`);
203
  }
 
 
 
 
204
  const content = completion.choices[0].message.content || "";
205
  return { text: content };
206
  } catch (e) {
207
  lastError = e;
208
  console.warn(`⚠️ [AI Debug] OpenRouter Model ${modelName} failed with key ${apiKey.slice(-4)}.`, e.message);
209
  if (isQuotaError(e)) break; // Try next key
210
- // Keep trying models if it's not a quota error? OpenRouter often has specific model downtimes.
211
- // But for key rotation logic, usually 429 applies to account.
212
  }
213
  }
214
  }
@@ -236,7 +260,12 @@ async function callGemmaProvider(baseParams) {
236
  model: modelName,
237
  config: gemmaConfig
238
  };
239
- return await callAIWithRetry(() => client.models.generateContent(currentParams), 1);
 
 
 
 
 
240
  } catch (e) {
241
  lastError = e;
242
  console.warn(`⚠️ [AI Debug] Backup Model ${modelName} failed with key ${apiKey.slice(-4)}.`, e.message);
@@ -297,6 +326,9 @@ async function streamGemini(baseParams, res) {
297
  const currentParams = { ...baseParams, model: modelName };
298
  const result = await client.models.generateContentStream(currentParams);
299
 
 
 
 
300
  let fullText = "";
301
  for await (const chunk of result) {
302
  const chunkText = chunk.text;
@@ -352,6 +384,10 @@ async function streamOpenRouter(baseParams, res) {
352
  messages: messages,
353
  stream: true
354
  });
 
 
 
 
355
  let fullText = '';
356
  for await (const chunk of stream) {
357
  const text = chunk.choices[0]?.delta?.content || '';
@@ -393,6 +429,10 @@ async function streamGemma(baseParams, res) {
393
  config: gemmaConfig
394
  };
395
  const result = await client.models.generateContentStream(currentParams);
 
 
 
 
396
  let fullText = "";
397
  for await (const chunk of result) {
398
  const chunkText = chunk.text;
@@ -476,6 +516,63 @@ const checkAIAccess = async (req, res, next) => {
476
 
477
  // --- Routes ---
478
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
479
  // POST /api/ai/reset-pool (New Endpoint)
480
  router.post('/reset-pool', checkAIAccess, (req, res) => {
481
  resetProviderOrder();
@@ -557,7 +654,6 @@ router.post('/chat', checkAIAccess, async (req, res) => {
557
  }
558
  }
559
 
560
- await ConfigModel.findOneAndUpdate({ key: 'main' }, { $inc: { aiTotalCalls: 1 } }, { upsert: true });
561
  res.write('data: [DONE]\n\n');
562
  res.end();
563
 
@@ -643,8 +739,6 @@ router.post('/evaluate', checkAIAccess, async (req, res) => {
643
  }
644
  }
645
 
646
- await ConfigModel.findOneAndUpdate({ key: 'main' }, { $inc: { aiTotalCalls: 1 } }, { upsert: true });
647
-
648
  res.json({
649
  ...resultJson,
650
  audio: feedbackAudio,
 
2
  const express = require('express');
3
  const router = express.Router();
4
  const OpenAI = require('openai');
5
+ const { ConfigModel, User, AIUsageModel } = require('./models');
6
 
7
  // --- Key Management & Rotation ---
8
 
 
29
  return pool;
30
  }
31
 
32
+ // --- Usage Tracking Helper ---
33
+ async function recordUsage(model, provider) {
34
+ try {
35
+ const today = new Date().toISOString().split('T')[0];
36
+ // Upsert the usage record for this specific day/model/provider combination
37
+ await AIUsageModel.findOneAndUpdate(
38
+ { date: today, model: model, provider: provider },
39
+ { $inc: { count: 1 } },
40
+ { upsert: true, new: true }
41
+ );
42
+ // Also increment total stats for backward compatibility
43
+ await ConfigModel.findOneAndUpdate({ key: 'main' }, { $inc: { aiTotalCalls: 1 } }, { upsert: true });
44
+ } catch (e) {
45
+ console.error("Failed to record AI usage stats:", e);
46
+ }
47
+ }
48
+
49
  // --- Helpers ---
50
 
51
  const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
 
164
  try {
165
  console.log(`🚀 [AI Debug] Calling Gemini non-stream: ${modelName} (Key ends: ${apiKey.slice(-4)})`);
166
  const currentParams = { ...baseParams, model: modelName };
167
+ const result = await callAIWithRetry(() => client.models.generateContent(currentParams), 1);
168
+
169
+ // Track Usage on Success
170
+ recordUsage(modelName, PROVIDERS.GEMINI);
171
+
172
+ return result;
173
  } catch (e) {
174
  lastError = e;
175
  console.error(`⚠️ [AI Debug] Gemini ${modelName} Error with key ${apiKey.slice(-4)}:`, e.message);
 
223
  if (!completion || !completion.choices || !completion.choices[0]) {
224
  throw new Error(`Invalid response structure from ${modelName}`);
225
  }
226
+
227
+ // Track Usage on Success
228
+ recordUsage(modelName, PROVIDERS.OPENROUTER);
229
+
230
  const content = completion.choices[0].message.content || "";
231
  return { text: content };
232
  } catch (e) {
233
  lastError = e;
234
  console.warn(`⚠️ [AI Debug] OpenRouter Model ${modelName} failed with key ${apiKey.slice(-4)}.`, e.message);
235
  if (isQuotaError(e)) break; // Try next key
 
 
236
  }
237
  }
238
  }
 
260
  model: modelName,
261
  config: gemmaConfig
262
  };
263
+ const result = await callAIWithRetry(() => client.models.generateContent(currentParams), 1);
264
+
265
+ // Track Usage on Success
266
+ recordUsage(modelName, PROVIDERS.GEMMA);
267
+
268
+ return result;
269
  } catch (e) {
270
  lastError = e;
271
  console.warn(`⚠️ [AI Debug] Backup Model ${modelName} failed with key ${apiKey.slice(-4)}.`, e.message);
 
326
  const currentParams = { ...baseParams, model: modelName };
327
  const result = await client.models.generateContentStream(currentParams);
328
 
329
+ // Track Usage on Success (Stream Start)
330
+ recordUsage(modelName, PROVIDERS.GEMINI);
331
+
332
  let fullText = "";
333
  for await (const chunk of result) {
334
  const chunkText = chunk.text;
 
384
  messages: messages,
385
  stream: true
386
  });
387
+
388
+ // Track Usage on Success (Stream Start)
389
+ recordUsage(modelName, PROVIDERS.OPENROUTER);
390
+
391
  let fullText = '';
392
  for await (const chunk of stream) {
393
  const text = chunk.choices[0]?.delta?.content || '';
 
429
  config: gemmaConfig
430
  };
431
  const result = await client.models.generateContentStream(currentParams);
432
+
433
+ // Track Usage on Success (Stream Start)
434
+ recordUsage(modelName, PROVIDERS.GEMMA);
435
+
436
  let fullText = "";
437
  for await (const chunk of result) {
438
  const chunkText = chunk.text;
 
516
 
517
  // --- Routes ---
518
 
519
+ // GET /api/ai/stats (New Detailed Stats Endpoint)
520
+ router.get('/stats', checkAIAccess, async (req, res) => {
521
+ try {
522
+ const config = await ConfigModel.findOne({ key: 'main' });
523
+ const totalCalls = config ? (config.aiTotalCalls || 0) : 0;
524
+
525
+ // Get Today's Date
526
+ const todayStr = new Date().toISOString().split('T')[0];
527
+
528
+ // Get dates for the last 7 days
529
+ const last7Days = [];
530
+ for (let i = 6; i >= 0; i--) {
531
+ const d = new Date();
532
+ d.setDate(d.getDate() - i);
533
+ last7Days.push(d.toISOString().split('T')[0]);
534
+ }
535
+
536
+ // Aggregate usage data from AIUsageModel
537
+ const allUsage = await AIUsageModel.find({});
538
+
539
+ // 1. Calculate Today's Calls
540
+ let todayCount = 0;
541
+ allUsage.forEach(u => {
542
+ if (u.date === todayStr) todayCount += u.count;
543
+ });
544
+
545
+ // 2. Calculate Weekly Trend (Date -> Count)
546
+ const dailyTrend = last7Days.map(date => {
547
+ const count = allUsage.filter(u => u.date === date).reduce((sum, u) => sum + u.count, 0);
548
+ return { date, count };
549
+ });
550
+
551
+ // 3. Calculate Model Distribution (Model Name -> Total Count)
552
+ const modelStats = {};
553
+ allUsage.forEach(u => {
554
+ if (!modelStats[u.model]) modelStats[u.model] = 0;
555
+ modelStats[u.model] += u.count;
556
+ });
557
+
558
+ const modelDistribution = Object.keys(modelStats).map(key => ({
559
+ name: key,
560
+ value: modelStats[key]
561
+ })).sort((a,b) => b.value - a.value);
562
+
563
+ res.json({
564
+ totalCalls,
565
+ todayCount,
566
+ dailyTrend,
567
+ modelDistribution
568
+ });
569
+
570
+ } catch (e) {
571
+ console.error("Stats Error:", e);
572
+ res.status(500).json({ error: e.message });
573
+ }
574
+ });
575
+
576
  // POST /api/ai/reset-pool (New Endpoint)
577
  router.post('/reset-pool', checkAIAccess, (req, res) => {
578
  resetProviderOrder();
 
654
  }
655
  }
656
 
 
657
  res.write('data: [DONE]\n\n');
658
  res.end();
659
 
 
739
  }
740
  }
741
 
 
 
742
  res.json({
743
  ...resultJson,
744
  audio: feedbackAudio,
models.js CHANGED
@@ -1,8 +1,7 @@
1
 
2
  const mongoose = require('mongoose');
3
 
4
- // ... (Previous Models)
5
-
6
  const SchoolSchema = new mongoose.Schema({ name: String, code: String });
7
  const School = mongoose.model('School', SchoolSchema);
8
 
@@ -110,7 +109,7 @@ const ConfigSchema = new mongoose.Schema({
110
  semesters: [String],
111
  allowRegister: Boolean,
112
  allowAdminRegister: Boolean,
113
- allowPrincipalRegister: Boolean,
114
  allowStudentRegister: Boolean,
115
  maintenanceMode: Boolean,
116
  emailNotify: Boolean,
@@ -241,7 +240,6 @@ const FeedbackSchema = new mongoose.Schema({
241
  });
242
  const FeedbackModel = mongoose.model('Feedback', FeedbackSchema);
243
 
244
- // NEW: Todo Model
245
  const TodoSchema = new mongoose.Schema({
246
  userId: String,
247
  content: String,
@@ -250,9 +248,20 @@ const TodoSchema = new mongoose.Schema({
250
  });
251
  const TodoModel = mongoose.model('Todo', TodoSchema);
252
 
 
 
 
 
 
 
 
 
 
 
 
253
  module.exports = {
254
  School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
255
  ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
256
  AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
257
- WishModel, FeedbackModel, TodoModel
258
  };
 
1
 
2
  const mongoose = require('mongoose');
3
 
4
+ // ... (Previous Models: School, User, Student, Course, Score, Class, Subject, Exam Models - No Change)
 
5
  const SchoolSchema = new mongoose.Schema({ name: String, code: String });
6
  const School = mongoose.model('School', SchoolSchema);
7
 
 
109
  semesters: [String],
110
  allowRegister: Boolean,
111
  allowAdminRegister: Boolean,
112
+ allowPrincipalRegister: Boolean,
113
  allowStudentRegister: Boolean,
114
  maintenanceMode: Boolean,
115
  emailNotify: Boolean,
 
240
  });
241
  const FeedbackModel = mongoose.model('Feedback', FeedbackSchema);
242
 
 
243
  const TodoSchema = new mongoose.Schema({
244
  userId: String,
245
  content: String,
 
248
  });
249
  const TodoModel = mongoose.model('Todo', TodoSchema);
250
 
251
+ // NEW: Detailed AI Usage Stats
252
+ const AIUsageSchema = new mongoose.Schema({
253
+ date: String, // Format: YYYY-MM-DD
254
+ model: String,
255
+ provider: String,
256
+ count: { type: Number, default: 0 }
257
+ });
258
+ // Optimize lookups by date and model
259
+ AIUsageSchema.index({ date: 1, model: 1, provider: 1 }, { unique: true });
260
+ const AIUsageModel = mongoose.model('AIUsage', AIUsageSchema);
261
+
262
  module.exports = {
263
  School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
264
  ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
265
  AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
266
+ WishModel, FeedbackModel, TodoModel, AIUsageModel
267
  };
pages/AIAssistant.tsx CHANGED
@@ -2,10 +2,11 @@
2
  import React, { useState, useRef, useEffect } from 'react';
3
  import { api } from '../services/api';
4
  import { AIChatMessage, SystemConfig, UserRole, OpenRouterModelConfig } from '../types';
5
- import { Bot, Mic, Square, Play, Volume2, Send, CheckCircle, Brain, Sparkles, Loader2, StopCircle, Trash2, Image as ImageIcon, X, AlertTriangle, ShieldCheck, Activity, Power, ExternalLink, Key, Plus, Save, ArrowUp, ArrowDown } from 'lucide-react';
6
  import ReactMarkdown from 'react-markdown';
7
  import remarkGfm from 'remark-gfm';
8
  import { Toast, ToastState } from '../components/Toast';
 
9
 
10
  // Utility to handle Base64 conversion
11
  const blobToBase64 = (blob: Blob): Promise<string> => {
@@ -92,6 +93,9 @@ const DEFAULT_OR_MODELS = [
92
  { id: 'tngtech/deepseek-r1t-chimera:free', name: 'DeepSeek R1T', isCustom: false }
93
  ];
94
 
 
 
 
95
  export const AIAssistant: React.FC = () => {
96
  const currentUser = api.auth.getCurrentUser();
97
  const isAdmin = currentUser?.role === UserRole.ADMIN;
@@ -100,6 +104,14 @@ export const AIAssistant: React.FC = () => {
100
  const [systemConfig, setSystemConfig] = useState<SystemConfig | null>(null);
101
  const [configLoading, setConfigLoading] = useState(true);
102
  const [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' });
 
 
 
 
 
 
 
 
103
 
104
  // Chat State with Persistence
105
  const [activeTab, setActiveTab] = useState<'chat' | 'assessment'>('chat');
@@ -160,12 +172,16 @@ export const AIAssistant: React.FC = () => {
160
 
161
  const cfg = await api.config.get();
162
  setSystemConfig(cfg);
163
- if (isAdmin && cfg.apiKeys) {
164
- setGeminiKeys(cfg.apiKeys.gemini || []);
165
- setOpenRouterKeys(cfg.apiKeys.openrouter || []);
166
- }
167
  if (isAdmin) {
 
 
 
 
168
  setOrModels(cfg.openRouterModels && cfg.openRouterModels.length > 0 ? cfg.openRouterModels : DEFAULT_OR_MODELS);
 
 
 
 
169
  }
170
  } catch (e) {
171
  console.error("Init failed", e);
@@ -557,16 +573,62 @@ export const AIAssistant: React.FC = () => {
557
  </div>
558
 
559
  <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
560
- <div className="bg-white p-6 rounded-xl border border-gray-100 shadow-sm">
561
- <h3 className="font-bold text-gray-800 mb-4 flex items-center"><Activity className="mr-2 text-purple-500"/> 调用统计</h3>
562
- <div className="flex items-center gap-4">
563
- <div className="bg-purple-50 p-4 rounded-xl text-center flex-1">
564
- <div className="text-3xl font-black text-purple-600">{systemConfig?.aiTotalCalls || 0}</div>
565
- <div className="text-xs text-purple-700 font-bold uppercase mt-1">总调用次数</div>
 
 
566
  </div>
567
- <div className="text-sm text-gray-500 flex-1">
568
- <p className="mb-2">计费周期: 当前学期</p>
569
- <p>服务商: Gemini & OpenRouter</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  </div>
571
  </div>
572
  </div>
@@ -707,7 +769,8 @@ export const AIAssistant: React.FC = () => {
707
  );
708
  }
709
 
710
- // --- MAINTENANCE MODE VIEW (For Teachers) ---
 
711
  if (!systemConfig?.enableAI) {
712
  return (
713
  <div className="flex flex-col items-center justify-center h-full text-center p-6 space-y-4">
@@ -720,7 +783,7 @@ export const AIAssistant: React.FC = () => {
720
  );
721
  }
722
 
723
- // --- TEACHER VIEW (Chat UI) ---
724
  return (
725
  <div className="h-full flex flex-col bg-slate-50 overflow-hidden relative">
726
  {toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
 
2
  import React, { useState, useRef, useEffect } from 'react';
3
  import { api } from '../services/api';
4
  import { AIChatMessage, SystemConfig, UserRole, OpenRouterModelConfig } from '../types';
5
+ import { Bot, Mic, Square, Play, Volume2, Send, CheckCircle, Brain, Sparkles, Loader2, StopCircle, Trash2, Image as ImageIcon, X, AlertTriangle, ShieldCheck, Activity, Power, ExternalLink, Key, Plus, Save, ArrowUp, ArrowDown, BarChart2 } from 'lucide-react';
6
  import ReactMarkdown from 'react-markdown';
7
  import remarkGfm from 'remark-gfm';
8
  import { Toast, ToastState } from '../components/Toast';
9
+ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts';
10
 
11
  // Utility to handle Base64 conversion
12
  const blobToBase64 = (blob: Blob): Promise<string> => {
 
93
  { id: 'tngtech/deepseek-r1t-chimera:free', name: 'DeepSeek R1T', isCustom: false }
94
  ];
95
 
96
+ // Colors for Pie Chart
97
+ const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4'];
98
+
99
  export const AIAssistant: React.FC = () => {
100
  const currentUser = api.auth.getCurrentUser();
101
  const isAdmin = currentUser?.role === UserRole.ADMIN;
 
104
  const [systemConfig, setSystemConfig] = useState<SystemConfig | null>(null);
105
  const [configLoading, setConfigLoading] = useState(true);
106
  const [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' });
107
+
108
+ // Admin Stats State
109
+ const [detailedStats, setDetailedStats] = useState<{
110
+ totalCalls: number;
111
+ todayCount: number;
112
+ dailyTrend: {date: string, count: number}[];
113
+ modelDistribution: {name: string, value: number}[];
114
+ } | null>(null);
115
 
116
  // Chat State with Persistence
117
  const [activeTab, setActiveTab] = useState<'chat' | 'assessment'>('chat');
 
172
 
173
  const cfg = await api.config.get();
174
  setSystemConfig(cfg);
 
 
 
 
175
  if (isAdmin) {
176
+ if (cfg.apiKeys) {
177
+ setGeminiKeys(cfg.apiKeys.gemini || []);
178
+ setOpenRouterKeys(cfg.apiKeys.openrouter || []);
179
+ }
180
  setOrModels(cfg.openRouterModels && cfg.openRouterModels.length > 0 ? cfg.openRouterModels : DEFAULT_OR_MODELS);
181
+
182
+ // Fetch Detailed Stats
183
+ const stats = await api.ai.getStats();
184
+ setDetailedStats(stats);
185
  }
186
  } catch (e) {
187
  console.error("Init failed", e);
 
573
  </div>
574
 
575
  <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
576
+ {/* STATS PANEL */}
577
+ <div className="bg-white p-6 rounded-xl border border-gray-100 shadow-sm md:col-span-2">
578
+ <h3 className="font-bold text-gray-800 mb-6 flex items-center"><Activity className="mr-2 text-purple-500"/> 调用数据分析</h3>
579
+
580
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
581
+ <div className="bg-purple-50 p-4 rounded-xl text-center">
582
+ <div className="text-3xl font-black text-purple-600">{detailedStats?.totalCalls || 0}</div>
583
+ <div className="text-xs text-purple-700 font-bold uppercase mt-1">历史累计调用</div>
584
  </div>
585
+ <div className="bg-blue-50 p-4 rounded-xl text-center">
586
+ <div className="text-3xl font-black text-blue-600">{detailedStats?.todayCount || 0}</div>
587
+ <div className="text-xs text-blue-700 font-bold uppercase mt-1">今日调用次数</div>
588
+ </div>
589
+ <div className="md:col-span-2 bg-gray-50 p-4 rounded-xl flex items-center justify-center text-gray-500 text-sm">
590
+ <span className="mr-2 font-bold">当前状态:</span>
591
+ {systemConfig?.enableAI ? <span className="text-green-600 font-bold flex items-center"><CheckCircle size={14} className="mr-1"/> 服务运行中</span> : <span className="text-red-500 font-bold flex items-center"><X size={14} className="mr-1"/> 服务已暂停</span>}
592
+ </div>
593
+ </div>
594
+
595
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
596
+ {/* Weekly Trend Chart */}
597
+ <div className="h-64">
598
+ <h4 className="text-sm font-bold text-gray-600 mb-4 flex items-center"><BarChart2 size={16} className="mr-2"/> 近7日调用趋势</h4>
599
+ <ResponsiveContainer width="100%" height="100%">
600
+ <BarChart data={detailedStats?.dailyTrend || []}>
601
+ <CartesianGrid strokeDasharray="3 3" vertical={false}/>
602
+ <XAxis dataKey="date" tick={{fontSize: 10}} tickFormatter={(val) => val.slice(5)}/>
603
+ <YAxis allowDecimals={false}/>
604
+ <Tooltip cursor={{fill: 'transparent'}} contentStyle={{borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)'}}/>
605
+ <Bar dataKey="count" fill="#8b5cf6" radius={[4, 4, 0, 0]} name="调用次数" />
606
+ </BarChart>
607
+ </ResponsiveContainer>
608
+ </div>
609
+
610
+ {/* Model Distribution Pie */}
611
+ <div className="h-64">
612
+ <h4 className="text-sm font-bold text-gray-600 mb-4 flex items-center"><Brain size={16} className="mr-2"/> 模型使用分布</h4>
613
+ <ResponsiveContainer width="100%" height="100%">
614
+ <PieChart>
615
+ <Pie
616
+ data={detailedStats?.modelDistribution || []}
617
+ cx="50%"
618
+ cy="50%"
619
+ innerRadius={60}
620
+ outerRadius={80}
621
+ paddingAngle={5}
622
+ dataKey="value"
623
+ >
624
+ {(detailedStats?.modelDistribution || []).map((entry, index) => (
625
+ <Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
626
+ ))}
627
+ </Pie>
628
+ <Tooltip />
629
+ <Legend layout="vertical" verticalAlign="middle" align="right" wrapperStyle={{fontSize: '10px'}}/>
630
+ </PieChart>
631
+ </ResponsiveContainer>
632
  </div>
633
  </div>
634
  </div>
 
769
  );
770
  }
771
 
772
+ // ... (Rest of component remains same)
773
+ // --- MAINTENANCE MODE VIEW ---
774
  if (!systemConfig?.enableAI) {
775
  return (
776
  <div className="flex flex-col items-center justify-center h-full text-center p-6 space-y-4">
 
783
  );
784
  }
785
 
786
+ // ... (Teacher View - Chat UI - Same as before)
787
  return (
788
  <div className="h-full flex flex-col bg-slate-50 overflow-hidden relative">
789
  {toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
services/api.ts CHANGED
@@ -271,7 +271,8 @@ export const api = {
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' }), // NEW
 
275
  },
276
 
277
  todos: { // NEW
 
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