Upload 139 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +2 -0
- DEVELOPMENT_CHECKLIST.md +539 -0
- Dockerfile +23 -0
- README-local.md +176 -0
- _archive/templates/code_execution.html +718 -0
- _archive/templates/index.html +0 -0
- _archive/templates/login.html +504 -0
- _archive/templates/student.html +1651 -0
- _archive/templates/student_portal.html +1004 -0
- _archive/templates/token_verification.html +521 -0
- app.py +987 -0
- config.py +23 -0
- frontend/.gitignore +24 -0
- frontend/README.md +16 -0
- frontend/eslint.config.js +29 -0
- frontend/index.html +13 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +53 -0
- frontend/postcss.config.js +7 -0
- frontend/public/vite.svg +1 -0
- frontend/src/App.jsx +99 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/components/WorkflowEditor.jsx +449 -0
- frontend/src/components/common/ProtectedRoute.jsx +28 -0
- frontend/src/components/plugins/CodeExecutor.jsx +251 -0
- frontend/src/components/plugins/MindmapViewer.jsx +135 -0
- frontend/src/components/plugins/Visualization3D.jsx +133 -0
- frontend/src/hooks/useTypewriter.js +108 -0
- frontend/src/index.css +189 -0
- frontend/src/main.jsx +10 -0
- frontend/src/pages/LoginPage.jsx +497 -0
- frontend/src/pages/RegisterPage.jsx +176 -0
- frontend/src/pages/student/AgentChat.css +159 -0
- frontend/src/pages/student/AgentChat.jsx +854 -0
- frontend/src/pages/student/StudentDashboard.jsx +14 -0
- frontend/src/pages/student/StudentPortal.jsx +389 -0
- frontend/src/pages/teacher/AgentList.jsx +660 -0
- frontend/src/pages/teacher/CreateAgent.jsx +696 -0
- frontend/src/pages/teacher/Dashboard.jsx +413 -0
- frontend/src/pages/teacher/KnowledgeBase.jsx +473 -0
- frontend/src/pages/teacher/StudentManagement.jsx +564 -0
- frontend/src/pages/teacher/TeacherDashboard.jsx +242 -0
- frontend/src/services/agentService.js +49 -0
- frontend/src/services/api.js +69 -0
- frontend/src/services/authService.js +60 -0
- frontend/src/services/knowledgeService.js +47 -0
- frontend/src/store/slices/agentSlice.js +59 -0
- frontend/src/store/slices/authSlice.js +73 -0
- frontend/src/store/slices/chatSlice.js +58 -0
- frontend/src/store/slices/uiSlice.js +57 -0
.gitattributes
CHANGED
|
@@ -35,3 +35,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
uploads/496449c167344601ba87c24743701b99.pdf filter=lfs diff=lfs merge=lfs -text
|
| 37 |
uploads/eb55dde866f64be4a1fdbc81d3d1df0e.pdf filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
uploads/496449c167344601ba87c24743701b99.pdf filter=lfs diff=lfs merge=lfs -text
|
| 37 |
uploads/eb55dde866f64be4a1fdbc81d3d1df0e.pdf filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
uploads/1a9b23e3e2d34305b0acdbd3693d276d.pdf filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
uploads/1c64cc79a5694889ba38c29bd630746d.pdf filter=lfs diff=lfs merge=lfs -text
|
DEVELOPMENT_CHECKLIST.md
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TeachAgent 新前端迁移开发清单
|
| 2 |
+
|
| 3 |
+
> 本文档记录从旧版 Flask 模板前端迁移到新版 React 前端的完整开发任务。
|
| 4 |
+
> 更新日期:2026-04-13
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## 一、项目架构概览
|
| 9 |
+
|
| 10 |
+
| 层级 | 技术栈 | 端口 |
|
| 11 |
+
|------|--------|------|
|
| 12 |
+
| 新前端 | React 19 + Vite + Ant Design + TailwindCSS + Redux | 3000 |
|
| 13 |
+
| 旧前端 | Flask Jinja2 + Bootstrap 5 + 原生JS | 7860(与后端同源) |
|
| 14 |
+
| 后端 API | Flask + Blueprint | 7860 |
|
| 15 |
+
| 数据库 | Elasticsearch 9.3.3 (向量+文档) + SQLite (用户) | 9200 |
|
| 16 |
+
| AI 服务 | SiliconFlow (Embedding/Rerank) + DeepSeek (Chat) | 外部API |
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## 二、迁移状态总览
|
| 21 |
+
|
| 22 |
+
### 已完成模块
|
| 23 |
+
|
| 24 |
+
| 模块 | 新前端文件 | 状态 | 备注 |
|
| 25 |
+
|------|-----------|------|------|
|
| 26 |
+
| 登录 | `LoginPage.jsx` | ✅ 完成 | 邮箱/手机/密码三种方式 |
|
| 27 |
+
| 注册 | `RegisterPage.jsx` | ✅ 完成 | 用户名+密码 |
|
| 28 |
+
| 路由保护 | `ProtectedRoute.jsx` | ✅ 完成 | JWT + 角色校验 |
|
| 29 |
+
| 教师 Dashboard | `teacher/Dashboard.jsx` | ✅ 完成 | 统计卡片、快速入门 |
|
| 30 |
+
| Agent 列表 | `teacher/AgentList.jsx` | ✅ 完成 | CRUD、分发链接、详情查看 |
|
| 31 |
+
| Agent 创建 | `teacher/CreateAgent.jsx` | ✅ 完成 | 4步向导、AI工作流生成 |
|
| 32 |
+
| 工作流编辑器 | `components/common/WorkflowEditor.jsx` | ✅ 完成 | React Flow 可视化 |
|
| 33 |
+
| 知识库管理 | `teacher/KnowledgeBase.jsx` | ⚠️ 基本完成 | 缺批量上传、拖拽 |
|
| 34 |
+
| 学生管理 | `teacher/StudentManagement.jsx` | ✅ 完成 | CRUD、密码重置、进度 |
|
| 35 |
+
| 学生门户 | `student/StudentPortal.jsx` | ✅ 完成 | Agent列表、Token验证、活动记录 |
|
| 36 |
+
| Agent 对话 | `student/AgentChat.jsx` | ⚠️ 部分完成 | 流式对话可用,插件系统缺失 |
|
| 37 |
+
| 打字机 Hook | `hooks/useTypewriter.js` | ✅ 已实现 | 未接入使用 |
|
| 38 |
+
|
| 39 |
+
### 未实现模块
|
| 40 |
+
|
| 41 |
+
| 模块 | 旧版位置 | 优先级 |
|
| 42 |
+
|------|----------|--------|
|
| 43 |
+
| 代码执行插件(对话内嵌) | `student.html` 侧面板 | P0 |
|
| 44 |
+
| 3D 可视化插件(对话内嵌) | `student.html` 侧面板 | P1 |
|
| 45 |
+
| 思维导图插件(对话内嵌) | `student.html` 侧面板 | P1 |
|
| 46 |
+
| 独立代码执行页 | `code_execution.html` | P1 |
|
| 47 |
+
| Token 直接访问入口(登录页) | `login.html` | P2 |
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## 三、关键 BUG 与问题
|
| 52 |
+
|
| 53 |
+
### 3.1 流式输出 SSE 实现问题(P0)
|
| 54 |
+
|
| 55 |
+
**当前实现**: `AgentChat.jsx` 使用 `fetch` + `ReadableStream reader` 手动解析 SSE
|
| 56 |
+
|
| 57 |
+
**后端发送格式**:
|
| 58 |
+
```
|
| 59 |
+
data: {"type": "start", "plugins": [...], "references": [...]}
|
| 60 |
+
data: {"type": "content", "content": "文本片段"}
|
| 61 |
+
data: {"type": "metadata", "content": {...}}
|
| 62 |
+
data: {"type": "done"}
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
**问题清单**:
|
| 66 |
+
|
| 67 |
+
| # | 问题 | 严重度 | 说明 |
|
| 68 |
+
|---|------|--------|------|
|
| 69 |
+
| 1 | `metadata` 事件未处理 | 中 | 前端只处理 start/content/done,metadata 被忽略 |
|
| 70 |
+
| 2 | 无 `error` 事件类型 | 高 | 后端出错时直接断流,前端会一直卡在"思考中" |
|
| 71 |
+
| 3 | `[DONE]` 哨兵检测无效 | 低 | 前端检查 `[DONE]` 但后端从不发送此标记 |
|
| 72 |
+
| 4 | 流结束时 buffer 未清空 | 中 | 如果流在 `\n\n` 结尾,最后一个事件可能丢失 |
|
| 73 |
+
| 5 | 断流时无 JSON 恢复 | 中 | 连接中断时 partial JSON 直接丢失 |
|
| 74 |
+
| 6 | 无超时/重连机制 | 中 | 网络波动时前端无法恢复 |
|
| 75 |
+
|
| 76 |
+
**建议重构方向**:
|
| 77 |
+
- 后端增加 `type: "error"` 事件
|
| 78 |
+
- 前端增加 metadata 事件处理
|
| 79 |
+
- 添加流超时检测(如 30s 无数据则提示)
|
| 80 |
+
- 清理无用的 `[DONE]` 检查
|
| 81 |
+
|
| 82 |
+
### 3.2 API 路径不一致(P0)
|
| 83 |
+
|
| 84 |
+
| 前端调用 | 后端实际路径 | 问题 |
|
| 85 |
+
|----------|-------------|------|
|
| 86 |
+
| `axios.post('/api/verify_token')` (AgentChat.jsx:84) | `/api/verify_token` | axios baseURL 是 `/api`,实际请求变成 `/api/api/verify_token`(双重前缀)|
|
| 87 |
+
| `getProgress(taskId)` → `/knowledge/progress/${taskId}` | `/api/progress/${taskId}`(在 app.py 主路由) | 路径不在 knowledge blueprint 下 |
|
| 88 |
+
| `getDistributions(agentId)` → `/agent/${agentId}/distributions` | 后端未实现此端点 | 404 |
|
| 89 |
+
| `authService.refreshToken()` → `/auth/refresh` | 后端未实现 | 404 |
|
| 90 |
+
| `authService.getCurrentUser()` → `/auth/me` | 后端未实现 | 404 |
|
| 91 |
+
| `authService.changePassword()` → `/auth/change_password` | 后端未实现 | 404 |
|
| 92 |
+
|
| 93 |
+
### 3.3 其他前端问题
|
| 94 |
+
|
| 95 |
+
| # | 问题 | 位置 | 说明 |
|
| 96 |
+
|---|------|------|------|
|
| 97 |
+
| 1 | Agent 编辑路由缺失 | `CreateAgent.jsx` | 编辑模式通过 localStorage 传参,无独立路由 |
|
| 98 |
+
| 2 | 知识库进度轮询路径错误 | `knowledgeService.js` | 应直接调 `/api/progress/` 而非通过 knowledge 前缀 |
|
| 99 |
+
| 3 | 学生 Dashboard 是空壳 | `StudentDashboard.jsx` | 只是路由容器,无独立内容 |
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## 四、完整 API 端点对照表
|
| 104 |
+
|
| 105 |
+
### 4.1 认证模块 `/api/auth/*`
|
| 106 |
+
|
| 107 |
+
| 端点 | 方法 | 后端 | 前端调用 | 状态 |
|
| 108 |
+
|------|------|------|---------|------|
|
| 109 |
+
| `/api/auth/login` | POST | ✅ 旧版密码登录 | ❌ 未使用 | 可废弃 |
|
| 110 |
+
| `/api/auth/login-password` | POST | ✅ | ✅ | 正常 |
|
| 111 |
+
| `/api/auth/login-email` | POST | ✅ | ✅ | 正常 |
|
| 112 |
+
| `/api/auth/login-phone` | POST | ✅ | ✅ | 正常 |
|
| 113 |
+
| `/api/auth/send-email-code` | POST | ✅ | ✅ | 正常(邮件服务不可用) |
|
| 114 |
+
| `/api/auth/send-phone-code` | POST | ✅ | ✅ | 正常(开发模式模拟) |
|
| 115 |
+
| `/api/auth/register` | POST | ✅ | ✅ | 正常 |
|
| 116 |
+
| `/api/auth/verify` | GET | ✅ | ❌ 未使用 | - |
|
| 117 |
+
| `/api/auth/logout` | POST | ✅ | ✅ | 正常 |
|
| 118 |
+
| `/api/auth/refresh` | POST | ❌ 未实现 | ✅ 有调用 | **需实现** |
|
| 119 |
+
| `/api/auth/me` | GET | ❌ 未实现 | ✅ 有调用 | **需实现** |
|
| 120 |
+
| `/api/auth/change_password` | POST | ❌ 未实现 | ✅ 有调用 | **需实现** |
|
| 121 |
+
| `/api/auth/check` | GET | ✅ session检查 | ❌ 未使用 | 旧版遗留 |
|
| 122 |
+
|
| 123 |
+
### 4.2 知识库模块 `/api/knowledge/*`
|
| 124 |
+
|
| 125 |
+
| 端点 | 方法 | 后端 | 前端调用 | 状态 |
|
| 126 |
+
|------|------|------|---------|------|
|
| 127 |
+
| `/api/knowledge/` | GET | ✅ | ✅ | 正常 |
|
| 128 |
+
| `/api/knowledge/` | POST | ✅ 文件上传创建 | ✅ | 正常 |
|
| 129 |
+
| `/api/knowledge/<id>` | DELETE | ✅ | ✅ | 正常 |
|
| 130 |
+
| `/api/knowledge/<id>/documents` | GET | ✅ | ✅ | 正常 |
|
| 131 |
+
| `/api/knowledge/<id>/documents` | POST | ✅ | ✅ | 正常 |
|
| 132 |
+
| `/api/knowledge/<id>/documents/<file>` | DELETE | ✅ | ✅ | 正常 |
|
| 133 |
+
| `/api/progress/<task_id>` | GET | ✅(app.py主路由) | ⚠️ 路径可能错 | **需核实** |
|
| 134 |
+
|
| 135 |
+
### 4.3 Agent 模块 `/api/agent/*`
|
| 136 |
+
|
| 137 |
+
| 端点 | 方法 | 后端 | 前端调用 | 状态 |
|
| 138 |
+
|------|------|------|---------|------|
|
| 139 |
+
| `/api/agent/create` | POST | ✅ | ✅ | 正常 |
|
| 140 |
+
| `/api/agent/list` | GET | ✅ | ✅ | 正常 |
|
| 141 |
+
| `/api/agent/<id>` | GET | ✅ | ✅ | 正常 |
|
| 142 |
+
| `/api/agent/<id>` | PUT | ✅ | ✅ | 正常 |
|
| 143 |
+
| `/api/agent/<id>` | DELETE | ✅ | ✅ | 正常 |
|
| 144 |
+
| `/api/agent/<id>/distribute` | POST | ✅ | ✅ | 正常 |
|
| 145 |
+
| `/api/agent/<id>/distributions` | GET | ❌ 未实现 | ✅ 有调用 | **需实现** |
|
| 146 |
+
| `/api/agent/ai-assist` | POST | ✅ | ✅ | 正常 |
|
| 147 |
+
|
| 148 |
+
### 4.4 代码执行模块 `/api/code/*`
|
| 149 |
+
|
| 150 |
+
| 端点 | 方法 | 后端 | 前端调用 | 状态 |
|
| 151 |
+
|------|------|------|---------|------|
|
| 152 |
+
| `/api/code/execute` | POST | ✅ | ❌ 未接入 | **需前端实现** |
|
| 153 |
+
| `/api/code/input` | POST | ✅ | ❌ 未接入 | **需前端实现** |
|
| 154 |
+
| `/api/code/stop` | POST | ✅ | ❌ 未接入 | **需前端实现** |
|
| 155 |
+
| `/api/code/generate` | POST | ✅ | ❌ 未接入 | **需前端实现** |
|
| 156 |
+
|
| 157 |
+
### 4.5 可视化模块 `/api/visualization/*`
|
| 158 |
+
|
| 159 |
+
| 端点 | 方法 | 后端 | 前端调用 | 状态 |
|
| 160 |
+
|------|------|------|---------|------|
|
| 161 |
+
| `/api/visualization/mindmap` | POST | ✅ | ❌ 未接入 | **需前端实现** |
|
| 162 |
+
| `/api/visualization/3d-surface` | POST | ✅ | ❌ 未接入 | **需前端实现** |
|
| 163 |
+
|
| 164 |
+
### 4.6 学生模块
|
| 165 |
+
|
| 166 |
+
| 端点 | 方法 | 后端 | 前端调用 | 状态 |
|
| 167 |
+
|------|------|------|---------|------|
|
| 168 |
+
| `/api/student/agents` | GET | ✅ | ✅ | 正常 |
|
| 169 |
+
| `/api/student/activities` | GET | ✅ | ✅ | 正常 |
|
| 170 |
+
| `/api/student/chat/<id>` | POST (SSE) | ✅ | ✅ | ⚠️ 有解析问题 |
|
| 171 |
+
| `/api/verify_token` | POST | ✅ (app.py) | ⚠️ 路径双重前缀 | **需修复** |
|
| 172 |
+
|
| 173 |
+
### 4.7 学生管理模块 `/api/teacher/students/*`
|
| 174 |
+
|
| 175 |
+
| 端点 | 方法 | 后端 | 前端调用 | 状态 |
|
| 176 |
+
|------|------|------|---------|------|
|
| 177 |
+
| `/api/teacher/students` | GET | ✅ | ✅ | 正常 |
|
| 178 |
+
| `/api/teacher/students` | POST | ✅ | ✅ | 正常 |
|
| 179 |
+
| `/api/teacher/students/<id>` | PUT | ✅ | ✅ | 正常 |
|
| 180 |
+
| `/api/teacher/students/<id>` | DELETE | ✅ | ✅ | 正常 |
|
| 181 |
+
| `/api/teacher/students/<id>/reset-password` | POST | ✅ | ✅ | 正常 |
|
| 182 |
+
| `/api/teacher/students/<id>/progress` | GET | ✅ | ✅ | 正常 |
|
| 183 |
+
|
| 184 |
+
---
|
| 185 |
+
|
| 186 |
+
## 五、分阶段开发计划
|
| 187 |
+
|
| 188 |
+
### 第一阶段:基础修复(让现有功能跑通)
|
| 189 |
+
|
| 190 |
+
- [ ] **修复 API 路径双重前缀问题**
|
| 191 |
+
- `AgentChat.jsx` 中 `axios.post('/api/verify_token')` 改为用 api 实例或去掉 `/api` 前缀
|
| 192 |
+
- 排查所有直接使用 `axios` 而非 `api` 实例的调用
|
| 193 |
+
|
| 194 |
+
- [ ] **修复知识库进度轮询路径**
|
| 195 |
+
- `knowledgeService.js` 中 `getProgress()` 路径对齐后端 `/api/progress/<task_id>`
|
| 196 |
+
|
| 197 |
+
- [ ] **修复 SSE 流式输出**
|
| 198 |
+
- 前端增加 `metadata` 事件处理(至少不报错)
|
| 199 |
+
- 后端增加 `type: "error"` 事件,出错时通知前端
|
| 200 |
+
- 移除前端无用的 `[DONE]` 检查
|
| 201 |
+
- 添加流超时检测(30s 无数据提示用户)
|
| 202 |
+
- 修复流结束时 buffer 未清空问题
|
| 203 |
+
|
| 204 |
+
- [ ] **实现缺失的后端端点**
|
| 205 |
+
- `GET /api/agent/<id>/distributions` — 获取 Agent 的分发链接列表
|
| 206 |
+
- `GET /api/auth/me` — 返回当前登录用户信息
|
| 207 |
+
- `POST /api/auth/refresh` — JWT token 刷新(或前端去掉此调用)
|
| 208 |
+
- `POST /api/auth/change_password` — 修改密码(或前端暂时隐藏入口)
|
| 209 |
+
|
| 210 |
+
### 第二阶段:插件系统迁移(核心差异)
|
| 211 |
+
|
| 212 |
+
- [ ] **AgentChat 插件框架设计**
|
| 213 |
+
- 在对话界面右侧增加可展开的插件面板(Ant Design Drawer 或自定义侧栏)
|
| 214 |
+
- 根据后端 SSE `start` 事件返回的 `plugins` 字段决定显示哪些��件 Tab
|
| 215 |
+
- 插件自动激活:后端检测到关键词后在 `start` 事件中标记 `suggested_plugins`
|
| 216 |
+
- 插件面板状态:折叠/展开/全屏
|
| 217 |
+
- 布局:左侧对话区 + 右侧插件面板,移动端改为底部抽屉
|
| 218 |
+
|
| 219 |
+
- [ ] **代码执行插件**
|
| 220 |
+
- 使用 CodeMirror 6 (`@uiw/react-codemirror` + `@codemirror/lang-python`)
|
| 221 |
+
- 上半区:代码编辑器(Python语法高亮、行号)
|
| 222 |
+
- 下半区:终端输出面板(深色背景、绿色提示符、支持 ANSI 颜色)
|
| 223 |
+
- 交互式输入:检测到 `input()` 时显示输入框
|
| 224 |
+
- 控制按钮:运行 / 停止 / 清除
|
| 225 |
+
- 对接 API:
|
| 226 |
+
- `POST /api/code/execute` → 发送代码,轮询输出
|
| 227 |
+
- `POST /api/code/input` → 发送用户输入
|
| 228 |
+
- `POST /api/code/stop` → 终止执行
|
| 229 |
+
- AI 回复中的代码块可一键复制到编辑器
|
| 230 |
+
- AI 代码生成按钮 → `POST /api/code/generate`
|
| 231 |
+
|
| 232 |
+
- [ ] **3D 可视化插件**(保持旧版 Plotly iframe 方案)
|
| 233 |
+
- 从 AI 回复中提取 Python 代码(包含 `create_3d_plot` 函数)
|
| 234 |
+
- `POST /api/visualization/3d-surface` 发送代码
|
| 235 |
+
- 返回 `html_url` 后在插件面板中用 `<iframe>` 加载
|
| 236 |
+
- 支持全屏查看(Ant Design Modal 全屏模式)
|
| 237 |
+
- 加载中显示 Spin 组件
|
| 238 |
+
- Plotly 原生交互:旋转、缩放、平移、悬浮提示、导出
|
| 239 |
+
|
| 240 |
+
- [ ] **思维导图插件**(markmap 前端渲染,替代旧版 PlantUML)
|
| 241 |
+
- 安装 `markmap-lib` + `markmap-view`
|
| 242 |
+
- 从 AI 回复中提取 Markdown 层级结构
|
| 243 |
+
- 使用 `Transformer.transform()` 解析为树结构
|
| 244 |
+
- 使用 `Markmap.create()` 渲染到 SVG 容器
|
| 245 |
+
- 交互支持:缩放、平移、节点展开/折叠
|
| 246 |
+
- 支持全屏查看和导出为 SVG/PNG
|
| 247 |
+
- 后端修改:让 LLM prompt 输出 Markdown 标题格式而非 PlantUML 语法
|
| 248 |
+
|
| 249 |
+
### 第三阶段:独立功能页面
|
| 250 |
+
|
| 251 |
+
- [ ] **独立代码执行页 `/code`**
|
| 252 |
+
- 完整的 Python IDE 界面(编辑器 + 终端)
|
| 253 |
+
- 语法高亮、行号、Tab 缩进
|
| 254 |
+
- 运行/停止/清除控制按钮
|
| 255 |
+
- 从旧版 `code_execution.html` 迁移逻辑
|
| 256 |
+
|
| 257 |
+
- [ ] **Token 直接访问入口**
|
| 258 |
+
- 登录页增加"使用 Token 直接访问"选项
|
| 259 |
+
- 输入 Token → 验证 → 跳转到对应 Agent 对话页
|
| 260 |
+
|
| 261 |
+
### 第四阶段:体验优化
|
| 262 |
+
|
| 263 |
+
- [ ] **打字机效果**
|
| 264 |
+
- 将 `useTypewriter.js` hook 接入 AgentChat 的流式渲染
|
| 265 |
+
- 支持暂停/继续/加速控制
|
| 266 |
+
- 或直接在流式输出时模拟打字效果(当前逐 chunk 渲染已有类似效果,评估是否需要)
|
| 267 |
+
|
| 268 |
+
- [ ] **知识库批量上传**
|
| 269 |
+
- 支持多文件选择和拖拽上传
|
| 270 |
+
- 批量进度追踪
|
| 271 |
+
|
| 272 |
+
- [ ] **Agent 编辑流程优化**
|
| 273 |
+
- 为编辑模式添加独立路由 `/teacher/agent/edit/:id`
|
| 274 |
+
- 替换 localStorage 传参方式为 URL 参数 + API 获取
|
| 275 |
+
|
| 276 |
+
- [ ] **学生 Dashboard 充实内容**
|
| 277 |
+
- 学习统计、最近活动、推荐 Agent 等
|
| 278 |
+
|
| 279 |
+
- [ ] **LaTeX 公式渲染**
|
| 280 |
+
- 旧版使用 KaTeX,新版 AgentChat 的 Markdown 渲染中需集成
|
| 281 |
+
- 支持行内公式 `$...$` 和块级公式 `$$...$$`
|
| 282 |
+
|
| 283 |
+
- [ ] **消息中的引用源展示优化**
|
| 284 |
+
- 旧版有可折叠的引用卡片,显示文件名+摘要
|
| 285 |
+
- 新版已有基础实现,但样式和交互可优化
|
| 286 |
+
|
| 287 |
+
### 第五阶段:后端优化
|
| 288 |
+
|
| 289 |
+
- [ ] **统一认证机制**
|
| 290 |
+
- 旧版 app.py 中的硬编码用户和 session 装饰器与新版 JWT 并存,需要统一
|
| 291 |
+
- 决策:全部迁移到 JWT,废弃 session 方式
|
| 292 |
+
- 更新 `@login_required`、`@teacher_required`、`@student_required` 装饰器为 JWT 验证
|
| 293 |
+
|
| 294 |
+
- [ ] **密码哈希升级**
|
| 295 |
+
- 当前使用 SHA256 直接哈希,建议升级为 bcrypt 或 argon2
|
| 296 |
+
- 需要兼容迁移方案(登录时自动升级哈希)
|
| 297 |
+
|
| 298 |
+
- [ ] **插件检测逻辑优化**
|
| 299 |
+
- 当前基于消息关键词匹配(如包含"代码"就触发代码插件),过于粗糙
|
| 300 |
+
- 考虑让 LLM 在回复中标记是否需要插件,或使用更精确的意图识别
|
| 301 |
+
|
| 302 |
+
---
|
| 303 |
+
|
| 304 |
+
## 六、旧版功能对照清单
|
| 305 |
+
|
| 306 |
+
### 教师端功能
|
| 307 |
+
|
| 308 |
+
| 功能 | 旧版 | 新版 | 差距 |
|
| 309 |
+
|------|------|------|------|
|
| 310 |
+
| 统计面板 | 知识库数/Agent数/分发数 | ✅ 相同 | - |
|
| 311 |
+
| 知识库创建 | 多文件上传+进度条 | ⚠️ 单文件 | 缺批量 |
|
| 312 |
+
| 知识库文件管理 | 添加/删除文件 | ✅ | - |
|
| 313 |
+
| Agent 创建 | 基本信息+插件+知识库+工作流 | ✅ 4步向导 | - |
|
| 314 |
+
| Agent 工作流 | jsPlumb 可视化 | ✅ React Flow | 升级 |
|
| 315 |
+
| Agent 管理 | 列表+详情+编辑+删除 | ✅ | 编辑入口可优化 |
|
| 316 |
+
| 分发链接 | 创建+复制+过期设置 | ✅ | - |
|
| 317 |
+
| 学生管理 | CRUD+密码重置+进度 | ✅ | - |
|
| 318 |
+
|
| 319 |
+
### 学生端功能
|
| 320 |
+
|
| 321 |
+
| 功能 | 旧版 | 新版 | 差距 |
|
| 322 |
+
|------|------|------|------|
|
| 323 |
+
| 学生门户 | Agent列表+Token验证+活动记录 | ✅ | - |
|
| 324 |
+
| Agent 对话 | SSE流式+Markdown+代码高亮 | ✅ 基本可用 | SSE有bug |
|
| 325 |
+
| 打字机效果 | 暂停/加速控制 | ❌ Hook已写未接入 | 需接入 |
|
| 326 |
+
| LaTeX 公式 | KaTeX 渲染 | ❌ | 需集成 |
|
| 327 |
+
| 代码执行插件 | 侧面板内嵌 IDE | ❌ | **核心缺失** |
|
| 328 |
+
| 3D 可视化插件 | Plotly 侧面板 | ❌ | **核心缺失** |
|
| 329 |
+
| 思维导图插件 | PlantUML 侧面板 | ❌ | **核心缺失** |
|
| 330 |
+
| 引用源展示 | 可折叠卡片 | ⚠️ 基础实现 | 可优化 |
|
| 331 |
+
| 独立代码页 | 完整Python IDE | ❌ | 需新建页面 |
|
| 332 |
+
|
| 333 |
+
---
|
| 334 |
+
|
| 335 |
+
## 七、技术选型
|
| 336 |
+
|
| 337 |
+
### 7.1 思维导图:markmap(推荐)
|
| 338 |
+
|
| 339 |
+
**废弃旧方案**:旧版使用 PlantUML 语法 → 发送到外部 HuggingFace Space API → 返回 PNG 图片。问题:依赖外部服务、不可交互、渲染慢。
|
| 340 |
+
|
| 341 |
+
**新方案**:使用 markmap 在前端直接从 Markdown 生成交互式 SVG 思维导图。
|
| 342 |
+
|
| 343 |
+
| 对比项 | 旧方案 (PlantUML) | 新方案 (markmap) |
|
| 344 |
+
|--------|-------------------|------------------|
|
| 345 |
+
| 渲染位置 | 后端调外部API | 前端本地渲染 |
|
| 346 |
+
| 输出格式 | 静态 PNG | 交互式 SVG |
|
| 347 |
+
| 交互能力 | 无 | 缩放/平移/展开折叠 |
|
| 348 |
+
| 外部依赖 | HuggingFace Space | 无 |
|
| 349 |
+
| 延迟 | 3-10秒 | 毫秒级 |
|
| 350 |
+
| 输入格式 | PlantUML 专有语法 | Markdown 标题层级 |
|
| 351 |
+
|
| 352 |
+
**安装**:
|
| 353 |
+
```bash
|
| 354 |
+
npm install markmap-lib markmap-view d3@7
|
| 355 |
+
```
|
| 356 |
+
|
| 357 |
+
**核心用法**:
|
| 358 |
+
```jsx
|
| 359 |
+
import { Transformer } from 'markmap-lib';
|
| 360 |
+
import { Markmap } from 'markmap-view';
|
| 361 |
+
|
| 362 |
+
// Markdown → 思维导图数据
|
| 363 |
+
const transformer = new Transformer();
|
| 364 |
+
const { root } = transformer.transform(markdownText);
|
| 365 |
+
|
| 366 |
+
// 渲染到 SVG 元素
|
| 367 |
+
Markmap.create(svgElement, null, root);
|
| 368 |
+
```
|
| 369 |
+
|
| 370 |
+
**后端改动**:
|
| 371 |
+
- 让 LLM 在回复中直接输出 Markdown 层级结构(而非 PlantUML 语法)
|
| 372 |
+
- 或者保留后端端点,改为返回 Markdown 文本而非图片 URL
|
| 373 |
+
- 前端从 AI 回复中提取 Markdown 标题结构,直接渲染
|
| 374 |
+
|
| 375 |
+
**备选库对比**:
|
| 376 |
+
|
| 377 |
+
| 库 | Stars | 特点 | 适合场景 |
|
| 378 |
+
|-----|-------|------|---------|
|
| 379 |
+
| markmap | 9k+ | Markdown原生、SVG交互、轻量 | ✅ 本项目(AI输出Markdown) |
|
| 380 |
+
| simple-mind-map | 11k+ | 功能最全、主题丰富、插件多 | 需要完整思维导图编辑器 |
|
| 381 |
+
| mind-elixir | 3k | 零依赖、有React组件 | 需要拖拽编辑节点 |
|
| 382 |
+
| React Flow | 24k+ | 项目已在用、高度可定制 | 已用于工作流,可复用 |
|
| 383 |
+
|
| 384 |
+
选择 markmap 的理由:
|
| 385 |
+
1. AI 天然输出 Markdown,无需格式转换
|
| 386 |
+
2. 纯前端渲染,不依赖外部服务
|
| 387 |
+
3. 交互体验远超静态图片
|
| 388 |
+
4. 包体积小,集成简单
|
| 389 |
+
|
| 390 |
+
---
|
| 391 |
+
|
| 392 |
+
### 7.2 3D 可视化:保持 Plotly iframe 方案
|
| 393 |
+
|
| 394 |
+
旧版方案已经很成熟,保持不变:
|
| 395 |
+
|
| 396 |
+
```
|
| 397 |
+
前端提取代码 → POST /api/visualization/3d-surface → 后端沙箱执行 Python
|
| 398 |
+
→ 生成 Plotly HTML(内嵌 plotly.js) → 返回 HTML 文件 URL → 前端 iframe 加载
|
| 399 |
+
```
|
| 400 |
+
|
| 401 |
+
**交互能力**(Plotly 原生支持):
|
| 402 |
+
- 3D 旋转(鼠标拖拽)
|
| 403 |
+
- 缩放(滚轮)
|
| 404 |
+
- 平移(Shift+拖拽)
|
| 405 |
+
- 悬浮提示(坐标值)
|
| 406 |
+
- 图例切换
|
| 407 |
+
- 导出 PNG
|
| 408 |
+
- 双击重置视角
|
| 409 |
+
|
| 410 |
+
**React 迁移要点**:
|
| 411 |
+
- 不需要安装 `react-plotly.js`(图表在 iframe 中独立运行)
|
| 412 |
+
- 只需在 AgentChat 插件面板中嵌入 `<iframe src={htmlUrl} />`
|
| 413 |
+
- 添加全屏按钮和关闭按钮
|
| 414 |
+
|
| 415 |
+
**后端数据流**:
|
| 416 |
+
```
|
| 417 |
+
输入: { code: "def create_3d_plot():\n return {'x':[], 'y':[], 'z':[], 'type':'surface'}" }
|
| 418 |
+
输出: { success: true, html_url: "/static/3d_plot_1713012345.html" }
|
| 419 |
+
```
|
| 420 |
+
|
| 421 |
+
---
|
| 422 |
+
|
| 423 |
+
### 7.3 代码编辑器:CodeMirror 6(推荐)
|
| 424 |
+
|
| 425 |
+
| 对比项 | Monaco Editor | CodeMirror 6 |
|
| 426 |
+
|--------|--------------|--------------|
|
| 427 |
+
| 包体积 | ~2.5MB (gzip) | ~150KB (gzip) |
|
| 428 |
+
| 加载速度 | 较慢 | 快 |
|
| 429 |
+
| 移动端 | 差 | 好 |
|
| 430 |
+
| Python 支持 | 完整 | 完整 |
|
| 431 |
+
| React 集成 | @monaco-editor/react | @uiw/react-codemirror |
|
| 432 |
+
| 适合场景 | 完整 IDE | 轻量代码片段编辑 |
|
| 433 |
+
|
| 434 |
+
选择 CodeMirror 的理由:
|
| 435 |
+
1. 本项目只需要 Python 代码编辑,不需要完整 IDE 功能
|
| 436 |
+
2. 包体积小 15 倍,对教育平台的加载速度至关重要
|
| 437 |
+
3. 移动端适配更好(学生可能用手机访问)
|
| 438 |
+
|
| 439 |
+
**安装**:
|
| 440 |
+
```bash
|
| 441 |
+
npm install @uiw/react-codemirror @codemirror/lang-python
|
| 442 |
+
```
|
| 443 |
+
|
| 444 |
+
---
|
| 445 |
+
|
| 446 |
+
### 7.4 LaTeX 公式:rehype-katex(推荐)
|
| 447 |
+
|
| 448 |
+
当前 AgentChat 已使用 `react-markdown` + `remark-gfm`,集成 KaTeX 只需加两个插件:
|
| 449 |
+
|
| 450 |
+
```bash
|
| 451 |
+
npm install remark-math rehype-katex katex
|
| 452 |
+
```
|
| 453 |
+
|
| 454 |
+
```jsx
|
| 455 |
+
import remarkMath from 'remark-math';
|
| 456 |
+
import rehypeKatex from 'rehype-katex';
|
| 457 |
+
import 'katex/dist/katex.min.css';
|
| 458 |
+
|
| 459 |
+
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}>
|
| 460 |
+
{content}
|
| 461 |
+
</ReactMarkdown>
|
| 462 |
+
```
|
| 463 |
+
|
| 464 |
+
---
|
| 465 |
+
|
| 466 |
+
### 7.5 技术选型汇总
|
| 467 |
+
|
| 468 |
+
| 组件 | 选型 | npm 包 | 理由 |
|
| 469 |
+
|------|------|--------|------|
|
| 470 |
+
| 思维导图 | **markmap** | `markmap-lib` + `markmap-view` | Markdown原生、前端渲染、交互式 |
|
| 471 |
+
| 3D 可视化 | **Plotly iframe** | 无需前端安装 | 保持旧版成熟方案,后端生成HTML |
|
| 472 |
+
| 代码编辑器 | **CodeMirror 6** | `@uiw/react-codemirror` | 轻量、移动端友好 |
|
| 473 |
+
| LaTeX 公式 | **KaTeX** | `remark-math` + `rehype-katex` | 与现有 react-markdown 无缝集成 |
|
| 474 |
+
| 代码高亮 | **react-syntax-highlighter** | 已安装 | 项目已在用 |
|
| 475 |
+
| 工作流编辑 | **React Flow** | 已安装 | 项目已在用 |
|
| 476 |
+
|
| 477 |
+
---
|
| 478 |
+
|
| 479 |
+
## 八、文件结构参考
|
| 480 |
+
|
| 481 |
+
```
|
| 482 |
+
frontend/src/
|
| 483 |
+
├── App.jsx # 路由配置
|
| 484 |
+
├── index.css # 全局样式
|
| 485 |
+
├── services/
|
| 486 |
+
│ ├── api.js # axios 实例 + 拦截器
|
| 487 |
+
│ ├── authService.js # 认证 API
|
| 488 |
+
│ ├── agentService.js # Agent API
|
| 489 |
+
│ └── knowledgeService.js # 知识库 API
|
| 490 |
+
├── store/
|
| 491 |
+
│ ├── store.js # Redux store
|
| 492 |
+
│ └── slices/
|
| 493 |
+
│ ├── authSlice.js # 认证状态
|
| 494 |
+
│ ├── agentSlice.js # Agent 状态
|
| 495 |
+
│ ├── chatSlice.js # 对话状态
|
| 496 |
+
│ ├── workflowSlice.js # 工作流状态
|
| 497 |
+
│ └── uiSlice.js # UI 状态
|
| 498 |
+
├── pages/
|
| 499 |
+
│ ├── LoginPage.jsx # 登录页
|
| 500 |
+
│ ├── RegisterPage.jsx # 注册页
|
| 501 |
+
│ ├── teacher/
|
| 502 |
+
│ │ ├── TeacherDashboard.jsx # 教师端框架
|
| 503 |
+
│ │ ├── Dashboard.jsx # 概览页
|
| 504 |
+
│ │ ├── AgentList.jsx # Agent 列表
|
| 505 |
+
│ │ ├── CreateAgent.jsx # Agent 创建向导
|
| 506 |
+
│ │ ├── KnowledgeBase.jsx # 知识库管理
|
| 507 |
+
│ │ └── StudentManagement.jsx # 学生管理
|
| 508 |
+
│ └── student/
|
| 509 |
+
│ ├── StudentDashboard.jsx # 学生端框架
|
| 510 |
+
│ ├── StudentPortal.jsx # 学生门户
|
| 511 |
+
│ └── AgentChat.jsx # Agent 对话
|
| 512 |
+
├── components/
|
| 513 |
+
│ └── common/
|
| 514 |
+
│ ├── ProtectedRoute.jsx # 路由保护
|
| 515 |
+
│ └── WorkflowEditor.jsx # 工作流编辑器
|
| 516 |
+
└── hooks/
|
| 517 |
+
└── useTypewriter.js # 打字机效果 hook
|
| 518 |
+
|
| 519 |
+
agent/ # 后端
|
| 520 |
+
├── app.py # Flask 主应用 + 路由
|
| 521 |
+
├── config.py # 配置
|
| 522 |
+
├── auth.db # SQLite 用户库
|
| 523 |
+
├── student_management.py # 学生管理路由
|
| 524 |
+
├── modules/
|
| 525 |
+
│ ├── auth/routes.py # 认证路由
|
| 526 |
+
│ ├── knowledge_base/
|
| 527 |
+
│ │ ├── routes.py # 知识库路由
|
| 528 |
+
│ │ ├── retriever.py # ES 检索器
|
| 529 |
+
│ │ ├── reranker.py # 重排序器
|
| 530 |
+
│ │ ├── generator.py # LLM 生成器
|
| 531 |
+
│ │ ├── processor.py # 文档处理器
|
| 532 |
+
│ │ └── vector_store.py # ES 向量存储
|
| 533 |
+
│ ├── agent_builder/routes.py # Agent 构建路由
|
| 534 |
+
│ ├── code_executor/routes.py # 代码执行路由
|
| 535 |
+
│ └── visualization/routes.py # 可视化路由
|
| 536 |
+
├── agents/ # Agent JSON 配置存储
|
| 537 |
+
├── uploads/ # 上传文件存储
|
| 538 |
+
└── templates/ # 旧版 HTML 模板(待废弃)
|
| 539 |
+
```
|
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# 安装系统依赖
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
build-essential \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# 安装 Python 依赖
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
# 复制应用代码
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# 确保目录存在
|
| 18 |
+
RUN mkdir -p static uploads agents
|
| 19 |
+
|
| 20 |
+
EXPOSE 7860
|
| 21 |
+
|
| 22 |
+
# 使用 gunicorn 运行,支持 SSE 流式输出
|
| 23 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "2", "--threads", "4", "--timeout", "120", "app:app"]
|
README-local.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TeachAgent - 智能教育助手平台
|
| 2 |
+
|
| 3 |
+
AI 驱动的个性化学习平台,支持教师创建 AI Agent、管理知识库,学生通过对话式交互进行学习。
|
| 4 |
+
|
| 5 |
+
## 功能特性
|
| 6 |
+
|
| 7 |
+
- **教师端**:创建/管理 AI Agent、知识库管理、学生管理、分发链接、工作流编辑
|
| 8 |
+
- **学生端**:与 AI Agent 对话、代码执行、3D 可视化、思维导图
|
| 9 |
+
- **RAG 系统**:基于 Elasticsearch 的向量检索 + 重排序
|
| 10 |
+
- **流式对话**:SSE 实时流式输出
|
| 11 |
+
- **插件系统**:代码执行(CodeMirror)、3D 可视化(Plotly)、思维导图(markmap)
|
| 12 |
+
|
| 13 |
+
## 项目结构
|
| 14 |
+
|
| 15 |
+
```
|
| 16 |
+
teachagent/
|
| 17 |
+
├── ELASTIC/ # Elasticsearch 容器(独立部署)
|
| 18 |
+
│ └── Dockerfile
|
| 19 |
+
├── teachagent-app/ # 主应用
|
| 20 |
+
│ ├── app.py # Flask 入口
|
| 21 |
+
│ ├── config.py # 配置
|
| 22 |
+
│ ├── requirements.txt # Python 依赖
|
| 23 |
+
│ ├── Dockerfile # 应用容器
|
| 24 |
+
│ ├── .env # 环境变量
|
| 25 |
+
│ ├── modules/ # 后端模块
|
| 26 |
+
│ │ ├── auth/ # 认证(JWT + 密码/邮箱/手机登录)
|
| 27 |
+
│ │ ├── agent_builder/ # Agent 构建
|
| 28 |
+
│ │ ├── code_executor/ # Python 代码执行
|
| 29 |
+
│ │ ├── knowledge_base/ # RAG 知识库(检索/重排/生成)
|
| 30 |
+
│ │ └── visualization/ # 3D 可视化 + 思维导图
|
| 31 |
+
│ ├── frontend/ # React 源码(开发用)
|
| 32 |
+
│ │ ├── src/
|
| 33 |
+
│ │ ├── package.json
|
| 34 |
+
│ │ └── vite.config.js
|
| 35 |
+
│ ├── static/dist/ # React 构建产物(生产环境前端)
|
| 36 |
+
│ ├── agents/ # Agent JSON 配置存储
|
| 37 |
+
│ ├── uploads/ # 用户上传文件
|
| 38 |
+
│ └── _archive/ # 旧版 Flask 模板归档
|
| 39 |
+
└── docker-compose.yml # 两容器编排
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
## 部署方式
|
| 43 |
+
|
| 44 |
+
### 方式一:本地开发
|
| 45 |
+
|
| 46 |
+
适合开发调试,前后端分离运行。
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
# 1. 启动 Elasticsearch(Docker 或本地安装)
|
| 50 |
+
docker run -d --name es -p 9200:9200 \
|
| 51 |
+
-e discovery.type=single-node \
|
| 52 |
+
-e xpack.security.enabled=false \
|
| 53 |
+
elasticsearch:9.3.3
|
| 54 |
+
|
| 55 |
+
# 2. 启动 Flask 后端
|
| 56 |
+
cd teachagent-app
|
| 57 |
+
pip install -r requirements.txt
|
| 58 |
+
FLASK_ENV=development python app.py
|
| 59 |
+
# 后端运行在 http://localhost:7860
|
| 60 |
+
|
| 61 |
+
# 3. 启动 React 前端(开发模式,带热更新)
|
| 62 |
+
cd frontend
|
| 63 |
+
npm install
|
| 64 |
+
npm run dev
|
| 65 |
+
# 前端运行在 http://localhost:3000,API 请求代理到 7860
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
**本地开发特点**:
|
| 69 |
+
- 前端 `localhost:3000`,后端 `localhost:7860`,通过 Vite 代理转发 API
|
| 70 |
+
- SSE 流式输出需要直连后端(绕过 Vite 代理)
|
| 71 |
+
- `FLASK_ENV=development` 开启 debug 模式和热重载
|
| 72 |
+
- 修改前端代码即时生效(HMR)
|
| 73 |
+
|
| 74 |
+
### 方式二:云端容器部署(生产环境)
|
| 75 |
+
|
| 76 |
+
两个容器,一个端口。
|
| 77 |
+
|
| 78 |
+
```bash
|
| 79 |
+
# 在项目根目录(包含 docker-compose.yml 的目录)
|
| 80 |
+
docker-compose up -d
|
| 81 |
+
|
| 82 |
+
# 或分别构建
|
| 83 |
+
docker-compose build
|
| 84 |
+
docker-compose up -d
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
**生产环境特点**:
|
| 88 |
+
- 单端口 `7860`:Flask 同时托管 API 和 React 静态文件
|
| 89 |
+
- 无跨域问题:前端和 API 同源
|
| 90 |
+
- SSE 流式直连:不经过代理,实时输出
|
| 91 |
+
- gunicorn 多 worker 运行,性能更好
|
| 92 |
+
- ES 通过 Docker 网络互通(`http://elasticsearch:9200`)
|
| 93 |
+
|
| 94 |
+
### 方式三:HuggingFace Spaces 部署
|
| 95 |
+
|
| 96 |
+
适用于 HuggingFace Spaces 平台。
|
| 97 |
+
|
| 98 |
+
1. 将 `teachagent-app/` 作为 Space 仓库
|
| 99 |
+
2. ES 单独部署为另一个 Space(`ELASTIC/`)
|
| 100 |
+
3. `.env` 中配置 `ELASTICSEARCH_URL=https://your-es-space.hf.space`
|
| 101 |
+
|
| 102 |
+
## 环境变量说明
|
| 103 |
+
|
| 104 |
+
| 变量 | 说明 | 示例 |
|
| 105 |
+
|------|------|------|
|
| 106 |
+
| `ELASTICSEARCH_URL` | Elasticsearch 地址 | `http://localhost:9200` 或 `https://xxx.hf.space` |
|
| 107 |
+
| `API_KEY` | SiliconFlow API Key(Embedding/Rerank) | `sk-xxx` |
|
| 108 |
+
| `BASE_URL` | SiliconFlow API 地址 | `https://api.siliconflow.cn/v1` |
|
| 109 |
+
| `STREAM_API_KEY` | DeepSeek API Key(对话生成) | `sk-xxx` |
|
| 110 |
+
| `STREAM_BASE_URL` | DeepSeek API 地址 | `https://api.deepseek.com/v1` |
|
| 111 |
+
| `STREAM_MODEL` | 对话模型 | `deepseek-chat` |
|
| 112 |
+
| `SECRET_KEY` | Flask Session 密钥 | 随机字符串 |
|
| 113 |
+
| `JWT_SECRET` | JWT 签名密钥 | 随机字符串 |
|
| 114 |
+
| `FLASK_ENV` | 运行环境 | `development` 或 `production` |
|
| 115 |
+
|
| 116 |
+
## 跨域与 HTTPS
|
| 117 |
+
|
| 118 |
+
### 生产环境(推荐)
|
| 119 |
+
|
| 120 |
+
- **无跨域问题**:前端构建产物由 Flask 直接托管,前端和 API 同源(同一个端口 7860)
|
| 121 |
+
- **HTTPS**:由反向代理(Nginx/Cloudflare/HuggingFace Spaces)统一处理 SSL 终止,Flask 本身只需 HTTP
|
| 122 |
+
- **SSE 流式**:同源直连,无需代理,数据实时到达
|
| 123 |
+
|
| 124 |
+
### 本地开发
|
| 125 |
+
|
| 126 |
+
- **跨域**:Vite 代理 (`localhost:3000` → `localhost:7860`) 处理 API 请求,SSE 直连后端
|
| 127 |
+
- **HTTPS**:本地开发无需 HTTPS,全部 HTTP
|
| 128 |
+
- **注意**:HTML 中不要加 `upgrade-insecure-requests` meta 标签(已移除)
|
| 129 |
+
|
| 130 |
+
### 带 HTTPS 反向代理部署
|
| 131 |
+
|
| 132 |
+
如果在自有服务器上通过 Nginx 反向代理:
|
| 133 |
+
|
| 134 |
+
```nginx
|
| 135 |
+
server {
|
| 136 |
+
listen 443 ssl;
|
| 137 |
+
server_name your-domain.com;
|
| 138 |
+
|
| 139 |
+
ssl_certificate /path/to/cert.pem;
|
| 140 |
+
ssl_certificate_key /path/to/key.pem;
|
| 141 |
+
|
| 142 |
+
location / {
|
| 143 |
+
proxy_pass http://127.0.0.1:7860;
|
| 144 |
+
proxy_http_version 1.1;
|
| 145 |
+
proxy_set_header Host $host;
|
| 146 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 147 |
+
|
| 148 |
+
# SSE 流式输出必须的配置
|
| 149 |
+
proxy_buffering off;
|
| 150 |
+
proxy_cache off;
|
| 151 |
+
proxy_set_header Connection '';
|
| 152 |
+
chunked_transfer_encoding on;
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
关键配置:`proxy_buffering off` 确保 SSE 数据不被 Nginx 缓冲。
|
| 158 |
+
|
| 159 |
+
## 演示账号
|
| 160 |
+
|
| 161 |
+
| 角色 | 用户名 | 密码 |
|
| 162 |
+
|------|--------|------|
|
| 163 |
+
| 教师 | teacher | 123456 |
|
| 164 |
+
| 学生 | student1 | 123456 |
|
| 165 |
+
|
| 166 |
+
也可通过注册页面创建新账号。
|
| 167 |
+
|
| 168 |
+
## 技术栈
|
| 169 |
+
|
| 170 |
+
| 层 | 技术 |
|
| 171 |
+
|----|------|
|
| 172 |
+
| 前端 | React 19 + Vite + Ant Design 5 + TailwindCSS + Redux Toolkit |
|
| 173 |
+
| 后端 | Flask + gunicorn |
|
| 174 |
+
| 数据库 | Elasticsearch 9.x(向量检索) + SQLite(用户管理) |
|
| 175 |
+
| AI | DeepSeek(对话) + SiliconFlow(Embedding/Rerank) |
|
| 176 |
+
| 部署 | Docker + docker-compose |
|
_archive/templates/code_execution.html
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>AI代码助手 - Python执行环境</title>
|
| 7 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css">
|
| 8 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
|
| 9 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/vs2015.min.css">
|
| 10 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
|
| 11 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/languages/python.min.js"></script>
|
| 12 |
+
<style>
|
| 13 |
+
/* Base styles */
|
| 14 |
+
:root {
|
| 15 |
+
--primary-color: #4361ee;
|
| 16 |
+
--secondary-color: #3f37c9;
|
| 17 |
+
--accent-color: #4cc9f0;
|
| 18 |
+
--success-color: #4caf50;
|
| 19 |
+
--warning-color: #ff9800;
|
| 20 |
+
--danger-color: #f44336;
|
| 21 |
+
--light-color: #f8f9fa;
|
| 22 |
+
--dark-color: #212529;
|
| 23 |
+
--border-color: #dee2e6;
|
| 24 |
+
--border-radius: 0.375rem;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
body {
|
| 28 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 29 |
+
margin: 0;
|
| 30 |
+
padding: 0;
|
| 31 |
+
height: 100vh;
|
| 32 |
+
background-color: #f5f7fa;
|
| 33 |
+
color: #333;
|
| 34 |
+
display: flex;
|
| 35 |
+
flex-direction: column;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* Layout structure */
|
| 39 |
+
.workspace {
|
| 40 |
+
display: grid;
|
| 41 |
+
grid-template-columns: 1fr 1fr;
|
| 42 |
+
gap: 16px;
|
| 43 |
+
flex: 1;
|
| 44 |
+
padding: 16px;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
@media (max-width: 992px) {
|
| 48 |
+
.workspace {
|
| 49 |
+
grid-template-columns: 1fr;
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.section {
|
| 54 |
+
background: #fff;
|
| 55 |
+
border-radius: var(--border-radius);
|
| 56 |
+
overflow: hidden;
|
| 57 |
+
display: flex;
|
| 58 |
+
flex-direction: column;
|
| 59 |
+
border: 1px solid var(--border-color);
|
| 60 |
+
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.section-header {
|
| 64 |
+
background: #f8f9fa;
|
| 65 |
+
padding: 12px 16px;
|
| 66 |
+
border-bottom: 1px solid var(--border-color);
|
| 67 |
+
display: flex;
|
| 68 |
+
justify-content: space-between;
|
| 69 |
+
align-items: center;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.section-title {
|
| 73 |
+
display: flex;
|
| 74 |
+
align-items: center;
|
| 75 |
+
gap: 8px;
|
| 76 |
+
font-size: 1rem;
|
| 77 |
+
font-weight: 500;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/* Code editor styles */
|
| 81 |
+
.editor-content {
|
| 82 |
+
flex: 1;
|
| 83 |
+
position: relative;
|
| 84 |
+
overflow: hidden;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.code-area {
|
| 88 |
+
position: absolute;
|
| 89 |
+
left: 40px;
|
| 90 |
+
right: 0;
|
| 91 |
+
top: 0;
|
| 92 |
+
bottom: 0;
|
| 93 |
+
padding: 12px 16px;
|
| 94 |
+
color: #333;
|
| 95 |
+
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
| 96 |
+
font-size: 14px;
|
| 97 |
+
line-height: 1.6;
|
| 98 |
+
overflow: auto;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.code-area pre {
|
| 102 |
+
margin: 0;
|
| 103 |
+
padding: 0;
|
| 104 |
+
background: none;
|
| 105 |
+
border: none;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.code-area code {
|
| 109 |
+
display: block;
|
| 110 |
+
padding: 0;
|
| 111 |
+
tab-size: 4;
|
| 112 |
+
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
| 113 |
+
outline: none;
|
| 114 |
+
position: relative;
|
| 115 |
+
min-height: 100%;
|
| 116 |
+
white-space: pre !important;
|
| 117 |
+
word-wrap: normal !important;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.line-numbers {
|
| 121 |
+
position: absolute;
|
| 122 |
+
left: 0;
|
| 123 |
+
top: 0;
|
| 124 |
+
bottom: 0;
|
| 125 |
+
width: 40px;
|
| 126 |
+
padding: 12px 0;
|
| 127 |
+
background: #f5f7fa;
|
| 128 |
+
border-right: 1px solid #e9ecef;
|
| 129 |
+
text-align: center;
|
| 130 |
+
color: #6c757d;
|
| 131 |
+
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
| 132 |
+
font-size: 14px;
|
| 133 |
+
line-height: 1.6;
|
| 134 |
+
user-select: none;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
/* Terminal styles */
|
| 138 |
+
.output-content {
|
| 139 |
+
flex: 1;
|
| 140 |
+
background: #f8f9fa;
|
| 141 |
+
position: relative;
|
| 142 |
+
overflow: hidden;
|
| 143 |
+
display: flex;
|
| 144 |
+
flex-direction: column;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.terminal-window {
|
| 148 |
+
flex: 1;
|
| 149 |
+
overflow-y: auto;
|
| 150 |
+
background: #212529;
|
| 151 |
+
color: #f8f9fa;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
#output {
|
| 155 |
+
color: #f8f9fa;
|
| 156 |
+
margin: 0;
|
| 157 |
+
padding: 12px 16px;
|
| 158 |
+
background: transparent;
|
| 159 |
+
border: none;
|
| 160 |
+
white-space: pre-wrap;
|
| 161 |
+
word-wrap: break-word;
|
| 162 |
+
line-height: 1.6;
|
| 163 |
+
font-size: 14px;
|
| 164 |
+
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.console-input-container {
|
| 168 |
+
display: flex;
|
| 169 |
+
align-items: center;
|
| 170 |
+
background: #343a40;
|
| 171 |
+
border-top: 1px solid #495057;
|
| 172 |
+
padding: 8px 12px;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.console-prompt {
|
| 176 |
+
color: #4caf50;
|
| 177 |
+
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
| 178 |
+
margin-right: 8px;
|
| 179 |
+
font-size: 14px;
|
| 180 |
+
user-select: none;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.console-input {
|
| 184 |
+
flex: 1;
|
| 185 |
+
background: transparent;
|
| 186 |
+
border: none;
|
| 187 |
+
color: #f8f9fa;
|
| 188 |
+
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
| 189 |
+
font-size: 14px;
|
| 190 |
+
line-height: 1.5;
|
| 191 |
+
padding: 4px 0;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.console-input:focus {
|
| 195 |
+
outline: none;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
/* Chat section */
|
| 199 |
+
.chat-section {
|
| 200 |
+
display: flex;
|
| 201 |
+
flex-direction: column;
|
| 202 |
+
height: 100%;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.chat-messages {
|
| 206 |
+
flex: 1;
|
| 207 |
+
overflow-y: auto;
|
| 208 |
+
padding: 16px;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.message {
|
| 212 |
+
margin-bottom: 16px;
|
| 213 |
+
padding: 12px;
|
| 214 |
+
border-radius: var(--border-radius);
|
| 215 |
+
max-width: 85%;
|
| 216 |
+
position: relative;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.message.user {
|
| 220 |
+
background-color: #e3f2fd;
|
| 221 |
+
color: #0d47a1;
|
| 222 |
+
align-self: flex-end;
|
| 223 |
+
margin-left: auto;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.message.bot {
|
| 227 |
+
background-color: #f5f5f5;
|
| 228 |
+
color: #333;
|
| 229 |
+
align-self: flex-start;
|
| 230 |
+
border-left: 3px solid var(--primary-color);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.chat-input-container {
|
| 234 |
+
padding: 16px;
|
| 235 |
+
border-top: 1px solid var(--border-color);
|
| 236 |
+
background-color: #f9f9f9;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.input-row {
|
| 240 |
+
display: flex;
|
| 241 |
+
gap: 8px;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.chat-input {
|
| 245 |
+
flex: 1;
|
| 246 |
+
padding: 12px;
|
| 247 |
+
border: 1px solid var(--border-color);
|
| 248 |
+
border-radius: var(--border-radius);
|
| 249 |
+
resize: none;
|
| 250 |
+
font-size: 14px;
|
| 251 |
+
height: 100px;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.chat-input:focus {
|
| 255 |
+
outline: none;
|
| 256 |
+
border-color: var(--primary-color);
|
| 257 |
+
box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.1);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/* Button styles */
|
| 261 |
+
.btn {
|
| 262 |
+
padding: 8px 16px;
|
| 263 |
+
border: none;
|
| 264 |
+
border-radius: var(--border-radius);
|
| 265 |
+
cursor: pointer;
|
| 266 |
+
font-weight: 500;
|
| 267 |
+
font-size: 14px;
|
| 268 |
+
display: flex;
|
| 269 |
+
align-items: center;
|
| 270 |
+
gap: 8px;
|
| 271 |
+
transition: all 0.2s ease;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.btn-primary {
|
| 275 |
+
background-color: var(--primary-color);
|
| 276 |
+
color: white;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.btn-primary:hover {
|
| 280 |
+
background-color: var(--secondary-color);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.btn-secondary {
|
| 284 |
+
background-color: #6c757d;
|
| 285 |
+
color: white;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.btn-secondary:hover {
|
| 289 |
+
background-color: #5a6268;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.btn-danger {
|
| 293 |
+
background-color: var(--danger-color);
|
| 294 |
+
color: white;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.btn-danger:hover {
|
| 298 |
+
background-color: #d32f2f;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
/* Utilities */
|
| 302 |
+
.loading {
|
| 303 |
+
display: none;
|
| 304 |
+
align-items: center;
|
| 305 |
+
gap: 8px;
|
| 306 |
+
color: #6c757d;
|
| 307 |
+
font-size: 14px;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.loading.active {
|
| 311 |
+
display: flex;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
@keyframes spin {
|
| 315 |
+
to { transform: rotate(360deg); }
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.loading i {
|
| 319 |
+
animation: spin 1s linear infinite;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
/* Custom scrollbar */
|
| 323 |
+
::-webkit-scrollbar {
|
| 324 |
+
width: 8px;
|
| 325 |
+
height: 8px;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
::-webkit-scrollbar-track {
|
| 329 |
+
background: #f1f1f1;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
::-webkit-scrollbar-thumb {
|
| 333 |
+
background: #c1c1c1;
|
| 334 |
+
border-radius: 4px;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
::-webkit-scrollbar-thumb:hover {
|
| 338 |
+
background: #a8a8a8;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
/* Terminal text styles */
|
| 342 |
+
.term-input {
|
| 343 |
+
color: #4caf50;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
.term-output {
|
| 347 |
+
color: #f8f9fa;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.term-error {
|
| 351 |
+
color: #f44336;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
.term-warning {
|
| 355 |
+
color: #ff9800;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.term-system {
|
| 359 |
+
color: #2196f3;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
/* Header */
|
| 363 |
+
.header {
|
| 364 |
+
background-color: #fff;
|
| 365 |
+
border-bottom: 1px solid var(--border-color);
|
| 366 |
+
padding: 1rem;
|
| 367 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.header-content {
|
| 371 |
+
max-width: 1200px;
|
| 372 |
+
margin: 0 auto;
|
| 373 |
+
display: flex;
|
| 374 |
+
justify-content: space-between;
|
| 375 |
+
align-items: center;
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
.header h1 {
|
| 379 |
+
margin: 0;
|
| 380 |
+
font-size: 1.5rem;
|
| 381 |
+
color: var(--primary-color);
|
| 382 |
+
}
|
| 383 |
+
</style>
|
| 384 |
+
</head>
|
| 385 |
+
<body>
|
| 386 |
+
<header class="header">
|
| 387 |
+
<div class="header-content">
|
| 388 |
+
<h1>AI代码助手</h1>
|
| 389 |
+
<div>
|
| 390 |
+
<span class="badge bg-primary">Python编程环境</span>
|
| 391 |
+
</div>
|
| 392 |
+
</div>
|
| 393 |
+
</header>
|
| 394 |
+
|
| 395 |
+
<div class="workspace">
|
| 396 |
+
<div class="section">
|
| 397 |
+
<div class="section-header">
|
| 398 |
+
<div class="section-title">
|
| 399 |
+
<i class="bi bi-code-square"></i>
|
| 400 |
+
代码编辑器
|
| 401 |
+
</div>
|
| 402 |
+
<div style="display: flex; gap: 8px;">
|
| 403 |
+
<button class="btn btn-primary" id="runCode">
|
| 404 |
+
<i class="bi bi-play-fill"></i>
|
| 405 |
+
运行
|
| 406 |
+
</button>
|
| 407 |
+
<button class="btn btn-danger" id="stopCode" style="display: none;">
|
| 408 |
+
<i class="bi bi-stop-fill"></i>
|
| 409 |
+
停止
|
| 410 |
+
</button>
|
| 411 |
+
<button class="btn btn-secondary" id="clearCode">
|
| 412 |
+
<i class="bi bi-trash"></i>
|
| 413 |
+
清除
|
| 414 |
+
</button>
|
| 415 |
+
</div>
|
| 416 |
+
</div>
|
| 417 |
+
<div class="editor-content">
|
| 418 |
+
<div class="line-numbers" id="lineNumbers">1</div>
|
| 419 |
+
<div class="code-area" id="codeArea">
|
| 420 |
+
<pre><code class="language-python" contenteditable="true" spellcheck="false" autocorrect="off" autocapitalize="off"># 您的代码将在这里显示</code></pre>
|
| 421 |
+
</div>
|
| 422 |
+
</div>
|
| 423 |
+
</div>
|
| 424 |
+
|
| 425 |
+
<div class="section">
|
| 426 |
+
<div class="section-header">
|
| 427 |
+
<div class="section-title">
|
| 428 |
+
<i class="bi bi-terminal"></i>
|
| 429 |
+
终端输出
|
| 430 |
+
</div>
|
| 431 |
+
<div style="display: flex; gap: 8px;">
|
| 432 |
+
<button class="btn btn-secondary" id="clearTerminal">
|
| 433 |
+
<i class="bi bi-eraser"></i>
|
| 434 |
+
清除
|
| 435 |
+
</button>
|
| 436 |
+
</div>
|
| 437 |
+
</div>
|
| 438 |
+
<div class="output-content">
|
| 439 |
+
<div class="terminal-window">
|
| 440 |
+
<pre id="output"></pre>
|
| 441 |
+
</div>
|
| 442 |
+
<div class="console-input-container" id="consoleInputContainer" style="display: none;">
|
| 443 |
+
<div class="console-prompt">>></div>
|
| 444 |
+
<input type="text" id="consoleInput" class="console-input" autocomplete="off" spellcheck="false" />
|
| 445 |
+
</div>
|
| 446 |
+
</div>
|
| 447 |
+
</div>
|
| 448 |
+
</div>
|
| 449 |
+
|
| 450 |
+
<script>
|
| 451 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 452 |
+
// Terminal elements
|
| 453 |
+
const terminalOutput = document.getElementById('output');
|
| 454 |
+
const consoleInputContainer = document.getElementById('consoleInputContainer');
|
| 455 |
+
const consoleInput = document.getElementById('consoleInput');
|
| 456 |
+
const clearTerminalBtn = document.getElementById('clearTerminal');
|
| 457 |
+
const stopCodeBtn = document.getElementById('stopCode');
|
| 458 |
+
|
| 459 |
+
// Editor elements
|
| 460 |
+
const codeArea = document.querySelector('#codeArea code');
|
| 461 |
+
const lineNumbers = document.getElementById('lineNumbers');
|
| 462 |
+
const runButton = document.getElementById('runCode');
|
| 463 |
+
const clearButton = document.getElementById('clearCode');
|
| 464 |
+
|
| 465 |
+
// State variables
|
| 466 |
+
let executionContext = null;
|
| 467 |
+
let isExecuting = false;
|
| 468 |
+
let executionStartTime = null;
|
| 469 |
+
|
| 470 |
+
// Initialize terminal
|
| 471 |
+
initializeTerminal();
|
| 472 |
+
|
| 473 |
+
// Initialize editor
|
| 474 |
+
updateLineNumbers();
|
| 475 |
+
|
| 476 |
+
// Function to update line numbers
|
| 477 |
+
function updateLineNumbers() {
|
| 478 |
+
const lines = codeArea.textContent.split('\n').length;
|
| 479 |
+
lineNumbers.innerHTML = Array.from({length: lines}, (_, i) => i + 1).join('<br>');
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
// Initialize terminal with welcome message
|
| 483 |
+
function initializeTerminal() {
|
| 484 |
+
clearTerminalOutput();
|
| 485 |
+
appendToTerminal("欢迎使用Python交互式终端", "term-system");
|
| 486 |
+
appendToTerminal("使用'运行'按钮执行您的代码", "term-system");
|
| 487 |
+
appendToTerminal("", "term-system"); // 空行
|
| 488 |
+
appendToTerminal(">>> 准备执行代码...", "term-system");
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
// Append text to terminal
|
| 492 |
+
function appendToTerminal(text, type = null) {
|
| 493 |
+
const lines = text.split('\n');
|
| 494 |
+
let html = '';
|
| 495 |
+
|
| 496 |
+
for (const line of lines) {
|
| 497 |
+
if (type) {
|
| 498 |
+
html += `<span class="${type}">${escapeHtml(line)}</span>\n`;
|
| 499 |
+
} else {
|
| 500 |
+
html += escapeHtml(line) + '\n';
|
| 501 |
+
}
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
terminalOutput.innerHTML += html;
|
| 505 |
+
terminalOutput.scrollTop = terminalOutput.scrollHeight;
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
// Clear terminal output
|
| 509 |
+
function clearTerminalOutput() {
|
| 510 |
+
terminalOutput.innerHTML = '';
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
// Escape HTML to prevent XSS
|
| 514 |
+
function escapeHtml(text) {
|
| 515 |
+
return text
|
| 516 |
+
.replace(/&/g, "&")
|
| 517 |
+
.replace(/</g, "<")
|
| 518 |
+
.replace(/>/g, ">")
|
| 519 |
+
.replace(/"/g, """)
|
| 520 |
+
.replace(/'/g, "'");
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
// Run code
|
| 524 |
+
runButton.addEventListener('click', async () => {
|
| 525 |
+
if (isExecuting) return; // Prevent multiple executions
|
| 526 |
+
|
| 527 |
+
const code = codeArea.textContent;
|
| 528 |
+
|
| 529 |
+
// Clear terminal and show execution start
|
| 530 |
+
clearTerminalOutput();
|
| 531 |
+
appendToTerminal("开始执行Python代码...", "term-system");
|
| 532 |
+
|
| 533 |
+
// Update UI state
|
| 534 |
+
isExecuting = true;
|
| 535 |
+
executionStartTime = performance.now();
|
| 536 |
+
runButton.style.display = 'none';
|
| 537 |
+
stopCodeBtn.style.display = 'flex';
|
| 538 |
+
|
| 539 |
+
try {
|
| 540 |
+
const response = await fetch('/api/code/execute', {
|
| 541 |
+
method: 'POST',
|
| 542 |
+
headers: { 'Content-Type': 'application/json' },
|
| 543 |
+
body: JSON.stringify({ code })
|
| 544 |
+
});
|
| 545 |
+
|
| 546 |
+
const data = await response.json();
|
| 547 |
+
|
| 548 |
+
if (data.success) {
|
| 549 |
+
// Show output (if any)
|
| 550 |
+
if (data.output && data.output.trim()) {
|
| 551 |
+
appendToTerminal(data.output, "term-output");
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
if (data.needsInput) {
|
| 555 |
+
// Code is waiting for input
|
| 556 |
+
executionContext = data.context_id;
|
| 557 |
+
consoleInputContainer.style.display = 'flex';
|
| 558 |
+
consoleInput.focus();
|
| 559 |
+
} else {
|
| 560 |
+
// Code has completed, no input needed
|
| 561 |
+
appendToTerminal("程序执行完成", "term-system");
|
| 562 |
+
finishExecution();
|
| 563 |
+
}
|
| 564 |
+
} else {
|
| 565 |
+
// Handle error
|
| 566 |
+
appendToTerminal(`错误: ${data.error}`, "term-error");
|
| 567 |
+
if (data.traceback) {
|
| 568 |
+
appendToTerminal(data.traceback, "term-error");
|
| 569 |
+
}
|
| 570 |
+
appendToTerminal("执行失败", "term-system");
|
| 571 |
+
finishExecution();
|
| 572 |
+
}
|
| 573 |
+
} catch (error) {
|
| 574 |
+
appendToTerminal(`系统错误: ${error.message}`, "term-error");
|
| 575 |
+
appendToTerminal("执行失败", "term-system");
|
| 576 |
+
finishExecution();
|
| 577 |
+
}
|
| 578 |
+
});
|
| 579 |
+
|
| 580 |
+
// Submit console input
|
| 581 |
+
consoleInput.addEventListener('keydown', async (e) => {
|
| 582 |
+
if (e.key === 'Enter' && executionContext) {
|
| 583 |
+
const input = consoleInput.value;
|
| 584 |
+
consoleInput.value = '';
|
| 585 |
+
|
| 586 |
+
// Add input to terminal
|
| 587 |
+
appendToTerminal(`>> ${input}`, "term-input");
|
| 588 |
+
|
| 589 |
+
try {
|
| 590 |
+
// Send input to backend
|
| 591 |
+
const response = await fetch('/api/code/input', {
|
| 592 |
+
method: 'POST',
|
| 593 |
+
headers: { 'Content-Type': 'application/json' },
|
| 594 |
+
body: JSON.stringify({
|
| 595 |
+
input: input,
|
| 596 |
+
context_id: executionContext
|
| 597 |
+
})
|
| 598 |
+
});
|
| 599 |
+
|
| 600 |
+
const data = await response.json();
|
| 601 |
+
|
| 602 |
+
if (data.success) {
|
| 603 |
+
// Show new output
|
| 604 |
+
if (data.output && data.output.trim()) {
|
| 605 |
+
appendToTerminal(data.output, "term-output");
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
if (data.needsInput) {
|
| 609 |
+
// Still waiting for more input
|
| 610 |
+
consoleInput.focus();
|
| 611 |
+
} else {
|
| 612 |
+
// Execution completed
|
| 613 |
+
appendToTerminal(">>> 程序执行完成", "term-system");
|
| 614 |
+
finishExecution();
|
| 615 |
+
}
|
| 616 |
+
} else {
|
| 617 |
+
// Handle error
|
| 618 |
+
appendToTerminal(`错误: ${data.error}`, "term-error");
|
| 619 |
+
if (data.traceback) {
|
| 620 |
+
appendToTerminal(data.traceback, "term-error");
|
| 621 |
+
}
|
| 622 |
+
finishExecution();
|
| 623 |
+
}
|
| 624 |
+
} catch (error) {
|
| 625 |
+
appendToTerminal(`系统错误: ${error.message}`, "term-error");
|
| 626 |
+
finishExecution();
|
| 627 |
+
}
|
| 628 |
+
}
|
| 629 |
+
});
|
| 630 |
+
|
| 631 |
+
// Stop code execution
|
| 632 |
+
stopCodeBtn.addEventListener('click', async () => {
|
| 633 |
+
if (!executionContext) return;
|
| 634 |
+
|
| 635 |
+
try {
|
| 636 |
+
// Send stop request to server
|
| 637 |
+
const response = await fetch('/api/code/stop', {
|
| 638 |
+
method: 'POST',
|
| 639 |
+
headers: { 'Content-Type': 'application/json' },
|
| 640 |
+
body: JSON.stringify({ context_id: executionContext })
|
| 641 |
+
});
|
| 642 |
+
|
| 643 |
+
// Clean up UI regardless of response
|
| 644 |
+
appendToTerminal("用户终止了执行", "term-warning");
|
| 645 |
+
finishExecution();
|
| 646 |
+
} catch (error) {
|
| 647 |
+
console.error('停止执行时出错:', error);
|
| 648 |
+
finishExecution();
|
| 649 |
+
}
|
| 650 |
+
});
|
| 651 |
+
|
| 652 |
+
// Clear code
|
| 653 |
+
clearButton.addEventListener('click', () => {
|
| 654 |
+
codeArea.textContent = '# 您的代码将在这里显示';
|
| 655 |
+
updateLineNumbers();
|
| 656 |
+
clearTerminalOutput();
|
| 657 |
+
initializeTerminal();
|
| 658 |
+
consoleInputContainer.style.display = 'none';
|
| 659 |
+
executionContext = null;
|
| 660 |
+
isExecuting = false;
|
| 661 |
+
runButton.style.display = 'flex';
|
| 662 |
+
stopCodeBtn.style.display = 'none';
|
| 663 |
+
});
|
| 664 |
+
|
| 665 |
+
// Clear terminal
|
| 666 |
+
clearTerminalBtn.addEventListener('click', () => {
|
| 667 |
+
if (!isExecuting) {
|
| 668 |
+
clearTerminalOutput();
|
| 669 |
+
initializeTerminal();
|
| 670 |
+
} else {
|
| 671 |
+
// If execution is in progress, just add a separator
|
| 672 |
+
appendToTerminal("\n--- 已清除终端 ---\n", "term-system");
|
| 673 |
+
}
|
| 674 |
+
});
|
| 675 |
+
|
| 676 |
+
// Update line numbers on code changes
|
| 677 |
+
codeArea.addEventListener('input', updateLineNumbers);
|
| 678 |
+
|
| 679 |
+
// Handle tab key
|
| 680 |
+
codeArea.addEventListener('keydown', (e) => {
|
| 681 |
+
if (e.key === 'Tab') {
|
| 682 |
+
e.preventDefault();
|
| 683 |
+
document.execCommand('insertText', false, ' ');
|
| 684 |
+
}
|
| 685 |
+
});
|
| 686 |
+
|
| 687 |
+
// Function to clean up after execution completes
|
| 688 |
+
function finishExecution() {
|
| 689 |
+
consoleInputContainer.style.display = 'none';
|
| 690 |
+
executionContext = null;
|
| 691 |
+
isExecuting = false;
|
| 692 |
+
runButton.style.display = 'flex';
|
| 693 |
+
stopCodeBtn.style.display = 'none';
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
// Check if code was provided via URL parameters
|
| 697 |
+
const urlParams = new URLSearchParams(window.location.search);
|
| 698 |
+
const initialCode = urlParams.get('code');
|
| 699 |
+
if (initialCode) {
|
| 700 |
+
try {
|
| 701 |
+
codeArea.textContent = decodeURIComponent(initialCode);
|
| 702 |
+
updateLineNumbers();
|
| 703 |
+
} catch (e) {
|
| 704 |
+
console.error('Failed to decode initial code:', e);
|
| 705 |
+
}
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
// Check for messages from parent frame
|
| 709 |
+
window.addEventListener('message', (event) => {
|
| 710 |
+
if (event.data && event.data.type === 'setCode') {
|
| 711 |
+
codeArea.textContent = event.data.code;
|
| 712 |
+
updateLineNumbers();
|
| 713 |
+
}
|
| 714 |
+
});
|
| 715 |
+
});
|
| 716 |
+
</script>
|
| 717 |
+
</body>
|
| 718 |
+
</html>
|
_archive/templates/index.html
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
_archive/templates/login.html
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>教育AI助手平台 - 登录</title>
|
| 7 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css">
|
| 8 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
|
| 9 |
+
<style>
|
| 10 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
| 11 |
+
|
| 12 |
+
:root {
|
| 13 |
+
/* 优雅的配色方案 */
|
| 14 |
+
--primary-color: #0f2d49;
|
| 15 |
+
--primary-light: #234a70;
|
| 16 |
+
--secondary-color: #4a6cfd;
|
| 17 |
+
--secondary-light: #7b91ff;
|
| 18 |
+
--tertiary-color: #f7f9fe;
|
| 19 |
+
--success-color: #10b981;
|
| 20 |
+
--success-light: rgba(16, 185, 129, 0.1);
|
| 21 |
+
--warning-color: #f59e0b;
|
| 22 |
+
--warning-light: rgba(245, 158, 11, 0.1);
|
| 23 |
+
--info-color: #0ea5e9;
|
| 24 |
+
--info-light: rgba(14, 165, 233, 0.1);
|
| 25 |
+
--danger-color: #ef4444;
|
| 26 |
+
--danger-light: rgba(239, 68, 68, 0.1);
|
| 27 |
+
--neutral-50: #f9fafb;
|
| 28 |
+
--neutral-100: #f3f4f6;
|
| 29 |
+
--neutral-200: #e5e7eb;
|
| 30 |
+
--neutral-300: #d1d5db;
|
| 31 |
+
--neutral-400: #9ca3af;
|
| 32 |
+
--neutral-500: #6b7280;
|
| 33 |
+
--neutral-600: #4b5563;
|
| 34 |
+
--neutral-700: #374151;
|
| 35 |
+
--neutral-800: #1f2937;
|
| 36 |
+
--neutral-900: #111827;
|
| 37 |
+
|
| 38 |
+
/* 样式变量 */
|
| 39 |
+
--border-radius-sm: 0.25rem;
|
| 40 |
+
--border-radius: 0.375rem;
|
| 41 |
+
--border-radius-lg: 0.5rem;
|
| 42 |
+
--border-radius-xl: 0.75rem;
|
| 43 |
+
--border-radius-2xl: 1rem;
|
| 44 |
+
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.1);
|
| 45 |
+
--card-shadow-hover: 0 10px 20px rgba(0, 0, 0, 0.05), 0 6px 6px rgba(0, 0, 0, 0.1);
|
| 46 |
+
--card-shadow-lg: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
| 47 |
+
--transition-base: all 0.2s ease-in-out;
|
| 48 |
+
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 49 |
+
--font-family: 'Inter', 'PingFang SC', 'Helvetica Neue', 'Microsoft YaHei', sans-serif;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/* 基础样式 */
|
| 53 |
+
body {
|
| 54 |
+
font-family: var(--font-family);
|
| 55 |
+
background-color: var(--neutral-50);
|
| 56 |
+
color: var(--neutral-800);
|
| 57 |
+
margin: 0;
|
| 58 |
+
padding: 0;
|
| 59 |
+
min-height: 100vh;
|
| 60 |
+
display: flex;
|
| 61 |
+
align-items: center;
|
| 62 |
+
justify-content: center;
|
| 63 |
+
-webkit-font-smoothing: antialiased;
|
| 64 |
+
-moz-osx-font-smoothing: grayscale;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
h1, h2, h3, h4, h5, h6 {
|
| 68 |
+
font-weight: 600;
|
| 69 |
+
color: var(--neutral-900);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.text-gradient {
|
| 73 |
+
background: linear-gradient(135deg, var(--secondary-color), var(--secondary-light));
|
| 74 |
+
-webkit-background-clip: text;
|
| 75 |
+
-webkit-text-fill-color: transparent;
|
| 76 |
+
background-clip: text;
|
| 77 |
+
color: transparent;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/* 登录容器样式 */
|
| 81 |
+
.login-container {
|
| 82 |
+
width: 100%;
|
| 83 |
+
max-width: 450px;
|
| 84 |
+
padding: 2.5rem;
|
| 85 |
+
background-color: white;
|
| 86 |
+
border-radius: var(--border-radius-xl);
|
| 87 |
+
box-shadow: var(--card-shadow-lg);
|
| 88 |
+
transition: var(--transition-smooth);
|
| 89 |
+
position: relative;
|
| 90 |
+
overflow: hidden;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.login-container::before {
|
| 94 |
+
content: '';
|
| 95 |
+
position: absolute;
|
| 96 |
+
top: 0;
|
| 97 |
+
left: 0;
|
| 98 |
+
width: 100%;
|
| 99 |
+
height: 4px;
|
| 100 |
+
background: linear-gradient(to right, var(--secondary-color), var(--secondary-light));
|
| 101 |
+
border-radius: 4px 4px 0 0;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.login-header {
|
| 105 |
+
text-align: center;
|
| 106 |
+
margin-bottom: 2.5rem;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.login-header h1 {
|
| 110 |
+
font-size: 1.75rem;
|
| 111 |
+
font-weight: 700;
|
| 112 |
+
margin-bottom: 0.5rem;
|
| 113 |
+
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
|
| 114 |
+
-webkit-background-clip: text;
|
| 115 |
+
-webkit-text-fill-color: transparent;
|
| 116 |
+
letter-spacing: -0.01em;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.login-header p {
|
| 120 |
+
color: var(--neutral-600);
|
| 121 |
+
margin-bottom: 0;
|
| 122 |
+
font-size: 0.95rem;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.user-type-switch {
|
| 126 |
+
margin-bottom: 2rem;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.user-type-switch .btn-group {
|
| 130 |
+
width: 100%;
|
| 131 |
+
box-shadow: var(--card-shadow);
|
| 132 |
+
border-radius: var(--border-radius-lg);
|
| 133 |
+
padding: 0.25rem;
|
| 134 |
+
background-color: var(--neutral-100);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.user-type-switch .btn {
|
| 138 |
+
flex: 1;
|
| 139 |
+
background: transparent;
|
| 140 |
+
border: none;
|
| 141 |
+
padding: 0.75rem 1rem;
|
| 142 |
+
font-weight: 500;
|
| 143 |
+
color: var(--neutral-700);
|
| 144 |
+
border-radius: var(--border-radius);
|
| 145 |
+
transition: var(--transition-base);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.user-type-switch .btn:hover {
|
| 149 |
+
color: var(--primary-color);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.user-type-switch .btn.active {
|
| 153 |
+
background: white;
|
| 154 |
+
color: var(--secondary-color);
|
| 155 |
+
box-shadow: var(--card-shadow);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/* 表单样式 */
|
| 159 |
+
.form-floating {
|
| 160 |
+
margin-bottom: 1.25rem;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.form-floating > .form-control {
|
| 164 |
+
padding: 1rem 1rem;
|
| 165 |
+
height: calc(3.5rem + 2px);
|
| 166 |
+
border-radius: var(--border-radius-lg);
|
| 167 |
+
border: 1px solid var(--neutral-200);
|
| 168 |
+
font-size: 0.95rem;
|
| 169 |
+
transition: var(--transition-base);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.form-floating > .form-control:focus {
|
| 173 |
+
border-color: var(--secondary-color);
|
| 174 |
+
box-shadow: 0 0 0 3px rgba(74, 108, 253, 0.1);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.form-floating > label {
|
| 178 |
+
padding: 1rem;
|
| 179 |
+
color: var(--neutral-500);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.form-check {
|
| 183 |
+
display: flex;
|
| 184 |
+
align-items: center;
|
| 185 |
+
margin-bottom: 1.5rem;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.form-check-input {
|
| 189 |
+
width: 1.25em;
|
| 190 |
+
height: 1.25em;
|
| 191 |
+
margin-right: 0.75rem;
|
| 192 |
+
background-color: white;
|
| 193 |
+
border: 1px solid var(--neutral-300);
|
| 194 |
+
border-radius: 0.25em;
|
| 195 |
+
transition: all 0.15s ease-in-out;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.form-check-input:checked {
|
| 199 |
+
background-color: var(--secondary-color);
|
| 200 |
+
border-color: var(--secondary-color);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.form-check-label {
|
| 204 |
+
color: var(--neutral-700);
|
| 205 |
+
font-size: 0.95rem;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
/* 按钮样式 */
|
| 209 |
+
.btn-primary {
|
| 210 |
+
background: linear-gradient(to right, var(--secondary-color), var(--secondary-light));
|
| 211 |
+
border: none;
|
| 212 |
+
width: 100%;
|
| 213 |
+
padding: 0.85rem;
|
| 214 |
+
font-weight: 500;
|
| 215 |
+
border-radius: var(--border-radius-lg);
|
| 216 |
+
transition: var(--transition-base);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.btn-primary:hover, .btn-primary:focus {
|
| 220 |
+
background: linear-gradient(to right, var(--secondary-light), var(--secondary-color));
|
| 221 |
+
box-shadow: 0 4px 10px rgba(74, 108, 253, 0.3);
|
| 222 |
+
transform: translateY(-1px);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.btn-outline-primary {
|
| 226 |
+
color: var(--secondary-color);
|
| 227 |
+
border-color: var(--secondary-color);
|
| 228 |
+
background-color: transparent;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.btn-outline-primary:hover, .btn-outline-primary:focus {
|
| 232 |
+
background-color: var(--secondary-color);
|
| 233 |
+
border-color: var(--secondary-color);
|
| 234 |
+
color: white;
|
| 235 |
+
box-shadow: 0 4px 10px rgba(74, 108, 253, 0.2);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
/* 学生说明部分 */
|
| 239 |
+
.student-instructions {
|
| 240 |
+
margin-top: 1.5rem;
|
| 241 |
+
padding: 1.25rem;
|
| 242 |
+
background-color: var(--neutral-50);
|
| 243 |
+
border-radius: var(--border-radius-lg);
|
| 244 |
+
border: 1px solid var(--neutral-200);
|
| 245 |
+
font-size: 0.95rem;
|
| 246 |
+
color: var(--neutral-700);
|
| 247 |
+
transition: var(--transition-smooth);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
.student-instructions p {
|
| 251 |
+
margin-bottom: 0.75rem;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.student-instructions p:last-child {
|
| 255 |
+
margin-bottom: 0;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.student-instructions strong {
|
| 259 |
+
color: var(--neutral-900);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
/* 页脚样式 */
|
| 263 |
+
.login-footer {
|
| 264 |
+
text-align: center;
|
| 265 |
+
margin-top: 2rem;
|
| 266 |
+
color: var(--neutral-500);
|
| 267 |
+
font-size: 0.85rem;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
/* 动画效果 */
|
| 271 |
+
@keyframes fadeIn {
|
| 272 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 273 |
+
to { opacity: 1; transform: translateY(0); }
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.fade-in {
|
| 277 |
+
opacity: 0;
|
| 278 |
+
animation: fadeIn 0.4s ease-out forwards;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
/* 输入组样式 */
|
| 282 |
+
.input-group {
|
| 283 |
+
position: relative;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.input-group .form-control {
|
| 287 |
+
padding-right: 3.5rem;
|
| 288 |
+
border-radius: var(--border-radius-lg);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
.input-group .btn {
|
| 292 |
+
position: absolute;
|
| 293 |
+
right: 0;
|
| 294 |
+
top: 0;
|
| 295 |
+
bottom: 0;
|
| 296 |
+
z-index: 5;
|
| 297 |
+
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
|
| 298 |
+
color: var(--neutral-700);
|
| 299 |
+
background-color: var(--neutral-100);
|
| 300 |
+
border: 1px solid var(--neutral-200);
|
| 301 |
+
border-left: none;
|
| 302 |
+
transition: var(--transition-base);
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.input-group .btn:hover, .input-group .btn:focus {
|
| 306 |
+
background-color: var(--neutral-200);
|
| 307 |
+
}
|
| 308 |
+
</style>
|
| 309 |
+
</head>
|
| 310 |
+
<body>
|
| 311 |
+
<div class="login-container fade-in">
|
| 312 |
+
<div class="login-header">
|
| 313 |
+
<h1>AI助教开发平台</h1>
|
| 314 |
+
<p id="login-subtitle">教师端登录</p>
|
| 315 |
+
</div>
|
| 316 |
+
|
| 317 |
+
<div class="user-type-switch">
|
| 318 |
+
<div class="btn-group" role="group" id="user-type-buttons">
|
| 319 |
+
<button type="button" class="btn active" id="teacher-button">教师</button>
|
| 320 |
+
<button type="button" class="btn" id="student-button">学生</button>
|
| 321 |
+
</div>
|
| 322 |
+
</div>
|
| 323 |
+
|
| 324 |
+
<form id="login-form">
|
| 325 |
+
<div class="form-floating">
|
| 326 |
+
<input type="text" class="form-control" id="username" placeholder="用户名" required>
|
| 327 |
+
<label for="username">用户名</label>
|
| 328 |
+
</div>
|
| 329 |
+
|
| 330 |
+
<div class="form-floating">
|
| 331 |
+
<input type="password" class="form-control" id="password" placeholder="密码" required>
|
| 332 |
+
<label for="password">密码</label>
|
| 333 |
+
</div>
|
| 334 |
+
|
| 335 |
+
<div class="form-check">
|
| 336 |
+
<input class="form-check-input" type="checkbox" id="remember-me">
|
| 337 |
+
<label class="form-check-label" for="remember-me">
|
| 338 |
+
记住我
|
| 339 |
+
</label>
|
| 340 |
+
</div>
|
| 341 |
+
|
| 342 |
+
<div id="student-instructions" class="student-instructions" style="display: none;">
|
| 343 |
+
<p><strong>学生登录说明:</strong></p>
|
| 344 |
+
<p>您也可以直接使用教师分享的链接访问AI助手,无需登录。</p>
|
| 345 |
+
<div class="input-group mt-3">
|
| 346 |
+
<input type="text" class="form-control" id="access-token" placeholder="输入访问令牌">
|
| 347 |
+
<button class="btn" type="button" id="access-button">
|
| 348 |
+
<i class="bi bi-arrow-right"></i>
|
| 349 |
+
</button>
|
| 350 |
+
</div>
|
| 351 |
+
</div>
|
| 352 |
+
|
| 353 |
+
<button type="submit" class="btn btn-primary mt-3">
|
| 354 |
+
<i class="bi bi-box-arrow-in-right me-2"></i>登录
|
| 355 |
+
</button>
|
| 356 |
+
</form>
|
| 357 |
+
|
| 358 |
+
<div class="login-footer">
|
| 359 |
+
© 2025 教育AI助手开发平台 版权所有
|
| 360 |
+
</div>
|
| 361 |
+
</div>
|
| 362 |
+
|
| 363 |
+
<script>
|
| 364 |
+
// 硬编码用户
|
| 365 |
+
const users = {
|
| 366 |
+
teachers: [
|
| 367 |
+
{ username: 'teacher', password: '123456', name: '李志刚' },
|
| 368 |
+
{ username: 'admin', password: 'admin123', name: '管理员' }
|
| 369 |
+
],
|
| 370 |
+
students: [
|
| 371 |
+
{ username: 'student1', password: '123456', name: '张三' },
|
| 372 |
+
{ username: 'student2', password: '123456', name: '李四' }
|
| 373 |
+
]
|
| 374 |
+
};
|
| 375 |
+
|
| 376 |
+
// DOM元素
|
| 377 |
+
const teacherButton = document.getElementById('teacher-button');
|
| 378 |
+
const studentButton = document.getElementById('student-button');
|
| 379 |
+
const loginSubtitle = document.getElementById('login-subtitle');
|
| 380 |
+
const studentInstructions = document.getElementById('student-instructions');
|
| 381 |
+
const loginForm = document.getElementById('login-form');
|
| 382 |
+
const accessButton = document.getElementById('access-button');
|
| 383 |
+
|
| 384 |
+
// 切换用户类型
|
| 385 |
+
teacherButton.addEventListener('click', function() {
|
| 386 |
+
teacherButton.classList.add('active');
|
| 387 |
+
studentButton.classList.remove('active');
|
| 388 |
+
loginSubtitle.textContent = '教师端登录';
|
| 389 |
+
studentInstructions.style.display = 'none';
|
| 390 |
+
});
|
| 391 |
+
|
| 392 |
+
studentButton.addEventListener('click', function() {
|
| 393 |
+
studentButton.classList.add('active');
|
| 394 |
+
teacherButton.classList.remove('active');
|
| 395 |
+
loginSubtitle.textContent = '学生端登录';
|
| 396 |
+
studentInstructions.style.display = 'block';
|
| 397 |
+
});
|
| 398 |
+
|
| 399 |
+
// 处理登录表单提交
|
| 400 |
+
// 处理登录表单提交
|
| 401 |
+
loginForm.addEventListener('submit', async function(e) {
|
| 402 |
+
e.preventDefault();
|
| 403 |
+
|
| 404 |
+
const username = document.getElementById('username').value;
|
| 405 |
+
const password = document.getElementById('password').value;
|
| 406 |
+
const isTeacher = teacherButton.classList.contains('active');
|
| 407 |
+
|
| 408 |
+
try {
|
| 409 |
+
const response = await fetch('/api/auth/login', {
|
| 410 |
+
method: 'POST',
|
| 411 |
+
headers: {
|
| 412 |
+
'Content-Type': 'application/json'
|
| 413 |
+
},
|
| 414 |
+
body: JSON.stringify({
|
| 415 |
+
username: username,
|
| 416 |
+
password: password,
|
| 417 |
+
type: isTeacher ? 'teacher' : 'student'
|
| 418 |
+
})
|
| 419 |
+
});
|
| 420 |
+
|
| 421 |
+
const data = await response.json();
|
| 422 |
+
|
| 423 |
+
if (data.success) {
|
| 424 |
+
// 登录成功,重定向到相应页面
|
| 425 |
+
window.location.href = isTeacher ? '/index.html' : '/student_portal.html';
|
| 426 |
+
} else {
|
| 427 |
+
showAlert('用户名或密码错误', 'danger');
|
| 428 |
+
}
|
| 429 |
+
} catch (error) {
|
| 430 |
+
console.error('登录失败:', error);
|
| 431 |
+
showAlert('登录请求失败,请重试', 'danger');
|
| 432 |
+
}
|
| 433 |
+
});
|
| 434 |
+
// 处理访问令牌
|
| 435 |
+
accessButton.addEventListener('click', function() {
|
| 436 |
+
const token = document.getElementById('access-token').value.trim();
|
| 437 |
+
|
| 438 |
+
if (token) {
|
| 439 |
+
// 简单验证令牌格式
|
| 440 |
+
if (token.length >= 32) {
|
| 441 |
+
// 这里应该向服务器验证令牌有效性,这里简化处理
|
| 442 |
+
// 直接从URL中提取agent_id (假设格式为: [agent_id]?token=[token])
|
| 443 |
+
if (token.includes('?token=')) {
|
| 444 |
+
window.location.href = '/student/' + token;
|
| 445 |
+
} else {
|
| 446 |
+
// 假设这是一个纯token,需要输入Agent ID
|
| 447 |
+
const agentId = prompt('请输入Agent ID');
|
| 448 |
+
if (agentId) {
|
| 449 |
+
window.location.href = `/student/${agentId}?token=${token}`;
|
| 450 |
+
}
|
| 451 |
+
}
|
| 452 |
+
} else {
|
| 453 |
+
showAlert('无效的访问令牌格式', 'warning');
|
| 454 |
+
}
|
| 455 |
+
} else {
|
| 456 |
+
showAlert('请输入访问令牌', 'warning');
|
| 457 |
+
}
|
| 458 |
+
});
|
| 459 |
+
|
| 460 |
+
// 显示提示信息
|
| 461 |
+
function showAlert(message, type) {
|
| 462 |
+
// 移除现有的提示
|
| 463 |
+
const existingAlert = document.querySelector('.alert');
|
| 464 |
+
if (existingAlert) {
|
| 465 |
+
existingAlert.remove();
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
// 创建新提示
|
| 469 |
+
const alert = document.createElement('div');
|
| 470 |
+
alert.className = `alert alert-${type} fade-in`;
|
| 471 |
+
alert.role = 'alert';
|
| 472 |
+
alert.style.marginBottom = '1.5rem';
|
| 473 |
+
alert.innerHTML = `
|
| 474 |
+
<i class="bi ${type === 'danger' ? 'bi-exclamation-triangle' : 'bi-info-circle'} me-2"></i>
|
| 475 |
+
${message}
|
| 476 |
+
`;
|
| 477 |
+
|
| 478 |
+
// 添加到表单上方
|
| 479 |
+
loginForm.insertAdjacentElement('beforebegin', alert);
|
| 480 |
+
|
| 481 |
+
// 3秒后自动移除
|
| 482 |
+
setTimeout(() => {
|
| 483 |
+
alert.style.opacity = '0';
|
| 484 |
+
setTimeout(() => alert.remove(), 300);
|
| 485 |
+
}, 3000);
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
// 页面加载时检查是否已经登录
|
| 489 |
+
// window.addEventListener('DOMContentLoaded', function() {
|
| 490 |
+
// const currentUser = localStorage.getItem('currentUser');
|
| 491 |
+
|
| 492 |
+
// if (currentUser) {
|
| 493 |
+
// const userData = JSON.parse(currentUser);
|
| 494 |
+
|
| 495 |
+
// if (userData.type === 'teacher') {
|
| 496 |
+
// window.location.href = '/index.html';
|
| 497 |
+
// } else if (userData.type === 'student') {
|
| 498 |
+
// window.location.href = '/student_portal.html';
|
| 499 |
+
// }
|
| 500 |
+
// }
|
| 501 |
+
// });
|
| 502 |
+
</script>
|
| 503 |
+
</body>
|
| 504 |
+
</html>
|
_archive/templates/student.html
ADDED
|
@@ -0,0 +1,1651 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>AI学习助手</title>
|
| 7 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css">
|
| 8 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
|
| 9 |
+
<!-- KaTeX 用于渲染数学公式 -->
|
| 10 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.4/dist/katex.min.css">
|
| 11 |
+
<!-- Highlight.js 样式 -->
|
| 12 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github.min.css">
|
| 13 |
+
<style>
|
| 14 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
| 15 |
+
|
| 16 |
+
:root {
|
| 17 |
+
/* 主要配色 */
|
| 18 |
+
--primary-color: #0f2d49;
|
| 19 |
+
--primary-light: #234a70;
|
| 20 |
+
--secondary-color: #4a6cfd;
|
| 21 |
+
--secondary-light: #7b91ff;
|
| 22 |
+
--tertiary-color: #f7f9fe;
|
| 23 |
+
|
| 24 |
+
/* 功能颜色 */
|
| 25 |
+
--success-color: #10b981;
|
| 26 |
+
--success-light: #d1fae5;
|
| 27 |
+
--warning-color: #f59e0b;
|
| 28 |
+
--warning-light: #fef3c7;
|
| 29 |
+
--danger-color: #ef4444;
|
| 30 |
+
--danger-light: #fee2e2;
|
| 31 |
+
--info-color: #3b82f6;
|
| 32 |
+
--info-light: #dbeafe;
|
| 33 |
+
|
| 34 |
+
/* 中性色 */
|
| 35 |
+
--neutral-50: #f9fafb;
|
| 36 |
+
--neutral-100: #f3f4f6;
|
| 37 |
+
--neutral-200: #e5e7eb;
|
| 38 |
+
--neutral-300: #d1d5db;
|
| 39 |
+
--neutral-400: #9ca3af;
|
| 40 |
+
--neutral-500: #6b7280;
|
| 41 |
+
--neutral-600: #4b5563;
|
| 42 |
+
--neutral-700: #374151;
|
| 43 |
+
--neutral-800: #1f2937;
|
| 44 |
+
--neutral-900: #111827;
|
| 45 |
+
|
| 46 |
+
/* 界面元素 */
|
| 47 |
+
--border-radius-sm: 0.25rem;
|
| 48 |
+
--border-radius: 0.375rem;
|
| 49 |
+
--border-radius-lg: 0.5rem;
|
| 50 |
+
--border-radius-xl: 0.75rem;
|
| 51 |
+
--border-radius-xxl: 1rem;
|
| 52 |
+
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
| 53 |
+
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
| 54 |
+
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
| 55 |
+
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
| 56 |
+
--shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06);
|
| 57 |
+
--transition-base: all 0.2s ease-in-out;
|
| 58 |
+
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 59 |
+
--font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
body {
|
| 63 |
+
font-family: var(--font-family);
|
| 64 |
+
margin: 0;
|
| 65 |
+
padding: 0;
|
| 66 |
+
height: 100vh;
|
| 67 |
+
background-color: var(--neutral-50);
|
| 68 |
+
color: var(--neutral-800);
|
| 69 |
+
display: flex;
|
| 70 |
+
flex-direction: column;
|
| 71 |
+
-webkit-font-smoothing: antialiased;
|
| 72 |
+
-moz-osx-font-smoothing: grayscale;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/* 头部样式 */
|
| 76 |
+
.header {
|
| 77 |
+
background: linear-gradient(135deg, var(--primary-color), var(--primary-light));
|
| 78 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
| 79 |
+
color: white;
|
| 80 |
+
padding: 1rem 1.5rem;
|
| 81 |
+
box-shadow: var(--shadow-md);
|
| 82 |
+
position: relative;
|
| 83 |
+
z-index: 10;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.header-content {
|
| 87 |
+
max-width: 1600px;
|
| 88 |
+
margin: 0 auto;
|
| 89 |
+
display: flex;
|
| 90 |
+
justify-content: space-between;
|
| 91 |
+
align-items: center;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.header h1 {
|
| 95 |
+
margin: 0;
|
| 96 |
+
font-size: 1.5rem;
|
| 97 |
+
font-weight: 600;
|
| 98 |
+
color: white;
|
| 99 |
+
display: flex;
|
| 100 |
+
align-items: center;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.header h1 i {
|
| 104 |
+
margin-right: 0.75rem;
|
| 105 |
+
color: rgba(255, 255, 255, 0.8);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.header .badge {
|
| 109 |
+
background-color: rgba(255, 255, 255, 0.15);
|
| 110 |
+
color: white;
|
| 111 |
+
font-weight: 500;
|
| 112 |
+
padding: 0.35em 0.75em;
|
| 113 |
+
font-size: 0.85rem;
|
| 114 |
+
border-radius: 9999px;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/* 主容器样式 */
|
| 118 |
+
.main-container {
|
| 119 |
+
flex: 1;
|
| 120 |
+
display: flex;
|
| 121 |
+
max-width: 1600px;
|
| 122 |
+
margin: 0 auto;
|
| 123 |
+
padding: 1.5rem;
|
| 124 |
+
width: 100%;
|
| 125 |
+
box-sizing: border-box;
|
| 126 |
+
height: calc(100vh - 70px); /* Header height + padding */
|
| 127 |
+
overflow: hidden;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.chat-container {
|
| 131 |
+
flex: 1;
|
| 132 |
+
display: flex;
|
| 133 |
+
flex-direction: column;
|
| 134 |
+
background-color: white;
|
| 135 |
+
border-radius: var(--border-radius-xl);
|
| 136 |
+
box-shadow: var(--shadow);
|
| 137 |
+
overflow: hidden;
|
| 138 |
+
height: 100%;
|
| 139 |
+
max-width: 800px;
|
| 140 |
+
margin: 0 auto;
|
| 141 |
+
transition: var(--transition-smooth);
|
| 142 |
+
position: relative;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
/* 聊天对话区域样式 */
|
| 146 |
+
.chat-messages {
|
| 147 |
+
flex: 1;
|
| 148 |
+
overflow-y: auto;
|
| 149 |
+
padding: 1.5rem;
|
| 150 |
+
display: flex;
|
| 151 |
+
flex-direction: column;
|
| 152 |
+
background-color: var(--neutral-50);
|
| 153 |
+
gap: 1rem;
|
| 154 |
+
scroll-behavior: smooth;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/* 自定义滚动条 */
|
| 158 |
+
.chat-messages::-webkit-scrollbar {
|
| 159 |
+
width: 6px;
|
| 160 |
+
height: 6px;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.chat-messages::-webkit-scrollbar-track {
|
| 164 |
+
background: var(--neutral-100);
|
| 165 |
+
border-radius: 10px;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.chat-messages::-webkit-scrollbar-thumb {
|
| 169 |
+
background: var(--neutral-300);
|
| 170 |
+
border-radius: 10px;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.chat-messages::-webkit-scrollbar-thumb:hover {
|
| 174 |
+
background: var(--neutral-400);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/* 消息气泡样式 */
|
| 178 |
+
.message {
|
| 179 |
+
max-width: 85%;
|
| 180 |
+
border-radius: var(--border-radius-lg);
|
| 181 |
+
padding: 1rem;
|
| 182 |
+
position: relative;
|
| 183 |
+
line-height: 1.5;
|
| 184 |
+
box-shadow: var(--shadow-sm);
|
| 185 |
+
overflow-wrap: break-word;
|
| 186 |
+
word-wrap: break-word;
|
| 187 |
+
hyphens: auto;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.message .message-content {
|
| 191 |
+
padding: 0 10px;
|
| 192 |
+
margin: 0;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.user-message {
|
| 196 |
+
background: linear-gradient(135deg, #e9f5ff, #c2e4ff);
|
| 197 |
+
align-self: flex-end;
|
| 198 |
+
color: var(--primary-color);
|
| 199 |
+
border-bottom-right-radius: 0.2rem;
|
| 200 |
+
border-left: 1px solid rgba(74, 108, 253, 0.1);
|
| 201 |
+
border-top: 1px solid rgba(255, 255, 255, 0.5);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.bot-message {
|
| 205 |
+
background-color: white;
|
| 206 |
+
align-self: flex-start;
|
| 207 |
+
color: var(--neutral-800);
|
| 208 |
+
border-bottom-left-radius: 0.2rem;
|
| 209 |
+
border-left: 3px solid var(--secondary-color);
|
| 210 |
+
box-shadow: var(--shadow);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
/* 输入区域样式 */
|
| 214 |
+
.input-container {
|
| 215 |
+
padding: 1rem 1.5rem;
|
| 216 |
+
border-top: 1px solid var(--neutral-200);
|
| 217 |
+
background-color: white;
|
| 218 |
+
position: relative;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
.input-row {
|
| 222 |
+
display: flex;
|
| 223 |
+
gap: 0.75rem;
|
| 224 |
+
position: relative;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.input-field {
|
| 228 |
+
flex: 1;
|
| 229 |
+
padding: 0.85rem 1rem;
|
| 230 |
+
border: 1px solid var(--neutral-200);
|
| 231 |
+
border-radius: var(--border-radius-lg);
|
| 232 |
+
resize: none;
|
| 233 |
+
font-size: 0.95rem;
|
| 234 |
+
box-shadow: var(--shadow-inner);
|
| 235 |
+
transition: var(--transition-base);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.input-field:focus {
|
| 239 |
+
outline: none;
|
| 240 |
+
border-color: var(--secondary-color);
|
| 241 |
+
box-shadow: 0 0 0 3px rgba(74, 108, 253, 0.15);
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.send-button {
|
| 245 |
+
padding: 0 1.25rem;
|
| 246 |
+
background: linear-gradient(135deg, var(--secondary-color), var(--secondary-light));
|
| 247 |
+
color: white;
|
| 248 |
+
border: none;
|
| 249 |
+
border-radius: var(--border-radius-lg);
|
| 250 |
+
cursor: pointer;
|
| 251 |
+
font-weight: 500;
|
| 252 |
+
transition: var(--transition-base);
|
| 253 |
+
display: flex;
|
| 254 |
+
align-items: center;
|
| 255 |
+
justify-content: center;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.send-button:hover {
|
| 259 |
+
box-shadow: 0 4px 10px rgba(74, 108, 253, 0.25);
|
| 260 |
+
transform: translateY(-1px);
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.send-button:disabled {
|
| 264 |
+
background: var(--neutral-300);
|
| 265 |
+
cursor: not-allowed;
|
| 266 |
+
transform: none;
|
| 267 |
+
box-shadow: none;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
/* 插件容器样式 */
|
| 271 |
+
.plugin-container {
|
| 272 |
+
display: none;
|
| 273 |
+
flex-direction: column;
|
| 274 |
+
background-color: white;
|
| 275 |
+
border-radius: var(--border-radius-xl);
|
| 276 |
+
box-shadow: var(--shadow);
|
| 277 |
+
overflow: hidden;
|
| 278 |
+
height: 100%;
|
| 279 |
+
flex: 2;
|
| 280 |
+
margin-left: 1.5rem;
|
| 281 |
+
transition: var(--transition-smooth);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
.plugin-header {
|
| 285 |
+
padding: 0.85rem 1.25rem;
|
| 286 |
+
border-bottom: 1px solid var(--neutral-200);
|
| 287 |
+
background-color: var(--neutral-50);
|
| 288 |
+
display: flex;
|
| 289 |
+
justify-content: space-between;
|
| 290 |
+
align-items: center;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.plugin-title {
|
| 294 |
+
margin: 0;
|
| 295 |
+
font-size: 1rem;
|
| 296 |
+
font-weight: 600;
|
| 297 |
+
color: var(--primary-color);
|
| 298 |
+
display: flex;
|
| 299 |
+
align-items: center;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
.plugin-title i {
|
| 303 |
+
margin-right: 0.5rem;
|
| 304 |
+
color: var(--secondary-color);
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.plugin-close {
|
| 308 |
+
width: 32px;
|
| 309 |
+
height: 32px;
|
| 310 |
+
background: var(--neutral-100);
|
| 311 |
+
border: none;
|
| 312 |
+
border-radius: 50%;
|
| 313 |
+
cursor: pointer;
|
| 314 |
+
font-size: 0.85rem;
|
| 315 |
+
color: var(--neutral-700);
|
| 316 |
+
display: flex;
|
| 317 |
+
align-items: center;
|
| 318 |
+
justify-content: center;
|
| 319 |
+
transition: var(--transition-base);
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
.plugin-close:hover {
|
| 323 |
+
background-color: var(--neutral-200);
|
| 324 |
+
color: var(--neutral-900);
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
.plugin-content {
|
| 328 |
+
flex: 1;
|
| 329 |
+
overflow-y: auto;
|
| 330 |
+
padding: 0;
|
| 331 |
+
background-color: var(--neutral-50);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
/* 当插件激活时的样式 */
|
| 335 |
+
.main-container.with-plugin .chat-container {
|
| 336 |
+
max-width: 380px;
|
| 337 |
+
margin: 0;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
/* 代码块样式 */
|
| 341 |
+
pre {
|
| 342 |
+
margin: 1rem 0;
|
| 343 |
+
padding: 1rem;
|
| 344 |
+
background-color: var(--neutral-800);
|
| 345 |
+
border-radius: var(--border-radius);
|
| 346 |
+
overflow-x: auto;
|
| 347 |
+
position: relative;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
pre code {
|
| 351 |
+
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
| 352 |
+
font-size: 0.9rem;
|
| 353 |
+
color: #e5e7eb;
|
| 354 |
+
padding: 0;
|
| 355 |
+
background: none;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.copy-button {
|
| 359 |
+
position: absolute;
|
| 360 |
+
top: 0.5rem;
|
| 361 |
+
right: 0.5rem;
|
| 362 |
+
padding: 0.25rem 0.5rem;
|
| 363 |
+
background-color: rgba(255, 255, 255, 0.1);
|
| 364 |
+
color: rgba(255, 255, 255, 0.6);
|
| 365 |
+
border: none;
|
| 366 |
+
border-radius: var(--border-radius-sm);
|
| 367 |
+
font-size: 0.75rem;
|
| 368 |
+
cursor: pointer;
|
| 369 |
+
transition: var(--transition-base);
|
| 370 |
+
display: flex;
|
| 371 |
+
align-items: center;
|
| 372 |
+
gap: 0.25rem;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.copy-button:hover {
|
| 376 |
+
background-color: rgba(255, 255, 255, 0.2);
|
| 377 |
+
color: rgba(255, 255, 255, 0.9);
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
/* 代码执行结果样式 */
|
| 381 |
+
.code-result {
|
| 382 |
+
margin-top: 0.5rem;
|
| 383 |
+
padding: 0.75rem;
|
| 384 |
+
background-color: var(--neutral-900);
|
| 385 |
+
border-radius: var(--border-radius);
|
| 386 |
+
color: white;
|
| 387 |
+
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
| 388 |
+
font-size: 0.85rem;
|
| 389 |
+
white-space: pre-wrap;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
/* 内联代码样式 */
|
| 393 |
+
code:not(pre code) {
|
| 394 |
+
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
| 395 |
+
font-size: 0.9em;
|
| 396 |
+
color: var(--primary-color);
|
| 397 |
+
background-color: var(--neutral-100);
|
| 398 |
+
padding: 0.2em 0.4em;
|
| 399 |
+
border-radius: 3px;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
/* 参考资料样式 */
|
| 403 |
+
.reference-container {
|
| 404 |
+
margin-top: 1rem;
|
| 405 |
+
border-top: 1px dashed var(--neutral-300);
|
| 406 |
+
padding-top: 0.75rem;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
.reference-toggle {
|
| 410 |
+
color: var(--neutral-600);
|
| 411 |
+
font-size: 0.85rem;
|
| 412 |
+
font-weight: 500;
|
| 413 |
+
cursor: pointer;
|
| 414 |
+
display: flex;
|
| 415 |
+
align-items: center;
|
| 416 |
+
transition: var(--transition-base);
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
.reference-toggle:hover {
|
| 420 |
+
color: var(--secondary-color);
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
.reference-toggle i {
|
| 424 |
+
margin-right: 0.35rem;
|
| 425 |
+
font-size: 0.9rem;
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
.reference-content {
|
| 429 |
+
display: none;
|
| 430 |
+
margin-top: 0.75rem;
|
| 431 |
+
padding: 0.75rem;
|
| 432 |
+
background-color: var(--neutral-100);
|
| 433 |
+
border-radius: var(--border-radius);
|
| 434 |
+
font-size: 0.85rem;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
.reference-item {
|
| 438 |
+
margin-bottom: 0.75rem;
|
| 439 |
+
padding-bottom: 0.75rem;
|
| 440 |
+
border-bottom: 1px solid var(--neutral-200);
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.reference-item:last-child {
|
| 444 |
+
margin-bottom: 0;
|
| 445 |
+
padding-bottom: 0;
|
| 446 |
+
border-bottom: none;
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
.reference-item-source {
|
| 450 |
+
color: var(--neutral-600);
|
| 451 |
+
font-size: 0.8rem;
|
| 452 |
+
margin-top: 0.25rem;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
/* 欢迎消息样式 */
|
| 456 |
+
.welcome-message {
|
| 457 |
+
display: flex;
|
| 458 |
+
flex-direction: column;
|
| 459 |
+
align-items: center;
|
| 460 |
+
text-align: center;
|
| 461 |
+
margin-bottom: 1.5rem;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
.welcome-icon {
|
| 465 |
+
width: 64px;
|
| 466 |
+
height: 64px;
|
| 467 |
+
background: linear-gradient(135deg, var(--secondary-color), var(--secondary-light));
|
| 468 |
+
border-radius: 50%;
|
| 469 |
+
display: flex;
|
| 470 |
+
align-items: center;
|
| 471 |
+
justify-content: center;
|
| 472 |
+
margin-bottom: 1rem;
|
| 473 |
+
color: white;
|
| 474 |
+
font-size: 2rem;
|
| 475 |
+
box-shadow: 0 4px 12px rgba(74, 108, 253, 0.25);
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
.welcome-title {
|
| 479 |
+
font-size: 1.5rem;
|
| 480 |
+
font-weight: 600;
|
| 481 |
+
margin-bottom: 0.5rem;
|
| 482 |
+
color: var(--primary-color);
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
.welcome-description {
|
| 486 |
+
color: var(--neutral-700);
|
| 487 |
+
max-width: 400px;
|
| 488 |
+
margin-bottom: 0;
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
/* 思维导图和可视化插件内容样式 */
|
| 492 |
+
.visualization-result, .mindmap-result {
|
| 493 |
+
padding: 1.5rem;
|
| 494 |
+
text-align: center;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
.visualization-image, .mindmap-image {
|
| 498 |
+
max-width: 100%;
|
| 499 |
+
border-radius: var(--border-radius);
|
| 500 |
+
box-shadow: var(--shadow);
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
/* 响应式样式 */
|
| 504 |
+
@media (max-width: 1200px) {
|
| 505 |
+
.main-container.with-plugin .chat-container {
|
| 506 |
+
max-width: 320px;
|
| 507 |
+
}
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
@media (max-width: 992px) {
|
| 511 |
+
.main-container {
|
| 512 |
+
flex-direction: column;
|
| 513 |
+
padding: 1rem;
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
.main-container.with-plugin .chat-container {
|
| 517 |
+
max-width: 100%;
|
| 518 |
+
margin-bottom: 1rem;
|
| 519 |
+
height: 40vh;
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
.plugin-container {
|
| 523 |
+
margin-left: 0;
|
| 524 |
+
height: calc(60vh - 32px);
|
| 525 |
+
}
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
@media (max-width: 768px) {
|
| 529 |
+
.main-container {
|
| 530 |
+
padding: 0.75rem;
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
.message {
|
| 534 |
+
max-width: 95%;
|
| 535 |
+
}
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
/* 增强的卡塔赫和Markdown样式 */
|
| 539 |
+
.katex {
|
| 540 |
+
font-size: 1.1em;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
.message h1, .message h2, .message h3,
|
| 544 |
+
.message h4, .message h5, .message h6 {
|
| 545 |
+
margin-top: 1em;
|
| 546 |
+
margin-bottom: 0.5em;
|
| 547 |
+
line-height: 1.3;
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
.message h1 {
|
| 551 |
+
font-size: 1.6em;
|
| 552 |
+
border-bottom: 1px solid var(--neutral-200);
|
| 553 |
+
padding-bottom: 0.3em;
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
.message h2 {
|
| 557 |
+
font-size: 1.4em;
|
| 558 |
+
border-bottom: 1px solid var(--neutral-200);
|
| 559 |
+
padding-bottom: 0.3em;
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
.message h3 {
|
| 563 |
+
font-size: 1.2em;
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
.message h4 {
|
| 567 |
+
font-size: 1.1em;
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
.message h5, .message h6 {
|
| 571 |
+
font-size: 1em;
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
.message p {
|
| 575 |
+
margin: 0.5em 0;
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
.message ul, .message ol {
|
| 579 |
+
margin: 0.5em 0;
|
| 580 |
+
padding-left: 1.5em;
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
.message li {
|
| 584 |
+
margin: 0.25em 0;
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
.message blockquote {
|
| 588 |
+
margin: 0.5em 0;
|
| 589 |
+
padding-left: 1em;
|
| 590 |
+
border-left: 4px solid var(--neutral-300);
|
| 591 |
+
color: var(--neutral-700);
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
.message img {
|
| 595 |
+
max-width: 100%;
|
| 596 |
+
border-radius: var(--border-radius);
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
.message table {
|
| 600 |
+
border-collapse: collapse;
|
| 601 |
+
margin: 1em 0;
|
| 602 |
+
width: 100%;
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
.message table th,
|
| 606 |
+
.message table td {
|
| 607 |
+
border: 1px solid var(--neutral-300);
|
| 608 |
+
padding: 0.5em;
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
.message table th {
|
| 612 |
+
background-color: var(--neutral-100);
|
| 613 |
+
font-weight: 600;
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
.message table tr:nth-child(even) {
|
| 617 |
+
background-color: var(--neutral-50);
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
/* 动画效果 */
|
| 621 |
+
@keyframes fadeIn {
|
| 622 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 623 |
+
to { opacity: 1; transform: translateY(0); }
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
.message {
|
| 627 |
+
animation: fadeIn 0.3s ease forwards;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
/* 输入提示区域 */
|
| 631 |
+
.input-suggestions {
|
| 632 |
+
position: absolute;
|
| 633 |
+
bottom: 100%;
|
| 634 |
+
left: 0;
|
| 635 |
+
right: 0;
|
| 636 |
+
background-color: white;
|
| 637 |
+
border-top-left-radius: var(--border-radius-lg);
|
| 638 |
+
border-top-right-radius: var(--border-radius-lg);
|
| 639 |
+
box-shadow: var(--shadow-md);
|
| 640 |
+
padding: 0.75rem;
|
| 641 |
+
display: none;
|
| 642 |
+
border: 1px solid var(--neutral-200);
|
| 643 |
+
border-bottom: none;
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
.suggestion-title {
|
| 647 |
+
font-size: 0.85rem;
|
| 648 |
+
font-weight: 600;
|
| 649 |
+
color: var(--neutral-700);
|
| 650 |
+
margin-bottom: 0.5rem;
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
.suggestion-buttons {
|
| 654 |
+
display: flex;
|
| 655 |
+
flex-wrap: wrap;
|
| 656 |
+
gap: 0.5rem;
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
.suggestion-button {
|
| 660 |
+
padding: 0.5rem 0.75rem;
|
| 661 |
+
background-color: var(--neutral-100);
|
| 662 |
+
border: 1px solid var(--neutral-200);
|
| 663 |
+
border-radius: var(--border-radius);
|
| 664 |
+
font-size: 0.85rem;
|
| 665 |
+
color: var(--neutral-800);
|
| 666 |
+
cursor: pointer;
|
| 667 |
+
transition: var(--transition-base);
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
.suggestion-button:hover {
|
| 671 |
+
background-color: var(--secondary-light);
|
| 672 |
+
color: white;
|
| 673 |
+
border-color: var(--secondary-light);
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
/* 打字机效果 */
|
| 677 |
+
.typing-indicator {
|
| 678 |
+
display: inline-block;
|
| 679 |
+
margin-left: 5px;
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
.typing-dot {
|
| 683 |
+
display: inline-block;
|
| 684 |
+
width: 6px;
|
| 685 |
+
height: 6px;
|
| 686 |
+
border-radius: 50%;
|
| 687 |
+
background-color: var(--neutral-500);
|
| 688 |
+
margin-right: 3px;
|
| 689 |
+
animation: typingDot 1.4s infinite ease-in-out;
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
.typing-dot:nth-child(1) { animation-delay: 0s; }
|
| 693 |
+
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
| 694 |
+
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
| 695 |
+
|
| 696 |
+
@keyframes typingDot {
|
| 697 |
+
0%, 60%, 100% { transform: translateY(0); }
|
| 698 |
+
30% { transform: translateY(-5px); }
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
/* 打字机控制样式 */
|
| 702 |
+
.typewriter-controls {
|
| 703 |
+
display: flex;
|
| 704 |
+
gap: 8px;
|
| 705 |
+
margin-top: 12px;
|
| 706 |
+
align-items: center;
|
| 707 |
+
justify-content: flex-end;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
.typewriter-btn {
|
| 711 |
+
width: 32px;
|
| 712 |
+
height: 32px;
|
| 713 |
+
border-radius: 50%;
|
| 714 |
+
border: none;
|
| 715 |
+
background-color: var(--neutral-100);
|
| 716 |
+
color: var(--neutral-600);
|
| 717 |
+
cursor: pointer;
|
| 718 |
+
display: flex;
|
| 719 |
+
align-items: center;
|
| 720 |
+
justify-content: center;
|
| 721 |
+
transition: var(--transition-base);
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
.typewriter-btn:hover {
|
| 725 |
+
background-color: var(--neutral-200);
|
| 726 |
+
color: var(--neutral-900);
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
.typewriter-btn.pause-btn {
|
| 730 |
+
color: var(--danger-color);
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
.typewriter-btn.continue-btn {
|
| 734 |
+
color: var(--success-color);
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
.typewriter-btn.speed-btn {
|
| 738 |
+
color: var(--warning-color);
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
/* 打字机光标效果 */
|
| 742 |
+
.typing-cursor {
|
| 743 |
+
display: inline-block;
|
| 744 |
+
width: 2px;
|
| 745 |
+
height: 1em;
|
| 746 |
+
background-color: var(--neutral-700);
|
| 747 |
+
margin-left: 2px;
|
| 748 |
+
vertical-align: middle;
|
| 749 |
+
animation: blink 1s infinite;
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
@keyframes blink {
|
| 753 |
+
0%, 100% { opacity: 1; }
|
| 754 |
+
50% { opacity: 0; }
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
/* 确保代码块内文本溢出有滚动条 */
|
| 758 |
+
pre {
|
| 759 |
+
position: relative;
|
| 760 |
+
white-space: pre;
|
| 761 |
+
word-wrap: normal;
|
| 762 |
+
overflow-x: auto;
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
/* 保证消息区内容正常显示 */
|
| 766 |
+
.message-content p {
|
| 767 |
+
word-break: break-word;
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
/* 确保代码复制按钮正确显示 */
|
| 771 |
+
.copy-button {
|
| 772 |
+
opacity: 0.8;
|
| 773 |
+
z-index: 10;
|
| 774 |
+
}
|
| 775 |
+
</style>
|
| 776 |
+
</head>
|
| 777 |
+
<body>
|
| 778 |
+
<header class="header">
|
| 779 |
+
<div class="header-content">
|
| 780 |
+
<h1><i class="bi bi-mortarboard"></i> {{ agent_name }}</h1>
|
| 781 |
+
<div>
|
| 782 |
+
<span class="badge">AI学习助手</span>
|
| 783 |
+
</div>
|
| 784 |
+
</div>
|
| 785 |
+
</header>
|
| 786 |
+
|
| 787 |
+
<div class="main-container" id="main-container">
|
| 788 |
+
<div class="chat-container">
|
| 789 |
+
<div class="chat-messages" id="chat-messages">
|
| 790 |
+
<div class="welcome-message">
|
| 791 |
+
<div class="welcome-icon">
|
| 792 |
+
<i class="bi bi-robot"></i>
|
| 793 |
+
</div>
|
| 794 |
+
<h2 class="welcome-title">欢迎使用 {{ agent_name }}</h2>
|
| 795 |
+
{% if agent_description %}
|
| 796 |
+
<p class="welcome-description">{{ agent_description }}</p>
|
| 797 |
+
{% else %}
|
| 798 |
+
<p class="welcome-description">我是您的AI学习助手,有任何问题都可以随时向我提问</p>
|
| 799 |
+
{% endif %}
|
| 800 |
+
</div>
|
| 801 |
+
|
| 802 |
+
<div class="message bot-message">
|
| 803 |
+
<div class="message-content">
|
| 804 |
+
<p>您好!我是{{ agent_name }},很高兴能够帮助您学习。请问有什么我可以协助您的问题吗?</p>
|
| 805 |
+
</div>
|
| 806 |
+
</div>
|
| 807 |
+
</div>
|
| 808 |
+
|
| 809 |
+
<div class="input-container">
|
| 810 |
+
<div class="input-suggestions" id="input-suggestions">
|
| 811 |
+
<div class="suggestion-title">推荐问题:</div>
|
| 812 |
+
<div class="suggestion-buttons">
|
| 813 |
+
<button class="suggestion-button">介绍一下这门课程的主要内容</button>
|
| 814 |
+
<button class="suggestion-button">这门课程有哪些重点知识?</button>
|
| 815 |
+
<button class="suggestion-button">请给我一些学习建议</button>
|
| 816 |
+
</div>
|
| 817 |
+
</div>
|
| 818 |
+
<div class="input-row">
|
| 819 |
+
<textarea class="input-field" id="user-input" placeholder="输入您的问题..." rows="2"></textarea>
|
| 820 |
+
<button class="send-button" id="send-button">
|
| 821 |
+
<i class="bi bi-send"></i>
|
| 822 |
+
</button>
|
| 823 |
+
</div>
|
| 824 |
+
</div>
|
| 825 |
+
</div>
|
| 826 |
+
|
| 827 |
+
<!-- 代码执行插件 -->
|
| 828 |
+
<div class="plugin-container code-plugin" id="code-plugin">
|
| 829 |
+
<div class="plugin-header">
|
| 830 |
+
<h3 class="plugin-title"><i class="bi bi-code-square"></i> Python代码执行</h3>
|
| 831 |
+
<button class="plugin-close" id="close-code-plugin">
|
| 832 |
+
<i class="bi bi-x-lg"></i>
|
| 833 |
+
</button>
|
| 834 |
+
</div>
|
| 835 |
+
<div class="plugin-content">
|
| 836 |
+
<iframe id="code-execution-frame" src="" style="width: 100%; height: 100%; border: none;"></iframe>
|
| 837 |
+
</div>
|
| 838 |
+
</div>
|
| 839 |
+
|
| 840 |
+
<!-- 3D可视化插件 -->
|
| 841 |
+
<div class="plugin-container visualization-plugin" id="visualization-plugin">
|
| 842 |
+
<div class="plugin-header">
|
| 843 |
+
<h3 class="plugin-title"><i class="bi bi-graph-up"></i> 3D可视化</h3>
|
| 844 |
+
<button class="plugin-close" id="close-visualization-plugin">
|
| 845 |
+
<i class="bi bi-x-lg"></i>
|
| 846 |
+
</button>
|
| 847 |
+
</div>
|
| 848 |
+
<div class="plugin-content">
|
| 849 |
+
<div class="visualization-result" id="visualization-result">
|
| 850 |
+
<div class="text-center py-4">
|
| 851 |
+
<div class="spinner-border text-primary" role="status">
|
| 852 |
+
<span class="visually-hidden">加载中...</span>
|
| 853 |
+
</div>
|
| 854 |
+
<p class="mt-3">正在准备3D可视化...</p>
|
| 855 |
+
</div>
|
| 856 |
+
</div>
|
| 857 |
+
</div>
|
| 858 |
+
</div>
|
| 859 |
+
|
| 860 |
+
<!-- 思维导图插件 -->
|
| 861 |
+
<div class="plugin-container mindmap-plugin" id="mindmap-plugin">
|
| 862 |
+
<div class="plugin-header">
|
| 863 |
+
<h3 class="plugin-title"><i class="bi bi-diagram-3"></i> 思维导图</h3>
|
| 864 |
+
<button class="plugin-close" id="close-mindmap-plugin">
|
| 865 |
+
<i class="bi bi-x-lg"></i>
|
| 866 |
+
</button>
|
| 867 |
+
</div>
|
| 868 |
+
<div class="plugin-content">
|
| 869 |
+
<div class="mindmap-result" id="mindmap-result">
|
| 870 |
+
<div class="text-center py-4">
|
| 871 |
+
<div class="spinner-border text-primary" role="status">
|
| 872 |
+
<span class="visually-hidden">加载中...</span>
|
| 873 |
+
</div>
|
| 874 |
+
<p class="mt-3">正在生成思维导图...</p>
|
| 875 |
+
</div>
|
| 876 |
+
</div>
|
| 877 |
+
</div>
|
| 878 |
+
</div>
|
| 879 |
+
</div>
|
| 880 |
+
|
| 881 |
+
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.4/dist/katex.min.js"></script>
|
| 882 |
+
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.4/dist/contrib/auto-render.min.js"></script>
|
| 883 |
+
<script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.1/dist/markdown-it.min.js"></script>
|
| 884 |
+
<script>
|
| 885 |
+
// 全局变量
|
| 886 |
+
const agentId = "{{ agent_id }}";
|
| 887 |
+
const token = "{{ token }}";
|
| 888 |
+
let executionContext = null;
|
| 889 |
+
const mainContainer = document.getElementById('main-container');
|
| 890 |
+
let isTyping = false;
|
| 891 |
+
let typewriterInterval = null;
|
| 892 |
+
let typewriterPaused = false;
|
| 893 |
+
let currentMessageQueue = [];
|
| 894 |
+
let typewriterSpeed = 20; // 打字速度(ms)
|
| 895 |
+
let currentMessageElement = null;
|
| 896 |
+
|
| 897 |
+
// DOM元素
|
| 898 |
+
const chatMessages = document.getElementById('chat-messages');
|
| 899 |
+
const userInput = document.getElementById('user-input');
|
| 900 |
+
const sendButton = document.getElementById('send-button');
|
| 901 |
+
|
| 902 |
+
// 代码执行插件元素
|
| 903 |
+
const codePlugin = document.getElementById('code-plugin');
|
| 904 |
+
const closeCodePlugin = document.getElementById('close-code-plugin');
|
| 905 |
+
const codeExecutionFrame = document.getElementById('code-execution-frame');
|
| 906 |
+
|
| 907 |
+
// 3D可视化插件元素
|
| 908 |
+
const visualizationPlugin = document.getElementById('visualization-plugin');
|
| 909 |
+
const closeVisualizationPlugin = document.getElementById('close-visualization-plugin');
|
| 910 |
+
const visualizationResult = document.getElementById('visualization-result');
|
| 911 |
+
|
| 912 |
+
// 思维导图插件元素
|
| 913 |
+
const mindmapPlugin = document.getElementById('mindmap-plugin');
|
| 914 |
+
const closeMindmapPlugin = document.getElementById('close-mindmap-plugin');
|
| 915 |
+
const mindmapResult = document.getElementById('mindmap-result');
|
| 916 |
+
|
| 917 |
+
// 输入建议
|
| 918 |
+
const inputSuggestions = document.getElementById('input-suggestions');
|
| 919 |
+
|
| 920 |
+
// Markdown-it 实例
|
| 921 |
+
const md = window.markdownit({
|
| 922 |
+
html: false,
|
| 923 |
+
linkify: true,
|
| 924 |
+
typographer: true,
|
| 925 |
+
breaks: true,
|
| 926 |
+
highlight: function (str, lang) {
|
| 927 |
+
if (lang && hljs.getLanguage(lang)) {
|
| 928 |
+
try {
|
| 929 |
+
const highlighted = hljs.highlight(str, { language: lang }).value;
|
| 930 |
+
return `<pre><code class="hljs language-${lang}">${highlighted}</code><button class="copy-button" onclick="copyToClipboard(this)"><i class="bi bi-clipboard"></i> 复制</button></pre>`;
|
| 931 |
+
} catch (__) {}
|
| 932 |
+
}
|
| 933 |
+
return `<pre><code class="hljs">${md.utils.escapeHtml(str)}</code><button class="copy-button" onclick="copyToClipboard(this)"><i class="bi bi-clipboard"></i> 复制</button></pre>`;
|
| 934 |
+
}
|
| 935 |
+
});
|
| 936 |
+
|
| 937 |
+
// 页面加载完成后执行
|
| 938 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 939 |
+
// 初始化发送按钮事件
|
| 940 |
+
sendButton.addEventListener('click', sendMessage);
|
| 941 |
+
|
| 942 |
+
// 初始化输入框回车事件
|
| 943 |
+
userInput.addEventListener('keydown', function(e) {
|
| 944 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 945 |
+
e.preventDefault();
|
| 946 |
+
sendMessage();
|
| 947 |
+
}
|
| 948 |
+
});
|
| 949 |
+
|
| 950 |
+
// 初始化问题建议按钮
|
| 951 |
+
document.querySelectorAll('.suggestion-button').forEach(button => {
|
| 952 |
+
button.addEventListener('click', function() {
|
| 953 |
+
userInput.value = this.textContent;
|
| 954 |
+
sendMessage();
|
| 955 |
+
});
|
| 956 |
+
});
|
| 957 |
+
|
| 958 |
+
// 初始化插件关闭事件
|
| 959 |
+
closeCodePlugin.addEventListener('click', () => {
|
| 960 |
+
codePlugin.style.display = 'none';
|
| 961 |
+
// 清空iframe源以停止任何运行中的代码
|
| 962 |
+
codeExecutionFrame.src = '';
|
| 963 |
+
updateMainContainerLayout();
|
| 964 |
+
});
|
| 965 |
+
|
| 966 |
+
closeVisualizationPlugin.addEventListener('click', () => {
|
| 967 |
+
visualizationPlugin.style.display = 'none';
|
| 968 |
+
updateMainContainerLayout();
|
| 969 |
+
});
|
| 970 |
+
|
| 971 |
+
closeMindmapPlugin.addEventListener('click', () => {
|
| 972 |
+
mindmapPlugin.style.display = 'none';
|
| 973 |
+
updateMainContainerLayout();
|
| 974 |
+
});
|
| 975 |
+
|
| 976 |
+
// 焦点事件
|
| 977 |
+
userInput.addEventListener('focus', function() {
|
| 978 |
+
// inputSuggestions.style.display = 'block';
|
| 979 |
+
});
|
| 980 |
+
|
| 981 |
+
userInput.addEventListener('blur', function(e) {
|
| 982 |
+
// 延迟执行,以便点击建议按钮事件先触发
|
| 983 |
+
setTimeout(() => {
|
| 984 |
+
// inputSuggestions.style.display = 'none';
|
| 985 |
+
}, 100);
|
| 986 |
+
});
|
| 987 |
+
});
|
| 988 |
+
|
| 989 |
+
// 更新主容器布局
|
| 990 |
+
function updateMainContainerLayout() {
|
| 991 |
+
// 检查是否有任何插件处于显示状态
|
| 992 |
+
const isAnyPluginVisible =
|
| 993 |
+
codePlugin.style.display === 'flex' ||
|
| 994 |
+
visualizationPlugin.style.display === 'flex' ||
|
| 995 |
+
mindmapPlugin.style.display === 'flex';
|
| 996 |
+
|
| 997 |
+
// 更新主容器类名
|
| 998 |
+
if (isAnyPluginVisible) {
|
| 999 |
+
mainContainer.classList.add('with-plugin');
|
| 1000 |
+
} else {
|
| 1001 |
+
mainContainer.classList.remove('with-plugin');
|
| 1002 |
+
}
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
// 发送消息
|
| 1006 |
+
async function sendMessage() {
|
| 1007 |
+
const message = userInput.value.trim();
|
| 1008 |
+
if (!message || isTyping) return;
|
| 1009 |
+
|
| 1010 |
+
// 添加用户消息
|
| 1011 |
+
addMessage(message, true);
|
| 1012 |
+
|
| 1013 |
+
// 清空输入框
|
| 1014 |
+
userInput.value = '';
|
| 1015 |
+
|
| 1016 |
+
// 禁用发送按钮
|
| 1017 |
+
sendButton.disabled = true;
|
| 1018 |
+
isTyping = true;
|
| 1019 |
+
|
| 1020 |
+
// 添加机器人正在输入的指示
|
| 1021 |
+
const typingIndicator = addTypingIndicator();
|
| 1022 |
+
|
| 1023 |
+
try {
|
| 1024 |
+
// 发送请求
|
| 1025 |
+
const response = await fetch(`/api/student/chat/${agentId}`, {
|
| 1026 |
+
method: 'POST',
|
| 1027 |
+
headers: {
|
| 1028 |
+
'Content-Type': 'application/json'
|
| 1029 |
+
},
|
| 1030 |
+
body: JSON.stringify({
|
| 1031 |
+
message: message,
|
| 1032 |
+
token: token
|
| 1033 |
+
})
|
| 1034 |
+
});
|
| 1035 |
+
|
| 1036 |
+
const data = await response.json();
|
| 1037 |
+
|
| 1038 |
+
// 移除输入指示器
|
| 1039 |
+
if (typingIndicator) {
|
| 1040 |
+
typingIndicator.remove();
|
| 1041 |
+
}
|
| 1042 |
+
|
| 1043 |
+
if (data.success) {
|
| 1044 |
+
// 处理回复内容,移除参考来源部分
|
| 1045 |
+
const processedContent = processResponseContent(data.message);
|
| 1046 |
+
|
| 1047 |
+
// 添加机器人回复(带打字机效果)
|
| 1048 |
+
const messageElement = addMessage(processedContent, false);
|
| 1049 |
+
|
| 1050 |
+
// 添加参考信息(如果有)
|
| 1051 |
+
if (data.references && data.references.length > 0) {
|
| 1052 |
+
// 等待打字机效果完成后添加参考信息
|
| 1053 |
+
const checkTypewriterComplete = setInterval(() => {
|
| 1054 |
+
if (!typewriterInterval) {
|
| 1055 |
+
clearInterval(checkTypewriterComplete);
|
| 1056 |
+
addReferences(messageElement, data.references);
|
| 1057 |
+
}
|
| 1058 |
+
}, 100);
|
| 1059 |
+
}
|
| 1060 |
+
|
| 1061 |
+
// 检查是否需要显示工具
|
| 1062 |
+
if (data.tools && data.tools.length > 0) {
|
| 1063 |
+
// 隐藏所有插件
|
| 1064 |
+
hideAllPlugins();
|
| 1065 |
+
|
| 1066 |
+
// 使用新的插件激活函数
|
| 1067 |
+
activatePlugins(data.message, data.tools);
|
| 1068 |
+
}
|
| 1069 |
+
} else {
|
| 1070 |
+
// 添加错误消息
|
| 1071 |
+
addMessage(`错误: ${data.message}`, false);
|
| 1072 |
+
}
|
| 1073 |
+
} catch (error) {
|
| 1074 |
+
console.error('发送请求出错:', error);
|
| 1075 |
+
// 移除输入指示器
|
| 1076 |
+
if (typingIndicator) {
|
| 1077 |
+
typingIndicator.remove();
|
| 1078 |
+
}
|
| 1079 |
+
addMessage('发送请求时出错,请重试', false);
|
| 1080 |
+
} finally {
|
| 1081 |
+
// 恢复发送按钮
|
| 1082 |
+
sendButton.disabled = false;
|
| 1083 |
+
isTyping = false;
|
| 1084 |
+
}
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
// 添加打字指示器
|
| 1088 |
+
function addTypingIndicator() {
|
| 1089 |
+
const botMessage = document.createElement('div');
|
| 1090 |
+
botMessage.className = 'message bot-message';
|
| 1091 |
+
|
| 1092 |
+
const content = document.createElement('div');
|
| 1093 |
+
content.className = 'message-content';
|
| 1094 |
+
content.innerHTML = `
|
| 1095 |
+
<p>
|
| 1096 |
+
<span class="typing-indicator">
|
| 1097 |
+
<span class="typing-dot"></span>
|
| 1098 |
+
<span class="typing-dot"></span>
|
| 1099 |
+
<span class="typing-dot"></span>
|
| 1100 |
+
</span>
|
| 1101 |
+
</p>
|
| 1102 |
+
`;
|
| 1103 |
+
|
| 1104 |
+
botMessage.appendChild(content);
|
| 1105 |
+
chatMessages.appendChild(botMessage);
|
| 1106 |
+
|
| 1107 |
+
// 滚动到底部
|
| 1108 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 1109 |
+
|
| 1110 |
+
return botMessage;
|
| 1111 |
+
}
|
| 1112 |
+
|
| 1113 |
+
// 处理回复内容,移除参考来源部分
|
| 1114 |
+
function processResponseContent(content) {
|
| 1115 |
+
// 移除 "===参考来源开始===" 到 "===参考来源结束===" 之间的内容
|
| 1116 |
+
return content.replace(/===参考来源开始===[\s\S]*?===参考来源结束===/, '');
|
| 1117 |
+
}
|
| 1118 |
+
|
| 1119 |
+
// 添加参考信息显示函数
|
| 1120 |
+
function addReferences(messageElement, references) {
|
| 1121 |
+
// 创建参考容器
|
| 1122 |
+
const referenceContainer = document.createElement('div');
|
| 1123 |
+
referenceContainer.className = 'reference-container';
|
| 1124 |
+
|
| 1125 |
+
// 创建参考切换按钮
|
| 1126 |
+
const toggleButton = document.createElement('div');
|
| 1127 |
+
toggleButton.className = 'reference-toggle';
|
| 1128 |
+
toggleButton.innerHTML = '<i class="bi bi-journal-text"></i> 参考来源';
|
| 1129 |
+
|
| 1130 |
+
// 创建参考内容
|
| 1131 |
+
const referenceContent = document.createElement('div');
|
| 1132 |
+
referenceContent.className = 'reference-content';
|
| 1133 |
+
|
| 1134 |
+
// 添加参考项
|
| 1135 |
+
references.forEach(ref => {
|
| 1136 |
+
const refItem = document.createElement('div');
|
| 1137 |
+
refItem.className = 'reference-item';
|
| 1138 |
+
|
| 1139 |
+
refItem.innerHTML = `
|
| 1140 |
+
<div><strong>[${ref.index}]</strong> ${ref.summary}</div>
|
| 1141 |
+
<div class="reference-item-source">来源: ${ref.file_name}</div>
|
| 1142 |
+
`;
|
| 1143 |
+
|
| 1144 |
+
referenceContent.appendChild(refItem);
|
| 1145 |
+
});
|
| 1146 |
+
|
| 1147 |
+
// 添加切换功能
|
| 1148 |
+
toggleButton.addEventListener('click', () => {
|
| 1149 |
+
if (referenceContent.style.display === 'block') {
|
| 1150 |
+
referenceContent.style.display = 'none';
|
| 1151 |
+
toggleButton.innerHTML = '<i class="bi bi-journal-text"></i> 参考来源';
|
| 1152 |
+
} else {
|
| 1153 |
+
referenceContent.style.display = 'block';
|
| 1154 |
+
toggleButton.innerHTML = '<i class="bi bi-journal-arrow-up"></i> 收起参考来源';
|
| 1155 |
+
}
|
| 1156 |
+
});
|
| 1157 |
+
|
| 1158 |
+
// 组装并添加到消息
|
| 1159 |
+
referenceContainer.appendChild(toggleButton);
|
| 1160 |
+
referenceContainer.appendChild(referenceContent);
|
| 1161 |
+
messageElement.appendChild(referenceContainer);
|
| 1162 |
+
}
|
| 1163 |
+
|
| 1164 |
+
// 添加消息函数
|
| 1165 |
+
function addMessage(content, isUser) {
|
| 1166 |
+
const messageDiv = document.createElement('div');
|
| 1167 |
+
messageDiv.className = isUser ? 'message user-message' : 'message bot-message';
|
| 1168 |
+
|
| 1169 |
+
const messageContent = document.createElement('div');
|
| 1170 |
+
messageContent.className = 'message-content';
|
| 1171 |
+
|
| 1172 |
+
if (isUser) {
|
| 1173 |
+
// 直接显示用户消息,仅替换换行符
|
| 1174 |
+
messageContent.innerHTML = `<p>${content.replace(/\n/g, '<br>')}</p>`;
|
| 1175 |
+
} else {
|
| 1176 |
+
// 为AI消息添加打字机效果
|
| 1177 |
+
messageContent.innerHTML = '<p></p>';
|
| 1178 |
+
// 添加控制按钮
|
| 1179 |
+
const controlsDiv = document.createElement('div');
|
| 1180 |
+
controlsDiv.className = 'typewriter-controls';
|
| 1181 |
+
controlsDiv.innerHTML = `
|
| 1182 |
+
<button class="typewriter-btn pause-btn" title="暂停"><i class="bi bi-pause-fill"></i></button>
|
| 1183 |
+
<button class="typewriter-btn continue-btn" style="display:none;" title="继续"><i class="bi bi-play-fill"></i></button>
|
| 1184 |
+
<button class="typewriter-btn speed-btn" title="加速"><i class="bi bi-lightning-fill"></i></button>
|
| 1185 |
+
`;
|
| 1186 |
+
messageContent.appendChild(controlsDiv);
|
| 1187 |
+
|
| 1188 |
+
// 添加事件监听器
|
| 1189 |
+
const pauseBtn = controlsDiv.querySelector('.pause-btn');
|
| 1190 |
+
const continueBtn = controlsDiv.querySelector('.continue-btn');
|
| 1191 |
+
const speedBtn = controlsDiv.querySelector('.speed-btn');
|
| 1192 |
+
|
| 1193 |
+
pauseBtn.addEventListener('click', () => {
|
| 1194 |
+
pauseTypewriter();
|
| 1195 |
+
pauseBtn.style.display = 'none';
|
| 1196 |
+
continueBtn.style.display = 'inline-block';
|
| 1197 |
+
});
|
| 1198 |
+
|
| 1199 |
+
continueBtn.addEventListener('click', () => {
|
| 1200 |
+
continueTypewriter();
|
| 1201 |
+
continueBtn.style.display = 'none';
|
| 1202 |
+
pauseBtn.style.display = 'inline-block';
|
| 1203 |
+
});
|
| 1204 |
+
|
| 1205 |
+
speedBtn.addEventListener('click', () => {
|
| 1206 |
+
toggleTypewriterSpeed();
|
| 1207 |
+
});
|
| 1208 |
+
|
| 1209 |
+
// 开始打字机效果
|
| 1210 |
+
startTypewriter(content, messageContent.querySelector('p'));
|
| 1211 |
+
}
|
| 1212 |
+
|
| 1213 |
+
messageDiv.appendChild(messageContent);
|
| 1214 |
+
chatMessages.appendChild(messageDiv);
|
| 1215 |
+
|
| 1216 |
+
// 滚动到底部
|
| 1217 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 1218 |
+
|
| 1219 |
+
return messageDiv;
|
| 1220 |
+
}
|
| 1221 |
+
|
| 1222 |
+
// 打字机效果函数
|
| 1223 |
+
function startTypewriter(text, element) {
|
| 1224 |
+
// 清除之前的打字机效果(如果有)
|
| 1225 |
+
if (typewriterInterval) {
|
| 1226 |
+
clearInterval(typewriterInterval);
|
| 1227 |
+
}
|
| 1228 |
+
|
| 1229 |
+
// 保存当前消息元素引用
|
| 1230 |
+
currentMessageElement = element;
|
| 1231 |
+
|
| 1232 |
+
// 初始化
|
| 1233 |
+
typewriterPaused = false;
|
| 1234 |
+
let mdContent = md.render(text);
|
| 1235 |
+
|
| 1236 |
+
// 将HTML内容转换为文本节点和元素节点的队列
|
| 1237 |
+
parseHTMLToQueue(mdContent);
|
| 1238 |
+
|
| 1239 |
+
// 开始逐字显示
|
| 1240 |
+
typewriterInterval = setInterval(typeNextChar, typewriterSpeed);
|
| 1241 |
+
}
|
| 1242 |
+
|
| 1243 |
+
// 解析HTML到队列
|
| 1244 |
+
function parseHTMLToQueue(html) {
|
| 1245 |
+
// 清空队列
|
| 1246 |
+
currentMessageQueue = [];
|
| 1247 |
+
|
| 1248 |
+
// 创建临时容器
|
| 1249 |
+
const tempDiv = document.createElement('div');
|
| 1250 |
+
tempDiv.innerHTML = html;
|
| 1251 |
+
|
| 1252 |
+
// 递归处理所有子节点
|
| 1253 |
+
processNode(tempDiv);
|
| 1254 |
+
}
|
| 1255 |
+
|
| 1256 |
+
// 递归处理节点
|
| 1257 |
+
function processNode(node) {
|
| 1258 |
+
// 遍历节点的所有子节点
|
| 1259 |
+
for (let i = 0; i < node.childNodes.length; i++) {
|
| 1260 |
+
const child = node.childNodes[i];
|
| 1261 |
+
|
| 1262 |
+
if (child.nodeType === Node.TEXT_NODE) {
|
| 1263 |
+
// 文本节点,将每个字符加入队列
|
| 1264 |
+
for (let j = 0; j < child.textContent.length; j++) {
|
| 1265 |
+
currentMessageQueue.push({
|
| 1266 |
+
type: 'text',
|
| 1267 |
+
content: child.textContent[j]
|
| 1268 |
+
});
|
| 1269 |
+
}
|
| 1270 |
+
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
| 1271 |
+
// 元素节点,将开始标签加入队列
|
| 1272 |
+
currentMessageQueue.push({
|
| 1273 |
+
type: 'element-start',
|
| 1274 |
+
tagName: child.tagName.toLowerCase(),
|
| 1275 |
+
attributes: Array.from(child.attributes)
|
| 1276 |
+
});
|
| 1277 |
+
|
| 1278 |
+
// 递归处理子节点
|
| 1279 |
+
processNode(child);
|
| 1280 |
+
|
| 1281 |
+
// 将结束标签加入队列
|
| 1282 |
+
currentMessageQueue.push({
|
| 1283 |
+
type: 'element-end',
|
| 1284 |
+
tagName: child.tagName.toLowerCase()
|
| 1285 |
+
});
|
| 1286 |
+
}
|
| 1287 |
+
}
|
| 1288 |
+
}
|
| 1289 |
+
|
| 1290 |
+
// 显示下一个字符
|
| 1291 |
+
function typeNextChar() {
|
| 1292 |
+
if (typewriterPaused || currentMessageQueue.length === 0 || !currentMessageElement) {
|
| 1293 |
+
return;
|
| 1294 |
+
}
|
| 1295 |
+
|
| 1296 |
+
let item = currentMessageQueue.shift();
|
| 1297 |
+
|
| 1298 |
+
if (item.type === 'text') {
|
| 1299 |
+
// 处理文本
|
| 1300 |
+
currentMessageElement.innerHTML += item.content;
|
| 1301 |
+
} else if (item.type === 'element-start') {
|
| 1302 |
+
// 处理元素开始标签
|
| 1303 |
+
const el = document.createElement(item.tagName);
|
| 1304 |
+
if (item.attributes) {
|
| 1305 |
+
item.attributes.forEach(attr => {
|
| 1306 |
+
el.setAttribute(attr.name, attr.value);
|
| 1307 |
+
});
|
| 1308 |
+
}
|
| 1309 |
+
currentMessageElement.appendChild(el);
|
| 1310 |
+
// 更新当前元素引用
|
| 1311 |
+
currentMessageElement = el;
|
| 1312 |
+
} else if (item.type === 'element-end') {
|
| 1313 |
+
// 处理元素结束标签
|
| 1314 |
+
// 将当前元素引用更新为父元素
|
| 1315 |
+
if (currentMessageElement.parentElement) {
|
| 1316 |
+
currentMessageElement = currentMessageElement.parentElement;
|
| 1317 |
+
}
|
| 1318 |
+
}
|
| 1319 |
+
|
| 1320 |
+
// 滚动到底部
|
| 1321 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 1322 |
+
|
| 1323 |
+
// 如果队列为空,需要渲染数学公式
|
| 1324 |
+
if (currentMessageQueue.length === 0) {
|
| 1325 |
+
clearInterval(typewriterInterval);
|
| 1326 |
+
typewriterInterval = null;
|
| 1327 |
+
|
| 1328 |
+
// 找到最近添加的机器人消息
|
| 1329 |
+
const botMessage = chatMessages.querySelector('.bot-message:last-child');
|
| 1330 |
+
if (botMessage) {
|
| 1331 |
+
try {
|
| 1332 |
+
renderMathInElement(botMessage.querySelector('.message-content'), {
|
| 1333 |
+
delimiters: [
|
| 1334 |
+
{left: '$$', right: '$$', display: true},
|
| 1335 |
+
{left: '$', right: '$', display: false},
|
| 1336 |
+
{left: '\\(', right: '\\)', display: false},
|
| 1337 |
+
{left: '\\[', right: '\\]', display: true}
|
| 1338 |
+
],
|
| 1339 |
+
throwOnError: false
|
| 1340 |
+
});
|
| 1341 |
+
} catch (e) {
|
| 1342 |
+
console.error('渲染LaTeX出错:', e);
|
| 1343 |
+
}
|
| 1344 |
+
|
| 1345 |
+
// 为代码块添加复制按钮
|
| 1346 |
+
botMessage.querySelectorAll('pre').forEach(pre => {
|
| 1347 |
+
if (!pre.querySelector('.copy-button')) {
|
| 1348 |
+
const copyButton = document.createElement('button');
|
| 1349 |
+
copyButton.className = 'copy-button';
|
| 1350 |
+
copyButton.innerHTML = '<i class="bi bi-clipboard"></i> 复制';
|
| 1351 |
+
copyButton.onclick = function() { copyToClipboard(this); };
|
| 1352 |
+
pre.appendChild(copyButton);
|
| 1353 |
+
}
|
| 1354 |
+
});
|
| 1355 |
+
}
|
| 1356 |
+
}
|
| 1357 |
+
}
|
| 1358 |
+
|
| 1359 |
+
// 暂停打字机效果
|
| 1360 |
+
function pauseTypewriter() {
|
| 1361 |
+
typewriterPaused = true;
|
| 1362 |
+
}
|
| 1363 |
+
|
| 1364 |
+
// 继续打字机效果
|
| 1365 |
+
function continueTypewriter() {
|
| 1366 |
+
typewriterPaused = false;
|
| 1367 |
+
|
| 1368 |
+
// 如果之前的定时器已清除,重新启动
|
| 1369 |
+
if (!typewriterInterval && currentMessageQueue.length > 0) {
|
| 1370 |
+
typewriterInterval = setInterval(typeNextChar, typewriterSpeed);
|
| 1371 |
+
}
|
| 1372 |
+
}
|
| 1373 |
+
|
| 1374 |
+
// 切换打字速度
|
| 1375 |
+
function toggleTypewriterSpeed() {
|
| 1376 |
+
if (typewriterSpeed === 20) {
|
| 1377 |
+
typewriterSpeed = 5; // 快速
|
| 1378 |
+
} else {
|
| 1379 |
+
typewriterSpeed = 20; // 正常速度
|
| 1380 |
+
}
|
| 1381 |
+
|
| 1382 |
+
// 如果正在运行,更新速度
|
| 1383 |
+
if (typewriterInterval) {
|
| 1384 |
+
clearInterval(typewriterInterval);
|
| 1385 |
+
typewriterInterval = setInterval(typeNextChar, typewriterSpeed);
|
| 1386 |
+
}
|
| 1387 |
+
}
|
| 1388 |
+
|
| 1389 |
+
// 复制代码到剪贴板
|
| 1390 |
+
function copyToClipboard(button) {
|
| 1391 |
+
const codeBlock = button.parentNode.querySelector('code');
|
| 1392 |
+
const text = codeBlock.textContent;
|
| 1393 |
+
|
| 1394 |
+
navigator.clipboard.writeText(text).then(() => {
|
| 1395 |
+
button.innerHTML = '<i class="bi bi-check"></i> 已复制';
|
| 1396 |
+
|
| 1397 |
+
setTimeout(() => {
|
| 1398 |
+
button.innerHTML = '<i class="bi bi-clipboard"></i> 复制';
|
| 1399 |
+
}, 2000);
|
| 1400 |
+
}).catch(err => {
|
| 1401 |
+
console.error('复制失败:', err);
|
| 1402 |
+
button.innerHTML = '<i class="bi bi-exclamation-triangle"></i> 失败';
|
| 1403 |
+
|
| 1404 |
+
setTimeout(() => {
|
| 1405 |
+
button.innerHTML = '<i class="bi bi-clipboard"></i> 复制';
|
| 1406 |
+
}, 2000);
|
| 1407 |
+
});
|
| 1408 |
+
}
|
| 1409 |
+
|
| 1410 |
+
// 隐藏所有插件
|
| 1411 |
+
function hideAllPlugins() {
|
| 1412 |
+
codePlugin.style.display = 'none';
|
| 1413 |
+
visualizationPlugin.style.display = 'none';
|
| 1414 |
+
mindmapPlugin.style.display = 'none';
|
| 1415 |
+
updateMainContainerLayout();
|
| 1416 |
+
}
|
| 1417 |
+
|
| 1418 |
+
// 提取代码块
|
| 1419 |
+
function extractCodeBlocks(message) {
|
| 1420 |
+
const codeBlocks = [];
|
| 1421 |
+
const codeRegex = /```python\n([\s\S]*?)\n```/g;
|
| 1422 |
+
|
| 1423 |
+
let match;
|
| 1424 |
+
while ((match = codeRegex.exec(message)) !== null) {
|
| 1425 |
+
codeBlocks.push(match[1]);
|
| 1426 |
+
}
|
| 1427 |
+
|
| 1428 |
+
return codeBlocks;
|
| 1429 |
+
}
|
| 1430 |
+
|
| 1431 |
+
// 提取3D可视化函数代码
|
| 1432 |
+
function extract3DVisualizationCode(message) {
|
| 1433 |
+
// 尝试提取函数代码块
|
| 1434 |
+
const codeRegex = /```python\s*(import[\s\S]*?def create_3d_plot\(\):[\s\S]*?return[\s\S]*?})\s*```/i;
|
| 1435 |
+
const match = codeRegex.exec(message);
|
| 1436 |
+
|
| 1437 |
+
if (match) {
|
| 1438 |
+
return match[1].trim();
|
| 1439 |
+
}
|
| 1440 |
+
|
| 1441 |
+
return null;
|
| 1442 |
+
}
|
| 1443 |
+
|
| 1444 |
+
// 提取思维导图内容
|
| 1445 |
+
function extractMindmapContent(message) {
|
| 1446 |
+
// 尝试提取@startmindmap和@endmindmap之间的内容
|
| 1447 |
+
const mindmapRegex = /@startmindmap\n([\s\S]*?)@endmindmap/;
|
| 1448 |
+
const match = mindmapRegex.exec(message);
|
| 1449 |
+
|
| 1450 |
+
if (match) {
|
| 1451 |
+
return `@startmindmap\n${match[1]}\n@endmindmap`;
|
| 1452 |
+
}
|
| 1453 |
+
|
| 1454 |
+
return null;
|
| 1455 |
+
}
|
| 1456 |
+
|
| 1457 |
+
// 激活代码执行插件
|
| 1458 |
+
function activateCodePlugin(message) {
|
| 1459 |
+
// 提取代码块
|
| 1460 |
+
const codeBlocks = extractCodeBlocks(message);
|
| 1461 |
+
|
| 1462 |
+
if (codeBlocks.length > 0) {
|
| 1463 |
+
const isAlreadyVisible = codePlugin.style.display === 'flex';
|
| 1464 |
+
|
| 1465 |
+
// 显示插件容器
|
| 1466 |
+
codePlugin.style.display = 'flex';
|
| 1467 |
+
updateMainContainerLayout();
|
| 1468 |
+
|
| 1469 |
+
// 获取iframe
|
| 1470 |
+
const iframe = document.getElementById('code-execution-frame');
|
| 1471 |
+
|
| 1472 |
+
// 如果插件已经可见且iframe已加载
|
| 1473 |
+
if (isAlreadyVisible && iframe.contentWindow) {
|
| 1474 |
+
// 发送新代码到现有iframe
|
| 1475 |
+
iframe.contentWindow.postMessage({
|
| 1476 |
+
type: 'setCode',
|
| 1477 |
+
code: codeBlocks[0]
|
| 1478 |
+
}, '*');
|
| 1479 |
+
} else {
|
| 1480 |
+
// 设置iframe源
|
| 1481 |
+
let src = '/code_execution.html';
|
| 1482 |
+
if (codeBlocks.length > 0) {
|
| 1483 |
+
src += `?code=${encodeURIComponent(codeBlocks[0])}`;
|
| 1484 |
+
}
|
| 1485 |
+
|
| 1486 |
+
iframe.src = src;
|
| 1487 |
+
|
| 1488 |
+
// 设置iframe加载事件处理程序
|
| 1489 |
+
iframe.onload = function() {
|
| 1490 |
+
if (codeBlocks.length > 0) {
|
| 1491 |
+
iframe.contentWindow.postMessage({
|
| 1492 |
+
type: 'setCode',
|
| 1493 |
+
code: codeBlocks[0]
|
| 1494 |
+
}, '*');
|
| 1495 |
+
}
|
| 1496 |
+
};
|
| 1497 |
+
}
|
| 1498 |
+
}
|
| 1499 |
+
}
|
| 1500 |
+
|
| 1501 |
+
// 激活3D可视化插件
|
| 1502 |
+
function activate3DVisualization(message) {
|
| 1503 |
+
const code = extract3DVisualizationCode(message);
|
| 1504 |
+
|
| 1505 |
+
if (code) {
|
| 1506 |
+
// 显示插件容器
|
| 1507 |
+
visualizationPlugin.style.display = 'flex';
|
| 1508 |
+
updateMainContainerLayout();
|
| 1509 |
+
|
| 1510 |
+
// 显示加载状态
|
| 1511 |
+
visualizationResult.innerHTML = `
|
| 1512 |
+
<div class="text-center py-4">
|
| 1513 |
+
<div class="spinner-border" style="color: var(--secondary-color);" role="status">
|
| 1514 |
+
<span class="visually-hidden">生成中...</span>
|
| 1515 |
+
</div>
|
| 1516 |
+
<p class="mt-3">正在生成3D图形,请稍候...</p>
|
| 1517 |
+
</div>
|
| 1518 |
+
`;
|
| 1519 |
+
|
| 1520 |
+
// 自动生成可视化
|
| 1521 |
+
fetch('/api/visualization/3d-surface', {
|
| 1522 |
+
method: 'POST',
|
| 1523 |
+
headers: {
|
| 1524 |
+
'Content-Type': 'application/json'
|
| 1525 |
+
},
|
| 1526 |
+
body: JSON.stringify({ code: code })
|
| 1527 |
+
})
|
| 1528 |
+
.then(response => response.json())
|
| 1529 |
+
.then(data => {
|
| 1530 |
+
if (data.success) {
|
| 1531 |
+
// 直接嵌入HTML
|
| 1532 |
+
visualizationResult.innerHTML = `
|
| 1533 |
+
<div class="visualization-iframe-container" style="width:100%; height:500px;">
|
| 1534 |
+
<iframe src="${data.html_url}" style="width:100%; height:100%; border:none;"></iframe>
|
| 1535 |
+
</div>
|
| 1536 |
+
<div class="text-center mt-3">
|
| 1537 |
+
<p>3D图形生成成功</p>
|
| 1538 |
+
<a href="${data.html_url}" class="btn btn-sm btn-outline-primary" target="_blank">
|
| 1539 |
+
<i class="bi bi-arrows-fullscreen"></i> 全屏查看
|
| 1540 |
+
</a>
|
| 1541 |
+
</div>
|
| 1542 |
+
`;
|
| 1543 |
+
} else {
|
| 1544 |
+
visualizationResult.innerHTML = `
|
| 1545 |
+
<div class="alert alert-danger">
|
| 1546 |
+
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
| 1547 |
+
生成失败: ${data.message}
|
| 1548 |
+
</div>
|
| 1549 |
+
`;
|
| 1550 |
+
}
|
| 1551 |
+
})
|
| 1552 |
+
.catch(error => {
|
| 1553 |
+
console.error('生成3D图形出错:', error);
|
| 1554 |
+
visualizationResult.innerHTML = `
|
| 1555 |
+
<div class="alert alert-danger">
|
| 1556 |
+
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
| 1557 |
+
生成3D图形时发生错误,请重试
|
| 1558 |
+
</div>
|
| 1559 |
+
`;
|
| 1560 |
+
});
|
| 1561 |
+
}
|
| 1562 |
+
}
|
| 1563 |
+
|
| 1564 |
+
// 激活思维导图插件
|
| 1565 |
+
function activateMindmap(message) {
|
| 1566 |
+
const content = extractMindmapContent(message);
|
| 1567 |
+
|
| 1568 |
+
if (content) {
|
| 1569 |
+
// 显示插件容器
|
| 1570 |
+
mindmapPlugin.style.display = 'flex';
|
| 1571 |
+
updateMainContainerLayout();
|
| 1572 |
+
|
| 1573 |
+
// 显示加载状态
|
| 1574 |
+
mindmapResult.innerHTML = `
|
| 1575 |
+
<div class="text-center py-4">
|
| 1576 |
+
<div class="spinner-border" style="color: var(--secondary-color);" role="status">
|
| 1577 |
+
<span class="visually-hidden">生成中...</span>
|
| 1578 |
+
</div>
|
| 1579 |
+
<p class="mt-3">正在生成思维导图,请稍候...</p>
|
| 1580 |
+
</div>
|
| 1581 |
+
`;
|
| 1582 |
+
|
| 1583 |
+
// 自动生成思维导图
|
| 1584 |
+
fetch('/api/visualization/mindmap', {
|
| 1585 |
+
method: 'POST',
|
| 1586 |
+
headers: {
|
| 1587 |
+
'Content-Type': 'application/json'
|
| 1588 |
+
},
|
| 1589 |
+
body: JSON.stringify({ content: content })
|
| 1590 |
+
})
|
| 1591 |
+
.then(response => response.json())
|
| 1592 |
+
.then(data => {
|
| 1593 |
+
if (data.success) {
|
| 1594 |
+
mindmapResult.innerHTML = `
|
| 1595 |
+
<div class="text-center">
|
| 1596 |
+
<img src="${data.url}" class="mindmap-image" alt="思维导图">
|
| 1597 |
+
<p class="mt-3">生成成功!</p>
|
| 1598 |
+
<a href="${data.url}" class="btn btn-sm btn-outline-primary mt-2" target="_blank">
|
| 1599 |
+
<i class="bi bi-download"></i> 下载图片
|
| 1600 |
+
</a>
|
| 1601 |
+
</div>
|
| 1602 |
+
`;
|
| 1603 |
+
} else {
|
| 1604 |
+
mindmapResult.innerHTML = `
|
| 1605 |
+
<div class="alert alert-danger">
|
| 1606 |
+
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
| 1607 |
+
生成失败: ${data.message}
|
| 1608 |
+
</div>
|
| 1609 |
+
`;
|
| 1610 |
+
}
|
| 1611 |
+
})
|
| 1612 |
+
.catch(error => {
|
| 1613 |
+
console.error('生成思维导图出错:', error);
|
| 1614 |
+
mindmapResult.innerHTML = `
|
| 1615 |
+
<div class="alert alert-danger">
|
| 1616 |
+
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
| 1617 |
+
生成思维导图时发生错误,请重试
|
| 1618 |
+
</div>
|
| 1619 |
+
`;
|
| 1620 |
+
});
|
| 1621 |
+
}
|
| 1622 |
+
}
|
| 1623 |
+
|
| 1624 |
+
// 根据消息内容激活适当的插件
|
| 1625 |
+
function activatePlugins(message, tools) {
|
| 1626 |
+
// 检查并激活代码执行插件
|
| 1627 |
+
if (tools.includes('code') && message.includes('```python')) {
|
| 1628 |
+
activateCodePlugin(message);
|
| 1629 |
+
}
|
| 1630 |
+
|
| 1631 |
+
// 检查并激活3D可视化插件
|
| 1632 |
+
if (tools.includes('visualization') &&
|
| 1633 |
+
(message.includes('def create_3d_plot') ||
|
| 1634 |
+
message.includes('3D') || message.includes('可视化'))) {
|
| 1635 |
+
activate3DVisualization(message);
|
| 1636 |
+
}
|
| 1637 |
+
|
| 1638 |
+
// 检查并激活思维导图插件
|
| 1639 |
+
if (tools.includes('mindmap') &&
|
| 1640 |
+
(message.includes('@startmindmap') ||
|
| 1641 |
+
message.includes('思维导图'))) {
|
| 1642 |
+
activateMindmap(message);
|
| 1643 |
+
}
|
| 1644 |
+
}
|
| 1645 |
+
</script>
|
| 1646 |
+
|
| 1647 |
+
<!-- 添加highlight.js用于代码高亮 -->
|
| 1648 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
|
| 1649 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/languages/python.min.js"></script>
|
| 1650 |
+
</body>
|
| 1651 |
+
</html>
|
_archive/templates/student_portal.html
ADDED
|
@@ -0,0 +1,1004 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>学生端门户 - 教育AI助手平台</title>
|
| 7 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css">
|
| 8 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
|
| 9 |
+
<style>
|
| 10 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
| 11 |
+
|
| 12 |
+
:root {
|
| 13 |
+
/* 优雅的配色方案 */
|
| 14 |
+
--primary-color: #0f2d49;
|
| 15 |
+
--primary-light: #234a70;
|
| 16 |
+
--secondary-color: #4a6cfd;
|
| 17 |
+
--secondary-light: #7b91ff;
|
| 18 |
+
--tertiary-color: #f7f9fe;
|
| 19 |
+
--success-color: #10b981;
|
| 20 |
+
--success-light: rgba(16, 185, 129, 0.1);
|
| 21 |
+
--warning-color: #f59e0b;
|
| 22 |
+
--warning-light: rgba(245, 158, 11, 0.1);
|
| 23 |
+
--info-color: #0ea5e9;
|
| 24 |
+
--info-light: rgba(14, 165, 233, 0.1);
|
| 25 |
+
--danger-color: #ef4444;
|
| 26 |
+
--danger-light: rgba(239, 68, 68, 0.1);
|
| 27 |
+
--neutral-50: #f9fafb;
|
| 28 |
+
--neutral-100: #f3f4f6;
|
| 29 |
+
--neutral-200: #e5e7eb;
|
| 30 |
+
--neutral-300: #d1d5db;
|
| 31 |
+
--neutral-400: #9ca3af;
|
| 32 |
+
--neutral-500: #6b7280;
|
| 33 |
+
--neutral-600: #4b5563;
|
| 34 |
+
--neutral-700: #374151;
|
| 35 |
+
--neutral-800: #1f2937;
|
| 36 |
+
--neutral-900: #111827;
|
| 37 |
+
|
| 38 |
+
/* 样式变量 */
|
| 39 |
+
--border-radius-sm: 0.25rem;
|
| 40 |
+
--border-radius: 0.375rem;
|
| 41 |
+
--border-radius-lg: 0.5rem;
|
| 42 |
+
--border-radius-xl: 0.75rem;
|
| 43 |
+
--border-radius-2xl: 1rem;
|
| 44 |
+
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.1);
|
| 45 |
+
--card-shadow-hover: 0 10px 20px rgba(0, 0, 0, 0.05), 0 6px 6px rgba(0, 0, 0, 0.1);
|
| 46 |
+
--card-shadow-lg: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
| 47 |
+
--transition-base: all 0.2s ease-in-out;
|
| 48 |
+
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 49 |
+
--font-family: 'Inter', 'PingFang SC', 'Helvetica Neue', 'Microsoft YaHei', sans-serif;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/* 基础样式 */
|
| 53 |
+
body {
|
| 54 |
+
font-family: var(--font-family);
|
| 55 |
+
background-color: var(--neutral-50);
|
| 56 |
+
color: var(--neutral-800);
|
| 57 |
+
margin: 0;
|
| 58 |
+
padding: 0;
|
| 59 |
+
min-height: 100vh;
|
| 60 |
+
display: flex;
|
| 61 |
+
flex-direction: column;
|
| 62 |
+
-webkit-font-smoothing: antialiased;
|
| 63 |
+
-moz-osx-font-smoothing: grayscale;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
h1, h2, h3, h4, h5, h6 {
|
| 67 |
+
font-weight: 600;
|
| 68 |
+
color: var(--neutral-900);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.text-gradient {
|
| 72 |
+
background: linear-gradient(135deg, var(--secondary-color), var(--secondary-light));
|
| 73 |
+
-webkit-background-clip: text;
|
| 74 |
+
-webkit-text-fill-color: transparent;
|
| 75 |
+
background-clip: text;
|
| 76 |
+
color: transparent;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/* 滚动条样式 */
|
| 80 |
+
::-webkit-scrollbar {
|
| 81 |
+
width: 6px;
|
| 82 |
+
height: 6px;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
::-webkit-scrollbar-track {
|
| 86 |
+
background: var(--neutral-100);
|
| 87 |
+
border-radius: 10px;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
::-webkit-scrollbar-thumb {
|
| 91 |
+
background: var(--neutral-300);
|
| 92 |
+
border-radius: 10px;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
::-webkit-scrollbar-thumb:hover {
|
| 96 |
+
background: var(--neutral-400);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/* 头部样式 */
|
| 100 |
+
.header {
|
| 101 |
+
background-color: white;
|
| 102 |
+
border-bottom: 1px solid var(--neutral-200);
|
| 103 |
+
padding: 1rem 1.5rem;
|
| 104 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
| 105 |
+
position: sticky;
|
| 106 |
+
top: 0;
|
| 107 |
+
z-index: 100;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.header-content {
|
| 111 |
+
max-width: 1400px;
|
| 112 |
+
margin: 0 auto;
|
| 113 |
+
display: flex;
|
| 114 |
+
justify-content: space-between;
|
| 115 |
+
align-items: center;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.header h1 {
|
| 119 |
+
margin: 0;
|
| 120 |
+
font-size: 1.5rem;
|
| 121 |
+
font-weight: 700;
|
| 122 |
+
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
|
| 123 |
+
-webkit-background-clip: text;
|
| 124 |
+
-webkit-text-fill-color: transparent;
|
| 125 |
+
letter-spacing: -0.01em;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.user-info {
|
| 129 |
+
display: flex;
|
| 130 |
+
align-items: center;
|
| 131 |
+
gap: 0.75rem;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.user-info .badge {
|
| 135 |
+
font-size: 0.85rem;
|
| 136 |
+
padding: 0.4rem 0.75rem;
|
| 137 |
+
border-radius: var(--border-radius-lg);
|
| 138 |
+
background: linear-gradient(to right, var(--secondary-color), var(--secondary-light));
|
| 139 |
+
color: white;
|
| 140 |
+
font-weight: 500;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.user-info button {
|
| 144 |
+
color: var(--neutral-600);
|
| 145 |
+
text-decoration: none;
|
| 146 |
+
font-size: 0.9rem;
|
| 147 |
+
transition: var(--transition-base);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.user-info button:hover {
|
| 151 |
+
color: var(--secondary-color);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
/* 主容器样式 */
|
| 155 |
+
.main-container {
|
| 156 |
+
flex: 1;
|
| 157 |
+
max-width: 1400px;
|
| 158 |
+
margin: 2rem auto;
|
| 159 |
+
padding: 0 1.5rem;
|
| 160 |
+
width: 100%;
|
| 161 |
+
box-sizing: border-box;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/* 欢迎消息样式 */
|
| 165 |
+
.welcome-message {
|
| 166 |
+
background-color: white;
|
| 167 |
+
border-radius: var(--border-radius-xl);
|
| 168 |
+
box-shadow: var(--card-shadow);
|
| 169 |
+
padding: 2rem;
|
| 170 |
+
margin-bottom: 2.5rem;
|
| 171 |
+
position: relative;
|
| 172 |
+
overflow: hidden;
|
| 173 |
+
border: none;
|
| 174 |
+
transition: var(--transition-smooth);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.welcome-message:hover {
|
| 178 |
+
box-shadow: var(--card-shadow-hover);
|
| 179 |
+
transform: translateY(-3px);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.welcome-message::before {
|
| 183 |
+
content: '';
|
| 184 |
+
position: absolute;
|
| 185 |
+
top: 0;
|
| 186 |
+
bottom: 0;
|
| 187 |
+
left: 0;
|
| 188 |
+
width: 4px;
|
| 189 |
+
background: linear-gradient(to bottom, var(--info-color), var(--secondary-light));
|
| 190 |
+
border-radius: 4px 0 0 4px;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.welcome-message h2 {
|
| 194 |
+
margin-top: 0;
|
| 195 |
+
margin-bottom: 1rem;
|
| 196 |
+
font-size: 1.6rem;
|
| 197 |
+
font-weight: 700;
|
| 198 |
+
color: var(--primary-color);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.welcome-message p {
|
| 202 |
+
color: var(--neutral-600);
|
| 203 |
+
margin-bottom: 0.75rem;
|
| 204 |
+
font-size: 0.95rem;
|
| 205 |
+
line-height: 1.5;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.welcome-message p:last-child {
|
| 209 |
+
margin-bottom: 0;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
/* 令牌输入样式 */
|
| 213 |
+
.token-input {
|
| 214 |
+
background-color: white;
|
| 215 |
+
border-radius: var(--border-radius-xl);
|
| 216 |
+
box-shadow: var(--card-shadow);
|
| 217 |
+
padding: 2rem;
|
| 218 |
+
margin-bottom: 2.5rem;
|
| 219 |
+
position: relative;
|
| 220 |
+
overflow: hidden;
|
| 221 |
+
transition: var(--transition-smooth);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.token-input:hover {
|
| 225 |
+
box-shadow: var(--card-shadow-hover);
|
| 226 |
+
transform: translateY(-3px);
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.token-input::before {
|
| 230 |
+
content: '';
|
| 231 |
+
position: absolute;
|
| 232 |
+
top: 0;
|
| 233 |
+
bottom: 0;
|
| 234 |
+
left: 0;
|
| 235 |
+
width: 4px;
|
| 236 |
+
background: linear-gradient(to bottom, var(--warning-color), var(--secondary-light));
|
| 237 |
+
border-radius: 4px 0 0 4px;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.token-input h3 {
|
| 241 |
+
margin-top: 0;
|
| 242 |
+
margin-bottom: 1rem;
|
| 243 |
+
font-size: 1.35rem;
|
| 244 |
+
font-weight: 600;
|
| 245 |
+
color: var(--neutral-900);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
.token-input p {
|
| 249 |
+
color: var(--neutral-600);
|
| 250 |
+
margin-bottom: 1.25rem;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.token-input .input-group {
|
| 254 |
+
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
| 255 |
+
border-radius: var(--border-radius-lg);
|
| 256 |
+
margin-bottom: 0;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.token-input .form-control {
|
| 260 |
+
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
|
| 261 |
+
border: 1px solid var(--neutral-200);
|
| 262 |
+
padding: 0.75rem 1rem;
|
| 263 |
+
font-size: 0.95rem;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.token-input .form-control:focus {
|
| 267 |
+
box-shadow: none;
|
| 268 |
+
border-color: var(--secondary-color);
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.token-input .btn {
|
| 272 |
+
background: linear-gradient(to right, var(--secondary-color), var(--secondary-light));
|
| 273 |
+
color: white;
|
| 274 |
+
border: none;
|
| 275 |
+
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
|
| 276 |
+
padding: 0.75rem 1.25rem;
|
| 277 |
+
transition: var(--transition-base);
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
.token-input .btn:hover {
|
| 281 |
+
background: linear-gradient(to right, var(--secondary-light), var(--secondary-color));
|
| 282 |
+
box-shadow: 0 4px 10px rgba(74, 108, 253, 0.2);
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
/* 标题样式 */
|
| 286 |
+
.section-header {
|
| 287 |
+
margin-bottom: 1.5rem;
|
| 288 |
+
display: flex;
|
| 289 |
+
flex-direction: column;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.section-header h2 {
|
| 293 |
+
margin: 0;
|
| 294 |
+
font-size: 1.5rem;
|
| 295 |
+
font-weight: 700;
|
| 296 |
+
color: var(--primary-color);
|
| 297 |
+
margin-bottom: 0.5rem;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
.section-header p {
|
| 301 |
+
color: var(--neutral-600);
|
| 302 |
+
margin: 0;
|
| 303 |
+
font-size: 0.95rem;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
/* Agent卡片样式 */
|
| 307 |
+
.agent-card {
|
| 308 |
+
background-color: white;
|
| 309 |
+
border-radius: var(--border-radius-xl);
|
| 310 |
+
margin-bottom: 1.5rem;
|
| 311 |
+
box-shadow: var(--card-shadow);
|
| 312 |
+
overflow: hidden;
|
| 313 |
+
transition: var(--transition-smooth);
|
| 314 |
+
position: relative;
|
| 315 |
+
border: none;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.agent-card:hover {
|
| 319 |
+
transform: translateY(-5px);
|
| 320 |
+
box-shadow: var(--card-shadow-hover);
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.agent-card::before {
|
| 324 |
+
content: '';
|
| 325 |
+
position: absolute;
|
| 326 |
+
top: 0;
|
| 327 |
+
bottom: 0;
|
| 328 |
+
left: 0;
|
| 329 |
+
width: 4px;
|
| 330 |
+
background: linear-gradient(to bottom, var(--secondary-color), var(--secondary-light));
|
| 331 |
+
border-radius: 4px 0 0 4px;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.agent-card-header {
|
| 335 |
+
padding: 1.5rem;
|
| 336 |
+
background-color: rgba(74, 108, 253, 0.03);
|
| 337 |
+
border-bottom: 1px solid var(--neutral-100);
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
.agent-card-title {
|
| 341 |
+
margin: 0;
|
| 342 |
+
font-size: 1.25rem;
|
| 343 |
+
font-weight: 600;
|
| 344 |
+
color: var(--primary-color);
|
| 345 |
+
display: flex;
|
| 346 |
+
align-items: center;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
.agent-card-title i {
|
| 350 |
+
margin-right: 0.75rem;
|
| 351 |
+
font-size: 1.2rem;
|
| 352 |
+
color: var(--secondary-color);
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.agent-card-content {
|
| 356 |
+
padding: 1.5rem;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
.agent-card-description {
|
| 360 |
+
margin-bottom: 1.25rem;
|
| 361 |
+
color: var(--neutral-700);
|
| 362 |
+
font-size: 0.95rem;
|
| 363 |
+
line-height: 1.5;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
.agent-meta {
|
| 367 |
+
display: flex;
|
| 368 |
+
flex-wrap: wrap;
|
| 369 |
+
margin-bottom: 1.25rem;
|
| 370 |
+
gap: 1.5rem;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
.agent-meta-item {
|
| 374 |
+
display: flex;
|
| 375 |
+
align-items: center;
|
| 376 |
+
font-size: 0.9rem;
|
| 377 |
+
color: var(--neutral-600);
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
.agent-meta-item i {
|
| 381 |
+
margin-right: 0.5rem;
|
| 382 |
+
font-size: 0.95rem;
|
| 383 |
+
color: var(--secondary-color);
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.agent-tags {
|
| 387 |
+
display: flex;
|
| 388 |
+
flex-wrap: wrap;
|
| 389 |
+
gap: 0.5rem;
|
| 390 |
+
margin-bottom: 1.5rem;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
.agent-tag {
|
| 394 |
+
display: inline-flex;
|
| 395 |
+
align-items: center;
|
| 396 |
+
padding: 0.35em 0.75em;
|
| 397 |
+
background-color: rgba(74, 108, 253, 0.1);
|
| 398 |
+
color: var(--secondary-color);
|
| 399 |
+
border-radius: 20px;
|
| 400 |
+
font-size: 0.85rem;
|
| 401 |
+
font-weight: 500;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.agent-tag i {
|
| 405 |
+
margin-right: 0.4rem;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.agent-actions {
|
| 409 |
+
display: flex;
|
| 410 |
+
justify-content: flex-end;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
.agent-actions .btn {
|
| 414 |
+
padding: 0.65em 1.25em;
|
| 415 |
+
font-weight: 500;
|
| 416 |
+
background: linear-gradient(to right, var(--secondary-color), var(--secondary-light));
|
| 417 |
+
border: none;
|
| 418 |
+
color: white;
|
| 419 |
+
border-radius: var(--border-radius-lg);
|
| 420 |
+
transition: var(--transition-base);
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
.agent-actions .btn:hover {
|
| 424 |
+
background: linear-gradient(to right, var(--secondary-light), var(--secondary-color));
|
| 425 |
+
transform: translateY(-2px);
|
| 426 |
+
box-shadow: 0 4px 10px rgba(74, 108, 253, 0.2);
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
.agent-actions .btn i {
|
| 430 |
+
margin-right: 0.5rem;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
/* 活动记录样式 */
|
| 434 |
+
.recent-activity {
|
| 435 |
+
background-color: white;
|
| 436 |
+
border-radius: var(--border-radius-xl);
|
| 437 |
+
box-shadow: var(--card-shadow);
|
| 438 |
+
padding: 1.5rem;
|
| 439 |
+
margin-bottom: 2rem;
|
| 440 |
+
position: relative;
|
| 441 |
+
overflow: hidden;
|
| 442 |
+
transition: var(--transition-smooth);
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
.recent-activity:hover {
|
| 446 |
+
box-shadow: var(--card-shadow-hover);
|
| 447 |
+
transform: translateY(-3px);
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
.recent-activity::before {
|
| 451 |
+
content: '';
|
| 452 |
+
position: absolute;
|
| 453 |
+
top: 0;
|
| 454 |
+
bottom: 0;
|
| 455 |
+
left: 0;
|
| 456 |
+
width: 4px;
|
| 457 |
+
background: linear-gradient(to bottom, var(--success-color), var(--secondary-light));
|
| 458 |
+
border-radius: 4px 0 0 4px;
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
.recent-activity h3 {
|
| 462 |
+
margin-top: 0;
|
| 463 |
+
margin-bottom: 1.5rem;
|
| 464 |
+
font-size: 1.35rem;
|
| 465 |
+
font-weight: 600;
|
| 466 |
+
color: var(--neutral-900);
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
.activity-list {
|
| 470 |
+
list-style: none;
|
| 471 |
+
padding: 0;
|
| 472 |
+
margin: 0;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
.activity-item {
|
| 476 |
+
display: flex;
|
| 477 |
+
padding: 1rem 0;
|
| 478 |
+
border-bottom: 1px solid var(--neutral-100);
|
| 479 |
+
align-items: center;
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
.activity-item:last-child {
|
| 483 |
+
border-bottom: none;
|
| 484 |
+
padding-bottom: 0;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
.activity-icon {
|
| 488 |
+
width: 40px;
|
| 489 |
+
height: 40px;
|
| 490 |
+
border-radius: 50%;
|
| 491 |
+
background-color: rgba(74, 108, 253, 0.1);
|
| 492 |
+
display: flex;
|
| 493 |
+
align-items: center;
|
| 494 |
+
justify-content: center;
|
| 495 |
+
margin-right: 1rem;
|
| 496 |
+
flex-shrink: 0;
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
.activity-icon i {
|
| 500 |
+
color: var(--secondary-color);
|
| 501 |
+
font-size: 1.1rem;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
.activity-content {
|
| 505 |
+
flex: 1;
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
.activity-title {
|
| 509 |
+
margin: 0;
|
| 510 |
+
margin-bottom: 0.35rem;
|
| 511 |
+
font-size: 0.95rem;
|
| 512 |
+
font-weight: 500;
|
| 513 |
+
color: var(--neutral-800);
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
.activity-time {
|
| 517 |
+
font-size: 0.85rem;
|
| 518 |
+
color: var(--neutral-500);
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
/* 动画效果 */
|
| 522 |
+
@keyframes fadeIn {
|
| 523 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 524 |
+
to { opacity: 1; transform: translateY(0); }
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
.fade-in {
|
| 528 |
+
opacity: 0;
|
| 529 |
+
animation: fadeIn 0.4s ease-out forwards;
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
.animate-delay-1 { animation-delay: 0.1s; }
|
| 533 |
+
.animate-delay-2 { animation-delay: 0.2s; }
|
| 534 |
+
.animate-delay-3 { animation-delay: 0.3s; }
|
| 535 |
+
.animate-delay-4 { animation-delay: 0.4s; }
|
| 536 |
+
.animate-delay-5 { animation-delay: 0.5s; }
|
| 537 |
+
|
| 538 |
+
/* 响应式样式 */
|
| 539 |
+
@media (max-width: 992px) {
|
| 540 |
+
.main-container {
|
| 541 |
+
padding: 0 1rem;
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
.agent-meta {
|
| 545 |
+
flex-direction: column;
|
| 546 |
+
gap: 0.75rem;
|
| 547 |
+
}
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
@media (max-width: 768px) {
|
| 551 |
+
.header h1 {
|
| 552 |
+
font-size: 1.25rem;
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
.welcome-message h2 {
|
| 556 |
+
font-size: 1.35rem;
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
.agent-card-title {
|
| 560 |
+
font-size: 1.1rem;
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
.section-header h2 {
|
| 564 |
+
font-size: 1.3rem;
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
.recent-activity h3,
|
| 568 |
+
.token-input h3 {
|
| 569 |
+
font-size: 1.2rem;
|
| 570 |
+
}
|
| 571 |
+
}
|
| 572 |
+
</style>
|
| 573 |
+
</head>
|
| 574 |
+
<body>
|
| 575 |
+
<header class="header">
|
| 576 |
+
<div class="header-content">
|
| 577 |
+
<h1>教育AI助手平台 - 学生端</h1>
|
| 578 |
+
<div class="user-info">
|
| 579 |
+
<span class="badge" id="user-name">张三</span>
|
| 580 |
+
<button class="btn btn-sm btn-link text-decoration-none" id="logout-btn">登出</button>
|
| 581 |
+
</div>
|
| 582 |
+
</div>
|
| 583 |
+
</header>
|
| 584 |
+
|
| 585 |
+
<div class="main-container">
|
| 586 |
+
<div class="welcome-message fade-in">
|
| 587 |
+
<h2>欢迎回来,<span id="welcome-name">张三</span>!</h2>
|
| 588 |
+
<p>教育AI助手平台为您提供智能学习辅助工具,帮助您更高效地学习和解决问题。</p>
|
| 589 |
+
<p>下方是您的教师分享给您的AI助手,点击"开始对话"与它们互动。</p>
|
| 590 |
+
</div>
|
| 591 |
+
|
| 592 |
+
<div class="token-input fade-in animate-delay-1">
|
| 593 |
+
<h3>访问新的AI助手</h3>
|
| 594 |
+
<p>输入教师分享的访问令牌,即可使用新的AI助手。</p>
|
| 595 |
+
<div class="input-group">
|
| 596 |
+
<input type="text" class="form-control" id="access-token" placeholder="输入访问令牌">
|
| 597 |
+
<button class="btn" id="access-button">
|
| 598 |
+
<i class="bi bi-arrow-right-circle me-1"></i> 访问
|
| 599 |
+
</button>
|
| 600 |
+
</div>
|
| 601 |
+
</div>
|
| 602 |
+
|
| 603 |
+
<div class="section-header fade-in animate-delay-2">
|
| 604 |
+
<h2>我的AI助手</h2>
|
| 605 |
+
<p>教师分享给您的智能学习助手</p>
|
| 606 |
+
</div>
|
| 607 |
+
|
| 608 |
+
<div class="row" id="agents-container">
|
| 609 |
+
<!-- 代码将在JavaScript中动态生成 -->
|
| 610 |
+
</div>
|
| 611 |
+
|
| 612 |
+
<div class="recent-activity fade-in animate-delay-4">
|
| 613 |
+
<h3>最近活动</h3>
|
| 614 |
+
<ul class="activity-list" id="activity-list">
|
| 615 |
+
<!-- 代码将在JavaScript中动态生成 -->
|
| 616 |
+
</ul>
|
| 617 |
+
</div>
|
| 618 |
+
</div>
|
| 619 |
+
|
| 620 |
+
<script>
|
| 621 |
+
// DOM元素
|
| 622 |
+
const userNameElement = document.getElementById('user-name');
|
| 623 |
+
const welcomeNameElement = document.getElementById('welcome-name');
|
| 624 |
+
const logoutBtn = document.getElementById('logout-btn');
|
| 625 |
+
const accessButton = document.getElementById('access-button');
|
| 626 |
+
const agentsContainer = document.getElementById('agents-container');
|
| 627 |
+
const activityList = document.getElementById('activity-list');
|
| 628 |
+
|
| 629 |
+
// 页面加载函数
|
| 630 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 631 |
+
// 检查登录状态
|
| 632 |
+
checkAuthStatus();
|
| 633 |
+
|
| 634 |
+
// 加载用户的AI助手列表
|
| 635 |
+
loadAgents();
|
| 636 |
+
|
| 637 |
+
// 初始化登出按钮
|
| 638 |
+
logoutBtn.addEventListener('click', logout);
|
| 639 |
+
|
| 640 |
+
// 初始化访问令牌按钮
|
| 641 |
+
accessButton.addEventListener('click', accessWithToken);
|
| 642 |
+
});
|
| 643 |
+
|
| 644 |
+
// 检查认证状态
|
| 645 |
+
async function checkAuthStatus() {
|
| 646 |
+
try {
|
| 647 |
+
const response = await fetch('/api/auth/check');
|
| 648 |
+
const data = await response.json();
|
| 649 |
+
|
| 650 |
+
if (!data.success) {
|
| 651 |
+
// 未登录,跳转到登录页面
|
| 652 |
+
window.location.href = '/login.html';
|
| 653 |
+
return;
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
// 更新用户信息显示
|
| 657 |
+
userNameElement.textContent = data.user.name;
|
| 658 |
+
welcomeNameElement.textContent = data.user.name;
|
| 659 |
+
|
| 660 |
+
// 如果不是学生,跳转到教师端
|
| 661 |
+
if (data.user.type !== 'student') {
|
| 662 |
+
window.location.href = '/index.html';
|
| 663 |
+
}
|
| 664 |
+
} catch (error) {
|
| 665 |
+
console.error('验证登录状态出错:', error);
|
| 666 |
+
window.location.href = '/login.html';
|
| 667 |
+
}
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
// 登出
|
| 671 |
+
async function logout() {
|
| 672 |
+
try {
|
| 673 |
+
const response = await fetch('/api/auth/logout', {
|
| 674 |
+
method: 'POST'
|
| 675 |
+
});
|
| 676 |
+
|
| 677 |
+
const data = await response.json();
|
| 678 |
+
|
| 679 |
+
if (data.success) {
|
| 680 |
+
window.location.href = '/login.html';
|
| 681 |
+
}
|
| 682 |
+
} catch (error) {
|
| 683 |
+
console.error('登出出错:', error);
|
| 684 |
+
alert('登出失败,请重试');
|
| 685 |
+
}
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
// 加载Agent列表
|
| 689 |
+
async function loadAgents() {
|
| 690 |
+
try {
|
| 691 |
+
// 显示加载状态
|
| 692 |
+
agentsContainer.innerHTML = `
|
| 693 |
+
<div class="col-12 text-center py-4">
|
| 694 |
+
<div class="spinner-border text-primary" role="status">
|
| 695 |
+
<span class="visually-hidden">加载中...</span>
|
| 696 |
+
</div>
|
| 697 |
+
<p class="mt-3">正在加载AI助手列表...</p>
|
| 698 |
+
</div>
|
| 699 |
+
`;
|
| 700 |
+
|
| 701 |
+
// 获取Agent列表
|
| 702 |
+
const response = await fetch('/api/student/agents');
|
| 703 |
+
const result = await response.json();
|
| 704 |
+
|
| 705 |
+
if (result.success) {
|
| 706 |
+
const agents = result.agents || [];
|
| 707 |
+
|
| 708 |
+
if (agents.length === 0) {
|
| 709 |
+
agentsContainer.innerHTML = `
|
| 710 |
+
<div class="col-12">
|
| 711 |
+
<div class="alert alert-info">
|
| 712 |
+
<i class="bi bi-info-circle me-2"></i>
|
| 713 |
+
您还没有可用的AI助手。请向您的教师获取访问令牌。
|
| 714 |
+
</div>
|
| 715 |
+
</div>
|
| 716 |
+
`;
|
| 717 |
+
|
| 718 |
+
// 无Agent时,清空活动列表
|
| 719 |
+
activityList.innerHTML = `
|
| 720 |
+
<li class="text-center py-3 text-muted">
|
| 721 |
+
暂无活动记录
|
| 722 |
+
</li>
|
| 723 |
+
`;
|
| 724 |
+
|
| 725 |
+
return;
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
// 渲染Agent列表
|
| 729 |
+
agentsContainer.innerHTML = '';
|
| 730 |
+
|
| 731 |
+
agents.forEach(agent => {
|
| 732 |
+
const col = document.createElement('div');
|
| 733 |
+
col.className = 'col-lg-6 col-12 fade-in';
|
| 734 |
+
|
| 735 |
+
// 格式化最后使用时间
|
| 736 |
+
let lastUsedText = '从未使用';
|
| 737 |
+
if (agent.last_used) {
|
| 738 |
+
const lastUsedDate = new Date(agent.last_used * 1000);
|
| 739 |
+
lastUsedText = lastUsedDate.toLocaleString();
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
// 构建插件标签
|
| 743 |
+
let pluginsHtml = '';
|
| 744 |
+
if (agent.plugins && agent.plugins.length > 0) {
|
| 745 |
+
pluginsHtml = '<div class="agent-tags">';
|
| 746 |
+
|
| 747 |
+
agent.plugins.forEach(plugin => {
|
| 748 |
+
let pluginName = '未知插件';
|
| 749 |
+
let pluginIcon = 'puzzle';
|
| 750 |
+
|
| 751 |
+
if (plugin === 'code') {
|
| 752 |
+
pluginName = '代码执行';
|
| 753 |
+
pluginIcon = 'code-square';
|
| 754 |
+
} else if (plugin === 'visualization') {
|
| 755 |
+
pluginName = '3D可视化';
|
| 756 |
+
pluginIcon = 'bar-chart';
|
| 757 |
+
} else if (plugin === 'mindmap') {
|
| 758 |
+
pluginName = '思维导图';
|
| 759 |
+
pluginIcon = 'diagram-3';
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
pluginsHtml += `
|
| 763 |
+
<span class="agent-tag">
|
| 764 |
+
<i class="bi bi-${pluginIcon}"></i> ${pluginName}
|
| 765 |
+
</span>
|
| 766 |
+
`;
|
| 767 |
+
});
|
| 768 |
+
|
| 769 |
+
pluginsHtml += '</div>';
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
// 构建主题和教师标签
|
| 773 |
+
let metaHtml = '<div class="agent-meta">';
|
| 774 |
+
|
| 775 |
+
if (agent.subject) {
|
| 776 |
+
metaHtml += `
|
| 777 |
+
<div class="agent-meta-item">
|
| 778 |
+
<i class="bi bi-book"></i> ${agent.subject}
|
| 779 |
+
</div>
|
| 780 |
+
`;
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
if (agent.instructor) {
|
| 784 |
+
metaHtml += `
|
| 785 |
+
<div class="agent-meta-item">
|
| 786 |
+
<i class="bi bi-person"></i> ${agent.instructor}
|
| 787 |
+
</div>
|
| 788 |
+
`;
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
metaHtml += `
|
| 792 |
+
<div class="agent-meta-item">
|
| 793 |
+
<i class="bi bi-clock"></i> 最后使用: ${lastUsedText}
|
| 794 |
+
</div>
|
| 795 |
+
</div>`;
|
| 796 |
+
|
| 797 |
+
// 构建Agent卡片
|
| 798 |
+
col.innerHTML = `
|
| 799 |
+
<div class="agent-card">
|
| 800 |
+
<div class="agent-card-header">
|
| 801 |
+
<h3 class="agent-card-title">
|
| 802 |
+
<i class="bi bi-robot"></i> ${agent.name}
|
| 803 |
+
</h3>
|
| 804 |
+
</div>
|
| 805 |
+
<div class="agent-card-content">
|
| 806 |
+
<div class="agent-card-description">
|
| 807 |
+
${agent.description || '暂无描述'}
|
| 808 |
+
</div>
|
| 809 |
+
${metaHtml}
|
| 810 |
+
${pluginsHtml}
|
| 811 |
+
<div class="agent-actions">
|
| 812 |
+
<a href="/student/${agent.id}?token=${agent.token}" class="btn">
|
| 813 |
+
<i class="bi bi-chat-dots me-1"></i> 开始对话
|
| 814 |
+
</a>
|
| 815 |
+
</div>
|
| 816 |
+
</div>
|
| 817 |
+
</div>
|
| 818 |
+
`;
|
| 819 |
+
|
| 820 |
+
agentsContainer.appendChild(col);
|
| 821 |
+
});
|
| 822 |
+
|
| 823 |
+
// 生成简单的活动记录(实际中应从API获取)
|
| 824 |
+
loadActivityRecords();
|
| 825 |
+
} else {
|
| 826 |
+
agentsContainer.innerHTML = `
|
| 827 |
+
<div class="col-12">
|
| 828 |
+
<div class="alert alert-danger">
|
| 829 |
+
<i class="bi bi-exclamation-triangle me-2"></i>
|
| 830 |
+
加载Agent列表失败: ${result.message}
|
| 831 |
+
</div>
|
| 832 |
+
</div>
|
| 833 |
+
`;
|
| 834 |
+
|
| 835 |
+
// 加载失败时,清空活动列表
|
| 836 |
+
activityList.innerHTML = `
|
| 837 |
+
<li class="text-center py-3 text-muted">
|
| 838 |
+
暂无活动记录
|
| 839 |
+
</li>
|
| 840 |
+
`;
|
| 841 |
+
}
|
| 842 |
+
} catch (error) {
|
| 843 |
+
console.error('加载Agent列表出错:', error);
|
| 844 |
+
agentsContainer.innerHTML = `
|
| 845 |
+
<div class="col-12">
|
| 846 |
+
<div class="alert alert-danger">
|
| 847 |
+
<i class="bi bi-exclamation-triangle me-2"></i>
|
| 848 |
+
加载Agent列表时发生错误,请刷新页面重试
|
| 849 |
+
</div>
|
| 850 |
+
</div>
|
| 851 |
+
`;
|
| 852 |
+
}
|
| 853 |
+
}
|
| 854 |
+
|
| 855 |
+
// 加载活动记录(实际中应从API获取)
|
| 856 |
+
// 加载活动记录
|
| 857 |
+
async function loadActivityRecords() {
|
| 858 |
+
try {
|
| 859 |
+
// 从API获取活动记录
|
| 860 |
+
const response = await fetch('/api/student/activities');
|
| 861 |
+
const result = await response.json();
|
| 862 |
+
|
| 863 |
+
if (result.success) {
|
| 864 |
+
const activities = result.activities || [];
|
| 865 |
+
|
| 866 |
+
if (activities.length === 0) {
|
| 867 |
+
activityList.innerHTML = `
|
| 868 |
+
<li class="text-center py-3 text-muted">
|
| 869 |
+
暂无活动记录
|
| 870 |
+
</li>
|
| 871 |
+
`;
|
| 872 |
+
return;
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
// 渲染活动记录
|
| 876 |
+
activityList.innerHTML = '';
|
| 877 |
+
|
| 878 |
+
activities.forEach(activity => {
|
| 879 |
+
let iconClass = 'bi-activity';
|
| 880 |
+
|
| 881 |
+
switch (activity.type) {
|
| 882 |
+
case 'chat':
|
| 883 |
+
iconClass = 'bi-chat-dots';
|
| 884 |
+
break;
|
| 885 |
+
case 'code':
|
| 886 |
+
iconClass = 'bi-code-square';
|
| 887 |
+
break;
|
| 888 |
+
case 'viz':
|
| 889 |
+
iconClass = 'bi-bar-chart';
|
| 890 |
+
break;
|
| 891 |
+
case 'mindmap':
|
| 892 |
+
iconClass = 'bi-diagram-3';
|
| 893 |
+
break;
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
const li = document.createElement('li');
|
| 897 |
+
li.className = 'activity-item';
|
| 898 |
+
|
| 899 |
+
// 如果有agent_id和token,添加链接
|
| 900 |
+
let titleHtml = `<h4 class="activity-title">${activity.title}</h4>`;
|
| 901 |
+
if (activity.agent_id) {
|
| 902 |
+
// 查找对应Agent的token
|
| 903 |
+
const agent = agents.find(a => a.id === activity.agent_id);
|
| 904 |
+
if (agent && agent.token) {
|
| 905 |
+
titleHtml = `
|
| 906 |
+
<h4 class="activity-title">
|
| 907 |
+
<a href="/student/${activity.agent_id}?token=${agent.token}">${activity.title}</a>
|
| 908 |
+
</h4>
|
| 909 |
+
`;
|
| 910 |
+
}
|
| 911 |
+
}
|
| 912 |
+
|
| 913 |
+
li.innerHTML = `
|
| 914 |
+
<div class="activity-icon">
|
| 915 |
+
<i class="bi ${iconClass}"></i>
|
| 916 |
+
</div>
|
| 917 |
+
<div class="activity-content">
|
| 918 |
+
${titleHtml}
|
| 919 |
+
<div class="activity-time">${activity.time}</div>
|
| 920 |
+
</div>
|
| 921 |
+
`;
|
| 922 |
+
|
| 923 |
+
activityList.appendChild(li);
|
| 924 |
+
});
|
| 925 |
+
} else {
|
| 926 |
+
activityList.innerHTML = `
|
| 927 |
+
<li class="text-center py-3 text-muted">
|
| 928 |
+
<i class="bi bi-exclamation-circle me-2"></i>
|
| 929 |
+
无法加载活动记录
|
| 930 |
+
</li>
|
| 931 |
+
`;
|
| 932 |
+
}
|
| 933 |
+
} catch (error) {
|
| 934 |
+
console.error('加载活动记录出错:', error);
|
| 935 |
+
activityList.innerHTML = `
|
| 936 |
+
<li class="text-center py-3 text-muted">
|
| 937 |
+
<i class="bi bi-exclamation-circle me-2"></i>
|
| 938 |
+
加载活动记录时出错
|
| 939 |
+
</li>
|
| 940 |
+
`;
|
| 941 |
+
}
|
| 942 |
+
}
|
| 943 |
+
// 使用令牌访问AI助手
|
| 944 |
+
async function accessWithToken() {
|
| 945 |
+
const token = document.getElementById('access-token').value.trim();
|
| 946 |
+
|
| 947 |
+
if (!token) {
|
| 948 |
+
showMessage('请输入访问令牌', 'warning');
|
| 949 |
+
return;
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
try {
|
| 953 |
+
// 验证令牌有效性
|
| 954 |
+
const response = await fetch('/api/verify_token', {
|
| 955 |
+
method: 'POST',
|
| 956 |
+
headers: {
|
| 957 |
+
'Content-Type': 'application/json'
|
| 958 |
+
},
|
| 959 |
+
body: JSON.stringify({ token })
|
| 960 |
+
});
|
| 961 |
+
|
| 962 |
+
const result = await response.json();
|
| 963 |
+
|
| 964 |
+
if (result.success) {
|
| 965 |
+
// 令牌有效,重定向到Agent页面
|
| 966 |
+
const agent = result.agent;
|
| 967 |
+
window.location.href = `/student/${agent.id}?token=${token}`;
|
| 968 |
+
} else {
|
| 969 |
+
showMessage(result.message || '无效的访问令牌', 'danger');
|
| 970 |
+
}
|
| 971 |
+
} catch (error) {
|
| 972 |
+
console.error('验证���牌出错:', error);
|
| 973 |
+
showMessage('验证令牌时发生错误,请重试', 'danger');
|
| 974 |
+
}
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
// 显示提示消息
|
| 978 |
+
function showMessage(message, type) {
|
| 979 |
+
// 移除现有的提示
|
| 980 |
+
const existingAlert = document.querySelector('.token-input .alert');
|
| 981 |
+
if (existingAlert) {
|
| 982 |
+
existingAlert.remove();
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
// 创建新提示
|
| 986 |
+
const alertDiv = document.createElement('div');
|
| 987 |
+
alertDiv.className = `alert alert-${type} mt-3`;
|
| 988 |
+
alertDiv.innerHTML = `
|
| 989 |
+
<i class="bi ${type === 'danger' ? 'bi-exclamation-triangle' : 'bi-info-circle'} me-2"></i>
|
| 990 |
+
${message}
|
| 991 |
+
`;
|
| 992 |
+
|
| 993 |
+
// 添加到令牌输入区域
|
| 994 |
+
document.querySelector('.token-input .input-group').insertAdjacentElement('afterend', alertDiv);
|
| 995 |
+
|
| 996 |
+
// 3秒后自动移除
|
| 997 |
+
setTimeout(() => {
|
| 998 |
+
alertDiv.style.opacity = '0';
|
| 999 |
+
setTimeout(() => alertDiv.remove(), 300);
|
| 1000 |
+
}, 3000);
|
| 1001 |
+
}
|
| 1002 |
+
</script>
|
| 1003 |
+
</body>
|
| 1004 |
+
</html>
|
_archive/templates/token_verification.html
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>访问验证 - 教育AI助手平台</title>
|
| 7 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css">
|
| 8 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
|
| 9 |
+
<style>
|
| 10 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
| 11 |
+
|
| 12 |
+
:root {
|
| 13 |
+
/* 优雅的配色方案 */
|
| 14 |
+
--primary-color: #0f2d49;
|
| 15 |
+
--primary-light: #234a70;
|
| 16 |
+
--secondary-color: #4a6cfd;
|
| 17 |
+
--secondary-light: #7b91ff;
|
| 18 |
+
--tertiary-color: #f7f9fe;
|
| 19 |
+
--success-color: #10b981;
|
| 20 |
+
--success-light: rgba(16, 185, 129, 0.1);
|
| 21 |
+
--warning-color: #f59e0b;
|
| 22 |
+
--warning-light: rgba(245, 158, 11, 0.1);
|
| 23 |
+
--info-color: #0ea5e9;
|
| 24 |
+
--info-light: rgba(14, 165, 233, 0.1);
|
| 25 |
+
--danger-color: #ef4444;
|
| 26 |
+
--danger-light: rgba(239, 68, 68, 0.1);
|
| 27 |
+
--neutral-50: #f9fafb;
|
| 28 |
+
--neutral-100: #f3f4f6;
|
| 29 |
+
--neutral-200: #e5e7eb;
|
| 30 |
+
--neutral-300: #d1d5db;
|
| 31 |
+
--neutral-400: #9ca3af;
|
| 32 |
+
--neutral-500: #6b7280;
|
| 33 |
+
--neutral-600: #4b5563;
|
| 34 |
+
--neutral-700: #374151;
|
| 35 |
+
--neutral-800: #1f2937;
|
| 36 |
+
--neutral-900: #111827;
|
| 37 |
+
|
| 38 |
+
/* 样式变量 */
|
| 39 |
+
--border-radius-sm: 0.25rem;
|
| 40 |
+
--border-radius: 0.375rem;
|
| 41 |
+
--border-radius-lg: 0.5rem;
|
| 42 |
+
--border-radius-xl: 0.75rem;
|
| 43 |
+
--border-radius-2xl: 1rem;
|
| 44 |
+
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.1);
|
| 45 |
+
--card-shadow-hover: 0 10px 20px rgba(0, 0, 0, 0.05), 0 6px 6px rgba(0, 0, 0, 0.1);
|
| 46 |
+
--card-shadow-lg: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
| 47 |
+
--transition-base: all 0.2s ease-in-out;
|
| 48 |
+
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 49 |
+
--font-family: 'Inter', 'PingFang SC', 'Helvetica Neue', 'Microsoft YaHei', sans-serif;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/* 基础样式 */
|
| 53 |
+
body {
|
| 54 |
+
font-family: var(--font-family);
|
| 55 |
+
background-color: var(--neutral-50);
|
| 56 |
+
color: var(--neutral-800);
|
| 57 |
+
margin: 0;
|
| 58 |
+
padding: 0;
|
| 59 |
+
min-height: 100vh;
|
| 60 |
+
display: flex;
|
| 61 |
+
align-items: center;
|
| 62 |
+
justify-content: center;
|
| 63 |
+
-webkit-font-smoothing: antialiased;
|
| 64 |
+
-moz-osx-font-smoothing: grayscale;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
h1, h2, h3, h4, h5, h6 {
|
| 68 |
+
font-weight: 600;
|
| 69 |
+
color: var(--neutral-900);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.text-gradient {
|
| 73 |
+
background: linear-gradient(135deg, var(--secondary-color), var(--secondary-light));
|
| 74 |
+
-webkit-background-clip: text;
|
| 75 |
+
-webkit-text-fill-color: transparent;
|
| 76 |
+
background-clip: text;
|
| 77 |
+
color: transparent;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/* 验证容器样式 */
|
| 81 |
+
.verification-container {
|
| 82 |
+
width: 100%;
|
| 83 |
+
max-width: 500px;
|
| 84 |
+
padding: 2.5rem;
|
| 85 |
+
background-color: white;
|
| 86 |
+
border-radius: var(--border-radius-xl);
|
| 87 |
+
box-shadow: var(--card-shadow-lg);
|
| 88 |
+
transition: var(--transition-smooth);
|
| 89 |
+
position: relative;
|
| 90 |
+
overflow: hidden;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.verification-container:hover {
|
| 94 |
+
box-shadow: var(--card-shadow-hover);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.verification-container::before {
|
| 98 |
+
content: '';
|
| 99 |
+
position: absolute;
|
| 100 |
+
top: 0;
|
| 101 |
+
left: 0;
|
| 102 |
+
width: 100%;
|
| 103 |
+
height: 4px;
|
| 104 |
+
background: linear-gradient(to right, var(--secondary-color), var(--secondary-light));
|
| 105 |
+
border-radius: 4px 4px 0 0;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.verification-header {
|
| 109 |
+
text-align: center;
|
| 110 |
+
margin-bottom: 2rem;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.verification-header h1 {
|
| 114 |
+
font-size: 1.75rem;
|
| 115 |
+
font-weight: 700;
|
| 116 |
+
margin-bottom: 0.5rem;
|
| 117 |
+
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
|
| 118 |
+
-webkit-background-clip: text;
|
| 119 |
+
-webkit-text-fill-color: transparent;
|
| 120 |
+
letter-spacing: -0.01em;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.verification-header p {
|
| 124 |
+
color: var(--neutral-600);
|
| 125 |
+
margin-bottom: 0;
|
| 126 |
+
font-size: 0.95rem;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/* 加载状态 */
|
| 130 |
+
.loading-container {
|
| 131 |
+
text-align: center;
|
| 132 |
+
margin-bottom: 1.5rem;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.spinner-border {
|
| 136 |
+
width: 3rem;
|
| 137 |
+
height: 3rem;
|
| 138 |
+
color: var(--secondary-color);
|
| 139 |
+
margin-bottom: 1.5rem;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.loading-container h2 {
|
| 143 |
+
margin-bottom: 0.75rem;
|
| 144 |
+
font-size: 1.4rem;
|
| 145 |
+
color: var(--primary-color);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.loading-container p {
|
| 149 |
+
color: var(--neutral-600);
|
| 150 |
+
font-size: 0.95rem;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/* Agent信息卡片 */
|
| 154 |
+
.agent-info {
|
| 155 |
+
padding: 1.5rem;
|
| 156 |
+
background-color: var(--neutral-50);
|
| 157 |
+
border-radius: var(--border-radius-lg);
|
| 158 |
+
margin-bottom: 1.75rem;
|
| 159 |
+
border: 1px solid var(--neutral-200);
|
| 160 |
+
position: relative;
|
| 161 |
+
overflow: hidden;
|
| 162 |
+
transition: var(--transition-base);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.agent-info:hover {
|
| 166 |
+
border-color: var(--secondary-color);
|
| 167 |
+
box-shadow: 0 4px 10px rgba(74, 108, 253, 0.1);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.agent-info::before {
|
| 171 |
+
content: '';
|
| 172 |
+
position: absolute;
|
| 173 |
+
top: 0;
|
| 174 |
+
bottom: 0;
|
| 175 |
+
left: 0;
|
| 176 |
+
width: 4px;
|
| 177 |
+
background: linear-gradient(to bottom, var(--secondary-color), var(--secondary-light));
|
| 178 |
+
border-radius: 4px 0 0 4px;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.agent-title {
|
| 182 |
+
font-size: 1.25rem;
|
| 183 |
+
font-weight: 600;
|
| 184 |
+
margin-bottom: 0.75rem;
|
| 185 |
+
color: var(--primary-color);
|
| 186 |
+
display: flex;
|
| 187 |
+
align-items: center;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.agent-title i {
|
| 191 |
+
margin-right: 0.75rem;
|
| 192 |
+
font-size: 1.1rem;
|
| 193 |
+
color: var(--secondary-color);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.agent-description {
|
| 197 |
+
color: var(--neutral-700);
|
| 198 |
+
margin-bottom: 1rem;
|
| 199 |
+
font-size: 0.95rem;
|
| 200 |
+
line-height: 1.5;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.agent-meta {
|
| 204 |
+
display: flex;
|
| 205 |
+
flex-wrap: wrap;
|
| 206 |
+
gap: 1rem;
|
| 207 |
+
font-size: 0.9rem;
|
| 208 |
+
color: var(--neutral-600);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.agent-meta-item {
|
| 212 |
+
display: flex;
|
| 213 |
+
align-items: center;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.agent-meta-item i {
|
| 217 |
+
margin-right: 0.5rem;
|
| 218 |
+
font-size: 0.95rem;
|
| 219 |
+
color: var(--secondary-color);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
/* 验证操作按钮 */
|
| 223 |
+
.verification-actions {
|
| 224 |
+
margin-bottom: 1.5rem;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.btn {
|
| 228 |
+
font-weight: 500;
|
| 229 |
+
padding: 0.75rem 1.25rem;
|
| 230 |
+
border-radius: var(--border-radius-lg);
|
| 231 |
+
transition: var(--transition-base);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.btn-primary {
|
| 235 |
+
background: linear-gradient(to right, var(--secondary-color), var(--secondary-light));
|
| 236 |
+
border: none;
|
| 237 |
+
color: white;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.btn-primary:hover, .btn-primary:focus {
|
| 241 |
+
background: linear-gradient(to right, var(--secondary-light), var(--secondary-color));
|
| 242 |
+
box-shadow: 0 4px 10px rgba(74, 108, 253, 0.3);
|
| 243 |
+
transform: translateY(-2px);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.btn-outline-secondary {
|
| 247 |
+
color: var(--neutral-700);
|
| 248 |
+
border-color: var(--neutral-300);
|
| 249 |
+
background-color: white;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.btn-outline-secondary:hover, .btn-outline-secondary:focus {
|
| 253 |
+
background-color: var(--neutral-100);
|
| 254 |
+
border-color: var(--neutral-400);
|
| 255 |
+
color: var(--neutral-900);
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
/* 过期/错误状态 */
|
| 259 |
+
.expired-container {
|
| 260 |
+
text-align: center;
|
| 261 |
+
color: var(--danger-color);
|
| 262 |
+
margin-bottom: 1.5rem;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.expired-container i {
|
| 266 |
+
font-size: 3rem;
|
| 267 |
+
margin-bottom: 1rem;
|
| 268 |
+
display: block;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.expired-container h2 {
|
| 272 |
+
margin-bottom: 0.75rem;
|
| 273 |
+
font-size: 1.5rem;
|
| 274 |
+
color: var(--danger-color);
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.expired-container p {
|
| 278 |
+
color: var(--neutral-700);
|
| 279 |
+
margin-bottom: 1.5rem;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
/* 页脚样式 */
|
| 283 |
+
.verification-footer {
|
| 284 |
+
text-align: center;
|
| 285 |
+
margin-top: 2rem;
|
| 286 |
+
color: var(--neutral-500);
|
| 287 |
+
font-size: 0.85rem;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.verification-footer a {
|
| 291 |
+
color: var(--secondary-color);
|
| 292 |
+
text-decoration: none;
|
| 293 |
+
transition: var(--transition-base);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.verification-footer a:hover {
|
| 297 |
+
text-decoration: underline;
|
| 298 |
+
color: var(--secondary-light);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
/* 动画效果 */
|
| 302 |
+
@keyframes fadeIn {
|
| 303 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 304 |
+
to { opacity: 1; transform: translateY(0); }
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.fade-in {
|
| 308 |
+
opacity: 0;
|
| 309 |
+
animation: fadeIn 0.4s ease-out forwards;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
/* 响应式样式 */
|
| 313 |
+
@media (max-width: 576px) {
|
| 314 |
+
.verification-container {
|
| 315 |
+
padding: 1.5rem;
|
| 316 |
+
margin: 0 1rem;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.verification-header h1 {
|
| 320 |
+
font-size: 1.5rem;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.agent-title {
|
| 324 |
+
font-size: 1.15rem;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
.agent-meta {
|
| 328 |
+
flex-direction: column;
|
| 329 |
+
gap: 0.5rem;
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
</style>
|
| 333 |
+
</head>
|
| 334 |
+
<body>
|
| 335 |
+
<div class="verification-container fade-in" id="main-container">
|
| 336 |
+
<div class="loading-container" id="loading-container">
|
| 337 |
+
<div class="spinner-border" role="status">
|
| 338 |
+
<span class="visually-hidden">验证中...</span>
|
| 339 |
+
</div>
|
| 340 |
+
<h2>正在验证访问权限</h2>
|
| 341 |
+
<p class="text-muted">请稍候,我们正在验证您的访问令牌...</p>
|
| 342 |
+
</div>
|
| 343 |
+
|
| 344 |
+
<div class="verification-header" id="verification-header" style="display: none;">
|
| 345 |
+
<h1>访问 AI 助手</h1>
|
| 346 |
+
<p>您正在使用访问令牌访问以下 AI 助手</p>
|
| 347 |
+
</div>
|
| 348 |
+
|
| 349 |
+
<div class="agent-info" id="agent-info" style="display: none;">
|
| 350 |
+
<!-- 将由JavaScript填充 -->
|
| 351 |
+
</div>
|
| 352 |
+
|
| 353 |
+
<div class="verification-actions" id="verification-actions" style="display: none;">
|
| 354 |
+
<div class="row g-3">
|
| 355 |
+
<div class="col-12">
|
| 356 |
+
<button class="btn btn-primary w-100" id="access-btn">
|
| 357 |
+
<i class="bi bi-robot me-2"></i>开始对话
|
| 358 |
+
</button>
|
| 359 |
+
</div>
|
| 360 |
+
<div class="col-12">
|
| 361 |
+
<button class="btn btn-outline-secondary w-100" id="back-btn">
|
| 362 |
+
<i class="bi bi-arrow-left me-2"></i>返回
|
| 363 |
+
</button>
|
| 364 |
+
</div>
|
| 365 |
+
</div>
|
| 366 |
+
</div>
|
| 367 |
+
|
| 368 |
+
<div class="expired-container" id="expired-container" style="display: none;">
|
| 369 |
+
<i class="bi bi-exclamation-triangle"></i>
|
| 370 |
+
<h2>访问令牌无效</h2>
|
| 371 |
+
<p>您使用的访问令牌无效或已过期,请联系教师获取新的访问令牌。</p>
|
| 372 |
+
<button class="btn btn-outline-secondary mt-3" id="back-btn-expired">
|
| 373 |
+
<i class="bi bi-arrow-left me-2"></i>返回
|
| 374 |
+
</button>
|
| 375 |
+
</div>
|
| 376 |
+
|
| 377 |
+
<div class="verification-footer" id="verification-footer" style="display: none;">
|
| 378 |
+
<p>教育 AI 助手平台 | <a href="/login.html">登录</a></p>
|
| 379 |
+
</div>
|
| 380 |
+
</div>
|
| 381 |
+
|
| 382 |
+
<script>
|
| 383 |
+
// 解析URL参数
|
| 384 |
+
function getUrlParams() {
|
| 385 |
+
const queryString = window.location.search;
|
| 386 |
+
const urlParams = new URLSearchParams(queryString);
|
| 387 |
+
const pathParts = window.location.pathname.split('/');
|
| 388 |
+
|
| 389 |
+
return {
|
| 390 |
+
agentId: pathParts[pathParts.length - 1] || '',
|
| 391 |
+
token: urlParams.get('token') || ''
|
| 392 |
+
};
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
// 页面加载函数
|
| 396 |
+
document.addEventListener('DOMContentLoaded', async function() {
|
| 397 |
+
const { agentId, token } = getUrlParams();
|
| 398 |
+
|
| 399 |
+
if (!token) {
|
| 400 |
+
showExpiredState('未提供访问令牌');
|
| 401 |
+
return;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
try {
|
| 405 |
+
// 验证令牌
|
| 406 |
+
const response = await fetch('/api/verify_token', {
|
| 407 |
+
method: 'POST',
|
| 408 |
+
headers: {
|
| 409 |
+
'Content-Type': 'application/json'
|
| 410 |
+
},
|
| 411 |
+
body: JSON.stringify({
|
| 412 |
+
token: token,
|
| 413 |
+
agent_id: agentId
|
| 414 |
+
})
|
| 415 |
+
});
|
| 416 |
+
|
| 417 |
+
const result = await response.json();
|
| 418 |
+
|
| 419 |
+
if (result.success) {
|
| 420 |
+
// 显示Agent信息
|
| 421 |
+
showAgentInfo(result.agent);
|
| 422 |
+
} else {
|
| 423 |
+
showExpiredState(result.message);
|
| 424 |
+
}
|
| 425 |
+
} catch (error) {
|
| 426 |
+
console.error('验证出错:', error);
|
| 427 |
+
showExpiredState('验证过程中出错');
|
| 428 |
+
}
|
| 429 |
+
});
|
| 430 |
+
|
| 431 |
+
// 显示Agent信息
|
| 432 |
+
function showAgentInfo(agent) {
|
| 433 |
+
// 隐藏加载区域
|
| 434 |
+
document.getElementById('loading-container').style.display = 'none';
|
| 435 |
+
|
| 436 |
+
// 显示验证内容
|
| 437 |
+
document.getElementById('verification-header').style.display = 'block';
|
| 438 |
+
document.getElementById('agent-info').style.display = 'block';
|
| 439 |
+
document.getElementById('verification-actions').style.display = 'block';
|
| 440 |
+
document.getElementById('verification-footer').style.display = 'block';
|
| 441 |
+
|
| 442 |
+
// 填充Agent信息
|
| 443 |
+
const agentInfoElement = document.getElementById('agent-info');
|
| 444 |
+
|
| 445 |
+
// 构建主题和教师信息
|
| 446 |
+
let metaHtml = '<div class="agent-meta">';
|
| 447 |
+
|
| 448 |
+
if (agent.subject) {
|
| 449 |
+
metaHtml += `
|
| 450 |
+
<div class="agent-meta-item">
|
| 451 |
+
<i class="bi bi-book"></i>
|
| 452 |
+
${agent.subject}
|
| 453 |
+
</div>
|
| 454 |
+
`;
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
if (agent.instructor) {
|
| 458 |
+
metaHtml += `
|
| 459 |
+
<div class="agent-meta-item">
|
| 460 |
+
<i class="bi bi-person"></i>
|
| 461 |
+
${agent.instructor}
|
| 462 |
+
</div>
|
| 463 |
+
`;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
metaHtml += '</div>';
|
| 467 |
+
|
| 468 |
+
agentInfoElement.innerHTML = `
|
| 469 |
+
<div class="agent-title">
|
| 470 |
+
<i class="bi bi-robot"></i>${agent.name}
|
| 471 |
+
</div>
|
| 472 |
+
<div class="agent-description">
|
| 473 |
+
${agent.description || '暂无描述'}
|
| 474 |
+
</div>
|
| 475 |
+
${metaHtml}
|
| 476 |
+
`;
|
| 477 |
+
|
| 478 |
+
// 设置按钮链接
|
| 479 |
+
document.getElementById('access-btn').addEventListener('click', function() {
|
| 480 |
+
const { token } = getUrlParams();
|
| 481 |
+
window.location.href = `/student/${agent.id}?token=${token}`;
|
| 482 |
+
});
|
| 483 |
+
|
| 484 |
+
document.getElementById('back-btn').addEventListener('click', function() {
|
| 485 |
+
// 检查是否从学生门户进入
|
| 486 |
+
const hasHistory = document.referrer.includes('student_portal.html');
|
| 487 |
+
if (hasHistory) {
|
| 488 |
+
window.history.back();
|
| 489 |
+
} else {
|
| 490 |
+
window.location.href = '/student_portal.html';
|
| 491 |
+
}
|
| 492 |
+
});
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
// 显示过期状态
|
| 496 |
+
function showExpiredState(message) {
|
| 497 |
+
// 隐藏加载区域
|
| 498 |
+
document.getElementById('loading-container').style.display = 'none';
|
| 499 |
+
|
| 500 |
+
// 显示过期内容
|
| 501 |
+
document.getElementById('expired-container').style.display = 'block';
|
| 502 |
+
document.getElementById('verification-footer').style.display = 'block';
|
| 503 |
+
|
| 504 |
+
// 更新过期消息
|
| 505 |
+
const expiredContainer = document.getElementById('expired-container');
|
| 506 |
+
expiredContainer.querySelector('p').textContent = message || '您使用的访问令牌无效或已过期,请联系教师获取新的访问令牌。';
|
| 507 |
+
|
| 508 |
+
// 设置返回按钮
|
| 509 |
+
document.getElementById('back-btn-expired').addEventListener('click', function() {
|
| 510 |
+
// 检查是否从学生门户进入
|
| 511 |
+
const hasHistory = document.referrer.includes('student_portal.html');
|
| 512 |
+
if (hasHistory) {
|
| 513 |
+
window.history.back();
|
| 514 |
+
} else {
|
| 515 |
+
window.location.href = '/login.html';
|
| 516 |
+
}
|
| 517 |
+
});
|
| 518 |
+
}
|
| 519 |
+
</script>
|
| 520 |
+
</body>
|
| 521 |
+
</html>
|
app.py
ADDED
|
@@ -0,0 +1,987 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, request, jsonify, send_from_directory, redirect, url_for, session
|
| 2 |
+
from student_management import register_student_routes
|
| 3 |
+
from flask_cors import CORS
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
import traceback
|
| 7 |
+
import json
|
| 8 |
+
import re
|
| 9 |
+
import sys
|
| 10 |
+
import io
|
| 11 |
+
import threading
|
| 12 |
+
import queue
|
| 13 |
+
import contextlib
|
| 14 |
+
import signal
|
| 15 |
+
import psutil
|
| 16 |
+
from dotenv import load_dotenv
|
| 17 |
+
|
| 18 |
+
# 导入模块路由
|
| 19 |
+
from modules.knowledge_base.routes import knowledge_bp
|
| 20 |
+
from modules.code_executor.routes import code_executor_bp
|
| 21 |
+
from modules.visualization.routes import visualization_bp
|
| 22 |
+
from modules.agent_builder.routes import agent_builder_bp
|
| 23 |
+
from modules.auth.routes import auth_bp
|
| 24 |
+
|
| 25 |
+
# 加载环境变量
|
| 26 |
+
load_dotenv()
|
| 27 |
+
|
| 28 |
+
app = Flask(__name__)
|
| 29 |
+
CORS(app)
|
| 30 |
+
|
| 31 |
+
# 设置session密钥
|
| 32 |
+
app.secret_key = os.getenv("SECRET_KEY", "your_secret_key_here")
|
| 33 |
+
|
| 34 |
+
# 注册蓝图
|
| 35 |
+
app.register_blueprint(knowledge_bp, url_prefix='/api/knowledge')
|
| 36 |
+
app.register_blueprint(code_executor_bp, url_prefix='/api/code')
|
| 37 |
+
app.register_blueprint(visualization_bp, url_prefix='/api/visualization')
|
| 38 |
+
app.register_blueprint(agent_builder_bp, url_prefix='/api/agent')
|
| 39 |
+
app.register_blueprint(auth_bp, url_prefix='/api/auth')
|
| 40 |
+
|
| 41 |
+
# 确保目录存在
|
| 42 |
+
os.makedirs('static', exist_ok=True)
|
| 43 |
+
os.makedirs('uploads', exist_ok=True)
|
| 44 |
+
os.makedirs('agents', exist_ok=True)
|
| 45 |
+
|
| 46 |
+
# 用于代码执行的上下文
|
| 47 |
+
execution_contexts = {}
|
| 48 |
+
|
| 49 |
+
# 硬编码用户(仅用于演示)
|
| 50 |
+
users = {
|
| 51 |
+
"teachers": [
|
| 52 |
+
{"username": "teacher", "password": "123456", "name": "李志刚"},
|
| 53 |
+
{"username": "admin", "password": "admin123", "name": "管理员"}
|
| 54 |
+
],
|
| 55 |
+
"students": [
|
| 56 |
+
{"username": "student1", "password": "123456", "name": "张三"},
|
| 57 |
+
{"username": "student2", "password": "123456", "name": "李四"}
|
| 58 |
+
]
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
# 学生活动记录存储(实际应用中应使用数据库)
|
| 62 |
+
student_activities = {}
|
| 63 |
+
|
| 64 |
+
def get_memory_usage():
|
| 65 |
+
"""获取当前进程的内存使用情况"""
|
| 66 |
+
process = psutil.Process(os.getpid())
|
| 67 |
+
return f"{process.memory_info().rss / 1024 / 1024:.1f} MB"
|
| 68 |
+
|
| 69 |
+
class CustomStdin:
|
| 70 |
+
def __init__(self, input_queue):
|
| 71 |
+
self.input_queue = input_queue
|
| 72 |
+
self.buffer = ""
|
| 73 |
+
|
| 74 |
+
def readline(self):
|
| 75 |
+
if not self.buffer:
|
| 76 |
+
self.buffer = self.input_queue.get() + "\n"
|
| 77 |
+
|
| 78 |
+
result = self.buffer
|
| 79 |
+
self.buffer = ""
|
| 80 |
+
return result
|
| 81 |
+
|
| 82 |
+
class InteractiveExecution:
|
| 83 |
+
"""管理Python代码的交互式执行"""
|
| 84 |
+
def __init__(self, code):
|
| 85 |
+
self.code = code
|
| 86 |
+
self.context_id = str(time.time())
|
| 87 |
+
self.is_complete = False
|
| 88 |
+
self.is_waiting_for_input = False
|
| 89 |
+
self.stdout_buffer = io.StringIO()
|
| 90 |
+
self.last_read_position = 0
|
| 91 |
+
self.input_queue = queue.Queue()
|
| 92 |
+
self.error = None
|
| 93 |
+
self.thread = None
|
| 94 |
+
self.should_terminate = False
|
| 95 |
+
|
| 96 |
+
def run(self):
|
| 97 |
+
"""在单独的线程中启动执行"""
|
| 98 |
+
self.thread = threading.Thread(target=self._execute)
|
| 99 |
+
self.thread.daemon = True
|
| 100 |
+
self.thread.start()
|
| 101 |
+
|
| 102 |
+
# 给执行一点时间开始
|
| 103 |
+
time.sleep(0.1)
|
| 104 |
+
return self.context_id
|
| 105 |
+
|
| 106 |
+
def _execute(self):
|
| 107 |
+
"""执行代码,处理标准输入输出"""
|
| 108 |
+
try:
|
| 109 |
+
# 保存原始的stdin/stdout
|
| 110 |
+
orig_stdin = sys.stdin
|
| 111 |
+
orig_stdout = sys.stdout
|
| 112 |
+
|
| 113 |
+
# 创建自定义stdin
|
| 114 |
+
custom_stdin = CustomStdin(self.input_queue)
|
| 115 |
+
|
| 116 |
+
# 重定向stdin和stdout
|
| 117 |
+
sys.stdin = custom_stdin
|
| 118 |
+
sys.stdout = self.stdout_buffer
|
| 119 |
+
|
| 120 |
+
try:
|
| 121 |
+
# 检查终止的函数
|
| 122 |
+
self._last_check_time = 0
|
| 123 |
+
|
| 124 |
+
def check_termination():
|
| 125 |
+
if self.should_terminate:
|
| 126 |
+
raise KeyboardInterrupt("Execution terminated by user")
|
| 127 |
+
|
| 128 |
+
# 设置一个模拟__main__模块的命名空间
|
| 129 |
+
shared_namespace = {
|
| 130 |
+
"__builtins__": __builtins__,
|
| 131 |
+
"_check_termination": check_termination,
|
| 132 |
+
"time": time,
|
| 133 |
+
"__name__": "__main__"
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
# 在这个命名空间中执行用户代码
|
| 137 |
+
try:
|
| 138 |
+
exec(self.code, shared_namespace)
|
| 139 |
+
except KeyboardInterrupt:
|
| 140 |
+
print("\nExecution terminated by user")
|
| 141 |
+
|
| 142 |
+
except Exception as e:
|
| 143 |
+
self.error = {
|
| 144 |
+
"error": str(e),
|
| 145 |
+
"traceback": traceback.format_exc()
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
finally:
|
| 149 |
+
# 恢复原始stdin/stdout
|
| 150 |
+
sys.stdin = orig_stdin
|
| 151 |
+
sys.stdout = orig_stdout
|
| 152 |
+
|
| 153 |
+
# 标记执行完成
|
| 154 |
+
self.is_complete = True
|
| 155 |
+
|
| 156 |
+
except Exception as e:
|
| 157 |
+
self.error = {
|
| 158 |
+
"error": str(e),
|
| 159 |
+
"traceback": traceback.format_exc()
|
| 160 |
+
}
|
| 161 |
+
self.is_complete = True
|
| 162 |
+
|
| 163 |
+
def terminate(self):
|
| 164 |
+
"""终止执行"""
|
| 165 |
+
self.should_terminate = True
|
| 166 |
+
|
| 167 |
+
# 如果在等待输入,放入一些内容以解除阻塞
|
| 168 |
+
if self.is_waiting_for_input:
|
| 169 |
+
self.input_queue.put("\n")
|
| 170 |
+
|
| 171 |
+
# 给执行一点时间终止
|
| 172 |
+
time.sleep(0.2)
|
| 173 |
+
|
| 174 |
+
# 标记为完成
|
| 175 |
+
self.is_complete = True
|
| 176 |
+
|
| 177 |
+
return True
|
| 178 |
+
|
| 179 |
+
def provide_input(self, user_input):
|
| 180 |
+
"""为运行的代码提供输入"""
|
| 181 |
+
self.input_queue.put(user_input)
|
| 182 |
+
self.is_waiting_for_input = False
|
| 183 |
+
return True
|
| 184 |
+
|
| 185 |
+
def get_output(self):
|
| 186 |
+
"""获取stdout缓冲区的当前内容"""
|
| 187 |
+
output = self.stdout_buffer.getvalue()
|
| 188 |
+
return output
|
| 189 |
+
|
| 190 |
+
def get_new_output(self):
|
| 191 |
+
"""只获取自上次读取以来的新输出"""
|
| 192 |
+
current_value = self.stdout_buffer.getvalue()
|
| 193 |
+
if self.last_read_position < len(current_value):
|
| 194 |
+
new_output = current_value[self.last_read_position:]
|
| 195 |
+
self.last_read_position = len(current_value)
|
| 196 |
+
return new_output
|
| 197 |
+
return ""
|
| 198 |
+
|
| 199 |
+
# 记录活动函数(可在各个操作点调用)
|
| 200 |
+
def record_student_activity(username, activity_type, title, agent_id=None, agent_name=None):
|
| 201 |
+
"""记录学生活动"""
|
| 202 |
+
if username not in student_activities:
|
| 203 |
+
student_activities[username] = []
|
| 204 |
+
|
| 205 |
+
# 创建活动记录
|
| 206 |
+
activity = {
|
| 207 |
+
"type": activity_type, # 'chat', 'code', 'viz', 'mindmap'
|
| 208 |
+
"title": title,
|
| 209 |
+
"timestamp": int(time.time()),
|
| 210 |
+
"agent_id": agent_id,
|
| 211 |
+
"agent_name": agent_name
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
# 添加到用户活动列表(最多保存20条记录)
|
| 215 |
+
student_activities[username].insert(0, activity)
|
| 216 |
+
if len(student_activities[username]) > 20:
|
| 217 |
+
student_activities[username] = student_activities[username][:20]
|
| 218 |
+
|
| 219 |
+
return activity
|
| 220 |
+
|
| 221 |
+
# 登录相关路由
|
| 222 |
+
@app.route('/login.html')
|
| 223 |
+
def login_page():
|
| 224 |
+
"""登录页面"""
|
| 225 |
+
return render_template('login.html')
|
| 226 |
+
|
| 227 |
+
@app.route('/api/auth/login', methods=['POST'])
|
| 228 |
+
def login():
|
| 229 |
+
"""处理登录请求"""
|
| 230 |
+
data = request.json
|
| 231 |
+
username = data.get('username')
|
| 232 |
+
password = data.get('password')
|
| 233 |
+
user_type = data.get('type', 'teacher') # 默认为教师
|
| 234 |
+
|
| 235 |
+
if user_type == 'teacher':
|
| 236 |
+
user_list = users['teachers']
|
| 237 |
+
else:
|
| 238 |
+
user_list = users['students']
|
| 239 |
+
|
| 240 |
+
for user in user_list:
|
| 241 |
+
if user['username'] == username and user['password'] == password:
|
| 242 |
+
# 设置session
|
| 243 |
+
session['logged_in'] = True
|
| 244 |
+
session['username'] = username
|
| 245 |
+
session['user_type'] = user_type
|
| 246 |
+
session['user_name'] = user['name']
|
| 247 |
+
|
| 248 |
+
return jsonify({
|
| 249 |
+
'success': True,
|
| 250 |
+
'user': {
|
| 251 |
+
'name': user['name'],
|
| 252 |
+
'type': user_type
|
| 253 |
+
}
|
| 254 |
+
})
|
| 255 |
+
|
| 256 |
+
return jsonify({
|
| 257 |
+
'success': False,
|
| 258 |
+
'message': '用户名或密码错误'
|
| 259 |
+
}), 401
|
| 260 |
+
|
| 261 |
+
@app.route('/api/auth/logout', methods=['POST'])
|
| 262 |
+
def logout():
|
| 263 |
+
"""处理登出请求"""
|
| 264 |
+
session.clear()
|
| 265 |
+
return jsonify({
|
| 266 |
+
'success': True
|
| 267 |
+
})
|
| 268 |
+
|
| 269 |
+
@app.route('/api/auth/check', methods=['GET'])
|
| 270 |
+
def check_auth():
|
| 271 |
+
"""检查用户是否已登录"""
|
| 272 |
+
if session.get('logged_in'):
|
| 273 |
+
return jsonify({
|
| 274 |
+
'success': True,
|
| 275 |
+
'user': {
|
| 276 |
+
'name': session.get('user_name'),
|
| 277 |
+
'type': session.get('user_type')
|
| 278 |
+
}
|
| 279 |
+
})
|
| 280 |
+
|
| 281 |
+
return jsonify({
|
| 282 |
+
'success': False
|
| 283 |
+
}), 401
|
| 284 |
+
|
| 285 |
+
# 登录验证装饰器
|
| 286 |
+
def login_required(f):
|
| 287 |
+
def decorated_function(*args, **kwargs):
|
| 288 |
+
if not session.get('logged_in'):
|
| 289 |
+
return redirect(url_for('login_page'))
|
| 290 |
+
return f(*args, **kwargs)
|
| 291 |
+
decorated_function.__name__ = f.__name__
|
| 292 |
+
return decorated_function
|
| 293 |
+
|
| 294 |
+
# 教师角色验证装饰器
|
| 295 |
+
def teacher_required(f):
|
| 296 |
+
def decorated_function(*args, **kwargs):
|
| 297 |
+
if not session.get('logged_in') or session.get('user_type') != 'teacher':
|
| 298 |
+
return jsonify({
|
| 299 |
+
'success': False,
|
| 300 |
+
'message': '需要教师权限'
|
| 301 |
+
}), 403
|
| 302 |
+
return f(*args, **kwargs)
|
| 303 |
+
decorated_function.__name__ = f.__name__
|
| 304 |
+
return decorated_function
|
| 305 |
+
|
| 306 |
+
# 学生角色验证装饰器
|
| 307 |
+
def student_required(f):
|
| 308 |
+
def decorated_function(*args, **kwargs):
|
| 309 |
+
if not session.get('logged_in') or session.get('user_type') != 'student':
|
| 310 |
+
return jsonify({
|
| 311 |
+
'success': False,
|
| 312 |
+
'message': '需要学生权限'
|
| 313 |
+
}), 403
|
| 314 |
+
return f(*args, **kwargs)
|
| 315 |
+
decorated_function.__name__ = f.__name__
|
| 316 |
+
return decorated_function
|
| 317 |
+
|
| 318 |
+
# React 前端静态文件目录
|
| 319 |
+
FRONTEND_DIST = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'dist')
|
| 320 |
+
|
| 321 |
+
# 首页路由 - 托管 React 前端
|
| 322 |
+
@app.route('/')
|
| 323 |
+
def root():
|
| 324 |
+
return send_from_directory(FRONTEND_DIST, 'index.html')
|
| 325 |
+
|
| 326 |
+
@app.route('/api/progress/<task_id>', methods=['GET'])
|
| 327 |
+
def get_progress(task_id):
|
| 328 |
+
"""获取文档处理进度"""
|
| 329 |
+
try:
|
| 330 |
+
# 从知识库模块访问处理任务
|
| 331 |
+
from modules.knowledge_base.routes import processing_tasks
|
| 332 |
+
|
| 333 |
+
progress_data = processing_tasks.get(task_id, {
|
| 334 |
+
'progress': 0,
|
| 335 |
+
'status': '未找到任务',
|
| 336 |
+
'error': True
|
| 337 |
+
})
|
| 338 |
+
|
| 339 |
+
return jsonify({"success": True, "data": progress_data})
|
| 340 |
+
except Exception as e:
|
| 341 |
+
traceback.print_exc()
|
| 342 |
+
return jsonify({"success": False, "message": str(e)}), 500
|
| 343 |
+
|
| 344 |
+
@app.route('/student/<agent_id>')
|
| 345 |
+
def student_view(agent_id):
|
| 346 |
+
"""学生访问Agent界面"""
|
| 347 |
+
token = request.args.get('token', '')
|
| 348 |
+
|
| 349 |
+
# 验证Agent存在
|
| 350 |
+
agent_path = os.path.join('agents', f"{agent_id}.json")
|
| 351 |
+
if not os.path.exists(agent_path):
|
| 352 |
+
return render_template('error.html',
|
| 353 |
+
message="找不到指定的Agent",
|
| 354 |
+
error_code=404)
|
| 355 |
+
|
| 356 |
+
# 加载Agent配置
|
| 357 |
+
with open(agent_path, 'r', encoding='utf-8') as f:
|
| 358 |
+
try:
|
| 359 |
+
agent_config = json.load(f)
|
| 360 |
+
except:
|
| 361 |
+
return render_template('error.html',
|
| 362 |
+
message="Agent配置无效",
|
| 363 |
+
error_code=500)
|
| 364 |
+
|
| 365 |
+
# 验证访问令牌
|
| 366 |
+
if token:
|
| 367 |
+
valid_token = False
|
| 368 |
+
if "distributions" in agent_config:
|
| 369 |
+
for dist in agent_config["distributions"]:
|
| 370 |
+
if dist.get("token") == token:
|
| 371 |
+
valid_token = True
|
| 372 |
+
break
|
| 373 |
+
|
| 374 |
+
if not valid_token:
|
| 375 |
+
return render_template('token_verification.html',
|
| 376 |
+
message="访问令牌无效",
|
| 377 |
+
error_code=403)
|
| 378 |
+
|
| 379 |
+
# 更新使用统计
|
| 380 |
+
if "distributions" in agent_config:
|
| 381 |
+
for dist in agent_config["distributions"]:
|
| 382 |
+
if dist.get("token") == token:
|
| 383 |
+
# 更新分发使用次数
|
| 384 |
+
dist["usage_count"] = dist.get("usage_count", 0) + 1
|
| 385 |
+
|
| 386 |
+
# 更新Agent使用统计
|
| 387 |
+
if "stats" not in agent_config:
|
| 388 |
+
agent_config["stats"] = {}
|
| 389 |
+
|
| 390 |
+
agent_config["stats"]["usage_count"] = agent_config["stats"].get("usage_count", 0) + 1
|
| 391 |
+
agent_config["stats"]["last_used"] = int(time.time())
|
| 392 |
+
|
| 393 |
+
# 保存更新后的Agent配置
|
| 394 |
+
with open(agent_path, 'w', encoding='utf-8') as f:
|
| 395 |
+
json.dump(agent_config, f, ensure_ascii=False, indent=2)
|
| 396 |
+
|
| 397 |
+
break
|
| 398 |
+
|
| 399 |
+
# 渲染学生页面
|
| 400 |
+
return render_template('student.html',
|
| 401 |
+
agent_id=agent_id,
|
| 402 |
+
agent_name=agent_config.get('name', 'AI学习助手'),
|
| 403 |
+
agent_description=agent_config.get('description', ''),
|
| 404 |
+
token=token)
|
| 405 |
+
|
| 406 |
+
@app.route('/api/student/chat/<agent_id>', methods=['POST'])
|
| 407 |
+
def student_chat(agent_id):
|
| 408 |
+
"""学生与Agent聊天的API"""
|
| 409 |
+
try:
|
| 410 |
+
data = request.json
|
| 411 |
+
message = data.get('message', '')
|
| 412 |
+
token = data.get('token', '')
|
| 413 |
+
|
| 414 |
+
if not message:
|
| 415 |
+
return jsonify({"success": False, "message": "消息不能为空"}), 400
|
| 416 |
+
|
| 417 |
+
# 验证Agent和令牌
|
| 418 |
+
agent_path = os.path.join('agents', f"{agent_id}.json")
|
| 419 |
+
if not os.path.exists(agent_path):
|
| 420 |
+
return jsonify({"success": False, "message": "Agent不存在"}), 404
|
| 421 |
+
|
| 422 |
+
with open(agent_path, 'r', encoding='utf-8') as f:
|
| 423 |
+
agent_config = json.load(f)
|
| 424 |
+
|
| 425 |
+
# 验证令牌(如果提供)
|
| 426 |
+
if token and "distributions" in agent_config:
|
| 427 |
+
valid_token = False
|
| 428 |
+
for dist in agent_config["distributions"]:
|
| 429 |
+
if dist.get("token") == token:
|
| 430 |
+
valid_token = True
|
| 431 |
+
|
| 432 |
+
# 更新使用计数
|
| 433 |
+
dist["usage_count"] = dist.get("usage_count", 0) + 1
|
| 434 |
+
break
|
| 435 |
+
|
| 436 |
+
if not valid_token:
|
| 437 |
+
return jsonify({"success": False, "message": "访问令牌无效"}), 403
|
| 438 |
+
|
| 439 |
+
# 更新Agent使用统计
|
| 440 |
+
if "stats" not in agent_config:
|
| 441 |
+
agent_config["stats"] = {}
|
| 442 |
+
|
| 443 |
+
agent_config["stats"]["usage_count"] = agent_config["stats"].get("usage_count", 0) + 1
|
| 444 |
+
agent_config["stats"]["last_used"] = int(time.time())
|
| 445 |
+
|
| 446 |
+
# 保存更新后的Agent配置
|
| 447 |
+
with open(agent_path, 'w', encoding='utf-8') as f:
|
| 448 |
+
json.dump(agent_config, f, ensure_ascii=False, indent=2)
|
| 449 |
+
|
| 450 |
+
# 获取Agent关联的知识库和插件
|
| 451 |
+
knowledge_bases = agent_config.get('knowledge_bases', [])
|
| 452 |
+
plugins = agent_config.get('plugins', [])
|
| 453 |
+
|
| 454 |
+
# 获取学科和指导者信息
|
| 455 |
+
subject = agent_config.get('subject', agent_config.get('name', '通用学科'))
|
| 456 |
+
instructor = agent_config.get('instructor', '教师')
|
| 457 |
+
|
| 458 |
+
# 创建Generator实例,传入学科和指导者信息
|
| 459 |
+
from modules.knowledge_base.generator import Generator
|
| 460 |
+
generator = Generator(subject=subject, instructor=instructor)
|
| 461 |
+
|
| 462 |
+
# 检测需要使用的插件
|
| 463 |
+
suggested_plugins = []
|
| 464 |
+
|
| 465 |
+
# 检测是否需要代码执行插件
|
| 466 |
+
if 'code' in plugins and ('代码' in message or 'python' in message.lower() or '编程' in message or 'code' in message.lower() or 'program' in message.lower()):
|
| 467 |
+
suggested_plugins.append('code')
|
| 468 |
+
|
| 469 |
+
# 检测是否需要3D可视化插件
|
| 470 |
+
if 'visualization' in plugins and ('3d' in message.lower() or '可视化' in message or '图形' in message):
|
| 471 |
+
suggested_plugins.append('visualization')
|
| 472 |
+
|
| 473 |
+
# 检测是否需要思维导图插件
|
| 474 |
+
if 'mindmap' in plugins and ('思维导图' in message or 'mindmap' in message.lower()):
|
| 475 |
+
suggested_plugins.append('mindmap')
|
| 476 |
+
|
| 477 |
+
# 记录活动(添加此部分代码)
|
| 478 |
+
if session.get('logged_in'):
|
| 479 |
+
username = session.get('username')
|
| 480 |
+
# 记录对话活动
|
| 481 |
+
record_student_activity(
|
| 482 |
+
username=username,
|
| 483 |
+
activity_type='chat',
|
| 484 |
+
title=f'与 {agent_config.get("name", "AI助手")} 进行了对话',
|
| 485 |
+
agent_id=agent_id,
|
| 486 |
+
agent_name=agent_config.get('name')
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
# 如果使用了插件,记录相应的插件活动
|
| 490 |
+
if 'code' in suggested_plugins:
|
| 491 |
+
record_student_activity(
|
| 492 |
+
username=username,
|
| 493 |
+
activity_type='code',
|
| 494 |
+
title='执行了Python代码',
|
| 495 |
+
agent_id=agent_id,
|
| 496 |
+
agent_name=agent_config.get('name')
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
if 'visualization' in suggested_plugins:
|
| 500 |
+
record_student_activity(
|
| 501 |
+
username=username,
|
| 502 |
+
activity_type='viz',
|
| 503 |
+
title='查看了3D可视化图形',
|
| 504 |
+
agent_id=agent_id,
|
| 505 |
+
agent_name=agent_config.get('name')
|
| 506 |
+
)
|
| 507 |
+
|
| 508 |
+
if 'mindmap' in suggested_plugins:
|
| 509 |
+
record_student_activity(
|
| 510 |
+
username=username,
|
| 511 |
+
activity_type='mindmap',
|
| 512 |
+
title='生成了思维导图',
|
| 513 |
+
agent_id=agent_id,
|
| 514 |
+
agent_name=agent_config.get('name')
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
# 检查是否有配置知识库
|
| 518 |
+
if not knowledge_bases:
|
| 519 |
+
# 没有知识库,直接使用模型进行回答
|
| 520 |
+
print(f"\n=== 处理查询: {message} (无知识库) ===")
|
| 521 |
+
|
| 522 |
+
# 创建SSE流式响应
|
| 523 |
+
def generate_sse():
|
| 524 |
+
try:
|
| 525 |
+
# 发送开始事件
|
| 526 |
+
yield f"data: {json.dumps({'type': 'start', 'plugins': suggested_plugins}, ensure_ascii=False)}\n\n"
|
| 527 |
+
|
| 528 |
+
# 流式生成回答
|
| 529 |
+
for chunk in generator.generate_stream(message, []):
|
| 530 |
+
if isinstance(chunk, dict):
|
| 531 |
+
yield f"data: {json.dumps({'type': 'metadata', 'content': chunk}, ensure_ascii=False)}\n\n"
|
| 532 |
+
else:
|
| 533 |
+
yield f"data: {json.dumps({'type': 'content', 'content': chunk}, ensure_ascii=False)}\n\n"
|
| 534 |
+
|
| 535 |
+
# 发送结束事件
|
| 536 |
+
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
|
| 537 |
+
except Exception as e:
|
| 538 |
+
yield f"data: {json.dumps({'type': 'error', 'content': str(e)}, ensure_ascii=False)}\n\n"
|
| 539 |
+
|
| 540 |
+
# 返回SSE响应
|
| 541 |
+
from flask import Response
|
| 542 |
+
return Response(
|
| 543 |
+
generate_sse(),
|
| 544 |
+
mimetype='text/event-stream',
|
| 545 |
+
headers={
|
| 546 |
+
'Cache-Control': 'no-cache',
|
| 547 |
+
'X-Accel-Buffering': 'no',
|
| 548 |
+
'Access-Control-Allow-Origin': '*'
|
| 549 |
+
}
|
| 550 |
+
)
|
| 551 |
+
|
| 552 |
+
# 有知识库配置,执行知识库查询流程
|
| 553 |
+
try:
|
| 554 |
+
# 导入RAG系统组件
|
| 555 |
+
from modules.knowledge_base.retriever import Retriever
|
| 556 |
+
from modules.knowledge_base.reranker import Reranker
|
| 557 |
+
|
| 558 |
+
retriever = Retriever()
|
| 559 |
+
reranker = Reranker()
|
| 560 |
+
|
| 561 |
+
# 构建工具定义 - 将所有知识库作为工具
|
| 562 |
+
tools = []
|
| 563 |
+
|
| 564 |
+
# 创建工具名称到索引的映射
|
| 565 |
+
tool_to_index = {}
|
| 566 |
+
|
| 567 |
+
for i, index in enumerate(knowledge_bases):
|
| 568 |
+
display_name = index[4:] if index.startswith('rag_') else index
|
| 569 |
+
|
| 570 |
+
# 判断是否是视频知识库
|
| 571 |
+
is_video = "视频" in display_name or "video" in display_name.lower()
|
| 572 |
+
|
| 573 |
+
# 根据内容类型生成适当的工具名称
|
| 574 |
+
if is_video:
|
| 575 |
+
tool_name = f"video_knowledge_base_{i+1}"
|
| 576 |
+
description = f"在'{display_name}'视频知识库中搜索,返回带时间戳的视频链接。适用于需要视频讲解的问题。"
|
| 577 |
+
else:
|
| 578 |
+
tool_name = f"knowledge_base_{i+1}"
|
| 579 |
+
description = f"在'{display_name}'知识库中搜索专业知识、概念和原理。适用于需要文本说明的问题。"
|
| 580 |
+
|
| 581 |
+
# 添加工具名到索引的映射
|
| 582 |
+
tool_to_index[tool_name] = index
|
| 583 |
+
|
| 584 |
+
tools.append({
|
| 585 |
+
"type": "function",
|
| 586 |
+
"function": {
|
| 587 |
+
"name": tool_name,
|
| 588 |
+
"description": description,
|
| 589 |
+
"parameters": {
|
| 590 |
+
"type": "object",
|
| 591 |
+
"properties": {
|
| 592 |
+
"keywords": {
|
| 593 |
+
"type": "array",
|
| 594 |
+
"items": {"type": "string"},
|
| 595 |
+
"description": "搜索的关键词列表"
|
| 596 |
+
}
|
| 597 |
+
},
|
| 598 |
+
"required": ["keywords"],
|
| 599 |
+
"additionalProperties": False
|
| 600 |
+
},
|
| 601 |
+
"strict": True
|
| 602 |
+
}
|
| 603 |
+
})
|
| 604 |
+
|
| 605 |
+
# 第一阶段:工具选择决策
|
| 606 |
+
print(f"\n=== 处理查询: {message} ===")
|
| 607 |
+
tool_calls = generator.extract_keywords_with_tools(message, tools)
|
| 608 |
+
|
| 609 |
+
# 如果不需要调用工具,直接回答
|
| 610 |
+
if not tool_calls:
|
| 611 |
+
print("未检测到需要使用知识库,直接回答")
|
| 612 |
+
|
| 613 |
+
# 创建SSE流式响应
|
| 614 |
+
def generate_sse_no_tools():
|
| 615 |
+
try:
|
| 616 |
+
yield f"data: {json.dumps({'type': 'start', 'plugins': suggested_plugins}, ensure_ascii=False)}\n\n"
|
| 617 |
+
|
| 618 |
+
for chunk in generator.generate_stream(message, []):
|
| 619 |
+
if isinstance(chunk, dict):
|
| 620 |
+
yield f"data: {json.dumps({'type': 'metadata', 'content': chunk}, ensure_ascii=False)}\n\n"
|
| 621 |
+
else:
|
| 622 |
+
yield f"data: {json.dumps({'type': 'content', 'content': chunk}, ensure_ascii=False)}\n\n"
|
| 623 |
+
|
| 624 |
+
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
|
| 625 |
+
except Exception as e:
|
| 626 |
+
yield f"data: {json.dumps({'type': 'error', 'content': str(e)}, ensure_ascii=False)}\n\n"
|
| 627 |
+
|
| 628 |
+
# 返回SSE响应
|
| 629 |
+
from flask import Response
|
| 630 |
+
return Response(
|
| 631 |
+
generate_sse_no_tools(),
|
| 632 |
+
mimetype='text/event-stream',
|
| 633 |
+
headers={
|
| 634 |
+
'Cache-Control': 'no-cache',
|
| 635 |
+
'X-Accel-Buffering': 'no',
|
| 636 |
+
'Access-Control-Allow-Origin': '*'
|
| 637 |
+
}
|
| 638 |
+
)
|
| 639 |
+
|
| 640 |
+
# 收集来自工具执行的所有文档
|
| 641 |
+
all_docs = []
|
| 642 |
+
|
| 643 |
+
# 执行每个工具调用
|
| 644 |
+
for tool_call in tool_calls:
|
| 645 |
+
try:
|
| 646 |
+
tool_name = tool_call["function"]["name"]
|
| 647 |
+
actual_index = tool_to_index.get(tool_name)
|
| 648 |
+
|
| 649 |
+
if not actual_index:
|
| 650 |
+
print(f"找不到工具名称 '{tool_name}' 对应的索引")
|
| 651 |
+
continue
|
| 652 |
+
|
| 653 |
+
print(f"\n执行工具 '{tool_name}' -> 使用索引 '{actual_index}'")
|
| 654 |
+
|
| 655 |
+
arguments = json.loads(tool_call["function"]["arguments"])
|
| 656 |
+
keywords = " ".join(arguments.get("keywords", []))
|
| 657 |
+
|
| 658 |
+
if not keywords:
|
| 659 |
+
print("没有提供关键词,跳过检索")
|
| 660 |
+
continue
|
| 661 |
+
|
| 662 |
+
print(f"检索关键词: {keywords}")
|
| 663 |
+
|
| 664 |
+
# 执行检索
|
| 665 |
+
retrieved_docs, _ = retriever.retrieve(keywords, specific_index=actual_index)
|
| 666 |
+
print(f"检索到 {len(retrieved_docs)} 个文档")
|
| 667 |
+
|
| 668 |
+
# 重排序文档
|
| 669 |
+
reranked_docs = reranker.rerank(message, retrieved_docs, actual_index)
|
| 670 |
+
print(f"重排序完成,排序后有 {len(reranked_docs)} 个文档")
|
| 671 |
+
|
| 672 |
+
# 添加结果
|
| 673 |
+
all_docs.extend(reranked_docs)
|
| 674 |
+
|
| 675 |
+
except Exception as e:
|
| 676 |
+
print(f"执行工具 '{tool_call.get('function', {}).get('name', '未知')}' 调用时出错: {str(e)}")
|
| 677 |
+
import traceback
|
| 678 |
+
traceback.print_exc()
|
| 679 |
+
|
| 680 |
+
# 如果没有检索到任何文档,直接回答
|
| 681 |
+
if not all_docs:
|
| 682 |
+
print("未检索到任何相关文档,直接回答")
|
| 683 |
+
|
| 684 |
+
# 创建SSE流式响应(即使没有文档也要流式输出)
|
| 685 |
+
def generate_sse_no_docs():
|
| 686 |
+
try:
|
| 687 |
+
yield f"data: {json.dumps({'type': 'start', 'plugins': suggested_plugins}, ensure_ascii=False)}\n\n"
|
| 688 |
+
|
| 689 |
+
for chunk in generator.generate_stream(message, []):
|
| 690 |
+
if isinstance(chunk, dict):
|
| 691 |
+
yield f"data: {json.dumps({'type': 'metadata', 'content': chunk}, ensure_ascii=False)}\n\n"
|
| 692 |
+
else:
|
| 693 |
+
yield f"data: {json.dumps({'type': 'content', 'content': chunk}, ensure_ascii=False)}\n\n"
|
| 694 |
+
|
| 695 |
+
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
|
| 696 |
+
except Exception as e:
|
| 697 |
+
yield f"data: {json.dumps({'type': 'error', 'content': str(e)}, ensure_ascii=False)}\n\n"
|
| 698 |
+
|
| 699 |
+
# 返回SSE响应
|
| 700 |
+
from flask import Response
|
| 701 |
+
return Response(
|
| 702 |
+
generate_sse_no_docs(),
|
| 703 |
+
mimetype='text/event-stream',
|
| 704 |
+
headers={
|
| 705 |
+
'Cache-Control': 'no-cache',
|
| 706 |
+
'X-Accel-Buffering': 'no',
|
| 707 |
+
'Access-Control-Allow-Origin': '*'
|
| 708 |
+
}
|
| 709 |
+
)
|
| 710 |
+
|
| 711 |
+
# 按相关性排序
|
| 712 |
+
all_docs.sort(key=lambda x: x.get('rerank_score', 0), reverse=True)
|
| 713 |
+
print(f"\n最终收集到 {len(all_docs)} 个文档用于生成回答")
|
| 714 |
+
|
| 715 |
+
# 提取参考信息
|
| 716 |
+
references = []
|
| 717 |
+
for i, doc in enumerate(all_docs[:3], 1): # 只展示前3个参考来源
|
| 718 |
+
file_name = doc['metadata'].get('file_name', '未知文件')
|
| 719 |
+
content = doc['content']
|
| 720 |
+
|
| 721 |
+
# 提取大约前100字符作为摘要
|
| 722 |
+
summary = content[:100] + ('...' if len(content) > 100 else '')
|
| 723 |
+
|
| 724 |
+
references.append({
|
| 725 |
+
'index': i,
|
| 726 |
+
'file_name': file_name,
|
| 727 |
+
'content': content,
|
| 728 |
+
'summary': summary
|
| 729 |
+
})
|
| 730 |
+
|
| 731 |
+
# 第二阶段:生成最终答案 - 流式响应
|
| 732 |
+
def generate_sse_with_kb():
|
| 733 |
+
try:
|
| 734 |
+
yield f"data: {json.dumps({'type': 'start', 'plugins': suggested_plugins, 'references': references}, ensure_ascii=False)}\n\n"
|
| 735 |
+
|
| 736 |
+
for chunk in generator.generate_stream(message, all_docs):
|
| 737 |
+
if isinstance(chunk, dict):
|
| 738 |
+
yield f"data: {json.dumps({'type': 'metadata', 'content': chunk}, ensure_ascii=False)}\n\n"
|
| 739 |
+
else:
|
| 740 |
+
yield f"data: {json.dumps({'type': 'content', 'content': chunk}, ensure_ascii=False)}\n\n"
|
| 741 |
+
|
| 742 |
+
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
|
| 743 |
+
except Exception as e:
|
| 744 |
+
yield f"data: {json.dumps({'type': 'error', 'content': str(e)}, ensure_ascii=False)}\n\n"
|
| 745 |
+
|
| 746 |
+
# 返回SSE响应
|
| 747 |
+
from flask import Response
|
| 748 |
+
return Response(
|
| 749 |
+
generate_sse_with_kb(),
|
| 750 |
+
mimetype='text/event-stream',
|
| 751 |
+
headers={
|
| 752 |
+
'Cache-Control': 'no-cache',
|
| 753 |
+
'X-Accel-Buffering': 'no',
|
| 754 |
+
'Access-Control-Allow-Origin': '*'
|
| 755 |
+
}
|
| 756 |
+
)
|
| 757 |
+
|
| 758 |
+
except Exception as e:
|
| 759 |
+
import traceback
|
| 760 |
+
traceback.print_exc()
|
| 761 |
+
return jsonify({
|
| 762 |
+
"success": False,
|
| 763 |
+
"message": f"处理查询时出错: {str(e)}"
|
| 764 |
+
}), 500
|
| 765 |
+
|
| 766 |
+
except Exception as e:
|
| 767 |
+
import traceback
|
| 768 |
+
traceback.print_exc()
|
| 769 |
+
return jsonify({"success": False, "message": str(e)}), 500
|
| 770 |
+
|
| 771 |
+
# API端点:获取学生活动记录
|
| 772 |
+
@app.route('/api/student/activities', methods=['GET'])
|
| 773 |
+
@student_required
|
| 774 |
+
def get_student_activities():
|
| 775 |
+
"""获取学生活动记录"""
|
| 776 |
+
try:
|
| 777 |
+
username = session.get('username')
|
| 778 |
+
|
| 779 |
+
# 获取该学生的活动记录
|
| 780 |
+
activities = student_activities.get(username, [])
|
| 781 |
+
|
| 782 |
+
# 格式化输出
|
| 783 |
+
formatted_activities = []
|
| 784 |
+
for activity in activities:
|
| 785 |
+
# 格式化时间显示
|
| 786 |
+
timestamp = activity['timestamp']
|
| 787 |
+
current_time = int(time.time())
|
| 788 |
+
|
| 789 |
+
if current_time - timestamp < 86400: # 24小时内
|
| 790 |
+
if current_time - timestamp < 3600: # 1小时内
|
| 791 |
+
time_text = f"{(current_time - timestamp) // 60}分钟前"
|
| 792 |
+
else:
|
| 793 |
+
time_text = f"今天 {time.strftime('%H:%M', time.localtime(timestamp))}"
|
| 794 |
+
elif current_time - timestamp < 172800: # 48小时内
|
| 795 |
+
time_text = f"昨天 {time.strftime('%H:%M', time.localtime(timestamp))}"
|
| 796 |
+
else:
|
| 797 |
+
time_text = time.strftime('%m月%d日 %H:%M', time.localtime(timestamp))
|
| 798 |
+
|
| 799 |
+
formatted_activities.append({
|
| 800 |
+
"type": activity['type'],
|
| 801 |
+
"title": activity['title'],
|
| 802 |
+
"time": time_text,
|
| 803 |
+
"agent_id": activity.get('agent_id'),
|
| 804 |
+
"agent_name": activity.get('agent_name')
|
| 805 |
+
})
|
| 806 |
+
|
| 807 |
+
return jsonify({
|
| 808 |
+
"success": True,
|
| 809 |
+
"activities": formatted_activities
|
| 810 |
+
})
|
| 811 |
+
|
| 812 |
+
except Exception as e:
|
| 813 |
+
import traceback
|
| 814 |
+
traceback.print_exc()
|
| 815 |
+
return jsonify({
|
| 816 |
+
"success": False,
|
| 817 |
+
"message": str(e)
|
| 818 |
+
}), 500
|
| 819 |
+
|
| 820 |
+
# API端点:验证访问令牌
|
| 821 |
+
@app.route('/api/verify_token', methods=['POST'])
|
| 822 |
+
def verify_token():
|
| 823 |
+
"""验证访问令牌有效性"""
|
| 824 |
+
try:
|
| 825 |
+
data = request.json
|
| 826 |
+
token = data.get('token', '')
|
| 827 |
+
agent_id = data.get('agent_id', '')
|
| 828 |
+
|
| 829 |
+
if not token:
|
| 830 |
+
return jsonify({
|
| 831 |
+
"success": False,
|
| 832 |
+
"message": "未提供访问令牌"
|
| 833 |
+
}), 400
|
| 834 |
+
|
| 835 |
+
# 如果提供了agent_id,验证特定Agent的令牌
|
| 836 |
+
if agent_id:
|
| 837 |
+
agent_path = os.path.join('agents', f"{agent_id}.json")
|
| 838 |
+
if not os.path.exists(agent_path):
|
| 839 |
+
return jsonify({
|
| 840 |
+
"success": False,
|
| 841 |
+
"message": "Agent不存在"
|
| 842 |
+
}), 404
|
| 843 |
+
|
| 844 |
+
with open(agent_path, 'r', encoding='utf-8') as f:
|
| 845 |
+
agent_config = json.load(f)
|
| 846 |
+
|
| 847 |
+
# 验证令牌
|
| 848 |
+
if "distributions" in agent_config:
|
| 849 |
+
for dist in agent_config["distributions"]:
|
| 850 |
+
if dist.get("token") == token:
|
| 851 |
+
# 检查是否过期
|
| 852 |
+
if dist.get("expires_at", 0) > 0 and dist.get("expires_at", 0) < time.time():
|
| 853 |
+
return jsonify({
|
| 854 |
+
"success": False,
|
| 855 |
+
"message": "访问令牌已过期"
|
| 856 |
+
})
|
| 857 |
+
|
| 858 |
+
return jsonify({
|
| 859 |
+
"success": True,
|
| 860 |
+
"agent": {
|
| 861 |
+
"id": agent_id,
|
| 862 |
+
"name": agent_config.get('name', 'AI学习助手'),
|
| 863 |
+
"description": agent_config.get('description', ''),
|
| 864 |
+
"subject": agent_config.get('subject', ''),
|
| 865 |
+
"instructor": agent_config.get('instructor', '教师')
|
| 866 |
+
}
|
| 867 |
+
})
|
| 868 |
+
|
| 869 |
+
return jsonify({
|
| 870 |
+
"success": False,
|
| 871 |
+
"message": "访问令牌无效"
|
| 872 |
+
})
|
| 873 |
+
|
| 874 |
+
# 如果没有提供agent_id,搜索所有Agent
|
| 875 |
+
valid_agent = None
|
| 876 |
+
|
| 877 |
+
for filename in os.listdir('agents'):
|
| 878 |
+
if filename.endswith('.json'):
|
| 879 |
+
agent_path = os.path.join('agents', filename)
|
| 880 |
+
with open(agent_path, 'r', encoding='utf-8') as f:
|
| 881 |
+
agent_config = json.load(f)
|
| 882 |
+
|
| 883 |
+
# 验证令牌
|
| 884 |
+
if "distributions" in agent_config:
|
| 885 |
+
for dist in agent_config["distributions"]:
|
| 886 |
+
if dist.get("token") == token:
|
| 887 |
+
# 检查是否过���
|
| 888 |
+
if dist.get("expires_at", 0) > 0 and dist.get("expires_at", 0) < time.time():
|
| 889 |
+
continue
|
| 890 |
+
|
| 891 |
+
valid_agent = {
|
| 892 |
+
"id": agent_config.get('id'),
|
| 893 |
+
"name": agent_config.get('name', 'AI学习助手'),
|
| 894 |
+
"description": agent_config.get('description', ''),
|
| 895 |
+
"subject": agent_config.get('subject', ''),
|
| 896 |
+
"instructor": agent_config.get('instructor', '教师')
|
| 897 |
+
}
|
| 898 |
+
break
|
| 899 |
+
|
| 900 |
+
if valid_agent:
|
| 901 |
+
break
|
| 902 |
+
|
| 903 |
+
if valid_agent:
|
| 904 |
+
return jsonify({
|
| 905 |
+
"success": True,
|
| 906 |
+
"agent": valid_agent
|
| 907 |
+
})
|
| 908 |
+
|
| 909 |
+
return jsonify({
|
| 910 |
+
"success": False,
|
| 911 |
+
"message": "未找到匹配的访问令牌"
|
| 912 |
+
})
|
| 913 |
+
|
| 914 |
+
except Exception as e:
|
| 915 |
+
import traceback
|
| 916 |
+
traceback.print_exc()
|
| 917 |
+
return jsonify({
|
| 918 |
+
"success": False,
|
| 919 |
+
"message": f"验证访问令牌时出错: {str(e)}"
|
| 920 |
+
}), 500
|
| 921 |
+
|
| 922 |
+
# API端点:获取学生的Agent列表
|
| 923 |
+
@app.route('/api/student/agents', methods=['GET'])
|
| 924 |
+
@student_required
|
| 925 |
+
def get_student_agents():
|
| 926 |
+
"""获取学生可访问的Agent列表"""
|
| 927 |
+
try:
|
| 928 |
+
# 实际应用中应根据学生ID过滤
|
| 929 |
+
# 这里简化为获取所有Agent
|
| 930 |
+
agents = []
|
| 931 |
+
|
| 932 |
+
for filename in os.listdir('agents'):
|
| 933 |
+
if filename.endswith('.json'):
|
| 934 |
+
agent_path = os.path.join('agents', filename)
|
| 935 |
+
with open(agent_path, 'r', encoding='utf-8') as f:
|
| 936 |
+
agent_config = json.load(f)
|
| 937 |
+
|
| 938 |
+
# 简化信息
|
| 939 |
+
agent_info = {
|
| 940 |
+
"id": agent_config.get('id'),
|
| 941 |
+
"name": agent_config.get('name', 'AI学习助手'),
|
| 942 |
+
"description": agent_config.get('description', ''),
|
| 943 |
+
"subject": agent_config.get('subject', ''),
|
| 944 |
+
"instructor": agent_config.get('instructor', '教师'),
|
| 945 |
+
"plugins": agent_config.get('plugins', []),
|
| 946 |
+
"last_used": agent_config.get('stats', {}).get('last_used')
|
| 947 |
+
}
|
| 948 |
+
|
| 949 |
+
# 添加TOKEN(实际应用中应严格控制令牌访问)
|
| 950 |
+
if "distributions" in agent_config and agent_config["distributions"]:
|
| 951 |
+
# 仅添加第一个分发的令牌
|
| 952 |
+
agent_info["token"] = agent_config["distributions"][0].get("token")
|
| 953 |
+
|
| 954 |
+
agents.append(agent_info)
|
| 955 |
+
|
| 956 |
+
# 按最后使用时间排序
|
| 957 |
+
agents.sort(key=lambda x: x.get('last_used', 0) or 0, reverse=True)
|
| 958 |
+
|
| 959 |
+
return jsonify({
|
| 960 |
+
"success": True,
|
| 961 |
+
"agents": agents
|
| 962 |
+
})
|
| 963 |
+
|
| 964 |
+
except Exception as e:
|
| 965 |
+
import traceback
|
| 966 |
+
traceback.print_exc()
|
| 967 |
+
return jsonify({
|
| 968 |
+
"success": False,
|
| 969 |
+
"message": str(e)
|
| 970 |
+
}), 500
|
| 971 |
+
|
| 972 |
+
# 注册学生管理路由
|
| 973 |
+
register_student_routes(app)
|
| 974 |
+
|
| 975 |
+
# React SPA catch-all:非 API 路径都返回 index.html(支持前端路由)
|
| 976 |
+
@app.route('/<path:path>')
|
| 977 |
+
def catch_all(path):
|
| 978 |
+
# 静态资源文件直接返回
|
| 979 |
+
file_path = os.path.join(FRONTEND_DIST, path)
|
| 980 |
+
if os.path.isfile(file_path):
|
| 981 |
+
return send_from_directory(FRONTEND_DIST, path)
|
| 982 |
+
# 其他路径返回 index.html,交给 React Router 处理
|
| 983 |
+
return send_from_directory(FRONTEND_DIST, 'index.html')
|
| 984 |
+
|
| 985 |
+
if __name__ == '__main__':
|
| 986 |
+
debug = os.getenv('FLASK_ENV', 'production') == 'development'
|
| 987 |
+
app.run(debug=debug, host='0.0.0.0', port=7860)
|
config.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# config.py
|
| 2 |
+
import os
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
# API keys
|
| 8 |
+
API_KEY = os.getenv("API_KEY")
|
| 9 |
+
BASE_URL = os.getenv("BASE_URL")
|
| 10 |
+
STREAM_API_KEY = os.getenv("STREAM_API_KEY")
|
| 11 |
+
STREAM_BASE_URL = os.getenv("STREAM_BASE_URL")
|
| 12 |
+
STREAM_MODEL = os.getenv("STREAM_MODEL")
|
| 13 |
+
DEFAULT_MODEL = os.getenv("DEFAULT_MODEL")
|
| 14 |
+
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
| 15 |
+
|
| 16 |
+
# Knowledge base configuration
|
| 17 |
+
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "BAAI/bge-m3")
|
| 18 |
+
RERANK_MODEL = os.getenv("RERANK_MODEL", "BAAI/bge-reranker-v2-m3")
|
| 19 |
+
|
| 20 |
+
# System configuration
|
| 21 |
+
UPLOAD_FOLDER = "uploads"
|
| 22 |
+
AGENTS_FOLDER = "agents"
|
| 23 |
+
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
frontend/eslint.config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 6 |
+
|
| 7 |
+
export default defineConfig([
|
| 8 |
+
globalIgnores(['dist']),
|
| 9 |
+
{
|
| 10 |
+
files: ['**/*.{js,jsx}'],
|
| 11 |
+
extends: [
|
| 12 |
+
js.configs.recommended,
|
| 13 |
+
reactHooks.configs['recommended-latest'],
|
| 14 |
+
reactRefresh.configs.vite,
|
| 15 |
+
],
|
| 16 |
+
languageOptions: {
|
| 17 |
+
ecmaVersion: 2020,
|
| 18 |
+
globals: globals.browser,
|
| 19 |
+
parserOptions: {
|
| 20 |
+
ecmaVersion: 'latest',
|
| 21 |
+
ecmaFeatures: { jsx: true },
|
| 22 |
+
sourceType: 'module',
|
| 23 |
+
},
|
| 24 |
+
},
|
| 25 |
+
rules: {
|
| 26 |
+
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
])
|
frontend/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>frontend</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@ant-design/icons": "^6.1.0",
|
| 14 |
+
"@codemirror/lang-python": "^6.2.1",
|
| 15 |
+
"@reduxjs/toolkit": "^2.10.1",
|
| 16 |
+
"@tailwindcss/forms": "^0.5.10",
|
| 17 |
+
"@tailwindcss/typography": "^0.5.19",
|
| 18 |
+
"@uiw/react-codemirror": "^4.25.9",
|
| 19 |
+
"antd": "^5.29.0",
|
| 20 |
+
"axios": "^1.13.2",
|
| 21 |
+
"dayjs": "^1.11.19",
|
| 22 |
+
"framer-motion": "^12.23.24",
|
| 23 |
+
"katex": "^0.16.45",
|
| 24 |
+
"markmap-lib": "^0.18.12",
|
| 25 |
+
"markmap-view": "^0.18.12",
|
| 26 |
+
"react": "^19.2.0",
|
| 27 |
+
"react-dom": "^19.2.0",
|
| 28 |
+
"react-markdown": "^9.1.0",
|
| 29 |
+
"react-redux": "^9.2.0",
|
| 30 |
+
"react-router-dom": "^7.9.6",
|
| 31 |
+
"react-syntax-highlighter": "^15.6.6",
|
| 32 |
+
"reactflow": "^11.11.4",
|
| 33 |
+
"recharts": "^3.4.1",
|
| 34 |
+
"rehype-katex": "^7.0.1",
|
| 35 |
+
"remark-gfm": "^4.0.1",
|
| 36 |
+
"remark-math": "^6.0.0"
|
| 37 |
+
},
|
| 38 |
+
"devDependencies": {
|
| 39 |
+
"@eslint/js": "^9.39.1",
|
| 40 |
+
"@types/react": "^19.2.2",
|
| 41 |
+
"@types/react-dom": "^19.2.2",
|
| 42 |
+
"@vitejs/plugin-react": "^5.1.0",
|
| 43 |
+
"autoprefixer": "^10.4.22",
|
| 44 |
+
"eslint": "^9.39.1",
|
| 45 |
+
"eslint-plugin-react-hooks": "^5.2.0",
|
| 46 |
+
"eslint-plugin-react-refresh": "^0.4.24",
|
| 47 |
+
"globals": "^16.5.0",
|
| 48 |
+
"postcss": "^8.5.6",
|
| 49 |
+
"postcss-import": "^16.1.1",
|
| 50 |
+
"tailwindcss": "^3.4.18",
|
| 51 |
+
"vite": "^7.2.2"
|
| 52 |
+
}
|
| 53 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
'postcss-import': {},
|
| 4 |
+
tailwindcss: {},
|
| 5 |
+
autoprefixer: {},
|
| 6 |
+
},
|
| 7 |
+
}
|
frontend/public/vite.svg
ADDED
|
|
frontend/src/App.jsx
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
| 3 |
+
import { Provider } from 'react-redux';
|
| 4 |
+
import { ConfigProvider, message } from 'antd';
|
| 5 |
+
import zhCN from 'antd/locale/zh_CN';
|
| 6 |
+
import store from './store/store';
|
| 7 |
+
import LoginPage from './pages/LoginPage';
|
| 8 |
+
import RegisterPage from './pages/RegisterPage';
|
| 9 |
+
import TeacherDashboard from './pages/teacher/TeacherDashboard';
|
| 10 |
+
import StudentDashboard from './pages/student/StudentDashboard';
|
| 11 |
+
import AgentChat from './pages/student/AgentChat';
|
| 12 |
+
import ProtectedRoute from './components/common/ProtectedRoute';
|
| 13 |
+
import './index.css';
|
| 14 |
+
|
| 15 |
+
// Ant Design 主题配置
|
| 16 |
+
const theme = {
|
| 17 |
+
token: {
|
| 18 |
+
colorPrimary: '#10b981',
|
| 19 |
+
colorSuccess: '#10b981',
|
| 20 |
+
colorWarning: '#f97316',
|
| 21 |
+
colorError: '#f43f5e',
|
| 22 |
+
colorInfo: '#8b5cf6',
|
| 23 |
+
borderRadius: 8,
|
| 24 |
+
fontSize: 14,
|
| 25 |
+
fontFamily: "'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif",
|
| 26 |
+
},
|
| 27 |
+
components: {
|
| 28 |
+
Button: {
|
| 29 |
+
controlHeight: 40,
|
| 30 |
+
fontWeight: 500,
|
| 31 |
+
},
|
| 32 |
+
Input: {
|
| 33 |
+
controlHeight: 40,
|
| 34 |
+
},
|
| 35 |
+
Select: {
|
| 36 |
+
controlHeight: 40,
|
| 37 |
+
},
|
| 38 |
+
Menu: {
|
| 39 |
+
itemBorderRadius: 8,
|
| 40 |
+
subMenuItemBorderRadius: 8,
|
| 41 |
+
},
|
| 42 |
+
Card: {
|
| 43 |
+
borderRadiusLG: 12,
|
| 44 |
+
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
|
| 45 |
+
},
|
| 46 |
+
},
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
// 全局消息配置
|
| 50 |
+
message.config({
|
| 51 |
+
top: 80,
|
| 52 |
+
duration: 3,
|
| 53 |
+
maxCount: 3,
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
function App() {
|
| 57 |
+
return (
|
| 58 |
+
<Provider store={store}>
|
| 59 |
+
<ConfigProvider theme={theme} locale={zhCN}>
|
| 60 |
+
<BrowserRouter>
|
| 61 |
+
<Routes>
|
| 62 |
+
<Route path="/login" element={<LoginPage />} />
|
| 63 |
+
<Route path="/register" element={<RegisterPage />} />
|
| 64 |
+
|
| 65 |
+
{/* Agent对话页面 - 不需要登录保护,通过token访问 */}
|
| 66 |
+
{/* 注意:这个路由必须在 /student/* 之前,否则会被捕获并要求登录 */}
|
| 67 |
+
<Route path="/student/chat/:agentId" element={<AgentChat />} />
|
| 68 |
+
|
| 69 |
+
{/* 教师端路由 */}
|
| 70 |
+
<Route
|
| 71 |
+
path="/teacher/*"
|
| 72 |
+
element={
|
| 73 |
+
<ProtectedRoute requiredRole="teacher">
|
| 74 |
+
<TeacherDashboard />
|
| 75 |
+
</ProtectedRoute>
|
| 76 |
+
}
|
| 77 |
+
/>
|
| 78 |
+
|
| 79 |
+
{/* 学生端路由 - 排除chat路径 */}
|
| 80 |
+
<Route
|
| 81 |
+
path="/student/*"
|
| 82 |
+
element={
|
| 83 |
+
<ProtectedRoute requiredRole="student">
|
| 84 |
+
<StudentDashboard />
|
| 85 |
+
</ProtectedRoute>
|
| 86 |
+
}
|
| 87 |
+
/>
|
| 88 |
+
|
| 89 |
+
{/* 默认重定向到登录 */}
|
| 90 |
+
<Route path="/" element={<Navigate to="/login" replace />} />
|
| 91 |
+
<Route path="*" element={<Navigate to="/login" replace />} />
|
| 92 |
+
</Routes>
|
| 93 |
+
</BrowserRouter>
|
| 94 |
+
</ConfigProvider>
|
| 95 |
+
</Provider>
|
| 96 |
+
);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
export default App;
|
frontend/src/assets/react.svg
ADDED
|
|
frontend/src/components/WorkflowEditor.jsx
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 工作流编辑器组件
|
| 3 |
+
* 使用React Flow实现可视化工作流编辑
|
| 4 |
+
*/
|
| 5 |
+
import { useState, useCallback, useRef } from 'react';
|
| 6 |
+
import ReactFlow, {
|
| 7 |
+
ReactFlowProvider,
|
| 8 |
+
addEdge,
|
| 9 |
+
useNodesState,
|
| 10 |
+
useEdgesState,
|
| 11 |
+
Controls,
|
| 12 |
+
MiniMap,
|
| 13 |
+
Background,
|
| 14 |
+
Panel,
|
| 15 |
+
MarkerType
|
| 16 |
+
} from 'reactflow';
|
| 17 |
+
import 'reactflow/dist/style.css';
|
| 18 |
+
import {
|
| 19 |
+
Card,
|
| 20 |
+
Button,
|
| 21 |
+
Drawer,
|
| 22 |
+
Form,
|
| 23 |
+
Input,
|
| 24 |
+
Select,
|
| 25 |
+
Space,
|
| 26 |
+
Typography,
|
| 27 |
+
message,
|
| 28 |
+
Modal,
|
| 29 |
+
Divider,
|
| 30 |
+
Tag
|
| 31 |
+
} from 'antd';
|
| 32 |
+
import {
|
| 33 |
+
PlusOutlined,
|
| 34 |
+
SaveOutlined,
|
| 35 |
+
ClearOutlined,
|
| 36 |
+
PlayCircleOutlined,
|
| 37 |
+
QuestionCircleOutlined,
|
| 38 |
+
CodeOutlined,
|
| 39 |
+
FileTextOutlined,
|
| 40 |
+
BranchesOutlined,
|
| 41 |
+
CheckCircleOutlined
|
| 42 |
+
} from '@ant-design/icons';
|
| 43 |
+
|
| 44 |
+
const { Title, Text } = Typography;
|
| 45 |
+
const { TextArea } = Input;
|
| 46 |
+
const { Option } = Select;
|
| 47 |
+
|
| 48 |
+
// 节点类型定义
|
| 49 |
+
const nodeTypes = {
|
| 50 |
+
start: {
|
| 51 |
+
label: '开始节点',
|
| 52 |
+
icon: <PlayCircleOutlined />,
|
| 53 |
+
color: '#52c41a'
|
| 54 |
+
},
|
| 55 |
+
question: {
|
| 56 |
+
label: '提问节点',
|
| 57 |
+
icon: <QuestionCircleOutlined />,
|
| 58 |
+
color: '#1890ff'
|
| 59 |
+
},
|
| 60 |
+
knowledge: {
|
| 61 |
+
label: '知识查询',
|
| 62 |
+
icon: <FileTextOutlined />,
|
| 63 |
+
color: '#722ed1'
|
| 64 |
+
},
|
| 65 |
+
code: {
|
| 66 |
+
label: '代码执行',
|
| 67 |
+
icon: <CodeOutlined />,
|
| 68 |
+
color: '#fa8c16'
|
| 69 |
+
},
|
| 70 |
+
condition: {
|
| 71 |
+
label: '条件分支',
|
| 72 |
+
icon: <BranchesOutlined />,
|
| 73 |
+
color: '#f5222d'
|
| 74 |
+
},
|
| 75 |
+
end: {
|
| 76 |
+
label: '结束节点',
|
| 77 |
+
icon: <CheckCircleOutlined />,
|
| 78 |
+
color: '#52c41a'
|
| 79 |
+
}
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
// 自定义节点组件
|
| 83 |
+
const CustomNode = ({ data, selected }) => {
|
| 84 |
+
const nodeType = nodeTypes[data.type] || nodeTypes.question;
|
| 85 |
+
|
| 86 |
+
return (
|
| 87 |
+
<div
|
| 88 |
+
style={{
|
| 89 |
+
padding: '10px 15px',
|
| 90 |
+
borderRadius: '8px',
|
| 91 |
+
background: '#fff',
|
| 92 |
+
border: `2px solid ${selected ? nodeType.color : '#d9d9d9'}`,
|
| 93 |
+
boxShadow: selected ? `0 0 0 2px ${nodeType.color}20` : '0 2px 4px rgba(0,0,0,0.1)',
|
| 94 |
+
minWidth: '150px',
|
| 95 |
+
textAlign: 'center'
|
| 96 |
+
}}
|
| 97 |
+
>
|
| 98 |
+
<Space>
|
| 99 |
+
<span style={{ color: nodeType.color, fontSize: '16px' }}>
|
| 100 |
+
{nodeType.icon}
|
| 101 |
+
</span>
|
| 102 |
+
<Text strong>{data.label}</Text>
|
| 103 |
+
</Space>
|
| 104 |
+
{data.description && (
|
| 105 |
+
<div style={{ marginTop: '4px' }}>
|
| 106 |
+
<Text type="secondary" style={{ fontSize: '12px' }}>
|
| 107 |
+
{data.description}
|
| 108 |
+
</Text>
|
| 109 |
+
</div>
|
| 110 |
+
)}
|
| 111 |
+
</div>
|
| 112 |
+
);
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
// 初始节点
|
| 116 |
+
const initialNodes = [
|
| 117 |
+
{
|
| 118 |
+
id: 'start',
|
| 119 |
+
type: 'custom',
|
| 120 |
+
position: { x: 250, y: 50 },
|
| 121 |
+
data: {
|
| 122 |
+
type: 'start',
|
| 123 |
+
label: '开始',
|
| 124 |
+
description: '工作流起点'
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
];
|
| 128 |
+
|
| 129 |
+
const WorkflowEditor = ({ value, onChange, onSave }) => {
|
| 130 |
+
const [nodes, setNodes, onNodesChange] = useNodesState(value?.nodes || initialNodes);
|
| 131 |
+
const [edges, setEdges, onEdgesChange] = useEdgesState(value?.edges || []);
|
| 132 |
+
const [drawerVisible, setDrawerVisible] = useState(false);
|
| 133 |
+
const [selectedNode, setSelectedNode] = useState(null);
|
| 134 |
+
const [nodeForm] = Form.useForm();
|
| 135 |
+
const reactFlowWrapper = useRef(null);
|
| 136 |
+
const reactFlowInstance = useRef(null);
|
| 137 |
+
|
| 138 |
+
// 连接节点
|
| 139 |
+
const onConnect = useCallback(
|
| 140 |
+
(params) => {
|
| 141 |
+
const newEdge = {
|
| 142 |
+
...params,
|
| 143 |
+
type: 'smoothstep',
|
| 144 |
+
animated: true,
|
| 145 |
+
style: { stroke: '#1890ff', strokeWidth: 2 },
|
| 146 |
+
markerEnd: {
|
| 147 |
+
type: MarkerType.ArrowClosed,
|
| 148 |
+
color: '#1890ff'
|
| 149 |
+
}
|
| 150 |
+
};
|
| 151 |
+
setEdges((eds) => addEdge(newEdge, eds));
|
| 152 |
+
},
|
| 153 |
+
[setEdges]
|
| 154 |
+
);
|
| 155 |
+
|
| 156 |
+
// 添加节点
|
| 157 |
+
const addNode = (type) => {
|
| 158 |
+
const newNode = {
|
| 159 |
+
id: `${type}_${Date.now()}`,
|
| 160 |
+
type: 'custom',
|
| 161 |
+
position: {
|
| 162 |
+
x: Math.random() * 400 + 100,
|
| 163 |
+
y: Math.random() * 300 + 100
|
| 164 |
+
},
|
| 165 |
+
data: {
|
| 166 |
+
type,
|
| 167 |
+
label: nodeTypes[type].label,
|
| 168 |
+
description: '',
|
| 169 |
+
config: {}
|
| 170 |
+
}
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
setNodes((nds) => nds.concat(newNode));
|
| 174 |
+
message.success(`已添加${nodeTypes[type].label}`);
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
// 选中节点
|
| 178 |
+
const onNodeClick = (event, node) => {
|
| 179 |
+
setSelectedNode(node);
|
| 180 |
+
nodeForm.setFieldsValue({
|
| 181 |
+
label: node.data.label,
|
| 182 |
+
description: node.data.description,
|
| 183 |
+
...node.data.config
|
| 184 |
+
});
|
| 185 |
+
setDrawerVisible(true);
|
| 186 |
+
};
|
| 187 |
+
|
| 188 |
+
// 删除节点
|
| 189 |
+
const deleteNode = (nodeId) => {
|
| 190 |
+
setNodes((nds) => nds.filter((n) => n.id !== nodeId));
|
| 191 |
+
setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId));
|
| 192 |
+
message.success('节点已删除');
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
// 更新节点
|
| 196 |
+
const updateNode = (values) => {
|
| 197 |
+
setNodes((nds) =>
|
| 198 |
+
nds.map((node) => {
|
| 199 |
+
if (node.id === selectedNode.id) {
|
| 200 |
+
return {
|
| 201 |
+
...node,
|
| 202 |
+
data: {
|
| 203 |
+
...node.data,
|
| 204 |
+
label: values.label,
|
| 205 |
+
description: values.description,
|
| 206 |
+
config: {
|
| 207 |
+
...values,
|
| 208 |
+
label: undefined,
|
| 209 |
+
description: undefined
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
};
|
| 213 |
+
}
|
| 214 |
+
return node;
|
| 215 |
+
})
|
| 216 |
+
);
|
| 217 |
+
setDrawerVisible(false);
|
| 218 |
+
message.success('节点已更新');
|
| 219 |
+
};
|
| 220 |
+
|
| 221 |
+
// 清空画布
|
| 222 |
+
const clearCanvas = () => {
|
| 223 |
+
Modal.confirm({
|
| 224 |
+
title: '确认清空',
|
| 225 |
+
content: '确定要清空所有节点和连接吗?',
|
| 226 |
+
onOk: () => {
|
| 227 |
+
setNodes([initialNodes[0]]);
|
| 228 |
+
setEdges([]);
|
| 229 |
+
message.success('画布已清空');
|
| 230 |
+
}
|
| 231 |
+
});
|
| 232 |
+
};
|
| 233 |
+
|
| 234 |
+
// 保存工作流
|
| 235 |
+
const handleSave = () => {
|
| 236 |
+
const workflow = {
|
| 237 |
+
nodes,
|
| 238 |
+
edges,
|
| 239 |
+
timestamp: Date.now()
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
if (onChange) {
|
| 243 |
+
onChange(workflow);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
if (onSave) {
|
| 247 |
+
onSave(workflow);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
message.success('工作流已保存');
|
| 251 |
+
};
|
| 252 |
+
|
| 253 |
+
// 验证工作流
|
| 254 |
+
const validateWorkflow = () => {
|
| 255 |
+
if (nodes.length < 2) {
|
| 256 |
+
message.warning('工作流至少需要包含开始和结束节点');
|
| 257 |
+
return false;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
const hasStart = nodes.some(n => n.data.type === 'start');
|
| 261 |
+
const hasEnd = nodes.some(n => n.data.type === 'end');
|
| 262 |
+
|
| 263 |
+
if (!hasStart || !hasEnd) {
|
| 264 |
+
message.warning('工作流必须包含开始和结束节点');
|
| 265 |
+
return false;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
return true;
|
| 269 |
+
};
|
| 270 |
+
|
| 271 |
+
return (
|
| 272 |
+
<div style={{ width: '100%', height: '600px' }}>
|
| 273 |
+
<ReactFlowProvider>
|
| 274 |
+
<div ref={reactFlowWrapper} style={{ width: '100%', height: '100%' }}>
|
| 275 |
+
<ReactFlow
|
| 276 |
+
nodes={nodes}
|
| 277 |
+
edges={edges}
|
| 278 |
+
onNodesChange={onNodesChange}
|
| 279 |
+
onEdgesChange={onEdgesChange}
|
| 280 |
+
onConnect={onConnect}
|
| 281 |
+
onNodeClick={onNodeClick}
|
| 282 |
+
onInit={(instance) => { reactFlowInstance.current = instance; }}
|
| 283 |
+
nodeTypes={{ custom: CustomNode }}
|
| 284 |
+
fitView
|
| 285 |
+
>
|
| 286 |
+
<Controls />
|
| 287 |
+
<MiniMap
|
| 288 |
+
nodeColor={node => nodeTypes[node.data.type]?.color || '#ccc'}
|
| 289 |
+
style={{
|
| 290 |
+
height: 120,
|
| 291 |
+
background: '#f0f2f5'
|
| 292 |
+
}}
|
| 293 |
+
/>
|
| 294 |
+
<Background variant="dots" gap={12} size={1} />
|
| 295 |
+
|
| 296 |
+
{/* 工具面板 */}
|
| 297 |
+
<Panel position="top-left">
|
| 298 |
+
<Card size="small" style={{ minWidth: '200px' }}>
|
| 299 |
+
<Space direction="vertical" style={{ width: '100%' }}>
|
| 300 |
+
<Text strong>添加节点</Text>
|
| 301 |
+
<Space wrap>
|
| 302 |
+
{Object.entries(nodeTypes).map(([key, type]) => (
|
| 303 |
+
<Button
|
| 304 |
+
key={key}
|
| 305 |
+
size="small"
|
| 306 |
+
icon={type.icon}
|
| 307 |
+
onClick={() => addNode(key)}
|
| 308 |
+
style={{ fontSize: '12px' }}
|
| 309 |
+
>
|
| 310 |
+
{type.label}
|
| 311 |
+
</Button>
|
| 312 |
+
))}
|
| 313 |
+
</Space>
|
| 314 |
+
</Space>
|
| 315 |
+
</Card>
|
| 316 |
+
</Panel>
|
| 317 |
+
|
| 318 |
+
{/* 操作按钮 */}
|
| 319 |
+
<Panel position="top-right">
|
| 320 |
+
<Space>
|
| 321 |
+
<Button
|
| 322 |
+
type="primary"
|
| 323 |
+
icon={<SaveOutlined />}
|
| 324 |
+
onClick={handleSave}
|
| 325 |
+
disabled={!validateWorkflow()}
|
| 326 |
+
>
|
| 327 |
+
保存工作流
|
| 328 |
+
</Button>
|
| 329 |
+
<Button
|
| 330 |
+
danger
|
| 331 |
+
icon={<ClearOutlined />}
|
| 332 |
+
onClick={clearCanvas}
|
| 333 |
+
>
|
| 334 |
+
清空画布
|
| 335 |
+
</Button>
|
| 336 |
+
</Space>
|
| 337 |
+
</Panel>
|
| 338 |
+
</ReactFlow>
|
| 339 |
+
</div>
|
| 340 |
+
</ReactFlowProvider>
|
| 341 |
+
|
| 342 |
+
{/* 节点编辑抽屉 */}
|
| 343 |
+
<Drawer
|
| 344 |
+
title="编辑节点"
|
| 345 |
+
placement="right"
|
| 346 |
+
width={400}
|
| 347 |
+
open={drawerVisible}
|
| 348 |
+
onClose={() => setDrawerVisible(false)}
|
| 349 |
+
footer={
|
| 350 |
+
<Space>
|
| 351 |
+
<Button onClick={() => setDrawerVisible(false)}>取消</Button>
|
| 352 |
+
<Button
|
| 353 |
+
danger
|
| 354 |
+
onClick={() => {
|
| 355 |
+
deleteNode(selectedNode.id);
|
| 356 |
+
setDrawerVisible(false);
|
| 357 |
+
}}
|
| 358 |
+
>
|
| 359 |
+
删除节点
|
| 360 |
+
</Button>
|
| 361 |
+
<Button type="primary" onClick={() => nodeForm.submit()}>
|
| 362 |
+
保存
|
| 363 |
+
</Button>
|
| 364 |
+
</Space>
|
| 365 |
+
}
|
| 366 |
+
>
|
| 367 |
+
{selectedNode && (
|
| 368 |
+
<Form
|
| 369 |
+
form={nodeForm}
|
| 370 |
+
layout="vertical"
|
| 371 |
+
onFinish={updateNode}
|
| 372 |
+
>
|
| 373 |
+
<Form.Item
|
| 374 |
+
name="label"
|
| 375 |
+
label="节点名称"
|
| 376 |
+
rules={[{ required: true, message: '请输入节点名称' }]}
|
| 377 |
+
>
|
| 378 |
+
<Input />
|
| 379 |
+
</Form.Item>
|
| 380 |
+
|
| 381 |
+
<Form.Item
|
| 382 |
+
name="description"
|
| 383 |
+
label="节点描述"
|
| 384 |
+
>
|
| 385 |
+
<TextArea rows={2} />
|
| 386 |
+
</Form.Item>
|
| 387 |
+
|
| 388 |
+
<Divider />
|
| 389 |
+
|
| 390 |
+
{/* 根据节点类型显示不同的配置项 */}
|
| 391 |
+
{selectedNode.data.type === 'question' && (
|
| 392 |
+
<Form.Item
|
| 393 |
+
name="prompt"
|
| 394 |
+
label="提示词模板"
|
| 395 |
+
>
|
| 396 |
+
<TextArea rows={4} placeholder="输入提示词模板..." />
|
| 397 |
+
</Form.Item>
|
| 398 |
+
)}
|
| 399 |
+
|
| 400 |
+
{selectedNode.data.type === 'knowledge' && (
|
| 401 |
+
<Form.Item
|
| 402 |
+
name="knowledge_base"
|
| 403 |
+
label="知识库"
|
| 404 |
+
>
|
| 405 |
+
<Select placeholder="选择知识库">
|
| 406 |
+
<Option value="default">默认知识库</Option>
|
| 407 |
+
</Select>
|
| 408 |
+
</Form.Item>
|
| 409 |
+
)}
|
| 410 |
+
|
| 411 |
+
{selectedNode.data.type === 'code' && (
|
| 412 |
+
<Form.Item
|
| 413 |
+
name="code"
|
| 414 |
+
label="代码"
|
| 415 |
+
>
|
| 416 |
+
<TextArea rows={6} placeholder="输入Python代码..." style={{ fontFamily: 'monospace' }} />
|
| 417 |
+
</Form.Item>
|
| 418 |
+
)}
|
| 419 |
+
|
| 420 |
+
{selectedNode.data.type === 'condition' && (
|
| 421 |
+
<>
|
| 422 |
+
<Form.Item
|
| 423 |
+
name="condition"
|
| 424 |
+
label="条件表达式"
|
| 425 |
+
>
|
| 426 |
+
<Input placeholder="例如: score > 80" />
|
| 427 |
+
</Form.Item>
|
| 428 |
+
<Form.Item
|
| 429 |
+
name="true_branch"
|
| 430 |
+
label="满足条件时"
|
| 431 |
+
>
|
| 432 |
+
<Input placeholder="跳转到节点..." />
|
| 433 |
+
</Form.Item>
|
| 434 |
+
<Form.Item
|
| 435 |
+
name="false_branch"
|
| 436 |
+
label="不满足条件时"
|
| 437 |
+
>
|
| 438 |
+
<Input placeholder="跳转到节点..." />
|
| 439 |
+
</Form.Item>
|
| 440 |
+
</>
|
| 441 |
+
)}
|
| 442 |
+
</Form>
|
| 443 |
+
)}
|
| 444 |
+
</Drawer>
|
| 445 |
+
</div>
|
| 446 |
+
);
|
| 447 |
+
};
|
| 448 |
+
|
| 449 |
+
export default WorkflowEditor;
|
frontend/src/components/common/ProtectedRoute.jsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Navigate } from 'react-router-dom';
|
| 3 |
+
import { useSelector } from 'react-redux';
|
| 4 |
+
|
| 5 |
+
const ProtectedRoute = ({ children, requiredRole }) => {
|
| 6 |
+
const { isAuthenticated, user } = useSelector(state => state.auth);
|
| 7 |
+
|
| 8 |
+
// 未登录,重定向到登录页
|
| 9 |
+
if (!isAuthenticated) {
|
| 10 |
+
return <Navigate to="/login" replace />;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
// 已登录但角色不匹配
|
| 14 |
+
if (requiredRole && user?.role !== requiredRole) {
|
| 15 |
+
// 根据用户角色重定向到对应页面
|
| 16 |
+
if (user?.role === 'teacher') {
|
| 17 |
+
return <Navigate to="/teacher" replace />;
|
| 18 |
+
} else if (user?.role === 'student') {
|
| 19 |
+
return <Navigate to="/student" replace />;
|
| 20 |
+
}
|
| 21 |
+
return <Navigate to="/login" replace />;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// 验证通过,渲染子组件
|
| 25 |
+
return children;
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
export default ProtectedRoute;
|
frontend/src/components/plugins/CodeExecutor.jsx
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 代码执行插件组件
|
| 3 |
+
* 使用 CodeMirror 编辑器 + 终端输出面板 + 交互式输入
|
| 4 |
+
*/
|
| 5 |
+
import { useState, useRef, useEffect } from 'react';
|
| 6 |
+
import { Button, Space, Input, Typography } from 'antd';
|
| 7 |
+
import {
|
| 8 |
+
PlayCircleOutlined,
|
| 9 |
+
StopOutlined,
|
| 10 |
+
ClearOutlined,
|
| 11 |
+
CodeOutlined
|
| 12 |
+
} from '@ant-design/icons';
|
| 13 |
+
import CodeMirror from '@uiw/react-codemirror';
|
| 14 |
+
import { python } from '@codemirror/lang-python';
|
| 15 |
+
import api from '../../services/api';
|
| 16 |
+
|
| 17 |
+
const { Text } = Typography;
|
| 18 |
+
|
| 19 |
+
const CodeExecutor = ({ initialCode = '' }) => {
|
| 20 |
+
const [code, setCode] = useState(initialCode);
|
| 21 |
+
const [output, setOutput] = useState([]);
|
| 22 |
+
const [isRunning, setIsRunning] = useState(false);
|
| 23 |
+
const [needsInput, setNeedsInput] = useState(false);
|
| 24 |
+
const [contextId, setContextId] = useState(null);
|
| 25 |
+
const [userInput, setUserInput] = useState('');
|
| 26 |
+
const outputEndRef = useRef(null);
|
| 27 |
+
|
| 28 |
+
useEffect(() => {
|
| 29 |
+
if (initialCode) setCode(initialCode);
|
| 30 |
+
}, [initialCode]);
|
| 31 |
+
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
outputEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 34 |
+
}, [output]);
|
| 35 |
+
|
| 36 |
+
const appendOutput = (type, text) => {
|
| 37 |
+
setOutput(prev => [...prev, { type, text }]);
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const handleRun = async () => {
|
| 41 |
+
if (!code.trim()) return;
|
| 42 |
+
setIsRunning(true);
|
| 43 |
+
setNeedsInput(false);
|
| 44 |
+
setOutput([{ type: 'info', text: '>>> 运行中...' }]);
|
| 45 |
+
|
| 46 |
+
try {
|
| 47 |
+
const res = await api.post('/code/execute', { code });
|
| 48 |
+
|
| 49 |
+
if (!res.success) {
|
| 50 |
+
appendOutput('error', res.error || '执行失败');
|
| 51 |
+
if (res.traceback) appendOutput('error', res.traceback);
|
| 52 |
+
setIsRunning(false);
|
| 53 |
+
return;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// 显示初始输出
|
| 57 |
+
if (res.output) {
|
| 58 |
+
appendOutput('stdout', res.output);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
if (res.needsInput) {
|
| 62 |
+
// 代码需要交互输入
|
| 63 |
+
setContextId(res.context_id);
|
| 64 |
+
setNeedsInput(true);
|
| 65 |
+
} else {
|
| 66 |
+
// 代码直接执行完了
|
| 67 |
+
appendOutput('info', '>>> 执行完成');
|
| 68 |
+
setIsRunning(false);
|
| 69 |
+
}
|
| 70 |
+
} catch (error) {
|
| 71 |
+
appendOutput('error', `错误: ${error.message}`);
|
| 72 |
+
setIsRunning(false);
|
| 73 |
+
}
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
const handleInput = async () => {
|
| 77 |
+
if (!contextId) return;
|
| 78 |
+
|
| 79 |
+
const inputText = userInput;
|
| 80 |
+
appendOutput('input', `<<< ${inputText}`);
|
| 81 |
+
setUserInput('');
|
| 82 |
+
setNeedsInput(false);
|
| 83 |
+
|
| 84 |
+
try {
|
| 85 |
+
const res = await api.post('/code/input', {
|
| 86 |
+
context_id: contextId,
|
| 87 |
+
input: inputText
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
if (!res.success) {
|
| 91 |
+
appendOutput('error', res.error || '输入处理失败');
|
| 92 |
+
if (res.traceback) appendOutput('error', res.traceback);
|
| 93 |
+
setIsRunning(false);
|
| 94 |
+
setContextId(null);
|
| 95 |
+
return;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
if (res.output) {
|
| 99 |
+
appendOutput('stdout', res.output);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
if (res.needsInput) {
|
| 103 |
+
// 还需要更多输入
|
| 104 |
+
setNeedsInput(true);
|
| 105 |
+
} else {
|
| 106 |
+
// 执行完成
|
| 107 |
+
appendOutput('info', '>>> 执行完成');
|
| 108 |
+
setIsRunning(false);
|
| 109 |
+
setContextId(null);
|
| 110 |
+
}
|
| 111 |
+
} catch (error) {
|
| 112 |
+
appendOutput('error', `错误: ${error.message}`);
|
| 113 |
+
setIsRunning(false);
|
| 114 |
+
setContextId(null);
|
| 115 |
+
}
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
const handleStop = async () => {
|
| 119 |
+
if (contextId) {
|
| 120 |
+
try {
|
| 121 |
+
const res = await api.post('/code/stop', { context_id: contextId });
|
| 122 |
+
if (res.output) {
|
| 123 |
+
appendOutput('stdout', res.output);
|
| 124 |
+
}
|
| 125 |
+
} catch (e) { /* ignore */ }
|
| 126 |
+
}
|
| 127 |
+
appendOutput('warning', '>>> 已终止');
|
| 128 |
+
setIsRunning(false);
|
| 129 |
+
setNeedsInput(false);
|
| 130 |
+
setContextId(null);
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
const handleClear = () => {
|
| 134 |
+
setOutput([]);
|
| 135 |
+
};
|
| 136 |
+
|
| 137 |
+
const getOutputColor = (type) => {
|
| 138 |
+
switch (type) {
|
| 139 |
+
case 'error': return '#ef4444';
|
| 140 |
+
case 'warning': return '#f59e0b';
|
| 141 |
+
case 'input': return '#10b981';
|
| 142 |
+
case 'info': return '#6b7280';
|
| 143 |
+
default: return '#e5e7eb';
|
| 144 |
+
}
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
return (
|
| 148 |
+
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: '8px' }}>
|
| 149 |
+
{/* 工具栏 */}
|
| 150 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 151 |
+
<Text strong style={{ fontSize: '13px' }}>
|
| 152 |
+
<CodeOutlined /> Python 编辑器
|
| 153 |
+
</Text>
|
| 154 |
+
<Space size="small">
|
| 155 |
+
{!isRunning ? (
|
| 156 |
+
<Button
|
| 157 |
+
type="primary"
|
| 158 |
+
size="small"
|
| 159 |
+
icon={<PlayCircleOutlined />}
|
| 160 |
+
onClick={handleRun}
|
| 161 |
+
style={{ background: '#10b981', borderColor: '#10b981' }}
|
| 162 |
+
>
|
| 163 |
+
运行
|
| 164 |
+
</Button>
|
| 165 |
+
) : (
|
| 166 |
+
<Button
|
| 167 |
+
danger
|
| 168 |
+
size="small"
|
| 169 |
+
icon={<StopOutlined />}
|
| 170 |
+
onClick={handleStop}
|
| 171 |
+
>
|
| 172 |
+
停止
|
| 173 |
+
</Button>
|
| 174 |
+
)}
|
| 175 |
+
<Button
|
| 176 |
+
size="small"
|
| 177 |
+
icon={<ClearOutlined />}
|
| 178 |
+
onClick={handleClear}
|
| 179 |
+
>
|
| 180 |
+
清除
|
| 181 |
+
</Button>
|
| 182 |
+
</Space>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
{/* 代码编辑器 */}
|
| 186 |
+
<div style={{
|
| 187 |
+
flex: '0 0 auto',
|
| 188 |
+
maxHeight: '45%',
|
| 189 |
+
overflow: 'auto',
|
| 190 |
+
borderRadius: '6px',
|
| 191 |
+
border: '1px solid rgba(0,0,0,0.1)'
|
| 192 |
+
}}>
|
| 193 |
+
<CodeMirror
|
| 194 |
+
value={code}
|
| 195 |
+
height="auto"
|
| 196 |
+
minHeight="120px"
|
| 197 |
+
maxHeight="300px"
|
| 198 |
+
extensions={[python()]}
|
| 199 |
+
onChange={(val) => setCode(val)}
|
| 200 |
+
theme="light"
|
| 201 |
+
basicSetup={{
|
| 202 |
+
lineNumbers: true,
|
| 203 |
+
highlightActiveLine: true,
|
| 204 |
+
foldGutter: true,
|
| 205 |
+
}}
|
| 206 |
+
/>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
{/* 终端输出 */}
|
| 210 |
+
<div style={{
|
| 211 |
+
flex: 1,
|
| 212 |
+
minHeight: '120px',
|
| 213 |
+
background: '#1e1e1e',
|
| 214 |
+
borderRadius: '6px',
|
| 215 |
+
padding: '12px',
|
| 216 |
+
overflow: 'auto',
|
| 217 |
+
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
| 218 |
+
fontSize: '13px',
|
| 219 |
+
}}>
|
| 220 |
+
{output.map((line, i) => (
|
| 221 |
+
<div key={i} style={{ color: getOutputColor(line.type), lineHeight: '1.6', whiteSpace: 'pre-wrap' }}>
|
| 222 |
+
{line.text}
|
| 223 |
+
</div>
|
| 224 |
+
))}
|
| 225 |
+
{needsInput && (
|
| 226 |
+
<div style={{ display: 'flex', gap: '8px', marginTop: '8px', alignItems: 'center' }}>
|
| 227 |
+
<span style={{ color: '#10b981', flexShrink: 0 }}>{'>>>'}</span>
|
| 228 |
+
<Input
|
| 229 |
+
size="small"
|
| 230 |
+
value={userInput}
|
| 231 |
+
onChange={e => setUserInput(e.target.value)}
|
| 232 |
+
onPressEnter={handleInput}
|
| 233 |
+
placeholder="输入内容后按回车..."
|
| 234 |
+
style={{
|
| 235 |
+
flex: 1,
|
| 236 |
+
background: '#2d2d2d',
|
| 237 |
+
border: '1px solid #444',
|
| 238 |
+
color: '#e5e7eb',
|
| 239 |
+
fontFamily: 'monospace'
|
| 240 |
+
}}
|
| 241 |
+
autoFocus
|
| 242 |
+
/>
|
| 243 |
+
</div>
|
| 244 |
+
)}
|
| 245 |
+
<div ref={outputEndRef} />
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
);
|
| 249 |
+
};
|
| 250 |
+
|
| 251 |
+
export default CodeExecutor;
|
frontend/src/components/plugins/MindmapViewer.jsx
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 思维导图插件组件
|
| 3 |
+
* 使用 markmap 从 Markdown 文本渲染交互式 SVG 思维导图
|
| 4 |
+
*/
|
| 5 |
+
import { useState, useRef, useEffect, useCallback } from 'react';
|
| 6 |
+
import { Button, Empty, Typography, Modal } from 'antd';
|
| 7 |
+
import { ExpandOutlined, DownloadOutlined } from '@ant-design/icons';
|
| 8 |
+
|
| 9 |
+
const { Text } = Typography;
|
| 10 |
+
|
| 11 |
+
const MindmapViewer = ({ markdown = '' }) => {
|
| 12 |
+
const svgRef = useRef(null);
|
| 13 |
+
const markmapRef = useRef(null);
|
| 14 |
+
const [fullscreen, setFullscreen] = useState(false);
|
| 15 |
+
const [hasContent, setHasContent] = useState(false);
|
| 16 |
+
|
| 17 |
+
const renderMap = useCallback(async (svgEl, md) => {
|
| 18 |
+
if (!svgEl || !md) return;
|
| 19 |
+
|
| 20 |
+
try {
|
| 21 |
+
// 动态导入 markmap(避免 SSR 问题)
|
| 22 |
+
const { Transformer } = await import('markmap-lib');
|
| 23 |
+
const { Markmap } = await import('markmap-view');
|
| 24 |
+
|
| 25 |
+
const transformer = new Transformer();
|
| 26 |
+
const { root } = transformer.transform(md);
|
| 27 |
+
|
| 28 |
+
// 清除旧内容
|
| 29 |
+
svgEl.innerHTML = '';
|
| 30 |
+
|
| 31 |
+
// 渲染
|
| 32 |
+
markmapRef.current = Markmap.create(svgEl, {
|
| 33 |
+
autoFit: true,
|
| 34 |
+
duration: 300,
|
| 35 |
+
maxWidth: 300,
|
| 36 |
+
}, root);
|
| 37 |
+
|
| 38 |
+
setHasContent(true);
|
| 39 |
+
} catch (e) {
|
| 40 |
+
console.error('Markmap 渲染失败:', e);
|
| 41 |
+
}
|
| 42 |
+
}, []);
|
| 43 |
+
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
if (markdown && svgRef.current) {
|
| 46 |
+
renderMap(svgRef.current, markdown);
|
| 47 |
+
}
|
| 48 |
+
}, [markdown, renderMap]);
|
| 49 |
+
|
| 50 |
+
const handleExport = () => {
|
| 51 |
+
if (!svgRef.current) return;
|
| 52 |
+
const svgData = new XMLSerializer().serializeToString(svgRef.current);
|
| 53 |
+
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
| 54 |
+
const url = URL.createObjectURL(blob);
|
| 55 |
+
const a = document.createElement('a');
|
| 56 |
+
a.href = url;
|
| 57 |
+
a.download = 'mindmap.svg';
|
| 58 |
+
a.click();
|
| 59 |
+
URL.revokeObjectURL(url);
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: '8px' }}>
|
| 64 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 65 |
+
<Text strong style={{ fontSize: '13px' }}>思维导图</Text>
|
| 66 |
+
<div>
|
| 67 |
+
{hasContent && (
|
| 68 |
+
<>
|
| 69 |
+
<Button
|
| 70 |
+
size="small"
|
| 71 |
+
icon={<ExpandOutlined />}
|
| 72 |
+
onClick={() => setFullscreen(true)}
|
| 73 |
+
style={{ marginRight: '8px' }}
|
| 74 |
+
>
|
| 75 |
+
全屏
|
| 76 |
+
</Button>
|
| 77 |
+
<Button
|
| 78 |
+
size="small"
|
| 79 |
+
icon={<DownloadOutlined />}
|
| 80 |
+
onClick={handleExport}
|
| 81 |
+
>
|
| 82 |
+
导出 SVG
|
| 83 |
+
</Button>
|
| 84 |
+
</>
|
| 85 |
+
)}
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
<div style={{
|
| 90 |
+
flex: 1,
|
| 91 |
+
borderRadius: '6px',
|
| 92 |
+
border: '1px solid rgba(0,0,0,0.1)',
|
| 93 |
+
overflow: 'hidden',
|
| 94 |
+
background: '#fff',
|
| 95 |
+
minHeight: '300px',
|
| 96 |
+
display: 'flex',
|
| 97 |
+
alignItems: 'center',
|
| 98 |
+
justifyContent: 'center',
|
| 99 |
+
}}>
|
| 100 |
+
{markdown ? (
|
| 101 |
+
<svg
|
| 102 |
+
ref={svgRef}
|
| 103 |
+
style={{ width: '100%', height: '100%', minHeight: '300px' }}
|
| 104 |
+
/>
|
| 105 |
+
) : (
|
| 106 |
+
<Empty description="等待生成思维导图..." />
|
| 107 |
+
)}
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
{/* 全屏模态框 */}
|
| 111 |
+
<Modal
|
| 112 |
+
open={fullscreen}
|
| 113 |
+
onCancel={() => setFullscreen(false)}
|
| 114 |
+
footer={null}
|
| 115 |
+
width="90vw"
|
| 116 |
+
style={{ top: '5vh' }}
|
| 117 |
+
styles={{ body: { height: '80vh', padding: '12px' } }}
|
| 118 |
+
destroyOnClose
|
| 119 |
+
afterOpenChange={(open) => {
|
| 120 |
+
if (open && markdown) {
|
| 121 |
+
// 全屏模态框中重新渲染
|
| 122 |
+
setTimeout(() => {
|
| 123 |
+
const modalSvg = document.querySelector('.ant-modal-body svg');
|
| 124 |
+
if (modalSvg) renderMap(modalSvg, markdown);
|
| 125 |
+
}, 100);
|
| 126 |
+
}
|
| 127 |
+
}}
|
| 128 |
+
>
|
| 129 |
+
<svg style={{ width: '100%', height: '100%' }} />
|
| 130 |
+
</Modal>
|
| 131 |
+
</div>
|
| 132 |
+
);
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
export default MindmapViewer;
|
frontend/src/components/plugins/Visualization3D.jsx
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 3D 可视化插件组件
|
| 3 |
+
* 后端生成 Plotly HTML,前端通过 iframe 加载
|
| 4 |
+
*/
|
| 5 |
+
import { useState, useEffect } from 'react';
|
| 6 |
+
import { Button, Spin, Typography, Modal, Empty } from 'antd';
|
| 7 |
+
import {
|
| 8 |
+
ExpandOutlined,
|
| 9 |
+
ReloadOutlined
|
| 10 |
+
} from '@ant-design/icons';
|
| 11 |
+
import api from '../../services/api';
|
| 12 |
+
|
| 13 |
+
const { Text } = Typography;
|
| 14 |
+
|
| 15 |
+
const Visualization3D = ({ code = '' }) => {
|
| 16 |
+
const [htmlUrl, setHtmlUrl] = useState(null);
|
| 17 |
+
const [loading, setLoading] = useState(false);
|
| 18 |
+
const [error, setError] = useState(null);
|
| 19 |
+
const [fullscreen, setFullscreen] = useState(false);
|
| 20 |
+
|
| 21 |
+
const generate = async (codeToRun) => {
|
| 22 |
+
const targetCode = codeToRun || code;
|
| 23 |
+
if (!targetCode) return;
|
| 24 |
+
|
| 25 |
+
setLoading(true);
|
| 26 |
+
setError(null);
|
| 27 |
+
|
| 28 |
+
try {
|
| 29 |
+
const response = await api.post('/visualization/3d-surface', { code: targetCode });
|
| 30 |
+
if (response.success) {
|
| 31 |
+
// html_url 是相对于后端的路径,需要加上后端地址
|
| 32 |
+
setHtmlUrl(`http://localhost:7860${response.html_url}`);
|
| 33 |
+
} else {
|
| 34 |
+
setError(response.message || '生成失败');
|
| 35 |
+
}
|
| 36 |
+
} catch (e) {
|
| 37 |
+
setError(e.message || '请求失败');
|
| 38 |
+
} finally {
|
| 39 |
+
setLoading(false);
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
// 当 code 变化时自动生成
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
if (code) generate(code);
|
| 46 |
+
}, [code]);
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: '8px' }}>
|
| 50 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 51 |
+
<Text strong style={{ fontSize: '13px' }}>3D 可视化</Text>
|
| 52 |
+
<div>
|
| 53 |
+
{htmlUrl && (
|
| 54 |
+
<Button
|
| 55 |
+
size="small"
|
| 56 |
+
icon={<ExpandOutlined />}
|
| 57 |
+
onClick={() => setFullscreen(true)}
|
| 58 |
+
style={{ marginRight: '8px' }}
|
| 59 |
+
>
|
| 60 |
+
全屏
|
| 61 |
+
</Button>
|
| 62 |
+
)}
|
| 63 |
+
<Button
|
| 64 |
+
size="small"
|
| 65 |
+
icon={<ReloadOutlined />}
|
| 66 |
+
onClick={() => generate()}
|
| 67 |
+
disabled={!code || loading}
|
| 68 |
+
>
|
| 69 |
+
重新生成
|
| 70 |
+
</Button>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div style={{
|
| 75 |
+
flex: 1,
|
| 76 |
+
borderRadius: '6px',
|
| 77 |
+
border: '1px solid rgba(0,0,0,0.1)',
|
| 78 |
+
overflow: 'hidden',
|
| 79 |
+
position: 'relative',
|
| 80 |
+
background: '#fff',
|
| 81 |
+
minHeight: '300px'
|
| 82 |
+
}}>
|
| 83 |
+
{loading ? (
|
| 84 |
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
| 85 |
+
<Spin tip="正在生成3D图形..."><div style={{ height: 80 }} /></Spin>
|
| 86 |
+
</div>
|
| 87 |
+
) : error ? (
|
| 88 |
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
| 89 |
+
<Empty description={<Text type="danger">{error}</Text>} />
|
| 90 |
+
</div>
|
| 91 |
+
) : htmlUrl ? (
|
| 92 |
+
<iframe
|
| 93 |
+
src={htmlUrl}
|
| 94 |
+
style={{
|
| 95 |
+
position: 'absolute',
|
| 96 |
+
top: 0,
|
| 97 |
+
left: 0,
|
| 98 |
+
width: '100%',
|
| 99 |
+
height: '100%',
|
| 100 |
+
border: 'none',
|
| 101 |
+
}}
|
| 102 |
+
title="3D Visualization"
|
| 103 |
+
/>
|
| 104 |
+
) : (
|
| 105 |
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
| 106 |
+
<Empty description="等待生成3D图形..." />
|
| 107 |
+
</div>
|
| 108 |
+
)}
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
{/* 全屏模态框 */}
|
| 112 |
+
<Modal
|
| 113 |
+
open={fullscreen}
|
| 114 |
+
onCancel={() => setFullscreen(false)}
|
| 115 |
+
footer={null}
|
| 116 |
+
width="90vw"
|
| 117 |
+
style={{ top: '5vh' }}
|
| 118 |
+
styles={{ body: { height: '80vh', padding: 0 } }}
|
| 119 |
+
destroyOnClose
|
| 120 |
+
>
|
| 121 |
+
{htmlUrl && (
|
| 122 |
+
<iframe
|
| 123 |
+
src={htmlUrl}
|
| 124 |
+
style={{ width: '100%', height: '80vh', border: 'none' }}
|
| 125 |
+
title="3D Visualization Fullscreen"
|
| 126 |
+
/>
|
| 127 |
+
)}
|
| 128 |
+
</Modal>
|
| 129 |
+
</div>
|
| 130 |
+
);
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
export default Visualization3D;
|
frontend/src/hooks/useTypewriter.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 打字机效果Hook
|
| 3 |
+
* 实现逐字显示文本的效果
|
| 4 |
+
*/
|
| 5 |
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
| 6 |
+
|
| 7 |
+
const useTypewriter = (text, options = {}) => {
|
| 8 |
+
const {
|
| 9 |
+
speed = 30, // 打字速度(毫秒)
|
| 10 |
+
onComplete = () => {}, // 完成回调
|
| 11 |
+
enabled = true // 是否启用打字机效果
|
| 12 |
+
} = options;
|
| 13 |
+
|
| 14 |
+
const [displayText, setDisplayText] = useState('');
|
| 15 |
+
const [isTyping, setIsTyping] = useState(false);
|
| 16 |
+
const [isPaused, setIsPaused] = useState(false);
|
| 17 |
+
const indexRef = useRef(0);
|
| 18 |
+
const intervalRef = useRef(null);
|
| 19 |
+
|
| 20 |
+
// 开始打字
|
| 21 |
+
const startTyping = useCallback(() => {
|
| 22 |
+
if (!enabled || !text) {
|
| 23 |
+
setDisplayText(text);
|
| 24 |
+
return;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
setIsTyping(true);
|
| 28 |
+
setIsPaused(false);
|
| 29 |
+
indexRef.current = 0;
|
| 30 |
+
setDisplayText('');
|
| 31 |
+
|
| 32 |
+
intervalRef.current = setInterval(() => {
|
| 33 |
+
if (indexRef.current < text.length) {
|
| 34 |
+
setDisplayText(prev => prev + text[indexRef.current]);
|
| 35 |
+
indexRef.current++;
|
| 36 |
+
} else {
|
| 37 |
+
// 打字完成
|
| 38 |
+
clearInterval(intervalRef.current);
|
| 39 |
+
setIsTyping(false);
|
| 40 |
+
onComplete();
|
| 41 |
+
}
|
| 42 |
+
}, speed);
|
| 43 |
+
}, [text, speed, enabled, onComplete]);
|
| 44 |
+
|
| 45 |
+
// 暂停打字
|
| 46 |
+
const pauseTyping = useCallback(() => {
|
| 47 |
+
if (intervalRef.current) {
|
| 48 |
+
clearInterval(intervalRef.current);
|
| 49 |
+
setIsPaused(true);
|
| 50 |
+
}
|
| 51 |
+
}, []);
|
| 52 |
+
|
| 53 |
+
// 继续打字
|
| 54 |
+
const resumeTyping = useCallback(() => {
|
| 55 |
+
if (isPaused && indexRef.current < text.length) {
|
| 56 |
+
setIsPaused(false);
|
| 57 |
+
intervalRef.current = setInterval(() => {
|
| 58 |
+
if (indexRef.current < text.length) {
|
| 59 |
+
setDisplayText(prev => prev + text[indexRef.current]);
|
| 60 |
+
indexRef.current++;
|
| 61 |
+
} else {
|
| 62 |
+
clearInterval(intervalRef.current);
|
| 63 |
+
setIsTyping(false);
|
| 64 |
+
onComplete();
|
| 65 |
+
}
|
| 66 |
+
}, speed);
|
| 67 |
+
}
|
| 68 |
+
}, [isPaused, text, speed, onComplete]);
|
| 69 |
+
|
| 70 |
+
// 立即显示全部文本
|
| 71 |
+
const skipTyping = useCallback(() => {
|
| 72 |
+
if (intervalRef.current) {
|
| 73 |
+
clearInterval(intervalRef.current);
|
| 74 |
+
}
|
| 75 |
+
setDisplayText(text);
|
| 76 |
+
setIsTyping(false);
|
| 77 |
+
indexRef.current = text.length;
|
| 78 |
+
onComplete();
|
| 79 |
+
}, [text, onComplete]);
|
| 80 |
+
|
| 81 |
+
// 当文本改变时重新开始
|
| 82 |
+
useEffect(() => {
|
| 83 |
+
return () => {
|
| 84 |
+
if (intervalRef.current) {
|
| 85 |
+
clearInterval(intervalRef.current);
|
| 86 |
+
}
|
| 87 |
+
};
|
| 88 |
+
}, []);
|
| 89 |
+
|
| 90 |
+
useEffect(() => {
|
| 91 |
+
if (text && enabled) {
|
| 92 |
+
startTyping();
|
| 93 |
+
} else {
|
| 94 |
+
setDisplayText(text);
|
| 95 |
+
}
|
| 96 |
+
}, [text, enabled]);
|
| 97 |
+
|
| 98 |
+
return {
|
| 99 |
+
displayText,
|
| 100 |
+
isTyping,
|
| 101 |
+
isPaused,
|
| 102 |
+
pauseTyping,
|
| 103 |
+
resumeTyping,
|
| 104 |
+
skipTyping
|
| 105 |
+
};
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
export default useTypewriter;
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import 'tailwindcss/base';
|
| 2 |
+
@import 'tailwindcss/components';
|
| 3 |
+
@import 'tailwindcss/utilities';
|
| 4 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
| 5 |
+
|
| 6 |
+
/* 全局样式 */
|
| 7 |
+
@layer base {
|
| 8 |
+
:root {
|
| 9 |
+
/* 主题色 */
|
| 10 |
+
--color-primary: #10b981;
|
| 11 |
+
--color-primary-light: #d1fae5;
|
| 12 |
+
--color-secondary: #f97316;
|
| 13 |
+
--color-secondary-light: #fed7aa;
|
| 14 |
+
--color-tertiary: #8b5cf6;
|
| 15 |
+
--color-tertiary-light: #ddd6fe;
|
| 16 |
+
--color-accent: #14b8a6;
|
| 17 |
+
--color-accent-light: #ccfbf1;
|
| 18 |
+
--color-danger: #f43f5e;
|
| 19 |
+
--color-danger-light: #fecdd3;
|
| 20 |
+
|
| 21 |
+
/* 中性色 */
|
| 22 |
+
--color-neutral-50: #fafbfc;
|
| 23 |
+
--color-neutral-100: #f3f4f6;
|
| 24 |
+
--color-neutral-200: #e5e7eb;
|
| 25 |
+
--color-neutral-300: #d1d5db;
|
| 26 |
+
--color-neutral-400: #9ca3af;
|
| 27 |
+
--color-neutral-500: #6b7280;
|
| 28 |
+
--color-neutral-600: #4b5563;
|
| 29 |
+
--color-neutral-700: #374151;
|
| 30 |
+
--color-neutral-800: #1f2937;
|
| 31 |
+
--color-neutral-900: #111827;
|
| 32 |
+
|
| 33 |
+
/* 阴影 */
|
| 34 |
+
--shadow-soft: 0 2px 8px rgba(0, 0, 0, 0.04);
|
| 35 |
+
--shadow-medium: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
| 36 |
+
--shadow-large: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
* {
|
| 40 |
+
margin: 0;
|
| 41 |
+
padding: 0;
|
| 42 |
+
box-sizing: border-box;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
body {
|
| 46 |
+
font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
| 47 |
+
background-color: var(--color-neutral-50);
|
| 48 |
+
color: var(--color-neutral-800);
|
| 49 |
+
-webkit-font-smoothing: antialiased;
|
| 50 |
+
-moz-osx-font-smoothing: grayscale;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/* 自定义滚动条 */
|
| 54 |
+
::-webkit-scrollbar {
|
| 55 |
+
width: 8px;
|
| 56 |
+
height: 8px;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
::-webkit-scrollbar-track {
|
| 60 |
+
background: var(--color-neutral-100);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
::-webkit-scrollbar-thumb {
|
| 64 |
+
background: var(--color-neutral-400);
|
| 65 |
+
border-radius: 4px;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
::-webkit-scrollbar-thumb:hover {
|
| 69 |
+
background: var(--color-neutral-500);
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/* 卡片样式 */
|
| 74 |
+
@layer components {
|
| 75 |
+
.card {
|
| 76 |
+
@apply bg-white rounded-lg p-6 transition-all duration-300;
|
| 77 |
+
box-shadow: var(--shadow-soft);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.card:hover {
|
| 81 |
+
transform: translateY(-2px);
|
| 82 |
+
box-shadow: var(--shadow-medium);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/* 按钮基础样式 */
|
| 86 |
+
.btn {
|
| 87 |
+
@apply px-4 py-2 rounded-lg font-medium transition-all duration-300 inline-flex items-center justify-center;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.btn-primary {
|
| 91 |
+
@apply bg-primary text-white hover:bg-primary/90;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.btn-secondary {
|
| 95 |
+
@apply bg-secondary text-white hover:bg-secondary/90;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.btn-tertiary {
|
| 99 |
+
@apply bg-tertiary text-white hover:bg-tertiary/90;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.btn-outline {
|
| 103 |
+
@apply border-2 border-primary text-primary hover:bg-primary hover:text-white;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/* 浮动背景球 */
|
| 107 |
+
.floating-ball {
|
| 108 |
+
position: absolute;
|
| 109 |
+
border-radius: 50%;
|
| 110 |
+
filter: blur(100px);
|
| 111 |
+
opacity: 0.15;
|
| 112 |
+
animation: float 20s ease-in-out infinite;
|
| 113 |
+
pointer-events: none;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.floating-ball-1 {
|
| 117 |
+
width: 300px;
|
| 118 |
+
height: 300px;
|
| 119 |
+
background: linear-gradient(135deg, var(--color-primary), var(--color-accent));
|
| 120 |
+
top: -150px;
|
| 121 |
+
left: -150px;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.floating-ball-2 {
|
| 125 |
+
width: 400px;
|
| 126 |
+
height: 400px;
|
| 127 |
+
background: linear-gradient(135deg, var(--color-secondary), var(--color-tertiary));
|
| 128 |
+
bottom: -200px;
|
| 129 |
+
right: -200px;
|
| 130 |
+
animation-delay: 5s;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.floating-ball-3 {
|
| 134 |
+
width: 250px;
|
| 135 |
+
height: 250px;
|
| 136 |
+
background: linear-gradient(135deg, var(--color-tertiary), var(--color-primary));
|
| 137 |
+
top: 50%;
|
| 138 |
+
left: 50%;
|
| 139 |
+
transform: translate(-50%, -50%);
|
| 140 |
+
animation-delay: 10s;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/* 加载动画 */
|
| 144 |
+
.loading-spinner {
|
| 145 |
+
@apply inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full;
|
| 146 |
+
animation: spin 1s linear infinite;
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/* 动画 */
|
| 151 |
+
@layer utilities {
|
| 152 |
+
@keyframes float {
|
| 153 |
+
0%, 100% {
|
| 154 |
+
transform: translate(0, 0) scale(1);
|
| 155 |
+
}
|
| 156 |
+
33% {
|
| 157 |
+
transform: translate(30px, -30px) scale(1.1);
|
| 158 |
+
}
|
| 159 |
+
66% {
|
| 160 |
+
transform: translate(-20px, 20px) scale(0.9);
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
@keyframes spin {
|
| 165 |
+
to {
|
| 166 |
+
transform: rotate(360deg);
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
/* Ant Design 覆盖 */
|
| 172 |
+
.ant-btn-primary {
|
| 173 |
+
background-color: var(--color-primary);
|
| 174 |
+
border-color: var(--color-primary);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.ant-btn-primary:hover {
|
| 178 |
+
background-color: var(--color-primary);
|
| 179 |
+
border-color: var(--color-primary);
|
| 180 |
+
opacity: 0.9;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.ant-menu-item-selected {
|
| 184 |
+
color: var(--color-primary) !important;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.ant-menu-item-selected::after {
|
| 188 |
+
border-color: var(--color-primary) !important;
|
| 189 |
+
}
|
frontend/src/main.jsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
+
import App from './App.jsx'
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
)
|
frontend/src/pages/LoginPage.jsx
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { useNavigate } from 'react-router-dom';
|
| 3 |
+
import { useDispatch } from 'react-redux';
|
| 4 |
+
import { Form, Input, Button, Tabs, message, Divider, Space } from 'antd';
|
| 5 |
+
import {
|
| 6 |
+
MailOutlined,
|
| 7 |
+
PhoneOutlined,
|
| 8 |
+
SafetyOutlined,
|
| 9 |
+
UserOutlined,
|
| 10 |
+
LockOutlined,
|
| 11 |
+
} from '@ant-design/icons';
|
| 12 |
+
import { motion } from 'framer-motion';
|
| 13 |
+
import authService from '../services/authService';
|
| 14 |
+
import { loginSuccess } from '../store/slices/authSlice';
|
| 15 |
+
|
| 16 |
+
const LoginPage = () => {
|
| 17 |
+
const [form] = Form.useForm();
|
| 18 |
+
const navigate = useNavigate();
|
| 19 |
+
const dispatch = useDispatch();
|
| 20 |
+
const [loading, setLoading] = useState(false);
|
| 21 |
+
const [activeTab, setActiveTab] = useState('email'); // 默认使用邮箱登录
|
| 22 |
+
const [userRole, setUserRole] = useState('student'); // 默认学生角色
|
| 23 |
+
const [countdown, setCountdown] = useState(0);
|
| 24 |
+
|
| 25 |
+
// 发送邮箱验证码
|
| 26 |
+
const handleSendEmailCode = async () => {
|
| 27 |
+
const email = form.getFieldValue('email');
|
| 28 |
+
if (!email) {
|
| 29 |
+
message.warning('请输入邮箱地址');
|
| 30 |
+
return;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
try {
|
| 34 |
+
setLoading(true);
|
| 35 |
+
await authService.sendEmailCode(email);
|
| 36 |
+
message.success('验证码已发送到您的邮箱');
|
| 37 |
+
|
| 38 |
+
// 开始倒计时
|
| 39 |
+
let count = 60;
|
| 40 |
+
setCountdown(count);
|
| 41 |
+
const timer = setInterval(() => {
|
| 42 |
+
count--;
|
| 43 |
+
setCountdown(count);
|
| 44 |
+
if (count <= 0) {
|
| 45 |
+
clearInterval(timer);
|
| 46 |
+
}
|
| 47 |
+
}, 1000);
|
| 48 |
+
} catch (error) {
|
| 49 |
+
message.error('发送验证码失败');
|
| 50 |
+
} finally {
|
| 51 |
+
setLoading(false);
|
| 52 |
+
}
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
// 发送手机验证码(模拟)
|
| 56 |
+
const handleSendPhoneCode = async () => {
|
| 57 |
+
const phone = form.getFieldValue('phone');
|
| 58 |
+
if (!phone) {
|
| 59 |
+
message.warning('请输入手机号');
|
| 60 |
+
return;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
setLoading(true);
|
| 64 |
+
// 模拟发送
|
| 65 |
+
setTimeout(() => {
|
| 66 |
+
message.success('测试验证码:123456(开发模式)');
|
| 67 |
+
setLoading(false);
|
| 68 |
+
|
| 69 |
+
// 开始倒计时
|
| 70 |
+
let count = 60;
|
| 71 |
+
setCountdown(count);
|
| 72 |
+
const timer = setInterval(() => {
|
| 73 |
+
count--;
|
| 74 |
+
setCountdown(count);
|
| 75 |
+
if (count <= 0) {
|
| 76 |
+
clearInterval(timer);
|
| 77 |
+
}
|
| 78 |
+
}, 1000);
|
| 79 |
+
}, 1000);
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
// 处理登录
|
| 83 |
+
const handleLogin = async (values) => {
|
| 84 |
+
try {
|
| 85 |
+
setLoading(true);
|
| 86 |
+
let response;
|
| 87 |
+
|
| 88 |
+
switch (activeTab) {
|
| 89 |
+
case 'email':
|
| 90 |
+
response = await authService.loginWithEmailCode(values.email, values.emailCode, userRole);
|
| 91 |
+
break;
|
| 92 |
+
case 'phone':
|
| 93 |
+
response = await authService.loginWithPhoneCode(values.phone, values.phoneCode, userRole);
|
| 94 |
+
break;
|
| 95 |
+
case 'password':
|
| 96 |
+
response = await authService.loginWithPassword(values.username, values.password);
|
| 97 |
+
break;
|
| 98 |
+
default:
|
| 99 |
+
break;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
if (response.success) {
|
| 103 |
+
dispatch(loginSuccess({
|
| 104 |
+
user: response.data.user,
|
| 105 |
+
token: response.data.token,
|
| 106 |
+
}));
|
| 107 |
+
message.success('登录成功');
|
| 108 |
+
|
| 109 |
+
// 根据角色跳转
|
| 110 |
+
if (response.data.user.role === 'teacher') {
|
| 111 |
+
navigate('/teacher');
|
| 112 |
+
} else {
|
| 113 |
+
navigate('/student');
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
} catch (error) {
|
| 117 |
+
message.error(error.response?.data?.message || '登录失败');
|
| 118 |
+
} finally {
|
| 119 |
+
setLoading(false);
|
| 120 |
+
}
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
const tabItems = [
|
| 124 |
+
{
|
| 125 |
+
key: 'email',
|
| 126 |
+
label: (
|
| 127 |
+
<span className="text-base font-medium">
|
| 128 |
+
邮箱登录
|
| 129 |
+
</span>
|
| 130 |
+
),
|
| 131 |
+
children: (
|
| 132 |
+
<>
|
| 133 |
+
<Form.Item
|
| 134 |
+
name="email"
|
| 135 |
+
rules={[
|
| 136 |
+
{ required: activeTab === 'email', message: '请输入邮箱' },
|
| 137 |
+
{ type: 'email', message: '请输入有效的邮箱地址' },
|
| 138 |
+
]}
|
| 139 |
+
>
|
| 140 |
+
<Input
|
| 141 |
+
prefix={<MailOutlined className="text-neutral-400" />}
|
| 142 |
+
placeholder="邮箱地址"
|
| 143 |
+
size="large"
|
| 144 |
+
className="rounded-lg"
|
| 145 |
+
/>
|
| 146 |
+
</Form.Item>
|
| 147 |
+
<Form.Item
|
| 148 |
+
name="emailCode"
|
| 149 |
+
rules={[{ required: activeTab === 'email', message: '请输入验证码' }]}
|
| 150 |
+
>
|
| 151 |
+
<Space.Compact style={{ width: '100%' }}>
|
| 152 |
+
<Input
|
| 153 |
+
prefix={<SafetyOutlined className="text-neutral-400" />}
|
| 154 |
+
placeholder="验证码"
|
| 155 |
+
size="large"
|
| 156 |
+
className="rounded-l-lg"
|
| 157 |
+
/>
|
| 158 |
+
<Button
|
| 159 |
+
size="large"
|
| 160 |
+
onClick={handleSendEmailCode}
|
| 161 |
+
disabled={countdown > 0}
|
| 162 |
+
className="rounded-r-lg px-6"
|
| 163 |
+
style={{
|
| 164 |
+
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
| 165 |
+
borderColor: 'rgba(16, 185, 129, 0.3)',
|
| 166 |
+
color: '#10b981'
|
| 167 |
+
}}
|
| 168 |
+
>
|
| 169 |
+
{countdown > 0 ? `${countdown}秒后重试` : '发送验证码'}
|
| 170 |
+
</Button>
|
| 171 |
+
</Space.Compact>
|
| 172 |
+
</Form.Item>
|
| 173 |
+
</>
|
| 174 |
+
),
|
| 175 |
+
},
|
| 176 |
+
{
|
| 177 |
+
key: 'phone',
|
| 178 |
+
label: (
|
| 179 |
+
<span className="text-base font-medium">
|
| 180 |
+
手机登录
|
| 181 |
+
</span>
|
| 182 |
+
),
|
| 183 |
+
children: (
|
| 184 |
+
<>
|
| 185 |
+
<Form.Item
|
| 186 |
+
name="phone"
|
| 187 |
+
rules={[
|
| 188 |
+
{ required: activeTab === 'phone', message: '请输入手机号' },
|
| 189 |
+
{ pattern: /^1[3-9]\d{9}$/, message: '请输入有效的手机号' },
|
| 190 |
+
]}
|
| 191 |
+
>
|
| 192 |
+
<Input
|
| 193 |
+
prefix={<PhoneOutlined className="text-neutral-400" />}
|
| 194 |
+
placeholder="手机号"
|
| 195 |
+
size="large"
|
| 196 |
+
className="rounded-lg"
|
| 197 |
+
/>
|
| 198 |
+
</Form.Item>
|
| 199 |
+
<Form.Item
|
| 200 |
+
name="phoneCode"
|
| 201 |
+
rules={[{ required: activeTab === 'phone', message: '请输入验证码' }]}
|
| 202 |
+
>
|
| 203 |
+
<Space.Compact style={{ width: '100%' }}>
|
| 204 |
+
<Input
|
| 205 |
+
prefix={<SafetyOutlined className="text-neutral-400" />}
|
| 206 |
+
placeholder="验证码"
|
| 207 |
+
size="large"
|
| 208 |
+
className="rounded-l-lg"
|
| 209 |
+
/>
|
| 210 |
+
<Button
|
| 211 |
+
size="large"
|
| 212 |
+
onClick={handleSendPhoneCode}
|
| 213 |
+
disabled={countdown > 0}
|
| 214 |
+
className="rounded-r-lg px-6"
|
| 215 |
+
style={{
|
| 216 |
+
backgroundColor: 'rgba(249, 115, 22, 0.1)',
|
| 217 |
+
borderColor: 'rgba(249, 115, 22, 0.3)',
|
| 218 |
+
color: '#f97316'
|
| 219 |
+
}}
|
| 220 |
+
>
|
| 221 |
+
{countdown > 0 ? `${countdown}秒后重试` : '发送验证码'}
|
| 222 |
+
</Button>
|
| 223 |
+
</Space.Compact>
|
| 224 |
+
</Form.Item>
|
| 225 |
+
</>
|
| 226 |
+
),
|
| 227 |
+
},
|
| 228 |
+
{
|
| 229 |
+
key: 'password',
|
| 230 |
+
label: (
|
| 231 |
+
<span className="text-base font-medium">
|
| 232 |
+
密码登录
|
| 233 |
+
</span>
|
| 234 |
+
),
|
| 235 |
+
children: (
|
| 236 |
+
<>
|
| 237 |
+
<Form.Item
|
| 238 |
+
name="username"
|
| 239 |
+
rules={[{ required: activeTab === 'password', message: '请输入用户名' }]}
|
| 240 |
+
>
|
| 241 |
+
<Input
|
| 242 |
+
prefix={<UserOutlined className="text-neutral-400" />}
|
| 243 |
+
placeholder="用户名"
|
| 244 |
+
size="large"
|
| 245 |
+
className="rounded-lg"
|
| 246 |
+
/>
|
| 247 |
+
</Form.Item>
|
| 248 |
+
<Form.Item
|
| 249 |
+
name="password"
|
| 250 |
+
rules={[{ required: activeTab === 'password', message: '请输入密码' }]}
|
| 251 |
+
>
|
| 252 |
+
<Input.Password
|
| 253 |
+
prefix={<LockOutlined className="text-neutral-400" />}
|
| 254 |
+
placeholder="密码"
|
| 255 |
+
size="large"
|
| 256 |
+
className="rounded-lg"
|
| 257 |
+
/>
|
| 258 |
+
</Form.Item>
|
| 259 |
+
</>
|
| 260 |
+
),
|
| 261 |
+
},
|
| 262 |
+
];
|
| 263 |
+
|
| 264 |
+
return (
|
| 265 |
+
<div className="min-h-screen flex relative overflow-hidden" style={{ backgroundColor: '#fafbfc' }}>
|
| 266 |
+
{/* 装饰性背景元素 - 使用单色极淡透明度 */}
|
| 267 |
+
<div
|
| 268 |
+
className="absolute top-20 left-20 w-96 h-96 rounded-full opacity-10"
|
| 269 |
+
style={{
|
| 270 |
+
background: '#10b981',
|
| 271 |
+
filter: 'blur(120px)'
|
| 272 |
+
}}
|
| 273 |
+
/>
|
| 274 |
+
<div
|
| 275 |
+
className="absolute bottom-20 right-20 w-96 h-96 rounded-full opacity-10"
|
| 276 |
+
style={{
|
| 277 |
+
background: '#f97316',
|
| 278 |
+
filter: 'blur(120px)'
|
| 279 |
+
}}
|
| 280 |
+
/>
|
| 281 |
+
|
| 282 |
+
{/* 左侧装饰区 */}
|
| 283 |
+
<div className="hidden lg:flex lg:w-1/2 relative p-12">
|
| 284 |
+
<div className="relative z-10 flex flex-col justify-center items-center w-full">
|
| 285 |
+
<motion.div
|
| 286 |
+
initial={{ opacity: 0, y: 20 }}
|
| 287 |
+
animate={{ opacity: 1, y: 0 }}
|
| 288 |
+
transition={{ duration: 0.6 }}
|
| 289 |
+
className="w-full max-w-lg"
|
| 290 |
+
>
|
| 291 |
+
<h1 className="text-5xl font-bold mb-6">
|
| 292 |
+
<span style={{ color: '#10b981' }}>智能</span>
|
| 293 |
+
<span style={{ color: '#f97316' }} className="mx-2">教育</span>
|
| 294 |
+
<span style={{ color: '#8b5cf6' }}>助手</span>
|
| 295 |
+
</h1>
|
| 296 |
+
<p className="text-xl text-neutral-600 mb-12">
|
| 297 |
+
AI驱动的个性化学习平台
|
| 298 |
+
</p>
|
| 299 |
+
|
| 300 |
+
{/* 特色卡片 - 符合设计要求的卡片 */}
|
| 301 |
+
<div className="grid grid-cols-2 gap-6">
|
| 302 |
+
<motion.div
|
| 303 |
+
className="p-6 bg-white rounded-xl transition-all duration-300"
|
| 304 |
+
style={{
|
| 305 |
+
border: '1px solid rgba(16, 185, 129, 0.2)',
|
| 306 |
+
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)'
|
| 307 |
+
}}
|
| 308 |
+
whileHover={{
|
| 309 |
+
y: -4,
|
| 310 |
+
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
| 311 |
+
borderColor: 'rgba(16, 185, 129, 0.4)'
|
| 312 |
+
}}
|
| 313 |
+
>
|
| 314 |
+
<div className="w-12 h-12 rounded-lg flex items-center justify-center mb-4"
|
| 315 |
+
style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)' }}>
|
| 316 |
+
<svg className="w-6 h-6" style={{ color: '#10b981' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
| 317 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 318 |
+
</svg>
|
| 319 |
+
</div>
|
| 320 |
+
<h3 className="font-semibold text-neutral-800 mb-2">个性化学习</h3>
|
| 321 |
+
<p className="text-sm text-neutral-600">根据学习进度智能调整</p>
|
| 322 |
+
</motion.div>
|
| 323 |
+
|
| 324 |
+
<motion.div
|
| 325 |
+
className="p-6 bg-white rounded-xl transition-all duration-300"
|
| 326 |
+
style={{
|
| 327 |
+
border: '1px solid rgba(249, 115, 22, 0.2)',
|
| 328 |
+
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)'
|
| 329 |
+
}}
|
| 330 |
+
whileHover={{
|
| 331 |
+
y: -4,
|
| 332 |
+
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
| 333 |
+
borderColor: 'rgba(249, 115, 22, 0.4)'
|
| 334 |
+
}}
|
| 335 |
+
>
|
| 336 |
+
<div className="w-12 h-12 rounded-lg flex items-center justify-center mb-4"
|
| 337 |
+
style={{ backgroundColor: 'rgba(249, 115, 22, 0.1)' }}>
|
| 338 |
+
<svg className="w-6 h-6" style={{ color: '#f97316' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
| 339 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
| 340 |
+
</svg>
|
| 341 |
+
</div>
|
| 342 |
+
<h3 className="font-semibold text-neutral-800 mb-2">AI助教</h3>
|
| 343 |
+
<p className="text-sm text-neutral-600">24小时在线答疑解惑</p>
|
| 344 |
+
</motion.div>
|
| 345 |
+
|
| 346 |
+
<motion.div
|
| 347 |
+
className="p-6 bg-white rounded-xl transition-all duration-300"
|
| 348 |
+
style={{
|
| 349 |
+
border: '1px solid rgba(139, 92, 246, 0.2)',
|
| 350 |
+
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)'
|
| 351 |
+
}}
|
| 352 |
+
whileHover={{
|
| 353 |
+
y: -4,
|
| 354 |
+
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
| 355 |
+
borderColor: 'rgba(139, 92, 246, 0.4)'
|
| 356 |
+
}}
|
| 357 |
+
>
|
| 358 |
+
<div className="w-12 h-12 rounded-lg flex items-center justify-center mb-4"
|
| 359 |
+
style={{ backgroundColor: 'rgba(139, 92, 246, 0.1)' }}>
|
| 360 |
+
<svg className="w-6 h-6" style={{ color: '#8b5cf6' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
| 361 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
| 362 |
+
</svg>
|
| 363 |
+
</div>
|
| 364 |
+
<h3 className="font-semibold text-neutral-800 mb-2">实时反馈</h3>
|
| 365 |
+
<p className="text-sm text-neutral-600">即时评估学习效果</p>
|
| 366 |
+
</motion.div>
|
| 367 |
+
|
| 368 |
+
<motion.div
|
| 369 |
+
className="p-6 bg-white rounded-xl transition-all duration-300"
|
| 370 |
+
style={{
|
| 371 |
+
border: '1px solid rgba(20, 184, 166, 0.2)',
|
| 372 |
+
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)'
|
| 373 |
+
}}
|
| 374 |
+
whileHover={{
|
| 375 |
+
y: -4,
|
| 376 |
+
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
| 377 |
+
borderColor: 'rgba(20, 184, 166, 0.4)'
|
| 378 |
+
}}
|
| 379 |
+
>
|
| 380 |
+
<div className="w-12 h-12 rounded-lg flex items-center justify-center mb-4"
|
| 381 |
+
style={{ backgroundColor: 'rgba(20, 184, 166, 0.1)' }}>
|
| 382 |
+
<svg className="w-6 h-6" style={{ color: '#14b8a6' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
| 383 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
| 384 |
+
</svg>
|
| 385 |
+
</div>
|
| 386 |
+
<h3 className="font-semibold text-neutral-800 mb-2">知识管理</h3>
|
| 387 |
+
<p className="text-sm text-neutral-600">系统化知识体系构建</p>
|
| 388 |
+
</motion.div>
|
| 389 |
+
</div>
|
| 390 |
+
</motion.div>
|
| 391 |
+
</div>
|
| 392 |
+
</div>
|
| 393 |
+
|
| 394 |
+
{/* 右侧登录区 */}
|
| 395 |
+
<div className="flex-1 flex items-center justify-center p-8">
|
| 396 |
+
<motion.div
|
| 397 |
+
initial={{ opacity: 0, x: 20 }}
|
| 398 |
+
animate={{ opacity: 1, x: 0 }}
|
| 399 |
+
transition={{ duration: 0.6 }}
|
| 400 |
+
className="w-full max-w-md"
|
| 401 |
+
>
|
| 402 |
+
<div
|
| 403 |
+
className="bg-white rounded-2xl p-8 transition-all duration-300"
|
| 404 |
+
style={{
|
| 405 |
+
border: '1px solid rgba(0, 0, 0, 0.08)',
|
| 406 |
+
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.04)'
|
| 407 |
+
}}
|
| 408 |
+
>
|
| 409 |
+
<div className="text-center mb-8">
|
| 410 |
+
<h2 className="text-3xl font-bold text-neutral-800">欢迎回来</h2>
|
| 411 |
+
<p className="text-neutral-500 mt-2">登录您的账户继续学习</p>
|
| 412 |
+
</div>
|
| 413 |
+
|
| 414 |
+
{/* 角色选择 */}
|
| 415 |
+
<div className="mb-6">
|
| 416 |
+
<div className="flex gap-3 p-1 bg-neutral-100 rounded-lg">
|
| 417 |
+
<button
|
| 418 |
+
type="button"
|
| 419 |
+
onClick={() => setUserRole('teacher')}
|
| 420 |
+
className={`flex-1 py-2.5 px-4 rounded-lg font-medium transition-all duration-200 ${
|
| 421 |
+
userRole === 'teacher'
|
| 422 |
+
? 'bg-white text-emerald-600 shadow-sm'
|
| 423 |
+
: 'text-neutral-600 hover:text-neutral-800'
|
| 424 |
+
}`}
|
| 425 |
+
>
|
| 426 |
+
教师
|
| 427 |
+
</button>
|
| 428 |
+
<button
|
| 429 |
+
type="button"
|
| 430 |
+
onClick={() => setUserRole('student')}
|
| 431 |
+
className={`flex-1 py-2.5 px-4 rounded-lg font-medium transition-all duration-200 ${
|
| 432 |
+
userRole === 'student'
|
| 433 |
+
? 'bg-white text-orange-600 shadow-sm'
|
| 434 |
+
: 'text-neutral-600 hover:text-neutral-800'
|
| 435 |
+
}`}
|
| 436 |
+
>
|
| 437 |
+
学生
|
| 438 |
+
</button>
|
| 439 |
+
</div>
|
| 440 |
+
</div>
|
| 441 |
+
|
| 442 |
+
<Form
|
| 443 |
+
form={form}
|
| 444 |
+
onFinish={handleLogin}
|
| 445 |
+
layout="vertical"
|
| 446 |
+
size="large"
|
| 447 |
+
>
|
| 448 |
+
<Tabs
|
| 449 |
+
activeKey={activeTab}
|
| 450 |
+
onChange={setActiveTab}
|
| 451 |
+
items={tabItems}
|
| 452 |
+
className="mb-4"
|
| 453 |
+
/>
|
| 454 |
+
|
| 455 |
+
<Form.Item className="mb-4">
|
| 456 |
+
<Button
|
| 457 |
+
type="primary"
|
| 458 |
+
htmlType="submit"
|
| 459 |
+
loading={loading}
|
| 460 |
+
block
|
| 461 |
+
size="large"
|
| 462 |
+
className="rounded-lg h-12 text-base font-medium"
|
| 463 |
+
style={{
|
| 464 |
+
backgroundColor: activeTab === 'email' ? '#10b981' : activeTab === 'phone' ? '#f97316' : '#8b5cf6',
|
| 465 |
+
borderColor: activeTab === 'email' ? '#10b981' : activeTab === 'phone' ? '#f97316' : '#8b5cf6'
|
| 466 |
+
}}
|
| 467 |
+
>
|
| 468 |
+
登录
|
| 469 |
+
</Button>
|
| 470 |
+
</Form.Item>
|
| 471 |
+
|
| 472 |
+
<Divider className="text-neutral-400">或</Divider>
|
| 473 |
+
|
| 474 |
+
<div className="text-center">
|
| 475 |
+
<span className="text-neutral-500">还没有账号?</span>
|
| 476 |
+
<Button
|
| 477 |
+
type="link"
|
| 478 |
+
onClick={() => navigate('/register')}
|
| 479 |
+
style={{ color: '#10b981' }}
|
| 480 |
+
className="font-medium"
|
| 481 |
+
>
|
| 482 |
+
立即注册
|
| 483 |
+
</Button>
|
| 484 |
+
</div>
|
| 485 |
+
</Form>
|
| 486 |
+
</div>
|
| 487 |
+
|
| 488 |
+
<div className="text-center mt-8 text-sm text-neutral-400">
|
| 489 |
+
© 2024 智能教育助手 - AI驱动的未来教育
|
| 490 |
+
</div>
|
| 491 |
+
</motion.div>
|
| 492 |
+
</div>
|
| 493 |
+
</div>
|
| 494 |
+
);
|
| 495 |
+
};
|
| 496 |
+
|
| 497 |
+
export default LoginPage;
|
frontend/src/pages/RegisterPage.jsx
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { useNavigate } from 'react-router-dom';
|
| 3 |
+
import { Form, Input, Button, message } from 'antd';
|
| 4 |
+
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
| 5 |
+
import { motion } from 'framer-motion';
|
| 6 |
+
import authService from '../services/authService';
|
| 7 |
+
|
| 8 |
+
const RegisterPage = () => {
|
| 9 |
+
const [form] = Form.useForm();
|
| 10 |
+
const navigate = useNavigate();
|
| 11 |
+
const [loading, setLoading] = useState(false);
|
| 12 |
+
const [userRole, setUserRole] = useState('student');
|
| 13 |
+
|
| 14 |
+
const handleRegister = async (values) => {
|
| 15 |
+
if (values.password !== values.confirmPassword) {
|
| 16 |
+
message.error('两次输入的密码不一致');
|
| 17 |
+
return;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
try {
|
| 21 |
+
setLoading(true);
|
| 22 |
+
const response = await authService.register({
|
| 23 |
+
username: values.username,
|
| 24 |
+
password: values.password,
|
| 25 |
+
role: userRole,
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
if (response.success) {
|
| 29 |
+
message.success('注册成功,请登录');
|
| 30 |
+
navigate('/login');
|
| 31 |
+
}
|
| 32 |
+
} catch (error) {
|
| 33 |
+
message.error(error.response?.data?.message || '注册失败');
|
| 34 |
+
} finally {
|
| 35 |
+
setLoading(false);
|
| 36 |
+
}
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
return (
|
| 40 |
+
<div className="min-h-screen flex relative overflow-hidden" style={{ backgroundColor: '#fafbfc' }}>
|
| 41 |
+
<div
|
| 42 |
+
className="absolute top-20 left-20 w-96 h-96 rounded-full opacity-10"
|
| 43 |
+
style={{ background: '#8b5cf6', filter: 'blur(120px)' }}
|
| 44 |
+
/>
|
| 45 |
+
<div
|
| 46 |
+
className="absolute bottom-20 right-20 w-96 h-96 rounded-full opacity-10"
|
| 47 |
+
style={{ background: '#10b981', filter: 'blur(120px)' }}
|
| 48 |
+
/>
|
| 49 |
+
|
| 50 |
+
<div className="flex-1 flex items-center justify-center p-8">
|
| 51 |
+
<motion.div
|
| 52 |
+
initial={{ opacity: 0, y: 20 }}
|
| 53 |
+
animate={{ opacity: 1, y: 0 }}
|
| 54 |
+
transition={{ duration: 0.6 }}
|
| 55 |
+
className="w-full max-w-md"
|
| 56 |
+
>
|
| 57 |
+
<div
|
| 58 |
+
className="bg-white rounded-2xl p-8 transition-all duration-300"
|
| 59 |
+
style={{
|
| 60 |
+
border: '1px solid rgba(0, 0, 0, 0.08)',
|
| 61 |
+
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.04)',
|
| 62 |
+
}}
|
| 63 |
+
>
|
| 64 |
+
<div className="text-center mb-8">
|
| 65 |
+
<h2 className="text-3xl font-bold text-neutral-800">创建账号</h2>
|
| 66 |
+
<p className="text-neutral-500 mt-2">注册一个新账号开始使用</p>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
{/* 角色选择 */}
|
| 70 |
+
<div className="mb-6">
|
| 71 |
+
<div className="flex gap-3 p-1 bg-neutral-100 rounded-lg">
|
| 72 |
+
<button
|
| 73 |
+
type="button"
|
| 74 |
+
onClick={() => setUserRole('teacher')}
|
| 75 |
+
className={`flex-1 py-2.5 px-4 rounded-lg font-medium transition-all duration-200 ${
|
| 76 |
+
userRole === 'teacher'
|
| 77 |
+
? 'bg-white text-emerald-600 shadow-sm'
|
| 78 |
+
: 'text-neutral-600 hover:text-neutral-800'
|
| 79 |
+
}`}
|
| 80 |
+
>
|
| 81 |
+
教师
|
| 82 |
+
</button>
|
| 83 |
+
<button
|
| 84 |
+
type="button"
|
| 85 |
+
onClick={() => setUserRole('student')}
|
| 86 |
+
className={`flex-1 py-2.5 px-4 rounded-lg font-medium transition-all duration-200 ${
|
| 87 |
+
userRole === 'student'
|
| 88 |
+
? 'bg-white text-orange-600 shadow-sm'
|
| 89 |
+
: 'text-neutral-600 hover:text-neutral-800'
|
| 90 |
+
}`}
|
| 91 |
+
>
|
| 92 |
+
学生
|
| 93 |
+
</button>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<Form form={form} onFinish={handleRegister} layout="vertical" size="large">
|
| 98 |
+
<Form.Item
|
| 99 |
+
name="username"
|
| 100 |
+
rules={[{ required: true, message: '请输入用户名' }]}
|
| 101 |
+
>
|
| 102 |
+
<Input
|
| 103 |
+
prefix={<UserOutlined className="text-neutral-400" />}
|
| 104 |
+
placeholder="用户名"
|
| 105 |
+
size="large"
|
| 106 |
+
className="rounded-lg"
|
| 107 |
+
/>
|
| 108 |
+
</Form.Item>
|
| 109 |
+
|
| 110 |
+
<Form.Item
|
| 111 |
+
name="password"
|
| 112 |
+
rules={[
|
| 113 |
+
{ required: true, message: '请输入密码' },
|
| 114 |
+
{ min: 6, message: '密码至少6位' },
|
| 115 |
+
]}
|
| 116 |
+
>
|
| 117 |
+
<Input.Password
|
| 118 |
+
prefix={<LockOutlined className="text-neutral-400" />}
|
| 119 |
+
placeholder="密码"
|
| 120 |
+
size="large"
|
| 121 |
+
className="rounded-lg"
|
| 122 |
+
/>
|
| 123 |
+
</Form.Item>
|
| 124 |
+
|
| 125 |
+
<Form.Item
|
| 126 |
+
name="confirmPassword"
|
| 127 |
+
rules={[
|
| 128 |
+
{ required: true, message: '请确认密码' },
|
| 129 |
+
]}
|
| 130 |
+
>
|
| 131 |
+
<Input.Password
|
| 132 |
+
prefix={<LockOutlined className="text-neutral-400" />}
|
| 133 |
+
placeholder="确认密码"
|
| 134 |
+
size="large"
|
| 135 |
+
className="rounded-lg"
|
| 136 |
+
/>
|
| 137 |
+
</Form.Item>
|
| 138 |
+
|
| 139 |
+
<Form.Item className="mb-4">
|
| 140 |
+
<Button
|
| 141 |
+
type="primary"
|
| 142 |
+
htmlType="submit"
|
| 143 |
+
loading={loading}
|
| 144 |
+
block
|
| 145 |
+
size="large"
|
| 146 |
+
className="rounded-lg h-12 text-base font-medium"
|
| 147 |
+
style={{ backgroundColor: '#8b5cf6', borderColor: '#8b5cf6' }}
|
| 148 |
+
>
|
| 149 |
+
注册
|
| 150 |
+
</Button>
|
| 151 |
+
</Form.Item>
|
| 152 |
+
|
| 153 |
+
<div className="text-center">
|
| 154 |
+
<span className="text-neutral-500">已有账号?</span>
|
| 155 |
+
<Button
|
| 156 |
+
type="link"
|
| 157 |
+
onClick={() => navigate('/login')}
|
| 158 |
+
style={{ color: '#10b981' }}
|
| 159 |
+
className="font-medium"
|
| 160 |
+
>
|
| 161 |
+
返回登录
|
| 162 |
+
</Button>
|
| 163 |
+
</div>
|
| 164 |
+
</Form>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
<div className="text-center mt-8 text-sm text-neutral-400">
|
| 168 |
+
© 2024 智能教育助手 - AI驱动的未来教育
|
| 169 |
+
</div>
|
| 170 |
+
</motion.div>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
);
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
+
export default RegisterPage;
|
frontend/src/pages/student/AgentChat.css
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* 思考中动画 */
|
| 2 |
+
.thinking-dots {
|
| 3 |
+
display: inline-flex;
|
| 4 |
+
align-items: center;
|
| 5 |
+
gap: 4px;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.thinking-dots span {
|
| 9 |
+
width: 8px;
|
| 10 |
+
height: 8px;
|
| 11 |
+
background-color: #8b5cf6;
|
| 12 |
+
border-radius: 50%;
|
| 13 |
+
animation: thinking 1.4s ease-in-out infinite;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.thinking-dots span:nth-child(1) {
|
| 17 |
+
animation-delay: -0.32s;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.thinking-dots span:nth-child(2) {
|
| 21 |
+
animation-delay: -0.16s;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
@keyframes thinking {
|
| 25 |
+
0%, 80%, 100% {
|
| 26 |
+
transform: scale(0);
|
| 27 |
+
opacity: 0.5;
|
| 28 |
+
}
|
| 29 |
+
40% {
|
| 30 |
+
transform: scale(1);
|
| 31 |
+
opacity: 1;
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
/* 打字光标 */
|
| 36 |
+
.typing-cursor {
|
| 37 |
+
display: inline-block;
|
| 38 |
+
width: 2px;
|
| 39 |
+
height: 16px;
|
| 40 |
+
background-color: #8b5cf6;
|
| 41 |
+
animation: blink 1s infinite;
|
| 42 |
+
margin-left: 2px;
|
| 43 |
+
vertical-align: text-bottom;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
@keyframes blink {
|
| 47 |
+
0%, 50% {
|
| 48 |
+
opacity: 1;
|
| 49 |
+
}
|
| 50 |
+
51%, 100% {
|
| 51 |
+
opacity: 0;
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/* Markdown样式 */
|
| 56 |
+
.markdown-body {
|
| 57 |
+
font-size: 14px;
|
| 58 |
+
line-height: 1.6;
|
| 59 |
+
color: #374151;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.markdown-body h1,
|
| 63 |
+
.markdown-body h2,
|
| 64 |
+
.markdown-body h3,
|
| 65 |
+
.markdown-body h4,
|
| 66 |
+
.markdown-body h5,
|
| 67 |
+
.markdown-body h6 {
|
| 68 |
+
margin-top: 16px;
|
| 69 |
+
margin-bottom: 8px;
|
| 70 |
+
font-weight: 600;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.markdown-body h1 {
|
| 74 |
+
font-size: 1.5em;
|
| 75 |
+
border-bottom: 1px solid #e5e7eb;
|
| 76 |
+
padding-bottom: 8px;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.markdown-body h2 {
|
| 80 |
+
font-size: 1.3em;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.markdown-body h3 {
|
| 84 |
+
font-size: 1.15em;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.markdown-body pre {
|
| 88 |
+
margin: 12px 0;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.markdown-body table {
|
| 92 |
+
border-collapse: collapse;
|
| 93 |
+
width: 100%;
|
| 94 |
+
margin: 12px 0;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.markdown-body table th,
|
| 98 |
+
.markdown-body table td {
|
| 99 |
+
border: 1px solid #e5e7eb;
|
| 100 |
+
padding: 8px 12px;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.markdown-body table th {
|
| 104 |
+
background-color: #f9fafb;
|
| 105 |
+
font-weight: 600;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.markdown-body hr {
|
| 109 |
+
border: none;
|
| 110 |
+
border-top: 1px solid #e5e7eb;
|
| 111 |
+
margin: 16px 0;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/* 消息样式 */
|
| 115 |
+
.bot-message {
|
| 116 |
+
word-wrap: break-word;
|
| 117 |
+
word-break: break-word;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/* 自定义滚动条 */
|
| 121 |
+
::-webkit-scrollbar {
|
| 122 |
+
width: 8px;
|
| 123 |
+
height: 8px;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
::-webkit-scrollbar-track {
|
| 127 |
+
background: #f3f4f6;
|
| 128 |
+
border-radius: 4px;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
::-webkit-scrollbar-thumb {
|
| 132 |
+
background: #d1d5db;
|
| 133 |
+
border-radius: 4px;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
::-webkit-scrollbar-thumb:hover {
|
| 137 |
+
background: #9ca3af;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/* 插件面板 Tabs 撑满高度 */
|
| 141 |
+
.ant-tabs {
|
| 142 |
+
display: flex;
|
| 143 |
+
flex-direction: column;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.ant-tabs-content-holder {
|
| 147 |
+
flex: 1;
|
| 148 |
+
overflow: hidden;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.ant-tabs-content {
|
| 152 |
+
height: 100%;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.ant-tabs-tabpane {
|
| 156 |
+
height: 100%;
|
| 157 |
+
padding: 12px 16px;
|
| 158 |
+
overflow: auto;
|
| 159 |
+
}
|
frontend/src/pages/student/AgentChat.jsx
ADDED
|
@@ -0,0 +1,854 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 学生端 - Agent对话页面
|
| 3 |
+
* 支持SSE流式响应、参考文献显示、代码高亮、插件面板等功能
|
| 4 |
+
*/
|
| 5 |
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
| 6 |
+
import { useParams, useSearchParams } from 'react-router-dom';
|
| 7 |
+
import {
|
| 8 |
+
Layout,
|
| 9 |
+
Input,
|
| 10 |
+
Button,
|
| 11 |
+
Card,
|
| 12 |
+
Space,
|
| 13 |
+
Avatar,
|
| 14 |
+
Spin,
|
| 15 |
+
message,
|
| 16 |
+
Typography,
|
| 17 |
+
Tag,
|
| 18 |
+
Empty,
|
| 19 |
+
Tabs
|
| 20 |
+
} from 'antd';
|
| 21 |
+
import {
|
| 22 |
+
SendOutlined,
|
| 23 |
+
RobotOutlined,
|
| 24 |
+
UserOutlined,
|
| 25 |
+
CodeOutlined,
|
| 26 |
+
CopyOutlined,
|
| 27 |
+
BookOutlined,
|
| 28 |
+
FileTextOutlined,
|
| 29 |
+
AppstoreOutlined,
|
| 30 |
+
CloseOutlined,
|
| 31 |
+
BarChartOutlined,
|
| 32 |
+
BranchesOutlined
|
| 33 |
+
} from '@ant-design/icons';
|
| 34 |
+
import ReactMarkdown from 'react-markdown';
|
| 35 |
+
import remarkGfm from 'remark-gfm';
|
| 36 |
+
import remarkMath from 'remark-math';
|
| 37 |
+
import rehypeKatex from 'rehype-katex';
|
| 38 |
+
import 'katex/dist/katex.min.css';
|
| 39 |
+
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
| 40 |
+
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
| 41 |
+
import api from '../../services/api';
|
| 42 |
+
import CodeExecutor from '../../components/plugins/CodeExecutor';
|
| 43 |
+
import Visualization3D from '../../components/plugins/Visualization3D';
|
| 44 |
+
import MindmapViewer from '../../components/plugins/MindmapViewer';
|
| 45 |
+
import './AgentChat.css';
|
| 46 |
+
|
| 47 |
+
const { Content } = Layout;
|
| 48 |
+
const { TextArea } = Input;
|
| 49 |
+
const { Title, Text, Paragraph } = Typography;
|
| 50 |
+
|
| 51 |
+
const AgentChat = () => {
|
| 52 |
+
const { agentId } = useParams();
|
| 53 |
+
const [searchParams] = useSearchParams();
|
| 54 |
+
const token = searchParams.get('token');
|
| 55 |
+
|
| 56 |
+
// 状态管理
|
| 57 |
+
const [agent, setAgent] = useState(null);
|
| 58 |
+
const [messages, setMessages] = useState([]);
|
| 59 |
+
const [isDragging, setIsDragging] = useState(false);
|
| 60 |
+
const [inputMessage, setInputMessage] = useState('');
|
| 61 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 62 |
+
const [loadingAgent, setLoadingAgent] = useState(true);
|
| 63 |
+
const [error, setError] = useState(null);
|
| 64 |
+
|
| 65 |
+
// 插件面板状态
|
| 66 |
+
const [pluginPanelOpen, setPluginPanelOpen] = useState(false);
|
| 67 |
+
const [activePlugin, setActivePlugin] = useState('code');
|
| 68 |
+
const [availablePlugins, setAvailablePlugins] = useState([]);
|
| 69 |
+
const [pluginData, setPluginData] = useState({ code: '', vizCode: '', mindmapMd: '' });
|
| 70 |
+
const [panelRatio, setPanelRatio] = useState(4 / 7); // 插件面板占比 4/7(对话3:面板4)
|
| 71 |
+
const dragRef = useRef(null);
|
| 72 |
+
|
| 73 |
+
const messagesEndRef = useRef(null);
|
| 74 |
+
|
| 75 |
+
// 验证Token并加载Agent信息
|
| 76 |
+
useEffect(() => {
|
| 77 |
+
if (!agentId || !token) {
|
| 78 |
+
setError('无效的访问链接');
|
| 79 |
+
setLoadingAgent(false);
|
| 80 |
+
return;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
verifyAndLoadAgent();
|
| 84 |
+
}, [agentId, token]);
|
| 85 |
+
|
| 86 |
+
// 自动滚动到底部
|
| 87 |
+
useEffect(() => {
|
| 88 |
+
scrollToBottom();
|
| 89 |
+
}, [messages]);
|
| 90 |
+
|
| 91 |
+
const scrollToBottom = () => {
|
| 92 |
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
// 验证Token并加载Agent
|
| 96 |
+
const verifyAndLoadAgent = async () => {
|
| 97 |
+
try {
|
| 98 |
+
setLoadingAgent(true);
|
| 99 |
+
const response = await api.post('/verify_token', {
|
| 100 |
+
agent_id: agentId,
|
| 101 |
+
token: token
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
if (response.success) {
|
| 105 |
+
setAgent(response.agent);
|
| 106 |
+
// 添加欢迎消息
|
| 107 |
+
setMessages([{
|
| 108 |
+
id: Date.now(),
|
| 109 |
+
type: 'bot',
|
| 110 |
+
content: `你好!我是${response.agent.name},${response.agent.description || '有什么可以帮助你的吗?'}`,
|
| 111 |
+
timestamp: new Date()
|
| 112 |
+
}]);
|
| 113 |
+
} else {
|
| 114 |
+
setError('访问令牌无效或已过期');
|
| 115 |
+
}
|
| 116 |
+
} catch (error) {
|
| 117 |
+
console.error('验证失败:', error);
|
| 118 |
+
setError('无法验证访问权限: ' + (error.message || '未知错误'));
|
| 119 |
+
} finally {
|
| 120 |
+
setLoadingAgent(false);
|
| 121 |
+
}
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
// 发送消息
|
| 125 |
+
const handleSendMessage = async () => {
|
| 126 |
+
if (!inputMessage.trim() || isLoading) return;
|
| 127 |
+
|
| 128 |
+
const userMessage = {
|
| 129 |
+
id: Date.now(),
|
| 130 |
+
type: 'user',
|
| 131 |
+
content: inputMessage,
|
| 132 |
+
timestamp: new Date()
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
setMessages(prev => [...prev, userMessage]);
|
| 136 |
+
const currentMessage = inputMessage;
|
| 137 |
+
setInputMessage('');
|
| 138 |
+
setIsLoading(true);
|
| 139 |
+
|
| 140 |
+
// 创建机器人消息占位
|
| 141 |
+
const botMessageId = Date.now() + 1;
|
| 142 |
+
const botMessage = {
|
| 143 |
+
id: botMessageId,
|
| 144 |
+
type: 'bot',
|
| 145 |
+
content: '',
|
| 146 |
+
timestamp: new Date(),
|
| 147 |
+
isStreaming: true,
|
| 148 |
+
references: []
|
| 149 |
+
};
|
| 150 |
+
setMessages(prev => [...prev, botMessage]);
|
| 151 |
+
|
| 152 |
+
try {
|
| 153 |
+
// 发送请求到后端(SSE流式响应)
|
| 154 |
+
// SSE 直连后端,绕过 Vite 代理避免缓冲
|
| 155 |
+
const SSE_BASE = window.location.hostname === 'localhost' ? 'http://localhost:7860' : '';
|
| 156 |
+
const response = await fetch(`${SSE_BASE}/api/student/chat/${agentId}`, {
|
| 157 |
+
method: 'POST',
|
| 158 |
+
headers: {
|
| 159 |
+
'Content-Type': 'application/json',
|
| 160 |
+
},
|
| 161 |
+
body: JSON.stringify({
|
| 162 |
+
message: currentMessage,
|
| 163 |
+
token: token,
|
| 164 |
+
conversation_id: Date.now().toString()
|
| 165 |
+
})
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
if (!response.ok) {
|
| 169 |
+
throw new Error('发送失败');
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// 读取SSE流
|
| 173 |
+
const reader = response.body.getReader();
|
| 174 |
+
const decoder = new TextDecoder();
|
| 175 |
+
let buffer = '';
|
| 176 |
+
let botContent = '';
|
| 177 |
+
let references = [];
|
| 178 |
+
let streamPlugins = [];
|
| 179 |
+
let pluginTriggered = false;
|
| 180 |
+
const STREAM_TIMEOUT = 60000; // 60秒无数据超时
|
| 181 |
+
|
| 182 |
+
const readWithTimeout = () => {
|
| 183 |
+
return Promise.race([
|
| 184 |
+
reader.read(),
|
| 185 |
+
new Promise((_, reject) =>
|
| 186 |
+
setTimeout(() => {
|
| 187 |
+
reader.cancel();
|
| 188 |
+
reject(new Error('响应超时,请重试'));
|
| 189 |
+
}, STREAM_TIMEOUT)
|
| 190 |
+
)
|
| 191 |
+
]);
|
| 192 |
+
};
|
| 193 |
+
|
| 194 |
+
while (true) {
|
| 195 |
+
const { done, value } = await readWithTimeout();
|
| 196 |
+
|
| 197 |
+
if (done) {
|
| 198 |
+
// 处理 buffer 中可能残留的最后一条数据
|
| 199 |
+
if (buffer.trim()) {
|
| 200 |
+
const remaining = buffer.trim();
|
| 201 |
+
if (remaining.startsWith('data: ')) {
|
| 202 |
+
try {
|
| 203 |
+
const data = JSON.parse(remaining.slice(6));
|
| 204 |
+
if (data.type === 'content') {
|
| 205 |
+
botContent += data.content;
|
| 206 |
+
}
|
| 207 |
+
} catch (e) { /* ignore partial JSON */ }
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
// 标记流结束
|
| 211 |
+
setMessages(prev => prev.map(msg =>
|
| 212 |
+
msg.id === botMessageId
|
| 213 |
+
? { ...msg, content: botContent, isStreaming: false, references }
|
| 214 |
+
: msg
|
| 215 |
+
));
|
| 216 |
+
break;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
buffer += decoder.decode(value, { stream: true });
|
| 220 |
+
const lines = buffer.split('\n');
|
| 221 |
+
buffer = lines.pop() || '';
|
| 222 |
+
|
| 223 |
+
for (const line of lines) {
|
| 224 |
+
if (!line.startsWith('data: ')) continue;
|
| 225 |
+
const dataStr = line.slice(6).trim();
|
| 226 |
+
if (!dataStr) continue;
|
| 227 |
+
|
| 228 |
+
try {
|
| 229 |
+
const data = JSON.parse(dataStr);
|
| 230 |
+
|
| 231 |
+
if (data.type === 'start') {
|
| 232 |
+
if (data.references) {
|
| 233 |
+
references = data.references;
|
| 234 |
+
}
|
| 235 |
+
// 记录可用插件
|
| 236 |
+
if (data.plugins && data.plugins.length > 0) {
|
| 237 |
+
streamPlugins = data.plugins;
|
| 238 |
+
setAvailablePlugins(data.plugins);
|
| 239 |
+
}
|
| 240 |
+
} else if (data.type === 'content') {
|
| 241 |
+
botContent += data.content;
|
| 242 |
+
setMessages(prev => prev.map(msg =>
|
| 243 |
+
msg.id === botMessageId
|
| 244 |
+
? { ...msg, content: botContent }
|
| 245 |
+
: msg
|
| 246 |
+
));
|
| 247 |
+
|
| 248 |
+
// 流式过程中实时检测代码块
|
| 249 |
+
if (streamPlugins.length > 0) {
|
| 250 |
+
// 阶段1:检测到代码块开头,打开面板并实时填充编辑器
|
| 251 |
+
const openBlock = botContent.match(/```(python|mindmap|markdown)\n([\s\S]*)$/);
|
| 252 |
+
if (openBlock && !pluginTriggered) {
|
| 253 |
+
const lang = openBlock[1];
|
| 254 |
+
const partial = openBlock[2];
|
| 255 |
+
if (lang === 'python' && streamPlugins.includes('code') && !partial.includes('```')) {
|
| 256 |
+
setPluginData(prev => ({ ...prev, code: partial }));
|
| 257 |
+
setActivePlugin('code');
|
| 258 |
+
setPluginPanelOpen(true);
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
// 阶段2:代码块闭合,最终触发(3D渲染/思维导图等需要完整代码)
|
| 263 |
+
if (!pluginTriggered) {
|
| 264 |
+
const completeBlock = botContent.match(/```(?:python|mindmap|markdown)\n[\s\S]+?```/);
|
| 265 |
+
if (completeBlock) {
|
| 266 |
+
pluginTriggered = true;
|
| 267 |
+
extractPluginContent(botContent, streamPlugins);
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
} else if (data.type === 'metadata') {
|
| 272 |
+
// 处理元数据(token 用量等),暂记录到 console
|
| 273 |
+
console.log('Stream metadata:', data.content);
|
| 274 |
+
} else if (data.type === 'error') {
|
| 275 |
+
// 后端报错
|
| 276 |
+
throw new Error(data.content || '服务端处理出错');
|
| 277 |
+
} else if (data.type === 'done') {
|
| 278 |
+
setMessages(prev => prev.map(msg =>
|
| 279 |
+
msg.id === botMessageId
|
| 280 |
+
? { ...msg, isStreaming: false, references }
|
| 281 |
+
: msg
|
| 282 |
+
));
|
| 283 |
+
}
|
| 284 |
+
} catch (e) {
|
| 285 |
+
if (e.message?.includes('超时') || e.message?.includes('服务端')) throw e;
|
| 286 |
+
console.warn('解析SSE数据失败:', dataStr, e);
|
| 287 |
+
}
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
// 流结束后,如果还没触发过插件,最终检测一次
|
| 292 |
+
if (!pluginTriggered) {
|
| 293 |
+
extractPluginContent(botContent, streamPlugins);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
} catch (error) {
|
| 297 |
+
console.error('发送消息失败:', error);
|
| 298 |
+
message.error('发送失败,请重试');
|
| 299 |
+
|
| 300 |
+
// 更新错误状态
|
| 301 |
+
setMessages(prev => prev.map(msg =>
|
| 302 |
+
msg.id === botMessageId
|
| 303 |
+
? { ...msg, content: '抱歉,我遇到了一些问题。请稍后再试。', isStreaming: false, isError: true }
|
| 304 |
+
: msg
|
| 305 |
+
));
|
| 306 |
+
} finally {
|
| 307 |
+
setIsLoading(false);
|
| 308 |
+
}
|
| 309 |
+
};
|
| 310 |
+
|
| 311 |
+
// 拖拽调整面板宽度
|
| 312 |
+
const handleDragStart = useCallback((e) => {
|
| 313 |
+
e.preventDefault();
|
| 314 |
+
const container = e.target.parentElement;
|
| 315 |
+
const containerWidth = container.getBoundingClientRect().width;
|
| 316 |
+
setIsDragging(true);
|
| 317 |
+
|
| 318 |
+
const onMouseMove = (moveEvent) => {
|
| 319 |
+
const rect = container.getBoundingClientRect();
|
| 320 |
+
const x = moveEvent.clientX - rect.left;
|
| 321 |
+
const ratio = 1 - (x / containerWidth);
|
| 322 |
+
setPanelRatio(Math.min(0.7, Math.max(0.25, ratio)));
|
| 323 |
+
};
|
| 324 |
+
|
| 325 |
+
const onMouseUp = () => {
|
| 326 |
+
document.removeEventListener('mousemove', onMouseMove);
|
| 327 |
+
document.removeEventListener('mouseup', onMouseUp);
|
| 328 |
+
document.body.style.cursor = '';
|
| 329 |
+
document.body.style.userSelect = '';
|
| 330 |
+
setIsDragging(false);
|
| 331 |
+
};
|
| 332 |
+
|
| 333 |
+
document.body.style.cursor = 'col-resize';
|
| 334 |
+
document.body.style.userSelect = 'none';
|
| 335 |
+
document.addEventListener('mousemove', onMouseMove);
|
| 336 |
+
document.addEventListener('mouseup', onMouseUp);
|
| 337 |
+
}, []);
|
| 338 |
+
|
| 339 |
+
// 从 AI 回复中提取插件相关内容
|
| 340 |
+
const extractPluginContent = useCallback((content, plugins) => {
|
| 341 |
+
if (!content) return;
|
| 342 |
+
const activePlugins = plugins || availablePlugins;
|
| 343 |
+
|
| 344 |
+
// 提取 3D 可视化代码(包含 create_3d_plot,优先匹配)
|
| 345 |
+
const vizMatch = content.match(/```python\n([\s\S]*?create_3d_plot[\s\S]*?)```/);
|
| 346 |
+
if (vizMatch && activePlugins.includes('visualization')) {
|
| 347 |
+
setPluginData(prev => ({ ...prev, vizCode: vizMatch[1].trim() }));
|
| 348 |
+
setActivePlugin('viz');
|
| 349 |
+
setPluginPanelOpen(true);
|
| 350 |
+
return; // 3D 优先,不再检查代码插件
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
// 提取 Python 代码块(用于代码执行插件)
|
| 354 |
+
const pythonMatch = content.match(/```python\n([\s\S]*?)```/);
|
| 355 |
+
if (pythonMatch && activePlugins.includes('code')) {
|
| 356 |
+
setPluginData(prev => ({ ...prev, code: pythonMatch[1].trim() }));
|
| 357 |
+
setActivePlugin('code');
|
| 358 |
+
setPluginPanelOpen(true);
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
// 提取思维导图内容
|
| 362 |
+
// 支持三种格式:```markdown 代码块、```mindmap 代码块、@startmindmap PlantUML
|
| 363 |
+
let mindmapMd = null;
|
| 364 |
+
|
| 365 |
+
// 格式1: markdown/mindmap 代码块
|
| 366 |
+
const mdBlockMatch = content.match(/```(?:markdown|mindmap)\n([\s\S]*?)```/);
|
| 367 |
+
if (mdBlockMatch) {
|
| 368 |
+
mindmapMd = mdBlockMatch[1].trim();
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
// 格式2: PlantUML @startmindmap 格式 → 转换为 Markdown 标题
|
| 372 |
+
if (!mindmapMd) {
|
| 373 |
+
const plantUmlMatch = content.match(/@startmindmap\s*\n([\s\S]*?)@endmindmap/);
|
| 374 |
+
if (plantUmlMatch) {
|
| 375 |
+
mindmapMd = plantUmlMatch[1]
|
| 376 |
+
.split('\n')
|
| 377 |
+
.filter(line => line.trim())
|
| 378 |
+
.map(line => {
|
| 379 |
+
// PlantUML: * = h1, ** = h2, *** = h3, ...
|
| 380 |
+
const match = line.match(/^(\*+)\s*(.*)/);
|
| 381 |
+
if (match) {
|
| 382 |
+
const level = match[1].length;
|
| 383 |
+
const text = match[2].replace(/^[:\s]+|;$/g, '').trim();
|
| 384 |
+
return '#'.repeat(level) + ' ' + text;
|
| 385 |
+
}
|
| 386 |
+
return line;
|
| 387 |
+
})
|
| 388 |
+
.join('\n');
|
| 389 |
+
}
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
if (mindmapMd && activePlugins.includes('mindmap')) {
|
| 393 |
+
setPluginData(prev => ({ ...prev, mindmapMd }));
|
| 394 |
+
setActivePlugin('mindmap');
|
| 395 |
+
setPluginPanelOpen(true);
|
| 396 |
+
}
|
| 397 |
+
}, [availablePlugins]);
|
| 398 |
+
|
| 399 |
+
// 复制代码
|
| 400 |
+
const copyCode = (code) => {
|
| 401 |
+
navigator.clipboard.writeText(code).then(() => {
|
| 402 |
+
message.success('代码已复制');
|
| 403 |
+
}).catch(() => {
|
| 404 |
+
message.error('复制失败');
|
| 405 |
+
});
|
| 406 |
+
};
|
| 407 |
+
|
| 408 |
+
// 渲染消息
|
| 409 |
+
const renderMessage = (msg) => {
|
| 410 |
+
const isBot = msg.type === 'bot';
|
| 411 |
+
|
| 412 |
+
// 处理参考来源(从消息内容中移除)
|
| 413 |
+
let displayContent = msg.content || '';
|
| 414 |
+
|
| 415 |
+
// 移除消息中的参考来源标记
|
| 416 |
+
displayContent = displayContent.replace(/===参考来源开始===[\s\S]*?===参考来源结束===/g, '').trim();
|
| 417 |
+
|
| 418 |
+
return (
|
| 419 |
+
<div
|
| 420 |
+
key={msg.id}
|
| 421 |
+
style={{
|
| 422 |
+
display: 'flex',
|
| 423 |
+
gap: '12px',
|
| 424 |
+
marginBottom: '24px',
|
| 425 |
+
flexDirection: isBot ? 'row' : 'row-reverse'
|
| 426 |
+
}}
|
| 427 |
+
>
|
| 428 |
+
<Avatar
|
| 429 |
+
icon={isBot ? <RobotOutlined /> : <UserOutlined />}
|
| 430 |
+
style={{
|
| 431 |
+
backgroundColor: isBot ? '#8b5cf6' : '#10b981',
|
| 432 |
+
flexShrink: 0,
|
| 433 |
+
border: `2px solid ${isBot ? 'rgba(139, 92, 246, 0.2)' : 'rgba(16, 185, 129, 0.2)'}`
|
| 434 |
+
}}
|
| 435 |
+
/>
|
| 436 |
+
|
| 437 |
+
<div style={{ maxWidth: '70%', minWidth: '200px' }}>
|
| 438 |
+
<Card
|
| 439 |
+
style={{
|
| 440 |
+
border: `1px solid ${isBot ? 'rgba(139, 92, 246, 0.15)' : 'rgba(16, 185, 129, 0.2)'}`,
|
| 441 |
+
background: isBot ? '#ffffff' : 'rgba(16, 185, 129, 0.03)',
|
| 442 |
+
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
| 443 |
+
borderRadius: '8px'
|
| 444 |
+
}}
|
| 445 |
+
styles={{ body: { padding: '12px 16px' } }}
|
| 446 |
+
>
|
| 447 |
+
{isBot ? (
|
| 448 |
+
<div className="bot-message">
|
| 449 |
+
{msg.isStreaming && !displayContent ? (
|
| 450 |
+
// 只在没有内容时显示思考中
|
| 451 |
+
<Space align="center">
|
| 452 |
+
<div className="thinking-dots">
|
| 453 |
+
<span></span>
|
| 454 |
+
<span></span>
|
| 455 |
+
<span></span>
|
| 456 |
+
</div>
|
| 457 |
+
<Text type="secondary" style={{ fontSize: '14px' }}>正在思考中</Text>
|
| 458 |
+
</Space>
|
| 459 |
+
) : (
|
| 460 |
+
<>
|
| 461 |
+
<ReactMarkdown
|
| 462 |
+
className="markdown-body"
|
| 463 |
+
remarkPlugins={[remarkGfm, remarkMath]}
|
| 464 |
+
rehypePlugins={[rehypeKatex]}
|
| 465 |
+
components={{
|
| 466 |
+
code({ node, inline, className, children, ...props }) {
|
| 467 |
+
const match = /language-(\w+)/.exec(className || '');
|
| 468 |
+
const language = match ? match[1] : '';
|
| 469 |
+
|
| 470 |
+
if (!inline && language) {
|
| 471 |
+
return (
|
| 472 |
+
<div style={{
|
| 473 |
+
position: 'relative',
|
| 474 |
+
marginTop: '12px',
|
| 475 |
+
marginBottom: '12px',
|
| 476 |
+
borderRadius: '6px',
|
| 477 |
+
overflow: 'hidden',
|
| 478 |
+
border: '1px solid rgba(139, 92, 246, 0.1)'
|
| 479 |
+
}}>
|
| 480 |
+
<div style={{
|
| 481 |
+
display: 'flex',
|
| 482 |
+
justifyContent: 'space-between',
|
| 483 |
+
alignItems: 'center',
|
| 484 |
+
padding: '8px 12px',
|
| 485 |
+
background: 'rgba(139, 92, 246, 0.05)',
|
| 486 |
+
borderBottom: '1px solid rgba(139, 92, 246, 0.1)'
|
| 487 |
+
}}>
|
| 488 |
+
<Text style={{ fontSize: '12px', color: '#6b7280' }}>{language}</Text>
|
| 489 |
+
<Button
|
| 490 |
+
size="small"
|
| 491 |
+
type="text"
|
| 492 |
+
icon={<CopyOutlined />}
|
| 493 |
+
onClick={() => copyCode(String(children).replace(/\n$/, ''))}
|
| 494 |
+
>
|
| 495 |
+
复制
|
| 496 |
+
</Button>
|
| 497 |
+
</div>
|
| 498 |
+
<SyntaxHighlighter
|
| 499 |
+
language={language}
|
| 500 |
+
style={tomorrow}
|
| 501 |
+
PreTag="div"
|
| 502 |
+
customStyle={{
|
| 503 |
+
margin: 0,
|
| 504 |
+
background: '#fafbfc',
|
| 505 |
+
fontSize: '14px'
|
| 506 |
+
}}
|
| 507 |
+
{...props}
|
| 508 |
+
>
|
| 509 |
+
{String(children).replace(/\n$/, '')}
|
| 510 |
+
</SyntaxHighlighter>
|
| 511 |
+
</div>
|
| 512 |
+
);
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
return (
|
| 516 |
+
<code
|
| 517 |
+
style={{
|
| 518 |
+
background: 'rgba(139, 92, 246, 0.08)',
|
| 519 |
+
padding: '2px 6px',
|
| 520 |
+
borderRadius: '4px',
|
| 521 |
+
fontSize: '0.9em',
|
| 522 |
+
color: '#8b5cf6'
|
| 523 |
+
}}
|
| 524 |
+
{...props}
|
| 525 |
+
>
|
| 526 |
+
{children}
|
| 527 |
+
</code>
|
| 528 |
+
);
|
| 529 |
+
},
|
| 530 |
+
p: ({ children }) => <p style={{ marginBottom: '12px', lineHeight: '1.6' }}>{children}</p>,
|
| 531 |
+
ul: ({ children }) => <ul style={{ marginBottom: '12px', paddingLeft: '20px' }}>{children}</ul>,
|
| 532 |
+
ol: ({ children }) => <ol style={{ marginBottom: '12px', paddingLeft: '20px' }}>{children}</ol>,
|
| 533 |
+
li: ({ children }) => <li style={{ marginBottom: '4px' }}>{children}</li>,
|
| 534 |
+
blockquote: ({ children }) => (
|
| 535 |
+
<blockquote style={{
|
| 536 |
+
borderLeft: '4px solid rgba(139, 92, 246, 0.3)',
|
| 537 |
+
paddingLeft: '12px',
|
| 538 |
+
marginLeft: 0,
|
| 539 |
+
color: '#6b7280',
|
| 540 |
+
fontStyle: 'italic'
|
| 541 |
+
}}>
|
| 542 |
+
{children}
|
| 543 |
+
</blockquote>
|
| 544 |
+
)
|
| 545 |
+
}}
|
| 546 |
+
>
|
| 547 |
+
{displayContent}
|
| 548 |
+
</ReactMarkdown>
|
| 549 |
+
|
| 550 |
+
{msg.isStreaming && displayContent && (
|
| 551 |
+
// 流式传输时的光标
|
| 552 |
+
<span className="typing-cursor"></span>
|
| 553 |
+
)}
|
| 554 |
+
</>
|
| 555 |
+
)}
|
| 556 |
+
</div>
|
| 557 |
+
) : (
|
| 558 |
+
<Text style={{ fontSize: '14px', lineHeight: '1.6' }}>{msg.content}</Text>
|
| 559 |
+
)}
|
| 560 |
+
|
| 561 |
+
{msg.isError && (
|
| 562 |
+
<div style={{ marginTop: '8px' }}>
|
| 563 |
+
<Text type="danger" style={{ fontSize: '12px' }}>
|
| 564 |
+
⚠ 发生错误
|
| 565 |
+
</Text>
|
| 566 |
+
</div>
|
| 567 |
+
)}
|
| 568 |
+
</Card>
|
| 569 |
+
|
| 570 |
+
{/* 参考文献卡片 */}
|
| 571 |
+
{isBot && msg.references && msg.references.length > 0 && (
|
| 572 |
+
<Card
|
| 573 |
+
size="small"
|
| 574 |
+
style={{
|
| 575 |
+
marginTop: '8px',
|
| 576 |
+
border: '1px solid rgba(139, 92, 246, 0.1)',
|
| 577 |
+
background: 'rgba(139, 92, 246, 0.02)',
|
| 578 |
+
borderRadius: '8px'
|
| 579 |
+
}}
|
| 580 |
+
title={
|
| 581 |
+
<Space>
|
| 582 |
+
<FileTextOutlined style={{ color: '#8b5cf6' }} />
|
| 583 |
+
<Text style={{ fontSize: '13px', fontWeight: 500 }}>参考来源</Text>
|
| 584 |
+
</Space>
|
| 585 |
+
}
|
| 586 |
+
styles={{ body: { padding: '12px' } }}
|
| 587 |
+
>
|
| 588 |
+
{msg.references.map((ref, index) => (
|
| 589 |
+
<div key={index} style={{
|
| 590 |
+
marginBottom: index < msg.references.length - 1 ? '12px' : 0,
|
| 591 |
+
paddingBottom: index < msg.references.length - 1 ? '12px' : 0,
|
| 592 |
+
borderBottom: index < msg.references.length - 1 ? '1px solid rgba(139, 92, 246, 0.08)' : 'none'
|
| 593 |
+
}}>
|
| 594 |
+
<Space align="start">
|
| 595 |
+
<Tag color="purple" style={{ marginRight: 0 }}>{ref.index || index + 1}</Tag>
|
| 596 |
+
<div>
|
| 597 |
+
<Text style={{ fontSize: '13px', color: '#374151' }}>
|
| 598 |
+
{ref.summary}
|
| 599 |
+
</Text>
|
| 600 |
+
<br />
|
| 601 |
+
<Text type="secondary" style={{ fontSize: '12px' }}>
|
| 602 |
+
来源: {ref.file_name}
|
| 603 |
+
</Text>
|
| 604 |
+
</div>
|
| 605 |
+
</Space>
|
| 606 |
+
</div>
|
| 607 |
+
))}
|
| 608 |
+
</Card>
|
| 609 |
+
)}
|
| 610 |
+
|
| 611 |
+
<Text type="secondary" style={{ fontSize: '12px', display: 'block', marginTop: '8px' }}>
|
| 612 |
+
{new Date(msg.timestamp).toLocaleTimeString()}
|
| 613 |
+
</Text>
|
| 614 |
+
</div>
|
| 615 |
+
</div>
|
| 616 |
+
);
|
| 617 |
+
};
|
| 618 |
+
|
| 619 |
+
// 显示加载状态
|
| 620 |
+
if (loadingAgent) {
|
| 621 |
+
return (
|
| 622 |
+
<div style={{
|
| 623 |
+
height: '100vh',
|
| 624 |
+
display: 'flex',
|
| 625 |
+
justifyContent: 'center',
|
| 626 |
+
alignItems: 'center',
|
| 627 |
+
background: '#fafbfc'
|
| 628 |
+
}}>
|
| 629 |
+
<Spin size="large" tip="正在加载Agent信息...">
|
| 630 |
+
<div style={{ height: '100px' }} />
|
| 631 |
+
</Spin>
|
| 632 |
+
</div>
|
| 633 |
+
);
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
// 显示错误状态
|
| 637 |
+
if (error) {
|
| 638 |
+
return (
|
| 639 |
+
<div style={{
|
| 640 |
+
height: '100vh',
|
| 641 |
+
display: 'flex',
|
| 642 |
+
justifyContent: 'center',
|
| 643 |
+
alignItems: 'center',
|
| 644 |
+
background: '#fafbfc'
|
| 645 |
+
}}>
|
| 646 |
+
<Card style={{ textAlign: 'center', padding: '20px' }}>
|
| 647 |
+
<Title level={4} type="danger">加载失败</Title>
|
| 648 |
+
<Text type="secondary">{error}</Text>
|
| 649 |
+
<br />
|
| 650 |
+
<Button
|
| 651 |
+
type="primary"
|
| 652 |
+
style={{ marginTop: '20px' }}
|
| 653 |
+
onClick={() => window.location.reload()}
|
| 654 |
+
>
|
| 655 |
+
重试
|
| 656 |
+
</Button>
|
| 657 |
+
</Card>
|
| 658 |
+
</div>
|
| 659 |
+
);
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
if (!agent) {
|
| 663 |
+
return (
|
| 664 |
+
<div style={{
|
| 665 |
+
height: '100vh',
|
| 666 |
+
display: 'flex',
|
| 667 |
+
justifyContent: 'center',
|
| 668 |
+
alignItems: 'center',
|
| 669 |
+
background: '#fafbfc'
|
| 670 |
+
}}>
|
| 671 |
+
<Empty description="Agent未找到" />
|
| 672 |
+
</div>
|
| 673 |
+
);
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
// 插件 Tab 配置
|
| 677 |
+
const pluginTabs = [
|
| 678 |
+
...(availablePlugins.includes('code') ? [{
|
| 679 |
+
key: 'code',
|
| 680 |
+
label: <span><CodeOutlined /> 代码</span>,
|
| 681 |
+
children: <CodeExecutor initialCode={pluginData.code} />
|
| 682 |
+
}] : []),
|
| 683 |
+
...(availablePlugins.includes('visualization') ? [{
|
| 684 |
+
key: 'viz',
|
| 685 |
+
label: <span><BarChartOutlined /> 3D</span>,
|
| 686 |
+
children: <Visualization3D code={pluginData.vizCode} />
|
| 687 |
+
}] : []),
|
| 688 |
+
...(availablePlugins.includes('mindmap') ? [{
|
| 689 |
+
key: 'mindmap',
|
| 690 |
+
label: <span><BranchesOutlined /> 导图</span>,
|
| 691 |
+
children: <MindmapViewer markdown={pluginData.mindmapMd} />
|
| 692 |
+
}] : []),
|
| 693 |
+
];
|
| 694 |
+
|
| 695 |
+
return (
|
| 696 |
+
<Layout style={{ height: '100vh', background: '#fafbfc' }}>
|
| 697 |
+
{/* 顶部栏 */}
|
| 698 |
+
<div style={{
|
| 699 |
+
height: '60px',
|
| 700 |
+
background: '#ffffff',
|
| 701 |
+
borderBottom: '1px solid rgba(0, 0, 0, 0.08)',
|
| 702 |
+
display: 'flex',
|
| 703 |
+
alignItems: 'center',
|
| 704 |
+
justifyContent: 'space-between',
|
| 705 |
+
padding: '0 24px',
|
| 706 |
+
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)'
|
| 707 |
+
}}>
|
| 708 |
+
<Space size="large">
|
| 709 |
+
<Avatar
|
| 710 |
+
size="large"
|
| 711 |
+
icon={<RobotOutlined />}
|
| 712 |
+
style={{ backgroundColor: '#8b5cf6' }}
|
| 713 |
+
/>
|
| 714 |
+
<div>
|
| 715 |
+
<Title level={4} style={{ margin: 0 }}>{agent.name}</Title>
|
| 716 |
+
<Text type="secondary" style={{ fontSize: '12px' }}>{agent.description}</Text>
|
| 717 |
+
</div>
|
| 718 |
+
</Space>
|
| 719 |
+
{/* 插件面板切换按钮 */}
|
| 720 |
+
{availablePlugins.length > 0 && (
|
| 721 |
+
<Button
|
| 722 |
+
type={pluginPanelOpen ? 'primary' : 'default'}
|
| 723 |
+
icon={pluginPanelOpen ? <CloseOutlined /> : <AppstoreOutlined />}
|
| 724 |
+
onClick={() => setPluginPanelOpen(!pluginPanelOpen)}
|
| 725 |
+
style={pluginPanelOpen ? { background: '#8b5cf6', borderColor: '#8b5cf6' } : {}}
|
| 726 |
+
>
|
| 727 |
+
{pluginPanelOpen ? '关闭面板' : '插件面板'}
|
| 728 |
+
</Button>
|
| 729 |
+
)}
|
| 730 |
+
</div>
|
| 731 |
+
|
| 732 |
+
{/* 主体区域:对话 + 插件面板 */}
|
| 733 |
+
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
| 734 |
+
{/* 左侧对话区 */}
|
| 735 |
+
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
| 736 |
+
<Content style={{
|
| 737 |
+
flex: 1,
|
| 738 |
+
padding: '24px',
|
| 739 |
+
overflowY: 'auto',
|
| 740 |
+
background: '#fafbfc'
|
| 741 |
+
}}>
|
| 742 |
+
<div style={{ maxWidth: '900px', margin: '0 auto' }}>
|
| 743 |
+
{messages.map(renderMessage)}
|
| 744 |
+
<div ref={messagesEndRef} />
|
| 745 |
+
</div>
|
| 746 |
+
</Content>
|
| 747 |
+
|
| 748 |
+
{/* 输入区域 */}
|
| 749 |
+
<div style={{
|
| 750 |
+
padding: '16px 24px',
|
| 751 |
+
background: '#ffffff',
|
| 752 |
+
borderTop: '1px solid rgba(0, 0, 0, 0.08)',
|
| 753 |
+
boxShadow: '0 -2px 8px rgba(0, 0, 0, 0.04)'
|
| 754 |
+
}}>
|
| 755 |
+
<div style={{ maxWidth: '900px', margin: '0 auto' }}>
|
| 756 |
+
<Space.Compact style={{ width: '100%' }}>
|
| 757 |
+
<TextArea
|
| 758 |
+
value={inputMessage}
|
| 759 |
+
onChange={e => setInputMessage(e.target.value)}
|
| 760 |
+
onPressEnter={e => {
|
| 761 |
+
if (!e.shiftKey) {
|
| 762 |
+
e.preventDefault();
|
| 763 |
+
handleSendMessage();
|
| 764 |
+
}
|
| 765 |
+
}}
|
| 766 |
+
placeholder="输入你的问题... (Shift+Enter换行)"
|
| 767 |
+
autoSize={{ minRows: 1, maxRows: 4 }}
|
| 768 |
+
style={{
|
| 769 |
+
borderRadius: '8px 0 0 8px',
|
| 770 |
+
resize: 'none'
|
| 771 |
+
}}
|
| 772 |
+
/>
|
| 773 |
+
<Button
|
| 774 |
+
type="primary"
|
| 775 |
+
icon={<SendOutlined />}
|
| 776 |
+
onClick={handleSendMessage}
|
| 777 |
+
loading={isLoading}
|
| 778 |
+
style={{
|
| 779 |
+
height: 'auto',
|
| 780 |
+
minHeight: '32px',
|
| 781 |
+
borderRadius: '0 8px 8px 0',
|
| 782 |
+
background: '#10b981'
|
| 783 |
+
}}
|
| 784 |
+
>
|
| 785 |
+
发送
|
| 786 |
+
</Button>
|
| 787 |
+
</Space.Compact>
|
| 788 |
+
</div>
|
| 789 |
+
</div>
|
| 790 |
+
</div>
|
| 791 |
+
|
| 792 |
+
{/* 右侧:拖拽分隔条 + 插件面板 */}
|
| 793 |
+
{pluginPanelOpen && pluginTabs.length > 0 && (
|
| 794 |
+
<>
|
| 795 |
+
{/* 拖拽分隔条 */}
|
| 796 |
+
<div
|
| 797 |
+
ref={dragRef}
|
| 798 |
+
onMouseDown={handleDragStart}
|
| 799 |
+
style={{
|
| 800 |
+
width: '6px',
|
| 801 |
+
cursor: 'col-resize',
|
| 802 |
+
background: 'transparent',
|
| 803 |
+
position: 'relative',
|
| 804 |
+
flexShrink: 0,
|
| 805 |
+
zIndex: 10,
|
| 806 |
+
}}
|
| 807 |
+
>
|
| 808 |
+
<div style={{
|
| 809 |
+
position: 'absolute',
|
| 810 |
+
left: '2px',
|
| 811 |
+
top: 0,
|
| 812 |
+
bottom: 0,
|
| 813 |
+
width: '2px',
|
| 814 |
+
background: 'rgba(139, 92, 246, 0.2)',
|
| 815 |
+
transition: 'background 0.2s',
|
| 816 |
+
}} />
|
| 817 |
+
</div>
|
| 818 |
+
{/* 插件面板 */}
|
| 819 |
+
<div style={{
|
| 820 |
+
flex: `0 0 ${panelRatio * 100}%`,
|
| 821 |
+
minWidth: '300px',
|
| 822 |
+
maxWidth: '75%',
|
| 823 |
+
background: '#ffffff',
|
| 824 |
+
display: 'flex',
|
| 825 |
+
flexDirection: 'column',
|
| 826 |
+
overflow: 'hidden',
|
| 827 |
+
position: 'relative'
|
| 828 |
+
}}>
|
| 829 |
+
{/* 拖拽时遮罩,阻止 iframe 捕获鼠标 */}
|
| 830 |
+
{isDragging && (
|
| 831 |
+
<div style={{
|
| 832 |
+
position: 'absolute',
|
| 833 |
+
inset: 0,
|
| 834 |
+
zIndex: 100,
|
| 835 |
+
cursor: 'col-resize',
|
| 836 |
+
}} />
|
| 837 |
+
)}
|
| 838 |
+
<Tabs
|
| 839 |
+
activeKey={activePlugin}
|
| 840 |
+
onChange={setActivePlugin}
|
| 841 |
+
items={pluginTabs}
|
| 842 |
+
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
|
| 843 |
+
tabBarStyle={{ padding: '0 16px', marginBottom: 0 }}
|
| 844 |
+
tabBarGutter={16}
|
| 845 |
+
/>
|
| 846 |
+
</div>
|
| 847 |
+
</>
|
| 848 |
+
)}
|
| 849 |
+
</div>
|
| 850 |
+
</Layout>
|
| 851 |
+
);
|
| 852 |
+
};
|
| 853 |
+
|
| 854 |
+
export default AgentChat;
|
frontend/src/pages/student/StudentDashboard.jsx
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 学生仪表板主路由组件
|
| 2 |
+
import { Routes, Route, Navigate } from 'react-router-dom';
|
| 3 |
+
import StudentPortal from './StudentPortal';
|
| 4 |
+
|
| 5 |
+
const StudentDashboard = () => {
|
| 6 |
+
return (
|
| 7 |
+
<Routes>
|
| 8 |
+
<Route path="/" element={<StudentPortal />} />
|
| 9 |
+
<Route path="*" element={<Navigate to="/student" replace />} />
|
| 10 |
+
</Routes>
|
| 11 |
+
);
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
export default StudentDashboard;
|
frontend/src/pages/student/StudentPortal.jsx
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 学生端 - 门户页面
|
| 3 |
+
* 基于 student_portal.html 的React实现
|
| 4 |
+
* 包含Agent列表、活动记录、访问令牌输入等功能
|
| 5 |
+
*/
|
| 6 |
+
import { useState, useEffect } from 'react';
|
| 7 |
+
import { useNavigate } from 'react-router-dom';
|
| 8 |
+
import {
|
| 9 |
+
Layout,
|
| 10 |
+
Card,
|
| 11 |
+
Row,
|
| 12 |
+
Col,
|
| 13 |
+
Input,
|
| 14 |
+
Button,
|
| 15 |
+
Space,
|
| 16 |
+
Typography,
|
| 17 |
+
Tag,
|
| 18 |
+
Empty,
|
| 19 |
+
message,
|
| 20 |
+
Spin,
|
| 21 |
+
Timeline,
|
| 22 |
+
Avatar,
|
| 23 |
+
Divider
|
| 24 |
+
} from 'antd';
|
| 25 |
+
import {
|
| 26 |
+
RobotOutlined,
|
| 27 |
+
ClockCircleOutlined,
|
| 28 |
+
CodeOutlined,
|
| 29 |
+
BarChartOutlined,
|
| 30 |
+
BulbOutlined,
|
| 31 |
+
LoginOutlined,
|
| 32 |
+
BookOutlined,
|
| 33 |
+
MessageOutlined,
|
| 34 |
+
ExperimentOutlined,
|
| 35 |
+
TeamOutlined,
|
| 36 |
+
CalendarOutlined
|
| 37 |
+
} from '@ant-design/icons';
|
| 38 |
+
import api from '../../services/api';
|
| 39 |
+
import { motion } from 'framer-motion';
|
| 40 |
+
|
| 41 |
+
const { Content } = Layout;
|
| 42 |
+
const { Title, Text, Paragraph } = Typography;
|
| 43 |
+
const { Search } = Input;
|
| 44 |
+
|
| 45 |
+
const StudentPortal = () => {
|
| 46 |
+
const navigate = useNavigate();
|
| 47 |
+
const [loading, setLoading] = useState(true);
|
| 48 |
+
const [agents, setAgents] = useState([]);
|
| 49 |
+
const [activities, setActivities] = useState([]);
|
| 50 |
+
const [accessToken, setAccessToken] = useState('');
|
| 51 |
+
const [loadingActivities, setLoadingActivities] = useState(false);
|
| 52 |
+
|
| 53 |
+
// 加载Agent列表和活动记录
|
| 54 |
+
useEffect(() => {
|
| 55 |
+
loadData();
|
| 56 |
+
}, []);
|
| 57 |
+
|
| 58 |
+
const loadData = async () => {
|
| 59 |
+
try {
|
| 60 |
+
setLoading(true);
|
| 61 |
+
// 并行加载Agent列表和活动记录
|
| 62 |
+
const [agentsRes, activitiesRes] = await Promise.all([
|
| 63 |
+
loadAgents(),
|
| 64 |
+
loadActivities()
|
| 65 |
+
]);
|
| 66 |
+
|
| 67 |
+
setAgents(agentsRes || []);
|
| 68 |
+
setActivities(activitiesRes || []);
|
| 69 |
+
} catch (error) {
|
| 70 |
+
console.error('加载数据失败:', error);
|
| 71 |
+
} finally {
|
| 72 |
+
setLoading(false);
|
| 73 |
+
}
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
// 加载学生可访问的Agent列表
|
| 77 |
+
const loadAgents = async () => {
|
| 78 |
+
try {
|
| 79 |
+
const response = await api.get('/student/agents');
|
| 80 |
+
if (response.success) {
|
| 81 |
+
return response.data || [];
|
| 82 |
+
}
|
| 83 |
+
return [];
|
| 84 |
+
} catch (error) {
|
| 85 |
+
console.error('加载Agent列表失败:', error);
|
| 86 |
+
return [];
|
| 87 |
+
}
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
// 加载活动记录
|
| 91 |
+
const loadActivities = async () => {
|
| 92 |
+
try {
|
| 93 |
+
setLoadingActivities(true);
|
| 94 |
+
const response = await api.get('/student/activities');
|
| 95 |
+
if (response.success) {
|
| 96 |
+
return response.activities || [];
|
| 97 |
+
}
|
| 98 |
+
return [];
|
| 99 |
+
} catch (error) {
|
| 100 |
+
console.error('加载活动记录失败:', error);
|
| 101 |
+
return [];
|
| 102 |
+
} finally {
|
| 103 |
+
setLoadingActivities(false);
|
| 104 |
+
}
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
// 使用访问令牌访问Agent
|
| 108 |
+
const handleAccessWithToken = async () => {
|
| 109 |
+
if (!accessToken.trim()) {
|
| 110 |
+
message.warning('请输入访问令牌');
|
| 111 |
+
return;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
try {
|
| 115 |
+
// 验证令牌
|
| 116 |
+
const response = await api.post('/verify_token', {
|
| 117 |
+
token: accessToken
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
if (response.success) {
|
| 121 |
+
const agent = response.agent;
|
| 122 |
+
message.success('验证成功,正在跳转...');
|
| 123 |
+
// 跳转到聊天界面
|
| 124 |
+
navigate(`/student/chat/${agent.id}?token=${accessToken}`);
|
| 125 |
+
} else {
|
| 126 |
+
message.error('访问令牌无效或已过期');
|
| 127 |
+
}
|
| 128 |
+
} catch (error) {
|
| 129 |
+
console.error('验证失败:', error);
|
| 130 |
+
message.error('验证失败,请检查令牌是否正确');
|
| 131 |
+
}
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
// 访问Agent
|
| 135 |
+
const handleAccessAgent = (agent) => {
|
| 136 |
+
// 如果有token,使用token访问
|
| 137 |
+
if (agent.token) {
|
| 138 |
+
navigate(`/student/chat/${agent.id}?token=${agent.token}`);
|
| 139 |
+
} else {
|
| 140 |
+
message.warning('该Agent暂无可用的访问令牌');
|
| 141 |
+
}
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
// 获取活动图标
|
| 145 |
+
const getActivityIcon = (type) => {
|
| 146 |
+
const icons = {
|
| 147 |
+
chat: <MessageOutlined style={{ color: '#10b981' }} />,
|
| 148 |
+
code: <CodeOutlined style={{ color: '#3b82f6' }} />,
|
| 149 |
+
viz: <BarChartOutlined style={{ color: '#f59e0b' }} />,
|
| 150 |
+
mindmap: <BulbOutlined style={{ color: '#8b5cf6' }} />
|
| 151 |
+
};
|
| 152 |
+
return icons[type] || <ClockCircleOutlined style={{ color: '#6b7280' }} />;
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
// 获取插件标签颜色
|
| 156 |
+
const getPluginColor = (plugin) => {
|
| 157 |
+
const colors = {
|
| 158 |
+
code: 'blue',
|
| 159 |
+
visualization: 'orange',
|
| 160 |
+
mindmap: 'purple'
|
| 161 |
+
};
|
| 162 |
+
return colors[plugin] || 'default';
|
| 163 |
+
};
|
| 164 |
+
|
| 165 |
+
if (loading) {
|
| 166 |
+
return (
|
| 167 |
+
<div style={{
|
| 168 |
+
height: '100vh',
|
| 169 |
+
display: 'flex',
|
| 170 |
+
justifyContent: 'center',
|
| 171 |
+
alignItems: 'center',
|
| 172 |
+
backgroundColor: '#f9fafb'
|
| 173 |
+
}}>
|
| 174 |
+
<Spin size="large" tip="正在加载..." />
|
| 175 |
+
</div>
|
| 176 |
+
);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
return (
|
| 180 |
+
<Layout style={{ minHeight: '100vh', backgroundColor: '#f9fafb' }}>
|
| 181 |
+
<Content style={{ padding: '24px' }}>
|
| 182 |
+
{/* 欢迎区域 */}
|
| 183 |
+
<motion.div
|
| 184 |
+
initial={{ opacity: 0, y: -20 }}
|
| 185 |
+
animate={{ opacity: 1, y: 0 }}
|
| 186 |
+
transition={{ duration: 0.5 }}
|
| 187 |
+
>
|
| 188 |
+
<Card
|
| 189 |
+
style={{
|
| 190 |
+
marginBottom: '24px',
|
| 191 |
+
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
| 192 |
+
border: 'none'
|
| 193 |
+
}}
|
| 194 |
+
>
|
| 195 |
+
<Row align="middle">
|
| 196 |
+
<Col span={18}>
|
| 197 |
+
<Title level={2} style={{ color: '#fff', marginBottom: '8px' }}>
|
| 198 |
+
欢迎来到智能学习平台
|
| 199 |
+
</Title>
|
| 200 |
+
<Paragraph style={{ color: 'rgba(255, 255, 255, 0.9)', fontSize: '16px', marginBottom: 0 }}>
|
| 201 |
+
探索AI驱动的个性化学习体验,与智能助教互动,提升学习效率
|
| 202 |
+
</Paragraph>
|
| 203 |
+
</Col>
|
| 204 |
+
<Col span={6} style={{ textAlign: 'right' }}>
|
| 205 |
+
<Avatar
|
| 206 |
+
size={80}
|
| 207 |
+
icon={<BookOutlined />}
|
| 208 |
+
style={{ backgroundColor: 'rgba(255, 255, 255, 0.2)' }}
|
| 209 |
+
/>
|
| 210 |
+
</Col>
|
| 211 |
+
</Row>
|
| 212 |
+
</Card>
|
| 213 |
+
</motion.div>
|
| 214 |
+
|
| 215 |
+
{/* 快速访问区域 */}
|
| 216 |
+
<Card
|
| 217 |
+
title="快速访问"
|
| 218 |
+
style={{ marginBottom: '24px' }}
|
| 219 |
+
extra={
|
| 220 |
+
<Text type="secondary">
|
| 221 |
+
输入教师提供的访问令牌
|
| 222 |
+
</Text>
|
| 223 |
+
}
|
| 224 |
+
>
|
| 225 |
+
<Search
|
| 226 |
+
placeholder="请输入访问令牌"
|
| 227 |
+
enterButton={
|
| 228 |
+
<Button type="primary" icon={<LoginOutlined />}>
|
| 229 |
+
访问
|
| 230 |
+
</Button>
|
| 231 |
+
}
|
| 232 |
+
size="large"
|
| 233 |
+
value={accessToken}
|
| 234 |
+
onChange={(e) => setAccessToken(e.target.value)}
|
| 235 |
+
onSearch={handleAccessWithToken}
|
| 236 |
+
style={{ maxWidth: '600px' }}
|
| 237 |
+
/>
|
| 238 |
+
</Card>
|
| 239 |
+
|
| 240 |
+
<Row gutter={[24, 24]}>
|
| 241 |
+
{/* Agent列表 */}
|
| 242 |
+
<Col xs={24} lg={16}>
|
| 243 |
+
<Card
|
| 244 |
+
title={
|
| 245 |
+
<Space>
|
| 246 |
+
<RobotOutlined />
|
| 247 |
+
<span>可用的AI助教</span>
|
| 248 |
+
</Space>
|
| 249 |
+
}
|
| 250 |
+
bordered={false}
|
| 251 |
+
>
|
| 252 |
+
{agents.length > 0 ? (
|
| 253 |
+
<Row gutter={[16, 16]}>
|
| 254 |
+
{agents.map((agent, index) => (
|
| 255 |
+
<Col xs={24} sm={12} key={agent.id}>
|
| 256 |
+
<motion.div
|
| 257 |
+
initial={{ opacity: 0, scale: 0.9 }}
|
| 258 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 259 |
+
transition={{ delay: index * 0.1 }}
|
| 260 |
+
>
|
| 261 |
+
<Card
|
| 262 |
+
hoverable
|
| 263 |
+
onClick={() => handleAccessAgent(agent)}
|
| 264 |
+
style={{ height: '100%' }}
|
| 265 |
+
>
|
| 266 |
+
<Space direction="vertical" style={{ width: '100%' }}>
|
| 267 |
+
<div>
|
| 268 |
+
<Avatar
|
| 269 |
+
size="large"
|
| 270 |
+
icon={<RobotOutlined />}
|
| 271 |
+
style={{
|
| 272 |
+
backgroundColor: '#8b5cf6',
|
| 273 |
+
marginRight: '12px'
|
| 274 |
+
}}
|
| 275 |
+
/>
|
| 276 |
+
<Text strong style={{ fontSize: '16px' }}>
|
| 277 |
+
{agent.name}
|
| 278 |
+
</Text>
|
| 279 |
+
</div>
|
| 280 |
+
|
| 281 |
+
<Text type="secondary">
|
| 282 |
+
{agent.description || '智能学习助手'}
|
| 283 |
+
</Text>
|
| 284 |
+
|
| 285 |
+
<div>
|
| 286 |
+
{agent.plugins && agent.plugins.map(plugin => (
|
| 287 |
+
<Tag
|
| 288 |
+
key={plugin}
|
| 289 |
+
color={getPluginColor(plugin)}
|
| 290 |
+
style={{ marginRight: '8px' }}
|
| 291 |
+
>
|
| 292 |
+
{plugin === 'code' && <CodeOutlined />}
|
| 293 |
+
{plugin === 'visualization' && <BarChartOutlined />}
|
| 294 |
+
{plugin === 'mindmap' && <BulbOutlined />}
|
| 295 |
+
<span style={{ marginLeft: '4px' }}>
|
| 296 |
+
{plugin === 'code' && '代码执行'}
|
| 297 |
+
{plugin === 'visualization' && '3D可视化'}
|
| 298 |
+
{plugin === 'mindmap' && '思维导图'}
|
| 299 |
+
</span>
|
| 300 |
+
</Tag>
|
| 301 |
+
))}
|
| 302 |
+
</div>
|
| 303 |
+
|
| 304 |
+
<div style={{ marginTop: '8px' }}>
|
| 305 |
+
<Space>
|
| 306 |
+
<TeamOutlined />
|
| 307 |
+
<Text type="secondary" style={{ fontSize: '12px' }}>
|
| 308 |
+
{agent.instructor || '教师'}
|
| 309 |
+
</Text>
|
| 310 |
+
<Divider type="vertical" />
|
| 311 |
+
<CalendarOutlined />
|
| 312 |
+
<Text type="secondary" style={{ fontSize: '12px' }}>
|
| 313 |
+
{agent.last_used || '未使用'}
|
| 314 |
+
</Text>
|
| 315 |
+
</Space>
|
| 316 |
+
</div>
|
| 317 |
+
</Space>
|
| 318 |
+
</Card>
|
| 319 |
+
</motion.div>
|
| 320 |
+
</Col>
|
| 321 |
+
))}
|
| 322 |
+
</Row>
|
| 323 |
+
) : (
|
| 324 |
+
<Empty
|
| 325 |
+
description="暂无可用的AI助教"
|
| 326 |
+
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
| 327 |
+
>
|
| 328 |
+
<Button
|
| 329 |
+
type="primary"
|
| 330 |
+
icon={<LoginOutlined />}
|
| 331 |
+
onClick={() => document.querySelector('input').focus()}
|
| 332 |
+
>
|
| 333 |
+
使用访问令牌
|
| 334 |
+
</Button>
|
| 335 |
+
</Empty>
|
| 336 |
+
)}
|
| 337 |
+
</Card>
|
| 338 |
+
</Col>
|
| 339 |
+
|
| 340 |
+
{/* 活动记录 */}
|
| 341 |
+
<Col xs={24} lg={8}>
|
| 342 |
+
<Card
|
| 343 |
+
title={
|
| 344 |
+
<Space>
|
| 345 |
+
<ClockCircleOutlined />
|
| 346 |
+
<span>最近活动</span>
|
| 347 |
+
</Space>
|
| 348 |
+
}
|
| 349 |
+
bordered={false}
|
| 350 |
+
loading={loadingActivities}
|
| 351 |
+
>
|
| 352 |
+
{activities.length > 0 ? (
|
| 353 |
+
<Timeline>
|
| 354 |
+
{activities.map((activity, index) => (
|
| 355 |
+
<Timeline.Item
|
| 356 |
+
key={index}
|
| 357 |
+
dot={getActivityIcon(activity.type)}
|
| 358 |
+
>
|
| 359 |
+
<div style={{ marginBottom: '12px' }}>
|
| 360 |
+
<Text strong>{activity.title}</Text>
|
| 361 |
+
<br />
|
| 362 |
+
{activity.agent_name && (
|
| 363 |
+
<Text type="secondary" style={{ fontSize: '12px' }}>
|
| 364 |
+
{activity.agent_name}
|
| 365 |
+
</Text>
|
| 366 |
+
)}
|
| 367 |
+
<br />
|
| 368 |
+
<Text type="secondary" style={{ fontSize: '12px' }}>
|
| 369 |
+
{activity.time}
|
| 370 |
+
</Text>
|
| 371 |
+
</div>
|
| 372 |
+
</Timeline.Item>
|
| 373 |
+
))}
|
| 374 |
+
</Timeline>
|
| 375 |
+
) : (
|
| 376 |
+
<Empty
|
| 377 |
+
description="暂无活动记录"
|
| 378 |
+
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
| 379 |
+
/>
|
| 380 |
+
)}
|
| 381 |
+
</Card>
|
| 382 |
+
</Col>
|
| 383 |
+
</Row>
|
| 384 |
+
</Content>
|
| 385 |
+
</Layout>
|
| 386 |
+
);
|
| 387 |
+
};
|
| 388 |
+
|
| 389 |
+
export default StudentPortal;
|
frontend/src/pages/teacher/AgentList.jsx
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 教师端 - Agent列表页面
|
| 3 |
+
* 对应原始 index.html 的 agent-list section
|
| 4 |
+
* 复用原始HTML的Agent列表展示和管理逻辑
|
| 5 |
+
*/
|
| 6 |
+
import { useState, useEffect } from 'react';
|
| 7 |
+
import { Card, Row, Col, Button, Space, Tag, Modal, Form, Select, message, Input, Descriptions, Popconfirm } from 'antd';
|
| 8 |
+
import {
|
| 9 |
+
RobotOutlined,
|
| 10 |
+
ShareAltOutlined,
|
| 11 |
+
EyeOutlined,
|
| 12 |
+
EditOutlined,
|
| 13 |
+
DeleteOutlined,
|
| 14 |
+
PlusOutlined,
|
| 15 |
+
ExclamationCircleOutlined,
|
| 16 |
+
CopyOutlined,
|
| 17 |
+
BookOutlined,
|
| 18 |
+
UserOutlined,
|
| 19 |
+
CodeOutlined,
|
| 20 |
+
BarChartOutlined,
|
| 21 |
+
BulbOutlined,
|
| 22 |
+
} from '@ant-design/icons';
|
| 23 |
+
import { motion } from 'framer-motion';
|
| 24 |
+
import agentService from '../../services/agentService';
|
| 25 |
+
|
| 26 |
+
const { confirm } = Modal;
|
| 27 |
+
|
| 28 |
+
const AgentList = () => {
|
| 29 |
+
const [agents, setAgents] = useState([]);
|
| 30 |
+
const [loading, setLoading] = useState(false);
|
| 31 |
+
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
| 32 |
+
const [distributionModalVisible, setDistributionModalVisible] = useState(false);
|
| 33 |
+
const [selectedAgent, setSelectedAgent] = useState(null);
|
| 34 |
+
const [distributionForm] = Form.useForm();
|
| 35 |
+
const [createdDistribution, setCreatedDistribution] = useState(null);
|
| 36 |
+
|
| 37 |
+
useEffect(() => {
|
| 38 |
+
loadAgents();
|
| 39 |
+
}, []);
|
| 40 |
+
|
| 41 |
+
// 加载Agent列表
|
| 42 |
+
const loadAgents = async () => {
|
| 43 |
+
try {
|
| 44 |
+
setLoading(true);
|
| 45 |
+
const res = await agentService.getAgents();
|
| 46 |
+
setAgents(res.data || []);
|
| 47 |
+
} catch (error) {
|
| 48 |
+
console.error('加载Agent列表失败:', error);
|
| 49 |
+
message.error('加载Agent列表失败');
|
| 50 |
+
} finally {
|
| 51 |
+
setLoading(false);
|
| 52 |
+
}
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
// 获取Agent类型文本
|
| 56 |
+
const getAgentTypeText = (type) => {
|
| 57 |
+
const typeMap = {
|
| 58 |
+
educational: '教育辅导',
|
| 59 |
+
programming: '编程辅助',
|
| 60 |
+
math: '数学辅导',
|
| 61 |
+
general: '通用助手',
|
| 62 |
+
};
|
| 63 |
+
return typeMap[type] || type;
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
// 获取Agent类型颜色
|
| 67 |
+
const getAgentTypeColor = (type) => {
|
| 68 |
+
const colorMap = {
|
| 69 |
+
educational: '#10b981',
|
| 70 |
+
programming: '#3b82f6',
|
| 71 |
+
math: '#f59e0b',
|
| 72 |
+
general: '#8b5cf6',
|
| 73 |
+
};
|
| 74 |
+
return colorMap[type] || '#6b7280';
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
// 获取插件图标
|
| 78 |
+
const getPluginIcon = (pluginId) => {
|
| 79 |
+
const iconMap = {
|
| 80 |
+
code: <CodeOutlined style={{ fontSize: '18px' }} />,
|
| 81 |
+
visualization: <BarChartOutlined style={{ fontSize: '18px' }} />,
|
| 82 |
+
mindmap: <BulbOutlined style={{ fontSize: '18px' }} />,
|
| 83 |
+
};
|
| 84 |
+
return iconMap[pluginId] || <RobotOutlined />;
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
// 获取插件名称
|
| 88 |
+
const getPluginName = (pluginId) => {
|
| 89 |
+
const nameMap = {
|
| 90 |
+
code: '代码执行',
|
| 91 |
+
visualization: '3D可视化',
|
| 92 |
+
mindmap: '思维导图',
|
| 93 |
+
};
|
| 94 |
+
return nameMap[pluginId] || pluginId;
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
// 查看Agent详情
|
| 98 |
+
const handleShowDetail = async (agent) => {
|
| 99 |
+
try {
|
| 100 |
+
const res = await agentService.getAgentDetail(agent.id);
|
| 101 |
+
setSelectedAgent(res.data);
|
| 102 |
+
setDetailModalVisible(true);
|
| 103 |
+
} catch (error) {
|
| 104 |
+
message.error('获取Agent详情失败');
|
| 105 |
+
}
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
// 编辑Agent
|
| 109 |
+
const handleEdit = (agent) => {
|
| 110 |
+
// 将agent数据保存到localStorage
|
| 111 |
+
localStorage.setItem('editingAgent', JSON.stringify(agent));
|
| 112 |
+
// 跳转到创建页面(编辑模式)
|
| 113 |
+
window.location.href = '/teacher/create-agent?mode=edit';
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
// 删除Agent
|
| 117 |
+
const handleDelete = async (agentId) => {
|
| 118 |
+
try {
|
| 119 |
+
await agentService.deleteAgent(agentId);
|
| 120 |
+
message.success('Agent已删除');
|
| 121 |
+
loadAgents();
|
| 122 |
+
} catch (error) {
|
| 123 |
+
console.error('删除失败:', error);
|
| 124 |
+
message.error('删除失败');
|
| 125 |
+
}
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
// 显示创建分发链接对话框
|
| 129 |
+
const handleShowDistribution = (agent) => {
|
| 130 |
+
setSelectedAgent(agent);
|
| 131 |
+
setDistributionModalVisible(true);
|
| 132 |
+
distributionForm.resetFields();
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
// 创建分发链接
|
| 136 |
+
const handleCreateDistribution = async (values) => {
|
| 137 |
+
try {
|
| 138 |
+
const res = await agentService.createDistribution(selectedAgent.id, values.expiresIn);
|
| 139 |
+
|
| 140 |
+
if (res.success) {
|
| 141 |
+
// 后端返回的是 distribution 对象
|
| 142 |
+
const distribution = res.distribution;
|
| 143 |
+
const origin = window.location.origin;
|
| 144 |
+
const fullLink = `${origin}/student/chat/${selectedAgent.id}?token=${distribution.token}`;
|
| 145 |
+
const accessToken = distribution.token;
|
| 146 |
+
|
| 147 |
+
setCreatedDistribution({
|
| 148 |
+
fullLink,
|
| 149 |
+
accessToken,
|
| 150 |
+
});
|
| 151 |
+
|
| 152 |
+
message.success('分发链接创建成功');
|
| 153 |
+
loadAgents(); // 刷新列表
|
| 154 |
+
|
| 155 |
+
// 刷新当前 Agent 详情,确保 distributions 数组更新
|
| 156 |
+
try {
|
| 157 |
+
const detail = await agentService.getAgentDetail(selectedAgent.id);
|
| 158 |
+
setSelectedAgent(detail.data);
|
| 159 |
+
} catch (e) { /* ignore */ }
|
| 160 |
+
} else {
|
| 161 |
+
message.error(res.message || '创建分发链接失败');
|
| 162 |
+
}
|
| 163 |
+
} catch (error) {
|
| 164 |
+
console.error('创建分发链接失败:', error);
|
| 165 |
+
message.error('创建分发链接失败');
|
| 166 |
+
}
|
| 167 |
+
};
|
| 168 |
+
|
| 169 |
+
// 复制到剪贴板
|
| 170 |
+
const copyToClipboard = (text) => {
|
| 171 |
+
navigator.clipboard.writeText(text).then(() => {
|
| 172 |
+
message.success('已复制到剪贴板');
|
| 173 |
+
}).catch(() => {
|
| 174 |
+
message.error('复制失败');
|
| 175 |
+
});
|
| 176 |
+
};
|
| 177 |
+
|
| 178 |
+
return (
|
| 179 |
+
<div>
|
| 180 |
+
<div style={{ marginBottom: '32px' }}>
|
| 181 |
+
<h1 style={{ fontSize: '28px', fontWeight: 600, color: '#1f2937', margin: 0 }}>
|
| 182 |
+
Agent列表
|
| 183 |
+
</h1>
|
| 184 |
+
<p style={{ fontSize: '14px', color: '#6b7280', marginTop: '8px' }}>
|
| 185 |
+
管理您创建的所有AI教学助手
|
| 186 |
+
</p>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
{loading ? (
|
| 190 |
+
<Card loading={loading} />
|
| 191 |
+
) : agents.length > 0 ? (
|
| 192 |
+
<Row gutter={[24, 24]}>
|
| 193 |
+
{agents.map((agent) => (
|
| 194 |
+
<Col xs={24} md={12} lg={8} key={agent.id}>
|
| 195 |
+
<motion.div
|
| 196 |
+
initial={{ opacity: 0, y: 20 }}
|
| 197 |
+
animate={{ opacity: 1, y: 0 }}
|
| 198 |
+
whileHover={{ y: -4 }}
|
| 199 |
+
transition={{ duration: 0.2 }}
|
| 200 |
+
>
|
| 201 |
+
<Card
|
| 202 |
+
style={{
|
| 203 |
+
border: `1px solid ${getAgentTypeColor(agent.type)}33`,
|
| 204 |
+
borderRadius: '12px',
|
| 205 |
+
height: '100%',
|
| 206 |
+
}}
|
| 207 |
+
bodyStyle={{ padding: '20px' }}
|
| 208 |
+
>
|
| 209 |
+
{/* Agent头部 */}
|
| 210 |
+
<div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: '16px' }}>
|
| 211 |
+
<div
|
| 212 |
+
style={{
|
| 213 |
+
width: '48px',
|
| 214 |
+
height: '48px',
|
| 215 |
+
borderRadius: '12px',
|
| 216 |
+
background: `${getAgentTypeColor(agent.type)}15`,
|
| 217 |
+
display: 'flex',
|
| 218 |
+
alignItems: 'center',
|
| 219 |
+
justifyContent: 'center',
|
| 220 |
+
marginRight: '12px',
|
| 221 |
+
flexShrink: 0,
|
| 222 |
+
}}
|
| 223 |
+
>
|
| 224 |
+
<RobotOutlined
|
| 225 |
+
style={{
|
| 226 |
+
fontSize: '24px',
|
| 227 |
+
color: getAgentTypeColor(agent.type),
|
| 228 |
+
}}
|
| 229 |
+
/>
|
| 230 |
+
</div>
|
| 231 |
+
<div style={{ flex: 1, minWidth: 0 }}>
|
| 232 |
+
<h3
|
| 233 |
+
style={{
|
| 234 |
+
fontSize: '18px',
|
| 235 |
+
fontWeight: 600,
|
| 236 |
+
margin: 0,
|
| 237 |
+
color: '#1f2937',
|
| 238 |
+
overflow: 'hidden',
|
| 239 |
+
textOverflow: 'ellipsis',
|
| 240 |
+
whiteSpace: 'nowrap',
|
| 241 |
+
}}
|
| 242 |
+
>
|
| 243 |
+
{agent.name}
|
| 244 |
+
</h3>
|
| 245 |
+
<Tag
|
| 246 |
+
color={getAgentTypeColor(agent.type)}
|
| 247 |
+
style={{ marginTop: '4px' }}
|
| 248 |
+
>
|
| 249 |
+
{getAgentTypeText(agent.type)}
|
| 250 |
+
</Tag>
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
|
| 254 |
+
{/* Agent描述 */}
|
| 255 |
+
<p
|
| 256 |
+
style={{
|
| 257 |
+
fontSize: '14px',
|
| 258 |
+
color: '#6b7280',
|
| 259 |
+
marginBottom: '16px',
|
| 260 |
+
lineHeight: '1.5',
|
| 261 |
+
display: '-webkit-box',
|
| 262 |
+
WebkitLineClamp: 2,
|
| 263 |
+
WebkitBoxOrient: 'vertical',
|
| 264 |
+
overflow: 'hidden',
|
| 265 |
+
}}
|
| 266 |
+
>
|
| 267 |
+
{agent.description || '暂无描述'}
|
| 268 |
+
</p>
|
| 269 |
+
|
| 270 |
+
{/* Agent标签信息 */}
|
| 271 |
+
<div style={{ marginBottom: '16px' }}>
|
| 272 |
+
{agent.subject && (
|
| 273 |
+
<div style={{ fontSize: '13px', color: '#6b7280', marginBottom: '4px' }}>
|
| 274 |
+
<BookOutlined style={{ marginRight: '6px' }} />
|
| 275 |
+
{agent.subject}
|
| 276 |
+
</div>
|
| 277 |
+
)}
|
| 278 |
+
{agent.instructor && (
|
| 279 |
+
<div style={{ fontSize: '13px', color: '#6b7280' }}>
|
| 280 |
+
<UserOutlined style={{ marginRight: '6px' }} />
|
| 281 |
+
{agent.instructor}
|
| 282 |
+
</div>
|
| 283 |
+
)}
|
| 284 |
+
</div>
|
| 285 |
+
|
| 286 |
+
{/* 插件标签 */}
|
| 287 |
+
{agent.plugins && agent.plugins.length > 0 && (
|
| 288 |
+
<div style={{ marginBottom: '16px' }}>
|
| 289 |
+
<Space size={[4, 4]} wrap>
|
| 290 |
+
{agent.plugins.map((pluginId) => (
|
| 291 |
+
<Tag
|
| 292 |
+
key={pluginId}
|
| 293 |
+
icon={getPluginIcon(pluginId)}
|
| 294 |
+
style={{ fontSize: '12px' }}
|
| 295 |
+
>
|
| 296 |
+
{getPluginName(pluginId)}
|
| 297 |
+
</Tag>
|
| 298 |
+
))}
|
| 299 |
+
</Space>
|
| 300 |
+
</div>
|
| 301 |
+
)}
|
| 302 |
+
|
| 303 |
+
{/* ���计信息 */}
|
| 304 |
+
<div
|
| 305 |
+
style={{
|
| 306 |
+
paddingTop: '16px',
|
| 307 |
+
borderTop: '1px solid #e5e7eb',
|
| 308 |
+
marginBottom: '16px',
|
| 309 |
+
}}
|
| 310 |
+
>
|
| 311 |
+
<Row gutter={16}>
|
| 312 |
+
<Col span={12}>
|
| 313 |
+
<div style={{ fontSize: '12px', color: '#9ca3af' }}>分发链接</div>
|
| 314 |
+
<div style={{ fontSize: '18px', fontWeight: 600, color: '#1f2937' }}>
|
| 315 |
+
{agent.distribution_count || 0}
|
| 316 |
+
</div>
|
| 317 |
+
</Col>
|
| 318 |
+
<Col span={12}>
|
| 319 |
+
<div style={{ fontSize: '12px', color: '#9ca3af' }}>使用次数</div>
|
| 320 |
+
<div style={{ fontSize: '18px', fontWeight: 600, color: '#1f2937' }}>
|
| 321 |
+
{agent.usageCount || 0}
|
| 322 |
+
</div>
|
| 323 |
+
</Col>
|
| 324 |
+
</Row>
|
| 325 |
+
</div>
|
| 326 |
+
|
| 327 |
+
{/* 操作按钮 */}
|
| 328 |
+
<Space size="small" style={{ width: '100%' }} wrap>
|
| 329 |
+
<Button
|
| 330 |
+
type="primary"
|
| 331 |
+
size="small"
|
| 332 |
+
icon={<ShareAltOutlined />}
|
| 333 |
+
onClick={() => handleShowDistribution(agent)}
|
| 334 |
+
style={{ backgroundColor: '#10b981' }}
|
| 335 |
+
>
|
| 336 |
+
分发
|
| 337 |
+
</Button>
|
| 338 |
+
<Button
|
| 339 |
+
size="small"
|
| 340 |
+
icon={<EyeOutlined />}
|
| 341 |
+
onClick={() => handleShowDetail(agent)}
|
| 342 |
+
>
|
| 343 |
+
详情
|
| 344 |
+
</Button>
|
| 345 |
+
<Button
|
| 346 |
+
size="small"
|
| 347 |
+
icon={<EditOutlined />}
|
| 348 |
+
onClick={() => handleEdit(agent)}
|
| 349 |
+
>
|
| 350 |
+
编辑
|
| 351 |
+
</Button>
|
| 352 |
+
<Popconfirm
|
| 353 |
+
title="确认删除"
|
| 354 |
+
description={`确定要删除Agent "${agent.name}" 吗?`}
|
| 355 |
+
onConfirm={() => handleDelete(agent.id)}
|
| 356 |
+
okText="删除"
|
| 357 |
+
cancelText="取消"
|
| 358 |
+
okButtonProps={{ danger: true }}
|
| 359 |
+
>
|
| 360 |
+
<Button
|
| 361 |
+
danger
|
| 362 |
+
size="small"
|
| 363 |
+
icon={<DeleteOutlined />}
|
| 364 |
+
>
|
| 365 |
+
删除
|
| 366 |
+
</Button>
|
| 367 |
+
</Popconfirm>
|
| 368 |
+
</Space>
|
| 369 |
+
</Card>
|
| 370 |
+
</motion.div>
|
| 371 |
+
</Col>
|
| 372 |
+
))}
|
| 373 |
+
</Row>
|
| 374 |
+
) : (
|
| 375 |
+
<Card>
|
| 376 |
+
<div
|
| 377 |
+
style={{
|
| 378 |
+
textAlign: 'center',
|
| 379 |
+
padding: '60px 20px',
|
| 380 |
+
color: '#9ca3af',
|
| 381 |
+
}}
|
| 382 |
+
>
|
| 383 |
+
<RobotOutlined style={{ fontSize: '64px', marginBottom: '16px', opacity: 0.5 }} />
|
| 384 |
+
<div style={{ fontSize: '16px', marginBottom: '8px' }}>
|
| 385 |
+
还没有创建任何Agent
|
| 386 |
+
</div>
|
| 387 |
+
<div style={{ fontSize: '14px', marginBottom: '24px' }}>
|
| 388 |
+
创建您的第一个AI教学助手
|
| 389 |
+
</div>
|
| 390 |
+
<Button
|
| 391 |
+
type="primary"
|
| 392 |
+
size="large"
|
| 393 |
+
icon={<PlusOutlined />}
|
| 394 |
+
style={{ backgroundColor: '#10b981' }}
|
| 395 |
+
>
|
| 396 |
+
创建Agent
|
| 397 |
+
</Button>
|
| 398 |
+
</div>
|
| 399 |
+
</Card>
|
| 400 |
+
)}
|
| 401 |
+
|
| 402 |
+
{/* Agent详情对话框 */}
|
| 403 |
+
<Modal
|
| 404 |
+
title={<span style={{ fontSize: '20px', fontWeight: 600 }}>Agent详情</span>}
|
| 405 |
+
open={detailModalVisible}
|
| 406 |
+
onCancel={() => setDetailModalVisible(false)}
|
| 407 |
+
footer={null}
|
| 408 |
+
width={800}
|
| 409 |
+
>
|
| 410 |
+
{selectedAgent && (
|
| 411 |
+
<div>
|
| 412 |
+
<Descriptions column={2} bordered>
|
| 413 |
+
<Descriptions.Item label="名称" span={2}>
|
| 414 |
+
{selectedAgent.name}
|
| 415 |
+
</Descriptions.Item>
|
| 416 |
+
<Descriptions.Item label="描述" span={2}>
|
| 417 |
+
{selectedAgent.description}
|
| 418 |
+
</Descriptions.Item>
|
| 419 |
+
<Descriptions.Item label="类型">
|
| 420 |
+
<Tag color={getAgentTypeColor(selectedAgent.type)}>
|
| 421 |
+
{getAgentTypeText(selectedAgent.type)}
|
| 422 |
+
</Tag>
|
| 423 |
+
</Descriptions.Item>
|
| 424 |
+
<Descriptions.Item label="学科">
|
| 425 |
+
{selectedAgent.subject || '-'}
|
| 426 |
+
</Descriptions.Item>
|
| 427 |
+
<Descriptions.Item label="指导教师">
|
| 428 |
+
{selectedAgent.instructor || '-'}
|
| 429 |
+
</Descriptions.Item>
|
| 430 |
+
<Descriptions.Item label="创建时间">
|
| 431 |
+
{selectedAgent.created_at || '-'}
|
| 432 |
+
</Descriptions.Item>
|
| 433 |
+
</Descriptions>
|
| 434 |
+
|
| 435 |
+
<div style={{ marginTop: '24px' }}>
|
| 436 |
+
<h4 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '12px' }}>
|
| 437 |
+
启用的插件
|
| 438 |
+
</h4>
|
| 439 |
+
{selectedAgent.plugins && selectedAgent.plugins.length > 0 ? (
|
| 440 |
+
<Space size={[8, 8]} wrap>
|
| 441 |
+
{selectedAgent.plugins.map((pluginId) => (
|
| 442 |
+
<Tag
|
| 443 |
+
key={pluginId}
|
| 444 |
+
icon={getPluginIcon(pluginId)}
|
| 445 |
+
style={{ fontSize: '14px', padding: '4px 12px' }}
|
| 446 |
+
>
|
| 447 |
+
{getPluginName(pluginId)}
|
| 448 |
+
</Tag>
|
| 449 |
+
))}
|
| 450 |
+
</Space>
|
| 451 |
+
) : (
|
| 452 |
+
<div style={{ color: '#9ca3af' }}>未启用任何插件</div>
|
| 453 |
+
)}
|
| 454 |
+
</div>
|
| 455 |
+
|
| 456 |
+
<div style={{ marginTop: '24px' }}>
|
| 457 |
+
<h4 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '12px' }}>
|
| 458 |
+
关联的知识库
|
| 459 |
+
</h4>
|
| 460 |
+
{selectedAgent.knowledgeBases && selectedAgent.knowledgeBases.length > 0 ? (
|
| 461 |
+
<Space direction="vertical" style={{ width: '100%' }}>
|
| 462 |
+
{selectedAgent.knowledgeBases.map((kb) => (
|
| 463 |
+
<div
|
| 464 |
+
key={kb.id}
|
| 465 |
+
style={{
|
| 466 |
+
padding: '12px',
|
| 467 |
+
background: '#f8fafc',
|
| 468 |
+
borderRadius: '8px',
|
| 469 |
+
border: '1px solid #e5e7eb',
|
| 470 |
+
}}
|
| 471 |
+
>
|
| 472 |
+
{kb.name}
|
| 473 |
+
</div>
|
| 474 |
+
))}
|
| 475 |
+
</Space>
|
| 476 |
+
) : (
|
| 477 |
+
<div style={{ color: '#9ca3af' }}>未关联任何知识库</div>
|
| 478 |
+
)}
|
| 479 |
+
</div>
|
| 480 |
+
|
| 481 |
+
{/* 已创建的分发链接 */}
|
| 482 |
+
<div style={{ marginTop: '24px' }}>
|
| 483 |
+
<h4 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '12px' }}>
|
| 484 |
+
已创建的分发链接 ({selectedAgent.distributions?.length || 0})
|
| 485 |
+
</h4>
|
| 486 |
+
{selectedAgent.distributions && selectedAgent.distributions.length > 0 ? (
|
| 487 |
+
<Space direction="vertical" style={{ width: '100%' }}>
|
| 488 |
+
{selectedAgent.distributions.map((dist) => (
|
| 489 |
+
<div
|
| 490 |
+
key={dist.id}
|
| 491 |
+
style={{
|
| 492 |
+
padding: '12px',
|
| 493 |
+
background: '#f0f9ff',
|
| 494 |
+
borderRadius: '8px',
|
| 495 |
+
border: '1px solid #bae6fd',
|
| 496 |
+
}}
|
| 497 |
+
>
|
| 498 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 499 |
+
<div>
|
| 500 |
+
<div style={{ fontSize: '12px', color: '#6b7280' }}>
|
| 501 |
+
Token: {dist.token}
|
| 502 |
+
</div>
|
| 503 |
+
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '4px' }}>
|
| 504 |
+
创建时间: {new Date(dist.created_at * 1000).toLocaleString()}
|
| 505 |
+
</div>
|
| 506 |
+
{dist.expires_at > 0 && (
|
| 507 |
+
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '4px' }}>
|
| 508 |
+
过期时间: {new Date(dist.expires_at * 1000).toLocaleString()}
|
| 509 |
+
</div>
|
| 510 |
+
)}
|
| 511 |
+
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '4px' }}>
|
| 512 |
+
使用次数: {dist.usage_count || 0}
|
| 513 |
+
</div>
|
| 514 |
+
</div>
|
| 515 |
+
<Space>
|
| 516 |
+
<Button
|
| 517 |
+
size="small"
|
| 518 |
+
icon={<CopyOutlined />}
|
| 519 |
+
onClick={() => {
|
| 520 |
+
const link = `${window.location.origin}/student/chat/${selectedAgent.id}?token=${dist.token}`;
|
| 521 |
+
copyToClipboard(link);
|
| 522 |
+
}}
|
| 523 |
+
>
|
| 524 |
+
复制链接
|
| 525 |
+
</Button>
|
| 526 |
+
<Button
|
| 527 |
+
size="small"
|
| 528 |
+
type="link"
|
| 529 |
+
onClick={() => {
|
| 530 |
+
const link = `${window.location.origin}/student/chat/${selectedAgent.id}?token=${dist.token}`;
|
| 531 |
+
window.open(link, '_blank');
|
| 532 |
+
}}
|
| 533 |
+
>
|
| 534 |
+
测试
|
| 535 |
+
</Button>
|
| 536 |
+
</Space>
|
| 537 |
+
</div>
|
| 538 |
+
</div>
|
| 539 |
+
))}
|
| 540 |
+
</Space>
|
| 541 |
+
) : (
|
| 542 |
+
<div style={{ color: '#9ca3af' }}>还没有创建分发链接</div>
|
| 543 |
+
)}
|
| 544 |
+
</div>
|
| 545 |
+
</div>
|
| 546 |
+
)}
|
| 547 |
+
</Modal>
|
| 548 |
+
|
| 549 |
+
{/* 创建分发链接对话框 */}
|
| 550 |
+
<Modal
|
| 551 |
+
title={<span style={{ fontSize: '20px', fontWeight: 600 }}>创建分发链接</span>}
|
| 552 |
+
open={distributionModalVisible}
|
| 553 |
+
onCancel={() => {
|
| 554 |
+
setDistributionModalVisible(false);
|
| 555 |
+
setCreatedDistribution(null);
|
| 556 |
+
}}
|
| 557 |
+
footer={null}
|
| 558 |
+
width={600}
|
| 559 |
+
>
|
| 560 |
+
{!createdDistribution ? (
|
| 561 |
+
<Form
|
| 562 |
+
form={distributionForm}
|
| 563 |
+
layout="vertical"
|
| 564 |
+
onFinish={handleCreateDistribution}
|
| 565 |
+
>
|
| 566 |
+
<Form.Item
|
| 567 |
+
label="链接有效期"
|
| 568 |
+
name="expiresIn"
|
| 569 |
+
rules={[{ required: true, message: '请选择有效期' }]}
|
| 570 |
+
initialValue={7}
|
| 571 |
+
>
|
| 572 |
+
<Select size="large">
|
| 573 |
+
<Select.Option value={7}>7天</Select.Option>
|
| 574 |
+
<Select.Option value={30}>30天</Select.Option>
|
| 575 |
+
<Select.Option value={90}>90天</Select.Option>
|
| 576 |
+
<Select.Option value={0}>永不过期</Select.Option>
|
| 577 |
+
</Select>
|
| 578 |
+
</Form.Item>
|
| 579 |
+
|
| 580 |
+
<Form.Item>
|
| 581 |
+
<Button
|
| 582 |
+
type="primary"
|
| 583 |
+
htmlType="submit"
|
| 584 |
+
size="large"
|
| 585 |
+
block
|
| 586 |
+
style={{ backgroundColor: '#10b981' }}
|
| 587 |
+
>
|
| 588 |
+
生成分发链接
|
| 589 |
+
</Button>
|
| 590 |
+
</Form.Item>
|
| 591 |
+
</Form>
|
| 592 |
+
) : (
|
| 593 |
+
<div>
|
| 594 |
+
<div
|
| 595 |
+
style={{
|
| 596 |
+
padding: '16px',
|
| 597 |
+
background: '#f0fdf4',
|
| 598 |
+
borderRadius: '8px',
|
| 599 |
+
border: '1px solid #86efac',
|
| 600 |
+
marginBottom: '16px',
|
| 601 |
+
}}
|
| 602 |
+
>
|
| 603 |
+
<div style={{ color: '#15803d', marginBottom: '8px', fontWeight: 500 }}>
|
| 604 |
+
✓ 分发链接创建成功
|
| 605 |
+
</div>
|
| 606 |
+
</div>
|
| 607 |
+
|
| 608 |
+
<div style={{ marginBottom: '16px' }}>
|
| 609 |
+
<div style={{ fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
| 610 |
+
完整链接:
|
| 611 |
+
</div>
|
| 612 |
+
<Input.TextArea
|
| 613 |
+
value={createdDistribution.fullLink}
|
| 614 |
+
readOnly
|
| 615 |
+
autoSize={{ minRows: 2 }}
|
| 616 |
+
style={{ marginBottom: '8px' }}
|
| 617 |
+
/>
|
| 618 |
+
<Button
|
| 619 |
+
icon={<CopyOutlined />}
|
| 620 |
+
onClick={() => copyToClipboard(createdDistribution.fullLink)}
|
| 621 |
+
block
|
| 622 |
+
style={{ marginBottom: '8px' }}
|
| 623 |
+
>
|
| 624 |
+
复制完整链接
|
| 625 |
+
</Button>
|
| 626 |
+
<Button
|
| 627 |
+
type="primary"
|
| 628 |
+
onClick={() => window.open(createdDistribution.fullLink, '_blank')}
|
| 629 |
+
block
|
| 630 |
+
>
|
| 631 |
+
测试链接
|
| 632 |
+
</Button>
|
| 633 |
+
</div>
|
| 634 |
+
|
| 635 |
+
<div>
|
| 636 |
+
<div style={{ fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
| 637 |
+
访问令牌(学生可在登录页输入):
|
| 638 |
+
</div>
|
| 639 |
+
<Input.TextArea
|
| 640 |
+
value={createdDistribution.accessToken}
|
| 641 |
+
readOnly
|
| 642 |
+
autoSize={{ minRows: 2 }}
|
| 643 |
+
style={{ marginBottom: '8px' }}
|
| 644 |
+
/>
|
| 645 |
+
<Button
|
| 646 |
+
icon={<CopyOutlined />}
|
| 647 |
+
onClick={() => copyToClipboard(createdDistribution.accessToken)}
|
| 648 |
+
block
|
| 649 |
+
>
|
| 650 |
+
复制访问令牌
|
| 651 |
+
</Button>
|
| 652 |
+
</div>
|
| 653 |
+
</div>
|
| 654 |
+
)}
|
| 655 |
+
</Modal>
|
| 656 |
+
</div>
|
| 657 |
+
);
|
| 658 |
+
};
|
| 659 |
+
|
| 660 |
+
export default AgentList;
|
frontend/src/pages/teacher/CreateAgent.jsx
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 教师端 - 创建Agent页面
|
| 3 |
+
* 对应原始 index.html 的 create-agent section (4步向导)
|
| 4 |
+
* 复用原始HTML的4步表单逻辑
|
| 5 |
+
*/
|
| 6 |
+
import { useState, useEffect } from 'react';
|
| 7 |
+
import { Steps, Card, Form, Input, Select, Button, Space, Checkbox, message, Row, Col, Modal } from 'antd';
|
| 8 |
+
import {
|
| 9 |
+
InfoCircleOutlined,
|
| 10 |
+
AppstoreOutlined,
|
| 11 |
+
DatabaseOutlined,
|
| 12 |
+
BranchesOutlined,
|
| 13 |
+
ArrowLeftOutlined,
|
| 14 |
+
ArrowRightOutlined,
|
| 15 |
+
CheckOutlined,
|
| 16 |
+
} from '@ant-design/icons';
|
| 17 |
+
import { motion } from 'framer-motion';
|
| 18 |
+
import knowledgeService from '../../services/knowledgeService';
|
| 19 |
+
import agentService from '../../services/agentService';
|
| 20 |
+
import WorkflowEditor from '../../components/WorkflowEditor';
|
| 21 |
+
|
| 22 |
+
const { TextArea } = Input;
|
| 23 |
+
|
| 24 |
+
const CreateAgent = () => {
|
| 25 |
+
const [form] = Form.useForm();
|
| 26 |
+
const [currentStep, setCurrentStep] = useState(0);
|
| 27 |
+
const [submitting, setSubmitting] = useState(false); // 创建Agent的loading
|
| 28 |
+
const [aiGenerating, setAiGenerating] = useState(false); // AI生成工作流的loading
|
| 29 |
+
const [selectedPlugins, setSelectedPlugins] = useState([]);
|
| 30 |
+
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState([]);
|
| 31 |
+
const [knowledgeBases, setKnowledgeBases] = useState([]);
|
| 32 |
+
const [workflow, setWorkflow] = useState(null);
|
| 33 |
+
const [showWorkflowEditor, setShowWorkflowEditor] = useState(false);
|
| 34 |
+
|
| 35 |
+
// 检查是否是编辑模式
|
| 36 |
+
const urlParams = new URLSearchParams(window.location.search);
|
| 37 |
+
const isEditMode = urlParams.get('mode') === 'edit';
|
| 38 |
+
const [editingAgentId, setEditingAgentId] = useState(null);
|
| 39 |
+
|
| 40 |
+
// 保存每个步骤的数据
|
| 41 |
+
const [formData, setFormData] = useState({
|
| 42 |
+
name: '',
|
| 43 |
+
description: '',
|
| 44 |
+
subject: '',
|
| 45 |
+
instructor: '',
|
| 46 |
+
type: 'general',
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
// 加载知识库列表和编辑数据
|
| 50 |
+
useEffect(() => {
|
| 51 |
+
loadKnowledgeBases();
|
| 52 |
+
|
| 53 |
+
// 如果是编辑模式,加载agent数据
|
| 54 |
+
if (isEditMode) {
|
| 55 |
+
const editingAgentStr = localStorage.getItem('editingAgent');
|
| 56 |
+
if (editingAgentStr) {
|
| 57 |
+
try {
|
| 58 |
+
const editingAgent = JSON.parse(editingAgentStr);
|
| 59 |
+
setEditingAgentId(editingAgent.id);
|
| 60 |
+
|
| 61 |
+
// 设置表单数据
|
| 62 |
+
setFormData({
|
| 63 |
+
name: editingAgent.name || '',
|
| 64 |
+
description: editingAgent.description || '',
|
| 65 |
+
subject: editingAgent.subject || '',
|
| 66 |
+
instructor: editingAgent.instructor || '',
|
| 67 |
+
type: editingAgent.type || 'general',
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
// 设置表单初始值
|
| 71 |
+
form.setFieldsValue({
|
| 72 |
+
name: editingAgent.name || '',
|
| 73 |
+
description: editingAgent.description || '',
|
| 74 |
+
subject: editingAgent.subject || '',
|
| 75 |
+
instructor: editingAgent.instructor || '',
|
| 76 |
+
type: editingAgent.type || 'general',
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
// 设置其他数据
|
| 80 |
+
setSelectedPlugins(editingAgent.plugins || []);
|
| 81 |
+
setSelectedKnowledgeBases(editingAgent.knowledgeBases || []);
|
| 82 |
+
setWorkflow(editingAgent.workflow || null);
|
| 83 |
+
|
| 84 |
+
// 清除localStorage
|
| 85 |
+
localStorage.removeItem('editingAgent');
|
| 86 |
+
} catch (error) {
|
| 87 |
+
console.error('加载编辑数据失败:', error);
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
}, [isEditMode]);
|
| 92 |
+
|
| 93 |
+
const loadKnowledgeBases = async () => {
|
| 94 |
+
try {
|
| 95 |
+
const res = await knowledgeService.getKnowledgeBases();
|
| 96 |
+
setKnowledgeBases(res.data || []);
|
| 97 |
+
} catch (error) {
|
| 98 |
+
console.error('加载知识库失败:', error);
|
| 99 |
+
}
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
// 可用插件列表(对应原始HTML的插件选项)
|
| 103 |
+
const availablePlugins = [
|
| 104 |
+
{
|
| 105 |
+
id: 'code',
|
| 106 |
+
name: '代码执行',
|
| 107 |
+
icon: '💻',
|
| 108 |
+
description: '允许学生编写和执行Python代码,实时获取结果',
|
| 109 |
+
color: '#10b981',
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
id: 'visualization',
|
| 113 |
+
name: '3D可视化',
|
| 114 |
+
icon: '📊',
|
| 115 |
+
description: '生成交互式3D图形,帮助学生理解数学概念',
|
| 116 |
+
color: '#f97316',
|
| 117 |
+
},
|
| 118 |
+
{
|
| 119 |
+
id: 'mindmap',
|
| 120 |
+
name: '思维导图',
|
| 121 |
+
icon: '🗺️',
|
| 122 |
+
description: '创建结构化的思维导图,帮助学生梳理知识点',
|
| 123 |
+
color: '#8b5cf6',
|
| 124 |
+
},
|
| 125 |
+
];
|
| 126 |
+
|
| 127 |
+
// Agent类型选项
|
| 128 |
+
const agentTypes = [
|
| 129 |
+
{ value: 'educational', label: '教育辅导' },
|
| 130 |
+
{ value: 'programming', label: '编程辅助' },
|
| 131 |
+
{ value: 'math', label: '数学辅导' },
|
| 132 |
+
{ value: 'general', label: '通用助手' },
|
| 133 |
+
];
|
| 134 |
+
|
| 135 |
+
// 步骤配置
|
| 136 |
+
const steps = [
|
| 137 |
+
{
|
| 138 |
+
title: '基本信息',
|
| 139 |
+
icon: <InfoCircleOutlined />,
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
title: '插件选择',
|
| 143 |
+
icon: <AppstoreOutlined />,
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
title: '知识库',
|
| 147 |
+
icon: <DatabaseOutlined />,
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
title: '工作流',
|
| 151 |
+
icon: <BranchesOutlined />,
|
| 152 |
+
},
|
| 153 |
+
];
|
| 154 |
+
|
| 155 |
+
// 切换插件选择
|
| 156 |
+
const togglePlugin = (pluginId) => {
|
| 157 |
+
setSelectedPlugins((prev) =>
|
| 158 |
+
prev.includes(pluginId)
|
| 159 |
+
? prev.filter((id) => id !== pluginId)
|
| 160 |
+
: [...prev, pluginId]
|
| 161 |
+
);
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
// 切换知识库���择
|
| 165 |
+
const toggleKnowledgeBase = (kbId) => {
|
| 166 |
+
setSelectedKnowledgeBases((prev) =>
|
| 167 |
+
prev.includes(kbId)
|
| 168 |
+
? prev.filter((id) => id !== kbId)
|
| 169 |
+
: [...prev, kbId]
|
| 170 |
+
);
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
// 生成AI工作流
|
| 174 |
+
const handleGenerateAIWorkflow = async () => {
|
| 175 |
+
try {
|
| 176 |
+
setAiGenerating(true);
|
| 177 |
+
const values = form.getFieldsValue();
|
| 178 |
+
const res = await agentService.generateAIWorkflow({
|
| 179 |
+
name: values.name,
|
| 180 |
+
description: values.description,
|
| 181 |
+
type: values.type,
|
| 182 |
+
plugins: selectedPlugins,
|
| 183 |
+
knowledgeBases: selectedKnowledgeBases,
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
if (res.success) {
|
| 187 |
+
setWorkflow(res.data.workflow);
|
| 188 |
+
message.success('AI工作流生成成功');
|
| 189 |
+
}
|
| 190 |
+
} catch (error) {
|
| 191 |
+
message.error('生成工作流失败');
|
| 192 |
+
} finally {
|
| 193 |
+
setAiGenerating(false);
|
| 194 |
+
}
|
| 195 |
+
};
|
| 196 |
+
|
| 197 |
+
// 下一步
|
| 198 |
+
const handleNext = async () => {
|
| 199 |
+
try {
|
| 200 |
+
// 验证当前步骤的表单
|
| 201 |
+
if (currentStep === 0) {
|
| 202 |
+
await form.validateFields(['name', 'description', 'type']);
|
| 203 |
+
// 保存第一步的表单数据
|
| 204 |
+
const values = form.getFieldsValue();
|
| 205 |
+
setFormData({
|
| 206 |
+
name: values.name,
|
| 207 |
+
description: values.description,
|
| 208 |
+
subject: values.subject || '', // subject 可能为空
|
| 209 |
+
instructor: values.instructor || '', // instructor 可能为空
|
| 210 |
+
type: values.type,
|
| 211 |
+
});
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
setCurrentStep(currentStep + 1);
|
| 215 |
+
} catch (error) {
|
| 216 |
+
message.error('请填写完整信息');
|
| 217 |
+
}
|
| 218 |
+
};
|
| 219 |
+
|
| 220 |
+
// 上一步
|
| 221 |
+
const handlePrev = () => {
|
| 222 |
+
setCurrentStep(currentStep - 1);
|
| 223 |
+
// 返回第一步时,恢复表单数据
|
| 224 |
+
if (currentStep === 1) {
|
| 225 |
+
setTimeout(() => {
|
| 226 |
+
form.setFieldsValue(formData);
|
| 227 |
+
}, 0);
|
| 228 |
+
}
|
| 229 |
+
};
|
| 230 |
+
|
| 231 |
+
// 提交创建或更新Agent
|
| 232 |
+
const handleSubmit = async () => {
|
| 233 |
+
try {
|
| 234 |
+
setSubmitting(true);
|
| 235 |
+
|
| 236 |
+
const agentData = {
|
| 237 |
+
name: formData.name,
|
| 238 |
+
description: formData.description,
|
| 239 |
+
subject: formData.subject,
|
| 240 |
+
instructor: formData.instructor,
|
| 241 |
+
type: formData.type,
|
| 242 |
+
plugins: selectedPlugins,
|
| 243 |
+
knowledgeBases: selectedKnowledgeBases,
|
| 244 |
+
workflow: workflow || {
|
| 245 |
+
nodes: [],
|
| 246 |
+
edges: [],
|
| 247 |
+
},
|
| 248 |
+
};
|
| 249 |
+
|
| 250 |
+
console.log('发送Agent数据:', agentData);
|
| 251 |
+
|
| 252 |
+
let res;
|
| 253 |
+
if (isEditMode && editingAgentId) {
|
| 254 |
+
// 更新Agent
|
| 255 |
+
res = await agentService.updateAgent(editingAgentId, agentData);
|
| 256 |
+
if (res.success) {
|
| 257 |
+
message.success('Agent更新成功!');
|
| 258 |
+
// 跳转回列表页
|
| 259 |
+
window.location.href = '/teacher/agents';
|
| 260 |
+
}
|
| 261 |
+
} else {
|
| 262 |
+
// 创建新Agent
|
| 263 |
+
res = await agentService.createAgent(agentData);
|
| 264 |
+
if (res.success) {
|
| 265 |
+
message.success('Agent创建成功!');
|
| 266 |
+
// 重置表单
|
| 267 |
+
form.resetFields();
|
| 268 |
+
setCurrentStep(0);
|
| 269 |
+
setSelectedPlugins([]);
|
| 270 |
+
setSelectedKnowledgeBases([]);
|
| 271 |
+
setWorkflow(null);
|
| 272 |
+
setFormData({
|
| 273 |
+
name: '',
|
| 274 |
+
description: '',
|
| 275 |
+
subject: '',
|
| 276 |
+
instructor: '',
|
| 277 |
+
type: 'general',
|
| 278 |
+
});
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
if (!res.success) {
|
| 283 |
+
message.error(res.message || '操作失败');
|
| 284 |
+
}
|
| 285 |
+
} catch (error) {
|
| 286 |
+
console.error('操作失败:', error);
|
| 287 |
+
message.error(error.response?.data?.message || '操作失败');
|
| 288 |
+
} finally {
|
| 289 |
+
setSubmitting(false);
|
| 290 |
+
}
|
| 291 |
+
};
|
| 292 |
+
|
| 293 |
+
// 渲染步骤内容
|
| 294 |
+
const renderStepContent = () => {
|
| 295 |
+
switch (currentStep) {
|
| 296 |
+
case 0:
|
| 297 |
+
// 步骤1: 基本信息
|
| 298 |
+
return (
|
| 299 |
+
<motion.div
|
| 300 |
+
initial={{ opacity: 0, x: 20 }}
|
| 301 |
+
animate={{ opacity: 1, x: 0 }}
|
| 302 |
+
transition={{ duration: 0.3 }}
|
| 303 |
+
>
|
| 304 |
+
<Form form={form} layout="vertical" size="large">
|
| 305 |
+
<Form.Item
|
| 306 |
+
label="Agent名称"
|
| 307 |
+
name="name"
|
| 308 |
+
rules={[{ required: true, message: '请输入Agent名称' }]}
|
| 309 |
+
>
|
| 310 |
+
<Input placeholder="例如:Python编程助手" />
|
| 311 |
+
</Form.Item>
|
| 312 |
+
|
| 313 |
+
<Form.Item
|
| 314 |
+
label="描述"
|
| 315 |
+
name="description"
|
| 316 |
+
rules={[{ required: true, message: '请输入描述' }]}
|
| 317 |
+
>
|
| 318 |
+
<TextArea
|
| 319 |
+
rows={4}
|
| 320 |
+
placeholder="简要描述这个Agent的功能和用途"
|
| 321 |
+
/>
|
| 322 |
+
</Form.Item>
|
| 323 |
+
|
| 324 |
+
<Row gutter={16}>
|
| 325 |
+
<Col span={12}>
|
| 326 |
+
<Form.Item label="学科/课程名称" name="subject">
|
| 327 |
+
<Input placeholder="例如:计算机科学" />
|
| 328 |
+
</Form.Item>
|
| 329 |
+
</Col>
|
| 330 |
+
<Col span={12}>
|
| 331 |
+
<Form.Item label="指导教师" name="instructor">
|
| 332 |
+
<Input placeholder="例如:张老师" />
|
| 333 |
+
</Form.Item>
|
| 334 |
+
</Col>
|
| 335 |
+
</Row>
|
| 336 |
+
|
| 337 |
+
<Form.Item
|
| 338 |
+
label="Agent类型"
|
| 339 |
+
name="type"
|
| 340 |
+
rules={[{ required: true, message: '请选择Agent类型' }]}
|
| 341 |
+
>
|
| 342 |
+
<Select placeholder="选择Agent类型" options={agentTypes} />
|
| 343 |
+
</Form.Item>
|
| 344 |
+
</Form>
|
| 345 |
+
</motion.div>
|
| 346 |
+
);
|
| 347 |
+
|
| 348 |
+
case 1:
|
| 349 |
+
// 步骤2: 插件选择
|
| 350 |
+
return (
|
| 351 |
+
<motion.div
|
| 352 |
+
initial={{ opacity: 0, x: 20 }}
|
| 353 |
+
animate={{ opacity: 1, x: 0 }}
|
| 354 |
+
transition={{ duration: 0.3 }}
|
| 355 |
+
>
|
| 356 |
+
<div style={{ marginBottom: '16px' }}>
|
| 357 |
+
<h3 style={{ fontSize: '16px', fontWeight: 600, color: '#1f2937' }}>
|
| 358 |
+
选择Agent可以使用的插件
|
| 359 |
+
</h3>
|
| 360 |
+
<p style={{ fontSize: '14px', color: '#6b7280' }}>
|
| 361 |
+
这些插件将增强Agent的能力,帮助学生更好地学习
|
| 362 |
+
</p>
|
| 363 |
+
</div>
|
| 364 |
+
|
| 365 |
+
<Row gutter={[16, 16]}>
|
| 366 |
+
{availablePlugins.map((plugin) => (
|
| 367 |
+
<Col span={24} key={plugin.id}>
|
| 368 |
+
<motion.div
|
| 369 |
+
whileHover={{ x: 4 }}
|
| 370 |
+
style={{
|
| 371 |
+
padding: '20px',
|
| 372 |
+
background: selectedPlugins.includes(plugin.id)
|
| 373 |
+
? `${plugin.color}10`
|
| 374 |
+
: '#f8fafc',
|
| 375 |
+
border: selectedPlugins.includes(plugin.id)
|
| 376 |
+
? `2px solid ${plugin.color}`
|
| 377 |
+
: '2px solid #e5e7eb',
|
| 378 |
+
borderRadius: '12px',
|
| 379 |
+
cursor: 'pointer',
|
| 380 |
+
transition: 'all 0.2s',
|
| 381 |
+
}}
|
| 382 |
+
onClick={() => togglePlugin(plugin.id)}
|
| 383 |
+
>
|
| 384 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
| 385 |
+
<div
|
| 386 |
+
style={{
|
| 387 |
+
fontSize: '32px',
|
| 388 |
+
width: '56px',
|
| 389 |
+
height: '56px',
|
| 390 |
+
display: 'flex',
|
| 391 |
+
alignItems: 'center',
|
| 392 |
+
justifyContent: 'center',
|
| 393 |
+
background: `${plugin.color}15`,
|
| 394 |
+
borderRadius: '12px',
|
| 395 |
+
}}
|
| 396 |
+
>
|
| 397 |
+
{plugin.icon}
|
| 398 |
+
</div>
|
| 399 |
+
<div style={{ flex: 1 }}>
|
| 400 |
+
<div style={{ fontSize: '18px', fontWeight: 600, color: '#1f2937', marginBottom: '4px' }}>
|
| 401 |
+
{plugin.name}
|
| 402 |
+
</div>
|
| 403 |
+
<div style={{ fontSize: '14px', color: '#6b7280' }}>
|
| 404 |
+
{plugin.description}
|
| 405 |
+
</div>
|
| 406 |
+
</div>
|
| 407 |
+
<Checkbox checked={selectedPlugins.includes(plugin.id)} />
|
| 408 |
+
</div>
|
| 409 |
+
</motion.div>
|
| 410 |
+
</Col>
|
| 411 |
+
))}
|
| 412 |
+
</Row>
|
| 413 |
+
</motion.div>
|
| 414 |
+
);
|
| 415 |
+
|
| 416 |
+
case 2:
|
| 417 |
+
// 步骤3: 知识库关联
|
| 418 |
+
return (
|
| 419 |
+
<motion.div
|
| 420 |
+
initial={{ opacity: 0, x: 20 }}
|
| 421 |
+
animate={{ opacity: 1, x: 0 }}
|
| 422 |
+
transition={{ duration: 0.3 }}
|
| 423 |
+
>
|
| 424 |
+
<div style={{ marginBottom: '16px' }}>
|
| 425 |
+
<h3 style={{ fontSize: '16px', fontWeight: 600, color: '#1f2937' }}>
|
| 426 |
+
选择关联的知识库
|
| 427 |
+
</h3>
|
| 428 |
+
<p style={{ fontSize: '14px', color: '#6b7280' }}>
|
| 429 |
+
选择与此Agent相关的知识库,Agent将基于这些知识回答问题
|
| 430 |
+
</p>
|
| 431 |
+
</div>
|
| 432 |
+
|
| 433 |
+
{knowledgeBases.length > 0 ? (
|
| 434 |
+
<Row gutter={[16, 16]}>
|
| 435 |
+
{knowledgeBases.map((kb) => (
|
| 436 |
+
<Col span={12} key={kb.id}>
|
| 437 |
+
<motion.div
|
| 438 |
+
whileHover={{ y: -4 }}
|
| 439 |
+
style={{
|
| 440 |
+
padding: '16px',
|
| 441 |
+
background: selectedKnowledgeBases.includes(kb.id)
|
| 442 |
+
? 'rgba(16, 185, 129, 0.1)'
|
| 443 |
+
: 'white',
|
| 444 |
+
border: selectedKnowledgeBases.includes(kb.id)
|
| 445 |
+
? '2px solid #10b981'
|
| 446 |
+
: '1px solid #e5e7eb',
|
| 447 |
+
borderRadius: '12px',
|
| 448 |
+
cursor: 'pointer',
|
| 449 |
+
transition: 'all 0.2s',
|
| 450 |
+
}}
|
| 451 |
+
onClick={() => toggleKnowledgeBase(kb.id)}
|
| 452 |
+
>
|
| 453 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 454 |
+
<div>
|
| 455 |
+
<div style={{ fontSize: '16px', fontWeight: 600, color: '#1f2937' }}>
|
| 456 |
+
{kb.name}
|
| 457 |
+
</div>
|
| 458 |
+
<div style={{ fontSize: '14px', color: '#6b7280', marginTop: '4px' }}>
|
| 459 |
+
{kb.fileCount || 0} 个文件
|
| 460 |
+
</div>
|
| 461 |
+
</div>
|
| 462 |
+
<Checkbox checked={selectedKnowledgeBases.includes(kb.id)} />
|
| 463 |
+
</div>
|
| 464 |
+
</motion.div>
|
| 465 |
+
</Col>
|
| 466 |
+
))}
|
| 467 |
+
</Row>
|
| 468 |
+
) : (
|
| 469 |
+
<div style={{ textAlign: 'center', padding: '40px', color: '#9ca3af' }}>
|
| 470 |
+
<DatabaseOutlined style={{ fontSize: '48px', marginBottom: '16px' }} />
|
| 471 |
+
<div>还没有创建任何知识库</div>
|
| 472 |
+
<Button type="link" style={{ marginTop: '8px' }}>
|
| 473 |
+
去创建知识库
|
| 474 |
+
</Button>
|
| 475 |
+
</div>
|
| 476 |
+
)}
|
| 477 |
+
</motion.div>
|
| 478 |
+
);
|
| 479 |
+
|
| 480 |
+
case 3:
|
| 481 |
+
// 步骤4: 工作流设计
|
| 482 |
+
return (
|
| 483 |
+
<motion.div
|
| 484 |
+
initial={{ opacity: 0, x: 20 }}
|
| 485 |
+
animate={{ opacity: 1, x: 0 }}
|
| 486 |
+
transition={{ duration: 0.3 }}
|
| 487 |
+
>
|
| 488 |
+
<div style={{ marginBottom: '24px' }}>
|
| 489 |
+
<h3 style={{ fontSize: '16px', fontWeight: 600, color: '#1f2937' }}>
|
| 490 |
+
工作流设计
|
| 491 |
+
</h3>
|
| 492 |
+
<p style={{ fontSize: '14px', color: '#6b7280' }}>
|
| 493 |
+
定义Agent如何处理学生的问题和请求
|
| 494 |
+
</p>
|
| 495 |
+
</div>
|
| 496 |
+
|
| 497 |
+
<Card
|
| 498 |
+
style={{
|
| 499 |
+
border: '1px solid rgba(139, 92, 246, 0.2)',
|
| 500 |
+
borderRadius: '12px',
|
| 501 |
+
marginBottom: '16px',
|
| 502 |
+
}}
|
| 503 |
+
>
|
| 504 |
+
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
| 505 |
+
<div>
|
| 506 |
+
<h4 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px' }}>
|
| 507 |
+
AI自动设计(推荐)
|
| 508 |
+
</h4>
|
| 509 |
+
<p style={{ fontSize: '14px', color: '#6b7280', marginBottom: '16px' }}>
|
| 510 |
+
让AI根据您选择的插件和知识库自动生成最优工作流
|
| 511 |
+
</p>
|
| 512 |
+
<Button
|
| 513 |
+
type="primary"
|
| 514 |
+
icon={<BranchesOutlined />}
|
| 515 |
+
loading={aiGenerating}
|
| 516 |
+
onClick={(e) => {
|
| 517 |
+
e.stopPropagation();
|
| 518 |
+
handleGenerateAIWorkflow();
|
| 519 |
+
}}
|
| 520 |
+
style={{ backgroundColor: '#8b5cf6' }}
|
| 521 |
+
>
|
| 522 |
+
AI自动生成工作流
|
| 523 |
+
</Button>
|
| 524 |
+
</div>
|
| 525 |
+
|
| 526 |
+
{workflow && (
|
| 527 |
+
<div
|
| 528 |
+
style={{
|
| 529 |
+
padding: '16px',
|
| 530 |
+
background: '#f8fafc',
|
| 531 |
+
borderRadius: '8px',
|
| 532 |
+
border: '1px solid #e5e7eb',
|
| 533 |
+
}}
|
| 534 |
+
>
|
| 535 |
+
<div style={{ fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
| 536 |
+
工作流已生成
|
| 537 |
+
</div>
|
| 538 |
+
<div style={{ fontSize: '12px', color: '#6b7280' }}>
|
| 539 |
+
包含 {workflow.nodes?.length || 0} 个节点,
|
| 540 |
+
{workflow.edges?.length || 0} 条连接
|
| 541 |
+
</div>
|
| 542 |
+
</div>
|
| 543 |
+
)}
|
| 544 |
+
|
| 545 |
+
<div style={{ paddingTop: '16px', borderTop: '1px solid #e5e7eb' }}>
|
| 546 |
+
<h4 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px' }}>
|
| 547 |
+
跳过工作流配置
|
| 548 |
+
</h4>
|
| 549 |
+
<p style={{ fontSize: '14px', color: '#6b7280', marginBottom: '16px' }}>
|
| 550 |
+
暂时不配置工作流,后续可以在Agent详情中进行配置
|
| 551 |
+
</p>
|
| 552 |
+
<Button
|
| 553 |
+
type="default"
|
| 554 |
+
icon={<CheckOutlined />}
|
| 555 |
+
onClick={(e) => {
|
| 556 |
+
e.stopPropagation();
|
| 557 |
+
setWorkflow({ nodes: [], edges: [] });
|
| 558 |
+
message.success('已设置为空工作流,可以直接创建Agent');
|
| 559 |
+
}}
|
| 560 |
+
style={{
|
| 561 |
+
borderColor: '#10b981',
|
| 562 |
+
color: '#10b981',
|
| 563 |
+
}}
|
| 564 |
+
>
|
| 565 |
+
跳过工作流配置
|
| 566 |
+
</Button>
|
| 567 |
+
</div>
|
| 568 |
+
|
| 569 |
+
<div style={{ paddingTop: '16px', borderTop: '1px solid #e5e7eb' }}>
|
| 570 |
+
<h4 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px' }}>
|
| 571 |
+
手动设计(高级)
|
| 572 |
+
</h4>
|
| 573 |
+
<p style={{ fontSize: '14px', color: '#6b7280' }}>
|
| 574 |
+
使用可视化编辑器自定义工作流
|
| 575 |
+
</p>
|
| 576 |
+
<Button onClick={() => setShowWorkflowEditor(true)}>
|
| 577 |
+
打开工作流编辑器
|
| 578 |
+
</Button>
|
| 579 |
+
</div>
|
| 580 |
+
</Space>
|
| 581 |
+
</Card>
|
| 582 |
+
|
| 583 |
+
{/* 提示:可以直接创建 */}
|
| 584 |
+
<div
|
| 585 |
+
style={{
|
| 586 |
+
marginTop: '16px',
|
| 587 |
+
padding: '16px',
|
| 588 |
+
background: '#f0f9ff',
|
| 589 |
+
borderRadius: '8px',
|
| 590 |
+
border: '1px solid #bae6fd',
|
| 591 |
+
}}
|
| 592 |
+
>
|
| 593 |
+
<div style={{ fontSize: '14px', color: '#0369a1', marginBottom: '4px', fontWeight: 500 }}>
|
| 594 |
+
💡 提示
|
| 595 |
+
</div>
|
| 596 |
+
<div style={{ fontSize: '14px', color: '#075985' }}>
|
| 597 |
+
工作流设计是可选的。您可以直接点击下方的"创建Agent"按钮,先创建一个基础Agent,后续再配置工作流。
|
| 598 |
+
</div>
|
| 599 |
+
</div>
|
| 600 |
+
</motion.div>
|
| 601 |
+
);
|
| 602 |
+
|
| 603 |
+
default:
|
| 604 |
+
return null;
|
| 605 |
+
}
|
| 606 |
+
};
|
| 607 |
+
|
| 608 |
+
return (
|
| 609 |
+
<div>
|
| 610 |
+
<div style={{ marginBottom: '32px' }}>
|
| 611 |
+
<h1 style={{ fontSize: '28px', fontWeight: 600, color: '#1f2937', margin: 0 }}>
|
| 612 |
+
{isEditMode ? '编辑Agent' : '创建新的Agent'}
|
| 613 |
+
</h1>
|
| 614 |
+
<p style={{ fontSize: '14px', color: '#6b7280', marginTop: '8px' }}>
|
| 615 |
+
{isEditMode ? '修改您的AI教学助手' : '按照步骤创建您的AI教学助手'}
|
| 616 |
+
</p>
|
| 617 |
+
</div>
|
| 618 |
+
|
| 619 |
+
{/* 步骤指示器 */}
|
| 620 |
+
<Card style={{ marginBottom: '24px', border: '1px solid #e5e7eb', borderRadius: '12px' }}>
|
| 621 |
+
<Steps current={currentStep} items={steps} />
|
| 622 |
+
</Card>
|
| 623 |
+
|
| 624 |
+
{/* 步骤内容 */}
|
| 625 |
+
<Card style={{ border: '1px solid #e5e7eb', borderRadius: '12px', minHeight: '400px' }}>
|
| 626 |
+
{renderStepContent()}
|
| 627 |
+
|
| 628 |
+
{/* 导航按钮 */}
|
| 629 |
+
<div
|
| 630 |
+
style={{
|
| 631 |
+
marginTop: '32px',
|
| 632 |
+
paddingTop: '24px',
|
| 633 |
+
borderTop: '1px solid #e5e7eb',
|
| 634 |
+
display: 'flex',
|
| 635 |
+
justifyContent: 'space-between',
|
| 636 |
+
}}
|
| 637 |
+
>
|
| 638 |
+
<Button
|
| 639 |
+
size="large"
|
| 640 |
+
icon={<ArrowLeftOutlined />}
|
| 641 |
+
onClick={handlePrev}
|
| 642 |
+
disabled={currentStep === 0}
|
| 643 |
+
>
|
| 644 |
+
上一步
|
| 645 |
+
</Button>
|
| 646 |
+
|
| 647 |
+
{currentStep < steps.length - 1 ? (
|
| 648 |
+
<Button
|
| 649 |
+
type="primary"
|
| 650 |
+
size="large"
|
| 651 |
+
icon={<ArrowRightOutlined />}
|
| 652 |
+
onClick={handleNext}
|
| 653 |
+
style={{ backgroundColor: '#10b981' }}
|
| 654 |
+
>
|
| 655 |
+
下一步
|
| 656 |
+
</Button>
|
| 657 |
+
) : (
|
| 658 |
+
<Button
|
| 659 |
+
type="primary"
|
| 660 |
+
size="large"
|
| 661 |
+
icon={<CheckOutlined />}
|
| 662 |
+
onClick={handleSubmit}
|
| 663 |
+
loading={submitting}
|
| 664 |
+
style={{ backgroundColor: '#10b981' }}
|
| 665 |
+
>
|
| 666 |
+
{isEditMode ? '保存修改' : '创建Agent'}
|
| 667 |
+
</Button>
|
| 668 |
+
)}
|
| 669 |
+
</div>
|
| 670 |
+
</Card>
|
| 671 |
+
|
| 672 |
+
{/* 工作流编辑器Modal */}
|
| 673 |
+
<Modal
|
| 674 |
+
title="工作流编辑器"
|
| 675 |
+
open={showWorkflowEditor}
|
| 676 |
+
onCancel={() => setShowWorkflowEditor(false)}
|
| 677 |
+
width="90%"
|
| 678 |
+
style={{ top: 20 }}
|
| 679 |
+
footer={null}
|
| 680 |
+
destroyOnClose
|
| 681 |
+
>
|
| 682 |
+
<WorkflowEditor
|
| 683 |
+
value={workflow}
|
| 684 |
+
onChange={(newWorkflow) => setWorkflow(newWorkflow)}
|
| 685 |
+
onSave={(newWorkflow) => {
|
| 686 |
+
setWorkflow(newWorkflow);
|
| 687 |
+
setShowWorkflowEditor(false);
|
| 688 |
+
message.success('工作流已保存');
|
| 689 |
+
}}
|
| 690 |
+
/>
|
| 691 |
+
</Modal>
|
| 692 |
+
</div>
|
| 693 |
+
);
|
| 694 |
+
};
|
| 695 |
+
|
| 696 |
+
export default CreateAgent;
|
frontend/src/pages/teacher/Dashboard.jsx
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 教师端 - 仪表板页面
|
| 3 |
+
* 对应原始 index.html 的 dashboard section
|
| 4 |
+
*/
|
| 5 |
+
import { useEffect, useState } from 'react';
|
| 6 |
+
import { Card, Row, Col, Statistic, Button, Space } from 'antd';
|
| 7 |
+
import {
|
| 8 |
+
RobotOutlined,
|
| 9 |
+
DatabaseOutlined,
|
| 10 |
+
ShareAltOutlined,
|
| 11 |
+
ArrowRightOutlined,
|
| 12 |
+
PlusCircleOutlined,
|
| 13 |
+
} from '@ant-design/icons';
|
| 14 |
+
import { motion } from 'framer-motion';
|
| 15 |
+
import agentService from '../../services/agentService';
|
| 16 |
+
import knowledgeService from '../../services/knowledgeService';
|
| 17 |
+
|
| 18 |
+
const Dashboard = () => {
|
| 19 |
+
const [stats, setStats] = useState({
|
| 20 |
+
agentCount: 0,
|
| 21 |
+
knowledgeCount: 0,
|
| 22 |
+
distributionCount: 0,
|
| 23 |
+
});
|
| 24 |
+
const [recentAgents, setRecentAgents] = useState([]);
|
| 25 |
+
const [loading, setLoading] = useState(true);
|
| 26 |
+
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
loadDashboardData();
|
| 29 |
+
}, []);
|
| 30 |
+
|
| 31 |
+
const loadDashboardData = async () => {
|
| 32 |
+
try {
|
| 33 |
+
setLoading(true);
|
| 34 |
+
// 加载Agent列表
|
| 35 |
+
const agentsRes = await agentService.getAgents();
|
| 36 |
+
const agents = agentsRes.data || [];
|
| 37 |
+
|
| 38 |
+
// 加载知识库列表
|
| 39 |
+
const kbRes = await knowledgeService.getKnowledgeBases();
|
| 40 |
+
const knowledgeBases = kbRes.data || [];
|
| 41 |
+
|
| 42 |
+
// 计算统计数据
|
| 43 |
+
const distributionCount = agents.reduce((sum, agent) => {
|
| 44 |
+
return sum + (agent.distributions?.length || 0);
|
| 45 |
+
}, 0);
|
| 46 |
+
|
| 47 |
+
setStats({
|
| 48 |
+
agentCount: agents.length,
|
| 49 |
+
knowledgeCount: knowledgeBases.length,
|
| 50 |
+
distributionCount,
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
// 获取最近的3个Agent
|
| 54 |
+
const recent = agents.slice(0, 3);
|
| 55 |
+
setRecentAgents(recent);
|
| 56 |
+
} catch (error) {
|
| 57 |
+
console.error('加载仪表板数据失败:', error);
|
| 58 |
+
} finally {
|
| 59 |
+
setLoading(false);
|
| 60 |
+
}
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
const quickStartSteps = [
|
| 64 |
+
{
|
| 65 |
+
step: 1,
|
| 66 |
+
title: '创建知识库',
|
| 67 |
+
description: '上传课程资料、文档和教材',
|
| 68 |
+
icon: <DatabaseOutlined />,
|
| 69 |
+
color: '#10b981',
|
| 70 |
+
action: 'knowledge-base',
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
step: 2,
|
| 74 |
+
title: '创建Agent',
|
| 75 |
+
description: '设计您的AI助教',
|
| 76 |
+
icon: <RobotOutlined />,
|
| 77 |
+
color: '#f97316',
|
| 78 |
+
action: 'create-agent',
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
step: 3,
|
| 82 |
+
title: '分发链接',
|
| 83 |
+
description: '生成分享链接给学生',
|
| 84 |
+
icon: <ShareAltOutlined />,
|
| 85 |
+
color: '#8b5cf6',
|
| 86 |
+
action: 'agent-list',
|
| 87 |
+
},
|
| 88 |
+
];
|
| 89 |
+
|
| 90 |
+
return (
|
| 91 |
+
<div>
|
| 92 |
+
{/* 页面标题 */}
|
| 93 |
+
<div style={{ marginBottom: '24px' }}>
|
| 94 |
+
<h1 style={{ fontSize: '28px', fontWeight: 600, color: '#1f2937', margin: 0 }}>
|
| 95 |
+
欢迎回来
|
| 96 |
+
</h1>
|
| 97 |
+
<p style={{ fontSize: '14px', color: '#6b7280', marginTop: '8px' }}>
|
| 98 |
+
这是您的教学助手控制面板
|
| 99 |
+
</p>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
{/* 统计卡片 */}
|
| 103 |
+
<Row gutter={[24, 24]} style={{ marginBottom: '32px' }}>
|
| 104 |
+
<Col xs={24} sm={8}>
|
| 105 |
+
<motion.div
|
| 106 |
+
whileHover={{
|
| 107 |
+
y: -4,
|
| 108 |
+
transition: { duration: 0.2, ease: 'easeOut' }
|
| 109 |
+
}}
|
| 110 |
+
>
|
| 111 |
+
<Card
|
| 112 |
+
loading={loading}
|
| 113 |
+
style={{
|
| 114 |
+
border: '1px solid rgba(16, 185, 129, 0.2)',
|
| 115 |
+
borderRadius: '12px',
|
| 116 |
+
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
|
| 117 |
+
background: '#ffffff',
|
| 118 |
+
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
| 119 |
+
}}
|
| 120 |
+
hoverable
|
| 121 |
+
>
|
| 122 |
+
<Statistic
|
| 123 |
+
title={
|
| 124 |
+
<span style={{ color: '#6b7280', fontSize: '14px', fontWeight: 500 }}>
|
| 125 |
+
AI助手数量
|
| 126 |
+
</span>
|
| 127 |
+
}
|
| 128 |
+
value={stats.agentCount}
|
| 129 |
+
prefix={
|
| 130 |
+
<div style={{
|
| 131 |
+
width: '48px',
|
| 132 |
+
height: '48px',
|
| 133 |
+
borderRadius: '12px',
|
| 134 |
+
background: 'rgba(16, 185, 129, 0.1)',
|
| 135 |
+
display: 'flex',
|
| 136 |
+
alignItems: 'center',
|
| 137 |
+
justifyContent: 'center',
|
| 138 |
+
marginRight: '12px',
|
| 139 |
+
}}>
|
| 140 |
+
<RobotOutlined style={{ color: '#10b981', fontSize: '24px' }} />
|
| 141 |
+
</div>
|
| 142 |
+
}
|
| 143 |
+
valueStyle={{ color: '#1f2937', fontSize: '32px', fontWeight: 600 }}
|
| 144 |
+
/>
|
| 145 |
+
</Card>
|
| 146 |
+
</motion.div>
|
| 147 |
+
</Col>
|
| 148 |
+
<Col xs={24} sm={8}>
|
| 149 |
+
<motion.div
|
| 150 |
+
whileHover={{
|
| 151 |
+
y: -4,
|
| 152 |
+
transition: { duration: 0.2, ease: 'easeOut' }
|
| 153 |
+
}}
|
| 154 |
+
>
|
| 155 |
+
<Card
|
| 156 |
+
loading={loading}
|
| 157 |
+
style={{
|
| 158 |
+
border: '1px solid rgba(249, 115, 22, 0.2)',
|
| 159 |
+
borderRadius: '12px',
|
| 160 |
+
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
|
| 161 |
+
background: '#ffffff',
|
| 162 |
+
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
| 163 |
+
}}
|
| 164 |
+
hoverable
|
| 165 |
+
>
|
| 166 |
+
<Statistic
|
| 167 |
+
title={
|
| 168 |
+
<span style={{ color: '#6b7280', fontSize: '14px', fontWeight: 500 }}>
|
| 169 |
+
知识库数量
|
| 170 |
+
</span>
|
| 171 |
+
}
|
| 172 |
+
value={stats.knowledgeCount}
|
| 173 |
+
prefix={
|
| 174 |
+
<div style={{
|
| 175 |
+
width: '48px',
|
| 176 |
+
height: '48px',
|
| 177 |
+
borderRadius: '12px',
|
| 178 |
+
background: 'rgba(249, 115, 22, 0.1)',
|
| 179 |
+
display: 'flex',
|
| 180 |
+
alignItems: 'center',
|
| 181 |
+
justifyContent: 'center',
|
| 182 |
+
marginRight: '12px',
|
| 183 |
+
}}>
|
| 184 |
+
<DatabaseOutlined style={{ color: '#f97316', fontSize: '24px' }} />
|
| 185 |
+
</div>
|
| 186 |
+
}
|
| 187 |
+
valueStyle={{ color: '#1f2937', fontSize: '32px', fontWeight: 600 }}
|
| 188 |
+
/>
|
| 189 |
+
</Card>
|
| 190 |
+
</motion.div>
|
| 191 |
+
</Col>
|
| 192 |
+
<Col xs={24} sm={8}>
|
| 193 |
+
<motion.div
|
| 194 |
+
whileHover={{
|
| 195 |
+
y: -4,
|
| 196 |
+
transition: { duration: 0.2, ease: 'easeOut' }
|
| 197 |
+
}}
|
| 198 |
+
>
|
| 199 |
+
<Card
|
| 200 |
+
loading={loading}
|
| 201 |
+
style={{
|
| 202 |
+
border: '1px solid rgba(139, 92, 246, 0.2)',
|
| 203 |
+
borderRadius: '12px',
|
| 204 |
+
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
|
| 205 |
+
background: '#ffffff',
|
| 206 |
+
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
| 207 |
+
}}
|
| 208 |
+
hoverable
|
| 209 |
+
>
|
| 210 |
+
<Statistic
|
| 211 |
+
title={
|
| 212 |
+
<span style={{ color: '#6b7280', fontSize: '14px', fontWeight: 500 }}>
|
| 213 |
+
分发链接数
|
| 214 |
+
</span>
|
| 215 |
+
}
|
| 216 |
+
value={stats.distributionCount}
|
| 217 |
+
prefix={
|
| 218 |
+
<div style={{
|
| 219 |
+
width: '48px',
|
| 220 |
+
height: '48px',
|
| 221 |
+
borderRadius: '12px',
|
| 222 |
+
background: 'rgba(139, 92, 246, 0.1)',
|
| 223 |
+
display: 'flex',
|
| 224 |
+
alignItems: 'center',
|
| 225 |
+
justifyContent: 'center',
|
| 226 |
+
marginRight: '12px',
|
| 227 |
+
}}>
|
| 228 |
+
<ShareAltOutlined style={{ color: '#8b5cf6', fontSize: '24px' }} />
|
| 229 |
+
</div>
|
| 230 |
+
}
|
| 231 |
+
valueStyle={{ color: '#1f2937', fontSize: '32px', fontWeight: 600 }}
|
| 232 |
+
/>
|
| 233 |
+
</Card>
|
| 234 |
+
</motion.div>
|
| 235 |
+
</Col>
|
| 236 |
+
</Row>
|
| 237 |
+
|
| 238 |
+
{/* 快速开始指引 */}
|
| 239 |
+
<Row gutter={[24, 24]} style={{ marginBottom: '32px' }}>
|
| 240 |
+
{quickStartSteps.map((item) => (
|
| 241 |
+
<Col xs={24} md={8} key={item.step}>
|
| 242 |
+
<motion.div
|
| 243 |
+
whileHover={{
|
| 244 |
+
y: -4,
|
| 245 |
+
transition: { duration: 0.2, ease: 'easeOut' }
|
| 246 |
+
}}
|
| 247 |
+
>
|
| 248 |
+
<Card
|
| 249 |
+
style={{
|
| 250 |
+
border: `1px solid ${item.color}30`,
|
| 251 |
+
borderRadius: '12px',
|
| 252 |
+
background: '#ffffff',
|
| 253 |
+
height: '100%',
|
| 254 |
+
cursor: 'pointer',
|
| 255 |
+
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
| 256 |
+
}}
|
| 257 |
+
hoverable
|
| 258 |
+
>
|
| 259 |
+
<div style={{ padding: '8px' }}>
|
| 260 |
+
<div
|
| 261 |
+
style={{
|
| 262 |
+
width: '56px',
|
| 263 |
+
height: '56px',
|
| 264 |
+
borderRadius: '12px',
|
| 265 |
+
background: `${item.color}15`,
|
| 266 |
+
display: 'flex',
|
| 267 |
+
alignItems: 'center',
|
| 268 |
+
justifyContent: 'center',
|
| 269 |
+
marginBottom: '16px',
|
| 270 |
+
}}
|
| 271 |
+
>
|
| 272 |
+
<span style={{ color: item.color, fontSize: '28px' }}>
|
| 273 |
+
{item.icon}
|
| 274 |
+
</span>
|
| 275 |
+
</div>
|
| 276 |
+
<div
|
| 277 |
+
style={{
|
| 278 |
+
fontSize: '12px',
|
| 279 |
+
color: item.color,
|
| 280 |
+
fontWeight: 600,
|
| 281 |
+
marginBottom: '8px',
|
| 282 |
+
textTransform: 'uppercase',
|
| 283 |
+
letterSpacing: '0.5px',
|
| 284 |
+
}}
|
| 285 |
+
>
|
| 286 |
+
第 {item.step} 步
|
| 287 |
+
</div>
|
| 288 |
+
<div style={{ fontSize: '18px', fontWeight: 600, color: '#1f2937', marginBottom: '8px' }}>
|
| 289 |
+
{item.title}
|
| 290 |
+
</div>
|
| 291 |
+
<div style={{ fontSize: '14px', color: '#6b7280', lineHeight: 1.6 }}>
|
| 292 |
+
{item.description}
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
</Card>
|
| 296 |
+
</motion.div>
|
| 297 |
+
</Col>
|
| 298 |
+
))}
|
| 299 |
+
</Row>
|
| 300 |
+
|
| 301 |
+
{/* 最近创建的Agent */}
|
| 302 |
+
<Card
|
| 303 |
+
title={
|
| 304 |
+
<span style={{
|
| 305 |
+
fontSize: '18px',
|
| 306 |
+
fontWeight: 600,
|
| 307 |
+
color: '#1f2937'
|
| 308 |
+
}}>
|
| 309 |
+
最近创建的Agent
|
| 310 |
+
</span>
|
| 311 |
+
}
|
| 312 |
+
extra={
|
| 313 |
+
<Button
|
| 314 |
+
type="link"
|
| 315 |
+
icon={<ArrowRightOutlined />}
|
| 316 |
+
style={{ color: '#10b981' }}
|
| 317 |
+
>
|
| 318 |
+
查看全部
|
| 319 |
+
</Button>
|
| 320 |
+
}
|
| 321 |
+
style={{
|
| 322 |
+
border: '1px solid #e5e7eb',
|
| 323 |
+
borderRadius: '12px',
|
| 324 |
+
marginBottom: '32px',
|
| 325 |
+
background: '#ffffff',
|
| 326 |
+
}}
|
| 327 |
+
>
|
| 328 |
+
{recentAgents.length > 0 ? (
|
| 329 |
+
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
| 330 |
+
{recentAgents.map((agent) => (
|
| 331 |
+
<motion.div
|
| 332 |
+
key={agent.id}
|
| 333 |
+
whileHover={{
|
| 334 |
+
x: 4,
|
| 335 |
+
transition: { duration: 0.2, ease: 'easeOut' }
|
| 336 |
+
}}
|
| 337 |
+
style={{
|
| 338 |
+
padding: '16px',
|
| 339 |
+
background: '#f9fafb',
|
| 340 |
+
borderRadius: '8px',
|
| 341 |
+
border: '1px solid #e5e7eb',
|
| 342 |
+
cursor: 'pointer',
|
| 343 |
+
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
| 344 |
+
}}
|
| 345 |
+
>
|
| 346 |
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
| 347 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
| 348 |
+
<div
|
| 349 |
+
style={{
|
| 350 |
+
width: '40px',
|
| 351 |
+
height: '40px',
|
| 352 |
+
borderRadius: '8px',
|
| 353 |
+
background: 'rgba(16, 185, 129, 0.1)',
|
| 354 |
+
display: 'flex',
|
| 355 |
+
alignItems: 'center',
|
| 356 |
+
justifyContent: 'center',
|
| 357 |
+
border: '1px solid rgba(16, 185, 129, 0.2)',
|
| 358 |
+
}}
|
| 359 |
+
>
|
| 360 |
+
<RobotOutlined style={{ color: '#10b981', fontSize: '20px' }} />
|
| 361 |
+
</div>
|
| 362 |
+
<div>
|
| 363 |
+
<div style={{ fontSize: '16px', fontWeight: 500, color: '#1f2937' }}>
|
| 364 |
+
{agent.name}
|
| 365 |
+
</div>
|
| 366 |
+
<div style={{ fontSize: '14px', color: '#6b7280', marginTop: '2px' }}>
|
| 367 |
+
{agent.description || '暂无描述'}
|
| 368 |
+
</div>
|
| 369 |
+
</div>
|
| 370 |
+
</div>
|
| 371 |
+
<ArrowRightOutlined style={{ color: '#9ca3af' }} />
|
| 372 |
+
</div>
|
| 373 |
+
</motion.div>
|
| 374 |
+
))}
|
| 375 |
+
</Space>
|
| 376 |
+
) : (
|
| 377 |
+
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
| 378 |
+
<div
|
| 379 |
+
style={{
|
| 380 |
+
width: '64px',
|
| 381 |
+
height: '64px',
|
| 382 |
+
borderRadius: '16px',
|
| 383 |
+
background: 'rgba(16, 185, 129, 0.1)',
|
| 384 |
+
display: 'flex',
|
| 385 |
+
alignItems: 'center',
|
| 386 |
+
justifyContent: 'center',
|
| 387 |
+
margin: '0 auto 16px',
|
| 388 |
+
border: '1px solid rgba(16, 185, 129, 0.2)',
|
| 389 |
+
}}
|
| 390 |
+
>
|
| 391 |
+
<RobotOutlined style={{ fontSize: '32px', color: '#10b981' }} />
|
| 392 |
+
</div>
|
| 393 |
+
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '16px' }}>
|
| 394 |
+
还没有创建任何Agent
|
| 395 |
+
</div>
|
| 396 |
+
<Button
|
| 397 |
+
type="primary"
|
| 398 |
+
icon={<PlusCircleOutlined />}
|
| 399 |
+
style={{
|
| 400 |
+
backgroundColor: '#10b981',
|
| 401 |
+
borderColor: '#10b981'
|
| 402 |
+
}}
|
| 403 |
+
>
|
| 404 |
+
创建第一个Agent
|
| 405 |
+
</Button>
|
| 406 |
+
</div>
|
| 407 |
+
)}
|
| 408 |
+
</Card>
|
| 409 |
+
</div>
|
| 410 |
+
);
|
| 411 |
+
};
|
| 412 |
+
|
| 413 |
+
export default Dashboard;
|
frontend/src/pages/teacher/KnowledgeBase.jsx
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 教师端 - 知识库管理页面
|
| 3 |
+
* 对应原始 index.html 的 knowledge-base section
|
| 4 |
+
* 复用原始HTML的知识库创建和管理逻辑
|
| 5 |
+
*/
|
| 6 |
+
import { useState, useEffect } from 'react';
|
| 7 |
+
import { Card, Form, Input, Upload, Button, Row, Col, message, Progress, Space, Modal, List, Tag } from 'antd';
|
| 8 |
+
import {
|
| 9 |
+
UploadOutlined,
|
| 10 |
+
DeleteOutlined,
|
| 11 |
+
FileTextOutlined,
|
| 12 |
+
FilePdfOutlined,
|
| 13 |
+
FileWordOutlined,
|
| 14 |
+
FileImageOutlined,
|
| 15 |
+
PlusOutlined,
|
| 16 |
+
ExclamationCircleOutlined,
|
| 17 |
+
} from '@ant-design/icons';
|
| 18 |
+
import { motion } from 'framer-motion';
|
| 19 |
+
import knowledgeService from '../../services/knowledgeService';
|
| 20 |
+
|
| 21 |
+
const { confirm } = Modal;
|
| 22 |
+
|
| 23 |
+
const KnowledgeBase = () => {
|
| 24 |
+
const [form] = Form.useForm();
|
| 25 |
+
const [loading, setLoading] = useState(false);
|
| 26 |
+
const [knowledgeBases, setKnowledgeBases] = useState([]);
|
| 27 |
+
const [uploadProgress, setUploadProgress] = useState(0);
|
| 28 |
+
const [uploading, setUploading] = useState(false);
|
| 29 |
+
const [fileList, setFileList] = useState([]);
|
| 30 |
+
|
| 31 |
+
useEffect(() => {
|
| 32 |
+
loadKnowledgeBases();
|
| 33 |
+
}, []);
|
| 34 |
+
|
| 35 |
+
// 加载知识库列表
|
| 36 |
+
const loadKnowledgeBases = async () => {
|
| 37 |
+
try {
|
| 38 |
+
setLoading(true);
|
| 39 |
+
const res = await knowledgeService.getKnowledgeBases();
|
| 40 |
+
setKnowledgeBases(res.data || []);
|
| 41 |
+
} catch (error) {
|
| 42 |
+
console.error('加载知识库失败:', error);
|
| 43 |
+
message.error('加载知识库失败');
|
| 44 |
+
} finally {
|
| 45 |
+
setLoading(false);
|
| 46 |
+
}
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
// 创建知识库
|
| 50 |
+
const handleCreateKnowledgeBase = async (values) => {
|
| 51 |
+
if (fileList.length === 0) {
|
| 52 |
+
message.warning('请选择要上传的文件');
|
| 53 |
+
return;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
try {
|
| 57 |
+
setUploading(true);
|
| 58 |
+
|
| 59 |
+
// 后端只支持单文件上传,所以需要逐个上传
|
| 60 |
+
const firstFile = fileList[0];
|
| 61 |
+
const formData = new FormData();
|
| 62 |
+
formData.append('name', values.name);
|
| 63 |
+
formData.append('file', firstFile.originFileObj); // 注意:使用 'file' 不是 'files'
|
| 64 |
+
|
| 65 |
+
const res = await knowledgeService.createKnowledgeBase(formData);
|
| 66 |
+
|
| 67 |
+
if (res.success) {
|
| 68 |
+
message.success('知识库创建成功!');
|
| 69 |
+
|
| 70 |
+
// 如果有taskId,轮询进度
|
| 71 |
+
if (res.data?.task_id) {
|
| 72 |
+
pollProgress(res.data.task_id);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// 如果还有其他文件,需要逐个添加到知识库
|
| 76 |
+
if (fileList.length > 1) {
|
| 77 |
+
message.info(`正在上传剩余 ${fileList.length - 1} 个文件...`);
|
| 78 |
+
// 获取创建的知识库ID
|
| 79 |
+
const kbId = `rag_${values.name}`;
|
| 80 |
+
|
| 81 |
+
// 上传剩余文件
|
| 82 |
+
for (let i = 1; i < fileList.length; i++) {
|
| 83 |
+
const additionalFormData = new FormData();
|
| 84 |
+
additionalFormData.append('file', fileList[i].originFileObj);
|
| 85 |
+
|
| 86 |
+
try {
|
| 87 |
+
const addRes = await knowledgeService.addFileToKnowledgeBase(kbId, additionalFormData);
|
| 88 |
+
if (addRes.success && addRes.data?.task_id) {
|
| 89 |
+
pollProgress(addRes.data.task_id);
|
| 90 |
+
}
|
| 91 |
+
} catch (error) {
|
| 92 |
+
console.error(`上传文件 ${fileList[i].name} 失败:`, error);
|
| 93 |
+
message.error(`文件 ${fileList[i].name} 上传失败`);
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
form.resetFields();
|
| 99 |
+
setFileList([]);
|
| 100 |
+
setUploadProgress(0);
|
| 101 |
+
loadKnowledgeBases();
|
| 102 |
+
}
|
| 103 |
+
} catch (error) {
|
| 104 |
+
message.error('创建知识库失败');
|
| 105 |
+
console.error('创建知识库失败:', error);
|
| 106 |
+
} finally {
|
| 107 |
+
setUploading(false);
|
| 108 |
+
}
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
// 轮询文件处理进度
|
| 112 |
+
const pollProgress = async (taskId) => {
|
| 113 |
+
const maxAttempts = 60; // 最多轮询60次(约1分钟)
|
| 114 |
+
let attempts = 0;
|
| 115 |
+
|
| 116 |
+
const poll = async () => {
|
| 117 |
+
try {
|
| 118 |
+
const res = await knowledgeService.getProgress(taskId);
|
| 119 |
+
|
| 120 |
+
if (res.data?.status === 'completed') {
|
| 121 |
+
setUploadProgress(100);
|
| 122 |
+
message.success('文件处理完成');
|
| 123 |
+
loadKnowledgeBases();
|
| 124 |
+
return;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
if (res.data?.status === 'failed') {
|
| 128 |
+
message.error('文件处理失败');
|
| 129 |
+
return;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
if (res.data?.progress) {
|
| 133 |
+
setUploadProgress(res.data.progress);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
attempts++;
|
| 137 |
+
if (attempts < maxAttempts) {
|
| 138 |
+
setTimeout(poll, 1000); // 每秒轮询一次
|
| 139 |
+
}
|
| 140 |
+
} catch (error) {
|
| 141 |
+
console.error('获取进度失败:', error);
|
| 142 |
+
}
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
poll();
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
// 删除知识库
|
| 149 |
+
const handleDeleteKnowledgeBase = (kbId, kbName) => {
|
| 150 |
+
confirm({
|
| 151 |
+
title: '确认删除',
|
| 152 |
+
icon: <ExclamationCircleOutlined />,
|
| 153 |
+
content: `确定要删除知识库 "${kbName}" 吗?此操作不可恢复。`,
|
| 154 |
+
okText: '删除',
|
| 155 |
+
okType: 'danger',
|
| 156 |
+
cancelText: '取消',
|
| 157 |
+
onOk: async () => {
|
| 158 |
+
try {
|
| 159 |
+
await knowledgeService.deleteKnowledgeBase(kbId);
|
| 160 |
+
message.success('知识库已删除');
|
| 161 |
+
loadKnowledgeBases();
|
| 162 |
+
} catch (error) {
|
| 163 |
+
message.error('删除失败');
|
| 164 |
+
}
|
| 165 |
+
},
|
| 166 |
+
});
|
| 167 |
+
};
|
| 168 |
+
|
| 169 |
+
// 删除知识库中的文件
|
| 170 |
+
const handleDeleteFile = (indexId, fileName, kbName) => {
|
| 171 |
+
confirm({
|
| 172 |
+
title: '确认删除文件',
|
| 173 |
+
icon: <ExclamationCircleOutlined />,
|
| 174 |
+
content: `确定要从 "${kbName}" 中删除文件 "${fileName}" 吗?`,
|
| 175 |
+
okText: '删除',
|
| 176 |
+
okType: 'danger',
|
| 177 |
+
cancelText: '取消',
|
| 178 |
+
onOk: async () => {
|
| 179 |
+
try {
|
| 180 |
+
await knowledgeService.deleteFile(indexId, fileName);
|
| 181 |
+
message.success('文件已删除');
|
| 182 |
+
loadKnowledgeBases();
|
| 183 |
+
} catch (error) {
|
| 184 |
+
message.error('删除文件失败');
|
| 185 |
+
}
|
| 186 |
+
},
|
| 187 |
+
});
|
| 188 |
+
};
|
| 189 |
+
|
| 190 |
+
// 添加文件到现有知识库
|
| 191 |
+
const handleAddFiles = async (kbId) => {
|
| 192 |
+
// TODO: 实现文件添加功能
|
| 193 |
+
message.info('添加文件功能开发中');
|
| 194 |
+
};
|
| 195 |
+
|
| 196 |
+
// 获取文件图标
|
| 197 |
+
const getFileIcon = (fileName) => {
|
| 198 |
+
if (!fileName || typeof fileName !== 'string') {
|
| 199 |
+
return <FileTextOutlined style={{ color: '#6b7280', fontSize: '20px' }} />;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
const ext = fileName.split('.').pop().toLowerCase();
|
| 203 |
+
|
| 204 |
+
if (['txt', 'md'].includes(ext)) {
|
| 205 |
+
return <FileTextOutlined style={{ color: '#10b981', fontSize: '20px' }} />;
|
| 206 |
+
}
|
| 207 |
+
if (['pdf'].includes(ext)) {
|
| 208 |
+
return <FilePdfOutlined style={{ color: '#ef4444', fontSize: '20px' }} />;
|
| 209 |
+
}
|
| 210 |
+
if (['doc', 'docx'].includes(ext)) {
|
| 211 |
+
return <FileWordOutlined style={{ color: '#3b82f6', fontSize: '20px' }} />;
|
| 212 |
+
}
|
| 213 |
+
if (['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(ext)) {
|
| 214 |
+
return <FileImageOutlined style={{ color: '#f59e0b', fontSize: '20px' }} />;
|
| 215 |
+
}
|
| 216 |
+
return <FileTextOutlined style={{ color: '#6b7280', fontSize: '20px' }} />;
|
| 217 |
+
};
|
| 218 |
+
|
| 219 |
+
// 上传配置
|
| 220 |
+
const uploadProps = {
|
| 221 |
+
fileList,
|
| 222 |
+
onChange: ({ fileList: newFileList }) => setFileList(newFileList),
|
| 223 |
+
beforeUpload: (file) => {
|
| 224 |
+
const isValidType = [
|
| 225 |
+
'text/plain',
|
| 226 |
+
'text/markdown',
|
| 227 |
+
'application/pdf',
|
| 228 |
+
'application/msword',
|
| 229 |
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
| 230 |
+
'image/jpeg',
|
| 231 |
+
'image/png',
|
| 232 |
+
'image/gif',
|
| 233 |
+
'image/bmp',
|
| 234 |
+
].includes(file.type);
|
| 235 |
+
|
| 236 |
+
if (!isValidType) {
|
| 237 |
+
message.error('不支持的文件类型');
|
| 238 |
+
return Upload.LIST_IGNORE;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
const isLt16M = file.size / 1024 / 1024 < 16;
|
| 242 |
+
if (!isLt16M) {
|
| 243 |
+
message.error('文件大小不能超过 16MB');
|
| 244 |
+
return Upload.LIST_IGNORE;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
return false; // 阻止自动上传
|
| 248 |
+
},
|
| 249 |
+
multiple: true,
|
| 250 |
+
};
|
| 251 |
+
|
| 252 |
+
return (
|
| 253 |
+
<div>
|
| 254 |
+
<div style={{ marginBottom: '32px' }}>
|
| 255 |
+
<h1 style={{ fontSize: '28px', fontWeight: 600, color: '#1f2937', margin: 0 }}>
|
| 256 |
+
知识库管理
|
| 257 |
+
</h1>
|
| 258 |
+
<p style={{ fontSize: '14px', color: '#6b7280', marginTop: '8px' }}>
|
| 259 |
+
创建和管理您的教学资料知识库
|
| 260 |
+
</p>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
<Row gutter={24}>
|
| 264 |
+
{/* 左侧:创建知识库 */}
|
| 265 |
+
<Col xs={24} lg={10}>
|
| 266 |
+
<Card
|
| 267 |
+
title={
|
| 268 |
+
<span style={{ fontSize: '18px', fontWeight: 600 }}>
|
| 269 |
+
<PlusOutlined style={{ marginRight: '8px', color: '#10b981' }} />
|
| 270 |
+
创建新知识库
|
| 271 |
+
</span>
|
| 272 |
+
}
|
| 273 |
+
style={{ border: '1px solid #e5e7eb', borderRadius: '12px' }}
|
| 274 |
+
>
|
| 275 |
+
<Form form={form} layout="vertical" onFinish={handleCreateKnowledgeBase}>
|
| 276 |
+
<Form.Item
|
| 277 |
+
label="知识库名称"
|
| 278 |
+
name="name"
|
| 279 |
+
rules={[{ required: true, message: '请输入知识库名称' }]}
|
| 280 |
+
>
|
| 281 |
+
<Input
|
| 282 |
+
placeholder="例如:Python编程资料"
|
| 283 |
+
size="large"
|
| 284 |
+
/>
|
| 285 |
+
</Form.Item>
|
| 286 |
+
|
| 287 |
+
<Form.Item label="上传文件">
|
| 288 |
+
<Upload {...uploadProps}>
|
| 289 |
+
<Button icon={<UploadOutlined />} size="large" block>
|
| 290 |
+
选择文件
|
| 291 |
+
</Button>
|
| 292 |
+
</Upload>
|
| 293 |
+
<div style={{ marginTop: '8px', fontSize: '12px', color: '#6b7280' }}>
|
| 294 |
+
支持格式:TXT, MD, PDF, DOC, DOCX, JPG, PNG, GIF, BMP
|
| 295 |
+
<br />
|
| 296 |
+
单个文件最大 16MB
|
| 297 |
+
</div>
|
| 298 |
+
</Form.Item>
|
| 299 |
+
|
| 300 |
+
{uploading && uploadProgress > 0 && (
|
| 301 |
+
<Form.Item>
|
| 302 |
+
<Progress
|
| 303 |
+
percent={uploadProgress}
|
| 304 |
+
status={uploadProgress === 100 ? 'success' : 'active'}
|
| 305 |
+
strokeColor="#10b981"
|
| 306 |
+
/>
|
| 307 |
+
</Form.Item>
|
| 308 |
+
)}
|
| 309 |
+
|
| 310 |
+
<Form.Item>
|
| 311 |
+
<Button
|
| 312 |
+
type="primary"
|
| 313 |
+
htmlType="submit"
|
| 314 |
+
size="large"
|
| 315 |
+
loading={uploading}
|
| 316 |
+
block
|
| 317 |
+
style={{ backgroundColor: '#10b981' }}
|
| 318 |
+
>
|
| 319 |
+
创���知识库
|
| 320 |
+
</Button>
|
| 321 |
+
</Form.Item>
|
| 322 |
+
</Form>
|
| 323 |
+
</Card>
|
| 324 |
+
</Col>
|
| 325 |
+
|
| 326 |
+
{/* 右侧:现有知识库列表 */}
|
| 327 |
+
<Col xs={24} lg={14}>
|
| 328 |
+
<Card
|
| 329 |
+
title={
|
| 330 |
+
<span style={{ fontSize: '18px', fontWeight: 600 }}>
|
| 331 |
+
现有知识库
|
| 332 |
+
</span>
|
| 333 |
+
}
|
| 334 |
+
style={{ border: '1px solid #e5e7eb', borderRadius: '12px' }}
|
| 335 |
+
loading={loading}
|
| 336 |
+
>
|
| 337 |
+
{knowledgeBases.length > 0 ? (
|
| 338 |
+
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
| 339 |
+
{knowledgeBases.map((kb) => (
|
| 340 |
+
<motion.div
|
| 341 |
+
key={kb.id}
|
| 342 |
+
initial={{ opacity: 0, y: 10 }}
|
| 343 |
+
animate={{ opacity: 1, y: 0 }}
|
| 344 |
+
style={{
|
| 345 |
+
padding: '20px',
|
| 346 |
+
background: '#f8fafc',
|
| 347 |
+
borderRadius: '12px',
|
| 348 |
+
border: '1px solid #e5e7eb',
|
| 349 |
+
}}
|
| 350 |
+
>
|
| 351 |
+
{/* 知识库头部 */}
|
| 352 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
| 353 |
+
<div>
|
| 354 |
+
<h3 style={{ fontSize: '18px', fontWeight: 600, margin: 0, color: '#1f2937' }}>
|
| 355 |
+
{kb.name}
|
| 356 |
+
</h3>
|
| 357 |
+
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '4px' }}>
|
| 358 |
+
知识库ID: {kb.id}
|
| 359 |
+
</div>
|
| 360 |
+
</div>
|
| 361 |
+
<Space>
|
| 362 |
+
<Button
|
| 363 |
+
type="primary"
|
| 364 |
+
size="small"
|
| 365 |
+
icon={<PlusOutlined />}
|
| 366 |
+
onClick={() => handleAddFiles(kb.id)}
|
| 367 |
+
>
|
| 368 |
+
添加文件
|
| 369 |
+
</Button>
|
| 370 |
+
<Button
|
| 371 |
+
danger
|
| 372 |
+
size="small"
|
| 373 |
+
icon={<DeleteOutlined />}
|
| 374 |
+
onClick={() => handleDeleteKnowledgeBase(kb.id, kb.name)}
|
| 375 |
+
>
|
| 376 |
+
删除
|
| 377 |
+
</Button>
|
| 378 |
+
</Space>
|
| 379 |
+
</div>
|
| 380 |
+
|
| 381 |
+
{/* 文件列表 */}
|
| 382 |
+
{kb.files && kb.files.length > 0 ? (
|
| 383 |
+
<div
|
| 384 |
+
style={{
|
| 385 |
+
background: 'white',
|
| 386 |
+
borderRadius: '8px',
|
| 387 |
+
padding: '12px',
|
| 388 |
+
border: '1px solid #e5e7eb',
|
| 389 |
+
}}
|
| 390 |
+
>
|
| 391 |
+
<List
|
| 392 |
+
size="small"
|
| 393 |
+
dataSource={kb.files}
|
| 394 |
+
renderItem={(file) => (
|
| 395 |
+
<List.Item
|
| 396 |
+
actions={[
|
| 397 |
+
<Button
|
| 398 |
+
type="text"
|
| 399 |
+
danger
|
| 400 |
+
size="small"
|
| 401 |
+
icon={<DeleteOutlined />}
|
| 402 |
+
onClick={() => handleDeleteFile(kb.id, file.name, kb.name)}
|
| 403 |
+
>
|
| 404 |
+
删除
|
| 405 |
+
</Button>,
|
| 406 |
+
]}
|
| 407 |
+
>
|
| 408 |
+
<List.Item.Meta
|
| 409 |
+
avatar={getFileIcon(file.name)}
|
| 410 |
+
title={
|
| 411 |
+
<span style={{ fontSize: '14px', color: '#1f2937' }}>
|
| 412 |
+
{file.name}
|
| 413 |
+
</span>
|
| 414 |
+
}
|
| 415 |
+
description={
|
| 416 |
+
<span style={{ fontSize: '12px', color: '#6b7280' }}>
|
| 417 |
+
{file.size || '未知大小'}
|
| 418 |
+
</span>
|
| 419 |
+
}
|
| 420 |
+
/>
|
| 421 |
+
</List.Item>
|
| 422 |
+
)}
|
| 423 |
+
/>
|
| 424 |
+
</div>
|
| 425 |
+
) : (
|
| 426 |
+
<div
|
| 427 |
+
style={{
|
| 428 |
+
textAlign: 'center',
|
| 429 |
+
padding: '20px',
|
| 430 |
+
color: '#9ca3af',
|
| 431 |
+
background: 'white',
|
| 432 |
+
borderRadius: '8px',
|
| 433 |
+
border: '1px dashed #d1d5db',
|
| 434 |
+
}}
|
| 435 |
+
>
|
| 436 |
+
<FileTextOutlined style={{ fontSize: '32px', marginBottom: '8px' }} />
|
| 437 |
+
<div>暂无文件</div>
|
| 438 |
+
</div>
|
| 439 |
+
)}
|
| 440 |
+
|
| 441 |
+
{/* 知识库��数据 */}
|
| 442 |
+
<div style={{ marginTop: '12px', display: 'flex', gap: '12px' }}>
|
| 443 |
+
<Tag color="blue">{kb.fileCount || 0} 个文件</Tag>
|
| 444 |
+
<Tag color="green">索引: {kb.id}</Tag>
|
| 445 |
+
</div>
|
| 446 |
+
</motion.div>
|
| 447 |
+
))}
|
| 448 |
+
</Space>
|
| 449 |
+
) : (
|
| 450 |
+
<div
|
| 451 |
+
style={{
|
| 452 |
+
textAlign: 'center',
|
| 453 |
+
padding: '60px 20px',
|
| 454 |
+
color: '#9ca3af',
|
| 455 |
+
}}
|
| 456 |
+
>
|
| 457 |
+
<FileTextOutlined style={{ fontSize: '64px', marginBottom: '16px', opacity: 0.5 }} />
|
| 458 |
+
<div style={{ fontSize: '16px', marginBottom: '8px' }}>
|
| 459 |
+
还没有创建任何知识库
|
| 460 |
+
</div>
|
| 461 |
+
<div style={{ fontSize: '14px' }}>
|
| 462 |
+
在左侧创建您的第一个知识库
|
| 463 |
+
</div>
|
| 464 |
+
</div>
|
| 465 |
+
)}
|
| 466 |
+
</Card>
|
| 467 |
+
</Col>
|
| 468 |
+
</Row>
|
| 469 |
+
</div>
|
| 470 |
+
);
|
| 471 |
+
};
|
| 472 |
+
|
| 473 |
+
export default KnowledgeBase;
|
frontend/src/pages/teacher/StudentManagement.jsx
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 学生管理组件
|
| 3 |
+
* 教师可以管理学生账号,查看学习进度
|
| 4 |
+
*/
|
| 5 |
+
import { useState, useEffect } from 'react';
|
| 6 |
+
import {
|
| 7 |
+
Card,
|
| 8 |
+
Table,
|
| 9 |
+
Button,
|
| 10 |
+
Space,
|
| 11 |
+
Tag,
|
| 12 |
+
Modal,
|
| 13 |
+
Form,
|
| 14 |
+
Input,
|
| 15 |
+
Select,
|
| 16 |
+
message,
|
| 17 |
+
Popconfirm,
|
| 18 |
+
Tooltip,
|
| 19 |
+
Avatar,
|
| 20 |
+
Progress,
|
| 21 |
+
Row,
|
| 22 |
+
Col,
|
| 23 |
+
Statistic,
|
| 24 |
+
Typography,
|
| 25 |
+
Drawer,
|
| 26 |
+
Timeline,
|
| 27 |
+
Badge
|
| 28 |
+
} from 'antd';
|
| 29 |
+
import {
|
| 30 |
+
UserAddOutlined,
|
| 31 |
+
EditOutlined,
|
| 32 |
+
DeleteOutlined,
|
| 33 |
+
ReloadOutlined,
|
| 34 |
+
SearchOutlined,
|
| 35 |
+
TeamOutlined,
|
| 36 |
+
TrophyOutlined,
|
| 37 |
+
ClockCircleOutlined,
|
| 38 |
+
MailOutlined,
|
| 39 |
+
LockOutlined,
|
| 40 |
+
EyeOutlined,
|
| 41 |
+
FileTextOutlined,
|
| 42 |
+
CheckCircleOutlined,
|
| 43 |
+
ExclamationCircleOutlined
|
| 44 |
+
} from '@ant-design/icons';
|
| 45 |
+
import api from '../../services/api';
|
| 46 |
+
import { motion } from 'framer-motion';
|
| 47 |
+
|
| 48 |
+
const { Title, Text, Paragraph } = Typography;
|
| 49 |
+
const { Option } = Select;
|
| 50 |
+
|
| 51 |
+
const StudentManagement = () => {
|
| 52 |
+
const [students, setStudents] = useState([]);
|
| 53 |
+
const [loading, setLoading] = useState(false);
|
| 54 |
+
const [modalVisible, setModalVisible] = useState(false);
|
| 55 |
+
const [editingStudent, setEditingStudent] = useState(null);
|
| 56 |
+
const [selectedStudent, setSelectedStudent] = useState(null);
|
| 57 |
+
const [drawerVisible, setDrawerVisible] = useState(false);
|
| 58 |
+
const [searchText, setSearchText] = useState('');
|
| 59 |
+
const [form] = Form.useForm();
|
| 60 |
+
|
| 61 |
+
// 加载学生列表
|
| 62 |
+
const fetchStudents = async () => {
|
| 63 |
+
setLoading(true);
|
| 64 |
+
try {
|
| 65 |
+
const response = await api.get('/teacher/students');
|
| 66 |
+
if (response.success) {
|
| 67 |
+
setStudents(response.students || []);
|
| 68 |
+
} else {
|
| 69 |
+
message.error('加载学生列表失败');
|
| 70 |
+
}
|
| 71 |
+
} catch (error) {
|
| 72 |
+
console.error('加载学生列表失败:', error);
|
| 73 |
+
message.error('加载学生列表失败');
|
| 74 |
+
} finally {
|
| 75 |
+
setLoading(false);
|
| 76 |
+
}
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
useEffect(() => {
|
| 80 |
+
fetchStudents();
|
| 81 |
+
}, []);
|
| 82 |
+
|
| 83 |
+
// 创建或编辑学生
|
| 84 |
+
const handleSubmit = async (values) => {
|
| 85 |
+
try {
|
| 86 |
+
const url = editingStudent
|
| 87 |
+
? `/teacher/students/${editingStudent.id}`
|
| 88 |
+
: '/teacher/students';
|
| 89 |
+
|
| 90 |
+
const method = editingStudent ? 'put' : 'post';
|
| 91 |
+
|
| 92 |
+
const response = await api[method](url, values);
|
| 93 |
+
|
| 94 |
+
if (response.success) {
|
| 95 |
+
message.success(editingStudent ? '学生信息已更新' : '学生已创建');
|
| 96 |
+
setModalVisible(false);
|
| 97 |
+
form.resetFields();
|
| 98 |
+
setEditingStudent(null);
|
| 99 |
+
fetchStudents();
|
| 100 |
+
} else {
|
| 101 |
+
message.error(response.message || '操作失败');
|
| 102 |
+
}
|
| 103 |
+
} catch (error) {
|
| 104 |
+
console.error('操作失败:', error);
|
| 105 |
+
message.error('操作失败');
|
| 106 |
+
}
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
// 删除学生
|
| 110 |
+
const handleDelete = async (studentId) => {
|
| 111 |
+
try {
|
| 112 |
+
const response = await api.delete(`/teacher/students/${studentId}`);
|
| 113 |
+
if (response.success) {
|
| 114 |
+
message.success('学生已删除');
|
| 115 |
+
fetchStudents();
|
| 116 |
+
} else {
|
| 117 |
+
message.error('删除失败');
|
| 118 |
+
}
|
| 119 |
+
} catch (error) {
|
| 120 |
+
console.error('删除失败:', error);
|
| 121 |
+
message.error('删除失败');
|
| 122 |
+
}
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
// 重置密码
|
| 126 |
+
const handleResetPassword = async (studentId) => {
|
| 127 |
+
try {
|
| 128 |
+
const response = await api.post(`/teacher/students/${studentId}/reset-password`);
|
| 129 |
+
if (response.success) {
|
| 130 |
+
Modal.success({
|
| 131 |
+
title: '密码重置成功',
|
| 132 |
+
content: `新密码:${response.newPassword}`,
|
| 133 |
+
});
|
| 134 |
+
} else {
|
| 135 |
+
message.error('重置密码失败');
|
| 136 |
+
}
|
| 137 |
+
} catch (error) {
|
| 138 |
+
console.error('重置密码失败:', error);
|
| 139 |
+
message.error('重置密码失败');
|
| 140 |
+
}
|
| 141 |
+
};
|
| 142 |
+
|
| 143 |
+
// 查看学生详情
|
| 144 |
+
const handleViewDetails = async (student) => {
|
| 145 |
+
setSelectedStudent(student);
|
| 146 |
+
setDrawerVisible(true);
|
| 147 |
+
|
| 148 |
+
// 加载学生的学习进度
|
| 149 |
+
try {
|
| 150 |
+
const response = await api.get(`/teacher/students/${student.id}/progress`);
|
| 151 |
+
if (response.success) {
|
| 152 |
+
setSelectedStudent({
|
| 153 |
+
...student,
|
| 154 |
+
progress: response.progress
|
| 155 |
+
});
|
| 156 |
+
}
|
| 157 |
+
} catch (error) {
|
| 158 |
+
console.error('加载学生进度失败:', error);
|
| 159 |
+
}
|
| 160 |
+
};
|
| 161 |
+
|
| 162 |
+
// 表格列配置
|
| 163 |
+
const columns = [
|
| 164 |
+
{
|
| 165 |
+
title: '学生信息',
|
| 166 |
+
key: 'info',
|
| 167 |
+
render: (_, record) => (
|
| 168 |
+
<Space>
|
| 169 |
+
<Avatar style={{ backgroundColor: '#10b981' }}>
|
| 170 |
+
{record.name ? record.name[0] : 'S'}
|
| 171 |
+
</Avatar>
|
| 172 |
+
<div>
|
| 173 |
+
<div style={{ fontWeight: 500 }}>{record.name}</div>
|
| 174 |
+
<Text type="secondary" style={{ fontSize: '12px' }}>
|
| 175 |
+
{record.username}
|
| 176 |
+
</Text>
|
| 177 |
+
</div>
|
| 178 |
+
</Space>
|
| 179 |
+
),
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
title: '邮箱',
|
| 183 |
+
dataIndex: 'email',
|
| 184 |
+
key: 'email',
|
| 185 |
+
render: (email) => (
|
| 186 |
+
<Space>
|
| 187 |
+
<MailOutlined />
|
| 188 |
+
<span>{email || '未设置'}</span>
|
| 189 |
+
</Space>
|
| 190 |
+
),
|
| 191 |
+
},
|
| 192 |
+
{
|
| 193 |
+
title: '学号',
|
| 194 |
+
dataIndex: 'student_id',
|
| 195 |
+
key: 'student_id',
|
| 196 |
+
},
|
| 197 |
+
{
|
| 198 |
+
title: '班级',
|
| 199 |
+
dataIndex: 'class',
|
| 200 |
+
key: 'class',
|
| 201 |
+
render: (cls) => <Tag color="blue">{cls || '未分配'}</Tag>,
|
| 202 |
+
},
|
| 203 |
+
{
|
| 204 |
+
title: '学习进度',
|
| 205 |
+
key: 'progress',
|
| 206 |
+
render: (_, record) => {
|
| 207 |
+
const progress = record.total_interactions || 0;
|
| 208 |
+
const level = progress > 50 ? 'high' : progress > 20 ? 'medium' : 'low';
|
| 209 |
+
const color = level === 'high' ? '#52c41a' : level === 'medium' ? '#faad14' : '#f5222d';
|
| 210 |
+
|
| 211 |
+
return (
|
| 212 |
+
<Tooltip title={`总互动次数: ${progress}`}>
|
| 213 |
+
<Progress
|
| 214 |
+
percent={Math.min(progress, 100)}
|
| 215 |
+
size="small"
|
| 216 |
+
strokeColor={color}
|
| 217 |
+
format={() => `${progress}次`}
|
| 218 |
+
/>
|
| 219 |
+
</Tooltip>
|
| 220 |
+
);
|
| 221 |
+
},
|
| 222 |
+
},
|
| 223 |
+
{
|
| 224 |
+
title: '状态',
|
| 225 |
+
key: 'status',
|
| 226 |
+
render: (_, record) => {
|
| 227 |
+
const isActive = record.last_active &&
|
| 228 |
+
(Date.now() - new Date(record.last_active).getTime()) < 7 * 24 * 60 * 60 * 1000;
|
| 229 |
+
|
| 230 |
+
return (
|
| 231 |
+
<Badge
|
| 232 |
+
status={isActive ? 'success' : 'default'}
|
| 233 |
+
text={isActive ? '活跃' : '不活跃'}
|
| 234 |
+
/>
|
| 235 |
+
);
|
| 236 |
+
},
|
| 237 |
+
},
|
| 238 |
+
{
|
| 239 |
+
title: '操作',
|
| 240 |
+
key: 'action',
|
| 241 |
+
fixed: 'right',
|
| 242 |
+
render: (_, record) => (
|
| 243 |
+
<Space size="small">
|
| 244 |
+
<Tooltip title="查看详情">
|
| 245 |
+
<Button
|
| 246 |
+
type="text"
|
| 247 |
+
icon={<EyeOutlined />}
|
| 248 |
+
onClick={() => handleViewDetails(record)}
|
| 249 |
+
/>
|
| 250 |
+
</Tooltip>
|
| 251 |
+
<Tooltip title="编辑">
|
| 252 |
+
<Button
|
| 253 |
+
type="text"
|
| 254 |
+
icon={<EditOutlined />}
|
| 255 |
+
onClick={() => {
|
| 256 |
+
setEditingStudent(record);
|
| 257 |
+
form.setFieldsValue(record);
|
| 258 |
+
setModalVisible(true);
|
| 259 |
+
}}
|
| 260 |
+
/>
|
| 261 |
+
</Tooltip>
|
| 262 |
+
<Tooltip title="重置密码">
|
| 263 |
+
<Popconfirm
|
| 264 |
+
title="确定要重置该学生的密码吗?"
|
| 265 |
+
onConfirm={() => handleResetPassword(record.id)}
|
| 266 |
+
>
|
| 267 |
+
<Button type="text" icon={<LockOutlined />} />
|
| 268 |
+
</Popconfirm>
|
| 269 |
+
</Tooltip>
|
| 270 |
+
<Tooltip title="删除">
|
| 271 |
+
<Popconfirm
|
| 272 |
+
title="确定要删除该学生吗?"
|
| 273 |
+
onConfirm={() => handleDelete(record.id)}
|
| 274 |
+
>
|
| 275 |
+
<Button type="text" danger icon={<DeleteOutlined />} />
|
| 276 |
+
</Popconfirm>
|
| 277 |
+
</Tooltip>
|
| 278 |
+
</Space>
|
| 279 |
+
),
|
| 280 |
+
},
|
| 281 |
+
];
|
| 282 |
+
|
| 283 |
+
// 过滤学生
|
| 284 |
+
const filteredStudents = students.filter((student) =>
|
| 285 |
+
student.name?.toLowerCase().includes(searchText.toLowerCase()) ||
|
| 286 |
+
student.username?.toLowerCase().includes(searchText.toLowerCase()) ||
|
| 287 |
+
student.email?.toLowerCase().includes(searchText.toLowerCase()) ||
|
| 288 |
+
student.student_id?.toLowerCase().includes(searchText.toLowerCase())
|
| 289 |
+
);
|
| 290 |
+
|
| 291 |
+
// 统计数据
|
| 292 |
+
const stats = {
|
| 293 |
+
total: students.length,
|
| 294 |
+
active: students.filter(s => s.last_active &&
|
| 295 |
+
(Date.now() - new Date(s.last_active).getTime()) < 7 * 24 * 60 * 60 * 1000).length,
|
| 296 |
+
avgProgress: students.reduce((sum, s) => sum + (s.total_interactions || 0), 0) / (students.length || 1)
|
| 297 |
+
};
|
| 298 |
+
|
| 299 |
+
return (
|
| 300 |
+
<div style={{ padding: '24px' }}>
|
| 301 |
+
{/* 统计卡片 */}
|
| 302 |
+
<Row gutter={[16, 16]} style={{ marginBottom: '24px' }}>
|
| 303 |
+
<Col xs={24} sm={8}>
|
| 304 |
+
<Card>
|
| 305 |
+
<Statistic
|
| 306 |
+
title="总学生数"
|
| 307 |
+
value={stats.total}
|
| 308 |
+
prefix={<TeamOutlined />}
|
| 309 |
+
valueStyle={{ color: '#1890ff' }}
|
| 310 |
+
/>
|
| 311 |
+
</Card>
|
| 312 |
+
</Col>
|
| 313 |
+
<Col xs={24} sm={8}>
|
| 314 |
+
<Card>
|
| 315 |
+
<Statistic
|
| 316 |
+
title="活跃学生"
|
| 317 |
+
value={stats.active}
|
| 318 |
+
prefix={<CheckCircleOutlined />}
|
| 319 |
+
valueStyle={{ color: '#52c41a' }}
|
| 320 |
+
/>
|
| 321 |
+
</Card>
|
| 322 |
+
</Col>
|
| 323 |
+
<Col xs={24} sm={8}>
|
| 324 |
+
<Card>
|
| 325 |
+
<Statistic
|
| 326 |
+
title="平均互动次数"
|
| 327 |
+
value={stats.avgProgress}
|
| 328 |
+
precision={1}
|
| 329 |
+
prefix={<TrophyOutlined />}
|
| 330 |
+
valueStyle={{ color: '#faad14' }}
|
| 331 |
+
/>
|
| 332 |
+
</Card>
|
| 333 |
+
</Col>
|
| 334 |
+
</Row>
|
| 335 |
+
|
| 336 |
+
{/* 学生列表 */}
|
| 337 |
+
<Card
|
| 338 |
+
title={
|
| 339 |
+
<Space>
|
| 340 |
+
<TeamOutlined />
|
| 341 |
+
<span>学生管理</span>
|
| 342 |
+
</Space>
|
| 343 |
+
}
|
| 344 |
+
extra={
|
| 345 |
+
<Space>
|
| 346 |
+
<Input
|
| 347 |
+
placeholder="搜索学生..."
|
| 348 |
+
prefix={<SearchOutlined />}
|
| 349 |
+
value={searchText}
|
| 350 |
+
onChange={(e) => setSearchText(e.target.value)}
|
| 351 |
+
style={{ width: 200 }}
|
| 352 |
+
/>
|
| 353 |
+
<Button
|
| 354 |
+
icon={<ReloadOutlined />}
|
| 355 |
+
onClick={fetchStudents}
|
| 356 |
+
loading={loading}
|
| 357 |
+
>
|
| 358 |
+
刷新
|
| 359 |
+
</Button>
|
| 360 |
+
<Button
|
| 361 |
+
type="primary"
|
| 362 |
+
icon={<UserAddOutlined />}
|
| 363 |
+
onClick={() => {
|
| 364 |
+
setEditingStudent(null);
|
| 365 |
+
form.resetFields();
|
| 366 |
+
setModalVisible(true);
|
| 367 |
+
}}
|
| 368 |
+
>
|
| 369 |
+
添加学生
|
| 370 |
+
</Button>
|
| 371 |
+
</Space>
|
| 372 |
+
}
|
| 373 |
+
>
|
| 374 |
+
<Table
|
| 375 |
+
columns={columns}
|
| 376 |
+
dataSource={filteredStudents}
|
| 377 |
+
rowKey="id"
|
| 378 |
+
loading={loading}
|
| 379 |
+
pagination={{
|
| 380 |
+
pageSize: 10,
|
| 381 |
+
showSizeChanger: true,
|
| 382 |
+
showTotal: (total) => `共 ${total} 条记录`,
|
| 383 |
+
}}
|
| 384 |
+
/>
|
| 385 |
+
</Card>
|
| 386 |
+
|
| 387 |
+
{/* 创建/编辑学生Modal */}
|
| 388 |
+
<Modal
|
| 389 |
+
title={editingStudent ? '编辑学生' : '添加学生'}
|
| 390 |
+
open={modalVisible}
|
| 391 |
+
onCancel={() => {
|
| 392 |
+
setModalVisible(false);
|
| 393 |
+
form.resetFields();
|
| 394 |
+
setEditingStudent(null);
|
| 395 |
+
}}
|
| 396 |
+
footer={null}
|
| 397 |
+
>
|
| 398 |
+
<Form
|
| 399 |
+
form={form}
|
| 400 |
+
layout="vertical"
|
| 401 |
+
onFinish={handleSubmit}
|
| 402 |
+
>
|
| 403 |
+
<Form.Item
|
| 404 |
+
name="username"
|
| 405 |
+
label="用户名"
|
| 406 |
+
rules={[{ required: true, message: '请输入用户名' }]}
|
| 407 |
+
>
|
| 408 |
+
<Input prefix={<UserAddOutlined />} placeholder="学生登录用户名" />
|
| 409 |
+
</Form.Item>
|
| 410 |
+
|
| 411 |
+
<Form.Item
|
| 412 |
+
name="password"
|
| 413 |
+
label="密码"
|
| 414 |
+
rules={[
|
| 415 |
+
{ required: !editingStudent, message: '请输入密码' },
|
| 416 |
+
{ min: 6, message: '密码至少6个字符' }
|
| 417 |
+
]}
|
| 418 |
+
>
|
| 419 |
+
<Input.Password
|
| 420 |
+
prefix={<LockOutlined />}
|
| 421 |
+
placeholder={editingStudent ? '留空保持原密码' : '登录密码'}
|
| 422 |
+
/>
|
| 423 |
+
</Form.Item>
|
| 424 |
+
|
| 425 |
+
<Form.Item
|
| 426 |
+
name="name"
|
| 427 |
+
label="姓名"
|
| 428 |
+
rules={[{ required: true, message: '请输入姓名' }]}
|
| 429 |
+
>
|
| 430 |
+
<Input placeholder="学生真实姓名" />
|
| 431 |
+
</Form.Item>
|
| 432 |
+
|
| 433 |
+
<Form.Item
|
| 434 |
+
name="student_id"
|
| 435 |
+
label="学号"
|
| 436 |
+
>
|
| 437 |
+
<Input placeholder="学生学号" />
|
| 438 |
+
</Form.Item>
|
| 439 |
+
|
| 440 |
+
<Form.Item
|
| 441 |
+
name="email"
|
| 442 |
+
label="邮箱"
|
| 443 |
+
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
|
| 444 |
+
>
|
| 445 |
+
<Input prefix={<MailOutlined />} placeholder="学生邮箱" />
|
| 446 |
+
</Form.Item>
|
| 447 |
+
|
| 448 |
+
<Form.Item
|
| 449 |
+
name="class"
|
| 450 |
+
label="班级"
|
| 451 |
+
>
|
| 452 |
+
<Input placeholder="所在班级" />
|
| 453 |
+
</Form.Item>
|
| 454 |
+
|
| 455 |
+
<Form.Item>
|
| 456 |
+
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
| 457 |
+
<Button onClick={() => setModalVisible(false)}>
|
| 458 |
+
取消
|
| 459 |
+
</Button>
|
| 460 |
+
<Button type="primary" htmlType="submit">
|
| 461 |
+
{editingStudent ? '更新' : '创建'}
|
| 462 |
+
</Button>
|
| 463 |
+
</Space>
|
| 464 |
+
</Form.Item>
|
| 465 |
+
</Form>
|
| 466 |
+
</Modal>
|
| 467 |
+
|
| 468 |
+
{/* 学生详情Drawer */}
|
| 469 |
+
<Drawer
|
| 470 |
+
title="学生详情"
|
| 471 |
+
placement="right"
|
| 472 |
+
width={600}
|
| 473 |
+
open={drawerVisible}
|
| 474 |
+
onClose={() => setDrawerVisible(false)}
|
| 475 |
+
>
|
| 476 |
+
{selectedStudent && (
|
| 477 |
+
<div>
|
| 478 |
+
<Card style={{ marginBottom: '16px' }}>
|
| 479 |
+
<Space align="center" style={{ width: '100%' }}>
|
| 480 |
+
<Avatar size={64} style={{ backgroundColor: '#10b981' }}>
|
| 481 |
+
{selectedStudent.name ? selectedStudent.name[0] : 'S'}
|
| 482 |
+
</Avatar>
|
| 483 |
+
<div style={{ flex: 1 }}>
|
| 484 |
+
<Title level={4} style={{ margin: 0 }}>
|
| 485 |
+
{selectedStudent.name}
|
| 486 |
+
</Title>
|
| 487 |
+
<Text type="secondary">@{selectedStudent.username}</Text>
|
| 488 |
+
</div>
|
| 489 |
+
</Space>
|
| 490 |
+
|
| 491 |
+
<div style={{ marginTop: '24px' }}>
|
| 492 |
+
<Row gutter={[16, 16]}>
|
| 493 |
+
<Col span={12}>
|
| 494 |
+
<Text type="secondary">学号</Text>
|
| 495 |
+
<div>{selectedStudent.student_id || '未设置'}</div>
|
| 496 |
+
</Col>
|
| 497 |
+
<Col span={12}>
|
| 498 |
+
<Text type="secondary">班级</Text>
|
| 499 |
+
<div>{selectedStudent.class || '未分配'}</div>
|
| 500 |
+
</Col>
|
| 501 |
+
<Col span={12}>
|
| 502 |
+
<Text type="secondary">邮箱</Text>
|
| 503 |
+
<div>{selectedStudent.email || '未设置'}</div>
|
| 504 |
+
</Col>
|
| 505 |
+
<Col span={12}>
|
| 506 |
+
<Text type="secondary">注册��间</Text>
|
| 507 |
+
<div>{selectedStudent.created_at || '未知'}</div>
|
| 508 |
+
</Col>
|
| 509 |
+
</Row>
|
| 510 |
+
</div>
|
| 511 |
+
</Card>
|
| 512 |
+
|
| 513 |
+
{/* 学习进度 */}
|
| 514 |
+
{selectedStudent.progress && (
|
| 515 |
+
<Card title="学习进度" style={{ marginBottom: '16px' }}>
|
| 516 |
+
<Space direction="vertical" style={{ width: '100%' }}>
|
| 517 |
+
<div>
|
| 518 |
+
<Text type="secondary">总互动次数</Text>
|
| 519 |
+
<Title level={4}>{selectedStudent.progress.total_interactions || 0}</Title>
|
| 520 |
+
</div>
|
| 521 |
+
<div>
|
| 522 |
+
<Text type="secondary">最近活跃</Text>
|
| 523 |
+
<div>{selectedStudent.progress.last_active || '从未活跃'}</div>
|
| 524 |
+
</div>
|
| 525 |
+
<div>
|
| 526 |
+
<Text type="secondary">常用Agent</Text>
|
| 527 |
+
<div>
|
| 528 |
+
{selectedStudent.progress.frequently_used_agents?.map(agent => (
|
| 529 |
+
<Tag key={agent}>{agent}</Tag>
|
| 530 |
+
)) || '暂无'}
|
| 531 |
+
</div>
|
| 532 |
+
</div>
|
| 533 |
+
</Space>
|
| 534 |
+
</Card>
|
| 535 |
+
)}
|
| 536 |
+
|
| 537 |
+
{/* 最近活动 */}
|
| 538 |
+
<Card title="最近活动">
|
| 539 |
+
{selectedStudent.progress?.recent_activities?.length > 0 ? (
|
| 540 |
+
<Timeline>
|
| 541 |
+
{selectedStudent.progress.recent_activities.map((activity, index) => (
|
| 542 |
+
<Timeline.Item key={index}>
|
| 543 |
+
<div>
|
| 544 |
+
<Text strong>{activity.title}</Text>
|
| 545 |
+
<br />
|
| 546 |
+
<Text type="secondary" style={{ fontSize: '12px' }}>
|
| 547 |
+
{activity.time}
|
| 548 |
+
</Text>
|
| 549 |
+
</div>
|
| 550 |
+
</Timeline.Item>
|
| 551 |
+
))}
|
| 552 |
+
</Timeline>
|
| 553 |
+
) : (
|
| 554 |
+
<Empty description="暂无活动记录" />
|
| 555 |
+
)}
|
| 556 |
+
</Card>
|
| 557 |
+
</div>
|
| 558 |
+
)}
|
| 559 |
+
</Drawer>
|
| 560 |
+
</div>
|
| 561 |
+
);
|
| 562 |
+
};
|
| 563 |
+
|
| 564 |
+
export default StudentManagement;
|
frontend/src/pages/teacher/TeacherDashboard.jsx
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 教师端主框架
|
| 3 |
+
* 复用原始 index.html 的侧边栏和导航结构
|
| 4 |
+
*/
|
| 5 |
+
import { useState } from 'react';
|
| 6 |
+
import { Layout, Menu, message } from 'antd';
|
| 7 |
+
import { useNavigate } from 'react-router-dom';
|
| 8 |
+
import { useDispatch, useSelector } from 'react-redux';
|
| 9 |
+
import {
|
| 10 |
+
DashboardOutlined,
|
| 11 |
+
PlusCircleOutlined,
|
| 12 |
+
DatabaseOutlined,
|
| 13 |
+
AppstoreOutlined,
|
| 14 |
+
LogoutOutlined,
|
| 15 |
+
UserOutlined,
|
| 16 |
+
} from '@ant-design/icons';
|
| 17 |
+
import { logout } from '../../store/slices/authSlice';
|
| 18 |
+
import authService from '../../services/authService';
|
| 19 |
+
|
| 20 |
+
// 导入子页面组件
|
| 21 |
+
import Dashboard from './Dashboard';
|
| 22 |
+
import CreateAgent from './CreateAgent';
|
| 23 |
+
import KnowledgeBase from './KnowledgeBase';
|
| 24 |
+
import StudentManagement from './StudentManagement';
|
| 25 |
+
import AgentList from './AgentList';
|
| 26 |
+
|
| 27 |
+
const { Sider, Content } = Layout;
|
| 28 |
+
|
| 29 |
+
const TeacherDashboard = () => {
|
| 30 |
+
const navigate = useNavigate();
|
| 31 |
+
const dispatch = useDispatch();
|
| 32 |
+
const user = useSelector((state) => state.auth.user);
|
| 33 |
+
const [currentSection, setCurrentSection] = useState('dashboard');
|
| 34 |
+
|
| 35 |
+
// 菜单项(对应原始HTML的侧边栏菜单)
|
| 36 |
+
const menuItems = [
|
| 37 |
+
{
|
| 38 |
+
key: 'dashboard',
|
| 39 |
+
icon: <DashboardOutlined />,
|
| 40 |
+
label: '仪表板',
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
key: 'create-agent',
|
| 44 |
+
icon: <PlusCircleOutlined />,
|
| 45 |
+
label: '创建Agent',
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
key: 'knowledge-base',
|
| 49 |
+
icon: <DatabaseOutlined />,
|
| 50 |
+
label: '知识库',
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
key: 'agent-list',
|
| 54 |
+
icon: <AppstoreOutlined />,
|
| 55 |
+
label: 'Agent列表',
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
key: 'students',
|
| 59 |
+
icon: <UserOutlined />,
|
| 60 |
+
label: '学生管理',
|
| 61 |
+
},
|
| 62 |
+
];
|
| 63 |
+
|
| 64 |
+
// 登出处理
|
| 65 |
+
const handleLogout = async () => {
|
| 66 |
+
try {
|
| 67 |
+
await authService.logout();
|
| 68 |
+
dispatch(logout());
|
| 69 |
+
message.success('已退出登录');
|
| 70 |
+
navigate('/login');
|
| 71 |
+
} catch (error) {
|
| 72 |
+
message.error('退出登录失败');
|
| 73 |
+
}
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
// 渲染当前内容区域
|
| 77 |
+
const renderContent = () => {
|
| 78 |
+
switch (currentSection) {
|
| 79 |
+
case 'dashboard':
|
| 80 |
+
return <Dashboard onNavigate={setCurrentSection} />;
|
| 81 |
+
case 'create-agent':
|
| 82 |
+
return <CreateAgent />;
|
| 83 |
+
case 'knowledge-base':
|
| 84 |
+
return <KnowledgeBase />;
|
| 85 |
+
case 'agent-list':
|
| 86 |
+
return <AgentList />;
|
| 87 |
+
case 'students':
|
| 88 |
+
return <StudentManagement />;
|
| 89 |
+
default:
|
| 90 |
+
return <Dashboard onNavigate={setCurrentSection} />;
|
| 91 |
+
}
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
return (
|
| 95 |
+
<Layout style={{ minHeight: '100vh', backgroundColor: '#fafbfc' }}>
|
| 96 |
+
{/* 侧边栏 */}
|
| 97 |
+
<Sider
|
| 98 |
+
width={260}
|
| 99 |
+
style={{
|
| 100 |
+
background: '#ffffff',
|
| 101 |
+
boxShadow: '2px 0 8px rgba(0, 0, 0, 0.04)',
|
| 102 |
+
position: 'fixed',
|
| 103 |
+
height: '100vh',
|
| 104 |
+
left: 0,
|
| 105 |
+
top: 0,
|
| 106 |
+
bottom: 0,
|
| 107 |
+
}}
|
| 108 |
+
>
|
| 109 |
+
{/* Logo区域 */}
|
| 110 |
+
<div
|
| 111 |
+
style={{
|
| 112 |
+
height: '64px',
|
| 113 |
+
display: 'flex',
|
| 114 |
+
alignItems: 'center',
|
| 115 |
+
justifyContent: 'center',
|
| 116 |
+
borderBottom: '1px solid #f0f0f0',
|
| 117 |
+
padding: '0 24px',
|
| 118 |
+
}}
|
| 119 |
+
>
|
| 120 |
+
<div
|
| 121 |
+
style={{
|
| 122 |
+
width: '40px',
|
| 123 |
+
height: '40px',
|
| 124 |
+
borderRadius: '10px',
|
| 125 |
+
background: 'linear-gradient(135deg, #10b981, #14b8a6)',
|
| 126 |
+
display: 'flex',
|
| 127 |
+
alignItems: 'center',
|
| 128 |
+
justifyContent: 'center',
|
| 129 |
+
marginRight: '12px',
|
| 130 |
+
}}
|
| 131 |
+
>
|
| 132 |
+
<svg
|
| 133 |
+
width="24"
|
| 134 |
+
height="24"
|
| 135 |
+
viewBox="0 0 24 24"
|
| 136 |
+
fill="none"
|
| 137 |
+
stroke="white"
|
| 138 |
+
strokeWidth="2"
|
| 139 |
+
strokeLinecap="round"
|
| 140 |
+
strokeLinejoin="round"
|
| 141 |
+
>
|
| 142 |
+
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
| 143 |
+
<path d="M2 17l10 5 10-5" />
|
| 144 |
+
<path d="M2 12l10 5 10-5" />
|
| 145 |
+
</svg>
|
| 146 |
+
</div>
|
| 147 |
+
<div>
|
| 148 |
+
<div
|
| 149 |
+
style={{
|
| 150 |
+
fontSize: '16px',
|
| 151 |
+
fontWeight: 600,
|
| 152 |
+
color: '#1f2937',
|
| 153 |
+
lineHeight: '20px',
|
| 154 |
+
}}
|
| 155 |
+
>
|
| 156 |
+
教育AI助手
|
| 157 |
+
</div>
|
| 158 |
+
<div style={{ fontSize: '12px', color: '#9ca3af' }}>开发平台</div>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
{/* 菜单 */}
|
| 163 |
+
<Menu
|
| 164 |
+
mode="inline"
|
| 165 |
+
selectedKeys={[currentSection]}
|
| 166 |
+
items={menuItems}
|
| 167 |
+
onClick={({ key }) => setCurrentSection(key)}
|
| 168 |
+
style={{
|
| 169 |
+
border: 'none',
|
| 170 |
+
marginTop: '16px',
|
| 171 |
+
padding: '0 12px',
|
| 172 |
+
}}
|
| 173 |
+
/>
|
| 174 |
+
|
| 175 |
+
{/* 登出按钮 */}
|
| 176 |
+
<div
|
| 177 |
+
style={{
|
| 178 |
+
position: 'absolute',
|
| 179 |
+
bottom: 0,
|
| 180 |
+
width: '100%',
|
| 181 |
+
padding: '16px',
|
| 182 |
+
borderTop: '1px solid #f0f0f0',
|
| 183 |
+
}}
|
| 184 |
+
>
|
| 185 |
+
<div
|
| 186 |
+
style={{
|
| 187 |
+
padding: '12px 16px',
|
| 188 |
+
background: '#f9fafb',
|
| 189 |
+
borderRadius: '8px',
|
| 190 |
+
marginBottom: '12px',
|
| 191 |
+
}}
|
| 192 |
+
>
|
| 193 |
+
<div style={{ fontSize: '14px', fontWeight: 500, color: '#1f2937' }}>
|
| 194 |
+
{user?.name || '教师'}
|
| 195 |
+
</div>
|
| 196 |
+
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '2px' }}>
|
| 197 |
+
{user?.email || user?.phone || ''}
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
<button
|
| 201 |
+
onClick={handleLogout}
|
| 202 |
+
style={{
|
| 203 |
+
width: '100%',
|
| 204 |
+
padding: '10px 16px',
|
| 205 |
+
background: 'transparent',
|
| 206 |
+
border: '1px solid #e5e7eb',
|
| 207 |
+
borderRadius: '8px',
|
| 208 |
+
color: '#6b7280',
|
| 209 |
+
fontSize: '14px',
|
| 210 |
+
fontWeight: 500,
|
| 211 |
+
cursor: 'pointer',
|
| 212 |
+
display: 'flex',
|
| 213 |
+
alignItems: 'center',
|
| 214 |
+
justifyContent: 'center',
|
| 215 |
+
transition: 'all 0.2s',
|
| 216 |
+
}}
|
| 217 |
+
onMouseEnter={(e) => {
|
| 218 |
+
e.target.style.borderColor = '#f43f5e';
|
| 219 |
+
e.target.style.color = '#f43f5e';
|
| 220 |
+
}}
|
| 221 |
+
onMouseLeave={(e) => {
|
| 222 |
+
e.target.style.borderColor = '#e5e7eb';
|
| 223 |
+
e.target.style.color = '#6b7280';
|
| 224 |
+
}}
|
| 225 |
+
>
|
| 226 |
+
<LogoutOutlined style={{ marginRight: '8px' }} />
|
| 227 |
+
退出登录
|
| 228 |
+
</button>
|
| 229 |
+
</div>
|
| 230 |
+
</Sider>
|
| 231 |
+
|
| 232 |
+
{/* 主内容区域 */}
|
| 233 |
+
<Layout style={{ marginLeft: 260, backgroundColor: '#fafbfc' }}>
|
| 234 |
+
<Content style={{ padding: '24px', minHeight: '100vh' }}>
|
| 235 |
+
{renderContent()}
|
| 236 |
+
</Content>
|
| 237 |
+
</Layout>
|
| 238 |
+
</Layout>
|
| 239 |
+
);
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
export default TeacherDashboard;
|
frontend/src/services/agentService.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Agent服务 - 对应后端 /api/agent 接口
|
| 3 |
+
* 复用原始 index.html 中的 Agent 相关逻辑
|
| 4 |
+
*/
|
| 5 |
+
import api from './api';
|
| 6 |
+
|
| 7 |
+
const agentService = {
|
| 8 |
+
// 获取Agent列表
|
| 9 |
+
getAgents: async () => {
|
| 10 |
+
return api.get('/agent/list');
|
| 11 |
+
},
|
| 12 |
+
|
| 13 |
+
// 获取Agent详情
|
| 14 |
+
getAgentDetail: async (agentId) => {
|
| 15 |
+
return api.get(`/agent/${agentId}`);
|
| 16 |
+
},
|
| 17 |
+
|
| 18 |
+
// 创建Agent
|
| 19 |
+
createAgent: async (agentData) => {
|
| 20 |
+
return api.post('/agent/create', agentData);
|
| 21 |
+
},
|
| 22 |
+
|
| 23 |
+
// 更新Agent
|
| 24 |
+
updateAgent: async (agentId, agentData) => {
|
| 25 |
+
return api.put(`/agent/${agentId}`, agentData);
|
| 26 |
+
},
|
| 27 |
+
|
| 28 |
+
// 删除Agent
|
| 29 |
+
deleteAgent: async (agentId) => {
|
| 30 |
+
return api.delete(`/agent/${agentId}`);
|
| 31 |
+
},
|
| 32 |
+
|
| 33 |
+
// AI辅助生成工作流
|
| 34 |
+
generateAIWorkflow: async (agentData) => {
|
| 35 |
+
return api.post('/agent/ai-assist', agentData);
|
| 36 |
+
},
|
| 37 |
+
|
| 38 |
+
// 创建分发链接
|
| 39 |
+
createDistribution: async (agentId, expiresIn) => {
|
| 40 |
+
return api.post(`/agent/${agentId}/distribute`, { expires_in: expiresIn });
|
| 41 |
+
},
|
| 42 |
+
|
| 43 |
+
// 获取分发链接列表
|
| 44 |
+
getDistributions: async (agentId) => {
|
| 45 |
+
return api.get(`/agent/${agentId}/distributions`);
|
| 46 |
+
},
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
export default agentService;
|
frontend/src/services/api.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios';
|
| 2 |
+
import { message } from 'antd';
|
| 3 |
+
|
| 4 |
+
// 创建axios实例
|
| 5 |
+
const api = axios.create({
|
| 6 |
+
baseURL: '/api',
|
| 7 |
+
timeout: 30000,
|
| 8 |
+
headers: {
|
| 9 |
+
'Content-Type': 'application/json',
|
| 10 |
+
},
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
// 请求拦截器
|
| 14 |
+
api.interceptors.request.use(
|
| 15 |
+
(config) => {
|
| 16 |
+
const token = localStorage.getItem('token');
|
| 17 |
+
if (token) {
|
| 18 |
+
config.headers.Authorization = `Bearer ${token}`;
|
| 19 |
+
}
|
| 20 |
+
return config;
|
| 21 |
+
},
|
| 22 |
+
(error) => {
|
| 23 |
+
return Promise.reject(error);
|
| 24 |
+
}
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
// 响应拦截器
|
| 28 |
+
api.interceptors.response.use(
|
| 29 |
+
(response) => {
|
| 30 |
+
return response.data;
|
| 31 |
+
},
|
| 32 |
+
(error) => {
|
| 33 |
+
if (error.response) {
|
| 34 |
+
const { status, data } = error.response;
|
| 35 |
+
|
| 36 |
+
switch (status) {
|
| 37 |
+
case 401:
|
| 38 |
+
// Token过期或无效
|
| 39 |
+
localStorage.removeItem('token');
|
| 40 |
+
localStorage.removeItem('user');
|
| 41 |
+
window.location.href = '/login';
|
| 42 |
+
message.error('登录已过期,请重新登录');
|
| 43 |
+
break;
|
| 44 |
+
case 403:
|
| 45 |
+
message.error('没有权限访问该资源');
|
| 46 |
+
break;
|
| 47 |
+
case 404:
|
| 48 |
+
message.error('请求的资源不存在');
|
| 49 |
+
break;
|
| 50 |
+
case 422:
|
| 51 |
+
message.error(data.message || '数据验证失败');
|
| 52 |
+
break;
|
| 53 |
+
case 500:
|
| 54 |
+
message.error('服务器错误,请稍后重试');
|
| 55 |
+
break;
|
| 56 |
+
default:
|
| 57 |
+
message.error(data.message || '请求失败');
|
| 58 |
+
}
|
| 59 |
+
} else if (error.request) {
|
| 60 |
+
message.error('网络错误,请检查网络连接');
|
| 61 |
+
} else {
|
| 62 |
+
message.error('请求配置错误');
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
return Promise.reject(error);
|
| 66 |
+
}
|
| 67 |
+
);
|
| 68 |
+
|
| 69 |
+
export default api;
|
frontend/src/services/authService.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import api from './api';
|
| 2 |
+
|
| 3 |
+
const authService = {
|
| 4 |
+
// 密码登录
|
| 5 |
+
loginWithPassword: async (username, password) => {
|
| 6 |
+
return api.post('/auth/login-password', { username, password });
|
| 7 |
+
},
|
| 8 |
+
|
| 9 |
+
// 邮箱验证码登录
|
| 10 |
+
loginWithEmailCode: async (email, emailCode, role = 'student') => {
|
| 11 |
+
return api.post('/auth/login-email', { email, code: emailCode, role });
|
| 12 |
+
},
|
| 13 |
+
|
| 14 |
+
// 手机验证码登录
|
| 15 |
+
loginWithPhoneCode: async (phone, phoneCode, role = 'student') => {
|
| 16 |
+
return api.post('/auth/login-phone', { phone, code: phoneCode, role });
|
| 17 |
+
},
|
| 18 |
+
|
| 19 |
+
// 发送邮箱验证码
|
| 20 |
+
sendEmailCode: async (email) => {
|
| 21 |
+
return api.post('/auth/send-email-code', { email });
|
| 22 |
+
},
|
| 23 |
+
|
| 24 |
+
// 发送手机验证码
|
| 25 |
+
sendPhoneCode: async (phone) => {
|
| 26 |
+
return api.post('/auth/send-phone-code', { phone });
|
| 27 |
+
},
|
| 28 |
+
|
| 29 |
+
// 注册
|
| 30 |
+
register: async (data) => {
|
| 31 |
+
return api.post('/auth/register', data);
|
| 32 |
+
},
|
| 33 |
+
|
| 34 |
+
// 登出
|
| 35 |
+
logout: async () => {
|
| 36 |
+
return api.post('/auth/logout');
|
| 37 |
+
},
|
| 38 |
+
|
| 39 |
+
// 刷新token
|
| 40 |
+
refreshToken: async () => {
|
| 41 |
+
return api.post('/auth/refresh');
|
| 42 |
+
},
|
| 43 |
+
|
| 44 |
+
// 获取当前用户信息
|
| 45 |
+
getCurrentUser: async () => {
|
| 46 |
+
return api.get('/auth/me');
|
| 47 |
+
},
|
| 48 |
+
|
| 49 |
+
// 修改密码
|
| 50 |
+
changePassword: async (oldPassword, newPassword) => {
|
| 51 |
+
return api.post('/auth/change_password', { oldPassword, newPassword });
|
| 52 |
+
},
|
| 53 |
+
|
| 54 |
+
// 重置密码
|
| 55 |
+
resetPassword: async (token, newPassword) => {
|
| 56 |
+
return api.post('/auth/reset_password', { token, newPassword });
|
| 57 |
+
},
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
export default authService;
|
frontend/src/services/knowledgeService.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 知识库服务 - 对应后端 /api/knowledge 接口
|
| 3 |
+
* 复用原始 index.html 中的知识库相关逻辑
|
| 4 |
+
*/
|
| 5 |
+
import api from './api';
|
| 6 |
+
|
| 7 |
+
const knowledgeService = {
|
| 8 |
+
// 获取知识库列表
|
| 9 |
+
getKnowledgeBases: async () => {
|
| 10 |
+
return api.get('/knowledge/');
|
| 11 |
+
},
|
| 12 |
+
|
| 13 |
+
// 创建知识库
|
| 14 |
+
createKnowledgeBase: async (formData) => {
|
| 15 |
+
return api.post('/knowledge/', formData, {
|
| 16 |
+
headers: {
|
| 17 |
+
'Content-Type': 'multipart/form-data',
|
| 18 |
+
},
|
| 19 |
+
});
|
| 20 |
+
},
|
| 21 |
+
|
| 22 |
+
// 添加文件到知识库
|
| 23 |
+
addFileToKnowledgeBase: async (knowledgeId, formData) => {
|
| 24 |
+
return api.post(`/knowledge/${knowledgeId}/documents`, formData, {
|
| 25 |
+
headers: {
|
| 26 |
+
'Content-Type': 'multipart/form-data',
|
| 27 |
+
},
|
| 28 |
+
});
|
| 29 |
+
},
|
| 30 |
+
|
| 31 |
+
// 删除知识库
|
| 32 |
+
deleteKnowledgeBase: async (knowledgeId) => {
|
| 33 |
+
return api.delete(`/knowledge/${knowledgeId}`);
|
| 34 |
+
},
|
| 35 |
+
|
| 36 |
+
// 删除知识库中的文件
|
| 37 |
+
deleteFile: async (indexId, fileName) => {
|
| 38 |
+
return api.delete(`/knowledge/${indexId}/documents/${fileName}`);
|
| 39 |
+
},
|
| 40 |
+
|
| 41 |
+
// 获取文件处理进度(端点在 app.py 主路由,不在 knowledge blueprint)
|
| 42 |
+
getProgress: async (taskId) => {
|
| 43 |
+
return api.get(`/progress/${taskId}`);
|
| 44 |
+
},
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
export default knowledgeService;
|
frontend/src/store/slices/agentSlice.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createSlice } from '@reduxjs/toolkit';
|
| 2 |
+
|
| 3 |
+
const initialState = {
|
| 4 |
+
agents: [],
|
| 5 |
+
currentAgent: null,
|
| 6 |
+
isLoading: false,
|
| 7 |
+
error: null,
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
const agentSlice = createSlice({
|
| 11 |
+
name: 'agent',
|
| 12 |
+
initialState,
|
| 13 |
+
reducers: {
|
| 14 |
+
setAgents: (state, action) => {
|
| 15 |
+
state.agents = action.payload;
|
| 16 |
+
state.error = null;
|
| 17 |
+
},
|
| 18 |
+
setCurrentAgent: (state, action) => {
|
| 19 |
+
state.currentAgent = action.payload;
|
| 20 |
+
},
|
| 21 |
+
addAgent: (state, action) => {
|
| 22 |
+
state.agents.push(action.payload);
|
| 23 |
+
},
|
| 24 |
+
updateAgent: (state, action) => {
|
| 25 |
+
const index = state.agents.findIndex(a => a.id === action.payload.id);
|
| 26 |
+
if (index !== -1) {
|
| 27 |
+
state.agents[index] = action.payload;
|
| 28 |
+
}
|
| 29 |
+
if (state.currentAgent?.id === action.payload.id) {
|
| 30 |
+
state.currentAgent = action.payload;
|
| 31 |
+
}
|
| 32 |
+
},
|
| 33 |
+
deleteAgent: (state, action) => {
|
| 34 |
+
state.agents = state.agents.filter(a => a.id !== action.payload);
|
| 35 |
+
if (state.currentAgent?.id === action.payload) {
|
| 36 |
+
state.currentAgent = null;
|
| 37 |
+
}
|
| 38 |
+
},
|
| 39 |
+
setLoading: (state, action) => {
|
| 40 |
+
state.isLoading = action.payload;
|
| 41 |
+
},
|
| 42 |
+
setError: (state, action) => {
|
| 43 |
+
state.error = action.payload;
|
| 44 |
+
state.isLoading = false;
|
| 45 |
+
}
|
| 46 |
+
},
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
export const {
|
| 50 |
+
setAgents,
|
| 51 |
+
setCurrentAgent,
|
| 52 |
+
addAgent,
|
| 53 |
+
updateAgent,
|
| 54 |
+
deleteAgent,
|
| 55 |
+
setLoading,
|
| 56 |
+
setError,
|
| 57 |
+
} = agentSlice.actions;
|
| 58 |
+
|
| 59 |
+
export default agentSlice.reducer;
|
frontend/src/store/slices/authSlice.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createSlice } from '@reduxjs/toolkit';
|
| 2 |
+
|
| 3 |
+
const initialState = {
|
| 4 |
+
user: JSON.parse(localStorage.getItem('user')) || null,
|
| 5 |
+
token: localStorage.getItem('token') || null,
|
| 6 |
+
isLoading: false,
|
| 7 |
+
error: null,
|
| 8 |
+
isAuthenticated: !!localStorage.getItem('token'),
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
const authSlice = createSlice({
|
| 12 |
+
name: 'auth',
|
| 13 |
+
initialState,
|
| 14 |
+
reducers: {
|
| 15 |
+
loginStart: (state) => {
|
| 16 |
+
state.isLoading = true;
|
| 17 |
+
state.error = null;
|
| 18 |
+
},
|
| 19 |
+
loginSuccess: (state, action) => {
|
| 20 |
+
state.isLoading = false;
|
| 21 |
+
state.isAuthenticated = true;
|
| 22 |
+
state.user = action.payload.user;
|
| 23 |
+
state.token = action.payload.token;
|
| 24 |
+
state.error = null;
|
| 25 |
+
|
| 26 |
+
// 保存到localStorage
|
| 27 |
+
localStorage.setItem('user', JSON.stringify(action.payload.user));
|
| 28 |
+
localStorage.setItem('token', action.payload.token);
|
| 29 |
+
},
|
| 30 |
+
loginFailure: (state, action) => {
|
| 31 |
+
state.isLoading = false;
|
| 32 |
+
state.isAuthenticated = false;
|
| 33 |
+
state.user = null;
|
| 34 |
+
state.token = null;
|
| 35 |
+
state.error = action.payload;
|
| 36 |
+
},
|
| 37 |
+
logout: (state) => {
|
| 38 |
+
state.user = null;
|
| 39 |
+
state.token = null;
|
| 40 |
+
state.isAuthenticated = false;
|
| 41 |
+
state.error = null;
|
| 42 |
+
|
| 43 |
+
// 清除localStorage
|
| 44 |
+
localStorage.removeItem('user');
|
| 45 |
+
localStorage.removeItem('token');
|
| 46 |
+
},
|
| 47 |
+
clearError: (state) => {
|
| 48 |
+
state.error = null;
|
| 49 |
+
},
|
| 50 |
+
updateUser: (state, action) => {
|
| 51 |
+
state.user = { ...state.user, ...action.payload };
|
| 52 |
+
localStorage.setItem('user', JSON.stringify(state.user));
|
| 53 |
+
}
|
| 54 |
+
},
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
export const {
|
| 58 |
+
loginStart,
|
| 59 |
+
loginSuccess,
|
| 60 |
+
loginFailure,
|
| 61 |
+
logout,
|
| 62 |
+
clearError,
|
| 63 |
+
updateUser,
|
| 64 |
+
} = authSlice.actions;
|
| 65 |
+
|
| 66 |
+
export default authSlice.reducer;
|
| 67 |
+
|
| 68 |
+
// Selectors
|
| 69 |
+
export const selectUser = (state) => state.auth.user;
|
| 70 |
+
export const selectIsAuthenticated = (state) => state.auth.isAuthenticated;
|
| 71 |
+
export const selectAuthLoading = (state) => state.auth.isLoading;
|
| 72 |
+
export const selectAuthError = (state) => state.auth.error;
|
| 73 |
+
export const selectUserRole = (state) => state.auth.user?.role;
|
frontend/src/store/slices/chatSlice.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createSlice } from '@reduxjs/toolkit';
|
| 2 |
+
|
| 3 |
+
const initialState = {
|
| 4 |
+
messages: [],
|
| 5 |
+
isStreaming: false,
|
| 6 |
+
currentStreamMessage: '',
|
| 7 |
+
executionContext: null,
|
| 8 |
+
isExecuting: false,
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
const chatSlice = createSlice({
|
| 12 |
+
name: 'chat',
|
| 13 |
+
initialState,
|
| 14 |
+
reducers: {
|
| 15 |
+
addMessage: (state, action) => {
|
| 16 |
+
state.messages.push(action.payload);
|
| 17 |
+
},
|
| 18 |
+
updateMessage: (state, action) => {
|
| 19 |
+
const { id, content } = action.payload;
|
| 20 |
+
const message = state.messages.find(m => m.id === id);
|
| 21 |
+
if (message) {
|
| 22 |
+
message.content = content;
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
clearMessages: (state) => {
|
| 26 |
+
state.messages = [];
|
| 27 |
+
state.currentStreamMessage = '';
|
| 28 |
+
},
|
| 29 |
+
setStreaming: (state, action) => {
|
| 30 |
+
state.isStreaming = action.payload;
|
| 31 |
+
},
|
| 32 |
+
setStreamMessage: (state, action) => {
|
| 33 |
+
state.currentStreamMessage = action.payload;
|
| 34 |
+
},
|
| 35 |
+
appendStreamMessage: (state, action) => {
|
| 36 |
+
state.currentStreamMessage += action.payload;
|
| 37 |
+
},
|
| 38 |
+
setExecutionContext: (state, action) => {
|
| 39 |
+
state.executionContext = action.payload;
|
| 40 |
+
},
|
| 41 |
+
setExecuting: (state, action) => {
|
| 42 |
+
state.isExecuting = action.payload;
|
| 43 |
+
}
|
| 44 |
+
},
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
export const {
|
| 48 |
+
addMessage,
|
| 49 |
+
updateMessage,
|
| 50 |
+
clearMessages,
|
| 51 |
+
setStreaming,
|
| 52 |
+
setStreamMessage,
|
| 53 |
+
appendStreamMessage,
|
| 54 |
+
setExecutionContext,
|
| 55 |
+
setExecuting,
|
| 56 |
+
} = chatSlice.actions;
|
| 57 |
+
|
| 58 |
+
export default chatSlice.reducer;
|
frontend/src/store/slices/uiSlice.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createSlice } from '@reduxjs/toolkit';
|
| 2 |
+
|
| 3 |
+
const initialState = {
|
| 4 |
+
sidebarOpen: true,
|
| 5 |
+
theme: 'light',
|
| 6 |
+
activeModal: null,
|
| 7 |
+
notifications: [],
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
const uiSlice = createSlice({
|
| 11 |
+
name: 'ui',
|
| 12 |
+
initialState,
|
| 13 |
+
reducers: {
|
| 14 |
+
toggleSidebar: (state) => {
|
| 15 |
+
state.sidebarOpen = !state.sidebarOpen;
|
| 16 |
+
},
|
| 17 |
+
setSidebarOpen: (state, action) => {
|
| 18 |
+
state.sidebarOpen = action.payload;
|
| 19 |
+
},
|
| 20 |
+
setTheme: (state, action) => {
|
| 21 |
+
state.theme = action.payload;
|
| 22 |
+
},
|
| 23 |
+
openModal: (state, action) => {
|
| 24 |
+
state.activeModal = action.payload;
|
| 25 |
+
},
|
| 26 |
+
closeModal: (state) => {
|
| 27 |
+
state.activeModal = null;
|
| 28 |
+
},
|
| 29 |
+
addNotification: (state, action) => {
|
| 30 |
+
state.notifications.push({
|
| 31 |
+
id: Date.now(),
|
| 32 |
+
...action.payload,
|
| 33 |
+
});
|
| 34 |
+
},
|
| 35 |
+
removeNotification: (state, action) => {
|
| 36 |
+
state.notifications = state.notifications.filter(
|
| 37 |
+
n => n.id !== action.payload
|
| 38 |
+
);
|
| 39 |
+
},
|
| 40 |
+
clearNotifications: (state) => {
|
| 41 |
+
state.notifications = [];
|
| 42 |
+
}
|
| 43 |
+
},
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
export const {
|
| 47 |
+
toggleSidebar,
|
| 48 |
+
setSidebarOpen,
|
| 49 |
+
setTheme,
|
| 50 |
+
openModal,
|
| 51 |
+
closeModal,
|
| 52 |
+
addNotification,
|
| 53 |
+
removeNotification,
|
| 54 |
+
clearNotifications,
|
| 55 |
+
} = uiSlice.actions;
|
| 56 |
+
|
| 57 |
+
export default uiSlice.reducer;
|