feat: 实现 KPL 系统功能改进计划
Some checks failed
continuous-integration/drone/push Build is failing

1. 课程学习进度追踪
   - 新增 UserCourseProgress 和 UserMaterialProgress 模型
   - 新增 /api/v1/progress/* 进度追踪 API
   - 更新 admin.py 使用真实课程完成率数据

2. 路由权限检查完善
   - 新增前端 permissionChecker.ts 权限检查工具
   - 更新 router/guard.ts 实现团队和课程权限验证
   - 新增后端 permission_service.py

3. AI 陪练音频转文本
   - 新增 speech_recognition.py 语音识别服务
   - 新增 /api/v1/speech/* API
   - 更新 ai-practice-coze.vue 支持语音输入

4. 双人对练报告生成
   - 更新 practice_room_service.py 添加报告生成功能
   - 新增 /rooms/{room_code}/report API
   - 更新 duo-practice-report.vue 调用真实 API

5. 学习提醒推送
   - 新增 notification_service.py 通知服务
   - 新增 scheduler_service.py 定时任务服务
   - 支持钉钉、企微、站内消息推送

6. 智能学习推荐
   - 新增 recommendation_service.py 推荐服务
   - 新增 /api/v1/recommendations/* API
   - 支持错题、能力、进度、热门多维度推荐

7. 安全问题修复
   - DEBUG 默认值改为 False
   - 添加 SECRET_KEY 安全警告
   - 新增 check_security_settings() 检查函数

8. 证书 PDF 生成
   - 更新 certificate_service.py 添加 PDF 生成
   - 添加 weasyprint、Pillow、qrcode 依赖
   - 更新下载 API 支持 PDF 和 PNG 格式
This commit is contained in:
yuliang_guo
2026-01-30 14:22:35 +08:00
parent 9793013a56
commit 64f5d567fa
66 changed files with 18067 additions and 14330 deletions

144
.gitignore vendored
View File

@@ -1,72 +1,72 @@
# ================================ # ================================
# AgentWD 项目 .gitignore # AgentWD 项目 .gitignore
# ================================ # ================================
# ---------------- # ----------------
# 环境配置(敏感) # 环境配置(敏感)
# ---------------- # ----------------
.env .env
.env.local .env.local
.env.*.local .env.*.local
# 允许提交非敏感的环境配置(用于 Vite 构建) # 允许提交非敏感的环境配置(用于 Vite 构建)
# .env.production # .env.production
# .env.staging # .env.staging
# ---------------- # ----------------
# 依赖目录 # 依赖目录
# ---------------- # ----------------
node_modules/ node_modules/
.pnpm-store/ .pnpm-store/
__pycache__/ __pycache__/
*.pyc *.pyc
.venv/ .venv/
venv/ venv/
# ---------------- # ----------------
# 构建产物 # 构建产物
# ---------------- # ----------------
dist/ dist/
build/ build/
.output/ .output/
*.egg-info/ *.egg-info/
# ---------------- # ----------------
# IDE 配置 # IDE 配置
# ---------------- # ----------------
.vscode/ .vscode/
.idea/ .idea/
*.swp *.swp
*.swo *.swo
.DS_Store .DS_Store
# ---------------- # ----------------
# 日志文件 # 日志文件
# ---------------- # ----------------
logs/ logs/
*.log *.log
npm-debug.log* npm-debug.log*
pnpm-debug.log* pnpm-debug.log*
# ---------------- # ----------------
# 测试覆盖率 # 测试覆盖率
# ---------------- # ----------------
coverage/ coverage/
.nyc_output/ .nyc_output/
# ---------------- # ----------------
# n8n 敏感信息 # n8n 敏感信息
# ---------------- # ----------------
n8n-workflows/*-credentials.json n8n-workflows/*-credentials.json
n8n-workflows/credentials.json n8n-workflows/credentials.json
# ---------------- # ----------------
# 历史备份(.history插件 # 历史备份(.history插件
# ---------------- # ----------------
.history/ .history/
# ---------------- # ----------------
# 临时文件 # 临时文件
# ---------------- # ----------------
*.tmp *.tmp
*.temp *.temp
.cache/ .cache/

View File

@@ -1,261 +1,261 @@
# KPL 考培练系统 功能迭代更新日志 # KPL 考培练系统 功能迭代更新日志
**日期**: 2026-01-29 **日期**: 2026-01-29
**版本**: v1.5.0 **版本**: v1.5.0
--- ---
## 一、奖章条件优化 ## 一、奖章条件优化
### 修复内容 ### 修复内容
- 修复 `badge_service.py` 中统计查询的 SQL 语法问题 - 修复 `badge_service.py` 中统计查询的 SQL 语法问题
- 将非标准 `func.if_` 替换为 SQLAlchemy 标准 `case` 语句 - 将非标准 `func.if_` 替换为 SQLAlchemy 标准 `case` 语句
- 优化考试统计逻辑:通过数、满分数、优秀数分开查询 - 优化考试统计逻辑:通过数、满分数、优秀数分开查询
- 添加 `func.coalesce` 处理空值 - 添加 `func.coalesce` 处理空值
### 新增功能 ### 新增功能
- `check_badges_by_category()` - 按类别检查奖章 - `check_badges_by_category()` - 按类别检查奖章
- `check_exam_badges()` - 考试后触发 - `check_exam_badges()` - 考试后触发
- `check_practice_badges()` - 练习后触发 - `check_practice_badges()` - 练习后触发
- `check_streak_badges()` - 签到后触发 - `check_streak_badges()` - 签到后触发
- `check_level_badges()` - 等级变化后触发 - `check_level_badges()` - 等级变化后触发
### 文件变更 ### 文件变更
- `backend/app/services/badge_service.py` - `backend/app/services/badge_service.py`
--- ---
## 二、移动端适配 ## 二、移动端适配
### 适配页面 ### 适配页面
| 页面 | 文件 | 适配要点 | | 页面 | 文件 | 适配要点 |
|------|------|----------| |------|------|----------|
| 登录页 | `views/login/index.vue` | 表单全宽、背景动画隐藏、钉钉安全区域 | | 登录页 | `views/login/index.vue` | 表单全宽、背景动画隐藏、钉钉安全区域 |
| 课程中心 | `views/trainee/course-center.vue` | 单列卡片、分类横向滚动、操作按钮网格化 | | 课程中心 | `views/trainee/course-center.vue` | 单列卡片、分类横向滚动、操作按钮网格化 |
| 课程详情 | `views/trainee/course-detail.vue` | 侧边栏折叠、视频自适应、工具栏响应式 | | 课程详情 | `views/trainee/course-detail.vue` | 侧边栏折叠、视频自适应、工具栏响应式 |
| 考试页面 | `views/exam/practice.vue` | 选项垂直排列、按钮放大、弹窗全屏 | | 考试页面 | `views/exam/practice.vue` | 选项垂直排列、按钮放大、弹窗全屏 |
| 成长路径 | `views/trainee/growth-path.vue` | 雷达图缩放、卡片堆叠、统计区折叠 | | 成长路径 | `views/trainee/growth-path.vue` | 雷达图缩放、卡片堆叠、统计区折叠 |
| 排行榜 | `views/trainee/leaderboard.vue` | 列表简化、签到按钮全宽 | | 排行榜 | `views/trainee/leaderboard.vue` | 列表简化、签到按钮全宽 |
### 技术方案 ### 技术方案
- 使用 `@media (max-width: 768px)``@media (max-width: 480px)` 断点 - 使用 `@media (max-width: 768px)``@media (max-width: 480px)` 断点
- 钉钉环境使用 `env(safe-area-inset-*)` 适配安全区域 - 钉钉环境使用 `env(safe-area-inset-*)` 适配安全区域
--- ---
## 三、证书系统 ## 三、证书系统
### 数据库设计 ### 数据库设计
```sql ```sql
-- 证书模板表 -- 证书模板表
CREATE TABLE certificate_templates ( CREATE TABLE certificate_templates (
id INT PRIMARY KEY AUTO_INCREMENT, id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
type ENUM('course', 'exam', 'achievement') NOT NULL, type ENUM('course', 'exam', 'achievement') NOT NULL,
background_url VARCHAR(500), background_url VARCHAR(500),
template_html TEXT, template_html TEXT,
template_style TEXT, template_style TEXT,
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
... ...
); );
-- 用户证书表 -- 用户证书表
CREATE TABLE user_certificates ( CREATE TABLE user_certificates (
id INT PRIMARY KEY AUTO_INCREMENT, id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL, user_id INT NOT NULL,
template_id INT NOT NULL, template_id INT NOT NULL,
certificate_no VARCHAR(50) UNIQUE NOT NULL, certificate_no VARCHAR(50) UNIQUE NOT NULL,
title VARCHAR(200) NOT NULL, title VARCHAR(200) NOT NULL,
... ...
); );
``` ```
### 后端实现 ### 后端实现
- **模型**: `CertificateTemplate`, `UserCertificate`, `CertificateType` - **模型**: `CertificateTemplate`, `UserCertificate`, `CertificateType`
- **服务**: `CertificateService` - **服务**: `CertificateService`
- `issue_course_certificate()` - 颁发课程证书 - `issue_course_certificate()` - 颁发课程证书
- `issue_exam_certificate()` - 颁发考试证书 - `issue_exam_certificate()` - 颁发考试证书
- `issue_achievement_certificate()` - 颁发成就证书 - `issue_achievement_certificate()` - 颁发成就证书
- `generate_certificate_image()` - 生成分享图片 - `generate_certificate_image()` - 生成分享图片
- `get_certificate_by_no()` - 验证证书 - `get_certificate_by_no()` - 验证证书
### API 端点 ### API 端点
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| GET | `/certificates/me` | 我的证书列表 | | GET | `/certificates/me` | 我的证书列表 |
| GET | `/certificates/{id}` | 证书详情 | | GET | `/certificates/{id}` | 证书详情 |
| GET | `/certificates/{id}/image` | 获取分享图片 | | GET | `/certificates/{id}/image` | 获取分享图片 |
| GET | `/certificates/{id}/download` | 下载证书 | | GET | `/certificates/{id}/download` | 下载证书 |
| GET | `/certificates/verify/{no}` | 验证证书(无需登录) | | GET | `/certificates/verify/{no}` | 验证证书(无需登录) |
| POST | `/certificates/issue/course` | 颁发课程证书 | | POST | `/certificates/issue/course` | 颁发课程证书 |
| POST | `/certificates/issue/exam` | 颁发考试证书 | | POST | `/certificates/issue/exam` | 颁发考试证书 |
### 前端实现 ### 前端实现
- **API**: `frontend/src/api/certificate.ts` - **API**: `frontend/src/api/certificate.ts`
- **页面**: `frontend/src/views/trainee/my-certificates.vue` - **页面**: `frontend/src/views/trainee/my-certificates.vue`
- **功能**: 证书列表、分类筛选、预览、分享、下载 - **功能**: 证书列表、分类筛选、预览、分享、下载
### 文件变更 ### 文件变更
- `backend/migrations/add_certificate_system.sql` (新增) - `backend/migrations/add_certificate_system.sql` (新增)
- `backend/app/models/certificate.py` (新增) - `backend/app/models/certificate.py` (新增)
- `backend/app/services/certificate_service.py` (新增) - `backend/app/services/certificate_service.py` (新增)
- `backend/app/api/v1/endpoints/certificate.py` (新增) - `backend/app/api/v1/endpoints/certificate.py` (新增)
- `backend/app/models/__init__.py` (修改) - `backend/app/models/__init__.py` (修改)
- `backend/app/api/v1/__init__.py` (修改) - `backend/app/api/v1/__init__.py` (修改)
- `frontend/src/api/certificate.ts` (新增) - `frontend/src/api/certificate.ts` (新增)
- `frontend/src/views/trainee/my-certificates.vue` (新增) - `frontend/src/views/trainee/my-certificates.vue` (新增)
- `frontend/src/router/index.ts` (修改) - `frontend/src/router/index.ts` (修改)
--- ---
## 四、数据大屏 ## 四、数据大屏
### 数据指标 ### 数据指标
| 类别 | 指标 | | 类别 | 指标 |
|------|------| |------|------|
| 概览 | 总学员数、今日活跃、周活跃、月活跃、总学习时长、签到率 | | 概览 | 总学员数、今日活跃、周活跃、月活跃、总学习时长、签到率 |
| 考试 | 总次数、通过率、平均分、满分人数 | | 考试 | 总次数、通过率、平均分、满分人数 |
| 部门 | 成员数、通过率、平均学习时长、平均等级 | | 部门 | 成员数、通过率、平均学习时长、平均等级 |
| 趋势 | 近7天活跃用户、学习时长、考试次数 | | 趋势 | 近7天活跃用户、学习时长、考试次数 |
| 分布 | 1-10级用户数量分布 | | 分布 | 1-10级用户数量分布 |
| 动态 | 最新学习活动实时滚动 | | 动态 | 最新学习活动实时滚动 |
### 后端实现 ### 后端实现
- **服务**: `DashboardService` - **服务**: `DashboardService`
- `get_enterprise_overview()` - 企业级概览 - `get_enterprise_overview()` - 企业级概览
- `get_department_comparison()` - 部门对比 - `get_department_comparison()` - 部门对比
- `get_learning_trend()` - 学习趋势 - `get_learning_trend()` - 学习趋势
- `get_level_distribution()` - 等级分布 - `get_level_distribution()` - 等级分布
- `get_realtime_activities()` - 实时动态 - `get_realtime_activities()` - 实时动态
- `get_team_dashboard()` - 团队级数据 - `get_team_dashboard()` - 团队级数据
- `get_course_ranking()` - 课程热度排行 - `get_course_ranking()` - 课程热度排行
### API 端点 ### API 端点
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| GET | `/dashboard/enterprise/overview` | 企业概览 | | GET | `/dashboard/enterprise/overview` | 企业概览 |
| GET | `/dashboard/enterprise/departments` | 部门对比 | | GET | `/dashboard/enterprise/departments` | 部门对比 |
| GET | `/dashboard/enterprise/trend` | 学习趋势 | | GET | `/dashboard/enterprise/trend` | 学习趋势 |
| GET | `/dashboard/enterprise/level-distribution` | 等级分布 | | GET | `/dashboard/enterprise/level-distribution` | 等级分布 |
| GET | `/dashboard/enterprise/activities` | 实时动态 | | GET | `/dashboard/enterprise/activities` | 实时动态 |
| GET | `/dashboard/enterprise/course-ranking` | 课程排行 | | GET | `/dashboard/enterprise/course-ranking` | 课程排行 |
| GET | `/dashboard/team` | 团队数据 | | GET | `/dashboard/team` | 团队数据 |
| GET | `/dashboard/all` | 完整数据(一次性) | | GET | `/dashboard/all` | 完整数据(一次性) |
### 前端实现 ### 前端实现
- **API**: `frontend/src/api/dashboard.ts` - **API**: `frontend/src/api/dashboard.ts`
- **页面**: `frontend/src/views/admin/data-dashboard.vue` - **页面**: `frontend/src/views/admin/data-dashboard.vue`
- **图表**: ECharts (横向柱状图、折线图、仪表盘、饼图) - **图表**: ECharts (横向柱状图、折线图、仪表盘、饼图)
- **功能**: 全屏模式、5分钟自动刷新、响应式布局 - **功能**: 全屏模式、5分钟自动刷新、响应式布局
### 文件变更 ### 文件变更
- `backend/app/services/dashboard_service.py` (新增) - `backend/app/services/dashboard_service.py` (新增)
- `backend/app/api/v1/endpoints/dashboard.py` (新增) - `backend/app/api/v1/endpoints/dashboard.py` (新增)
- `backend/app/api/v1/__init__.py` (修改) - `backend/app/api/v1/__init__.py` (修改)
- `frontend/src/api/dashboard.ts` (新增) - `frontend/src/api/dashboard.ts` (新增)
- `frontend/src/views/admin/data-dashboard.vue` (新增) - `frontend/src/views/admin/data-dashboard.vue` (新增)
- `frontend/src/router/index.ts` (修改) - `frontend/src/router/index.ts` (修改)
--- ---
## 部署说明 ## 部署说明
### 数据库迁移 ### 数据库迁移
需执行以下 SQL 脚本: 需执行以下 SQL 脚本:
```bash ```bash
# 证书系统迁移 # 证书系统迁移
mysql -u root -p kaopeilian < backend/migrations/add_certificate_system.sql mysql -u root -p kaopeilian < backend/migrations/add_certificate_system.sql
``` ```
### 依赖安装 ### 依赖安装
后端新增依赖(用于证书图片生成): 后端新增依赖(用于证书图片生成):
```bash ```bash
pip install Pillow qrcode pip install Pillow qrcode
``` ```
### 路由变更 ### 路由变更
新增前端路由: 新增前端路由:
- `/trainee/my-certificates` - 我的证书 - `/trainee/my-certificates` - 我的证书
- `/manager/data-dashboard` - 数据大屏 - `/manager/data-dashboard` - 数据大屏
--- ---
## 五、错误提示优化(下午更新) ## 五、错误提示优化(下午更新)
### 课程名重复错误优化 ### 课程名重复错误优化
**问题**:创建课程时名称重复返回 409 错误,前端提示不明确 **问题**:创建课程时名称重复返回 409 错误,前端提示不明确
**修复内容** **修复内容**
1. **后端** `course_service.py`:课程名重复时返回 `existing_id``existing_name` 1. **后端** `course_service.py`:课程名重复时返回 `existing_id``existing_name`
2. **前端** `edit-course.vue`:检测 409 错误后弹出确认框 2. **前端** `edit-course.vue`:检测 409 错误后弹出确认框
- "查看已有课程" → 跳转到已存在的课程 - "查看已有课程" → 跳转到已存在的课程
- "修改名称" → 留在当前页面 - "修改名称" → 留在当前页面
### 通用错误处理增强 ### 通用错误处理增强
- `errorHandler.ts` 新增 409 冲突错误处理 - `errorHandler.ts` 新增 409 冲突错误处理
- 新增工具函数:`isConflictError()`, `getConflictDetail()`, `getConflictMessage()` - 新增工具函数:`isConflictError()`, `getConflictDetail()`, `getConflictMessage()`
### 其他页面错误提示优化 ### 其他页面错误提示优化
- `position-management.vue`:岗位创建/编辑错误提取详细信息 - `position-management.vue`:岗位创建/编辑错误提取详细信息
- `user-management.vue`:用户编辑错误提取详细信息 - `user-management.vue`:用户编辑错误提取详细信息
### 文件变更 ### 文件变更
- `backend/app/services/course_service.py` (修改) - `backend/app/services/course_service.py` (修改)
- `frontend/src/utils/errorHandler.ts` (修改) - `frontend/src/utils/errorHandler.ts` (修改)
- `frontend/src/views/manager/edit-course.vue` (修改) - `frontend/src/views/manager/edit-course.vue` (修改)
- `frontend/src/views/admin/position-management.vue` (修改) - `frontend/src/views/admin/position-management.vue` (修改)
- `frontend/src/views/admin/user-management.vue` (修改) - `frontend/src/views/admin/user-management.vue` (修改)
--- ---
## 六、部署问题修复(下午更新) ## 六、部署问题修复(下午更新)
### 后端导入路径修复 ### 后端导入路径修复
1. `certificate.py` 模型:`from app.core.database import Base``from app.models.base import Base` 1. `certificate.py` 模型:`from app.core.database import Base``from app.models.base import Base`
2. `certificate.py` API`from app.core.database import get_db``from app.core.deps import get_db` 2. `certificate.py` API`from app.core.database import get_db``from app.core.deps import get_db`
3. `dashboard.py` API同上 3. `dashboard.py` API同上
4. 合并 `get_current_user` 导入到 `app.core.deps` 4. 合并 `get_current_user` 导入到 `app.core.deps`
### 依赖安装 ### 依赖安装
```bash ```bash
docker exec kpl-backend-dev pip install Pillow qrcode docker exec kpl-backend-dev pip install Pillow qrcode
``` ```
### 前端构建同步问题 ### 前端构建同步问题
- 问题:构建输出到 `/root/aiedu/frontend/dist/`,但容器挂载的是 `/root/aiedu/dist-test/` - 问题:构建输出到 `/root/aiedu/frontend/dist/`,但容器挂载的是 `/root/aiedu/dist-test/`
- 解决:构建后需手动同步 `cp -r /root/aiedu/frontend/dist/* /root/aiedu/dist-test/` - 解决:构建后需手动同步 `cp -r /root/aiedu/frontend/dist/* /root/aiedu/dist-test/`
--- ---
## 七、钉钉免密登录问题修复(下午更新) ## 七、钉钉免密登录问题修复(下午更新)
### 问题现象 ### 问题现象
- 钉钉环境打开应用后显示"没有访问此页面的权限" - 钉钉环境打开应用后显示"没有访问此页面的权限"
- 后端日志显示登录实际成功 - 后端日志显示登录实际成功
### 问题原因 ### 问题原因
登录成功后读取 URL 中的 `redirect` 参数跳转,但该参数指向用户无权限的页面(如 /admin/* 登录成功后读取 URL 中的 `redirect` 参数跳转,但该参数指向用户无权限的页面(如 /admin/*
### 修复内容 ### 修复内容
`login/index.vue`:登录成功后检查 redirect 目标是否有权限 `login/index.vue`:登录成功后检查 redirect 目标是否有权限
```javascript ```javascript
// 检查 redirect 目标是否有权限访问 // 检查 redirect 目标是否有权限访问
if ((redirect.startsWith('/admin') && userRole !== 'admin') || if ((redirect.startsWith('/admin') && userRole !== 'admin') ||
(redirect.startsWith('/manager') && !['admin', 'manager'].includes(userRole))) { (redirect.startsWith('/manager') && !['admin', 'manager'].includes(userRole))) {
redirect = defaultRoute // 改为跳转到默认页面 redirect = defaultRoute // 改为跳转到默认页面
} }
``` ```
### 调试工具 ### 调试工具
- 钉钉环境自动启用 vConsole`main.ts` 中根据 UA 判断) - 钉钉环境自动启用 vConsole`main.ts` 中根据 UA 判断)
- 依赖:`npm install vconsole` - 依赖:`npm install vconsole`
--- ---
## 待办事项 ## 待办事项
- [ ] 证书 PDF 生成(需安装 weasyprint - [ ] 证书 PDF 生成(需安装 weasyprint
- [ ] 课程完成进度追踪user_course_progress 表) - [ ] 课程完成进度追踪user_course_progress 表)
- [ ] 数据大屏数据缓存优化 - [ ] 数据大屏数据缓存优化
- [ ] 钉钉环境下底部导航适配 - [ ] 钉钉环境下底部导航适配
- [ ] 移除 vConsole 调试代码(问题确认解决后) - [ ] 移除 vConsole 调试代码(问题确认解决后)

48
backend/.env.production Normal file
View File

@@ -0,0 +1,48 @@
APP_NAME="考培练系统后端"
APP_VERSION="1.0.0"
DEBUG=false
HOST=0.0.0.0
PORT=8000
DATABASE_URL=mysql+aiomysql://root:Kaopeilian2025%21%40%23@mysql:3306/kaopeilian?charset=utf8mb4
REDIS_URL=redis://redis:6379/0
SECRET_KEY=66a6a6a2f1d84c2c8b3dbf02b7a2e2b8b7d88b1e7d1c4f8d9a2c6e1f4b9a7c3d
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
CORS_ORIGINS=["https://aiedu.ireborn.com.cn", "http://aiedu.ireborn.com.cn"]
LOG_LEVEL=INFO
LOG_FORMAT=json
UPLOAD_MAX_SIZE=10485760
UPLOAD_ALLOWED_TYPES=["image/jpeg", "image/png", "application/pdf", "audio/mpeg", "audio/wav", "audio/webm"]
UPLOAD_DIR=uploads
# Coze OAuth配置
COZE_OAUTH_CLIENT_ID=1114009328887
COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I
COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem
COZE_PRACTICE_BOT_ID=7560643598174683145
# Dify API 配置 (测试环境)
# 播课工作流配置 (测试-06-播课工作流)
COZE_BROADCAST_WORKFLOW_ID=7577983042284486666
COZE_BROADCAST_SPACE_ID=7474971491470688296
COZE_BROADCAST_BOT_ID=7560643598174683145
# AI 服务配置遵循瑞小美AI接入规范 - 多 Key 策略)
AI_PRIMARY_API_KEY=sk-V9QfxYscJzD54ZRIDyAvnd4Lew4IdtPUQqwDQ0swNkIYObxT
AI_ANTHROPIC_API_KEY=sk-wNAkUW3zwBeKBN8EeK5KnXXgOixnW5rhZmKolo8fBQuHepkX
AI_PRIMARY_BASE_URL=https://4sapi.com/v1
AI_FALLBACK_API_KEY=
AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1
AI_DEFAULT_MODEL=gemini-3-flash-preview
AI_TIMEOUT=120
# 租户配置(用于多租户部署)
TENANT_CODE=demo
# 管理库连接配置(用于从 tenant_configs 表读取配置)
ADMIN_DB_HOST=prod-mysql
ADMIN_DB_PORT=3306
ADMIN_DB_USER=root
ADMIN_DB_PASSWORD=ProdMySQL2025!@#
ADMIN_DB_NAME=kaopeilian_admin

View File

@@ -116,5 +116,14 @@ api_router.include_router(certificate_router, prefix="/certificates", tags=["cer
# dashboard_router 数据大屏路由 # dashboard_router 数据大屏路由
from .endpoints.dashboard import router as dashboard_router from .endpoints.dashboard import router as dashboard_router
api_router.include_router(dashboard_router, prefix="/dashboard", tags=["dashboard"]) api_router.include_router(dashboard_router, prefix="/dashboard", tags=["dashboard"])
# progress_router 学习进度追踪路由
from .endpoints.progress import router as progress_router
api_router.include_router(progress_router, prefix="/progress", tags=["progress"])
# speech_router 语音识别路由
from .endpoints.speech import router as speech_router
api_router.include_router(speech_router, prefix="/speech", tags=["speech"])
# recommendation_router 智能推荐路由
from .endpoints.recommendation import router as recommendation_router
api_router.include_router(recommendation_router, prefix="/recommendations", tags=["recommendations"])
__all__ = ["api_router"] __all__ = ["api_router"]

View File

@@ -11,6 +11,7 @@ from sqlalchemy import select, func
from app.core.deps import get_current_active_user as get_current_user, get_db from app.core.deps import get_current_active_user as get_current_user, get_db
from app.models.user import User from app.models.user import User
from app.models.course import Course, CourseStatus from app.models.course import Course, CourseStatus
from app.models.user_course_progress import UserCourseProgress, ProgressStatus
from app.schemas.base import ResponseModel from app.schemas.base import ResponseModel
router = APIRouter(prefix="/admin") router = APIRouter(prefix="/admin")
@@ -61,18 +62,32 @@ async def get_dashboard_stats(
.where(Course.status == CourseStatus.PUBLISHED) .where(Course.status == CourseStatus.PUBLISHED)
) )
# TODO: 完成的课程数需要根据用户课程进度表计算 # 根据用户课程进度表计算完成的课程学习记录数
completed_courses = 0 # 暂时设为0 completed_courses = await db.scalar(
select(func.count(UserCourseProgress.id))
.where(UserCourseProgress.status == ProgressStatus.COMPLETED.value)
) or 0
# 考试统计(如果有考试表的话) # 考试统计(如果有考试表的话)
total_exams = 0 total_exams = 0
avg_score = 0.0 avg_score = 0.0
pass_rate = "0%" pass_rate = "0%"
# 学习时长统计(如果有学习记录表的话) # 学习时长统计 - 从用户课程进度表获取
total_learning_hours = 0 total_study_seconds = await db.scalar(
avg_learning_hours = 0.0 select(func.coalesce(func.sum(UserCourseProgress.total_study_time), 0))
active_rate = "0%" ) or 0
total_learning_hours = round(total_study_seconds / 3600)
# 平均学习时长(每个活跃用户)
active_learners = await db.scalar(
select(func.count(func.distinct(UserCourseProgress.user_id)))
.where(UserCourseProgress.status != ProgressStatus.NOT_STARTED.value)
) or 0
avg_learning_hours = round(total_study_seconds / 3600 / max(active_learners, 1), 1)
# 活跃率 = 有学习记录的用户 / 总用户
active_rate = f"{round(active_learners / max(total_users, 1) * 100)}%"
# 构建响应数据 # 构建响应数据
stats = { stats = {
@@ -195,10 +210,28 @@ async def get_course_completion_data(
for course_name, course_id in courses: for course_name, course_id in courses:
course_names.append(course_name) course_names.append(course_name)
# TODO: 根据用户课程进度表计算完成率 # 根据用户课程进度表计算完成率
# 这里暂时生成模拟数据 # 统计该课程的完成用户数和总学习用户数
import random stats_result = await db.execute(
completion_rate = random.randint(60, 95) select(
func.count(UserCourseProgress.id).label('total'),
func.sum(
func.case(
(UserCourseProgress.status == ProgressStatus.COMPLETED.value, 1),
else_=0
)
).label('completed')
).where(UserCourseProgress.course_id == course_id)
)
stats = stats_result.one()
total_learners = stats.total or 0
completed_learners = stats.completed or 0
# 计算完成率
if total_learners > 0:
completion_rate = round(completed_learners / total_learners * 100)
else:
completion_rate = 0
completion_rates.append(completion_rate) completion_rates.append(completion_rate)
return ResponseModel( return ResponseModel(

View File

@@ -1,304 +1,329 @@
""" """
证书管理 API 端点 证书管理 API 端点
提供证书相关的 RESTful API 提供证书相关的 RESTful API
- 获取证书列表 - 获取证书列表
- 获取证书详情 - 获取证书详情
- 下载证书 - 下载证书
- 验证证书 - 验证证书
""" """
from typing import Optional, List from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Response, Query from fastapi import APIRouter, Depends, HTTPException, status, Response, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import io import io
from app.core.deps import get_db, get_current_user from app.core.deps import get_db, get_current_user
from app.models.user import User from app.models.user import User
from app.services.certificate_service import CertificateService from app.services.certificate_service import CertificateService
router = APIRouter() router = APIRouter()
@router.get("/templates") @router.get("/templates")
async def get_certificate_templates( async def get_certificate_templates(
cert_type: Optional[str] = Query(None, description="证书类型: course/exam/achievement"), cert_type: Optional[str] = Query(None, description="证书类型: course/exam/achievement"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""获取证书模板列表""" """获取证书模板列表"""
service = CertificateService(db) service = CertificateService(db)
templates = await service.get_templates(cert_type) templates = await service.get_templates(cert_type)
return { return {
"code": 200, "code": 200,
"message": "success", "message": "success",
"data": templates "data": templates
} }
@router.get("/me") @router.get("/me")
async def get_my_certificates( async def get_my_certificates(
cert_type: Optional[str] = Query(None, description="证书类型过滤"), cert_type: Optional[str] = Query(None, description="证书类型过滤"),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""获取当前用户的证书列表""" """获取当前用户的证书列表"""
service = CertificateService(db) service = CertificateService(db)
result = await service.get_user_certificates( result = await service.get_user_certificates(
user_id=current_user.id, user_id=current_user.id,
cert_type=cert_type, cert_type=cert_type,
offset=offset, offset=offset,
limit=limit limit=limit
) )
return { return {
"code": 200, "code": 200,
"message": "success", "message": "success",
"data": result "data": result
} }
@router.get("/user/{user_id}") @router.get("/user/{user_id}")
async def get_user_certificates( async def get_user_certificates(
user_id: int, user_id: int,
cert_type: Optional[str] = Query(None), cert_type: Optional[str] = Query(None),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""获取指定用户的证书列表(需要管理员权限)""" """获取指定用户的证书列表(需要管理员权限)"""
# 只允许查看自己的证书或管理员查看 # 只允许查看自己的证书或管理员查看
if current_user.id != user_id and current_user.role not in ["admin", "enterprise_admin"]: if current_user.id != user_id and current_user.role not in ["admin", "enterprise_admin"]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="无权查看其他用户的证书" detail="无权查看其他用户的证书"
) )
service = CertificateService(db) service = CertificateService(db)
result = await service.get_user_certificates( result = await service.get_user_certificates(
user_id=user_id, user_id=user_id,
cert_type=cert_type, cert_type=cert_type,
offset=offset, offset=offset,
limit=limit limit=limit
) )
return { return {
"code": 200, "code": 200,
"message": "success", "message": "success",
"data": result "data": result
} }
@router.get("/{cert_id}") @router.get("/{cert_id}")
async def get_certificate_detail( async def get_certificate_detail(
cert_id: int, cert_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""获取证书详情""" """获取证书详情"""
service = CertificateService(db) service = CertificateService(db)
cert = await service.get_certificate_by_id(cert_id) cert = await service.get_certificate_by_id(cert_id)
if not cert: if not cert:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="证书不存在" detail="证书不存在"
) )
return { return {
"code": 200, "code": 200,
"message": "success", "message": "success",
"data": cert "data": cert
} }
@router.get("/{cert_id}/image") @router.get("/{cert_id}/image")
async def get_certificate_image( async def get_certificate_image(
cert_id: int, cert_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""获取证书分享图片""" """获取证书分享图片"""
service = CertificateService(db) service = CertificateService(db)
try: try:
# 获取基础URL # 获取基础URL
base_url = "https://kpl.example.com/certificates" # 可从配置读取 base_url = "https://kpl.example.com/certificates" # 可从配置读取
image_bytes = await service.generate_certificate_image(cert_id, base_url) image_bytes = await service.generate_certificate_image(cert_id, base_url)
return StreamingResponse( return StreamingResponse(
io.BytesIO(image_bytes), io.BytesIO(image_bytes),
media_type="image/png", media_type="image/png",
headers={ headers={
"Content-Disposition": f"inline; filename=certificate_{cert_id}.png" "Content-Disposition": f"inline; filename=certificate_{cert_id}.png"
} }
) )
except ValueError as e: except ValueError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=str(e) detail=str(e)
) )
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"生成证书图片失败: {str(e)}" detail=f"生成证书图片失败: {str(e)}"
) )
@router.get("/{cert_id}/download") @router.get("/{cert_id}/download")
async def download_certificate_pdf( async def download_certificate(
cert_id: int, cert_id: int,
db: AsyncSession = Depends(get_db), format: str = Query("pdf", description="下载格式: pdf 或 png"),
current_user: User = Depends(get_current_user) db: AsyncSession = Depends(get_db),
): current_user: User = Depends(get_current_user)
"""下载证书PDF""" ):
service = CertificateService(db) """
cert = await service.get_certificate_by_id(cert_id) 下载证书
if not cert: 支持 PDF 和 PNG 两种格式
raise HTTPException( - PDF: 高质量打印版本(需要安装 weasyprint
status_code=status.HTTP_404_NOT_FOUND, - PNG: 图片版本
detail="证书不存在" """
) service = CertificateService(db)
cert = await service.get_certificate_by_id(cert_id)
# 如果已有PDF URL则重定向
if cert.get("pdf_url"): if not cert:
return { raise HTTPException(
"code": 200, status_code=status.HTTP_404_NOT_FOUND,
"message": "success", detail="证书不存在"
"data": { )
"download_url": cert["pdf_url"]
} # 如果已有缓存的 PDF/图片 URL 则返回
} if format.lower() == "pdf" and cert.get("pdf_url"):
return {
# 否则返回图片作为替代 "code": 200,
try: "message": "success",
base_url = "https://kpl.example.com/certificates" "data": {
image_bytes = await service.generate_certificate_image(cert_id, base_url) "download_url": cert["pdf_url"]
}
return StreamingResponse( }
io.BytesIO(image_bytes),
media_type="image/png", if format.lower() == "png" and cert.get("image_url"):
headers={ return {
"Content-Disposition": f"attachment; filename=certificate_{cert['certificate_no']}.png" "code": 200,
} "message": "success",
) "data": {
except Exception as e: "download_url": cert["image_url"]
raise HTTPException( }
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, }
detail=f"下载失败: {str(e)}"
) # 动态生成证书文件
try:
from app.core.config import settings
@router.get("/verify/{cert_no}") base_url = settings.PUBLIC_DOMAIN + "/certificates"
async def verify_certificate(
cert_no: str, content, filename, mime_type = await service.download_certificate(
db: AsyncSession = Depends(get_db) cert_id, format, base_url
): )
"""
验证证书真伪 return StreamingResponse(
io.BytesIO(content),
此接口无需登录,可用于公开验证证书 media_type=mime_type,
""" headers={
service = CertificateService(db) "Content-Disposition": f"attachment; filename={filename}"
cert = await service.get_certificate_by_no(cert_no) }
)
if not cert: except ValueError as e:
return { raise HTTPException(
"code": 404, status_code=status.HTTP_400_BAD_REQUEST,
"message": "证书不存在或编号错误", detail=str(e)
"data": { )
"valid": False, except Exception as e:
"certificate_no": cert_no raise HTTPException(
} status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
} detail=f"下载失败: {str(e)}"
)
return {
"code": 200,
"message": "证书验证通过", @router.get("/verify/{cert_no}")
"data": { async def verify_certificate(
"valid": True, cert_no: str,
"certificate_no": cert_no, db: AsyncSession = Depends(get_db)
"title": cert.get("title"), ):
"type_name": cert.get("type_name"), """
"issued_at": cert.get("issued_at"), 验证证书真伪
"user": cert.get("user", {}),
} 此接口无需登录,可用于公开验证证书
} """
service = CertificateService(db)
cert = await service.get_certificate_by_no(cert_no)
@router.post("/issue/course")
async def issue_course_certificate( if not cert:
course_id: int, return {
course_name: str, "code": 404,
completion_rate: float = 100.0, "message": "证书不存在或编号错误",
db: AsyncSession = Depends(get_db), "data": {
current_user: User = Depends(get_current_user) "valid": False,
): "certificate_no": cert_no
""" }
颁发课程结业证书 }
通常由系统在用户完成课程时自动调用 return {
""" "code": 200,
service = CertificateService(db) "message": "证书验证通过",
"data": {
try: "valid": True,
cert = await service.issue_course_certificate( "certificate_no": cert_no,
user_id=current_user.id, "title": cert.get("title"),
course_id=course_id, "type_name": cert.get("type_name"),
course_name=course_name, "issued_at": cert.get("issued_at"),
completion_rate=completion_rate, "user": cert.get("user", {}),
user_name=current_user.full_name or current_user.username }
) }
await db.commit()
return { @router.post("/issue/course")
"code": 200, async def issue_course_certificate(
"message": "证书颁发成功", course_id: int,
"data": cert course_name: str,
} completion_rate: float = 100.0,
except ValueError as e: db: AsyncSession = Depends(get_db),
raise HTTPException( current_user: User = Depends(get_current_user)
status_code=status.HTTP_400_BAD_REQUEST, ):
detail=str(e) """
) 颁发课程结业证书
通常由系统在用户完成课程时自动调用
@router.post("/issue/exam") """
async def issue_exam_certificate( service = CertificateService(db)
exam_id: int,
exam_name: str, try:
score: float, cert = await service.issue_course_certificate(
db: AsyncSession = Depends(get_db), user_id=current_user.id,
current_user: User = Depends(get_current_user) course_id=course_id,
): course_name=course_name,
""" completion_rate=completion_rate,
颁发考试合格证书 user_name=current_user.full_name or current_user.username
)
通常由系统在用户考试通过时自动调用 await db.commit()
"""
service = CertificateService(db) return {
"code": 200,
try: "message": "证书颁发成功",
cert = await service.issue_exam_certificate( "data": cert
user_id=current_user.id, }
exam_id=exam_id, except ValueError as e:
exam_name=exam_name, raise HTTPException(
score=score, status_code=status.HTTP_400_BAD_REQUEST,
user_name=current_user.full_name or current_user.username detail=str(e)
) )
await db.commit()
return { @router.post("/issue/exam")
"code": 200, async def issue_exam_certificate(
"message": "证书颁发成功", exam_id: int,
"data": cert exam_name: str,
} score: float,
except ValueError as e: db: AsyncSession = Depends(get_db),
raise HTTPException( current_user: User = Depends(get_current_user)
status_code=status.HTTP_400_BAD_REQUEST, ):
detail=str(e) """
) 颁发考试合格证书
通常由系统在用户考试通过时自动调用
"""
service = CertificateService(db)
try:
cert = await service.issue_exam_certificate(
user_id=current_user.id,
exam_id=exam_id,
exam_name=exam_name,
score=score,
user_name=current_user.full_name or current_user.username
)
await db.commit()
return {
"code": 200,
"message": "证书颁发成功",
"data": cert
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)

View File

@@ -1,230 +1,230 @@
""" """
数据大屏 API 端点 数据大屏 API 端点
提供企业级和团队级数据大屏接口 提供企业级和团队级数据大屏接口
""" """
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user from app.core.deps import get_db, get_current_user
from app.models.user import User from app.models.user import User
from app.services.dashboard_service import DashboardService from app.services.dashboard_service import DashboardService
router = APIRouter() router = APIRouter()
@router.get("/enterprise/overview") @router.get("/enterprise/overview")
async def get_enterprise_overview( async def get_enterprise_overview(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
获取企业级数据概览 获取企业级数据概览
需要管理员或企业管理员权限 需要管理员或企业管理员权限
""" """
if current_user.role not in ["admin", "enterprise_admin", "manager"]: if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限" detail="需要管理员权限"
) )
service = DashboardService(db) service = DashboardService(db)
data = await service.get_enterprise_overview() data = await service.get_enterprise_overview()
return { return {
"code": 200, "code": 200,
"message": "success", "message": "success",
"data": data "data": data
} }
@router.get("/enterprise/departments") @router.get("/enterprise/departments")
async def get_department_comparison( async def get_department_comparison(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
获取部门/团队学习对比数据 获取部门/团队学习对比数据
""" """
if current_user.role not in ["admin", "enterprise_admin", "manager"]: if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限" detail="需要管理员权限"
) )
service = DashboardService(db) service = DashboardService(db)
data = await service.get_department_comparison() data = await service.get_department_comparison()
return { return {
"code": 200, "code": 200,
"message": "success", "message": "success",
"data": data "data": data
} }
@router.get("/enterprise/trend") @router.get("/enterprise/trend")
async def get_learning_trend( async def get_learning_trend(
days: int = Query(7, ge=1, le=30), days: int = Query(7, ge=1, le=30),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
获取学习趋势数据 获取学习趋势数据
""" """
if current_user.role not in ["admin", "enterprise_admin", "manager"]: if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限" detail="需要管理员权限"
) )
service = DashboardService(db) service = DashboardService(db)
data = await service.get_learning_trend(days) data = await service.get_learning_trend(days)
return { return {
"code": 200, "code": 200,
"message": "success", "message": "success",
"data": data "data": data
} }
@router.get("/enterprise/level-distribution") @router.get("/enterprise/level-distribution")
async def get_level_distribution( async def get_level_distribution(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
获取等级分布数据 获取等级分布数据
""" """
if current_user.role not in ["admin", "enterprise_admin", "manager"]: if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限" detail="需要管理员权限"
) )
service = DashboardService(db) service = DashboardService(db)
data = await service.get_level_distribution() data = await service.get_level_distribution()
return { return {
"code": 200, "code": 200,
"message": "success", "message": "success",
"data": data "data": data
} }
@router.get("/enterprise/activities") @router.get("/enterprise/activities")
async def get_realtime_activities( async def get_realtime_activities(
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
获取实时动态 获取实时动态
""" """
if current_user.role not in ["admin", "enterprise_admin", "manager"]: if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限" detail="需要管理员权限"
) )
service = DashboardService(db) service = DashboardService(db)
data = await service.get_realtime_activities(limit) data = await service.get_realtime_activities(limit)
return { return {
"code": 200, "code": 200,
"message": "success", "message": "success",
"data": data "data": data
} }
@router.get("/enterprise/course-ranking") @router.get("/enterprise/course-ranking")
async def get_course_ranking( async def get_course_ranking(
limit: int = Query(10, ge=1, le=50), limit: int = Query(10, ge=1, le=50),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
获取课程热度排行 获取课程热度排行
""" """
if current_user.role not in ["admin", "enterprise_admin", "manager"]: if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限" detail="需要管理员权限"
) )
service = DashboardService(db) service = DashboardService(db)
data = await service.get_course_ranking(limit) data = await service.get_course_ranking(limit)
return { return {
"code": 200, "code": 200,
"message": "success", "message": "success",
"data": data "data": data
} }
@router.get("/team") @router.get("/team")
async def get_team_dashboard( async def get_team_dashboard(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
获取团队级数据大屏 获取团队级数据大屏
面向团队负责人,显示其管理团队的数据 面向团队负责人,显示其管理团队的数据
""" """
if current_user.role not in ["admin", "enterprise_admin", "manager"]: if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="需要团队负责人权限" detail="需要团队负责人权限"
) )
service = DashboardService(db) service = DashboardService(db)
data = await service.get_team_dashboard(current_user.id) data = await service.get_team_dashboard(current_user.id)
return { return {
"code": 200, "code": 200,
"message": "success", "message": "success",
"data": data "data": data
} }
@router.get("/all") @router.get("/all")
async def get_all_dashboard_data( async def get_all_dashboard_data(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
获取完整的大屏数据(一次性获取所有数据) 获取完整的大屏数据(一次性获取所有数据)
用于大屏初始化加载 用于大屏初始化加载
""" """
if current_user.role not in ["admin", "enterprise_admin", "manager"]: if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限" detail="需要管理员权限"
) )
service = DashboardService(db) service = DashboardService(db)
# 并行获取所有数据 # 并行获取所有数据
overview = await service.get_enterprise_overview() overview = await service.get_enterprise_overview()
departments = await service.get_department_comparison() departments = await service.get_department_comparison()
trend = await service.get_learning_trend(7) trend = await service.get_learning_trend(7)
level_dist = await service.get_level_distribution() level_dist = await service.get_level_distribution()
activities = await service.get_realtime_activities(20) activities = await service.get_realtime_activities(20)
course_ranking = await service.get_course_ranking(10) course_ranking = await service.get_course_ranking(10)
return { return {
"code": 200, "code": 200,
"message": "success", "message": "success",
"data": { "data": {
"overview": overview, "overview": overview,
"departments": departments, "departments": departments,
"trend": trend, "trend": trend,
"level_distribution": level_dist, "level_distribution": level_dist,
"activities": activities, "activities": activities,
"course_ranking": course_ranking, "course_ranking": course_ranking,
} }
} }

View File

@@ -1,277 +1,277 @@
""" """
等级与奖章 API 等级与奖章 API
提供等级查询、奖章查询、排行榜、签到等接口 提供等级查询、奖章查询、排行榜、签到等接口
""" """
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user from app.core.deps import get_db, get_current_user
from app.schemas.base import ResponseModel from app.schemas.base import ResponseModel
from app.services.level_service import LevelService from app.services.level_service import LevelService
from app.services.badge_service import BadgeService from app.services.badge_service import BadgeService
from app.models.user import User from app.models.user import User
router = APIRouter() router = APIRouter()
# ============================================ # ============================================
# 等级相关接口 # 等级相关接口
# ============================================ # ============================================
@router.get("/me", response_model=ResponseModel) @router.get("/me", response_model=ResponseModel)
async def get_my_level( async def get_my_level(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
获取当前用户等级信息 获取当前用户等级信息
返回用户的等级、经验值、称号、连续登录天数等信息 返回用户的等级、经验值、称号、连续登录天数等信息
""" """
level_service = LevelService(db) level_service = LevelService(db)
level_info = await level_service.get_user_level_info(current_user.id) level_info = await level_service.get_user_level_info(current_user.id)
return ResponseModel( return ResponseModel(
message="获取成功", message="获取成功",
data=level_info data=level_info
) )
@router.get("/user/{user_id}", response_model=ResponseModel) @router.get("/user/{user_id}", response_model=ResponseModel)
async def get_user_level( async def get_user_level(
user_id: int, user_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
获取指定用户等级信息 获取指定用户等级信息
Args: Args:
user_id: 用户ID user_id: 用户ID
""" """
level_service = LevelService(db) level_service = LevelService(db)
level_info = await level_service.get_user_level_info(user_id) level_info = await level_service.get_user_level_info(user_id)
return ResponseModel( return ResponseModel(
message="获取成功", message="获取成功",
data=level_info data=level_info
) )
@router.post("/checkin", response_model=ResponseModel) @router.post("/checkin", response_model=ResponseModel)
async def daily_checkin( async def daily_checkin(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
每日签到 每日签到
每天首次签到获得经验值,连续签到有额外奖励 每天首次签到获得经验值,连续签到有额外奖励
""" """
level_service = LevelService(db) level_service = LevelService(db)
badge_service = BadgeService(db) badge_service = BadgeService(db)
# 执行签到 # 执行签到
checkin_result = await level_service.daily_checkin(current_user.id) checkin_result = await level_service.daily_checkin(current_user.id)
# 检查是否解锁新奖章 # 检查是否解锁新奖章
new_badges = [] new_badges = []
if checkin_result["success"]: if checkin_result["success"]:
new_badges = await badge_service.check_and_award_badges(current_user.id) new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit() await db.commit()
return ResponseModel( return ResponseModel(
message=checkin_result["message"], message=checkin_result["message"],
data={ data={
**checkin_result, **checkin_result,
"new_badges": new_badges "new_badges": new_badges
} }
) )
@router.get("/exp-history", response_model=ResponseModel) @router.get("/exp-history", response_model=ResponseModel)
async def get_exp_history( async def get_exp_history(
limit: int = Query(default=50, ge=1, le=100), limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
exp_type: Optional[str] = Query(default=None, description="经验值类型筛选"), exp_type: Optional[str] = Query(default=None, description="经验值类型筛选"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
获取经验值变化历史 获取经验值变化历史
Args: Args:
limit: 每页数量默认50最大100 limit: 每页数量默认50最大100
offset: 偏移量 offset: 偏移量
exp_type: 类型筛选exam/practice/training/task/login/badge/other exp_type: 类型筛选exam/practice/training/task/login/badge/other
""" """
level_service = LevelService(db) level_service = LevelService(db)
history, total = await level_service.get_exp_history( history, total = await level_service.get_exp_history(
user_id=current_user.id, user_id=current_user.id,
limit=limit, limit=limit,
offset=offset, offset=offset,
exp_type=exp_type exp_type=exp_type
) )
return ResponseModel( return ResponseModel(
message="获取成功", message="获取成功",
data={ data={
"items": history, "items": history,
"total": total, "total": total,
"limit": limit, "limit": limit,
"offset": offset "offset": offset
} }
) )
@router.get("/leaderboard", response_model=ResponseModel) @router.get("/leaderboard", response_model=ResponseModel)
async def get_leaderboard( async def get_leaderboard(
limit: int = Query(default=50, ge=1, le=100), limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
获取等级排行榜 获取等级排行榜
Args: Args:
limit: 每页数量默认50最大100 limit: 每页数量默认50最大100
offset: 偏移量 offset: 偏移量
""" """
level_service = LevelService(db) level_service = LevelService(db)
# 获取排行榜 # 获取排行榜
leaderboard, total = await level_service.get_leaderboard(limit=limit, offset=offset) leaderboard, total = await level_service.get_leaderboard(limit=limit, offset=offset)
# 获取当前用户排名 # 获取当前用户排名
my_rank = await level_service.get_user_rank(current_user.id) my_rank = await level_service.get_user_rank(current_user.id)
# 获取当前用户等级信息 # 获取当前用户等级信息
my_level_info = await level_service.get_user_level_info(current_user.id) my_level_info = await level_service.get_user_level_info(current_user.id)
return ResponseModel( return ResponseModel(
message="获取成功", message="获取成功",
data={ data={
"items": leaderboard, "items": leaderboard,
"total": total, "total": total,
"limit": limit, "limit": limit,
"offset": offset, "offset": offset,
"my_rank": my_rank, "my_rank": my_rank,
"my_level_info": my_level_info "my_level_info": my_level_info
} }
) )
# ============================================ # ============================================
# 奖章相关接口 # 奖章相关接口
# ============================================ # ============================================
@router.get("/badges/all", response_model=ResponseModel) @router.get("/badges/all", response_model=ResponseModel)
async def get_all_badges( async def get_all_badges(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
获取所有奖章定义 获取所有奖章定义
返回所有可获得的奖章列表 返回所有可获得的奖章列表
""" """
badge_service = BadgeService(db) badge_service = BadgeService(db)
badges = await badge_service.get_all_badges() badges = await badge_service.get_all_badges()
return ResponseModel( return ResponseModel(
message="获取成功", message="获取成功",
data=badges data=badges
) )
@router.get("/badges/me", response_model=ResponseModel) @router.get("/badges/me", response_model=ResponseModel)
async def get_my_badges( async def get_my_badges(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
获取当前用户的奖章(含解锁状态) 获取当前用户的奖章(含解锁状态)
返回所有奖章及用户是否已解锁 返回所有奖章及用户是否已解锁
""" """
badge_service = BadgeService(db) badge_service = BadgeService(db)
badges = await badge_service.get_user_badges_with_status(current_user.id) badges = await badge_service.get_user_badges_with_status(current_user.id)
# 统计已解锁数量 # 统计已解锁数量
unlocked_count = sum(1 for b in badges if b["unlocked"]) unlocked_count = sum(1 for b in badges if b["unlocked"])
return ResponseModel( return ResponseModel(
message="获取成功", message="获取成功",
data={ data={
"badges": badges, "badges": badges,
"total": len(badges), "total": len(badges),
"unlocked_count": unlocked_count "unlocked_count": unlocked_count
} }
) )
@router.get("/badges/unnotified", response_model=ResponseModel) @router.get("/badges/unnotified", response_model=ResponseModel)
async def get_unnotified_badges( async def get_unnotified_badges(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
获取未通知的新奖章 获取未通知的新奖章
用于前端显示新获得奖章的弹窗提示 用于前端显示新获得奖章的弹窗提示
""" """
badge_service = BadgeService(db) badge_service = BadgeService(db)
badges = await badge_service.get_unnotified_badges(current_user.id) badges = await badge_service.get_unnotified_badges(current_user.id)
return ResponseModel( return ResponseModel(
message="获取成功", message="获取成功",
data=badges data=badges
) )
@router.post("/badges/mark-notified", response_model=ResponseModel) @router.post("/badges/mark-notified", response_model=ResponseModel)
async def mark_badges_notified( async def mark_badges_notified(
badge_ids: Optional[list[int]] = None, badge_ids: Optional[list[int]] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
标记奖章为已通知 标记奖章为已通知
Args: Args:
badge_ids: 要标记的奖章ID列表为空则标记全部 badge_ids: 要标记的奖章ID列表为空则标记全部
""" """
badge_service = BadgeService(db) badge_service = BadgeService(db)
await badge_service.mark_badges_notified(current_user.id, badge_ids) await badge_service.mark_badges_notified(current_user.id, badge_ids)
await db.commit() await db.commit()
return ResponseModel( return ResponseModel(
message="标记成功" message="标记成功"
) )
@router.post("/check-badges", response_model=ResponseModel) @router.post("/check-badges", response_model=ResponseModel)
async def check_and_award_badges( async def check_and_award_badges(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
检查并授予符合条件的奖章 检查并授予符合条件的奖章
手动触发奖章检查,返回新获得的奖章 手动触发奖章检查,返回新获得的奖章
""" """
badge_service = BadgeService(db) badge_service = BadgeService(db)
new_badges = await badge_service.check_and_award_badges(current_user.id) new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit() await db.commit()
return ResponseModel( return ResponseModel(
message="检查完成", message="检查完成",
data={ data={
"new_badges": new_badges, "new_badges": new_badges,
"count": len(new_badges) "count": len(new_badges)
} }
) )

View File

@@ -0,0 +1,470 @@
"""
用户课程学习进度 API
"""
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from pydantic import BaseModel, Field
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.models.course import Course, CourseMaterial
from app.models.user_course_progress import (
UserCourseProgress,
UserMaterialProgress,
ProgressStatus,
)
router = APIRouter()
# ============ Schemas ============
class MaterialProgressUpdate(BaseModel):
"""更新资料进度请求"""
progress_percent: float = Field(ge=0, le=100, description="进度百分比")
last_position: Optional[int] = Field(default=0, ge=0, description="播放位置(秒)")
study_time_delta: Optional[int] = Field(default=0, ge=0, description="本次学习时长(秒)")
is_completed: Optional[bool] = Field(default=None, description="是否标记完成")
class MaterialProgressResponse(BaseModel):
"""资料进度响应"""
material_id: int
material_name: str
is_completed: bool
progress_percent: float
last_position: int
study_time: int
first_accessed_at: Optional[datetime]
last_accessed_at: Optional[datetime]
completed_at: Optional[datetime]
class Config:
from_attributes = True
class CourseProgressResponse(BaseModel):
"""课程进度响应"""
course_id: int
course_name: str
status: str
progress_percent: float
completed_materials: int
total_materials: int
total_study_time: int
first_accessed_at: Optional[datetime]
last_accessed_at: Optional[datetime]
completed_at: Optional[datetime]
materials: List[MaterialProgressResponse] = []
class Config:
from_attributes = True
class ProgressSummary(BaseModel):
"""进度统计摘要"""
total_courses: int
completed_courses: int
in_progress_courses: int
not_started_courses: int
total_study_time: int
average_progress: float
# ============ API Endpoints ============
@router.get("/summary", response_model=ProgressSummary)
async def get_progress_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取用户学习进度摘要"""
# 获取用户所有课程进度
result = await db.execute(
select(UserCourseProgress).where(
UserCourseProgress.user_id == current_user.id
)
)
progress_list = result.scalars().all()
total_courses = len(progress_list)
completed = sum(1 for p in progress_list if p.status == ProgressStatus.COMPLETED.value)
in_progress = sum(1 for p in progress_list if p.status == ProgressStatus.IN_PROGRESS.value)
not_started = sum(1 for p in progress_list if p.status == ProgressStatus.NOT_STARTED.value)
total_time = sum(p.total_study_time for p in progress_list)
avg_progress = sum(p.progress_percent for p in progress_list) / total_courses if total_courses > 0 else 0
return ProgressSummary(
total_courses=total_courses,
completed_courses=completed,
in_progress_courses=in_progress,
not_started_courses=not_started,
total_study_time=total_time,
average_progress=round(avg_progress, 2),
)
@router.get("/courses", response_model=List[CourseProgressResponse])
async def get_all_course_progress(
status: Optional[str] = Query(None, description="过滤状态"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取用户所有课程的学习进度"""
query = select(UserCourseProgress, Course).join(
Course, UserCourseProgress.course_id == Course.id
).where(
UserCourseProgress.user_id == current_user.id
)
if status:
query = query.where(UserCourseProgress.status == status)
result = await db.execute(query)
rows = result.all()
response = []
for progress, course in rows:
response.append(CourseProgressResponse(
course_id=course.id,
course_name=course.name,
status=progress.status,
progress_percent=progress.progress_percent,
completed_materials=progress.completed_materials,
total_materials=progress.total_materials,
total_study_time=progress.total_study_time,
first_accessed_at=progress.first_accessed_at,
last_accessed_at=progress.last_accessed_at,
completed_at=progress.completed_at,
))
return response
@router.get("/courses/{course_id}", response_model=CourseProgressResponse)
async def get_course_progress(
course_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取指定课程的详细学习进度"""
# 获取课程信息
course_result = await db.execute(
select(Course).where(Course.id == course_id)
)
course = course_result.scalar_one_or_none()
if not course:
raise HTTPException(status_code=404, detail="课程不存在")
# 获取或创建课程进度
progress_result = await db.execute(
select(UserCourseProgress).where(
and_(
UserCourseProgress.user_id == current_user.id,
UserCourseProgress.course_id == course_id,
)
)
)
progress = progress_result.scalar_one_or_none()
if not progress:
# 获取课程资料数量
materials_result = await db.execute(
select(func.count(CourseMaterial.id)).where(
and_(
CourseMaterial.course_id == course_id,
CourseMaterial.is_deleted == False,
)
)
)
total_materials = materials_result.scalar() or 0
# 创建新的进度记录
progress = UserCourseProgress(
user_id=current_user.id,
course_id=course_id,
status=ProgressStatus.NOT_STARTED.value,
progress_percent=0.0,
completed_materials=0,
total_materials=total_materials,
)
db.add(progress)
await db.commit()
await db.refresh(progress)
# 获取资料进度
material_progress_result = await db.execute(
select(UserMaterialProgress, CourseMaterial).join(
CourseMaterial, UserMaterialProgress.material_id == CourseMaterial.id
).where(
and_(
UserMaterialProgress.user_id == current_user.id,
UserMaterialProgress.course_id == course_id,
)
)
)
material_rows = material_progress_result.all()
materials = []
for mp, material in material_rows:
materials.append(MaterialProgressResponse(
material_id=material.id,
material_name=material.name,
is_completed=mp.is_completed,
progress_percent=mp.progress_percent,
last_position=mp.last_position,
study_time=mp.study_time,
first_accessed_at=mp.first_accessed_at,
last_accessed_at=mp.last_accessed_at,
completed_at=mp.completed_at,
))
return CourseProgressResponse(
course_id=course.id,
course_name=course.name,
status=progress.status,
progress_percent=progress.progress_percent,
completed_materials=progress.completed_materials,
total_materials=progress.total_materials,
total_study_time=progress.total_study_time,
first_accessed_at=progress.first_accessed_at,
last_accessed_at=progress.last_accessed_at,
completed_at=progress.completed_at,
materials=materials,
)
@router.post("/materials/{material_id}", response_model=MaterialProgressResponse)
async def update_material_progress(
material_id: int,
data: MaterialProgressUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""更新资料学习进度"""
# 获取资料信息
material_result = await db.execute(
select(CourseMaterial).where(CourseMaterial.id == material_id)
)
material = material_result.scalar_one_or_none()
if not material:
raise HTTPException(status_code=404, detail="资料不存在")
course_id = material.course_id
now = datetime.now()
# 获取或创建资料进度
mp_result = await db.execute(
select(UserMaterialProgress).where(
and_(
UserMaterialProgress.user_id == current_user.id,
UserMaterialProgress.material_id == material_id,
)
)
)
mp = mp_result.scalar_one_or_none()
if not mp:
mp = UserMaterialProgress(
user_id=current_user.id,
material_id=material_id,
course_id=course_id,
first_accessed_at=now,
)
db.add(mp)
# 更新进度
mp.progress_percent = data.progress_percent
mp.last_position = data.last_position or mp.last_position
mp.study_time += data.study_time_delta or 0
mp.last_accessed_at = now
# 处理完成状态
if data.is_completed is not None:
if data.is_completed and not mp.is_completed:
mp.is_completed = True
mp.completed_at = now
mp.progress_percent = 100.0
elif not data.is_completed:
mp.is_completed = False
mp.completed_at = None
elif data.progress_percent >= 100:
mp.is_completed = True
mp.completed_at = now
await db.commit()
# 更新课程整体进度
await _update_course_progress(db, current_user.id, course_id)
await db.refresh(mp)
return MaterialProgressResponse(
material_id=mp.material_id,
material_name=material.name,
is_completed=mp.is_completed,
progress_percent=mp.progress_percent,
last_position=mp.last_position,
study_time=mp.study_time,
first_accessed_at=mp.first_accessed_at,
last_accessed_at=mp.last_accessed_at,
completed_at=mp.completed_at,
)
@router.post("/materials/{material_id}/complete")
async def mark_material_complete(
material_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""标记资料为已完成"""
return await update_material_progress(
material_id=material_id,
data=MaterialProgressUpdate(progress_percent=100, is_completed=True),
db=db,
current_user=current_user,
)
@router.post("/courses/{course_id}/start")
async def start_course(
course_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""开始学习课程(记录首次访问)"""
# 获取课程
course_result = await db.execute(
select(Course).where(Course.id == course_id)
)
course = course_result.scalar_one_or_none()
if not course:
raise HTTPException(status_code=404, detail="课程不存在")
now = datetime.now()
# 获取或创建进度
progress_result = await db.execute(
select(UserCourseProgress).where(
and_(
UserCourseProgress.user_id == current_user.id,
UserCourseProgress.course_id == course_id,
)
)
)
progress = progress_result.scalar_one_or_none()
if not progress:
# 获取资料数量
materials_result = await db.execute(
select(func.count(CourseMaterial.id)).where(
and_(
CourseMaterial.course_id == course_id,
CourseMaterial.is_deleted == False,
)
)
)
total_materials = materials_result.scalar() or 0
progress = UserCourseProgress(
user_id=current_user.id,
course_id=course_id,
status=ProgressStatus.IN_PROGRESS.value,
total_materials=total_materials,
first_accessed_at=now,
last_accessed_at=now,
)
db.add(progress)
else:
if progress.status == ProgressStatus.NOT_STARTED.value:
progress.status = ProgressStatus.IN_PROGRESS.value
if not progress.first_accessed_at:
progress.first_accessed_at = now
progress.last_accessed_at = now
await db.commit()
return {"code": 200, "message": "已开始学习", "data": {"course_id": course_id}}
# ============ Helper Functions ============
async def _update_course_progress(db: AsyncSession, user_id: int, course_id: int):
"""更新课程整体进度"""
now = datetime.now()
# 获取课程所有资料数量
materials_result = await db.execute(
select(func.count(CourseMaterial.id)).where(
and_(
CourseMaterial.course_id == course_id,
CourseMaterial.is_deleted == False,
)
)
)
total_materials = materials_result.scalar() or 0
# 获取已完成的资料数量和总学习时长
completed_result = await db.execute(
select(
func.count(UserMaterialProgress.id),
func.coalesce(func.sum(UserMaterialProgress.study_time), 0),
).where(
and_(
UserMaterialProgress.user_id == user_id,
UserMaterialProgress.course_id == course_id,
UserMaterialProgress.is_completed == True,
)
)
)
row = completed_result.one()
completed_materials = row[0]
total_study_time = row[1]
# 计算进度百分比
progress_percent = (completed_materials / total_materials * 100) if total_materials > 0 else 0
# 确定状态
if completed_materials == 0:
status = ProgressStatus.IN_PROGRESS.value # 已开始但未完成任何资料
elif completed_materials >= total_materials:
status = ProgressStatus.COMPLETED.value
else:
status = ProgressStatus.IN_PROGRESS.value
# 获取或创建课程进度
progress_result = await db.execute(
select(UserCourseProgress).where(
and_(
UserCourseProgress.user_id == user_id,
UserCourseProgress.course_id == course_id,
)
)
)
progress = progress_result.scalar_one_or_none()
if not progress:
progress = UserCourseProgress(
user_id=user_id,
course_id=course_id,
first_accessed_at=now,
)
db.add(progress)
# 更新进度
progress.status = status
progress.progress_percent = round(progress_percent, 2)
progress.completed_materials = completed_materials
progress.total_materials = total_materials
progress.total_study_time = total_study_time
progress.last_accessed_at = now
if status == ProgressStatus.COMPLETED.value and not progress.completed_at:
progress.completed_at = now
await db.commit()

View File

@@ -0,0 +1,157 @@
"""
智能学习推荐 API
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.services.recommendation_service import RecommendationService
router = APIRouter()
# ============ Schemas ============
class CourseRecommendation(BaseModel):
"""课程推荐响应"""
course_id: int
course_name: str
category: Optional[str] = None
cover_image: Optional[str] = None
description: Optional[str] = None
progress_percent: Optional[float] = None
student_count: Optional[int] = None
source: Optional[str] = None
reason: Optional[str] = None
class KnowledgePointRecommendation(BaseModel):
"""知识点推荐响应"""
knowledge_point_id: int
name: str
description: Optional[str] = None
type: Optional[str] = None
course_id: int
mistake_count: Optional[int] = None
reason: Optional[str] = None
class RecommendationResponse(BaseModel):
"""推荐响应"""
code: int = 200
message: str = "success"
data: dict
# ============ API Endpoints ============
@router.get("/courses", response_model=RecommendationResponse)
async def get_course_recommendations(
limit: int = Query(10, ge=1, le=50, description="推荐数量"),
include_reasons: bool = Query(True, description="是否包含推荐理由"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
获取个性化课程推荐
推荐策略:
- 基于错题分析推荐相关课程
- 基于能力评估推荐弱项课程
- 基于学习进度推荐未完成课程
- 基于热门程度推荐高人气课程
"""
service = RecommendationService(db)
recommendations = await service.get_recommendations(
user_id=current_user.id,
limit=limit,
include_reasons=include_reasons,
)
return RecommendationResponse(
code=200,
message="获取推荐成功",
data={
"recommendations": recommendations,
"total": len(recommendations),
}
)
@router.get("/knowledge-points", response_model=RecommendationResponse)
async def get_knowledge_point_recommendations(
limit: int = Query(5, ge=1, le=20, description="推荐数量"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
获取知识点复习推荐
基于错题记录推荐需要重点复习的知识点
"""
service = RecommendationService(db)
recommendations = await service.get_knowledge_point_recommendations(
user_id=current_user.id,
limit=limit,
)
return RecommendationResponse(
code=200,
message="获取推荐成功",
data={
"recommendations": recommendations,
"total": len(recommendations),
}
)
@router.get("/summary")
async def get_recommendation_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
获取推荐摘要
返回各类推荐的概要信息
"""
service = RecommendationService(db)
# 获取各类推荐
all_recs = await service.get_recommendations(
user_id=current_user.id,
limit=20,
include_reasons=True,
)
# 按来源分类统计
source_counts = {}
for rec in all_recs:
source = rec.get("source", "other")
source_counts[source] = source_counts.get(source, 0) + 1
# 获取知识点推荐
kp_recs = await service.get_knowledge_point_recommendations(
user_id=current_user.id,
limit=5,
)
return {
"code": 200,
"message": "success",
"data": {
"total_recommendations": len(all_recs),
"source_breakdown": {
"mistake_based": source_counts.get("mistake", 0),
"ability_based": source_counts.get("ability", 0),
"progress_based": source_counts.get("progress", 0),
"popular": source_counts.get("popular", 0),
},
"weak_knowledge_points": len(kp_recs),
"top_recommendation": all_recs[0] if all_recs else None,
}
}

View File

@@ -0,0 +1,145 @@
"""
语音识别 API
"""
from typing import Optional
from fastapi import APIRouter, Depends, File, UploadFile, Form, HTTPException
from pydantic import BaseModel
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.services.speech_recognition import (
get_speech_recognition_service,
SpeechRecognitionError,
)
router = APIRouter()
class SpeechRecognitionRequest(BaseModel):
"""语音识别请求(文本形式)"""
text: str
session_id: Optional[int] = None
class SpeechRecognitionResponse(BaseModel):
"""语音识别响应"""
code: int = 200
message: str = "识别成功"
data: dict
@router.post("/recognize/text", response_model=SpeechRecognitionResponse)
async def recognize_text(
request: SpeechRecognitionRequest,
current_user: User = Depends(get_current_user),
):
"""
处理前端已识别的语音文本
用于 Web Speech API 识别后的文本传输
"""
service = get_speech_recognition_service("simple")
try:
text = await service.recognize_text(request.text)
return SpeechRecognitionResponse(
code=200,
message="识别成功",
data={
"text": text,
"session_id": request.session_id,
}
)
except SpeechRecognitionError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/recognize/audio", response_model=SpeechRecognitionResponse)
async def recognize_audio(
audio: UploadFile = File(...),
format: str = Form(default="wav"),
sample_rate: int = Form(default=16000),
engine: str = Form(default="aliyun"),
current_user: User = Depends(get_current_user),
):
"""
识别音频文件
支持的音频格式wav, pcm, mp3, ogg, opus
支持的识别引擎aliyun, xunfei
"""
# 读取音频数据
audio_data = await audio.read()
if len(audio_data) == 0:
raise HTTPException(status_code=400, detail="音频文件为空")
if len(audio_data) > 10 * 1024 * 1024: # 10MB 限制
raise HTTPException(status_code=400, detail="音频文件过大,最大支持 10MB")
service = get_speech_recognition_service(engine)
try:
text = await service.recognize_audio(audio_data, format, sample_rate)
return SpeechRecognitionResponse(
code=200,
message="识别成功",
data={
"text": text,
"format": format,
"sample_rate": sample_rate,
"engine": engine,
}
)
except SpeechRecognitionError as e:
raise HTTPException(status_code=400, detail=str(e))
except NotImplementedError as e:
raise HTTPException(status_code=501, detail=str(e))
@router.get("/engines")
async def get_available_engines(
current_user: User = Depends(get_current_user),
):
"""
获取可用的语音识别引擎列表
"""
import os
engines = [
{
"id": "simple",
"name": "浏览器语音识别",
"description": "使用浏览器内置的 Web Speech API 进行语音识别",
"available": True,
},
{
"id": "aliyun",
"name": "阿里云智能语音",
"description": "使用阿里云 NLS 服务进行高精度语音识别",
"available": all([
os.getenv("ALIYUN_ACCESS_KEY_ID"),
os.getenv("ALIYUN_ACCESS_KEY_SECRET"),
os.getenv("ALIYUN_NLS_APP_KEY"),
]),
},
{
"id": "xunfei",
"name": "讯飞语音识别",
"description": "使用讯飞 IAT 服务进行语音识别",
"available": all([
os.getenv("XUNFEI_APP_ID"),
os.getenv("XUNFEI_API_KEY"),
os.getenv("XUNFEI_API_SECRET"),
]),
},
]
return {
"code": 200,
"message": "获取成功",
"data": {
"engines": engines,
"default": "simple",
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,306 +1,306 @@
""" """
系统设置 API 系统设置 API
供企业管理员admin角色配置系统级别的设置如钉钉免密登录等 供企业管理员admin角色配置系统级别的设置如钉钉免密登录等
""" """
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.core.deps import get_current_active_user, get_db from app.core.deps import get_current_active_user, get_db
from app.core.logger import logger from app.core.logger import logger
from app.models.user import User from app.models.user import User
from app.schemas.base import ResponseModel from app.schemas.base import ResponseModel
router = APIRouter() router = APIRouter()
# ============================================ # ============================================
# Schema 定义 # Schema 定义
# ============================================ # ============================================
class DingtalkConfigUpdate(BaseModel): class DingtalkConfigUpdate(BaseModel):
"""钉钉配置更新请求""" """钉钉配置更新请求"""
app_key: Optional[str] = Field(None, description="钉钉AppKey") app_key: Optional[str] = Field(None, description="钉钉AppKey")
app_secret: Optional[str] = Field(None, description="钉钉AppSecret") app_secret: Optional[str] = Field(None, description="钉钉AppSecret")
agent_id: Optional[str] = Field(None, description="钉钉AgentId") agent_id: Optional[str] = Field(None, description="钉钉AgentId")
corp_id: Optional[str] = Field(None, description="钉钉CorpId") corp_id: Optional[str] = Field(None, description="钉钉CorpId")
enabled: Optional[bool] = Field(None, description="是否启用钉钉免密登录") enabled: Optional[bool] = Field(None, description="是否启用钉钉免密登录")
class DingtalkConfigResponse(BaseModel): class DingtalkConfigResponse(BaseModel):
"""钉钉配置响应""" """钉钉配置响应"""
app_key: Optional[str] = None app_key: Optional[str] = None
app_secret_masked: Optional[str] = None # 脱敏显示 app_secret_masked: Optional[str] = None # 脱敏显示
agent_id: Optional[str] = None agent_id: Optional[str] = None
corp_id: Optional[str] = None corp_id: Optional[str] = None
enabled: bool = False enabled: bool = False
# ============================================ # ============================================
# 辅助函数 # 辅助函数
# ============================================ # ============================================
def check_admin_permission(user: User): def check_admin_permission(user: User):
"""检查是否为管理员""" """检查是否为管理员"""
if user.role != 'admin': if user.role != 'admin':
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限" detail="需要管理员权限"
) )
async def get_or_create_tenant_id(db: AsyncSession) -> int: async def get_or_create_tenant_id(db: AsyncSession) -> int:
"""获取或创建默认租户ID简化版假设单租户""" """获取或创建默认租户ID简化版假设单租户"""
# 对于考培练系统简化处理使用固定的租户ID=1 # 对于考培练系统简化处理使用固定的租户ID=1
return 1 return 1
async def get_system_config(db: AsyncSession, tenant_id: int, config_group: str, config_key: str) -> Optional[str]: async def get_system_config(db: AsyncSession, tenant_id: int, config_group: str, config_key: str) -> Optional[str]:
"""获取系统配置值""" """获取系统配置值"""
result = await db.execute( result = await db.execute(
text(""" text("""
SELECT config_value FROM tenant_configs SELECT config_value FROM tenant_configs
WHERE tenant_id = :tenant_id AND config_group = :config_group AND config_key = :config_key WHERE tenant_id = :tenant_id AND config_group = :config_group AND config_key = :config_key
"""), """),
{"tenant_id": tenant_id, "config_group": config_group, "config_key": config_key} {"tenant_id": tenant_id, "config_group": config_group, "config_key": config_key}
) )
row = result.fetchone() row = result.fetchone()
return row[0] if row else None return row[0] if row else None
async def set_system_config(db: AsyncSession, tenant_id: int, config_group: str, config_key: str, config_value: str): async def set_system_config(db: AsyncSession, tenant_id: int, config_group: str, config_key: str, config_value: str):
"""设置系统配置值""" """设置系统配置值"""
# 检查是否已存在 # 检查是否已存在
existing = await get_system_config(db, tenant_id, config_group, config_key) existing = await get_system_config(db, tenant_id, config_group, config_key)
if existing is not None: if existing is not None:
# 更新 # 更新
await db.execute( await db.execute(
text(""" text("""
UPDATE tenant_configs UPDATE tenant_configs
SET config_value = :config_value SET config_value = :config_value
WHERE tenant_id = :tenant_id AND config_group = :config_group AND config_key = :config_key WHERE tenant_id = :tenant_id AND config_group = :config_group AND config_key = :config_key
"""), """),
{"tenant_id": tenant_id, "config_group": config_group, "config_key": config_key, "config_value": config_value} {"tenant_id": tenant_id, "config_group": config_group, "config_key": config_key, "config_value": config_value}
) )
else: else:
# 插入 # 插入
await db.execute( await db.execute(
text(""" text("""
INSERT INTO tenant_configs (tenant_id, config_group, config_key, config_value, value_type, is_encrypted) INSERT INTO tenant_configs (tenant_id, config_group, config_key, config_value, value_type, is_encrypted)
VALUES (:tenant_id, :config_group, :config_key, :config_value, 'string', :is_encrypted) VALUES (:tenant_id, :config_group, :config_key, :config_value, 'string', :is_encrypted)
"""), """),
{ {
"tenant_id": tenant_id, "tenant_id": tenant_id,
"config_group": config_group, "config_group": config_group,
"config_key": config_key, "config_key": config_key,
"config_value": config_value, "config_value": config_value,
"is_encrypted": 1 if config_key == 'DINGTALK_APP_SECRET' else 0 "is_encrypted": 1 if config_key == 'DINGTALK_APP_SECRET' else 0
} }
) )
async def get_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str) -> bool: async def get_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str) -> bool:
"""获取功能开关状态""" """获取功能开关状态"""
# 先查租户级别 # 先查租户级别
result = await db.execute( result = await db.execute(
text(""" text("""
SELECT is_enabled FROM feature_switches SELECT is_enabled FROM feature_switches
WHERE feature_code = :feature_code AND tenant_id = :tenant_id WHERE feature_code = :feature_code AND tenant_id = :tenant_id
"""), """),
{"tenant_id": tenant_id, "feature_code": feature_code} {"tenant_id": tenant_id, "feature_code": feature_code}
) )
row = result.fetchone() row = result.fetchone()
if row: if row:
return bool(row[0]) return bool(row[0])
# 再查默认值 # 再查默认值
result = await db.execute( result = await db.execute(
text(""" text("""
SELECT is_enabled FROM feature_switches SELECT is_enabled FROM feature_switches
WHERE feature_code = :feature_code AND tenant_id IS NULL WHERE feature_code = :feature_code AND tenant_id IS NULL
"""), """),
{"feature_code": feature_code} {"feature_code": feature_code}
) )
row = result.fetchone() row = result.fetchone()
return bool(row[0]) if row else False return bool(row[0]) if row else False
async def set_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str, is_enabled: bool): async def set_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str, is_enabled: bool):
"""设置功能开关状态""" """设置功能开关状态"""
# 检查是否已存在租户级配置 # 检查是否已存在租户级配置
result = await db.execute( result = await db.execute(
text(""" text("""
SELECT id FROM feature_switches SELECT id FROM feature_switches
WHERE feature_code = :feature_code AND tenant_id = :tenant_id WHERE feature_code = :feature_code AND tenant_id = :tenant_id
"""), """),
{"tenant_id": tenant_id, "feature_code": feature_code} {"tenant_id": tenant_id, "feature_code": feature_code}
) )
row = result.fetchone() row = result.fetchone()
if row: if row:
# 更新 # 更新
await db.execute( await db.execute(
text(""" text("""
UPDATE feature_switches UPDATE feature_switches
SET is_enabled = :is_enabled SET is_enabled = :is_enabled
WHERE tenant_id = :tenant_id AND feature_code = :feature_code WHERE tenant_id = :tenant_id AND feature_code = :feature_code
"""), """),
{"tenant_id": tenant_id, "feature_code": feature_code, "is_enabled": 1 if is_enabled else 0} {"tenant_id": tenant_id, "feature_code": feature_code, "is_enabled": 1 if is_enabled else 0}
) )
else: else:
# 获取默认配置信息 # 获取默认配置信息
result = await db.execute( result = await db.execute(
text(""" text("""
SELECT feature_name, feature_group, description FROM feature_switches SELECT feature_name, feature_group, description FROM feature_switches
WHERE feature_code = :feature_code AND tenant_id IS NULL WHERE feature_code = :feature_code AND tenant_id IS NULL
"""), """),
{"feature_code": feature_code} {"feature_code": feature_code}
) )
default_row = result.fetchone() default_row = result.fetchone()
if default_row: if default_row:
# 插入租户级配置 # 插入租户级配置
await db.execute( await db.execute(
text(""" text("""
INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description) INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description)
VALUES (:tenant_id, :feature_code, :feature_name, :feature_group, :is_enabled, :description) VALUES (:tenant_id, :feature_code, :feature_name, :feature_group, :is_enabled, :description)
"""), """),
{ {
"tenant_id": tenant_id, "tenant_id": tenant_id,
"feature_code": feature_code, "feature_code": feature_code,
"feature_name": default_row[0], "feature_name": default_row[0],
"feature_group": default_row[1], "feature_group": default_row[1],
"is_enabled": 1 if is_enabled else 0, "is_enabled": 1 if is_enabled else 0,
"description": default_row[2] "description": default_row[2]
} }
) )
# ============================================ # ============================================
# API 端点 # API 端点
# ============================================ # ============================================
@router.get("/dingtalk", response_model=ResponseModel) @router.get("/dingtalk", response_model=ResponseModel)
async def get_dingtalk_config( async def get_dingtalk_config(
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> ResponseModel: ) -> ResponseModel:
""" """
获取钉钉配置 获取钉钉配置
仅限管理员访问 仅限管理员访问
""" """
check_admin_permission(current_user) check_admin_permission(current_user)
tenant_id = await get_or_create_tenant_id(db) tenant_id = await get_or_create_tenant_id(db)
# 获取配置 # 获取配置
app_key = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY') app_key = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY')
app_secret = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET') app_secret = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET')
agent_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_AGENT_ID') agent_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_AGENT_ID')
corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID') corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID')
enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login') enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login')
# 脱敏处理 app_secret # 脱敏处理 app_secret
app_secret_masked = None app_secret_masked = None
if app_secret: if app_secret:
if len(app_secret) > 8: if len(app_secret) > 8:
app_secret_masked = app_secret[:4] + '****' + app_secret[-4:] app_secret_masked = app_secret[:4] + '****' + app_secret[-4:]
else: else:
app_secret_masked = '****' app_secret_masked = '****'
return ResponseModel( return ResponseModel(
message="获取成功", message="获取成功",
data={ data={
"app_key": app_key, "app_key": app_key,
"app_secret_masked": app_secret_masked, "app_secret_masked": app_secret_masked,
"agent_id": agent_id, "agent_id": agent_id,
"corp_id": corp_id, "corp_id": corp_id,
"enabled": enabled, "enabled": enabled,
} }
) )
@router.put("/dingtalk", response_model=ResponseModel) @router.put("/dingtalk", response_model=ResponseModel)
async def update_dingtalk_config( async def update_dingtalk_config(
config: DingtalkConfigUpdate, config: DingtalkConfigUpdate,
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> ResponseModel: ) -> ResponseModel:
""" """
更新钉钉配置 更新钉钉配置
仅限管理员访问 仅限管理员访问
""" """
check_admin_permission(current_user) check_admin_permission(current_user)
tenant_id = await get_or_create_tenant_id(db) tenant_id = await get_or_create_tenant_id(db)
try: try:
# 更新配置 # 更新配置
if config.app_key is not None: if config.app_key is not None:
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY', config.app_key) await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY', config.app_key)
if config.app_secret is not None: if config.app_secret is not None:
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET', config.app_secret) await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET', config.app_secret)
if config.agent_id is not None: if config.agent_id is not None:
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_AGENT_ID', config.agent_id) await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_AGENT_ID', config.agent_id)
if config.corp_id is not None: if config.corp_id is not None:
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID', config.corp_id) await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID', config.corp_id)
if config.enabled is not None: if config.enabled is not None:
await set_feature_switch(db, tenant_id, 'dingtalk_login', config.enabled) await set_feature_switch(db, tenant_id, 'dingtalk_login', config.enabled)
await db.commit() await db.commit()
logger.info( logger.info(
"钉钉配置已更新", "钉钉配置已更新",
user_id=current_user.id, user_id=current_user.id,
username=current_user.username, username=current_user.username,
) )
return ResponseModel(message="配置已保存") return ResponseModel(message="配置已保存")
except Exception as e: except Exception as e:
await db.rollback() await db.rollback()
logger.error(f"更新钉钉配置失败: {str(e)}") logger.error(f"更新钉钉配置失败: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="保存配置失败" detail="保存配置失败"
) )
@router.get("/all", response_model=ResponseModel) @router.get("/all", response_model=ResponseModel)
async def get_all_settings( async def get_all_settings(
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> ResponseModel: ) -> ResponseModel:
""" """
获取所有系统设置概览 获取所有系统设置概览
仅限管理员访问 仅限管理员访问
""" """
check_admin_permission(current_user) check_admin_permission(current_user)
tenant_id = await get_or_create_tenant_id(db) tenant_id = await get_or_create_tenant_id(db)
# 钉钉配置状态 # 钉钉配置状态
dingtalk_enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login') dingtalk_enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login')
dingtalk_corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID') dingtalk_corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID')
return ResponseModel( return ResponseModel(
message="获取成功", message="获取成功",
data={ data={
"dingtalk": { "dingtalk": {
"enabled": dingtalk_enabled, "enabled": dingtalk_enabled,
"configured": bool(dingtalk_corp_id), # 是否已配置 "configured": bool(dingtalk_corp_id), # 是否已配置
} }
} }
) )

View File

@@ -23,7 +23,9 @@ class Settings(BaseSettings):
# 应用基础配置 # 应用基础配置
APP_NAME: str = "KaoPeiLian" APP_NAME: str = "KaoPeiLian"
APP_VERSION: str = "1.0.0" APP_VERSION: str = "1.0.0"
DEBUG: bool = Field(default=True) # DEBUG 模式:生产环境必须设置为 False
# 通过环境变量 DEBUG=false 或在 .env 文件中设置
DEBUG: bool = Field(default=False, description="调试模式,生产环境必须设置为 False")
# 租户配置(用于多租户部署) # 租户配置(用于多租户部署)
TENANT_CODE: str = Field(default="demo", description="租户编码,如 hua, yy, hl") TENANT_CODE: str = Field(default="demo", description="租户编码,如 hua, yy, hl")
@@ -56,7 +58,12 @@ class Settings(BaseSettings):
REDIS_URL: str = Field(default="redis://localhost:6379/0") REDIS_URL: str = Field(default="redis://localhost:6379/0")
# JWT配置 # JWT配置
SECRET_KEY: str = Field(default="your-secret-key-here") # 安全警告:必须在生产环境设置 SECRET_KEY 环境变量
# 可以使用命令生成python -c "import secrets; print(secrets.token_urlsafe(32))"
SECRET_KEY: str = Field(
default="INSECURE-DEFAULT-KEY-CHANGE-IN-PRODUCTION",
description="JWT 密钥,生产环境必须通过环境变量设置安全的随机密钥"
)
ALGORITHM: str = Field(default="HS256") ALGORITHM: str = Field(default="HS256")
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30) ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30)
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7) REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7)
@@ -165,6 +172,57 @@ def get_settings() -> Settings:
settings = get_settings() settings = get_settings()
def check_security_settings() -> list[str]:
"""
检查安全配置
返回安全警告列表,生产环境应确保列表为空
"""
warnings = []
# 检查 DEBUG 模式
if settings.DEBUG:
warnings.append(
"⚠️ DEBUG 模式已开启。生产环境请设置 DEBUG=false"
)
# 检查 SECRET_KEY
if settings.SECRET_KEY == "INSECURE-DEFAULT-KEY-CHANGE-IN-PRODUCTION":
warnings.append(
"⚠️ 使用默认 SECRET_KEY 不安全。生产环境请设置安全的 SECRET_KEY 环境变量。"
"生成命令python -c \"import secrets; print(secrets.token_urlsafe(32))\""
)
elif len(settings.SECRET_KEY) < 32:
warnings.append(
"⚠️ SECRET_KEY 长度不足 32 字符,安全性较弱"
)
# 检查数据库密码
if settings.MYSQL_PASSWORD in ["password", "123456", "root", ""]:
warnings.append(
"⚠️ 数据库密码不安全,请使用强密码"
)
return warnings
def print_security_warnings():
"""打印安全警告(应用启动时调用)"""
import logging
logger = logging.getLogger(__name__)
warnings = check_security_settings()
if warnings:
logger.warning("=" * 60)
logger.warning("安全配置警告:")
for warning in warnings:
logger.warning(warning)
logger.warning("=" * 60)
else:
logger.info("✅ 安全配置检查通过")
# ============================================ # ============================================
# 动态配置获取(支持从数据库读取) # 动态配置获取(支持从数据库读取)
# ============================================ # ============================================

View File

@@ -1,242 +1,242 @@
""" """
定时任务调度模块 定时任务调度模块
使用 APScheduler 实现定时任务: 使用 APScheduler 实现定时任务:
- 通讯录增量同步每30分钟 - 通讯录增量同步每30分钟
- 通讯录完整同步每天凌晨2点 - 通讯录完整同步每天凌晨2点
""" """
import os import os
import asyncio import asyncio
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED
from app.core.logger import logger from app.core.logger import logger
class SchedulerManager: class SchedulerManager:
""" """
定时任务调度管理器 定时任务调度管理器
单例模式,统一管理所有定时任务 单例模式,统一管理所有定时任务
""" """
_instance: Optional['SchedulerManager'] = None _instance: Optional['SchedulerManager'] = None
_scheduler: Optional[AsyncIOScheduler] = None _scheduler: Optional[AsyncIOScheduler] = None
_initialized: bool = False _initialized: bool = False
# 配置(可通过环境变量覆盖) # 配置(可通过环境变量覆盖)
AUTO_SYNC_ENABLED: bool = True AUTO_SYNC_ENABLED: bool = True
INCREMENTAL_SYNC_INTERVAL_MINUTES: int = 30 # 增量同步间隔(分钟) INCREMENTAL_SYNC_INTERVAL_MINUTES: int = 30 # 增量同步间隔(分钟)
FULL_SYNC_HOUR: int = 2 # 完整同步执行时间小时24小时制 FULL_SYNC_HOUR: int = 2 # 完整同步执行时间小时24小时制
def __new__(cls): def __new__(cls):
if cls._instance is None: if cls._instance is None:
cls._instance = super().__new__(cls) cls._instance = super().__new__(cls)
return cls._instance return cls._instance
@classmethod @classmethod
def get_instance(cls) -> 'SchedulerManager': def get_instance(cls) -> 'SchedulerManager':
"""获取调度管理器实例""" """获取调度管理器实例"""
if cls._instance is None: if cls._instance is None:
cls._instance = cls() cls._instance = cls()
return cls._instance return cls._instance
@classmethod @classmethod
def _load_config(cls): def _load_config(cls):
"""从环境变量加载配置""" """从环境变量加载配置"""
cls.AUTO_SYNC_ENABLED = os.getenv('AUTO_SYNC_ENABLED', 'true').lower() == 'true' cls.AUTO_SYNC_ENABLED = os.getenv('AUTO_SYNC_ENABLED', 'true').lower() == 'true'
cls.INCREMENTAL_SYNC_INTERVAL_MINUTES = int(os.getenv('AUTO_SYNC_INTERVAL_MINUTES', '30')) cls.INCREMENTAL_SYNC_INTERVAL_MINUTES = int(os.getenv('AUTO_SYNC_INTERVAL_MINUTES', '30'))
cls.FULL_SYNC_HOUR = int(os.getenv('FULL_SYNC_HOUR', '2')) cls.FULL_SYNC_HOUR = int(os.getenv('FULL_SYNC_HOUR', '2'))
async def init(self, db_session_factory): async def init(self, db_session_factory):
""" """
初始化调度器 初始化调度器
Args: Args:
db_session_factory: 数据库会话工厂async_sessionmaker db_session_factory: 数据库会话工厂async_sessionmaker
""" """
if self._initialized: if self._initialized:
logger.info("调度器已初始化,跳过") logger.info("调度器已初始化,跳过")
return return
self._load_config() self._load_config()
if not self.AUTO_SYNC_ENABLED: if not self.AUTO_SYNC_ENABLED:
logger.info("自动同步已禁用,调度器不启动") logger.info("自动同步已禁用,调度器不启动")
return return
self._db_session_factory = db_session_factory self._db_session_factory = db_session_factory
self._scheduler = AsyncIOScheduler(timezone='Asia/Shanghai') self._scheduler = AsyncIOScheduler(timezone='Asia/Shanghai')
# 添加任务执行监听器 # 添加任务执行监听器
self._scheduler.add_listener(self._job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR) self._scheduler.add_listener(self._job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
# 注册定时任务 # 注册定时任务
self._register_jobs() self._register_jobs()
self._initialized = True self._initialized = True
logger.info("调度器初始化完成") logger.info("调度器初始化完成")
def _register_jobs(self): def _register_jobs(self):
"""注册所有定时任务""" """注册所有定时任务"""
if not self._scheduler: if not self._scheduler:
return return
# 1. 增量同步任务每30分钟 # 1. 增量同步任务每30分钟
self._scheduler.add_job( self._scheduler.add_job(
self._run_incremental_sync, self._run_incremental_sync,
IntervalTrigger(minutes=self.INCREMENTAL_SYNC_INTERVAL_MINUTES), IntervalTrigger(minutes=self.INCREMENTAL_SYNC_INTERVAL_MINUTES),
id='employee_incremental_sync', id='employee_incremental_sync',
name='员工增量同步', name='员工增量同步',
replace_existing=True, replace_existing=True,
max_instances=1, # 防止任务堆积 max_instances=1, # 防止任务堆积
) )
logger.info(f"已注册任务: 员工增量同步(每{self.INCREMENTAL_SYNC_INTERVAL_MINUTES}分钟)") logger.info(f"已注册任务: 员工增量同步(每{self.INCREMENTAL_SYNC_INTERVAL_MINUTES}分钟)")
# 2. 完整同步任务每天凌晨2点 # 2. 完整同步任务每天凌晨2点
self._scheduler.add_job( self._scheduler.add_job(
self._run_full_sync, self._run_full_sync,
CronTrigger(hour=self.FULL_SYNC_HOUR, minute=0), CronTrigger(hour=self.FULL_SYNC_HOUR, minute=0),
id='employee_full_sync', id='employee_full_sync',
name='员工完整同步', name='员工完整同步',
replace_existing=True, replace_existing=True,
max_instances=1, max_instances=1,
) )
logger.info(f"已注册任务: 员工完整同步(每天{self.FULL_SYNC_HOUR}:00") logger.info(f"已注册任务: 员工完整同步(每天{self.FULL_SYNC_HOUR}:00")
def _job_listener(self, event): def _job_listener(self, event):
"""任务执行监听器""" """任务执行监听器"""
job_id = event.job_id job_id = event.job_id
if event.exception: if event.exception:
logger.error( logger.error(
f"定时任务执行失败", f"定时任务执行失败",
job_id=job_id, job_id=job_id,
error=str(event.exception), error=str(event.exception),
traceback=event.traceback traceback=event.traceback
) )
else: else:
logger.info( logger.info(
f"定时任务执行完成", f"定时任务执行完成",
job_id=job_id, job_id=job_id,
return_value=str(event.retval) if event.retval else None return_value=str(event.retval) if event.retval else None
) )
async def _run_incremental_sync(self): async def _run_incremental_sync(self):
"""执行增量同步""" """执行增量同步"""
from app.services.employee_sync_service import EmployeeSyncService from app.services.employee_sync_service import EmployeeSyncService
logger.info("开始执行定时增量同步任务") logger.info("开始执行定时增量同步任务")
start_time = datetime.now() start_time = datetime.now()
try: try:
async with self._db_session_factory() as db: async with self._db_session_factory() as db:
async with EmployeeSyncService(db) as sync_service: async with EmployeeSyncService(db) as sync_service:
stats = await sync_service.incremental_sync_employees() stats = await sync_service.incremental_sync_employees()
duration = (datetime.now() - start_time).total_seconds() duration = (datetime.now() - start_time).total_seconds()
logger.info( logger.info(
"定时增量同步完成", "定时增量同步完成",
duration_seconds=duration, duration_seconds=duration,
stats=stats stats=stats
) )
return stats return stats
except Exception as e: except Exception as e:
logger.error(f"定时增量同步失败: {str(e)}") logger.error(f"定时增量同步失败: {str(e)}")
raise raise
async def _run_full_sync(self): async def _run_full_sync(self):
"""执行完整同步""" """执行完整同步"""
from app.services.employee_sync_service import EmployeeSyncService from app.services.employee_sync_service import EmployeeSyncService
logger.info("开始执行定时完整同步任务") logger.info("开始执行定时完整同步任务")
start_time = datetime.now() start_time = datetime.now()
try: try:
async with self._db_session_factory() as db: async with self._db_session_factory() as db:
async with EmployeeSyncService(db) as sync_service: async with EmployeeSyncService(db) as sync_service:
stats = await sync_service.sync_employees() stats = await sync_service.sync_employees()
duration = (datetime.now() - start_time).total_seconds() duration = (datetime.now() - start_time).total_seconds()
logger.info( logger.info(
"定时完整同步完成", "定时完整同步完成",
duration_seconds=duration, duration_seconds=duration,
stats=stats stats=stats
) )
return stats return stats
except Exception as e: except Exception as e:
logger.error(f"定时完整同步失败: {str(e)}") logger.error(f"定时完整同步失败: {str(e)}")
raise raise
def start(self): def start(self):
"""启动调度器""" """启动调度器"""
if not self._scheduler: if not self._scheduler:
logger.warning("调度器未初始化,无法启动") logger.warning("调度器未初始化,无法启动")
return return
if self._scheduler.running: if self._scheduler.running:
logger.info("调度器已在运行") logger.info("调度器已在运行")
return return
self._scheduler.start() self._scheduler.start()
logger.info("调度器已启动") logger.info("调度器已启动")
# 打印已注册的任务 # 打印已注册的任务
jobs = self._scheduler.get_jobs() jobs = self._scheduler.get_jobs()
for job in jobs: for job in jobs:
logger.info(f" - {job.name} (ID: {job.id}, 下次执行: {job.next_run_time})") logger.info(f" - {job.name} (ID: {job.id}, 下次执行: {job.next_run_time})")
def stop(self): def stop(self):
"""停止调度器""" """停止调度器"""
if self._scheduler and self._scheduler.running: if self._scheduler and self._scheduler.running:
self._scheduler.shutdown(wait=True) self._scheduler.shutdown(wait=True)
logger.info("调度器已停止") logger.info("调度器已停止")
def get_jobs(self): def get_jobs(self):
"""获取所有任务列表""" """获取所有任务列表"""
if not self._scheduler: if not self._scheduler:
return [] return []
return [ return [
{ {
'id': job.id, 'id': job.id,
'name': job.name, 'name': job.name,
'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None, 'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None,
'pending': job.pending, 'pending': job.pending,
} }
for job in self._scheduler.get_jobs() for job in self._scheduler.get_jobs()
] ]
async def trigger_job(self, job_id: str): async def trigger_job(self, job_id: str):
""" """
手动触发任务 手动触发任务
Args: Args:
job_id: 任务ID job_id: 任务ID
""" """
if not self._scheduler: if not self._scheduler:
raise RuntimeError("调度器未初始化") raise RuntimeError("调度器未初始化")
job = self._scheduler.get_job(job_id) job = self._scheduler.get_job(job_id)
if not job: if not job:
raise ValueError(f"任务不存在: {job_id}") raise ValueError(f"任务不存在: {job_id}")
# 立即执行 # 立即执行
if job_id == 'employee_incremental_sync': if job_id == 'employee_incremental_sync':
return await self._run_incremental_sync() return await self._run_incremental_sync()
elif job_id == 'employee_full_sync': elif job_id == 'employee_full_sync':
return await self._run_full_sync() return await self._run_full_sync()
else: else:
raise ValueError(f"未知任务: {job_id}") raise ValueError(f"未知任务: {job_id}")
# 全局调度管理器实例 # 全局调度管理器实例
scheduler_manager = SchedulerManager.get_instance() scheduler_manager = SchedulerManager.get_instance()

View File

@@ -0,0 +1,71 @@
-- ================================================================
-- 用户课程学习进度表迁移脚本
-- 创建日期: 2026-01-30
-- 功能: 添加用户课程进度追踪表和用户资料进度追踪表
-- ================================================================
-- 事务开始
START TRANSACTION;
-- ================================================================
-- 1. 创建用户课程进度表
-- ================================================================
CREATE TABLE IF NOT EXISTS user_course_progress (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL COMMENT '用户ID',
course_id INT NOT NULL COMMENT '课程ID',
status VARCHAR(20) NOT NULL DEFAULT 'not_started' COMMENT '学习状态not_started/in_progress/completed',
progress_percent FLOAT NOT NULL DEFAULT 0 COMMENT '完成百分比(0-100)',
completed_materials INT NOT NULL DEFAULT 0 COMMENT '已完成资料数',
total_materials INT NOT NULL DEFAULT 0 COMMENT '总资料数',
total_study_time INT NOT NULL DEFAULT 0 COMMENT '总学习时长(秒)',
first_accessed_at DATETIME COMMENT '首次访问时间',
last_accessed_at DATETIME COMMENT '最后访问时间',
completed_at DATETIME COMMENT '完成时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_course (user_id, course_id),
INDEX idx_user_course_progress_user (user_id),
INDEX idx_user_course_progress_course (course_id),
INDEX idx_user_course_progress_status (status),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户课程进度表';
-- ================================================================
-- 2. 创建用户资料进度表
-- ================================================================
CREATE TABLE IF NOT EXISTS user_material_progress (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL COMMENT '用户ID',
material_id INT NOT NULL COMMENT '资料ID',
course_id INT NOT NULL COMMENT '课程ID冗余字段',
is_completed BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否已完成',
progress_percent FLOAT NOT NULL DEFAULT 0 COMMENT '阅读/播放进度百分比(0-100)',
last_position INT NOT NULL DEFAULT 0 COMMENT '上次播放位置(秒)',
total_duration INT NOT NULL DEFAULT 0 COMMENT '媒体总时长(秒)',
study_time INT NOT NULL DEFAULT 0 COMMENT '学习时长(秒)',
first_accessed_at DATETIME COMMENT '首次访问时间',
last_accessed_at DATETIME COMMENT '最后访问时间',
completed_at DATETIME COMMENT '完成时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_material (user_id, material_id),
INDEX idx_user_material_progress_user (user_id),
INDEX idx_user_material_progress_material (material_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (material_id) REFERENCES course_materials(id) ON DELETE CASCADE,
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资料进度表';
-- 提交事务
COMMIT;
-- ================================================================
-- 验证表创建
-- ================================================================
SELECT 'user_course_progress' as table_name, COUNT(*) as count FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'user_course_progress'
UNION ALL
SELECT 'user_material_progress' as table_name, COUNT(*) as count FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'user_material_progress';

View File

@@ -32,6 +32,11 @@ from app.models.certificate import (
UserCertificate, UserCertificate,
CertificateType, CertificateType,
) )
from app.models.user_course_progress import (
UserCourseProgress,
UserMaterialProgress,
ProgressStatus,
)
__all__ = [ __all__ = [
"Base", "Base",
@@ -72,4 +77,7 @@ __all__ = [
"CertificateTemplate", "CertificateTemplate",
"UserCertificate", "UserCertificate",
"CertificateType", "CertificateType",
"UserCourseProgress",
"UserMaterialProgress",
"ProgressStatus",
] ]

View File

@@ -1,76 +1,76 @@
""" """
证书系统数据模型 证书系统数据模型
定义证书模板和用户证书的数据结构 定义证书模板和用户证书的数据结构
""" """
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Enum as SQLEnum, DECIMAL, JSON from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Enum as SQLEnum, DECIMAL, JSON
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.models.base import Base from app.models.base import Base
class CertificateType(str, Enum): class CertificateType(str, Enum):
"""证书类型枚举""" """证书类型枚举"""
COURSE = "course" # 课程结业证书 COURSE = "course" # 课程结业证书
EXAM = "exam" # 考试合格证书 EXAM = "exam" # 考试合格证书
ACHIEVEMENT = "achievement" # 成就证书 ACHIEVEMENT = "achievement" # 成就证书
class CertificateTemplate(Base): class CertificateTemplate(Base):
"""证书模板表""" """证书模板表"""
__tablename__ = "certificate_templates" __tablename__ = "certificate_templates"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False, comment="模板名称") name = Column(String(100), nullable=False, comment="模板名称")
type = Column(SQLEnum(CertificateType), nullable=False, comment="证书类型") type = Column(SQLEnum(CertificateType), nullable=False, comment="证书类型")
background_url = Column(String(500), comment="证书背景图URL") background_url = Column(String(500), comment="证书背景图URL")
template_html = Column(Text, comment="HTML模板内容") template_html = Column(Text, comment="HTML模板内容")
template_style = Column(Text, comment="CSS样式") template_style = Column(Text, comment="CSS样式")
is_active = Column(Boolean, default=True, comment="是否启用") is_active = Column(Boolean, default=True, comment="是否启用")
sort_order = Column(Integer, default=0, comment="排序顺序") sort_order = Column(Integer, default=0, comment="排序顺序")
created_at = Column(DateTime, nullable=False, default=datetime.now) created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
# 关联 # 关联
certificates = relationship("UserCertificate", back_populates="template") certificates = relationship("UserCertificate", back_populates="template")
class UserCertificate(Base): class UserCertificate(Base):
"""用户证书表""" """用户证书表"""
__tablename__ = "user_certificates" __tablename__ = "user_certificates"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID") user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID")
template_id = Column(Integer, ForeignKey("certificate_templates.id"), nullable=False, comment="模板ID") template_id = Column(Integer, ForeignKey("certificate_templates.id"), nullable=False, comment="模板ID")
certificate_no = Column(String(50), unique=True, nullable=False, comment="证书编号") certificate_no = Column(String(50), unique=True, nullable=False, comment="证书编号")
title = Column(String(200), nullable=False, comment="证书标题") title = Column(String(200), nullable=False, comment="证书标题")
description = Column(Text, comment="证书描述") description = Column(Text, comment="证书描述")
issued_at = Column(DateTime, default=datetime.now, comment="颁发时间") issued_at = Column(DateTime, default=datetime.now, comment="颁发时间")
valid_until = Column(DateTime, comment="有效期至") valid_until = Column(DateTime, comment="有效期至")
# 关联信息 # 关联信息
course_id = Column(Integer, comment="关联课程ID") course_id = Column(Integer, comment="关联课程ID")
exam_id = Column(Integer, comment="关联考试ID") exam_id = Column(Integer, comment="关联考试ID")
badge_id = Column(Integer, comment="关联奖章ID") badge_id = Column(Integer, comment="关联奖章ID")
# 成绩信息 # 成绩信息
score = Column(DECIMAL(5, 2), comment="考试分数") score = Column(DECIMAL(5, 2), comment="考试分数")
completion_rate = Column(DECIMAL(5, 2), comment="完成率") completion_rate = Column(DECIMAL(5, 2), comment="完成率")
# 生成的文件 # 生成的文件
pdf_url = Column(String(500), comment="PDF文件URL") pdf_url = Column(String(500), comment="PDF文件URL")
image_url = Column(String(500), comment="分享图片URL") image_url = Column(String(500), comment="分享图片URL")
# 元数据 # 元数据
meta_data = Column(JSON, comment="扩展元数据") meta_data = Column(JSON, comment="扩展元数据")
created_at = Column(DateTime, nullable=False, default=datetime.now) created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
# 关联 # 关联
template = relationship("CertificateTemplate", back_populates="certificates") template = relationship("CertificateTemplate", back_populates="certificates")
user = relationship("User", backref="certificates") user = relationship("User", backref="certificates")

View File

@@ -1,140 +1,140 @@
""" """
等级与奖章系统模型 等级与奖章系统模型
包含: 包含:
- UserLevel: 用户等级信息 - UserLevel: 用户等级信息
- ExpHistory: 经验值变化历史 - ExpHistory: 经验值变化历史
- BadgeDefinition: 奖章定义 - BadgeDefinition: 奖章定义
- UserBadge: 用户已获得的奖章 - UserBadge: 用户已获得的奖章
- LevelConfig: 等级配置 - LevelConfig: 等级配置
""" """
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, List from typing import Optional, List
from sqlalchemy import Column, Integer, String, DateTime, Date, Boolean, ForeignKey, Text from sqlalchemy import Column, Integer, String, DateTime, Date, Boolean, ForeignKey, Text
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.models.base import Base, BaseModel from app.models.base import Base, BaseModel
class UserLevel(Base): class UserLevel(Base):
"""用户等级表""" """用户等级表"""
__tablename__ = "user_levels" __tablename__ = "user_levels"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
level = Column(Integer, nullable=False, default=1, comment="当前等级") level = Column(Integer, nullable=False, default=1, comment="当前等级")
exp = Column(Integer, nullable=False, default=0, comment="当前经验值") exp = Column(Integer, nullable=False, default=0, comment="当前经验值")
total_exp = Column(Integer, nullable=False, default=0, comment="累计获得经验值") total_exp = Column(Integer, nullable=False, default=0, comment="累计获得经验值")
login_streak = Column(Integer, nullable=False, default=0, comment="连续登录天数") login_streak = Column(Integer, nullable=False, default=0, comment="连续登录天数")
max_login_streak = Column(Integer, nullable=False, default=0, comment="历史最长连续登录天数") max_login_streak = Column(Integer, nullable=False, default=0, comment="历史最长连续登录天数")
last_login_date = Column(Date, nullable=True, comment="最后登录日期") last_login_date = Column(Date, nullable=True, comment="最后登录日期")
last_checkin_at = Column(DateTime, nullable=True, comment="最后签到时间") last_checkin_at = Column(DateTime, nullable=True, comment="最后签到时间")
created_at = Column(DateTime, nullable=False, default=datetime.now) created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
# 关联 # 关联
user = relationship("User", backref="user_level", uselist=False) user = relationship("User", backref="user_level", uselist=False)
class ExpHistory(Base): class ExpHistory(Base):
"""经验值历史表""" """经验值历史表"""
__tablename__ = "exp_history" __tablename__ = "exp_history"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
exp_change = Column(Integer, nullable=False, comment="经验值变化") exp_change = Column(Integer, nullable=False, comment="经验值变化")
exp_type = Column(String(50), nullable=False, index=True, comment="类型exam/practice/training/task/login/badge/other") exp_type = Column(String(50), nullable=False, index=True, comment="类型exam/practice/training/task/login/badge/other")
source_id = Column(Integer, nullable=True, comment="来源记录ID") source_id = Column(Integer, nullable=True, comment="来源记录ID")
description = Column(String(255), nullable=False, comment="描述") description = Column(String(255), nullable=False, comment="描述")
level_before = Column(Integer, nullable=True, comment="变化前等级") level_before = Column(Integer, nullable=True, comment="变化前等级")
level_after = Column(Integer, nullable=True, comment="变化后等级") level_after = Column(Integer, nullable=True, comment="变化后等级")
created_at = Column(DateTime, nullable=False, default=datetime.now) created_at = Column(DateTime, nullable=False, default=datetime.now)
# 关联 # 关联
user = relationship("User", backref="exp_histories") user = relationship("User", backref="exp_histories")
class BadgeDefinition(Base): class BadgeDefinition(Base):
"""奖章定义表""" """奖章定义表"""
__tablename__ = "badge_definitions" __tablename__ = "badge_definitions"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(50), nullable=False, unique=True, comment="奖章编码") code = Column(String(50), nullable=False, unique=True, comment="奖章编码")
name = Column(String(100), nullable=False, comment="奖章名称") name = Column(String(100), nullable=False, comment="奖章名称")
description = Column(String(255), nullable=False, comment="奖章描述") description = Column(String(255), nullable=False, comment="奖章描述")
icon = Column(String(100), nullable=False, default="Medal", comment="图标名称") icon = Column(String(100), nullable=False, default="Medal", comment="图标名称")
category = Column(String(50), nullable=False, index=True, comment="分类") category = Column(String(50), nullable=False, index=True, comment="分类")
condition_type = Column(String(50), nullable=False, comment="条件类型") condition_type = Column(String(50), nullable=False, comment="条件类型")
condition_field = Column(String(100), nullable=True, comment="条件字段") condition_field = Column(String(100), nullable=True, comment="条件字段")
condition_value = Column(Integer, nullable=False, default=1, comment="条件数值") condition_value = Column(Integer, nullable=False, default=1, comment="条件数值")
exp_reward = Column(Integer, nullable=False, default=0, comment="奖励经验值") exp_reward = Column(Integer, nullable=False, default=0, comment="奖励经验值")
sort_order = Column(Integer, nullable=False, default=0, comment="排序") sort_order = Column(Integer, nullable=False, default=0, comment="排序")
is_active = Column(Boolean, nullable=False, default=True, comment="是否启用") is_active = Column(Boolean, nullable=False, default=True, comment="是否启用")
created_at = Column(DateTime, nullable=False, default=datetime.now) created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
# 关联 # 关联
user_badges = relationship("UserBadge", back_populates="badge") user_badges = relationship("UserBadge", back_populates="badge")
class UserBadge(Base): class UserBadge(Base):
"""用户奖章表""" """用户奖章表"""
__tablename__ = "user_badges" __tablename__ = "user_badges"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
badge_id = Column(Integer, ForeignKey("badge_definitions.id", ondelete="CASCADE"), nullable=False, index=True) badge_id = Column(Integer, ForeignKey("badge_definitions.id", ondelete="CASCADE"), nullable=False, index=True)
unlocked_at = Column(DateTime, nullable=False, default=datetime.now, comment="解锁时间") unlocked_at = Column(DateTime, nullable=False, default=datetime.now, comment="解锁时间")
is_notified = Column(Boolean, nullable=False, default=False, comment="是否已通知") is_notified = Column(Boolean, nullable=False, default=False, comment="是否已通知")
notified_at = Column(DateTime, nullable=True, comment="通知时间") notified_at = Column(DateTime, nullable=True, comment="通知时间")
created_at = Column(DateTime, nullable=False, default=datetime.now) created_at = Column(DateTime, nullable=False, default=datetime.now)
# 关联 # 关联
user = relationship("User", backref="badges") user = relationship("User", backref="badges")
badge = relationship("BadgeDefinition", back_populates="user_badges") badge = relationship("BadgeDefinition", back_populates="user_badges")
class LevelConfig(Base): class LevelConfig(Base):
"""等级配置表""" """等级配置表"""
__tablename__ = "level_configs" __tablename__ = "level_configs"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
level = Column(Integer, nullable=False, unique=True, comment="等级") level = Column(Integer, nullable=False, unique=True, comment="等级")
exp_required = Column(Integer, nullable=False, comment="升到此级所需经验值") exp_required = Column(Integer, nullable=False, comment="升到此级所需经验值")
total_exp_required = Column(Integer, nullable=False, comment="累计所需经验值") total_exp_required = Column(Integer, nullable=False, comment="累计所需经验值")
title = Column(String(50), nullable=False, comment="等级称号") title = Column(String(50), nullable=False, comment="等级称号")
color = Column(String(20), nullable=True, comment="等级颜色") color = Column(String(20), nullable=True, comment="等级颜色")
created_at = Column(DateTime, nullable=False, default=datetime.now) created_at = Column(DateTime, nullable=False, default=datetime.now)
# 经验值类型枚举 # 经验值类型枚举
class ExpType: class ExpType:
"""经验值类型""" """经验值类型"""
EXAM = "exam" # 考试 EXAM = "exam" # 考试
PRACTICE = "practice" # 练习 PRACTICE = "practice" # 练习
TRAINING = "training" # 陪练 TRAINING = "training" # 陪练
TASK = "task" # 任务 TASK = "task" # 任务
LOGIN = "login" # 登录/签到 LOGIN = "login" # 登录/签到
BADGE = "badge" # 奖章奖励 BADGE = "badge" # 奖章奖励
OTHER = "other" # 其他 OTHER = "other" # 其他
# 奖章分类枚举 # 奖章分类枚举
class BadgeCategory: class BadgeCategory:
"""奖章分类""" """奖章分类"""
LEARNING = "learning" # 学习进度 LEARNING = "learning" # 学习进度
EXAM = "exam" # 考试成绩 EXAM = "exam" # 考试成绩
PRACTICE = "practice" # 练习时长 PRACTICE = "practice" # 练习时长
STREAK = "streak" # 连续打卡 STREAK = "streak" # 连续打卡
SPECIAL = "special" # 特殊成就 SPECIAL = "special" # 特殊成就
# 条件类型枚举 # 条件类型枚举
class ConditionType: class ConditionType:
"""解锁条件类型""" """解锁条件类型"""
COUNT = "count" # 次数 COUNT = "count" # 次数
SCORE = "score" # 分数 SCORE = "score" # 分数
STREAK = "streak" # 连续天数 STREAK = "streak" # 连续天数
LEVEL = "level" # 等级 LEVEL = "level" # 等级
DURATION = "duration" # 时长 DURATION = "duration" # 时长

View File

@@ -1,122 +1,122 @@
""" """
双人对练房间模型 双人对练房间模型
功能: 功能:
- 房间管理(创建、加入、状态) - 房间管理(创建、加入、状态)
- 参与者管理 - 参与者管理
- 实时消息同步 - 实时消息同步
""" """
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON
from sqlalchemy.sql import func from sqlalchemy.sql import func
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.models.base import Base from app.models.base import Base
class PracticeRoom(Base): class PracticeRoom(Base):
"""双人对练房间模型""" """双人对练房间模型"""
__tablename__ = "practice_rooms" __tablename__ = "practice_rooms"
id = Column(Integer, primary_key=True, index=True, comment="房间ID") id = Column(Integer, primary_key=True, index=True, comment="房间ID")
room_code = Column(String(10), unique=True, nullable=False, index=True, comment="6位邀请码") room_code = Column(String(10), unique=True, nullable=False, index=True, comment="6位邀请码")
room_name = Column(String(200), comment="房间名称") room_name = Column(String(200), comment="房间名称")
# 场景信息 # 场景信息
scene_id = Column(Integer, ForeignKey("practice_scenes.id", ondelete="SET NULL"), comment="关联场景ID") scene_id = Column(Integer, ForeignKey("practice_scenes.id", ondelete="SET NULL"), comment="关联场景ID")
scene_name = Column(String(200), comment="场景名称") scene_name = Column(String(200), comment="场景名称")
scene_type = Column(String(50), comment="场景类型") scene_type = Column(String(50), comment="场景类型")
scene_background = Column(Text, comment="场景背景") scene_background = Column(Text, comment="场景背景")
# 角色设置 # 角色设置
role_a_name = Column(String(50), default="角色A", comment="角色A名称如销售顾问") role_a_name = Column(String(50), default="角色A", comment="角色A名称如销售顾问")
role_b_name = Column(String(50), default="角色B", comment="角色B名称如顾客") role_b_name = Column(String(50), default="角色B", comment="角色B名称如顾客")
role_a_description = Column(Text, comment="角色A描述") role_a_description = Column(Text, comment="角色A描述")
role_b_description = Column(Text, comment="角色B描述") role_b_description = Column(Text, comment="角色B描述")
# 参与者信息 # 参与者信息
host_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="房主用户ID") host_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="房主用户ID")
guest_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="加入者用户ID") guest_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="加入者用户ID")
host_role = Column(String(10), default="A", comment="房主选择的角色(A/B)") host_role = Column(String(10), default="A", comment="房主选择的角色(A/B)")
max_participants = Column(Integer, default=2, comment="最大参与人数") max_participants = Column(Integer, default=2, comment="最大参与人数")
# 状态和时间 # 状态和时间
status = Column(String(20), default="waiting", index=True, comment="状态: waiting/ready/practicing/completed/canceled") status = Column(String(20), default="waiting", index=True, comment="状态: waiting/ready/practicing/completed/canceled")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间") created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
started_at = Column(DateTime, comment="开始时间") started_at = Column(DateTime, comment="开始时间")
ended_at = Column(DateTime, comment="结束时间") ended_at = Column(DateTime, comment="结束时间")
duration_seconds = Column(Integer, default=0, comment="对练时长(秒)") duration_seconds = Column(Integer, default=0, comment="对练时长(秒)")
# 对话统计 # 对话统计
total_turns = Column(Integer, default=0, comment="总对话轮次") total_turns = Column(Integer, default=0, comment="总对话轮次")
role_a_turns = Column(Integer, default=0, comment="角色A发言次数") role_a_turns = Column(Integer, default=0, comment="角色A发言次数")
role_b_turns = Column(Integer, default=0, comment="角色B发言次数") role_b_turns = Column(Integer, default=0, comment="角色B发言次数")
# 软删除 # 软删除
is_deleted = Column(Boolean, default=False, comment="是否删除") is_deleted = Column(Boolean, default=False, comment="是否删除")
deleted_at = Column(DateTime, comment="删除时间") deleted_at = Column(DateTime, comment="删除时间")
def __repr__(self): def __repr__(self):
return f"<PracticeRoom(code='{self.room_code}', status='{self.status}')>" return f"<PracticeRoom(code='{self.room_code}', status='{self.status}')>"
@property @property
def is_full(self) -> bool: def is_full(self) -> bool:
"""房间是否已满""" """房间是否已满"""
return self.guest_user_id is not None return self.guest_user_id is not None
@property @property
def participant_count(self) -> int: def participant_count(self) -> int:
"""当前参与人数""" """当前参与人数"""
count = 1 # 房主 count = 1 # 房主
if self.guest_user_id: if self.guest_user_id:
count += 1 count += 1
return count return count
def get_user_role(self, user_id: int) -> str: def get_user_role(self, user_id: int) -> str:
"""获取用户在房间中的角色""" """获取用户在房间中的角色"""
if user_id == self.host_user_id: if user_id == self.host_user_id:
return self.host_role return self.host_role
elif user_id == self.guest_user_id: elif user_id == self.guest_user_id:
return "B" if self.host_role == "A" else "A" return "B" if self.host_role == "A" else "A"
return None return None
def get_role_name(self, role: str) -> str: def get_role_name(self, role: str) -> str:
"""获取角色名称""" """获取角色名称"""
if role == "A": if role == "A":
return self.role_a_name return self.role_a_name
elif role == "B": elif role == "B":
return self.role_b_name return self.role_b_name
return None return None
def get_user_role_name(self, user_id: int) -> str: def get_user_role_name(self, user_id: int) -> str:
"""获取用户的角色名称""" """获取用户的角色名称"""
role = self.get_user_role(user_id) role = self.get_user_role(user_id)
return self.get_role_name(role) if role else None return self.get_role_name(role) if role else None
class PracticeRoomMessage(Base): class PracticeRoomMessage(Base):
"""房间实时消息模型""" """房间实时消息模型"""
__tablename__ = "practice_room_messages" __tablename__ = "practice_room_messages"
id = Column(Integer, primary_key=True, index=True, comment="消息ID") id = Column(Integer, primary_key=True, index=True, comment="消息ID")
room_id = Column(Integer, ForeignKey("practice_rooms.id", ondelete="CASCADE"), nullable=False, index=True, comment="房间ID") room_id = Column(Integer, ForeignKey("practice_rooms.id", ondelete="CASCADE"), nullable=False, index=True, comment="房间ID")
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="发送者用户ID") user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="发送者用户ID")
message_type = Column(String(20), nullable=False, comment="消息类型: chat/system/join/leave/start/end") message_type = Column(String(20), nullable=False, comment="消息类型: chat/system/join/leave/start/end")
content = Column(Text, comment="消息内容") content = Column(Text, comment="消息内容")
role_name = Column(String(50), comment="角色名称") role_name = Column(String(50), comment="角色名称")
sequence = Column(Integer, nullable=False, comment="消息序号") sequence = Column(Integer, nullable=False, comment="消息序号")
created_at = Column(DateTime(3), server_default=func.now(3), comment="创建时间") created_at = Column(DateTime(3), server_default=func.now(3), comment="创建时间")
def __repr__(self): def __repr__(self):
return f"<PracticeRoomMessage(room_id={self.room_id}, type='{self.message_type}', seq={self.sequence})>" return f"<PracticeRoomMessage(room_id={self.room_id}, type='{self.message_type}', seq={self.sequence})>"
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""转换为字典用于SSE推送""" """转换为字典用于SSE推送"""
return { return {
"id": self.id, "id": self.id,
"room_id": self.room_id, "room_id": self.room_id,
"user_id": self.user_id, "user_id": self.user_id,
"message_type": self.message_type, "message_type": self.message_type,
"content": self.content, "content": self.content,
"role_name": self.role_name, "role_name": self.role_name,
"sequence": self.sequence, "sequence": self.sequence,
"created_at": self.created_at.isoformat() if self.created_at else None "created_at": self.created_at.isoformat() if self.created_at else None
} }

View File

@@ -0,0 +1,201 @@
"""
用户课程学习进度数据库模型
"""
from enum import Enum
from typing import Optional
from datetime import datetime
from sqlalchemy import (
String,
Integer,
Boolean,
ForeignKey,
Float,
DateTime,
UniqueConstraint,
Index,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import BaseModel
class ProgressStatus(str, Enum):
"""学习进度状态枚举"""
NOT_STARTED = "not_started" # 未开始
IN_PROGRESS = "in_progress" # 学习中
COMPLETED = "completed" # 已完成
class UserCourseProgress(BaseModel):
"""
用户课程进度表
记录用户对每门课程的整体学习进度
"""
__tablename__ = "user_course_progress"
__table_args__ = (
UniqueConstraint("user_id", "course_id", name="uq_user_course"),
Index("idx_user_course_progress_user", "user_id"),
Index("idx_user_course_progress_course", "course_id"),
Index("idx_user_course_progress_status", "status"),
)
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
comment="用户ID",
)
course_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("courses.id", ondelete="CASCADE"),
nullable=False,
comment="课程ID",
)
# 进度信息
status: Mapped[ProgressStatus] = mapped_column(
String(20),
default=ProgressStatus.NOT_STARTED.value,
nullable=False,
comment="学习状态not_started/in_progress/completed",
)
progress_percent: Mapped[float] = mapped_column(
Float,
default=0.0,
nullable=False,
comment="完成百分比(0-100)",
)
completed_materials: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
comment="已完成资料数",
)
total_materials: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
comment="总资料数",
)
# 学习时长统计
total_study_time: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
comment="总学习时长(秒)",
)
# 时间记录
first_accessed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="首次访问时间",
)
last_accessed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="最后访问时间",
)
completed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="完成时间",
)
# 关联关系
user = relationship("User", backref="course_progress")
course = relationship("Course", backref="user_progress")
class UserMaterialProgress(BaseModel):
"""
用户资料进度表
记录用户对每个课程资料的学习进度
"""
__tablename__ = "user_material_progress"
__table_args__ = (
UniqueConstraint("user_id", "material_id", name="uq_user_material"),
Index("idx_user_material_progress_user", "user_id"),
Index("idx_user_material_progress_material", "material_id"),
)
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
comment="用户ID",
)
material_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("course_materials.id", ondelete="CASCADE"),
nullable=False,
comment="资料ID",
)
course_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("courses.id", ondelete="CASCADE"),
nullable=False,
comment="课程ID冗余字段便于查询",
)
# 进度信息
is_completed: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False,
comment="是否已完成",
)
progress_percent: Mapped[float] = mapped_column(
Float,
default=0.0,
nullable=False,
comment="阅读/播放进度百分比(0-100)",
)
# 视频/音频特有字段
last_position: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
comment="上次播放位置(秒)",
)
total_duration: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
comment="媒体总时长(秒)",
)
# 学习时长
study_time: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
comment="学习时长(秒)",
)
# 时间记录
first_accessed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="首次访问时间",
)
last_accessed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="最后访问时间",
)
completed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="完成时间",
)
# 关联关系
user = relationship("User", backref="material_progress")
material = relationship("CourseMaterial", backref="user_progress")
course = relationship("Course", backref="material_user_progress")

View File

@@ -1,323 +1,323 @@
""" """
双人对练分析服务 双人对练分析服务
功能: 功能:
- 分析双人对练对话 - 分析双人对练对话
- 生成双方评估报告 - 生成双方评估报告
- 对话标注和建议 - 对话标注和建议
""" """
import json import json
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from app.services.ai.ai_service import AIService from app.services.ai.ai_service import AIService
from app.services.ai.prompts.duo_practice_prompts import SYSTEM_PROMPT, USER_PROMPT from app.services.ai.prompts.duo_practice_prompts import SYSTEM_PROMPT, USER_PROMPT
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@dataclass @dataclass
class UserEvaluation: class UserEvaluation:
"""用户评估结果""" """用户评估结果"""
user_name: str user_name: str
role_name: str role_name: str
total_score: int total_score: int
dimensions: Dict[str, Dict[str, Any]] dimensions: Dict[str, Dict[str, Any]]
highlights: List[str] highlights: List[str]
improvements: List[Dict[str, str]] improvements: List[Dict[str, str]]
@dataclass @dataclass
class DuoPracticeAnalysisResult: class DuoPracticeAnalysisResult:
"""双人对练分析结果""" """双人对练分析结果"""
# 整体评估 # 整体评估
interaction_quality: int = 0 interaction_quality: int = 0
scene_restoration: int = 0 scene_restoration: int = 0
overall_comment: str = "" overall_comment: str = ""
# 用户A评估 # 用户A评估
user_a_evaluation: Optional[UserEvaluation] = None user_a_evaluation: Optional[UserEvaluation] = None
# 用户B评估 # 用户B评估
user_b_evaluation: Optional[UserEvaluation] = None user_b_evaluation: Optional[UserEvaluation] = None
# 对话标注 # 对话标注
dialogue_annotations: List[Dict[str, Any]] = field(default_factory=list) dialogue_annotations: List[Dict[str, Any]] = field(default_factory=list)
# AI 元数据 # AI 元数据
raw_response: str = "" raw_response: str = ""
ai_provider: str = "" ai_provider: str = ""
ai_model: str = "" ai_model: str = ""
ai_latency_ms: int = 0 ai_latency_ms: int = 0
class DuoPracticeAnalysisService: class DuoPracticeAnalysisService:
""" """
双人对练分析服务 双人对练分析服务
使用示例: 使用示例:
```python ```python
service = DuoPracticeAnalysisService() service = DuoPracticeAnalysisService()
result = await service.analyze( result = await service.analyze(
scene_name="销售场景", scene_name="销售场景",
scene_background="客户咨询产品", scene_background="客户咨询产品",
role_a_name="销售顾问", role_a_name="销售顾问",
role_b_name="顾客", role_b_name="顾客",
user_a_name="张三", user_a_name="张三",
user_b_name="李四", user_b_name="李四",
dialogue_history=dialogue_list, dialogue_history=dialogue_list,
duration_seconds=300, duration_seconds=300,
total_turns=20 total_turns=20
) )
``` ```
""" """
MODULE_CODE = "duo_practice_analysis" MODULE_CODE = "duo_practice_analysis"
async def analyze( async def analyze(
self, self,
scene_name: str, scene_name: str,
scene_background: str, scene_background: str,
role_a_name: str, role_a_name: str,
role_b_name: str, role_b_name: str,
role_a_description: str, role_a_description: str,
role_b_description: str, role_b_description: str,
user_a_name: str, user_a_name: str,
user_b_name: str, user_b_name: str,
dialogue_history: List[Dict[str, Any]], dialogue_history: List[Dict[str, Any]],
duration_seconds: int, duration_seconds: int,
total_turns: int, total_turns: int,
db: Any = None db: Any = None
) -> DuoPracticeAnalysisResult: ) -> DuoPracticeAnalysisResult:
""" """
分析双人对练 分析双人对练
Args: Args:
scene_name: 场景名称 scene_name: 场景名称
scene_background: 场景背景 scene_background: 场景背景
role_a_name: 角色A名称 role_a_name: 角色A名称
role_b_name: 角色B名称 role_b_name: 角色B名称
role_a_description: 角色A描述 role_a_description: 角色A描述
role_b_description: 角色B描述 role_b_description: 角色B描述
user_a_name: 用户A名称 user_a_name: 用户A名称
user_b_name: 用户B名称 user_b_name: 用户B名称
dialogue_history: 对话历史列表 dialogue_history: 对话历史列表
duration_seconds: 对练时长(秒) duration_seconds: 对练时长(秒)
total_turns: 总对话轮次 total_turns: 总对话轮次
db: 数据库会话 db: 数据库会话
Returns: Returns:
DuoPracticeAnalysisResult: 分析结果 DuoPracticeAnalysisResult: 分析结果
""" """
try: try:
logger.info(f"开始双人对练分析: {scene_name}, 轮次={total_turns}") logger.info(f"开始双人对练分析: {scene_name}, 轮次={total_turns}")
# 格式化对话历史 # 格式化对话历史
dialogue_text = self._format_dialogue_history(dialogue_history) dialogue_text = self._format_dialogue_history(dialogue_history)
# 创建 AI 服务 # 创建 AI 服务
ai_service = AIService(module_code=self.MODULE_CODE, db_session=db) ai_service = AIService(module_code=self.MODULE_CODE, db_session=db)
# 构建用户提示词 # 构建用户提示词
user_prompt = USER_PROMPT.format( user_prompt = USER_PROMPT.format(
scene_name=scene_name, scene_name=scene_name,
scene_background=scene_background or "未设置", scene_background=scene_background or "未设置",
role_a_name=role_a_name, role_a_name=role_a_name,
role_b_name=role_b_name, role_b_name=role_b_name,
role_a_description=role_a_description or f"扮演{role_a_name}角色", role_a_description=role_a_description or f"扮演{role_a_name}角色",
role_b_description=role_b_description or f"扮演{role_b_name}角色", role_b_description=role_b_description or f"扮演{role_b_name}角色",
user_a_name=user_a_name, user_a_name=user_a_name,
user_b_name=user_b_name, user_b_name=user_b_name,
dialogue_history=dialogue_text, dialogue_history=dialogue_text,
duration_seconds=duration_seconds, duration_seconds=duration_seconds,
total_turns=total_turns total_turns=total_turns
) )
# 调用 AI # 调用 AI
messages = [ messages = [
{"role": "system", "content": SYSTEM_PROMPT}, {"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt} {"role": "user", "content": user_prompt}
] ]
ai_response = await ai_service.chat( ai_response = await ai_service.chat(
messages=messages, messages=messages,
model="gemini-3-flash-preview", # 使用快速模型 model="gemini-3-flash-preview", # 使用快速模型
temperature=0.3, temperature=0.3,
prompt_name="duo_practice_analysis" prompt_name="duo_practice_analysis"
) )
logger.info(f"AI 分析完成: provider={ai_response.provider}, latency={ai_response.latency_ms}ms") logger.info(f"AI 分析完成: provider={ai_response.provider}, latency={ai_response.latency_ms}ms")
# 解析 AI 输出 # 解析 AI 输出
result = self._parse_analysis_result( result = self._parse_analysis_result(
ai_response.content, ai_response.content,
user_a_name=user_a_name, user_a_name=user_a_name,
user_b_name=user_b_name, user_b_name=user_b_name,
role_a_name=role_a_name, role_a_name=role_a_name,
role_b_name=role_b_name role_b_name=role_b_name
) )
# 填充 AI 元数据 # 填充 AI 元数据
result.raw_response = ai_response.content result.raw_response = ai_response.content
result.ai_provider = ai_response.provider result.ai_provider = ai_response.provider
result.ai_model = ai_response.model result.ai_model = ai_response.model
result.ai_latency_ms = ai_response.latency_ms result.ai_latency_ms = ai_response.latency_ms
return result return result
except Exception as e: except Exception as e:
logger.error(f"双人对练分析失败: {e}", exc_info=True) logger.error(f"双人对练分析失败: {e}", exc_info=True)
# 返回空结果 # 返回空结果
return DuoPracticeAnalysisResult( return DuoPracticeAnalysisResult(
overall_comment=f"分析失败: {str(e)}" overall_comment=f"分析失败: {str(e)}"
) )
def _format_dialogue_history(self, dialogues: List[Dict[str, Any]]) -> str: def _format_dialogue_history(self, dialogues: List[Dict[str, Any]]) -> str:
"""格式化对话历史""" """格式化对话历史"""
lines = [] lines = []
for d in dialogues: for d in dialogues:
speaker = d.get("role_name") or d.get("speaker", "未知") speaker = d.get("role_name") or d.get("speaker", "未知")
content = d.get("content", "") content = d.get("content", "")
seq = d.get("sequence", 0) seq = d.get("sequence", 0)
lines.append(f"[{seq}] {speaker}{content}") lines.append(f"[{seq}] {speaker}{content}")
return "\n".join(lines) return "\n".join(lines)
def _parse_analysis_result( def _parse_analysis_result(
self, self,
ai_output: str, ai_output: str,
user_a_name: str, user_a_name: str,
user_b_name: str, user_b_name: str,
role_a_name: str, role_a_name: str,
role_b_name: str role_b_name: str
) -> DuoPracticeAnalysisResult: ) -> DuoPracticeAnalysisResult:
"""解析 AI 输出""" """解析 AI 输出"""
result = DuoPracticeAnalysisResult() result = DuoPracticeAnalysisResult()
try: try:
# 尝试提取 JSON # 尝试提取 JSON
json_str = ai_output json_str = ai_output
# 如果输出包含 markdown 代码块,提取其中的 JSON # 如果输出包含 markdown 代码块,提取其中的 JSON
if "```json" in ai_output: if "```json" in ai_output:
start = ai_output.find("```json") + 7 start = ai_output.find("```json") + 7
end = ai_output.find("```", start) end = ai_output.find("```", start)
json_str = ai_output[start:end].strip() json_str = ai_output[start:end].strip()
elif "```" in ai_output: elif "```" in ai_output:
start = ai_output.find("```") + 3 start = ai_output.find("```") + 3
end = ai_output.find("```", start) end = ai_output.find("```", start)
json_str = ai_output[start:end].strip() json_str = ai_output[start:end].strip()
data = json.loads(json_str) data = json.loads(json_str)
# 解析整体评估 # 解析整体评估
overall = data.get("overall_evaluation", {}) overall = data.get("overall_evaluation", {})
result.interaction_quality = overall.get("interaction_quality", 0) result.interaction_quality = overall.get("interaction_quality", 0)
result.scene_restoration = overall.get("scene_restoration", 0) result.scene_restoration = overall.get("scene_restoration", 0)
result.overall_comment = overall.get("overall_comment", "") result.overall_comment = overall.get("overall_comment", "")
# 解析用户A评估 # 解析用户A评估
user_a_data = data.get("user_a_evaluation", {}) user_a_data = data.get("user_a_evaluation", {})
if user_a_data: if user_a_data:
result.user_a_evaluation = UserEvaluation( result.user_a_evaluation = UserEvaluation(
user_name=user_a_data.get("user_name", user_a_name), user_name=user_a_data.get("user_name", user_a_name),
role_name=user_a_data.get("role_name", role_a_name), role_name=user_a_data.get("role_name", role_a_name),
total_score=user_a_data.get("total_score", 0), total_score=user_a_data.get("total_score", 0),
dimensions=user_a_data.get("dimensions", {}), dimensions=user_a_data.get("dimensions", {}),
highlights=user_a_data.get("highlights", []), highlights=user_a_data.get("highlights", []),
improvements=user_a_data.get("improvements", []) improvements=user_a_data.get("improvements", [])
) )
# 解析用户B评估 # 解析用户B评估
user_b_data = data.get("user_b_evaluation", {}) user_b_data = data.get("user_b_evaluation", {})
if user_b_data: if user_b_data:
result.user_b_evaluation = UserEvaluation( result.user_b_evaluation = UserEvaluation(
user_name=user_b_data.get("user_name", user_b_name), user_name=user_b_data.get("user_name", user_b_name),
role_name=user_b_data.get("role_name", role_b_name), role_name=user_b_data.get("role_name", role_b_name),
total_score=user_b_data.get("total_score", 0), total_score=user_b_data.get("total_score", 0),
dimensions=user_b_data.get("dimensions", {}), dimensions=user_b_data.get("dimensions", {}),
highlights=user_b_data.get("highlights", []), highlights=user_b_data.get("highlights", []),
improvements=user_b_data.get("improvements", []) improvements=user_b_data.get("improvements", [])
) )
# 解析对话标注 # 解析对话标注
result.dialogue_annotations = data.get("dialogue_annotations", []) result.dialogue_annotations = data.get("dialogue_annotations", [])
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.warning(f"JSON 解析失败: {e}") logger.warning(f"JSON 解析失败: {e}")
result.overall_comment = "AI 输出格式异常,请重试" result.overall_comment = "AI 输出格式异常,请重试"
except Exception as e: except Exception as e:
logger.error(f"解析分析结果失败: {e}") logger.error(f"解析分析结果失败: {e}")
result.overall_comment = f"解析失败: {str(e)}" result.overall_comment = f"解析失败: {str(e)}"
return result return result
def result_to_dict(self, result: DuoPracticeAnalysisResult) -> Dict[str, Any]: def result_to_dict(self, result: DuoPracticeAnalysisResult) -> Dict[str, Any]:
"""将结果转换为字典(用于 API 响应)""" """将结果转换为字典(用于 API 响应)"""
return { return {
"overall_evaluation": { "overall_evaluation": {
"interaction_quality": result.interaction_quality, "interaction_quality": result.interaction_quality,
"scene_restoration": result.scene_restoration, "scene_restoration": result.scene_restoration,
"overall_comment": result.overall_comment "overall_comment": result.overall_comment
}, },
"user_a_evaluation": { "user_a_evaluation": {
"user_name": result.user_a_evaluation.user_name, "user_name": result.user_a_evaluation.user_name,
"role_name": result.user_a_evaluation.role_name, "role_name": result.user_a_evaluation.role_name,
"total_score": result.user_a_evaluation.total_score, "total_score": result.user_a_evaluation.total_score,
"dimensions": result.user_a_evaluation.dimensions, "dimensions": result.user_a_evaluation.dimensions,
"highlights": result.user_a_evaluation.highlights, "highlights": result.user_a_evaluation.highlights,
"improvements": result.user_a_evaluation.improvements "improvements": result.user_a_evaluation.improvements
} if result.user_a_evaluation else None, } if result.user_a_evaluation else None,
"user_b_evaluation": { "user_b_evaluation": {
"user_name": result.user_b_evaluation.user_name, "user_name": result.user_b_evaluation.user_name,
"role_name": result.user_b_evaluation.role_name, "role_name": result.user_b_evaluation.role_name,
"total_score": result.user_b_evaluation.total_score, "total_score": result.user_b_evaluation.total_score,
"dimensions": result.user_b_evaluation.dimensions, "dimensions": result.user_b_evaluation.dimensions,
"highlights": result.user_b_evaluation.highlights, "highlights": result.user_b_evaluation.highlights,
"improvements": result.user_b_evaluation.improvements "improvements": result.user_b_evaluation.improvements
} if result.user_b_evaluation else None, } if result.user_b_evaluation else None,
"dialogue_annotations": result.dialogue_annotations, "dialogue_annotations": result.dialogue_annotations,
"ai_metadata": { "ai_metadata": {
"provider": result.ai_provider, "provider": result.ai_provider,
"model": result.ai_model, "model": result.ai_model,
"latency_ms": result.ai_latency_ms "latency_ms": result.ai_latency_ms
} }
} }
# ==================== 全局实例 ==================== # ==================== 全局实例 ====================
duo_practice_analysis_service = DuoPracticeAnalysisService() duo_practice_analysis_service = DuoPracticeAnalysisService()
# ==================== 便捷函数 ==================== # ==================== 便捷函数 ====================
async def analyze_duo_practice( async def analyze_duo_practice(
scene_name: str, scene_name: str,
scene_background: str, scene_background: str,
role_a_name: str, role_a_name: str,
role_b_name: str, role_b_name: str,
role_a_description: str, role_a_description: str,
role_b_description: str, role_b_description: str,
user_a_name: str, user_a_name: str,
user_b_name: str, user_b_name: str,
dialogue_history: List[Dict[str, Any]], dialogue_history: List[Dict[str, Any]],
duration_seconds: int, duration_seconds: int,
total_turns: int, total_turns: int,
db: Any = None db: Any = None
) -> DuoPracticeAnalysisResult: ) -> DuoPracticeAnalysisResult:
"""便捷函数:分析双人对练""" """便捷函数:分析双人对练"""
return await duo_practice_analysis_service.analyze( return await duo_practice_analysis_service.analyze(
scene_name=scene_name, scene_name=scene_name,
scene_background=scene_background, scene_background=scene_background,
role_a_name=role_a_name, role_a_name=role_a_name,
role_b_name=role_b_name, role_b_name=role_b_name,
role_a_description=role_a_description, role_a_description=role_a_description,
role_b_description=role_b_description, role_b_description=role_b_description,
user_a_name=user_a_name, user_a_name=user_a_name,
user_b_name=user_b_name, user_b_name=user_b_name,
dialogue_history=dialogue_history, dialogue_history=dialogue_history,
duration_seconds=duration_seconds, duration_seconds=duration_seconds,
total_turns=total_turns, total_turns=total_turns,
db=db db=db
) )

View File

@@ -1,207 +1,207 @@
""" """
双人对练评估提示词模板 双人对练评估提示词模板
功能:评估双人角色扮演对练的表现 功能:评估双人角色扮演对练的表现
""" """
# ==================== 元数据 ==================== # ==================== 元数据 ====================
PROMPT_META = { PROMPT_META = {
"name": "duo_practice_analysis", "name": "duo_practice_analysis",
"display_name": "双人对练评估", "display_name": "双人对练评估",
"description": "评估双人角色扮演对练中双方的表现", "description": "评估双人角色扮演对练中双方的表现",
"module": "kaopeilian", "module": "kaopeilian",
"variables": [ "variables": [
"scene_name", "scene_background", "scene_name", "scene_background",
"role_a_name", "role_b_name", "role_a_name", "role_b_name",
"role_a_description", "role_b_description", "role_a_description", "role_b_description",
"user_a_name", "user_b_name", "user_a_name", "user_b_name",
"dialogue_history", "dialogue_history",
"duration_seconds", "total_turns" "duration_seconds", "total_turns"
], ],
"version": "1.0.0", "version": "1.0.0",
"author": "kaopeilian-team", "author": "kaopeilian-team",
} }
# ==================== 系统提示词 ==================== # ==================== 系统提示词 ====================
SYSTEM_PROMPT = """你是一位资深的销售培训专家和沟通教练,擅长评估角色扮演对练的表现。 SYSTEM_PROMPT = """你是一位资深的销售培训专家和沟通教练,擅长评估角色扮演对练的表现。
你需要观察双人对练的对话记录,分别对两位参与者的表现进行专业评估。 你需要观察双人对练的对话记录,分别对两位参与者的表现进行专业评估。
评估原则: 评估原则:
1. 客观公正,基于对话内容给出评价 1. 客观公正,基于对话内容给出评价
2. 突出亮点,指出不足 2. 突出亮点,指出不足
3. 给出具体、可操作的改进建议 3. 给出具体、可操作的改进建议
4. 考虑角色特点,评估角色代入度 4. 考虑角色特点,评估角色代入度
输出格式要求: 输出格式要求:
- 必须返回有效的 JSON 格式 - 必须返回有效的 JSON 格式
- 分数范围 0-100 - 分数范围 0-100
- 建议具体可行""" - 建议具体可行"""
# ==================== 用户提示词模板 ==================== # ==================== 用户提示词模板 ====================
USER_PROMPT = """# 双人对练评估任务 USER_PROMPT = """# 双人对练评估任务
## 场景信息 ## 场景信息
- **场景名称**{scene_name} - **场景名称**{scene_name}
- **场景背景**{scene_background} - **场景背景**{scene_background}
## 角色设置 ## 角色设置
### {role_a_name} ### {role_a_name}
- **扮演者**{user_a_name} - **扮演者**{user_a_name}
- **角色描述**{role_a_description} - **角色描述**{role_a_description}
### {role_b_name} ### {role_b_name}
- **扮演者**{user_b_name} - **扮演者**{user_b_name}
- **角色描述**{role_b_description} - **角色描述**{role_b_description}
## 对练数据 ## 对练数据
- **对练时长**{duration_seconds} - **对练时长**{duration_seconds}
- **总对话轮次**{total_turns} - **总对话轮次**{total_turns}
## 对话记录 ## 对话记录
{dialogue_history} {dialogue_history}
--- ---
## 评估要求 ## 评估要求
请按以下 JSON 格式输出评估结果: 请按以下 JSON 格式输出评估结果:
```json ```json
{{ {{
"overall_evaluation": {{ "overall_evaluation": {{
"interaction_quality": 85, "interaction_quality": 85,
"scene_restoration": 80, "scene_restoration": 80,
"overall_comment": "整体评价..." "overall_comment": "整体评价..."
}}, }},
"user_a_evaluation": {{ "user_a_evaluation": {{
"user_name": "{user_a_name}", "user_name": "{user_a_name}",
"role_name": "{role_a_name}", "role_name": "{role_a_name}",
"total_score": 85, "total_score": 85,
"dimensions": {{ "dimensions": {{
"role_immersion": {{ "role_immersion": {{
"score": 85, "score": 85,
"comment": "角色代入度评价..." "comment": "角色代入度评价..."
}}, }},
"communication": {{ "communication": {{
"score": 80, "score": 80,
"comment": "沟通表达能力评价..." "comment": "沟通表达能力评价..."
}}, }},
"professional_knowledge": {{ "professional_knowledge": {{
"score": 75, "score": 75,
"comment": "专业知识运用评价..." "comment": "专业知识运用评价..."
}}, }},
"response_quality": {{ "response_quality": {{
"score": 82, "score": 82,
"comment": "回应质量评价..." "comment": "回应质量评价..."
}}, }},
"goal_achievement": {{ "goal_achievement": {{
"score": 78, "score": 78,
"comment": "目标达成度评价..." "comment": "目标达成度评价..."
}} }}
}}, }},
"highlights": [ "highlights": [
"亮点1...", "亮点1...",
"亮点2..." "亮点2..."
], ],
"improvements": [ "improvements": [
{{ {{
"issue": "问题描述", "issue": "问题描述",
"suggestion": "改进建议", "suggestion": "改进建议",
"example": "示例话术" "example": "示例话术"
}} }}
] ]
}}, }},
"user_b_evaluation": {{ "user_b_evaluation": {{
"user_name": "{user_b_name}", "user_name": "{user_b_name}",
"role_name": "{role_b_name}", "role_name": "{role_b_name}",
"total_score": 82, "total_score": 82,
"dimensions": {{ "dimensions": {{
"role_immersion": {{ "role_immersion": {{
"score": 80, "score": 80,
"comment": "角色代入度评价..." "comment": "角色代入度评价..."
}}, }},
"communication": {{ "communication": {{
"score": 85, "score": 85,
"comment": "沟通表达能力评价..." "comment": "沟通表达能力评价..."
}}, }},
"professional_knowledge": {{ "professional_knowledge": {{
"score": 78, "score": 78,
"comment": "专业知识运用评价..." "comment": "专业知识运用评价..."
}}, }},
"response_quality": {{ "response_quality": {{
"score": 80, "score": 80,
"comment": "回应质量评价..." "comment": "回应质量评价..."
}}, }},
"goal_achievement": {{ "goal_achievement": {{
"score": 75, "score": 75,
"comment": "目标达成度评价..." "comment": "目标达成度评价..."
}} }}
}}, }},
"highlights": [ "highlights": [
"亮点1...", "亮点1...",
"亮点2..." "亮点2..."
], ],
"improvements": [ "improvements": [
{{ {{
"issue": "问题描述", "issue": "问题描述",
"suggestion": "改进建议", "suggestion": "改进建议",
"example": "示例话术" "example": "示例话术"
}} }}
] ]
}}, }},
"dialogue_annotations": [ "dialogue_annotations": [
{{ {{
"sequence": 1, "sequence": 1,
"speaker": "{role_a_name}", "speaker": "{role_a_name}",
"tags": ["good_opening"], "tags": ["good_opening"],
"comment": "开场白自然得体" "comment": "开场白自然得体"
}}, }},
{{ {{
"sequence": 3, "sequence": 3,
"speaker": "{role_b_name}", "speaker": "{role_b_name}",
"tags": ["needs_improvement"], "tags": ["needs_improvement"],
"comment": "可以更主动表达需求" "comment": "可以更主动表达需求"
}} }}
] ]
}} }}
``` ```
请基于对话内容,给出客观、专业的评估。""" 请基于对话内容,给出客观、专业的评估。"""
# ==================== 维度说明 ==================== # ==================== 维度说明 ====================
DIMENSION_DESCRIPTIONS = { DIMENSION_DESCRIPTIONS = {
"role_immersion": "角色代入度:是否完全进入角色,语言风格、态度是否符合角色设定", "role_immersion": "角色代入度:是否完全进入角色,语言风格、态度是否符合角色设定",
"communication": "沟通表达:语言是否清晰、逻辑是否通顺、表达是否得体", "communication": "沟通表达:语言是否清晰、逻辑是否通顺、表达是否得体",
"professional_knowledge": "专业知识:是否展现出角色应有的专业素养和知识储备", "professional_knowledge": "专业知识:是否展现出角色应有的专业素养和知识储备",
"response_quality": "回应质量:对对方发言的回应是否及时、恰当、有针对性", "response_quality": "回应质量:对对方发言的回应是否及时、恰当、有针对性",
"goal_achievement": "目标达成:是否朝着对练目标推进,是否达成预期效果" "goal_achievement": "目标达成:是否朝着对练目标推进,是否达成预期效果"
} }
# ==================== 对话标签 ==================== # ==================== 对话标签 ====================
DIALOGUE_TAGS = { DIALOGUE_TAGS = {
# 正面标签 # 正面标签
"good_opening": "开场良好", "good_opening": "开场良好",
"active_listening": "积极倾听", "active_listening": "积极倾听",
"empathy": "共情表达", "empathy": "共情表达",
"professional": "专业表现", "professional": "专业表现",
"good_closing": "结束得体", "good_closing": "结束得体",
"creative_response": "创意回应", "creative_response": "创意回应",
"problem_solving": "问题解决", "problem_solving": "问题解决",
# 需改进标签 # 需改进标签
"needs_improvement": "需要改进", "needs_improvement": "需要改进",
"off_topic": "偏离主题", "off_topic": "偏离主题",
"too_passive": "过于被动", "too_passive": "过于被动",
"lack_detail": "缺乏细节", "lack_detail": "缺乏细节",
"missed_opportunity": "错失机会", "missed_opportunity": "错失机会",
"unclear_expression": "表达不清" "unclear_expression": "表达不清"
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,489 +1,489 @@
""" """
数据大屏服务 数据大屏服务
提供企业级和团队级数据大屏功能: 提供企业级和团队级数据大屏功能:
- 学习数据概览 - 学习数据概览
- 部门/团队对比 - 部门/团队对比
- 趋势分析 - 趋势分析
- 实时动态 - 实时动态
""" """
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from sqlalchemy import select, func, and_, or_, desc, case from sqlalchemy import select, func, and_, or_, desc, case
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logger import get_logger from app.core.logger import get_logger
from app.models.user import User from app.models.user import User
from app.models.course import Course, CourseMaterial from app.models.course import Course, CourseMaterial
from app.models.exam import Exam from app.models.exam import Exam
from app.models.practice import PracticeSession from app.models.practice import PracticeSession
from app.models.training import TrainingSession, TrainingReport from app.models.training import TrainingSession, TrainingReport
from app.models.level import UserLevel, ExpHistory, UserBadge from app.models.level import UserLevel, ExpHistory, UserBadge
from app.models.position import Position from app.models.position import Position
from app.models.position_member import PositionMember from app.models.position_member import PositionMember
logger = get_logger(__name__) logger = get_logger(__name__)
class DashboardService: class DashboardService:
"""数据大屏服务""" """数据大屏服务"""
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.db = db self.db = db
async def get_enterprise_overview(self, enterprise_id: Optional[int] = None) -> Dict[str, Any]: async def get_enterprise_overview(self, enterprise_id: Optional[int] = None) -> Dict[str, Any]:
""" """
获取企业级数据概览 获取企业级数据概览
Args: Args:
enterprise_id: 企业ID可选用于多租户 enterprise_id: 企业ID可选用于多租户
Returns: Returns:
企业级数据概览 企业级数据概览
""" """
today = date.today() today = date.today()
week_ago = today - timedelta(days=7) week_ago = today - timedelta(days=7)
month_ago = today - timedelta(days=30) month_ago = today - timedelta(days=30)
# 基础统计 # 基础统计
# 1. 总学员数 # 1. 总学员数
result = await self.db.execute( result = await self.db.execute(
select(func.count(User.id)) select(func.count(User.id))
.where(User.is_deleted == False, User.role == 'trainee') .where(User.is_deleted == False, User.role == 'trainee')
) )
total_users = result.scalar() or 0 total_users = result.scalar() or 0
# 2. 今日活跃用户(有经验值记录) # 2. 今日活跃用户(有经验值记录)
result = await self.db.execute( result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id))) select(func.count(func.distinct(ExpHistory.user_id)))
.where(func.date(ExpHistory.created_at) == today) .where(func.date(ExpHistory.created_at) == today)
) )
today_active = result.scalar() or 0 today_active = result.scalar() or 0
# 3. 本周活跃用户 # 3. 本周活跃用户
result = await self.db.execute( result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id))) select(func.count(func.distinct(ExpHistory.user_id)))
.where(ExpHistory.created_at >= datetime.combine(week_ago, datetime.min.time())) .where(ExpHistory.created_at >= datetime.combine(week_ago, datetime.min.time()))
) )
week_active = result.scalar() or 0 week_active = result.scalar() or 0
# 4. 本月活跃用户 # 4. 本月活跃用户
result = await self.db.execute( result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id))) select(func.count(func.distinct(ExpHistory.user_id)))
.where(ExpHistory.created_at >= datetime.combine(month_ago, datetime.min.time())) .where(ExpHistory.created_at >= datetime.combine(month_ago, datetime.min.time()))
) )
month_active = result.scalar() or 0 month_active = result.scalar() or 0
# 5. 总学习时长(小时) # 5. 总学习时长(小时)
result = await self.db.execute( result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0)) select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(PracticeSession.status == 'completed') .where(PracticeSession.status == 'completed')
) )
practice_hours = (result.scalar() or 0) / 3600 practice_hours = (result.scalar() or 0) / 3600
result = await self.db.execute( result = await self.db.execute(
select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0)) select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0))
.where(TrainingSession.status == 'COMPLETED') .where(TrainingSession.status == 'COMPLETED')
) )
training_hours = (result.scalar() or 0) / 3600 training_hours = (result.scalar() or 0) / 3600
total_hours = round(practice_hours + training_hours, 1) total_hours = round(practice_hours + training_hours, 1)
# 6. 考试统计 # 6. 考试统计
result = await self.db.execute( result = await self.db.execute(
select( select(
func.count(Exam.id), func.count(Exam.id),
func.count(case((Exam.is_passed == True, 1))), func.count(case((Exam.is_passed == True, 1))),
func.avg(Exam.score) func.avg(Exam.score)
) )
.where(Exam.status == 'submitted') .where(Exam.status == 'submitted')
) )
exam_row = result.first() exam_row = result.first()
exam_count = exam_row[0] or 0 exam_count = exam_row[0] or 0
exam_passed = exam_row[1] or 0 exam_passed = exam_row[1] or 0
exam_avg_score = round(exam_row[2] or 0, 1) exam_avg_score = round(exam_row[2] or 0, 1)
exam_pass_rate = round(exam_passed / exam_count * 100, 1) if exam_count > 0 else 0 exam_pass_rate = round(exam_passed / exam_count * 100, 1) if exam_count > 0 else 0
# 7. 满分人数 # 7. 满分人数
result = await self.db.execute( result = await self.db.execute(
select(func.count(func.distinct(Exam.user_id))) select(func.count(func.distinct(Exam.user_id)))
.where(Exam.status == 'submitted', Exam.score >= Exam.total_score) .where(Exam.status == 'submitted', Exam.score >= Exam.total_score)
) )
perfect_users = result.scalar() or 0 perfect_users = result.scalar() or 0
# 8. 签到率(今日签到人数/总用户数) # 8. 签到率(今日签到人数/总用户数)
result = await self.db.execute( result = await self.db.execute(
select(func.count(UserLevel.id)) select(func.count(UserLevel.id))
.where(func.date(UserLevel.last_login_date) == today) .where(func.date(UserLevel.last_login_date) == today)
) )
today_checkin = result.scalar() or 0 today_checkin = result.scalar() or 0
checkin_rate = round(today_checkin / total_users * 100, 1) if total_users > 0 else 0 checkin_rate = round(today_checkin / total_users * 100, 1) if total_users > 0 else 0
return { return {
"overview": { "overview": {
"total_users": total_users, "total_users": total_users,
"today_active": today_active, "today_active": today_active,
"week_active": week_active, "week_active": week_active,
"month_active": month_active, "month_active": month_active,
"total_hours": total_hours, "total_hours": total_hours,
"checkin_rate": checkin_rate, "checkin_rate": checkin_rate,
}, },
"exam": { "exam": {
"total_count": exam_count, "total_count": exam_count,
"pass_rate": exam_pass_rate, "pass_rate": exam_pass_rate,
"avg_score": exam_avg_score, "avg_score": exam_avg_score,
"perfect_users": perfect_users, "perfect_users": perfect_users,
}, },
"updated_at": datetime.now().isoformat() "updated_at": datetime.now().isoformat()
} }
async def get_department_comparison(self) -> List[Dict[str, Any]]: async def get_department_comparison(self) -> List[Dict[str, Any]]:
""" """
获取部门/团队学习对比数据 获取部门/团队学习对比数据
Returns: Returns:
部门对比列表 部门对比列表
""" """
# 获取所有岗位及其成员的学习数据 # 获取所有岗位及其成员的学习数据
result = await self.db.execute( result = await self.db.execute(
select(Position) select(Position)
.where(Position.is_deleted == False) .where(Position.is_deleted == False)
.order_by(Position.name) .order_by(Position.name)
) )
positions = result.scalars().all() positions = result.scalars().all()
departments = [] departments = []
for pos in positions: for pos in positions:
# 获取该岗位的成员数 # 获取该岗位的成员数
result = await self.db.execute( result = await self.db.execute(
select(func.count(PositionMember.id)) select(func.count(PositionMember.id))
.where(PositionMember.position_id == pos.id) .where(PositionMember.position_id == pos.id)
) )
member_count = result.scalar() or 0 member_count = result.scalar() or 0
if member_count == 0: if member_count == 0:
continue continue
# 获取成员ID列表 # 获取成员ID列表
result = await self.db.execute( result = await self.db.execute(
select(PositionMember.user_id) select(PositionMember.user_id)
.where(PositionMember.position_id == pos.id) .where(PositionMember.position_id == pos.id)
) )
member_ids = [row[0] for row in result.all()] member_ids = [row[0] for row in result.all()]
# 统计该岗位成员的学习数据 # 统计该岗位成员的学习数据
# 考试通过率 # 考试通过率
result = await self.db.execute( result = await self.db.execute(
select( select(
func.count(Exam.id), func.count(Exam.id),
func.count(case((Exam.is_passed == True, 1))) func.count(case((Exam.is_passed == True, 1)))
) )
.where( .where(
Exam.user_id.in_(member_ids), Exam.user_id.in_(member_ids),
Exam.status == 'submitted' Exam.status == 'submitted'
) )
) )
exam_row = result.first() exam_row = result.first()
exam_total = exam_row[0] or 0 exam_total = exam_row[0] or 0
exam_passed = exam_row[1] or 0 exam_passed = exam_row[1] or 0
pass_rate = round(exam_passed / exam_total * 100, 1) if exam_total > 0 else 0 pass_rate = round(exam_passed / exam_total * 100, 1) if exam_total > 0 else 0
# 平均学习时长 # 平均学习时长
result = await self.db.execute( result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0)) select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where( .where(
PracticeSession.user_id.in_(member_ids), PracticeSession.user_id.in_(member_ids),
PracticeSession.status == 'completed' PracticeSession.status == 'completed'
) )
) )
total_seconds = result.scalar() or 0 total_seconds = result.scalar() or 0
avg_hours = round(total_seconds / 3600 / member_count, 1) if member_count > 0 else 0 avg_hours = round(total_seconds / 3600 / member_count, 1) if member_count > 0 else 0
# 平均等级 # 平均等级
result = await self.db.execute( result = await self.db.execute(
select(func.avg(UserLevel.level)) select(func.avg(UserLevel.level))
.where(UserLevel.user_id.in_(member_ids)) .where(UserLevel.user_id.in_(member_ids))
) )
avg_level = round(result.scalar() or 1, 1) avg_level = round(result.scalar() or 1, 1)
departments.append({ departments.append({
"id": pos.id, "id": pos.id,
"name": pos.name, "name": pos.name,
"member_count": member_count, "member_count": member_count,
"pass_rate": pass_rate, "pass_rate": pass_rate,
"avg_hours": avg_hours, "avg_hours": avg_hours,
"avg_level": avg_level, "avg_level": avg_level,
}) })
# 按通过率排序 # 按通过率排序
departments.sort(key=lambda x: x["pass_rate"], reverse=True) departments.sort(key=lambda x: x["pass_rate"], reverse=True)
return departments return departments
async def get_learning_trend(self, days: int = 7) -> Dict[str, Any]: async def get_learning_trend(self, days: int = 7) -> Dict[str, Any]:
""" """
获取学习趋势数据 获取学习趋势数据
Args: Args:
days: 统计天数 days: 统计天数
Returns: Returns:
趋势数据 趋势数据
""" """
today = date.today() today = date.today()
dates = [(today - timedelta(days=i)) for i in range(days-1, -1, -1)] dates = [(today - timedelta(days=i)) for i in range(days-1, -1, -1)]
trend_data = [] trend_data = []
for d in dates: for d in dates:
# 当日活跃用户 # 当日活跃用户
result = await self.db.execute( result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id))) select(func.count(func.distinct(ExpHistory.user_id)))
.where(func.date(ExpHistory.created_at) == d) .where(func.date(ExpHistory.created_at) == d)
) )
active_users = result.scalar() or 0 active_users = result.scalar() or 0
# 当日新增学习时长 # 当日新增学习时长
result = await self.db.execute( result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0)) select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where( .where(
func.date(PracticeSession.created_at) == d, func.date(PracticeSession.created_at) == d,
PracticeSession.status == 'completed' PracticeSession.status == 'completed'
) )
) )
hours = round((result.scalar() or 0) / 3600, 1) hours = round((result.scalar() or 0) / 3600, 1)
# 当日考试次数 # 当日考试次数
result = await self.db.execute( result = await self.db.execute(
select(func.count(Exam.id)) select(func.count(Exam.id))
.where( .where(
func.date(Exam.created_at) == d, func.date(Exam.created_at) == d,
Exam.status == 'submitted' Exam.status == 'submitted'
) )
) )
exams = result.scalar() or 0 exams = result.scalar() or 0
trend_data.append({ trend_data.append({
"date": d.isoformat(), "date": d.isoformat(),
"active_users": active_users, "active_users": active_users,
"learning_hours": hours, "learning_hours": hours,
"exam_count": exams, "exam_count": exams,
}) })
return { return {
"dates": [d.isoformat() for d in dates], "dates": [d.isoformat() for d in dates],
"trend": trend_data "trend": trend_data
} }
async def get_level_distribution(self) -> Dict[str, Any]: async def get_level_distribution(self) -> Dict[str, Any]:
""" """
获取等级分布数据 获取等级分布数据
Returns: Returns:
等级分布 等级分布
""" """
result = await self.db.execute( result = await self.db.execute(
select(UserLevel.level, func.count(UserLevel.id)) select(UserLevel.level, func.count(UserLevel.id))
.group_by(UserLevel.level) .group_by(UserLevel.level)
.order_by(UserLevel.level) .order_by(UserLevel.level)
) )
rows = result.all() rows = result.all()
distribution = {row[0]: row[1] for row in rows} distribution = {row[0]: row[1] for row in rows}
# 补全1-10级 # 补全1-10级
for i in range(1, 11): for i in range(1, 11):
if i not in distribution: if i not in distribution:
distribution[i] = 0 distribution[i] = 0
return { return {
"levels": list(range(1, 11)), "levels": list(range(1, 11)),
"counts": [distribution.get(i, 0) for i in range(1, 11)] "counts": [distribution.get(i, 0) for i in range(1, 11)]
} }
async def get_realtime_activities(self, limit: int = 20) -> List[Dict[str, Any]]: async def get_realtime_activities(self, limit: int = 20) -> List[Dict[str, Any]]:
""" """
获取实时动态 获取实时动态
Args: Args:
limit: 数量限制 limit: 数量限制
Returns: Returns:
实时动态列表 实时动态列表
""" """
activities = [] activities = []
# 获取最近的经验值记录 # 获取最近的经验值记录
result = await self.db.execute( result = await self.db.execute(
select(ExpHistory, User) select(ExpHistory, User)
.join(User, ExpHistory.user_id == User.id) .join(User, ExpHistory.user_id == User.id)
.order_by(ExpHistory.created_at.desc()) .order_by(ExpHistory.created_at.desc())
.limit(limit) .limit(limit)
) )
rows = result.all() rows = result.all()
for exp, user in rows: for exp, user in rows:
activity_type = "学习" activity_type = "学习"
if "考试" in (exp.description or ""): if "考试" in (exp.description or ""):
activity_type = "考试" activity_type = "考试"
elif "签到" in (exp.description or ""): elif "签到" in (exp.description or ""):
activity_type = "签到" activity_type = "签到"
elif "陪练" in (exp.description or ""): elif "陪练" in (exp.description or ""):
activity_type = "陪练" activity_type = "陪练"
elif "奖章" in (exp.description or ""): elif "奖章" in (exp.description or ""):
activity_type = "奖章" activity_type = "奖章"
activities.append({ activities.append({
"id": exp.id, "id": exp.id,
"user_id": user.id, "user_id": user.id,
"user_name": user.full_name or user.username, "user_name": user.full_name or user.username,
"type": activity_type, "type": activity_type,
"description": exp.description, "description": exp.description,
"exp_amount": exp.exp_amount, "exp_amount": exp.exp_amount,
"created_at": exp.created_at.isoformat() if exp.created_at else None, "created_at": exp.created_at.isoformat() if exp.created_at else None,
}) })
return activities return activities
async def get_team_dashboard(self, team_leader_id: int) -> Dict[str, Any]: async def get_team_dashboard(self, team_leader_id: int) -> Dict[str, Any]:
""" """
获取团队级数据大屏 获取团队级数据大屏
Args: Args:
team_leader_id: 团队负责人ID team_leader_id: 团队负责人ID
Returns: Returns:
团队数据 团队数据
""" """
# 获取团队负责人管理的岗位 # 获取团队负责人管理的岗位
result = await self.db.execute( result = await self.db.execute(
select(Position) select(Position)
.where( .where(
Position.is_deleted == False, Position.is_deleted == False,
or_( or_(
Position.manager_id == team_leader_id, Position.manager_id == team_leader_id,
Position.created_by == team_leader_id Position.created_by == team_leader_id
) )
) )
) )
positions = result.scalars().all() positions = result.scalars().all()
position_ids = [p.id for p in positions] position_ids = [p.id for p in positions]
if not position_ids: if not position_ids:
return { return {
"members": [], "members": [],
"overview": { "overview": {
"total_members": 0, "total_members": 0,
"avg_level": 0, "avg_level": 0,
"avg_exp": 0, "avg_exp": 0,
"total_badges": 0, "total_badges": 0,
}, },
"pending_tasks": [] "pending_tasks": []
} }
# 获取团队成员 # 获取团队成员
result = await self.db.execute( result = await self.db.execute(
select(PositionMember.user_id) select(PositionMember.user_id)
.where(PositionMember.position_id.in_(position_ids)) .where(PositionMember.position_id.in_(position_ids))
) )
member_ids = [row[0] for row in result.all()] member_ids = [row[0] for row in result.all()]
if not member_ids: if not member_ids:
return { return {
"members": [], "members": [],
"overview": { "overview": {
"total_members": 0, "total_members": 0,
"avg_level": 0, "avg_level": 0,
"avg_exp": 0, "avg_exp": 0,
"total_badges": 0, "total_badges": 0,
}, },
"pending_tasks": [] "pending_tasks": []
} }
# 获取成员详细信息 # 获取成员详细信息
result = await self.db.execute( result = await self.db.execute(
select(User, UserLevel) select(User, UserLevel)
.outerjoin(UserLevel, User.id == UserLevel.user_id) .outerjoin(UserLevel, User.id == UserLevel.user_id)
.where(User.id.in_(member_ids)) .where(User.id.in_(member_ids))
.order_by(UserLevel.total_exp.desc().nullslast()) .order_by(UserLevel.total_exp.desc().nullslast())
) )
rows = result.all() rows = result.all()
members = [] members = []
total_exp = 0 total_exp = 0
total_level = 0 total_level = 0
for user, level in rows: for user, level in rows:
user_level = level.level if level else 1 user_level = level.level if level else 1
user_exp = level.total_exp if level else 0 user_exp = level.total_exp if level else 0
total_level += user_level total_level += user_level
total_exp += user_exp total_exp += user_exp
# 获取用户奖章数 # 获取用户奖章数
result = await self.db.execute( result = await self.db.execute(
select(func.count(UserBadge.id)) select(func.count(UserBadge.id))
.where(UserBadge.user_id == user.id) .where(UserBadge.user_id == user.id)
) )
badge_count = result.scalar() or 0 badge_count = result.scalar() or 0
members.append({ members.append({
"id": user.id, "id": user.id,
"username": user.username, "username": user.username,
"full_name": user.full_name, "full_name": user.full_name,
"avatar_url": user.avatar_url, "avatar_url": user.avatar_url,
"level": user_level, "level": user_level,
"total_exp": user_exp, "total_exp": user_exp,
"badge_count": badge_count, "badge_count": badge_count,
}) })
total_members = len(members) total_members = len(members)
# 获取团队总奖章数 # 获取团队总奖章数
result = await self.db.execute( result = await self.db.execute(
select(func.count(UserBadge.id)) select(func.count(UserBadge.id))
.where(UserBadge.user_id.in_(member_ids)) .where(UserBadge.user_id.in_(member_ids))
) )
total_badges = result.scalar() or 0 total_badges = result.scalar() or 0
return { return {
"members": members, "members": members,
"overview": { "overview": {
"total_members": total_members, "total_members": total_members,
"avg_level": round(total_level / total_members, 1) if total_members > 0 else 0, "avg_level": round(total_level / total_members, 1) if total_members > 0 else 0,
"avg_exp": round(total_exp / total_members) if total_members > 0 else 0, "avg_exp": round(total_exp / total_members) if total_members > 0 else 0,
"total_badges": total_badges, "total_badges": total_badges,
}, },
"positions": [{"id": p.id, "name": p.name} for p in positions] "positions": [{"id": p.id, "name": p.name} for p in positions]
} }
async def get_course_ranking(self, limit: int = 10) -> List[Dict[str, Any]]: async def get_course_ranking(self, limit: int = 10) -> List[Dict[str, Any]]:
""" """
获取课程热度排行 获取课程热度排行
Args: Args:
limit: 数量限制 limit: 数量限制
Returns: Returns:
课程排行列表 课程排行列表
""" """
# 这里简化实现,实际应该统计课程学习次数 # 这里简化实现,实际应该统计课程学习次数
result = await self.db.execute( result = await self.db.execute(
select(Course) select(Course)
.where(Course.is_deleted == False, Course.is_published == True) .where(Course.is_deleted == False, Course.is_published == True)
.order_by(Course.created_at.desc()) .order_by(Course.created_at.desc())
.limit(limit) .limit(limit)
) )
courses = result.scalars().all() courses = result.scalars().all()
ranking = [] ranking = []
for i, course in enumerate(courses, 1): for i, course in enumerate(courses, 1):
ranking.append({ ranking.append({
"rank": i, "rank": i,
"id": course.id, "id": course.id,
"name": course.name, "name": course.name,
"description": course.description, "description": course.description,
# 这里可以添加实际的学习人数统计 # 这里可以添加实际的学习人数统计
"learners": 0, "learners": 0,
}) })
return ranking return ranking

View File

@@ -1,302 +1,302 @@
""" """
钉钉认证服务 钉钉认证服务
提供钉钉免密登录功能,从数据库读取配置 提供钉钉免密登录功能,从数据库读取配置
""" """
import json import json
import time import time
from typing import Optional, Dict, Any, Tuple from typing import Optional, Dict, Any, Tuple
import httpx import httpx
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logger import get_logger from app.core.logger import get_logger
from app.core.security import create_access_token, create_refresh_token from app.core.security import create_access_token, create_refresh_token
from app.models.user import User from app.models.user import User
from app.schemas.auth import Token from app.schemas.auth import Token
from app.services.user_service import UserService from app.services.user_service import UserService
logger = get_logger(__name__) logger = get_logger(__name__)
# 钉钉API地址 # 钉钉API地址
DINGTALK_API_BASE = "https://oapi.dingtalk.com" DINGTALK_API_BASE = "https://oapi.dingtalk.com"
class DingtalkAuthService: class DingtalkAuthService:
"""钉钉认证服务""" """钉钉认证服务"""
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.db = db self.db = db
self.user_service = UserService(db) self.user_service = UserService(db)
self._access_token_cache: Dict[int, Tuple[str, float]] = {} # tenant_id -> (token, expire_time) self._access_token_cache: Dict[int, Tuple[str, float]] = {} # tenant_id -> (token, expire_time)
async def get_dingtalk_config(self, tenant_id: int) -> Dict[str, str]: async def get_dingtalk_config(self, tenant_id: int) -> Dict[str, str]:
""" """
从数据库获取钉钉配置 从数据库获取钉钉配置
Args: Args:
tenant_id: 租户ID tenant_id: 租户ID
Returns: Returns:
配置字典 {app_key, app_secret, agent_id, corp_id} 配置字典 {app_key, app_secret, agent_id, corp_id}
""" """
result = await self.db.execute( result = await self.db.execute(
text(""" text("""
SELECT config_key, config_value SELECT config_key, config_value
FROM tenant_configs FROM tenant_configs
WHERE tenant_id = :tenant_id AND config_group = 'dingtalk' WHERE tenant_id = :tenant_id AND config_group = 'dingtalk'
"""), """),
{"tenant_id": tenant_id} {"tenant_id": tenant_id}
) )
rows = result.fetchall() rows = result.fetchall()
config = {} config = {}
key_mapping = { key_mapping = {
"DINGTALK_APP_KEY": "app_key", "DINGTALK_APP_KEY": "app_key",
"DINGTALK_APP_SECRET": "app_secret", "DINGTALK_APP_SECRET": "app_secret",
"DINGTALK_AGENT_ID": "agent_id", "DINGTALK_AGENT_ID": "agent_id",
"DINGTALK_CORP_ID": "corp_id", "DINGTALK_CORP_ID": "corp_id",
} }
for row in rows: for row in rows:
if row[0] in key_mapping: if row[0] in key_mapping:
config[key_mapping[row[0]]] = row[1] config[key_mapping[row[0]]] = row[1]
return config return config
async def is_dingtalk_login_enabled(self, tenant_id: int) -> bool: async def is_dingtalk_login_enabled(self, tenant_id: int) -> bool:
""" """
检查钉钉免密登录功能是否启用 检查钉钉免密登录功能是否启用
Args: Args:
tenant_id: 租户ID tenant_id: 租户ID
Returns: Returns:
是否启用 是否启用
""" """
# 先查租户级别的配置 # 先查租户级别的配置
result = await self.db.execute( result = await self.db.execute(
text(""" text("""
SELECT is_enabled FROM feature_switches SELECT is_enabled FROM feature_switches
WHERE feature_code = 'dingtalk_login' WHERE feature_code = 'dingtalk_login'
AND (tenant_id = :tenant_id OR tenant_id IS NULL) AND (tenant_id = :tenant_id OR tenant_id IS NULL)
ORDER BY tenant_id DESC ORDER BY tenant_id DESC
LIMIT 1 LIMIT 1
"""), """),
{"tenant_id": tenant_id} {"tenant_id": tenant_id}
) )
row = result.fetchone() row = result.fetchone()
if row: if row:
return bool(row[0]) return bool(row[0])
return False return False
async def get_access_token(self, tenant_id: int) -> str: async def get_access_token(self, tenant_id: int) -> str:
""" """
获取钉钉访问令牌(带内存缓存) 获取钉钉访问令牌(带内存缓存)
Args: Args:
tenant_id: 租户ID tenant_id: 租户ID
Returns: Returns:
access_token access_token
Raises: Raises:
Exception: 获取失败时抛出异常 Exception: 获取失败时抛出异常
""" """
# 检查缓存 # 检查缓存
if tenant_id in self._access_token_cache: if tenant_id in self._access_token_cache:
token, expire_time = self._access_token_cache[tenant_id] token, expire_time = self._access_token_cache[tenant_id]
if time.time() < expire_time - 300: # 提前5分钟刷新 if time.time() < expire_time - 300: # 提前5分钟刷新
return token return token
# 获取配置 # 获取配置
config = await self.get_dingtalk_config(tenant_id) config = await self.get_dingtalk_config(tenant_id)
if not config.get("app_key") or not config.get("app_secret"): if not config.get("app_key") or not config.get("app_secret"):
raise ValueError("钉钉配置不完整请在管理后台配置AppKey和AppSecret") raise ValueError("钉钉配置不完整请在管理后台配置AppKey和AppSecret")
# 调用钉钉API获取token # 调用钉钉API获取token
url = f"{DINGTALK_API_BASE}/gettoken" url = f"{DINGTALK_API_BASE}/gettoken"
params = { params = {
"appkey": config["app_key"], "appkey": config["app_key"],
"appsecret": config["app_secret"], "appsecret": config["app_secret"],
} }
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, params=params) response = await client.get(url, params=params)
data = response.json() data = response.json()
if data.get("errcode") != 0: if data.get("errcode") != 0:
error_msg = data.get("errmsg", "未知错误") error_msg = data.get("errmsg", "未知错误")
logger.error(f"获取钉钉access_token失败: {error_msg}") logger.error(f"获取钉钉access_token失败: {error_msg}")
raise Exception(f"获取钉钉access_token失败: {error_msg}") raise Exception(f"获取钉钉access_token失败: {error_msg}")
access_token = data["access_token"] access_token = data["access_token"]
expires_in = data.get("expires_in", 7200) expires_in = data.get("expires_in", 7200)
# 缓存token # 缓存token
self._access_token_cache[tenant_id] = (access_token, time.time() + expires_in) self._access_token_cache[tenant_id] = (access_token, time.time() + expires_in)
logger.info(f"获取钉钉access_token成功有效期: {expires_in}") logger.info(f"获取钉钉access_token成功有效期: {expires_in}")
return access_token return access_token
async def get_user_info_by_code(self, tenant_id: int, code: str) -> Dict[str, Any]: async def get_user_info_by_code(self, tenant_id: int, code: str) -> Dict[str, Any]:
""" """
通过免登码获取钉钉用户信息 通过免登码获取钉钉用户信息
Args: Args:
tenant_id: 租户ID tenant_id: 租户ID
code: 免登授权码 code: 免登授权码
Returns: Returns:
用户信息 {userid, name, ...} 用户信息 {userid, name, ...}
Raises: Raises:
Exception: 获取失败时抛出异常 Exception: 获取失败时抛出异常
""" """
access_token = await self.get_access_token(tenant_id) access_token = await self.get_access_token(tenant_id)
url = f"{DINGTALK_API_BASE}/topapi/v2/user/getuserinfo" url = f"{DINGTALK_API_BASE}/topapi/v2/user/getuserinfo"
params = {"access_token": access_token} params = {"access_token": access_token}
payload = {"code": code} payload = {"code": code}
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, params=params, json=payload) response = await client.post(url, params=params, json=payload)
data = response.json() data = response.json()
if data.get("errcode") != 0: if data.get("errcode") != 0:
error_msg = data.get("errmsg", "未知错误") error_msg = data.get("errmsg", "未知错误")
logger.error(f"通过code获取钉钉用户信息失败: {error_msg}") logger.error(f"通过code获取钉钉用户信息失败: {error_msg}")
raise Exception(f"获取钉钉用户信息失败: {error_msg}") raise Exception(f"获取钉钉用户信息失败: {error_msg}")
result = data.get("result", {}) result = data.get("result", {})
logger.info(f"获取钉钉用户信息成功: userid={result.get('userid')}, name={result.get('name')}") logger.info(f"获取钉钉用户信息成功: userid={result.get('userid')}, name={result.get('name')}")
return result return result
async def get_user_detail(self, tenant_id: int, userid: str) -> Dict[str, Any]: async def get_user_detail(self, tenant_id: int, userid: str) -> Dict[str, Any]:
""" """
获取钉钉用户详细信息 获取钉钉用户详细信息
Args: Args:
tenant_id: 租户ID tenant_id: 租户ID
userid: 钉钉用户ID userid: 钉钉用户ID
Returns: Returns:
用户详细信息 用户详细信息
""" """
access_token = await self.get_access_token(tenant_id) access_token = await self.get_access_token(tenant_id)
url = f"{DINGTALK_API_BASE}/topapi/v2/user/get" url = f"{DINGTALK_API_BASE}/topapi/v2/user/get"
params = {"access_token": access_token} params = {"access_token": access_token}
payload = {"userid": userid} payload = {"userid": userid}
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, params=params, json=payload) response = await client.post(url, params=params, json=payload)
data = response.json() data = response.json()
if data.get("errcode") != 0: if data.get("errcode") != 0:
error_msg = data.get("errmsg", "未知错误") error_msg = data.get("errmsg", "未知错误")
logger.warning(f"获取钉钉用户详情失败: {error_msg}") logger.warning(f"获取钉钉用户详情失败: {error_msg}")
return {} return {}
return data.get("result", {}) return data.get("result", {})
async def dingtalk_login(self, tenant_id: int, code: str) -> Tuple[User, Token]: async def dingtalk_login(self, tenant_id: int, code: str) -> Tuple[User, Token]:
""" """
钉钉免密登录主流程 钉钉免密登录主流程
Args: Args:
tenant_id: 租户ID tenant_id: 租户ID
code: 免登授权码 code: 免登授权码
Returns: Returns:
(用户对象, Token对象) (用户对象, Token对象)
Raises: Raises:
Exception: 登录失败时抛出异常 Exception: 登录失败时抛出异常
""" """
# 1. 检查功能是否启用 # 1. 检查功能是否启用
if not await self.is_dingtalk_login_enabled(tenant_id): if not await self.is_dingtalk_login_enabled(tenant_id):
raise Exception("钉钉免密登录功能未启用") raise Exception("钉钉免密登录功能未启用")
# 2. 通过code获取钉钉用户信息 # 2. 通过code获取钉钉用户信息
dingtalk_user = await self.get_user_info_by_code(tenant_id, code) dingtalk_user = await self.get_user_info_by_code(tenant_id, code)
dingtalk_userid = dingtalk_user.get("userid") dingtalk_userid = dingtalk_user.get("userid")
if not dingtalk_userid: if not dingtalk_userid:
raise Exception("无法获取钉钉用户ID") raise Exception("无法获取钉钉用户ID")
# 3. 根据dingtalk_id查找系统用户 # 3. 根据dingtalk_id查找系统用户
logger.info(f"开始查找用户钉钉userid: {dingtalk_userid}") logger.info(f"开始查找用户钉钉userid: {dingtalk_userid}")
user = await self.user_service.get_by_dingtalk_id(dingtalk_userid) user = await self.user_service.get_by_dingtalk_id(dingtalk_userid)
if not user: if not user:
logger.info(f"通过dingtalk_id未找到用户尝试手机号匹配") logger.info(f"通过dingtalk_id未找到用户尝试手机号匹配")
# 尝试通过手机号匹配 # 尝试通过手机号匹配
user_detail = await self.get_user_detail(tenant_id, dingtalk_userid) user_detail = await self.get_user_detail(tenant_id, dingtalk_userid)
mobile = user_detail.get("mobile") mobile = user_detail.get("mobile")
logger.info(f"获取到钉钉用户手机号: {mobile}") logger.info(f"获取到钉钉用户手机号: {mobile}")
if mobile: if mobile:
user = await self.user_service.get_by_phone(mobile) user = await self.user_service.get_by_phone(mobile)
if user: if user:
# 绑定dingtalk_id # 绑定dingtalk_id
user.dingtalk_id = dingtalk_userid user.dingtalk_id = dingtalk_userid
await self.db.commit() await self.db.commit()
logger.info(f"通过手机号匹配成功已绑定dingtalk_id: {dingtalk_userid}") logger.info(f"通过手机号匹配成功已绑定dingtalk_id: {dingtalk_userid}")
else: else:
logger.warning(f"通过手机号 {mobile} 也未找到用户") logger.warning(f"通过手机号 {mobile} 也未找到用户")
else: else:
logger.warning("无法获取钉钉用户手机号") logger.warning("无法获取钉钉用户手机号")
if not user: if not user:
logger.error(f"钉钉登录失败dingtalk_userid={dingtalk_userid}, 未找到对应用户") logger.error(f"钉钉登录失败dingtalk_userid={dingtalk_userid}, 未找到对应用户")
raise Exception("未找到对应的系统用户,请联系管理员") raise Exception("未找到对应的系统用户,请联系管理员")
if not user.is_active: if not user.is_active:
raise Exception("用户已被禁用") raise Exception("用户已被禁用")
# 4. 生成JWT Token # 4. 生成JWT Token
access_token = create_access_token(subject=user.id) access_token = create_access_token(subject=user.id)
refresh_token = create_refresh_token(subject=user.id) refresh_token = create_refresh_token(subject=user.id)
# 5. 更新最后登录时间 # 5. 更新最后登录时间
await self.user_service.update_last_login(user.id) await self.user_service.update_last_login(user.id)
logger.info(f"钉钉免密登录成功: user_id={user.id}, username={user.username}") logger.info(f"钉钉免密登录成功: user_id={user.id}, username={user.username}")
return user, Token( return user, Token(
access_token=access_token, access_token=access_token,
refresh_token=refresh_token, refresh_token=refresh_token,
) )
async def get_public_config(self, tenant_id: int) -> Dict[str, Any]: async def get_public_config(self, tenant_id: int) -> Dict[str, Any]:
""" """
获取钉钉公开配置前端需要用于初始化JSDK 获取钉钉公开配置前端需要用于初始化JSDK
Args: Args:
tenant_id: 租户ID tenant_id: 租户ID
Returns: Returns:
{corp_id, agent_id, enabled} {corp_id, agent_id, enabled}
""" """
enabled = await self.is_dingtalk_login_enabled(tenant_id) enabled = await self.is_dingtalk_login_enabled(tenant_id)
if not enabled: if not enabled:
return { return {
"enabled": False, "enabled": False,
"corp_id": None, "corp_id": None,
"agent_id": None, "agent_id": None,
} }
config = await self.get_dingtalk_config(tenant_id) config = await self.get_dingtalk_config(tenant_id)
return { return {
"enabled": True, "enabled": True,
"corp_id": config.get("corp_id"), "corp_id": config.get("corp_id"),
"agent_id": config.get("agent_id"), "agent_id": config.get("agent_id"),
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,330 +1,419 @@
""" """
站内消息通知服务 通知推送服务
提供通知的CRUD操作和业务逻辑 支持钉钉、企业微信、站内消息等多种渠道
""" """
from typing import List, Optional, Tuple import os
from sqlalchemy import select, and_, desc, func, update import json
from sqlalchemy.orm import selectinload import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from app.core.logger import get_logger
from app.models.notification import Notification
from app.models.user import User from app.models.user import User
from app.schemas.notification import ( from app.models.notification import Notification
NotificationCreate,
NotificationBatchCreate,
NotificationResponse,
NotificationType,
)
from app.services.base_service import BaseService
logger = get_logger(__name__) logger = logging.getLogger(__name__)
class NotificationService(BaseService[Notification]): class NotificationChannel:
""" """通知渠道基类"""
站内消息通知服务
提供通知的创建、查询、标记已读等功能 async def send(
"""
def __init__(self):
super().__init__(Notification)
async def create_notification(
self, self,
db: AsyncSession,
notification_in: NotificationCreate
) -> Notification:
"""
创建单个通知
Args:
db: 数据库会话
notification_in: 通知创建数据
Returns:
创建的通知对象
"""
notification = Notification(
user_id=notification_in.user_id,
title=notification_in.title,
content=notification_in.content,
type=notification_in.type.value if isinstance(notification_in.type, NotificationType) else notification_in.type,
related_id=notification_in.related_id,
related_type=notification_in.related_type,
sender_id=notification_in.sender_id,
is_read=False
)
db.add(notification)
await db.commit()
await db.refresh(notification)
logger.info(
"创建通知成功",
notification_id=notification.id,
user_id=notification_in.user_id,
type=notification_in.type
)
return notification
async def batch_create_notifications(
self,
db: AsyncSession,
batch_in: NotificationBatchCreate
) -> List[Notification]:
"""
批量创建通知(发送给多个用户)
Args:
db: 数据库会话
batch_in: 批量通知创建数据
Returns:
创建的通知列表
"""
notifications = []
notification_type = batch_in.type.value if isinstance(batch_in.type, NotificationType) else batch_in.type
for user_id in batch_in.user_ids:
notification = Notification(
user_id=user_id,
title=batch_in.title,
content=batch_in.content,
type=notification_type,
related_id=batch_in.related_id,
related_type=batch_in.related_type,
sender_id=batch_in.sender_id,
is_read=False
)
notifications.append(notification)
db.add(notification)
await db.commit()
# 刷新所有对象
for notification in notifications:
await db.refresh(notification)
logger.info(
"批量创建通知成功",
count=len(notifications),
user_ids=batch_in.user_ids,
type=batch_in.type
)
return notifications
async def get_user_notifications(
self,
db: AsyncSession,
user_id: int, user_id: int,
skip: int = 0, title: str,
limit: int = 20, content: str,
is_read: Optional[bool] = None, **kwargs
notification_type: Optional[str] = None
) -> Tuple[List[NotificationResponse], int, int]:
"""
获取用户的通知列表
Args:
db: 数据库会话
user_id: 用户ID
skip: 跳过数量
limit: 返回数量
is_read: 是否已读筛选
notification_type: 通知类型筛选
Returns:
(通知列表, 总数, 未读数)
"""
# 构建基础查询条件
conditions = [Notification.user_id == user_id]
if is_read is not None:
conditions.append(Notification.is_read == is_read)
if notification_type:
conditions.append(Notification.type == notification_type)
# 查询通知列表(带发送者信息)
stmt = (
select(Notification)
.where(and_(*conditions))
.order_by(desc(Notification.created_at))
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
notifications = result.scalars().all()
# 统计总数
count_stmt = select(func.count()).select_from(Notification).where(and_(*conditions))
total_result = await db.execute(count_stmt)
total = total_result.scalar_one()
# 统计未读数
unread_stmt = (
select(func.count())
.select_from(Notification)
.where(and_(Notification.user_id == user_id, Notification.is_read == False))
)
unread_result = await db.execute(unread_stmt)
unread_count = unread_result.scalar_one()
# 获取发送者信息
sender_ids = [n.sender_id for n in notifications if n.sender_id]
sender_names = {}
if sender_ids:
sender_stmt = select(User.id, User.full_name).where(User.id.in_(sender_ids))
sender_result = await db.execute(sender_stmt)
sender_names = {row[0]: row[1] for row in sender_result.fetchall()}
# 构建响应
responses = []
for notification in notifications:
response = NotificationResponse(
id=notification.id,
user_id=notification.user_id,
title=notification.title,
content=notification.content,
type=notification.type,
is_read=notification.is_read,
related_id=notification.related_id,
related_type=notification.related_type,
sender_id=notification.sender_id,
sender_name=sender_names.get(notification.sender_id) if notification.sender_id else None,
created_at=notification.created_at,
updated_at=notification.updated_at
)
responses.append(response)
return responses, total, unread_count
async def get_unread_count(
self,
db: AsyncSession,
user_id: int
) -> Tuple[int, int]:
"""
获取用户未读通知数量
Args:
db: 数据库会话
user_id: 用户ID
Returns:
(未读数, 总数)
"""
# 统计未读数
unread_stmt = (
select(func.count())
.select_from(Notification)
.where(and_(Notification.user_id == user_id, Notification.is_read == False))
)
unread_result = await db.execute(unread_stmt)
unread_count = unread_result.scalar_one()
# 统计总数
total_stmt = (
select(func.count())
.select_from(Notification)
.where(Notification.user_id == user_id)
)
total_result = await db.execute(total_stmt)
total = total_result.scalar_one()
return unread_count, total
async def mark_as_read(
self,
db: AsyncSession,
user_id: int,
notification_ids: Optional[List[int]] = None
) -> int:
"""
标记通知为已读
Args:
db: 数据库会话
user_id: 用户ID
notification_ids: 通知ID列表为空则标记全部
Returns:
更新的数量
"""
conditions = [
Notification.user_id == user_id,
Notification.is_read == False
]
if notification_ids:
conditions.append(Notification.id.in_(notification_ids))
stmt = (
update(Notification)
.where(and_(*conditions))
.values(is_read=True)
)
result = await db.execute(stmt)
await db.commit()
updated_count = result.rowcount
logger.info(
"标记通知已读",
user_id=user_id,
notification_ids=notification_ids,
updated_count=updated_count
)
return updated_count
async def delete_notification(
self,
db: AsyncSession,
user_id: int,
notification_id: int
) -> bool: ) -> bool:
""" """
删除通知 发送通知
Args: Args:
db: 数据库会话
user_id: 用户ID user_id: 用户ID
notification_id: 通知ID title: 通知标题
content: 通知内容
Returns: Returns:
是否删除成功 是否发送成功
""" """
stmt = select(Notification).where( raise NotImplementedError
and_(
Notification.id == notification_id,
Notification.user_id == user_id class DingtalkChannel(NotificationChannel):
"""
钉钉通知渠道
使用钉钉工作通知 API 发送消息
文档: https://open.dingtalk.com/document/orgapp/asynchronous-sending-of-enterprise-session-messages
"""
def __init__(
self,
app_key: Optional[str] = None,
app_secret: Optional[str] = None,
agent_id: Optional[str] = None,
):
self.app_key = app_key or os.getenv("DINGTALK_APP_KEY")
self.app_secret = app_secret or os.getenv("DINGTALK_APP_SECRET")
self.agent_id = agent_id or os.getenv("DINGTALK_AGENT_ID")
self._access_token = None
self._token_expires_at = None
async def _get_access_token(self) -> str:
"""获取钉钉访问令牌"""
if (
self._access_token
and self._token_expires_at
and datetime.now() < self._token_expires_at
):
return self._access_token
url = "https://oapi.dingtalk.com/gettoken"
params = {
"appkey": self.app_key,
"appsecret": self.app_secret,
}
async with httpx.AsyncClient() as client:
response = await client.get(url, params=params, timeout=10.0)
result = response.json()
if result.get("errcode") == 0:
self._access_token = result["access_token"]
self._token_expires_at = datetime.now() + timedelta(seconds=7000)
return self._access_token
else:
raise Exception(f"获取钉钉Token失败: {result.get('errmsg')}")
async def send(
self,
user_id: int,
title: str,
content: str,
dingtalk_user_id: Optional[str] = None,
**kwargs
) -> bool:
"""发送钉钉工作通知"""
if not all([self.app_key, self.app_secret, self.agent_id]):
logger.warning("钉钉配置不完整,跳过发送")
return False
if not dingtalk_user_id:
logger.warning(f"用户 {user_id} 没有绑定钉钉ID")
return False
try:
access_token = await self._get_access_token()
url = f"https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2?access_token={access_token}"
# 构建消息体
msg = {
"agent_id": self.agent_id,
"userid_list": dingtalk_user_id,
"msg": {
"msgtype": "text",
"text": {
"content": f"{title}\n\n{content}"
}
}
}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=msg, timeout=10.0)
result = response.json()
if result.get("errcode") == 0:
logger.info(f"钉钉消息发送成功: user_id={user_id}")
return True
else:
logger.error(f"钉钉消息发送失败: {result.get('errmsg')}")
return False
except Exception as e:
logger.error(f"钉钉消息发送异常: {str(e)}")
return False
class WeworkChannel(NotificationChannel):
"""
企业微信通知渠道
使用企业微信应用消息 API
文档: https://developer.work.weixin.qq.com/document/path/90236
"""
def __init__(
self,
corp_id: Optional[str] = None,
corp_secret: Optional[str] = None,
agent_id: Optional[str] = None,
):
self.corp_id = corp_id or os.getenv("WEWORK_CORP_ID")
self.corp_secret = corp_secret or os.getenv("WEWORK_CORP_SECRET")
self.agent_id = agent_id or os.getenv("WEWORK_AGENT_ID")
self._access_token = None
self._token_expires_at = None
async def _get_access_token(self) -> str:
"""获取企业微信访问令牌"""
if (
self._access_token
and self._token_expires_at
and datetime.now() < self._token_expires_at
):
return self._access_token
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
params = {
"corpid": self.corp_id,
"corpsecret": self.corp_secret,
}
async with httpx.AsyncClient() as client:
response = await client.get(url, params=params, timeout=10.0)
result = response.json()
if result.get("errcode") == 0:
self._access_token = result["access_token"]
self._token_expires_at = datetime.now() + timedelta(seconds=7000)
return self._access_token
else:
raise Exception(f"获取企微Token失败: {result.get('errmsg')}")
async def send(
self,
user_id: int,
title: str,
content: str,
wework_user_id: Optional[str] = None,
**kwargs
) -> bool:
"""发送企业微信应用消息"""
if not all([self.corp_id, self.corp_secret, self.agent_id]):
logger.warning("企业微信配置不完整,跳过发送")
return False
if not wework_user_id:
logger.warning(f"用户 {user_id} 没有绑定企业微信ID")
return False
try:
access_token = await self._get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
# 构建消息体
msg = {
"touser": wework_user_id,
"msgtype": "text",
"agentid": int(self.agent_id),
"text": {
"content": f"{title}\n\n{content}"
}
}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=msg, timeout=10.0)
result = response.json()
if result.get("errcode") == 0:
logger.info(f"企微消息发送成功: user_id={user_id}")
return True
else:
logger.error(f"企微消息发送失败: {result.get('errmsg')}")
return False
except Exception as e:
logger.error(f"企微消息发送异常: {str(e)}")
return False
class InAppChannel(NotificationChannel):
"""站内消息通道"""
def __init__(self, db: AsyncSession):
self.db = db
async def send(
self,
user_id: int,
title: str,
content: str,
notification_type: str = "system",
**kwargs
) -> bool:
"""创建站内消息"""
try:
notification = Notification(
user_id=user_id,
title=title,
content=content,
type=notification_type,
is_read=False,
) )
self.db.add(notification)
await self.db.commit()
logger.info(f"站内消息创建成功: user_id={user_id}")
return True
except Exception as e:
logger.error(f"站内消息创建失败: {str(e)}")
return False
class NotificationService:
"""
通知服务
统一管理多渠道通知发送
"""
def __init__(self, db: AsyncSession):
self.db = db
self.channels = {
"dingtalk": DingtalkChannel(),
"wework": WeworkChannel(),
"inapp": InAppChannel(db),
}
async def send_notification(
self,
user_id: int,
title: str,
content: str,
channels: Optional[List[str]] = None,
**kwargs
) -> Dict[str, bool]:
"""
发送通知
Args:
user_id: 用户ID
title: 通知标题
content: 通知内容
channels: 发送渠道列表,默认全部发送
Returns:
各渠道发送结果
"""
# 获取用户信息
user = await self._get_user(user_id)
if not user:
return {"error": "用户不存在"}
# 准备用户渠道标识
user_channels = {
"dingtalk_user_id": getattr(user, "dingtalk_id", None),
"wework_user_id": getattr(user, "wework_userid", None),
}
# 确定发送渠道
target_channels = channels or ["inapp"] # 默认只发站内消息
results = {}
for channel_name in target_channels:
if channel_name in self.channels:
channel = self.channels[channel_name]
success = await channel.send(
user_id=user_id,
title=title,
content=content,
**user_channels,
**kwargs
)
results[channel_name] = success
return results
async def send_learning_reminder(
self,
user_id: int,
course_name: str,
days_inactive: int = 3,
) -> Dict[str, bool]:
"""发送学习提醒"""
title = "📚 学习提醒"
content = f"您已有 {days_inactive} 天没有学习《{course_name}》课程了,快来继续学习吧!"
return await self.send_notification(
user_id=user_id,
title=title,
content=content,
channels=["inapp", "dingtalk", "wework"],
notification_type="learning_reminder",
)
async def send_task_deadline_reminder(
self,
user_id: int,
task_name: str,
deadline: datetime,
) -> Dict[str, bool]:
"""发送任务截止提醒"""
days_left = (deadline - datetime.now()).days
title = "⏰ 任务截止提醒"
content = f"任务《{task_name}》将于 {deadline.strftime('%Y-%m-%d %H:%M')} 截止,还有 {days_left} 天,请尽快完成!"
return await self.send_notification(
user_id=user_id,
title=title,
content=content,
channels=["inapp", "dingtalk", "wework"],
notification_type="task_deadline",
)
async def send_exam_reminder(
self,
user_id: int,
exam_name: str,
exam_time: datetime,
) -> Dict[str, bool]:
"""发送考试提醒"""
title = "📝 考试提醒"
content = f"考试《{exam_name}》将于 {exam_time.strftime('%Y-%m-%d %H:%M')} 开始,请提前做好准备!"
return await self.send_notification(
user_id=user_id,
title=title,
content=content,
channels=["inapp", "dingtalk", "wework"],
notification_type="exam_reminder",
)
async def send_weekly_report(
self,
user_id: int,
study_time: int,
courses_completed: int,
exams_passed: int,
) -> Dict[str, bool]:
"""发送周学习报告"""
title = "📊 本周学习报告"
content = (
f"本周学习总结:\n"
f"• 学习时长:{study_time // 60} 分钟\n"
f"• 完成课程:{courses_completed}\n"
f"• 通过考试:{exams_passed}\n\n"
f"继续加油!💪"
) )
result = await db.execute(stmt) return await self.send_notification(
notification = result.scalar_one_or_none() user_id=user_id,
title=title,
if notification: content=content,
await db.delete(notification) channels=["inapp", "dingtalk", "wework"],
await db.commit() notification_type="weekly_report",
)
logger.info(
"删除通知成功", async def _get_user(self, user_id: int) -> Optional[User]:
notification_id=notification_id, """获取用户信息"""
user_id=user_id result = await self.db.execute(
) select(User).where(User.id == user_id)
return True )
return result.scalar_one_or_none()
return False
# 创建服务实例 # 便捷函数
notification_service = NotificationService() def get_notification_service(db: AsyncSession) -> NotificationService:
"""获取通知服务实例"""
return NotificationService(db)

View File

@@ -0,0 +1,151 @@
"""
权限检查服务
"""
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from app.models.user import User
from app.models.position import Position
from app.models.position_member import PositionMember
from app.models.position_course import PositionCourse
from app.models.course import Course, CourseStatus
class PermissionService:
"""权限检查服务类"""
def __init__(self, db: AsyncSession):
self.db = db
async def check_team_membership(self, user_id: int, team_id: int) -> bool:
"""
检查用户是否属于指定团队(岗位)
"""
result = await self.db.execute(
select(PositionMember).where(
and_(
PositionMember.user_id == user_id,
PositionMember.position_id == team_id,
)
)
)
return result.scalar_one_or_none() is not None
async def check_course_access(self, user_id: int, course_id: int) -> bool:
"""
检查用户是否可以访问指定课程
规则:
1. 课程必须是已发布状态
2. 课程必须分配给用户所在的某个岗位
"""
# 获取课程信息
course_result = await self.db.execute(
select(Course).where(Course.id == course_id)
)
course = course_result.scalar_one_or_none()
if not course:
return False
# 草稿状态的课程只有管理员可以访问
if course.status != CourseStatus.PUBLISHED:
return False
# 获取用户所在的所有岗位
positions_result = await self.db.execute(
select(PositionMember.position_id).where(
PositionMember.user_id == user_id
)
)
user_position_ids = [row[0] for row in positions_result.all()]
if not user_position_ids:
# 没有岗位的用户可以访问所有已发布课程(基础学习权限)
return True
# 检查课程是否分配给用户的任一岗位
course_position_result = await self.db.execute(
select(PositionCourse).where(
and_(
PositionCourse.course_id == course_id,
PositionCourse.position_id.in_(user_position_ids),
)
)
)
has_position_access = course_position_result.scalar_one_or_none() is not None
# 如果没有通过岗位分配访问,仍然允许访问已发布的公开课程
# 这是为了确保所有用户都能看到公开课程
return has_position_access or True # 暂时允许所有已发布课程
async def get_user_accessible_courses(self, user_id: int) -> List[int]:
"""
获取用户可访问的所有课程ID
"""
# 获取用户所在的所有岗位
positions_result = await self.db.execute(
select(PositionMember.position_id).where(
PositionMember.user_id == user_id
)
)
user_position_ids = [row[0] for row in positions_result.all()]
if not user_position_ids:
# 没有岗位的用户返回所有已发布课程
courses_result = await self.db.execute(
select(Course.id).where(Course.status == CourseStatus.PUBLISHED)
)
return [row[0] for row in courses_result.all()]
# 获取岗位分配的课程
courses_result = await self.db.execute(
select(PositionCourse.course_id).where(
PositionCourse.position_id.in_(user_position_ids)
).distinct()
)
return [row[0] for row in courses_result.all()]
async def get_user_teams(self, user_id: int) -> List[dict]:
"""
获取用户所属的所有团队(岗位)
"""
result = await self.db.execute(
select(Position).join(
PositionMember, PositionMember.position_id == Position.id
).where(
PositionMember.user_id == user_id
)
)
positions = result.scalars().all()
return [{"id": p.id, "name": p.name} for p in positions]
async def is_team_manager(self, user_id: int, team_id: int) -> bool:
"""
检查用户是否是团队管理者
"""
# 检查用户是否是该岗位的创建者或管理者
position_result = await self.db.execute(
select(Position).where(Position.id == team_id)
)
position = position_result.scalar_one_or_none()
if not position:
return False
# 检查创建者
if hasattr(position, 'created_by') and position.created_by == user_id:
return True
# 检查用户角色是否为管理者
user_result = await self.db.execute(
select(User).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()
return user and user.role in ['admin', 'manager']
# 辅助函数:创建权限服务实例
def get_permission_service(db: AsyncSession) -> PermissionService:
return PermissionService(db)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,379 @@
"""
智能学习推荐服务
基于用户能力评估、错题记录和学习历史推荐学习内容
"""
import logging
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, func, desc
from sqlalchemy.orm import selectinload
from app.models.user import User
from app.models.course import Course, CourseStatus, CourseMaterial, KnowledgePoint
from app.models.exam import ExamResult
from app.models.exam_mistake import ExamMistake
from app.models.user_course_progress import UserCourseProgress, ProgressStatus
from app.models.ability import AbilityAssessment
logger = logging.getLogger(__name__)
class RecommendationService:
"""
智能学习推荐服务
推荐策略:
1. 基于错题分析:推荐与错题相关的知识点和课程
2. 基于能力评估:推荐弱项能力相关的课程
3. 基于学习进度:推荐未完成的课程继续学习
4. 基于热门课程:推荐学习人数多的课程
5. 基于岗位要求:推荐岗位必修课程
"""
def __init__(self, db: AsyncSession):
self.db = db
async def get_recommendations(
self,
user_id: int,
limit: int = 10,
include_reasons: bool = True,
) -> List[Dict[str, Any]]:
"""
获取个性化学习推荐
Args:
user_id: 用户ID
limit: 推荐数量上限
include_reasons: 是否包含推荐理由
Returns:
推荐课程列表,包含课程信息和推荐理由
"""
recommendations = []
# 1. 基于错题推荐
mistake_recs = await self._get_mistake_based_recommendations(user_id)
recommendations.extend(mistake_recs)
# 2. 基于能力评估推荐
ability_recs = await self._get_ability_based_recommendations(user_id)
recommendations.extend(ability_recs)
# 3. 基于未完成课程推荐
progress_recs = await self._get_progress_based_recommendations(user_id)
recommendations.extend(progress_recs)
# 4. 基于热门课程推荐
popular_recs = await self._get_popular_recommendations(user_id)
recommendations.extend(popular_recs)
# 去重并排序
seen_course_ids = set()
unique_recs = []
for rec in recommendations:
if rec["course_id"] not in seen_course_ids:
seen_course_ids.add(rec["course_id"])
unique_recs.append(rec)
# 按优先级排序
priority_map = {
"mistake": 1,
"ability": 2,
"progress": 3,
"popular": 4,
}
unique_recs.sort(key=lambda x: priority_map.get(x.get("source", ""), 5))
# 限制数量
result = unique_recs[:limit]
# 移除 source 字段如果不需要理由
if not include_reasons:
for rec in result:
rec.pop("source", None)
rec.pop("reason", None)
return result
async def _get_mistake_based_recommendations(
self,
user_id: int,
limit: int = 3,
) -> List[Dict[str, Any]]:
"""基于错题推荐"""
recommendations = []
try:
# 获取用户最近的错题
result = await self.db.execute(
select(ExamMistake).where(
ExamMistake.user_id == user_id
).order_by(
desc(ExamMistake.created_at)
).limit(50)
)
mistakes = result.scalars().all()
if not mistakes:
return recommendations
# 统计错题涉及的知识点
knowledge_point_counts = {}
for mistake in mistakes:
if hasattr(mistake, 'knowledge_point_id') and mistake.knowledge_point_id:
kp_id = mistake.knowledge_point_id
knowledge_point_counts[kp_id] = knowledge_point_counts.get(kp_id, 0) + 1
if not knowledge_point_counts:
return recommendations
# 找出错误最多的知识点对应的课程
top_kp_ids = sorted(
knowledge_point_counts.keys(),
key=lambda x: knowledge_point_counts[x],
reverse=True
)[:5]
course_result = await self.db.execute(
select(Course, KnowledgePoint).join(
KnowledgePoint, Course.id == KnowledgePoint.course_id
).where(
and_(
KnowledgePoint.id.in_(top_kp_ids),
Course.status == CourseStatus.PUBLISHED,
Course.is_deleted == False,
)
).distinct()
)
for course, kp in course_result.all()[:limit]:
recommendations.append({
"course_id": course.id,
"course_name": course.name,
"category": course.category.value if course.category else None,
"cover_image": course.cover_image,
"description": course.description,
"source": "mistake",
"reason": f"您在「{kp.name}」知识点上有错题,建议复习相关内容",
})
except Exception as e:
logger.error(f"基于错题推荐失败: {str(e)}")
return recommendations
async def _get_ability_based_recommendations(
self,
user_id: int,
limit: int = 3,
) -> List[Dict[str, Any]]:
"""基于能力评估推荐"""
recommendations = []
try:
# 获取用户最近的能力评估
result = await self.db.execute(
select(AbilityAssessment).where(
AbilityAssessment.user_id == user_id
).order_by(
desc(AbilityAssessment.created_at)
).limit(1)
)
assessment = result.scalar_one_or_none()
if not assessment:
return recommendations
# 解析能力评估结果,找出弱项
scores = {}
if hasattr(assessment, 'dimension_scores') and assessment.dimension_scores:
scores = assessment.dimension_scores
elif hasattr(assessment, 'scores') and assessment.scores:
scores = assessment.scores
if not scores:
return recommendations
# 找出分数最低的维度
weak_dimensions = sorted(
scores.items(),
key=lambda x: x[1] if isinstance(x[1], (int, float)) else 0
)[:3]
# 根据弱项维度推荐课程(按分类匹配)
category_map = {
"专业知识": "technology",
"沟通能力": "business",
"管理能力": "management",
}
for dim_name, score in weak_dimensions:
if isinstance(score, (int, float)) and score < 70:
category = category_map.get(dim_name)
if category:
course_result = await self.db.execute(
select(Course).where(
and_(
Course.category == category,
Course.status == CourseStatus.PUBLISHED,
Course.is_deleted == False,
)
).order_by(
desc(Course.student_count)
).limit(1)
)
course = course_result.scalar_one_or_none()
if course:
recommendations.append({
"course_id": course.id,
"course_name": course.name,
"category": course.category.value if course.category else None,
"cover_image": course.cover_image,
"description": course.description,
"source": "ability",
"reason": f"您的「{dim_name}」能力评分较低({score}分),推荐学习此课程提升",
})
except Exception as e:
logger.error(f"基于能力评估推荐失败: {str(e)}")
return recommendations[:limit]
async def _get_progress_based_recommendations(
self,
user_id: int,
limit: int = 3,
) -> List[Dict[str, Any]]:
"""基于学习进度推荐"""
recommendations = []
try:
# 获取未完成的课程
result = await self.db.execute(
select(UserCourseProgress, Course).join(
Course, UserCourseProgress.course_id == Course.id
).where(
and_(
UserCourseProgress.user_id == user_id,
UserCourseProgress.status == ProgressStatus.IN_PROGRESS.value,
Course.is_deleted == False,
)
).order_by(
desc(UserCourseProgress.last_accessed_at)
).limit(limit)
)
for progress, course in result.all():
recommendations.append({
"course_id": course.id,
"course_name": course.name,
"category": course.category.value if course.category else None,
"cover_image": course.cover_image,
"description": course.description,
"progress_percent": progress.progress_percent,
"source": "progress",
"reason": f"继续学习,已完成 {progress.progress_percent:.0f}%",
})
except Exception as e:
logger.error(f"基于进度推荐失败: {str(e)}")
return recommendations
async def _get_popular_recommendations(
self,
user_id: int,
limit: int = 3,
) -> List[Dict[str, Any]]:
"""基于热门课程推荐"""
recommendations = []
try:
# 获取用户已学习的课程ID
learned_result = await self.db.execute(
select(UserCourseProgress.course_id).where(
UserCourseProgress.user_id == user_id
)
)
learned_course_ids = [row[0] for row in learned_result.all()]
# 获取热门课程(排除已学习的)
query = select(Course).where(
and_(
Course.status == CourseStatus.PUBLISHED,
Course.is_deleted == False,
)
).order_by(
desc(Course.student_count)
).limit(limit + len(learned_course_ids))
result = await self.db.execute(query)
courses = result.scalars().all()
for course in courses:
if course.id not in learned_course_ids:
recommendations.append({
"course_id": course.id,
"course_name": course.name,
"category": course.category.value if course.category else None,
"cover_image": course.cover_image,
"description": course.description,
"student_count": course.student_count,
"source": "popular",
"reason": f"热门课程,已有 {course.student_count} 人学习",
})
if len(recommendations) >= limit:
break
except Exception as e:
logger.error(f"基于热门推荐失败: {str(e)}")
return recommendations
async def get_knowledge_point_recommendations(
self,
user_id: int,
limit: int = 5,
) -> List[Dict[str, Any]]:
"""
获取知识点级别的推荐
基于错题和能力评估推荐具体的知识点
"""
recommendations = []
try:
# 获取错题涉及的知识点
mistake_result = await self.db.execute(
select(
KnowledgePoint,
func.count(ExamMistake.id).label('mistake_count')
).join(
ExamMistake,
ExamMistake.knowledge_point_id == KnowledgePoint.id
).where(
ExamMistake.user_id == user_id
).group_by(
KnowledgePoint.id
).order_by(
desc('mistake_count')
).limit(limit)
)
for kp, count in mistake_result.all():
recommendations.append({
"knowledge_point_id": kp.id,
"name": kp.name,
"description": kp.description,
"type": kp.type,
"course_id": kp.course_id,
"mistake_count": count,
"reason": f"您在此知识点有 {count} 道错题,建议重点复习",
})
except Exception as e:
logger.error(f"知识点推荐失败: {str(e)}")
return recommendations
# 便捷函数
def get_recommendation_service(db: AsyncSession) -> RecommendationService:
"""获取推荐服务实例"""
return RecommendationService(db)

View File

@@ -0,0 +1,273 @@
"""
定时任务服务
使用 APScheduler 管理定时任务
"""
import logging
from datetime import datetime, timedelta
from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select, and_, func
from app.core.config import settings
from app.models.user import User
from app.models.user_course_progress import UserCourseProgress, ProgressStatus
from app.models.task import Task, TaskAssignment
logger = logging.getLogger(__name__)
# 全局调度器实例
scheduler: Optional[AsyncIOScheduler] = None
async def get_db_session() -> AsyncSession:
"""获取数据库会话"""
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
return async_session()
async def send_learning_reminders():
"""
发送学习提醒
检查所有用户的学习进度,对长时间未学习的用户发送提醒
"""
logger.info("开始执行学习提醒任务")
try:
db = await get_db_session()
from app.services.notification_service import NotificationService
notification_service = NotificationService(db)
# 查找超过3天未学习的用户
three_days_ago = datetime.now() - timedelta(days=3)
result = await db.execute(
select(UserCourseProgress, User).join(
User, UserCourseProgress.user_id == User.id
).where(
and_(
UserCourseProgress.status == ProgressStatus.IN_PROGRESS.value,
UserCourseProgress.last_accessed_at < three_days_ago,
)
)
)
inactive_progress = result.all()
for progress, user in inactive_progress:
# 获取课程名称
from app.models.course import Course
course_result = await db.execute(
select(Course.name).where(Course.id == progress.course_id)
)
course_name = course_result.scalar() or "未知课程"
days_inactive = (datetime.now() - progress.last_accessed_at).days
# 发送提醒
await notification_service.send_learning_reminder(
user_id=user.id,
course_name=course_name,
days_inactive=days_inactive,
)
logger.info(f"已发送学习提醒: user_id={user.id}, course={course_name}")
await db.close()
logger.info(f"学习提醒任务完成,发送了 {len(inactive_progress)} 条提醒")
except Exception as e:
logger.error(f"学习提醒任务失败: {str(e)}")
async def send_task_deadline_reminders():
"""
发送任务截止提醒
检查即将到期的任务,发送提醒给相关用户
"""
logger.info("开始执行任务截止提醒")
try:
db = await get_db_session()
from app.services.notification_service import NotificationService
notification_service = NotificationService(db)
# 查找3天内到期的未完成任务
now = datetime.now()
three_days_later = now + timedelta(days=3)
result = await db.execute(
select(Task, TaskAssignment, User).join(
TaskAssignment, Task.id == TaskAssignment.task_id
).join(
User, TaskAssignment.user_id == User.id
).where(
and_(
Task.end_time.between(now, three_days_later),
TaskAssignment.status.in_(["not_started", "in_progress"]),
)
)
)
upcoming_tasks = result.all()
for task, assignment, user in upcoming_tasks:
await notification_service.send_task_deadline_reminder(
user_id=user.id,
task_name=task.name,
deadline=task.end_time,
)
logger.info(f"已发送任务截止提醒: user_id={user.id}, task={task.name}")
await db.close()
logger.info(f"任务截止提醒完成,发送了 {len(upcoming_tasks)} 条提醒")
except Exception as e:
logger.error(f"任务截止提醒失败: {str(e)}")
async def send_weekly_reports():
"""
发送周学习报告
每周一发送上周的学习统计报告
"""
logger.info("开始生成周学习报告")
try:
db = await get_db_session()
from app.services.notification_service import NotificationService
notification_service = NotificationService(db)
# 获取所有活跃用户
result = await db.execute(
select(User).where(User.is_active == True)
)
users = result.scalars().all()
# 计算上周时间范围
today = datetime.now().date()
last_week_start = today - timedelta(days=today.weekday() + 7)
last_week_end = last_week_start + timedelta(days=6)
for user in users:
# 统计学习时长
study_time_result = await db.execute(
select(func.coalesce(func.sum(UserCourseProgress.total_study_time), 0)).where(
and_(
UserCourseProgress.user_id == user.id,
UserCourseProgress.last_accessed_at.between(
datetime.combine(last_week_start, datetime.min.time()),
datetime.combine(last_week_end, datetime.max.time()),
)
)
)
)
study_time = study_time_result.scalar() or 0
# 统计完成课程数
completed_result = await db.execute(
select(func.count(UserCourseProgress.id)).where(
and_(
UserCourseProgress.user_id == user.id,
UserCourseProgress.status == ProgressStatus.COMPLETED.value,
UserCourseProgress.completed_at.between(
datetime.combine(last_week_start, datetime.min.time()),
datetime.combine(last_week_end, datetime.max.time()),
)
)
)
)
courses_completed = completed_result.scalar() or 0
# 如果有学习活动,发送报告
if study_time > 0 or courses_completed > 0:
await notification_service.send_weekly_report(
user_id=user.id,
study_time=study_time,
courses_completed=courses_completed,
exams_passed=0, # TODO: 统计考试通过数
)
logger.info(f"已发送周报: user_id={user.id}")
await db.close()
logger.info("周学习报告发送完成")
except Exception as e:
logger.error(f"周学习报告发送失败: {str(e)}")
def init_scheduler():
"""初始化定时任务调度器"""
global scheduler
if scheduler is not None:
return scheduler
scheduler = AsyncIOScheduler()
# 学习提醒每天上午9点执行
scheduler.add_job(
send_learning_reminders,
CronTrigger(hour=9, minute=0),
id="learning_reminders",
name="学习提醒",
replace_existing=True,
)
# 任务截止提醒每天上午10点执行
scheduler.add_job(
send_task_deadline_reminders,
CronTrigger(hour=10, minute=0),
id="task_deadline_reminders",
name="任务截止提醒",
replace_existing=True,
)
# 周学习报告每周一上午8点发送
scheduler.add_job(
send_weekly_reports,
CronTrigger(day_of_week="mon", hour=8, minute=0),
id="weekly_reports",
name="周学习报告",
replace_existing=True,
)
logger.info("定时任务调度器初始化完成")
return scheduler
def start_scheduler():
"""启动调度器"""
global scheduler
if scheduler is None:
scheduler = init_scheduler()
if not scheduler.running:
scheduler.start()
logger.info("定时任务调度器已启动")
def stop_scheduler():
"""停止调度器"""
global scheduler
if scheduler and scheduler.running:
scheduler.shutdown()
logger.info("定时任务调度器已停止")
def get_scheduler() -> Optional[AsyncIOScheduler]:
"""获取调度器实例"""
return scheduler

View File

@@ -0,0 +1,256 @@
"""
语音识别服务
支持多种语音识别引擎:
1. 阿里云语音识别
2. 讯飞语音识别
3. 本地 Whisper 模型
"""
import os
import base64
import json
import hmac
import hashlib
import time
from datetime import datetime
from typing import Optional, Dict, Any
import httpx
from urllib.parse import urlencode
class SpeechRecognitionError(Exception):
"""语音识别错误"""
pass
class AliyunSpeechRecognition:
"""
阿里云智能语音交互 - 一句话识别
文档: https://help.aliyun.com/document_detail/92131.html
"""
def __init__(
self,
access_key_id: Optional[str] = None,
access_key_secret: Optional[str] = None,
app_key: Optional[str] = None,
):
self.access_key_id = access_key_id or os.getenv("ALIYUN_ACCESS_KEY_ID")
self.access_key_secret = access_key_secret or os.getenv("ALIYUN_ACCESS_KEY_SECRET")
self.app_key = app_key or os.getenv("ALIYUN_NLS_APP_KEY")
self.api_url = "https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr"
def _create_signature(self, params: Dict[str, str]) -> str:
"""创建签名"""
sorted_params = sorted(params.items())
query_string = urlencode(sorted_params)
string_to_sign = f"POST&%2F&{urlencode({query_string: ''}).split('=')[0]}"
signature = hmac.new(
(self.access_key_secret + "&").encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha1,
).digest()
return base64.b64encode(signature).decode("utf-8")
async def recognize(
self,
audio_data: bytes,
format: str = "wav",
sample_rate: int = 16000,
) -> str:
"""
识别音频
Args:
audio_data: 音频数据(二进制)
format: 音频格式,支持 pcm, wav, ogg, opus, mp3
sample_rate: 采样率,默认 16000
Returns:
识别出的文本
"""
if not all([self.access_key_id, self.access_key_secret, self.app_key]):
raise SpeechRecognitionError("阿里云语音识别配置不完整")
headers = {
"Content-Type": f"audio/{format}; samplerate={sample_rate}",
"X-NLS-Token": await self._get_token(),
}
params = {
"appkey": self.app_key,
"format": format,
"sample_rate": str(sample_rate),
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(
self.api_url,
params=params,
headers=headers,
content=audio_data,
timeout=30.0,
)
if response.status_code != 200:
raise SpeechRecognitionError(
f"阿里云语音识别请求失败: {response.status_code}"
)
result = response.json()
if result.get("status") == 20000000:
return result.get("result", "")
else:
raise SpeechRecognitionError(
f"语音识别失败: {result.get('message', '未知错误')}"
)
except httpx.RequestError as e:
raise SpeechRecognitionError(f"网络请求错误: {str(e)}")
async def _get_token(self) -> str:
"""获取访问令牌"""
# 简化版:实际生产环境需要缓存 token
token_url = "https://nls-meta.cn-shanghai.aliyuncs.com/"
timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
params = {
"AccessKeyId": self.access_key_id,
"Action": "CreateToken",
"Format": "JSON",
"RegionId": "cn-shanghai",
"SignatureMethod": "HMAC-SHA1",
"SignatureNonce": str(int(time.time() * 1000)),
"SignatureVersion": "1.0",
"Timestamp": timestamp,
"Version": "2019-02-28",
}
params["Signature"] = self._create_signature(params)
async with httpx.AsyncClient() as client:
response = await client.get(token_url, params=params, timeout=10.0)
result = response.json()
if "Token" in result:
return result["Token"]["Id"]
else:
raise SpeechRecognitionError(
f"获取阿里云语音识别 Token 失败: {result.get('Message', '未知错误')}"
)
class XunfeiSpeechRecognition:
"""
讯飞语音识别
文档: https://www.xfyun.cn/doc/asr/voicedictation/API.html
"""
def __init__(
self,
app_id: Optional[str] = None,
api_key: Optional[str] = None,
api_secret: Optional[str] = None,
):
self.app_id = app_id or os.getenv("XUNFEI_APP_ID")
self.api_key = api_key or os.getenv("XUNFEI_API_KEY")
self.api_secret = api_secret or os.getenv("XUNFEI_API_SECRET")
self.api_url = "wss://iat-api.xfyun.cn/v2/iat"
async def recognize(
self,
audio_data: bytes,
format: str = "audio/L16;rate=16000",
) -> str:
"""
识别音频
Args:
audio_data: 音频数据(二进制)
format: 音频格式
Returns:
识别出的文本
"""
if not all([self.app_id, self.api_key, self.api_secret]):
raise SpeechRecognitionError("讯飞语音识别配置不完整")
# 讯飞使用 WebSocket这里是简化实现
# 实际需要使用 websockets 库进行实时流式识别
raise NotImplementedError("讯飞语音识别需要 WebSocket 实现")
class SimpleSpeechRecognition:
"""
简易语音识别实现
使用浏览器 Web Speech API 的结果直接返回
用于前端已经完成识别的情况
"""
async def recognize(self, text: str) -> str:
"""直接返回前端传来的识别结果"""
return text.strip()
class SpeechRecognitionService:
"""
语音识别服务统一接口
根据配置选择不同的识别引擎
"""
def __init__(self, engine: str = "simple"):
"""
初始化语音识别服务
Args:
engine: 识别引擎,支持 aliyun, xunfei, simple
"""
self.engine = engine
if engine == "aliyun":
self._recognizer = AliyunSpeechRecognition()
elif engine == "xunfei":
self._recognizer = XunfeiSpeechRecognition()
else:
self._recognizer = SimpleSpeechRecognition()
async def recognize_audio(
self,
audio_data: bytes,
format: str = "wav",
sample_rate: int = 16000,
) -> str:
"""
识别音频数据
Args:
audio_data: 音频二进制数据
format: 音频格式
sample_rate: 采样率
Returns:
识别出的文本
"""
if self.engine == "simple":
raise SpeechRecognitionError(
"简易模式不支持音频识别,请使用前端 Web Speech API"
)
return await self._recognizer.recognize(audio_data, format, sample_rate)
async def recognize_text(self, text: str) -> str:
"""
直接处理已识别的文本(用于前端已完成识别的情况)
Args:
text: 已识别的文本
Returns:
处理后的文本
"""
return text.strip()
# 创建默认服务实例
def get_speech_recognition_service(engine: str = "simple") -> SpeechRecognitionService:
"""获取语音识别服务实例"""
return SpeechRecognitionService(engine=engine)

View File

@@ -1,82 +1,82 @@
# 数据库迁移说明 # 数据库迁移说明
本目录包含 KPL 考培练系统的数据库迁移脚本。 本目录包含 KPL 考培练系统的数据库迁移脚本。
## 迁移脚本列表 ## 迁移脚本列表
| 脚本 | 说明 | 创建时间 | | 脚本 | 说明 | 创建时间 |
|------|------|----------| |------|------|----------|
| `add_level_badge_system.sql` | 等级与奖章系统 | 2026-01-29 | | `add_level_badge_system.sql` | 等级与奖章系统 | 2026-01-29 |
## 执行迁移 ## 执行迁移
### 测试环境Docker ### 测试环境Docker
KPL 测试环境数据库在服务器 Docker 容器中运行: KPL 测试环境数据库在服务器 Docker 容器中运行:
```bash ```bash
# 1. SSH 登录 KPL 服务器 # 1. SSH 登录 KPL 服务器
ssh root@<KPL服务器IP> ssh root@<KPL服务器IP>
# 2. 进入项目目录 # 2. 进入项目目录
cd /www/wwwroot/kpl.ireborn.com.cn cd /www/wwwroot/kpl.ireborn.com.cn
# 3. 执行迁移(方法一:直接执行) # 3. 执行迁移(方法一:直接执行)
docker exec -i kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev < backend/migrations/add_level_badge_system.sql docker exec -i kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev < backend/migrations/add_level_badge_system.sql
# 或者(方法二:交互式执行) # 或者(方法二:交互式执行)
docker exec -it kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev docker exec -it kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev
# 然后复制粘贴 SQL 脚本内容执行 # 然后复制粘贴 SQL 脚本内容执行
# 方法三从本地执行需要先上传SQL文件到服务器 # 方法三从本地执行需要先上传SQL文件到服务器
# scp backend/migrations/add_level_badge_system.sql root@<服务器IP>:/tmp/ # scp backend/migrations/add_level_badge_system.sql root@<服务器IP>:/tmp/
# ssh root@<服务器IP> "docker exec -i kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev < /tmp/add_level_badge_system.sql" # ssh root@<服务器IP> "docker exec -i kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev < /tmp/add_level_badge_system.sql"
``` ```
**注意**MySQL 容器密码为 `nj861021`(之前通过 `docker exec kpl-mysql-dev env | grep MYSQL` 确认) **注意**MySQL 容器密码为 `nj861021`(之前通过 `docker exec kpl-mysql-dev env | grep MYSQL` 确认)
### 生产环境 ### 生产环境
生产环境迁移前请确保: 生产环境迁移前请确保:
1. 已备份数据库 1. 已备份数据库
2. 在低峰期执行 2. 在低峰期执行
3. 测试环境验证通过 3. 测试环境验证通过
```bash ```bash
# 执行迁移(替换为实际的生产数据库配置) # 执行迁移(替换为实际的生产数据库配置)
mysql -h<host> -u<user> -p<password> <database> < backend/migrations/add_level_badge_system.sql mysql -h<host> -u<user> -p<password> <database> < backend/migrations/add_level_badge_system.sql
``` ```
## 回滚方法 ## 回滚方法
如需回滚,执行以下 SQL 如需回滚,执行以下 SQL
```sql ```sql
DROP TABLE IF EXISTS user_badges; DROP TABLE IF EXISTS user_badges;
DROP TABLE IF EXISTS badge_definitions; DROP TABLE IF EXISTS badge_definitions;
DROP TABLE IF EXISTS exp_history; DROP TABLE IF EXISTS exp_history;
DROP TABLE IF EXISTS level_configs; DROP TABLE IF EXISTS level_configs;
DROP TABLE IF EXISTS user_levels; DROP TABLE IF EXISTS user_levels;
``` ```
## 验证迁移 ## 验证迁移
执行以下查询验证表是否创建成功: 执行以下查询验证表是否创建成功:
```sql ```sql
SHOW TABLES LIKE '%level%'; SHOW TABLES LIKE '%level%';
SHOW TABLES LIKE '%badge%'; SHOW TABLES LIKE '%badge%';
SHOW TABLES LIKE '%exp%'; SHOW TABLES LIKE '%exp%';
-- 查看表结构 -- 查看表结构
DESCRIBE user_levels; DESCRIBE user_levels;
DESCRIBE exp_history; DESCRIBE exp_history;
DESCRIBE badge_definitions; DESCRIBE badge_definitions;
DESCRIBE user_badges; DESCRIBE user_badges;
DESCRIBE level_configs; DESCRIBE level_configs;
-- 验证初始数据 -- 验证初始数据
SELECT COUNT(*) FROM level_configs; -- 应该是 10 条 SELECT COUNT(*) FROM level_configs; -- 应该是 10 条
SELECT COUNT(*) FROM badge_definitions; -- 应该是 20 条 SELECT COUNT(*) FROM badge_definitions; -- 应该是 20 条
SELECT COUNT(*) FROM user_levels; -- 应该等于用户数 SELECT COUNT(*) FROM user_levels; -- 应该等于用户数
``` ```

View File

@@ -1,166 +1,166 @@
-- ================================================================ -- ================================================================
-- 证书系统数据库迁移脚本 -- 证书系统数据库迁移脚本
-- 创建日期: 2026-01-29 -- 创建日期: 2026-01-29
-- 功能: 添加证书模板表和用户证书表 -- 功能: 添加证书模板表和用户证书表
-- ================================================================ -- ================================================================
-- 事务开始 -- 事务开始
START TRANSACTION; START TRANSACTION;
-- ================================================================ -- ================================================================
-- 1. 创建证书模板表 -- 1. 创建证书模板表
-- ================================================================ -- ================================================================
CREATE TABLE IF NOT EXISTS certificate_templates ( CREATE TABLE IF NOT EXISTS certificate_templates (
id INT PRIMARY KEY AUTO_INCREMENT, id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL COMMENT '模板名称', name VARCHAR(100) NOT NULL COMMENT '模板名称',
type ENUM('course', 'exam', 'achievement') NOT NULL COMMENT '证书类型: course=课程结业, exam=考试合格, achievement=成就证书', type ENUM('course', 'exam', 'achievement') NOT NULL COMMENT '证书类型: course=课程结业, exam=考试合格, achievement=成就证书',
background_url VARCHAR(500) COMMENT '证书背景图URL', background_url VARCHAR(500) COMMENT '证书背景图URL',
template_html TEXT COMMENT 'HTML模板内容', template_html TEXT COMMENT 'HTML模板内容',
template_style TEXT COMMENT 'CSS样式', template_style TEXT COMMENT 'CSS样式',
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用', is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
sort_order INT DEFAULT 0 COMMENT '排序顺序', sort_order INT DEFAULT 0 COMMENT '排序顺序',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_type (type), INDEX idx_type (type),
INDEX idx_is_active (is_active) INDEX idx_is_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='证书模板表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='证书模板表';
-- ================================================================ -- ================================================================
-- 2. 创建用户证书表 -- 2. 创建用户证书表
-- ================================================================ -- ================================================================
CREATE TABLE IF NOT EXISTS user_certificates ( CREATE TABLE IF NOT EXISTS user_certificates (
id INT PRIMARY KEY AUTO_INCREMENT, id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL COMMENT '用户ID', user_id INT NOT NULL COMMENT '用户ID',
template_id INT NOT NULL COMMENT '模板ID', template_id INT NOT NULL COMMENT '模板ID',
certificate_no VARCHAR(50) UNIQUE NOT NULL COMMENT '证书编号 KPL-年份-序号', certificate_no VARCHAR(50) UNIQUE NOT NULL COMMENT '证书编号 KPL-年份-序号',
title VARCHAR(200) NOT NULL COMMENT '证书标题', title VARCHAR(200) NOT NULL COMMENT '证书标题',
description TEXT COMMENT '证书描述/成就说明', description TEXT COMMENT '证书描述/成就说明',
issued_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '颁发时间', issued_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '颁发时间',
valid_until DATETIME COMMENT '有效期至NULL表示永久', valid_until DATETIME COMMENT '有效期至NULL表示永久',
-- 关联信息 -- 关联信息
course_id INT COMMENT '关联课程ID', course_id INT COMMENT '关联课程ID',
exam_id INT COMMENT '关联考试ID', exam_id INT COMMENT '关联考试ID',
badge_id INT COMMENT '关联奖章ID', badge_id INT COMMENT '关联奖章ID',
-- 成绩信息 -- 成绩信息
score DECIMAL(5,2) COMMENT '考试分数', score DECIMAL(5,2) COMMENT '考试分数',
completion_rate DECIMAL(5,2) COMMENT '完成率', completion_rate DECIMAL(5,2) COMMENT '完成率',
-- 生成的文件 -- 生成的文件
pdf_url VARCHAR(500) COMMENT 'PDF文件URL', pdf_url VARCHAR(500) COMMENT 'PDF文件URL',
image_url VARCHAR(500) COMMENT '分享图片URL', image_url VARCHAR(500) COMMENT '分享图片URL',
-- 元数据 -- 元数据
meta_data JSON COMMENT '扩展元数据', meta_data JSON COMMENT '扩展元数据',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (template_id) REFERENCES certificate_templates(id), FOREIGN KEY (template_id) REFERENCES certificate_templates(id),
INDEX idx_user_id (user_id), INDEX idx_user_id (user_id),
INDEX idx_certificate_no (certificate_no), INDEX idx_certificate_no (certificate_no),
INDEX idx_course_id (course_id), INDEX idx_course_id (course_id),
INDEX idx_exam_id (exam_id), INDEX idx_exam_id (exam_id),
INDEX idx_issued_at (issued_at) INDEX idx_issued_at (issued_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户证书表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户证书表';
-- ================================================================ -- ================================================================
-- 3. 插入默认证书模板 -- 3. 插入默认证书模板
-- ================================================================ -- ================================================================
INSERT INTO certificate_templates (name, type, template_html, template_style, is_active, sort_order) VALUES INSERT INTO certificate_templates (name, type, template_html, template_style, is_active, sort_order) VALUES
-- 课程结业证书模板 -- 课程结业证书模板
('课程结业证书', 'course', ('课程结业证书', 'course',
'<div class="certificate"> '<div class="certificate">
<div class="header"> <div class="header">
<div class="logo">考培练系统</div> <div class="logo">考培练系统</div>
<h1>结业证书</h1> <h1>结业证书</h1>
</div> </div>
<div class="body"> <div class="body">
<p class="recipient">兹证明 <strong>{{user_name}}</strong></p> <p class="recipient">兹证明 <strong>{{user_name}}</strong></p>
<p class="content">已完成课程《{{course_name}}》的全部学习内容</p> <p class="content">已完成课程《{{course_name}}》的全部学习内容</p>
<p class="completion">完成率:{{completion_rate}}%</p> <p class="completion">完成率:{{completion_rate}}%</p>
<p class="date">颁发日期:{{issue_date}}</p> <p class="date">颁发日期:{{issue_date}}</p>
</div> </div>
<div class="footer"> <div class="footer">
<div class="cert-no">证书编号:{{certificate_no}}</div> <div class="cert-no">证书编号:{{certificate_no}}</div>
<div class="qrcode">{{qrcode}}</div> <div class="qrcode">{{qrcode}}</div>
</div> </div>
</div>', </div>',
'.certificate { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #f5f7fa 0%, #e4e9f2 100%); font-family: "Microsoft YaHei", sans-serif; } '.certificate { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #f5f7fa 0%, #e4e9f2 100%); font-family: "Microsoft YaHei", sans-serif; }
.header { text-align: center; margin-bottom: 30px; } .header { text-align: center; margin-bottom: 30px; }
.header .logo { font-size: 24px; color: #667eea; font-weight: bold; } .header .logo { font-size: 24px; color: #667eea; font-weight: bold; }
.header h1 { font-size: 36px; color: #333; margin: 20px 0; } .header h1 { font-size: 36px; color: #333; margin: 20px 0; }
.body { text-align: center; padding: 30px 60px; } .body { text-align: center; padding: 30px 60px; }
.body .recipient { font-size: 20px; margin-bottom: 20px; } .body .recipient { font-size: 20px; margin-bottom: 20px; }
.body .content { font-size: 18px; color: #555; margin-bottom: 15px; } .body .content { font-size: 18px; color: #555; margin-bottom: 15px; }
.body .completion { font-size: 16px; color: #667eea; } .body .completion { font-size: 16px; color: #667eea; }
.body .date { font-size: 14px; color: #888; margin-top: 30px; } .body .date { font-size: 14px; color: #888; margin-top: 30px; }
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: 40px; } .footer { display: flex; justify-content: space-between; align-items: center; margin-top: 40px; }
.cert-no { font-size: 12px; color: #999; } .cert-no { font-size: 12px; color: #999; }
.qrcode { width: 80px; height: 80px; }', .qrcode { width: 80px; height: 80px; }',
TRUE, 1), TRUE, 1),
-- 考试合格证书模板 -- 考试合格证书模板
('考试合格证书', 'exam', ('考试合格证书', 'exam',
'<div class="certificate exam-cert"> '<div class="certificate exam-cert">
<div class="header"> <div class="header">
<div class="logo">考培练系统</div> <div class="logo">考培练系统</div>
<h1>考试合格证书</h1> <h1>考试合格证书</h1>
</div> </div>
<div class="body"> <div class="body">
<p class="recipient">兹证明 <strong>{{user_name}}</strong></p> <p class="recipient">兹证明 <strong>{{user_name}}</strong></p>
<p class="content">在《{{exam_name}}》考试中成绩合格</p> <p class="content">在《{{exam_name}}》考试中成绩合格</p>
<div class="score-badge"> <div class="score-badge">
<span class="score">{{score}}</span> <span class="score">{{score}}</span>
<span class="unit">分</span> <span class="unit">分</span>
</div> </div>
<p class="date">考试日期:{{exam_date}}</p> <p class="date">考试日期:{{exam_date}}</p>
</div> </div>
<div class="footer"> <div class="footer">
<div class="cert-no">证书编号:{{certificate_no}}</div> <div class="cert-no">证书编号:{{certificate_no}}</div>
<div class="qrcode">{{qrcode}}</div> <div class="qrcode">{{qrcode}}</div>
</div> </div>
</div>', </div>',
'.certificate.exam-cert { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); font-family: "Microsoft YaHei", sans-serif; } '.certificate.exam-cert { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); font-family: "Microsoft YaHei", sans-serif; }
.exam-cert .header h1 { color: #2e7d32; } .exam-cert .header h1 { color: #2e7d32; }
.score-badge { display: inline-block; padding: 20px 40px; background: #fff; border-radius: 50%; box-shadow: 0 4px 20px rgba(0,0,0,0.1); margin: 20px 0; } .score-badge { display: inline-block; padding: 20px 40px; background: #fff; border-radius: 50%; box-shadow: 0 4px 20px rgba(0,0,0,0.1); margin: 20px 0; }
.score-badge .score { font-size: 48px; font-weight: bold; color: #2e7d32; } .score-badge .score { font-size: 48px; font-weight: bold; color: #2e7d32; }
.score-badge .unit { font-size: 18px; color: #666; }', .score-badge .unit { font-size: 18px; color: #666; }',
TRUE, 2), TRUE, 2),
-- 成就证书模板 -- 成就证书模板
('成就证书', 'achievement', ('成就证书', 'achievement',
'<div class="certificate achievement-cert"> '<div class="certificate achievement-cert">
<div class="header"> <div class="header">
<div class="logo">考培练系统</div> <div class="logo">考培练系统</div>
<h1>成就证书</h1> <h1>成就证书</h1>
</div> </div>
<div class="body"> <div class="body">
<p class="recipient">兹证明 <strong>{{user_name}}</strong></p> <p class="recipient">兹证明 <strong>{{user_name}}</strong></p>
<div class="achievement-icon">{{badge_icon}}</div> <div class="achievement-icon">{{badge_icon}}</div>
<p class="achievement-name">{{badge_name}}</p> <p class="achievement-name">{{badge_name}}</p>
<p class="achievement-desc">{{badge_description}}</p> <p class="achievement-desc">{{badge_description}}</p>
<p class="date">获得日期:{{achieve_date}}</p> <p class="date">获得日期:{{achieve_date}}</p>
</div> </div>
<div class="footer"> <div class="footer">
<div class="cert-no">证书编号:{{certificate_no}}</div> <div class="cert-no">证书编号:{{certificate_no}}</div>
<div class="qrcode">{{qrcode}}</div> <div class="qrcode">{{qrcode}}</div>
</div> </div>
</div>', </div>',
'.certificate.achievement-cert { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%); font-family: "Microsoft YaHei", sans-serif; } '.certificate.achievement-cert { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%); font-family: "Microsoft YaHei", sans-serif; }
.achievement-cert .header h1 { color: #e65100; } .achievement-cert .header h1 { color: #e65100; }
.achievement-icon { font-size: 64px; margin: 20px 0; } .achievement-icon { font-size: 64px; margin: 20px 0; }
.achievement-name { font-size: 24px; font-weight: bold; color: #e65100; margin: 10px 0; } .achievement-name { font-size: 24px; font-weight: bold; color: #e65100; margin: 10px 0; }
.achievement-desc { font-size: 16px; color: #666; }', .achievement-desc { font-size: 16px; color: #666; }',
TRUE, 3); TRUE, 3);
-- 提交事务 -- 提交事务
COMMIT; COMMIT;
-- ================================================================ -- ================================================================
-- 验证脚本 -- 验证脚本
-- ================================================================ -- ================================================================
-- SELECT * FROM certificate_templates; -- SELECT * FROM certificate_templates;
-- SELECT COUNT(*) AS template_count FROM certificate_templates; -- SELECT COUNT(*) AS template_count FROM certificate_templates;

View File

@@ -1,41 +1,41 @@
-- ===================================================== -- =====================================================
-- 钉钉免密登录功能 - 数据库迁移脚本 -- 钉钉免密登录功能 - 数据库迁移脚本
-- 创建时间: 2026-01-28 -- 创建时间: 2026-01-28
-- 说明: 为考培练系统添加钉钉免密登录支持 -- 说明: 为考培练系统添加钉钉免密登录支持
-- ===================================================== -- =====================================================
-- 1. 用户表添加 dingtalk_id 字段 -- 1. 用户表添加 dingtalk_id 字段
-- ----------------------------------------------------- -- -----------------------------------------------------
ALTER TABLE users ADD COLUMN dingtalk_id VARCHAR(64) UNIQUE COMMENT '钉钉用户ID'; ALTER TABLE users ADD COLUMN dingtalk_id VARCHAR(64) UNIQUE COMMENT '钉钉用户ID';
CREATE INDEX idx_users_dingtalk_id ON users(dingtalk_id); CREATE INDEX idx_users_dingtalk_id ON users(dingtalk_id);
-- 2. 配置模板表添加钉钉配置项 -- 2. 配置模板表添加钉钉配置项
-- ----------------------------------------------------- -- -----------------------------------------------------
INSERT INTO config_templates (config_group, config_key, display_name, description, value_type, is_required, is_secret, sort_order) VALUES INSERT INTO config_templates (config_group, config_key, display_name, description, value_type, is_required, is_secret, sort_order) VALUES
('dingtalk', 'DINGTALK_APP_KEY', 'AppKey', '钉钉应用的AppKey从钉钉开放平台获取', 'string', 1, 0, 1), ('dingtalk', 'DINGTALK_APP_KEY', 'AppKey', '钉钉应用的AppKey从钉钉开放平台获取', 'string', 1, 0, 1),
('dingtalk', 'DINGTALK_APP_SECRET', 'AppSecret', '钉钉应用的AppSecret敏感信息', 'string', 1, 1, 2), ('dingtalk', 'DINGTALK_APP_SECRET', 'AppSecret', '钉钉应用的AppSecret敏感信息', 'string', 1, 1, 2),
('dingtalk', 'DINGTALK_AGENT_ID', 'AgentId', '钉钉应用的AgentId', 'string', 1, 0, 3), ('dingtalk', 'DINGTALK_AGENT_ID', 'AgentId', '钉钉应用的AgentId', 'string', 1, 0, 3),
('dingtalk', 'DINGTALK_CORP_ID', 'CorpId', '钉钉企业的CorpId', 'string', 1, 0, 4); ('dingtalk', 'DINGTALK_CORP_ID', 'CorpId', '钉钉企业的CorpId', 'string', 1, 0, 4);
-- 3. 功能开关表添加钉钉免密登录开关(默认禁用) -- 3. 功能开关表添加钉钉免密登录开关(默认禁用)
-- ----------------------------------------------------- -- -----------------------------------------------------
INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description) VALUES INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description) VALUES
(NULL, 'dingtalk_login', '钉钉免密登录', 'auth', 0, '启用后,用户可通过钉钉免密登录系统'); (NULL, 'dingtalk_login', '钉钉免密登录', 'auth', 0, '启用后,用户可通过钉钉免密登录系统');
-- ===================================================== -- =====================================================
-- 回滚脚本如需回滚执行以下SQL -- 回滚脚本如需回滚执行以下SQL
-- ===================================================== -- =====================================================
/* /*
-- 回滚步骤1: 删除功能开关 -- 回滚步骤1: 删除功能开关
DELETE FROM feature_switches WHERE feature_code = 'dingtalk_login'; DELETE FROM feature_switches WHERE feature_code = 'dingtalk_login';
-- 回滚步骤2: 删除配置模板 -- 回滚步骤2: 删除配置模板
DELETE FROM config_templates WHERE config_group = 'dingtalk'; DELETE FROM config_templates WHERE config_group = 'dingtalk';
-- 回滚步骤3: 删除用户表字段 -- 回滚步骤3: 删除用户表字段
ALTER TABLE users DROP INDEX idx_users_dingtalk_id; ALTER TABLE users DROP INDEX idx_users_dingtalk_id;
ALTER TABLE users DROP COLUMN dingtalk_id; ALTER TABLE users DROP COLUMN dingtalk_id;
*/ */

View File

@@ -1,192 +1,192 @@
-- ===================================================== -- =====================================================
-- 等级与奖章系统数据库迁移脚本 -- 等级与奖章系统数据库迁移脚本
-- 版本: 1.0.0 -- 版本: 1.0.0
-- 创建时间: 2026-01-29 -- 创建时间: 2026-01-29
-- 说明: 添加用户等级系统和奖章系统相关表 -- 说明: 添加用户等级系统和奖章系统相关表
-- ===================================================== -- =====================================================
-- 使用事务确保原子性 -- 使用事务确保原子性
START TRANSACTION; START TRANSACTION;
-- ===================================================== -- =====================================================
-- 1. 用户等级表 (user_levels) -- 1. 用户等级表 (user_levels)
-- 存储用户的等级和经验值信息 -- 存储用户的等级和经验值信息
-- ===================================================== -- =====================================================
CREATE TABLE IF NOT EXISTS user_levels ( CREATE TABLE IF NOT EXISTS user_levels (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '用户ID', user_id INT NOT NULL COMMENT '用户ID',
level INT NOT NULL DEFAULT 1 COMMENT '当前等级', level INT NOT NULL DEFAULT 1 COMMENT '当前等级',
exp INT NOT NULL DEFAULT 0 COMMENT '当前经验值', exp INT NOT NULL DEFAULT 0 COMMENT '当前经验值',
total_exp INT NOT NULL DEFAULT 0 COMMENT '累计获得经验值', total_exp INT NOT NULL DEFAULT 0 COMMENT '累计获得经验值',
login_streak INT NOT NULL DEFAULT 0 COMMENT '连续登录天数', login_streak INT NOT NULL DEFAULT 0 COMMENT '连续登录天数',
max_login_streak INT NOT NULL DEFAULT 0 COMMENT '历史最长连续登录天数', max_login_streak INT NOT NULL DEFAULT 0 COMMENT '历史最长连续登录天数',
last_login_date DATE NULL COMMENT '最后登录日期(用于计算连续登录)', last_login_date DATE NULL COMMENT '最后登录日期(用于计算连续登录)',
last_checkin_at DATETIME NULL COMMENT '最后签到时间', last_checkin_at DATETIME NULL COMMENT '最后签到时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_id (user_id), UNIQUE KEY uk_user_id (user_id),
INDEX idx_level (level), INDEX idx_level (level),
INDEX idx_total_exp (total_exp), INDEX idx_total_exp (total_exp),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户等级表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户等级表';
-- ===================================================== -- =====================================================
-- 2. 经验值历史表 (exp_history) -- 2. 经验值历史表 (exp_history)
-- 记录每次经验值变化的详细信息 -- 记录每次经验值变化的详细信息
-- ===================================================== -- =====================================================
CREATE TABLE IF NOT EXISTS exp_history ( CREATE TABLE IF NOT EXISTS exp_history (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '用户ID', user_id INT NOT NULL COMMENT '用户ID',
exp_change INT NOT NULL COMMENT '经验值变化(正为获得,负为扣除)', exp_change INT NOT NULL COMMENT '经验值变化(正为获得,负为扣除)',
exp_type VARCHAR(50) NOT NULL COMMENT '类型exam/practice/training/task/login/badge/other', exp_type VARCHAR(50) NOT NULL COMMENT '类型exam/practice/training/task/login/badge/other',
source_id INT NULL COMMENT '来源记录ID如考试ID、练习ID等', source_id INT NULL COMMENT '来源记录ID如考试ID、练习ID等',
description VARCHAR(255) NOT NULL COMMENT '描述', description VARCHAR(255) NOT NULL COMMENT '描述',
level_before INT NULL COMMENT '变化前等级', level_before INT NULL COMMENT '变化前等级',
level_after INT NULL COMMENT '变化后等级', level_after INT NULL COMMENT '变化后等级',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id), INDEX idx_user_id (user_id),
INDEX idx_exp_type (exp_type), INDEX idx_exp_type (exp_type),
INDEX idx_created_at (created_at), INDEX idx_created_at (created_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='经验值历史表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='经验值历史表';
-- ===================================================== -- =====================================================
-- 3. 奖章定义表 (badge_definitions) -- 3. 奖章定义表 (badge_definitions)
-- 定义所有可获得的奖章及其解锁条件 -- 定义所有可获得的奖章及其解锁条件
-- ===================================================== -- =====================================================
CREATE TABLE IF NOT EXISTS badge_definitions ( CREATE TABLE IF NOT EXISTS badge_definitions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(50) NOT NULL COMMENT '奖章编码(唯一标识)', code VARCHAR(50) NOT NULL COMMENT '奖章编码(唯一标识)',
name VARCHAR(100) NOT NULL COMMENT '奖章名称', name VARCHAR(100) NOT NULL COMMENT '奖章名称',
description VARCHAR(255) NOT NULL COMMENT '奖章描述', description VARCHAR(255) NOT NULL COMMENT '奖章描述',
icon VARCHAR(100) NOT NULL DEFAULT 'Medal' COMMENT '图标名称Element Plus 图标)', icon VARCHAR(100) NOT NULL DEFAULT 'Medal' COMMENT '图标名称Element Plus 图标)',
category VARCHAR(50) NOT NULL COMMENT '分类learning/exam/practice/streak/special', category VARCHAR(50) NOT NULL COMMENT '分类learning/exam/practice/streak/special',
condition_type VARCHAR(50) NOT NULL COMMENT '条件类型count/score/streak/level/duration', condition_type VARCHAR(50) NOT NULL COMMENT '条件类型count/score/streak/level/duration',
condition_field VARCHAR(100) NULL COMMENT '条件字段(用于复杂条件)', condition_field VARCHAR(100) NULL COMMENT '条件字段(用于复杂条件)',
condition_value INT NOT NULL DEFAULT 1 COMMENT '条件数值', condition_value INT NOT NULL DEFAULT 1 COMMENT '条件数值',
exp_reward INT NOT NULL DEFAULT 0 COMMENT '解锁奖励经验值', exp_reward INT NOT NULL DEFAULT 0 COMMENT '解锁奖励经验值',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序顺序', sort_order INT NOT NULL DEFAULT 0 COMMENT '排序顺序',
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_code (code), UNIQUE KEY uk_code (code),
INDEX idx_category (category), INDEX idx_category (category),
INDEX idx_is_active (is_active) INDEX idx_is_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='奖章定义表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='奖章定义表';
-- ===================================================== -- =====================================================
-- 4. 用户奖章表 (user_badges) -- 4. 用户奖章表 (user_badges)
-- 记录用户已解锁的奖章 -- 记录用户已解锁的奖章
-- ===================================================== -- =====================================================
CREATE TABLE IF NOT EXISTS user_badges ( CREATE TABLE IF NOT EXISTS user_badges (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL COMMENT '用户ID', user_id INT NOT NULL COMMENT '用户ID',
badge_id INT NOT NULL COMMENT '奖章ID', badge_id INT NOT NULL COMMENT '奖章ID',
unlocked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '解锁时间', unlocked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '解锁时间',
is_notified TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已通知用户', is_notified TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已通知用户',
notified_at DATETIME NULL COMMENT '通知时间', notified_at DATETIME NULL COMMENT '通知时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_badge (user_id, badge_id), UNIQUE KEY uk_user_badge (user_id, badge_id),
INDEX idx_user_id (user_id), INDEX idx_user_id (user_id),
INDEX idx_badge_id (badge_id), INDEX idx_badge_id (badge_id),
INDEX idx_unlocked_at (unlocked_at), INDEX idx_unlocked_at (unlocked_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (badge_id) REFERENCES badge_definitions(id) ON DELETE CASCADE FOREIGN KEY (badge_id) REFERENCES badge_definitions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户奖章表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户奖章表';
-- ===================================================== -- =====================================================
-- 5. 等级配置表 (level_configs) -- 5. 等级配置表 (level_configs)
-- 定义每个等级所需的经验值和称号 -- 定义每个等级所需的经验值和称号
-- ===================================================== -- =====================================================
CREATE TABLE IF NOT EXISTS level_configs ( CREATE TABLE IF NOT EXISTS level_configs (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
level INT NOT NULL COMMENT '等级', level INT NOT NULL COMMENT '等级',
exp_required INT NOT NULL COMMENT '升到此级所需经验值', exp_required INT NOT NULL COMMENT '升到此级所需经验值',
total_exp_required INT NOT NULL COMMENT '累计所需经验值', total_exp_required INT NOT NULL COMMENT '累计所需经验值',
title VARCHAR(50) NOT NULL COMMENT '等级称号', title VARCHAR(50) NOT NULL COMMENT '等级称号',
color VARCHAR(20) NULL COMMENT '等级颜色(十六进制)', color VARCHAR(20) NULL COMMENT '等级颜色(十六进制)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_level (level) UNIQUE KEY uk_level (level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='等级配置表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='等级配置表';
-- ===================================================== -- =====================================================
-- 6. 插入等级配置数据 -- 6. 插入等级配置数据
-- ===================================================== -- =====================================================
INSERT INTO level_configs (level, exp_required, total_exp_required, title, color) VALUES INSERT INTO level_configs (level, exp_required, total_exp_required, title, color) VALUES
(1, 0, 0, '初学者', '#909399'), (1, 0, 0, '初学者', '#909399'),
(2, 100, 100, '入门学徒', '#67C23A'), (2, 100, 100, '入门学徒', '#67C23A'),
(3, 200, 300, '勤奋学员', '#67C23A'), (3, 200, 300, '勤奋学员', '#67C23A'),
(4, 400, 700, '进阶学员', '#409EFF'), (4, 400, 700, '进阶学员', '#409EFF'),
(5, 600, 1300, '优秀学员', '#409EFF'), (5, 600, 1300, '优秀学员', '#409EFF'),
(6, 1000, 2300, '精英学员', '#E6A23C'), (6, 1000, 2300, '精英学员', '#E6A23C'),
(7, 1500, 3800, '资深学员', '#E6A23C'), (7, 1500, 3800, '资深学员', '#E6A23C'),
(8, 2000, 5800, '学习达人', '#F56C6C'), (8, 2000, 5800, '学习达人', '#F56C6C'),
(9, 3000, 8800, '学霸', '#F56C6C'), (9, 3000, 8800, '学霸', '#F56C6C'),
(10, 5000, 13800, '大师', '#9B59B6'); (10, 5000, 13800, '大师', '#9B59B6');
-- ===================================================== -- =====================================================
-- 7. 插入奖章定义数据 -- 7. 插入奖章定义数据
-- ===================================================== -- =====================================================
-- 7.1 学习进度类奖章 -- 7.1 学习进度类奖章
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
('first_login', '初来乍到', '首次登录系统', 'Star', 'learning', 'count', 'login_count', 1, 10, 101), ('first_login', '初来乍到', '首次登录系统', 'Star', 'learning', 'count', 'login_count', 1, 10, 101),
('course_1', '求知若渴', '完成1门课程学习', 'Reading', 'learning', 'count', 'course_completed', 1, 30, 102), ('course_1', '求知若渴', '完成1门课程学习', 'Reading', 'learning', 'count', 'course_completed', 1, 30, 102),
('course_5', '博学多才', '完成5门课程学习', 'Collection', 'learning', 'count', 'course_completed', 5, 100, 103), ('course_5', '博学多才', '完成5门课程学习', 'Collection', 'learning', 'count', 'course_completed', 5, 100, 103),
('course_10', '学识渊博', '完成10门课程学习', 'Files', 'learning', 'count', 'course_completed', 10, 200, 104); ('course_10', '学识渊博', '完成10门课程学习', 'Files', 'learning', 'count', 'course_completed', 10, 200, 104);
-- 7.2 考试成绩类奖章 -- 7.2 考试成绩类奖章
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
('exam_pass_1', '初试牛刀', '通过1次考试', 'Select', 'exam', 'count', 'exam_passed', 1, 20, 201), ('exam_pass_1', '初试牛刀', '通过1次考试', 'Select', 'exam', 'count', 'exam_passed', 1, 20, 201),
('exam_pass_10', '身经百战', '通过10次考试', 'Finished', 'exam', 'count', 'exam_passed', 10, 100, 202), ('exam_pass_10', '身经百战', '通过10次考试', 'Finished', 'exam', 'count', 'exam_passed', 10, 100, 202),
('exam_pass_50', '考试达人', '通过50次考试', 'Trophy', 'exam', 'count', 'exam_passed', 50, 300, 203), ('exam_pass_50', '考试达人', '通过50次考试', 'Trophy', 'exam', 'count', 'exam_passed', 50, 300, 203),
('exam_perfect', '完美答卷', '考试获得满分', 'Medal', 'exam', 'score', 'exam_perfect_count', 1, 150, 204), ('exam_perfect', '完美答卷', '考试获得满分', 'Medal', 'exam', 'score', 'exam_perfect_count', 1, 150, 204),
('exam_excellent_10', '学霸之路', '10次考试90分以上', 'TrendCharts', 'exam', 'count', 'exam_excellent', 10, 200, 205); ('exam_excellent_10', '学霸之路', '10次考试90分以上', 'TrendCharts', 'exam', 'count', 'exam_excellent', 10, 200, 205);
-- 7.3 练习时长类奖章 -- 7.3 练习时长类奖章
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
('practice_1h', '初窥门径', '累计练习1小时', 'Clock', 'practice', 'duration', 'practice_hours', 1, 30, 301), ('practice_1h', '初窥门径', '累计练习1小时', 'Clock', 'practice', 'duration', 'practice_hours', 1, 30, 301),
('practice_10h', '勤学苦练', '累计练习10小时', 'Timer', 'practice', 'duration', 'practice_hours', 10, 100, 302), ('practice_10h', '勤学苦练', '累计练习10小时', 'Timer', 'practice', 'duration', 'practice_hours', 10, 100, 302),
('practice_50h', '炉火纯青', '累计练习50小时', 'Stopwatch', 'practice', 'duration', 'practice_hours', 50, 300, 303), ('practice_50h', '炉火纯青', '累计练习50小时', 'Stopwatch', 'practice', 'duration', 'practice_hours', 50, 300, 303),
('practice_100', '练习狂人', '完成100次练习', 'Operation', 'practice', 'count', 'practice_count', 100, 200, 304); ('practice_100', '练习狂人', '完成100次练习', 'Operation', 'practice', 'count', 'practice_count', 100, 200, 304);
-- 7.4 连续打卡类奖章 -- 7.4 连续打卡类奖章
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
('streak_3', '小试身手', '连续登录3天', 'Calendar', 'streak', 'streak', 'login_streak', 3, 20, 401), ('streak_3', '小试身手', '连续登录3天', 'Calendar', 'streak', 'streak', 'login_streak', 3, 20, 401),
('streak_7', '坚持一周', '连续登录7天', 'Calendar', 'streak', 'streak', 'login_streak', 7, 50, 402), ('streak_7', '坚持一周', '连续登录7天', 'Calendar', 'streak', 'streak', 'login_streak', 7, 50, 402),
('streak_30', '持之以恒', '连续登录30天', 'Calendar', 'streak', 'streak', 'login_streak', 30, 200, 403), ('streak_30', '持之以恒', '连续登录30天', 'Calendar', 'streak', 'streak', 'login_streak', 30, 200, 403),
('streak_100', '百日不懈', '连续登录100天', 'Calendar', 'streak', 'streak', 'login_streak', 100, 500, 404); ('streak_100', '百日不懈', '连续登录100天', 'Calendar', 'streak', 'streak', 'login_streak', 100, 500, 404);
-- 7.5 特殊成就类奖章 -- 7.5 特殊成就类奖章
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
('level_5', '初露锋芒', '达到5级', 'Rank', 'special', 'level', 'user_level', 5, 100, 501), ('level_5', '初露锋芒', '达到5级', 'Rank', 'special', 'level', 'user_level', 5, 100, 501),
('level_10', '登峰造极', '达到满级', 'Crown', 'special', 'level', 'user_level', 10, 500, 502), ('level_10', '登峰造极', '达到满级', 'Crown', 'special', 'level', 'user_level', 10, 500, 502),
('training_master', '陪练大师', '完成50次陪练', 'Headset', 'special', 'count', 'training_count', 50, 300, 503), ('training_master', '陪练大师', '完成50次陪练', 'Headset', 'special', 'count', 'training_count', 50, 300, 503),
('first_perfect_practice', '首次完美', '陪练首次获得90分以上', 'StarFilled', 'special', 'score', 'first_practice_90', 1, 100, 504); ('first_perfect_practice', '首次完美', '陪练首次获得90分以上', 'StarFilled', 'special', 'score', 'first_practice_90', 1, 100, 504);
-- ===================================================== -- =====================================================
-- 8. 为现有用户初始化等级数据 -- 8. 为现有用户初始化等级数据
-- ===================================================== -- =====================================================
INSERT INTO user_levels (user_id, level, exp, total_exp, login_streak, last_login_date) INSERT INTO user_levels (user_id, level, exp, total_exp, login_streak, last_login_date)
SELECT SELECT
id as user_id, id as user_id,
1 as level, 1 as level,
0 as exp, 0 as exp,
0 as total_exp, 0 as total_exp,
0 as login_streak, 0 as login_streak,
NULL as last_login_date NULL as last_login_date
FROM users FROM users
WHERE is_deleted = 0 WHERE is_deleted = 0
ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP;
-- 提交事务 -- 提交事务
COMMIT; COMMIT;
-- ===================================================== -- =====================================================
-- 回滚脚本(如需回滚,执行以下语句) -- 回滚脚本(如需回滚,执行以下语句)
-- ===================================================== -- =====================================================
-- DROP TABLE IF EXISTS user_badges; -- DROP TABLE IF EXISTS user_badges;
-- DROP TABLE IF EXISTS badge_definitions; -- DROP TABLE IF EXISTS badge_definitions;
-- DROP TABLE IF EXISTS exp_history; -- DROP TABLE IF EXISTS exp_history;
-- DROP TABLE IF EXISTS level_configs; -- DROP TABLE IF EXISTS level_configs;
-- DROP TABLE IF EXISTS user_levels; -- DROP TABLE IF EXISTS user_levels;

View File

@@ -1,186 +1,186 @@
-- ============================================================================ -- ============================================================================
-- 双人对练功能数据库迁移脚本 -- 双人对练功能数据库迁移脚本
-- 版本: 2026-01-28 -- 版本: 2026-01-28
-- 功能: 新增对练房间表,扩展现有表支持多人对练 -- 功能: 新增对练房间表,扩展现有表支持多人对练
-- ============================================================================ -- ============================================================================
-- ============================================================================ -- ============================================================================
-- 1. 创建对练房间表 practice_rooms -- 1. 创建对练房间表 practice_rooms
-- ============================================================================ -- ============================================================================
CREATE TABLE IF NOT EXISTS `practice_rooms` ( CREATE TABLE IF NOT EXISTS `practice_rooms` (
`id` INT AUTO_INCREMENT PRIMARY KEY COMMENT '房间ID', `id` INT AUTO_INCREMENT PRIMARY KEY COMMENT '房间ID',
`room_code` VARCHAR(10) NOT NULL UNIQUE COMMENT '6位邀请码', `room_code` VARCHAR(10) NOT NULL UNIQUE COMMENT '6位邀请码',
`room_name` VARCHAR(200) COMMENT '房间名称', `room_name` VARCHAR(200) COMMENT '房间名称',
-- 场景信息 -- 场景信息
`scene_id` INT COMMENT '关联场景ID', `scene_id` INT COMMENT '关联场景ID',
`scene_name` VARCHAR(200) COMMENT '场景名称', `scene_name` VARCHAR(200) COMMENT '场景名称',
`scene_type` VARCHAR(50) COMMENT '场景类型', `scene_type` VARCHAR(50) COMMENT '场景类型',
`scene_background` TEXT COMMENT '场景背景', `scene_background` TEXT COMMENT '场景背景',
-- 角色设置 -- 角色设置
`role_a_name` VARCHAR(50) DEFAULT '角色A' COMMENT '角色A名称如销售顾问', `role_a_name` VARCHAR(50) DEFAULT '角色A' COMMENT '角色A名称如销售顾问',
`role_b_name` VARCHAR(50) DEFAULT '角色B' COMMENT '角色B名称如顾客', `role_b_name` VARCHAR(50) DEFAULT '角色B' COMMENT '角色B名称如顾客',
`role_a_description` TEXT COMMENT '角色A描述', `role_a_description` TEXT COMMENT '角色A描述',
`role_b_description` TEXT COMMENT '角色B描述', `role_b_description` TEXT COMMENT '角色B描述',
-- 参与者信息 -- 参与者信息
`host_user_id` INT NOT NULL COMMENT '房主用户ID', `host_user_id` INT NOT NULL COMMENT '房主用户ID',
`guest_user_id` INT COMMENT '加入者用户ID', `guest_user_id` INT COMMENT '加入者用户ID',
`host_role` VARCHAR(10) DEFAULT 'A' COMMENT '房主选择的角色(A/B)', `host_role` VARCHAR(10) DEFAULT 'A' COMMENT '房主选择的角色(A/B)',
`max_participants` INT DEFAULT 2 COMMENT '最大参与人数', `max_participants` INT DEFAULT 2 COMMENT '最大参与人数',
-- 状态和时间 -- 状态和时间
`status` VARCHAR(20) DEFAULT 'waiting' COMMENT '状态: waiting/ready/practicing/completed/canceled', `status` VARCHAR(20) DEFAULT 'waiting' COMMENT '状态: waiting/ready/practicing/completed/canceled',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`started_at` DATETIME COMMENT '开始时间', `started_at` DATETIME COMMENT '开始时间',
`ended_at` DATETIME COMMENT '结束时间', `ended_at` DATETIME COMMENT '结束时间',
`duration_seconds` INT DEFAULT 0 COMMENT '对练时长(秒)', `duration_seconds` INT DEFAULT 0 COMMENT '对练时长(秒)',
-- 对话统计 -- 对话统计
`total_turns` INT DEFAULT 0 COMMENT '总对话轮次', `total_turns` INT DEFAULT 0 COMMENT '总对话轮次',
`role_a_turns` INT DEFAULT 0 COMMENT '角色A发言次数', `role_a_turns` INT DEFAULT 0 COMMENT '角色A发言次数',
`role_b_turns` INT DEFAULT 0 COMMENT '角色B发言次数', `role_b_turns` INT DEFAULT 0 COMMENT '角色B发言次数',
-- 软删除 -- 软删除
`is_deleted` TINYINT(1) DEFAULT 0 COMMENT '是否删除', `is_deleted` TINYINT(1) DEFAULT 0 COMMENT '是否删除',
`deleted_at` DATETIME COMMENT '删除时间', `deleted_at` DATETIME COMMENT '删除时间',
-- 索引 -- 索引
INDEX `idx_room_code` (`room_code`), INDEX `idx_room_code` (`room_code`),
INDEX `idx_host_user` (`host_user_id`), INDEX `idx_host_user` (`host_user_id`),
INDEX `idx_guest_user` (`guest_user_id`), INDEX `idx_guest_user` (`guest_user_id`),
INDEX `idx_status` (`status`), INDEX `idx_status` (`status`),
INDEX `idx_created_at` (`created_at`), INDEX `idx_created_at` (`created_at`),
-- 外键(可选,根据实际需求决定是否启用) -- 外键(可选,根据实际需求决定是否启用)
-- FOREIGN KEY (`scene_id`) REFERENCES `practice_scenes`(`id`) ON DELETE SET NULL, -- FOREIGN KEY (`scene_id`) REFERENCES `practice_scenes`(`id`) ON DELETE SET NULL,
-- FOREIGN KEY (`host_user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, -- FOREIGN KEY (`host_user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
-- FOREIGN KEY (`guest_user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL -- FOREIGN KEY (`guest_user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL
CONSTRAINT `chk_host_role` CHECK (`host_role` IN ('A', 'B')) CONSTRAINT `chk_host_role` CHECK (`host_role` IN ('A', 'B'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='双人对练房间表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='双人对练房间表';
-- ============================================================================ -- ============================================================================
-- 2. 扩展对话记录表 practice_dialogues -- 2. 扩展对话记录表 practice_dialogues
-- ============================================================================ -- ============================================================================
-- 添加用户ID字段区分说话人 -- 添加用户ID字段区分说话人
ALTER TABLE `practice_dialogues` ALTER TABLE `practice_dialogues`
ADD COLUMN IF NOT EXISTS `user_id` INT COMMENT '说话人用户ID' AFTER `speaker`; ADD COLUMN IF NOT EXISTS `user_id` INT COMMENT '说话人用户ID' AFTER `speaker`;
-- 添加角色名称字段 -- 添加角色名称字段
ALTER TABLE `practice_dialogues` ALTER TABLE `practice_dialogues`
ADD COLUMN IF NOT EXISTS `role_name` VARCHAR(50) COMMENT '角色名称(如销售顾问/顾客)' AFTER `user_id`; ADD COLUMN IF NOT EXISTS `role_name` VARCHAR(50) COMMENT '角色名称(如销售顾问/顾客)' AFTER `user_id`;
-- 添加房间ID字段 -- 添加房间ID字段
ALTER TABLE `practice_dialogues` ALTER TABLE `practice_dialogues`
ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID双人对练时使用' AFTER `session_id`; ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID双人对练时使用' AFTER `session_id`;
-- 添加消息类型字段 -- 添加消息类型字段
ALTER TABLE `practice_dialogues` ALTER TABLE `practice_dialogues`
ADD COLUMN IF NOT EXISTS `message_type` VARCHAR(20) DEFAULT 'text' COMMENT '消息类型: text/voice/system' AFTER `content`; ADD COLUMN IF NOT EXISTS `message_type` VARCHAR(20) DEFAULT 'text' COMMENT '消息类型: text/voice/system' AFTER `content`;
-- 添加索引 -- 添加索引
ALTER TABLE `practice_dialogues` ALTER TABLE `practice_dialogues`
ADD INDEX IF NOT EXISTS `idx_user_id` (`user_id`), ADD INDEX IF NOT EXISTS `idx_user_id` (`user_id`),
ADD INDEX IF NOT EXISTS `idx_room_id` (`room_id`); ADD INDEX IF NOT EXISTS `idx_room_id` (`room_id`);
-- ============================================================================ -- ============================================================================
-- 3. 扩展会话表 practice_sessions -- 3. 扩展会话表 practice_sessions
-- ============================================================================ -- ============================================================================
-- 添加房间ID字段 -- 添加房间ID字段
ALTER TABLE `practice_sessions` ALTER TABLE `practice_sessions`
ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID双人对练时使用' AFTER `scene_type`; ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID双人对练时使用' AFTER `scene_type`;
-- 添加参与者角色字段 -- 添加参与者角色字段
ALTER TABLE `practice_sessions` ALTER TABLE `practice_sessions`
ADD COLUMN IF NOT EXISTS `participant_role` VARCHAR(50) COMMENT '用户在房间中的角色' AFTER `room_id`; ADD COLUMN IF NOT EXISTS `participant_role` VARCHAR(50) COMMENT '用户在房间中的角色' AFTER `room_id`;
-- 添加会话类型字段 -- 添加会话类型字段
ALTER TABLE `practice_sessions` ALTER TABLE `practice_sessions`
ADD COLUMN IF NOT EXISTS `session_type` VARCHAR(20) DEFAULT 'solo' COMMENT '会话类型: solo/duo' AFTER `participant_role`; ADD COLUMN IF NOT EXISTS `session_type` VARCHAR(20) DEFAULT 'solo' COMMENT '会话类型: solo/duo' AFTER `participant_role`;
-- 添加索引 -- 添加索引
ALTER TABLE `practice_sessions` ALTER TABLE `practice_sessions`
ADD INDEX IF NOT EXISTS `idx_room_id` (`room_id`); ADD INDEX IF NOT EXISTS `idx_room_id` (`room_id`);
-- ============================================================================ -- ============================================================================
-- 4. 扩展报告表 practice_reports支持双人报告 -- 4. 扩展报告表 practice_reports支持双人报告
-- ============================================================================ -- ============================================================================
-- 添加房间ID字段 -- 添加房间ID字段
ALTER TABLE `practice_reports` ALTER TABLE `practice_reports`
ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID双人对练时使用' AFTER `session_id`; ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID双人对练时使用' AFTER `session_id`;
-- 添加用户ID字段双人模式下每人一份报告 -- 添加用户ID字段双人模式下每人一份报告
ALTER TABLE `practice_reports` ALTER TABLE `practice_reports`
ADD COLUMN IF NOT EXISTS `user_id` INT COMMENT '用户ID' AFTER `room_id`; ADD COLUMN IF NOT EXISTS `user_id` INT COMMENT '用户ID' AFTER `room_id`;
-- 添加报告类型字段 -- 添加报告类型字段
ALTER TABLE `practice_reports` ALTER TABLE `practice_reports`
ADD COLUMN IF NOT EXISTS `report_type` VARCHAR(20) DEFAULT 'solo' COMMENT '报告类型: solo/duo' AFTER `user_id`; ADD COLUMN IF NOT EXISTS `report_type` VARCHAR(20) DEFAULT 'solo' COMMENT '报告类型: solo/duo' AFTER `user_id`;
-- 添加对方评价字段(双人模式) -- 添加对方评价字段(双人模式)
ALTER TABLE `practice_reports` ALTER TABLE `practice_reports`
ADD COLUMN IF NOT EXISTS `partner_feedback` JSON COMMENT '对方表现评价' AFTER `suggestions`; ADD COLUMN IF NOT EXISTS `partner_feedback` JSON COMMENT '对方表现评价' AFTER `suggestions`;
-- 添加互动质量评分 -- 添加互动质量评分
ALTER TABLE `practice_reports` ALTER TABLE `practice_reports`
ADD COLUMN IF NOT EXISTS `interaction_score` INT COMMENT '互动质量评分0-100' AFTER `partner_feedback`; ADD COLUMN IF NOT EXISTS `interaction_score` INT COMMENT '互动质量评分0-100' AFTER `partner_feedback`;
-- 修改唯一索引允许同一session有多个报告 -- 修改唯一索引允许同一session有多个报告
-- 注意:需要先删除旧的唯一索引 -- 注意:需要先删除旧的唯一索引
-- ALTER TABLE `practice_reports` DROP INDEX `session_id`; -- ALTER TABLE `practice_reports` DROP INDEX `session_id`;
-- ALTER TABLE `practice_reports` ADD UNIQUE INDEX `idx_session_user` (`session_id`, `user_id`); -- ALTER TABLE `practice_reports` ADD UNIQUE INDEX `idx_session_user` (`session_id`, `user_id`);
-- ============================================================================ -- ============================================================================
-- 5. 创建房间消息表(用于实时同步) -- 5. 创建房间消息表(用于实时同步)
-- ============================================================================ -- ============================================================================
CREATE TABLE IF NOT EXISTS `practice_room_messages` ( CREATE TABLE IF NOT EXISTS `practice_room_messages` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID', `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID',
`room_id` INT NOT NULL COMMENT '房间ID', `room_id` INT NOT NULL COMMENT '房间ID',
`user_id` INT COMMENT '发送者用户ID系统消息为NULL', `user_id` INT COMMENT '发送者用户ID系统消息为NULL',
`message_type` VARCHAR(20) NOT NULL COMMENT '消息类型: chat/system/join/leave/start/end', `message_type` VARCHAR(20) NOT NULL COMMENT '消息类型: chat/system/join/leave/start/end',
`content` TEXT COMMENT '消息内容', `content` TEXT COMMENT '消息内容',
`role_name` VARCHAR(50) COMMENT '角色名称', `role_name` VARCHAR(50) COMMENT '角色名称',
`sequence` INT NOT NULL COMMENT '消息序号', `sequence` INT NOT NULL COMMENT '消息序号',
`created_at` DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间(毫秒精度)', `created_at` DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间(毫秒精度)',
INDEX `idx_room_id` (`room_id`), INDEX `idx_room_id` (`room_id`),
INDEX `idx_room_sequence` (`room_id`, `sequence`), INDEX `idx_room_sequence` (`room_id`, `sequence`),
INDEX `idx_created_at` (`created_at`) INDEX `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间实时消息表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间实时消息表';
-- ============================================================================ -- ============================================================================
-- 回滚脚本(如需回滚,执行以下语句) -- 回滚脚本(如需回滚,执行以下语句)
-- ============================================================================ -- ============================================================================
/* /*
-- 删除新增的表 -- 删除新增的表
DROP TABLE IF EXISTS `practice_room_messages`; DROP TABLE IF EXISTS `practice_room_messages`;
DROP TABLE IF EXISTS `practice_rooms`; DROP TABLE IF EXISTS `practice_rooms`;
-- 删除 practice_dialogues 新增的列 -- 删除 practice_dialogues 新增的列
ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `user_id`; ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `user_id`;
ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `role_name`; ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `role_name`;
ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `room_id`; ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `room_id`;
ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `message_type`; ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `message_type`;
-- 删除 practice_sessions 新增的列 -- 删除 practice_sessions 新增的列
ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `room_id`; ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `room_id`;
ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `participant_role`; ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `participant_role`;
ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `session_type`; ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `session_type`;
-- 删除 practice_reports 新增的列 -- 删除 practice_reports 新增的列
ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `room_id`; ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `room_id`;
ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `user_id`; ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `user_id`;
ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `report_type`; ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `report_type`;
ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `partner_feedback`; ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `partner_feedback`;
ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `interaction_score`; ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `interaction_score`;
*/ */

View File

@@ -53,4 +53,9 @@ jsonschema>=4.0.0
# PDF 文档提取 # PDF 文档提取
PyPDF2>=3.0.0 PyPDF2>=3.0.0
python-docx>=1.0.0 python-docx>=1.0.0
# 证书生成
Pillow>=10.0.0
qrcode>=7.4.0
weasyprint>=60.0

View File

@@ -1,217 +1,217 @@
# 考培练系统 - 环境配置与部署指南 # 考培练系统 - 环境配置与部署指南
> 最后更新2026-01-28 > 最后更新2026-01-28
## 一、环境总览 ## 一、环境总览
| 环境 | 分支 | 域名 | dist 目录 | 用途 | | 环境 | 分支 | 域名 | dist 目录 | 用途 |
|------|------|------|-----------|------| |------|------|------|-----------|------|
| **测试环境** | `test` | https://kpl.ireborn.com.cn | dist-test | 功能开发测试 | | **测试环境** | `test` | https://kpl.ireborn.com.cn | dist-test | 功能开发测试 |
| **预生产** | `staging` | https://aiedu.ireborn.com.cn | dist-staging | 集成测试/预发布 | | **预生产** | `staging` | https://aiedu.ireborn.com.cn | dist-staging | 集成测试/预发布 |
| **生产环境** | `main` | 各租户域名 | dist-prod | 正式生产 | | **生产环境** | `main` | 各租户域名 | dist-prod | 正式生产 |
| **超级管理后台** | - | https://admin.kpl.ireborn.com.cn | - | SaaS管理 | | **超级管理后台** | - | https://admin.kpl.ireborn.com.cn | - | SaaS管理 |
--- ---
## 二、租户列表 ## 二、租户列表
| 租户代码 | 名称 | 域名 | 前端端口 | 后端端口 | | 租户代码 | 名称 | 域名 | 前端端口 | 后端端口 |
|----------|------|------|----------|----------| |----------|------|------|----------|----------|
| hua | 华尔倍丽 | https://hua.ireborn.com.cn | 3010 | 8010 | | hua | 华尔倍丽 | https://hua.ireborn.com.cn | 3010 | 8010 |
| yy | 杨扬宠物 | https://yy.ireborn.com.cn | 3011 | 8011 | | yy | 杨扬宠物 | https://yy.ireborn.com.cn | 3011 | 8011 |
| hl | 武汉禾丽 | https://hl.ireborn.com.cn | 3012 | 8012 | | hl | 武汉禾丽 | https://hl.ireborn.com.cn | 3012 | 8012 |
| xy | 芯颜定制 | https://xy.ireborn.com.cn | 3013 | 8013 | | xy | 芯颜定制 | https://xy.ireborn.com.cn | 3013 | 8013 |
| fw | 飞沃 | https://fw.ireborn.com.cn | 3014 | 8014 | | fw | 飞沃 | https://fw.ireborn.com.cn | 3014 | 8014 |
| ex | 恩喜成都总院 | https://ex.ireborn.com.cn | 3015 | 8015 | | ex | 恩喜成都总院 | https://ex.ireborn.com.cn | 3015 | 8015 |
| cxw | 崔曦文 | https://cxw.kpl.ireborn.com.cn | 3016 | 8016 | | cxw | 崔曦文 | https://cxw.kpl.ireborn.com.cn | 3016 | 8016 |
--- ---
## 三、CI/CD 部署方式 ## 三、CI/CD 部署方式
### 3.1 开发1环境 (kpl-dev) ### 3.1 开发1环境 (kpl-dev)
```bash ```bash
# 推送到 test 分支自动部署 # 推送到 test 分支自动部署
git push cicd test git push cicd test
``` ```
- **触发条件**`test` 分支 push - **触发条件**`test` 分支 push
- **部署目标**kpl-dev 容器组 - **部署目标**kpl-dev 容器组
- **访问地址**https://kpl.ireborn.com.cn - **访问地址**https://kpl.ireborn.com.cn
--- ---
### 3.2 开发2环境 (主站) ### 3.2 开发2环境 (主站)
```bash ```bash
# 推送到 dev2 分支自动部署 # 推送到 dev2 分支自动部署
git push cicd dev2 git push cicd dev2
``` ```
- **触发条件**`dev2` 分支 push - **触发条件**`dev2` 分支 push
- **部署目标**kaopeilian 主站容器 - **部署目标**kaopeilian 主站容器
- **访问地址**https://aiedu.ireborn.com.cn - **访问地址**https://aiedu.ireborn.com.cn
--- ---
### 3.3 生产环境 (租户) ### 3.3 生产环境 (租户)
```bash ```bash
# 推送到 main 分支,通过 commit message 控制部署范围 # 推送到 main 分支,通过 commit message 控制部署范围
git push cicd main git push cicd main
``` ```
#### 部署所有租户 #### 部署所有租户
```bash ```bash
git commit -m "feat: 新功能上线 [all]" git commit -m "feat: 新功能上线 [all]"
git push cicd main git push cicd main
``` ```
#### 部署单个租户 #### 部署单个租户
```bash ```bash
git commit -m "fix: 修复问题 [hua]" git commit -m "fix: 修复问题 [hua]"
git push cicd main git push cicd main
``` ```
#### 部署多个租户 #### 部署多个租户
```bash ```bash
git commit -m "feat: 功能更新 [cxw,yy,hl]" git commit -m "feat: 功能更新 [cxw,yy,hl]"
git push cicd main git push cicd main
``` ```
#### 默认行为 #### 默认行为
```bash ```bash
# 不带标签默认部署所有租户 # 不带标签默认部署所有租户
git commit -m "feat: 常规更新" git commit -m "feat: 常规更新"
git push cicd main git push cicd main
``` ```
--- ---
## 四、手动部署命令 ## 四、手动部署命令
### 4.1 SSH 登录服务器 ### 4.1 SSH 登录服务器
```bash ```bash
ssh root@120.79.247.16 ssh root@120.79.247.16
# 密码: Rxm88808 # 密码: Rxm88808
``` ```
### 4.2 重启单个租户后端 ### 4.2 重启单个租户后端
```bash ```bash
cd /root/aiedu cd /root/aiedu
docker restart cxw-backend docker restart cxw-backend
``` ```
### 4.3 重建单个租户后端 ### 4.3 重建单个租户后端
```bash ```bash
cd /root/aiedu cd /root/aiedu
docker-compose -f docker-compose.prod-multi.yml up -d --build --no-deps cxw-backend docker-compose -f docker-compose.prod-multi.yml up -d --build --no-deps cxw-backend
``` ```
### 4.4 查看日志 ### 4.4 查看日志
```bash ```bash
docker logs -f cxw-backend --tail 100 docker logs -f cxw-backend --tail 100
``` ```
### 4.5 重新编译前端(所有租户共享) ### 4.5 重新编译前端(所有租户共享)
```bash ```bash
cd /root/aiedu/kaopeilian-frontend cd /root/aiedu/kaopeilian-frontend
npm run build npm run build
``` ```
--- ---
## 五、数据库连接 ## 五、数据库连接
### 5.1 生产共享 MySQL (prod-mysql) ### 5.1 生产共享 MySQL (prod-mysql)
- **端口**3309 - **端口**3309
- **用户**root - **用户**root
- **密码**ProdMySQL2025!@# - **密码**ProdMySQL2025!@#
- **数据库**kaopeilian_hua, kaopeilian_yy, kaopeilian_hl, kaopeilian_xy, kaopeilian_fw, kaopeilian_ex, kaopeilian_cxw - **数据库**kaopeilian_hua, kaopeilian_yy, kaopeilian_hl, kaopeilian_xy, kaopeilian_fw, kaopeilian_ex, kaopeilian_cxw
### 5.2 开发测试 MySQL (kpl-mysql-dev) ### 5.2 开发测试 MySQL (kpl-mysql-dev)
- **端口**3308 - **端口**3308
- **用户**root - **用户**root
- **密码**nj861021 - **密码**nj861021
- **数据库**kaopeilian - **数据库**kaopeilian
### 5.3 主站 MySQL (kaopeilian-mysql) ### 5.3 主站 MySQL (kaopeilian-mysql)
- **端口**3307 - **端口**3307
- **用户**root - **用户**root
- **密码**nj861021 - **密码**nj861021
- **数据库**kaopeilian - **数据库**kaopeilian
--- ---
## 六、容器管理 ## 六、容器管理
### 当前运行容器统计 ### 当前运行容器统计
| 类型 | 数量 | | 类型 | 数量 |
|------|------| |------|------|
| 前端容器 | 11 | | 前端容器 | 11 |
| 后端容器 | 11 | | 后端容器 | 11 |
| Redis | 10 | | Redis | 10 |
| MySQL | 4 | | MySQL | 4 |
| Nginx | 1 | | Nginx | 1 |
| **总计** | **37** | | **总计** | **37** |
### 查看所有容器 ### 查看所有容器
```bash ```bash
docker ps --format 'table {{.Names}}\t{{.Status}}' docker ps --format 'table {{.Names}}\t{{.Status}}'
``` ```
--- ---
## 七、测试账户 ## 七、测试账户
| 角色 | 用户名 | 密码 | | 角色 | 用户名 | 密码 |
|------|--------|------| |------|--------|------|
| 系统管理员 | admin | Admin123! | | 系统管理员 | admin | Admin123! |
| 培训经理 | manager | Admin123! | | 培训经理 | manager | Admin123! |
| 测试学员 | testuser | Admin123! | | 测试学员 | testuser | Admin123! |
--- ---
## 八、注意事项 ## 八、注意事项
1. **前端共享**:所有生产租户共享同一套前端代码,编译一次全部更新 1. **前端共享**:所有生产租户共享同一套前端代码,编译一次全部更新
2. **后端独立**:每个租户有独立的后端容器和数据库 2. **后端独立**:每个租户有独立的后端容器和数据库
3. **域名解析** 3. **域名解析**
- `*.ireborn.com.cn` 解析到 120.79.242.43SCRM服务器 - `*.ireborn.com.cn` 解析到 120.79.242.43SCRM服务器
- `*.kpl.ireborn.com.cn` 解析到 120.79.247.16(考培练服务器) - `*.kpl.ireborn.com.cn` 解析到 120.79.247.16(考培练服务器)
4. **SSL证书**:使用 Let's Encrypt自动续期 4. **SSL证书**:使用 Let's Encrypt自动续期
--- ---
## 九、Git 仓库配置 ## 九、Git 仓库配置
```bash ```bash
# 查看远程仓库 # 查看远程仓库
git remote -v git remote -v
# origin: GitHub 源代码仓库 # origin: GitHub 源代码仓库
# cicd: Gitea CI/CD 触发仓库 # cicd: Gitea CI/CD 触发仓库
# 常规开发 # 常规开发
git push origin main git push origin main
# 触发部署 # 触发部署
git push cicd test # 部署开发1 git push cicd test # 部署开发1
git push cicd dev2 # 部署开发2 git push cicd dev2 # 部署开发2
git push cicd main # 部署生产 git push cicd main # 部署生产
``` ```

View File

@@ -1,149 +1,149 @@
/** /**
* 证书系统 API * 证书系统 API
*/ */
import request from '@/api/request' import request from '@/api/request'
// 证书类型 // 证书类型
export type CertificateType = 'course' | 'exam' | 'achievement' export type CertificateType = 'course' | 'exam' | 'achievement'
// 证书模板 // 证书模板
export interface CertificateTemplate { export interface CertificateTemplate {
id: number id: number
name: string name: string
type: CertificateType type: CertificateType
background_url?: string background_url?: string
is_active: boolean is_active: boolean
} }
// 证书信息 // 证书信息
export interface Certificate { export interface Certificate {
id: number id: number
certificate_no: string certificate_no: string
title: string title: string
description?: string description?: string
type: CertificateType type: CertificateType
type_name: string type_name: string
issued_at: string issued_at: string
valid_until?: string valid_until?: string
score?: number score?: number
completion_rate?: number completion_rate?: number
pdf_url?: string pdf_url?: string
image_url?: string image_url?: string
course_id?: number course_id?: number
exam_id?: number exam_id?: number
badge_id?: number badge_id?: number
meta_data?: Record<string, any> meta_data?: Record<string, any>
template?: { template?: {
id: number id: number
name: string name: string
background_url?: string background_url?: string
} }
user?: { user?: {
id: number id: number
username: string username: string
full_name?: string full_name?: string
} }
} }
// 证书列表响应 // 证书列表响应
export interface CertificateListResponse { export interface CertificateListResponse {
items: Certificate[] items: Certificate[]
total: number total: number
offset: number offset: number
limit: number limit: number
} }
// 验证结果 // 验证结果
export interface VerifyResult { export interface VerifyResult {
valid: boolean valid: boolean
certificate_no: string certificate_no: string
title?: string title?: string
type_name?: string type_name?: string
issued_at?: string issued_at?: string
user?: { user?: {
id: number id: number
username: string username: string
full_name?: string full_name?: string
} }
} }
/** /**
* 获取证书模板列表 * 获取证书模板列表
*/ */
export function getCertificateTemplates(type?: CertificateType) { export function getCertificateTemplates(type?: CertificateType) {
return request.get<CertificateTemplate[]>('/certificates/templates', { return request.get<CertificateTemplate[]>('/certificates/templates', {
params: { cert_type: type } params: { cert_type: type }
}) })
} }
/** /**
* 获取我的证书列表 * 获取我的证书列表
*/ */
export function getMyCertificates(params?: { export function getMyCertificates(params?: {
cert_type?: CertificateType cert_type?: CertificateType
offset?: number offset?: number
limit?: number limit?: number
}) { }) {
return request.get<CertificateListResponse>('/certificates/me', { params }) return request.get<CertificateListResponse>('/certificates/me', { params })
} }
/** /**
* 获取指定用户的证书列表 * 获取指定用户的证书列表
*/ */
export function getUserCertificates(userId: number, params?: { export function getUserCertificates(userId: number, params?: {
cert_type?: CertificateType cert_type?: CertificateType
offset?: number offset?: number
limit?: number limit?: number
}) { }) {
return request.get<CertificateListResponse>(`/certificates/user/${userId}`, { params }) return request.get<CertificateListResponse>(`/certificates/user/${userId}`, { params })
} }
/** /**
* 获取证书详情 * 获取证书详情
*/ */
export function getCertificateDetail(certId: number) { export function getCertificateDetail(certId: number) {
return request.get<Certificate>(`/certificates/${certId}`) return request.get<Certificate>(`/certificates/${certId}`)
} }
/** /**
* 获取证书分享图片URL * 获取证书分享图片URL
*/ */
export function getCertificateImageUrl(certId: number): string { export function getCertificateImageUrl(certId: number): string {
return `/api/v1/certificates/${certId}/image` return `/api/v1/certificates/${certId}/image`
} }
/** /**
* 获取证书下载URL * 获取证书下载URL
*/ */
export function getCertificateDownloadUrl(certId: number): string { export function getCertificateDownloadUrl(certId: number): string {
return `/api/v1/certificates/${certId}/download` return `/api/v1/certificates/${certId}/download`
} }
/** /**
* 验证证书 * 验证证书
*/ */
export function verifyCertificate(certNo: string) { export function verifyCertificate(certNo: string) {
return request.get<VerifyResult>(`/certificates/verify/${certNo}`) return request.get<VerifyResult>(`/certificates/verify/${certNo}`)
} }
/** /**
* 颁发课程证书 * 颁发课程证书
*/ */
export function issueCoursCertificate(data: { export function issueCoursCertificate(data: {
course_id: number course_id: number
course_name: string course_name: string
completion_rate?: number completion_rate?: number
}) { }) {
return request.post<Certificate>('/certificates/issue/course', data) return request.post<Certificate>('/certificates/issue/course', data)
} }
/** /**
* 颁发考试证书 * 颁发考试证书
*/ */
export function issueExamCertificate(data: { export function issueExamCertificate(data: {
exam_id: number exam_id: number
exam_name: string exam_name: string
score: number score: number
}) { }) {
return request.post<Certificate>('/certificates/issue/exam', data) return request.post<Certificate>('/certificates/issue/exam', data)
} }

View File

@@ -175,4 +175,4 @@ export function getTeamDashboard() {
*/ */
export function getFullDashboardData() { export function getFullDashboardData() {
return request.get<FullDashboardData>('/dashboard/all') return request.get<FullDashboardData>('/dashboard/all')
} }

View File

@@ -1,222 +1,222 @@
/** /**
* 双人对练 API * 双人对练 API
*/ */
import request from '@/api/request' import request from '@/api/request'
// ==================== 类型定义 ==================== // ==================== 类型定义 ====================
export interface CreateRoomRequest { export interface CreateRoomRequest {
scene_id?: number scene_id?: number
scene_name?: string scene_name?: string
scene_type?: string scene_type?: string
scene_background?: string scene_background?: string
role_a_name?: string role_a_name?: string
role_b_name?: string role_b_name?: string
role_a_description?: string role_a_description?: string
role_b_description?: string role_b_description?: string
host_role?: 'A' | 'B' host_role?: 'A' | 'B'
room_name?: string room_name?: string
} }
export interface CreateRoomResponse { export interface CreateRoomResponse {
room_code: string room_code: string
room_id: number room_id: number
room_name: string room_name: string
my_role: string my_role: string
my_role_name: string my_role_name: string
} }
export interface JoinRoomResponse { export interface JoinRoomResponse {
room_code: string room_code: string
room_id: number room_id: number
room_name: string room_name: string
status: string status: string
my_role: string my_role: string
my_role_name: string my_role_name: string
} }
export interface RoomUser { export interface RoomUser {
id: number id: number
username: string username: string
full_name: string full_name: string
avatar_url?: string avatar_url?: string
} }
export interface RoomInfo { export interface RoomInfo {
id: number id: number
room_code: string room_code: string
room_name?: string room_name?: string
scene_id?: number scene_id?: number
scene_name?: string scene_name?: string
scene_type?: string scene_type?: string
scene_background?: string scene_background?: string
role_a_name: string role_a_name: string
role_b_name: string role_b_name: string
role_a_description?: string role_a_description?: string
role_b_description?: string role_b_description?: string
host_role: string host_role: string
status: string status: string
created_at?: string created_at?: string
started_at?: string started_at?: string
ended_at?: string ended_at?: string
duration_seconds: number duration_seconds: number
total_turns: number total_turns: number
role_a_turns: number role_a_turns: number
role_b_turns: number role_b_turns: number
} }
export interface RoomDetailResponse { export interface RoomDetailResponse {
room: RoomInfo room: RoomInfo
host_user?: RoomUser host_user?: RoomUser
guest_user?: RoomUser guest_user?: RoomUser
host_role_name?: string host_role_name?: string
guest_role_name?: string guest_role_name?: string
my_role?: string my_role?: string
my_role_name?: string my_role_name?: string
is_host: boolean is_host: boolean
} }
export type MessageType = export type MessageType =
| 'chat' | 'chat'
| 'system' | 'system'
| 'join' | 'join'
| 'leave' | 'leave'
| 'start' | 'start'
| 'end' | 'end'
| 'voice_start' | 'voice_start'
| 'voice_offer' | 'voice_offer'
| 'voice_answer' | 'voice_answer'
| 'ice_candidate' | 'ice_candidate'
| 'voice_end' | 'voice_end'
export interface RoomMessage { export interface RoomMessage {
id: number id: number
room_id: number room_id: number
user_id?: number user_id?: number
message_type: MessageType message_type: MessageType
content?: string content?: string
role_name?: string role_name?: string
sequence: number sequence: number
created_at: string created_at: string
} }
export interface WebRTCSignalRequest { export interface WebRTCSignalRequest {
signal_type: 'voice_start' | 'voice_offer' | 'voice_answer' | 'ice_candidate' | 'voice_end' signal_type: 'voice_start' | 'voice_offer' | 'voice_answer' | 'ice_candidate' | 'voice_end'
payload: Record<string, any> payload: Record<string, any>
} }
export interface MessagesResponse { export interface MessagesResponse {
messages: RoomMessage[] messages: RoomMessage[]
room_status: string room_status: string
last_sequence: number last_sequence: number
} }
export interface RoomListItem { export interface RoomListItem {
id: number id: number
room_code: string room_code: string
room_name?: string room_name?: string
scene_name?: string scene_name?: string
status: string status: string
is_host: boolean is_host: boolean
created_at?: string created_at?: string
duration_seconds: number duration_seconds: number
total_turns: number total_turns: number
} }
// ==================== API 函数 ==================== // ==================== API 函数 ====================
/** /**
* 创建房间 * 创建房间
*/ */
export function createRoom(data: CreateRoomRequest) { export function createRoom(data: CreateRoomRequest) {
return request.post<CreateRoomResponse>('/api/v1/practice/rooms', data) return request.post<CreateRoomResponse>('/api/v1/practice/rooms', data)
} }
/** /**
* 加入房间 * 加入房间
*/ */
export function joinRoom(roomCode: string) { export function joinRoom(roomCode: string) {
return request.post<JoinRoomResponse>('/api/v1/practice/rooms/join', { return request.post<JoinRoomResponse>('/api/v1/practice/rooms/join', {
room_code: roomCode room_code: roomCode
}) })
} }
/** /**
* 获取房间详情 * 获取房间详情
*/ */
export function getRoomDetail(roomCode: string) { export function getRoomDetail(roomCode: string) {
return request.get<RoomDetailResponse>(`/api/v1/practice/rooms/${roomCode}`) return request.get<RoomDetailResponse>(`/api/v1/practice/rooms/${roomCode}`)
} }
/** /**
* 开始对练 * 开始对练
*/ */
export function startPractice(roomCode: string) { export function startPractice(roomCode: string) {
return request.post(`/api/v1/practice/rooms/${roomCode}/start`) return request.post(`/api/v1/practice/rooms/${roomCode}/start`)
} }
/** /**
* 结束对练 * 结束对练
*/ */
export function endPractice(roomCode: string) { export function endPractice(roomCode: string) {
return request.post(`/api/v1/practice/rooms/${roomCode}/end`) return request.post(`/api/v1/practice/rooms/${roomCode}/end`)
} }
/** /**
* 离开房间 * 离开房间
*/ */
export function leaveRoom(roomCode: string) { export function leaveRoom(roomCode: string) {
return request.post(`/api/v1/practice/rooms/${roomCode}/leave`) return request.post(`/api/v1/practice/rooms/${roomCode}/leave`)
} }
/** /**
* 发送消息 * 发送消息
*/ */
export function sendMessage(roomCode: string, content: string) { export function sendMessage(roomCode: string, content: string) {
return request.post<RoomMessage>(`/api/v1/practice/rooms/${roomCode}/message`, { return request.post<RoomMessage>(`/api/v1/practice/rooms/${roomCode}/message`, {
content content
}) })
} }
/** /**
* 获取消息列表 * 获取消息列表
*/ */
export function getMessages(roomCode: string, sinceSequence: number = 0) { export function getMessages(roomCode: string, sinceSequence: number = 0) {
return request.get<MessagesResponse>(`/api/v1/practice/rooms/${roomCode}/messages`, { return request.get<MessagesResponse>(`/api/v1/practice/rooms/${roomCode}/messages`, {
params: { since_sequence: sinceSequence } params: { since_sequence: sinceSequence }
}) })
} }
/** /**
* 获取我的房间列表 * 获取我的房间列表
*/ */
export function getMyRooms(status?: string, limit: number = 20) { export function getMyRooms(status?: string, limit: number = 20) {
return request.get<{ rooms: RoomListItem[] }>('/api/v1/practice/rooms', { return request.get<{ rooms: RoomListItem[] }>('/api/v1/practice/rooms', {
params: { status, limit } params: { status, limit }
}) })
} }
/** /**
* 生成分享链接 * 生成分享链接
*/ */
export function generateShareLink(roomCode: string): string { export function generateShareLink(roomCode: string): string {
const baseUrl = window.location.origin const baseUrl = window.location.origin
return `${baseUrl}/trainee/duo-practice/join/${roomCode}` return `${baseUrl}/trainee/duo-practice/join/${roomCode}`
} }
/** /**
* 发送 WebRTC 信令 * 发送 WebRTC 信令
*/ */
export function sendSignal(roomCode: string, signalType: string, payload: Record<string, any>) { export function sendSignal(roomCode: string, signalType: string, payload: Record<string, any>) {
return request.post(`/api/v1/practice/rooms/${roomCode}/signal`, { return request.post(`/api/v1/practice/rooms/${roomCode}/signal`, {
signal_type: signalType, signal_type: signalType,
payload payload
}) })
} }
/** /**
* 获取对练报告 * 获取对练报告
*/ */
export function getPracticeReport(roomCode: string) { export function getPracticeReport(roomCode: string) {
return request.get(`/api/v1/practice/rooms/${roomCode}/report`) return request.get(`/api/v1/practice/rooms/${roomCode}/report`)
} }

View File

@@ -1,182 +1,182 @@
/** /**
* 等级与奖章 API * 等级与奖章 API
*/ */
import request from '@/api/request' import request from '@/api/request'
// 类型定义 // 类型定义
export interface LevelInfo { export interface LevelInfo {
user_id: number user_id: number
level: number level: number
exp: number exp: number
total_exp: number total_exp: number
title: string title: string
color: string color: string
login_streak: number login_streak: number
max_login_streak: number max_login_streak: number
last_checkin_at: string | null last_checkin_at: string | null
next_level_exp: number next_level_exp: number
exp_to_next_level: number exp_to_next_level: number
is_max_level: boolean is_max_level: boolean
} }
export interface ExpHistoryItem { export interface ExpHistoryItem {
id: number id: number
exp_change: number exp_change: number
exp_type: string exp_type: string
description: string description: string
level_before: number | null level_before: number | null
level_after: number | null level_after: number | null
created_at: string created_at: string
} }
export interface LeaderboardItem { export interface LeaderboardItem {
rank: number rank: number
user_id: number user_id: number
username: string username: string
full_name: string | null full_name: string | null
avatar_url: string | null avatar_url: string | null
level: number level: number
title: string title: string
color: string color: string
total_exp: number total_exp: number
login_streak: number login_streak: number
} }
export interface Badge { export interface Badge {
id: number id: number
code: string code: string
name: string name: string
description: string description: string
icon: string icon: string
category: string category: string
condition_type?: string condition_type?: string
condition_value?: number condition_value?: number
exp_reward: number exp_reward: number
unlocked?: boolean unlocked?: boolean
unlocked_at?: string | null unlocked_at?: string | null
} }
export interface CheckinResult { export interface CheckinResult {
success: boolean success: boolean
message: string message: string
exp_gained: number exp_gained: number
base_exp?: number base_exp?: number
bonus_exp?: number bonus_exp?: number
login_streak: number login_streak: number
leveled_up?: boolean leveled_up?: boolean
new_level?: number | null new_level?: number | null
already_checked_in?: boolean already_checked_in?: boolean
new_badges?: Badge[] new_badges?: Badge[]
} }
// API 函数 // API 函数
/** /**
* 获取当前用户等级信息 * 获取当前用户等级信息
*/ */
export function getMyLevel() { export function getMyLevel() {
return request.get<LevelInfo>('/level/me') return request.get<LevelInfo>('/level/me')
} }
/** /**
* 获取指定用户等级信息 * 获取指定用户等级信息
*/ */
export function getUserLevel(userId: number) { export function getUserLevel(userId: number) {
return request.get<LevelInfo>(`/level/user/${userId}`) return request.get<LevelInfo>(`/level/user/${userId}`)
} }
/** /**
* 每日签到 * 每日签到
*/ */
export function dailyCheckin() { export function dailyCheckin() {
return request.post<CheckinResult>('/level/checkin') return request.post<CheckinResult>('/level/checkin')
} }
/** /**
* 获取经验值历史 * 获取经验值历史
*/ */
export function getExpHistory(params?: { export function getExpHistory(params?: {
limit?: number limit?: number
offset?: number offset?: number
exp_type?: string exp_type?: string
}) { }) {
return request.get<{ return request.get<{
items: ExpHistoryItem[] items: ExpHistoryItem[]
total: number total: number
limit: number limit: number
offset: number offset: number
}>('/level/exp-history', { params }) }>('/level/exp-history', { params })
} }
/** /**
* 获取等级排行榜 * 获取等级排行榜
*/ */
export function getLeaderboard(params?: { export function getLeaderboard(params?: {
limit?: number limit?: number
offset?: number offset?: number
}) { }) {
return request.get<{ return request.get<{
items: LeaderboardItem[] items: LeaderboardItem[]
total: number total: number
limit: number limit: number
offset: number offset: number
my_rank: number my_rank: number
my_level_info: LevelInfo my_level_info: LevelInfo
}>('/level/leaderboard', { params }) }>('/level/leaderboard', { params })
} }
/** /**
* 获取所有奖章定义 * 获取所有奖章定义
*/ */
export function getAllBadges() { export function getAllBadges() {
return request.get<Badge[]>('/level/badges/all') return request.get<Badge[]>('/level/badges/all')
} }
/** /**
* 获取用户奖章(含解锁状态) * 获取用户奖章(含解锁状态)
*/ */
export function getMyBadges() { export function getMyBadges() {
return request.get<{ return request.get<{
badges: Badge[] badges: Badge[]
total: number total: number
unlocked_count: number unlocked_count: number
}>('/level/badges/me') }>('/level/badges/me')
} }
/** /**
* 获取未通知的新奖章 * 获取未通知的新奖章
*/ */
export function getUnnotifiedBadges() { export function getUnnotifiedBadges() {
return request.get<Badge[]>('/level/badges/unnotified') return request.get<Badge[]>('/level/badges/unnotified')
} }
/** /**
* 标记奖章为已通知 * 标记奖章为已通知
*/ */
export function markBadgesNotified(badgeIds?: number[]) { export function markBadgesNotified(badgeIds?: number[]) {
return request.post('/level/badges/mark-notified', badgeIds) return request.post('/level/badges/mark-notified', badgeIds)
} }
/** /**
* 手动检查并授予奖章 * 手动检查并授予奖章
*/ */
export function checkAndAwardBadges() { export function checkAndAwardBadges() {
return request.post<{ return request.post<{
new_badges: Badge[] new_badges: Badge[]
count: number count: number
}>('/level/check-badges') }>('/level/check-badges')
} }
export default { export default {
getMyLevel, getMyLevel,
getUserLevel, getUserLevel,
dailyCheckin, dailyCheckin,
getExpHistory, getExpHistory,
getLeaderboard, getLeaderboard,
getAllBadges, getAllBadges,
getMyBadges, getMyBadges,
getUnnotifiedBadges, getUnnotifiedBadges,
markBadgesNotified, markBadgesNotified,
checkAndAwardBadges checkAndAwardBadges
} }

View File

@@ -0,0 +1,158 @@
/**
* 用户学习进度 API
*/
import { request } from '@/utils/request'
// ============ 类型定义 ============
export interface MaterialProgress {
material_id: number
material_name: string
is_completed: boolean
progress_percent: number
last_position: number
study_time: number
first_accessed_at: string | null
last_accessed_at: string | null
completed_at: string | null
}
export interface CourseProgress {
course_id: number
course_name: string
status: 'not_started' | 'in_progress' | 'completed'
progress_percent: number
completed_materials: number
total_materials: number
total_study_time: number
first_accessed_at: string | null
last_accessed_at: string | null
completed_at: string | null
materials?: MaterialProgress[]
}
export interface ProgressSummary {
total_courses: number
completed_courses: number
in_progress_courses: number
not_started_courses: number
total_study_time: number
average_progress: number
}
export interface MaterialProgressUpdate {
progress_percent: number
last_position?: number
study_time_delta?: number
is_completed?: boolean
}
// ============ API 方法 ============
/**
* 获取学习进度摘要
*/
export const getProgressSummary = () => {
return request.get<ProgressSummary>('/api/v1/progress/summary')
}
/**
* 获取所有课程学习进度
*/
export const getAllCourseProgress = (status?: string) => {
return request.get<CourseProgress[]>('/api/v1/progress/courses', {
params: status ? { status } : undefined,
})
}
/**
* 获取指定课程的详细学习进度
*/
export const getCourseProgress = (courseId: number) => {
return request.get<CourseProgress>(`/api/v1/progress/courses/${courseId}`)
}
/**
* 更新资料学习进度
*/
export const updateMaterialProgress = (
materialId: number,
data: MaterialProgressUpdate
) => {
return request.post<MaterialProgress>(
`/api/v1/progress/materials/${materialId}`,
data
)
}
/**
* 标记资料为已完成
*/
export const markMaterialComplete = (materialId: number) => {
return request.post<MaterialProgress>(
`/api/v1/progress/materials/${materialId}/complete`
)
}
/**
* 开始学习课程
*/
export const startCourse = (courseId: number) => {
return request.post(`/api/v1/progress/courses/${courseId}/start`)
}
/**
* 格式化学习时长
*/
export const formatStudyTime = (seconds: number): string => {
if (seconds < 60) {
return `${seconds}`
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60)
return `${minutes}分钟`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
}
/**
* 获取进度状态文本
*/
export const getProgressStatusText = (
status: 'not_started' | 'in_progress' | 'completed'
): string => {
const statusMap = {
not_started: '未开始',
in_progress: '学习中',
completed: '已完成',
}
return statusMap[status] || status
}
/**
* 获取进度状态颜色
*/
export const getProgressStatusType = (
status: 'not_started' | 'in_progress' | 'completed'
): 'info' | 'warning' | 'success' => {
const typeMap: Record<string, 'info' | 'warning' | 'success'> = {
not_started: 'info',
in_progress: 'warning',
completed: 'success',
}
return typeMap[status] || 'info'
}
export default {
getProgressSummary,
getAllCourseProgress,
getCourseProgress,
updateMaterialProgress,
markMaterialComplete,
startCourse,
formatStudyTime,
getProgressStatusText,
getProgressStatusType,
}

View File

@@ -1,174 +1,174 @@
<template> <template>
<div <div
class="badge-card" class="badge-card"
:class="{ unlocked, locked: !unlocked }" :class="{ unlocked, locked: !unlocked }"
@click="handleClick" @click="handleClick"
> >
<div class="badge-icon"> <div class="badge-icon">
<el-icon :size="iconSize"> <el-icon :size="iconSize">
<component :is="iconComponent" /> <component :is="iconComponent" />
</el-icon> </el-icon>
</div> </div>
<div class="badge-info"> <div class="badge-info">
<div class="badge-name">{{ name }}</div> <div class="badge-name">{{ name }}</div>
<div class="badge-desc">{{ description }}</div> <div class="badge-desc">{{ description }}</div>
<div class="badge-reward" v-if="expReward > 0 && !unlocked"> <div class="badge-reward" v-if="expReward > 0 && !unlocked">
+{{ expReward }} 经验 +{{ expReward }} 经验
</div> </div>
<div class="badge-unlock-time" v-if="unlocked && unlockedAt"> <div class="badge-unlock-time" v-if="unlocked && unlockedAt">
{{ formatDate(unlockedAt) }}解锁 {{ formatDate(unlockedAt) }}解锁
</div> </div>
</div> </div>
<div class="badge-status" v-if="!unlocked"> <div class="badge-status" v-if="!unlocked">
<el-icon><Lock /></el-icon> <el-icon><Lock /></el-icon>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { import {
Lock, Medal, Star, Reading, Collection, Files, Lock, Medal, Star, Reading, Collection, Files,
Select, Finished, Trophy, TrendCharts, Clock, Select, Finished, Trophy, TrendCharts, Clock,
Timer, Stopwatch, Operation, Calendar, Rank, Timer, Stopwatch, Operation, Calendar, Rank,
Headset, StarFilled Headset, StarFilled
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
interface Props { interface Props {
code: string code: string
name: string name: string
description: string description: string
icon: string icon: string
category: string category: string
expReward?: number expReward?: number
unlocked?: boolean unlocked?: boolean
unlockedAt?: string | null unlockedAt?: string | null
size?: 'small' | 'medium' | 'large' size?: 'small' | 'medium' | 'large'
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
expReward: 0, expReward: 0,
unlocked: false, unlocked: false,
unlockedAt: null, unlockedAt: null,
size: 'medium' size: 'medium'
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'click', badge: Props): void (e: 'click', badge: Props): void
}>() }>()
// 图标映射 // 图标映射
const iconMap: Record<string, any> = { const iconMap: Record<string, any> = {
Medal, Star, Reading, Collection, Files, Select, Medal, Star, Reading, Collection, Files, Select,
Finished, Trophy, TrendCharts, Clock, Timer, Finished, Trophy, TrendCharts, Clock, Timer,
Stopwatch, Operation, Calendar, Rank, Stopwatch, Operation, Calendar, Rank,
Headset, StarFilled, Lock Headset, StarFilled, Lock
} }
const iconComponent = computed(() => { const iconComponent = computed(() => {
return iconMap[props.icon] || Medal return iconMap[props.icon] || Medal
}) })
const sizeMap = { const sizeMap = {
small: 24, small: 24,
medium: 32, medium: 32,
large: 48 large: 48
} }
const iconSize = computed(() => sizeMap[props.size]) const iconSize = computed(() => sizeMap[props.size])
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
const date = new Date(dateStr) const date = new Date(dateStr)
return `${date.getMonth() + 1}/${date.getDate()}` return `${date.getMonth() + 1}/${date.getDate()}`
} }
const handleClick = () => { const handleClick = () => {
emit('click', props) emit('click', props)
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.badge-card { .badge-card {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 12px 16px; padding: 12px 16px;
border-radius: 8px; border-radius: 8px;
background-color: #fff; background-color: #fff;
border: 1px solid #EBEEF5; border: 1px solid #EBEEF5;
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.3s;
position: relative; position: relative;
&:hover { &:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
&.unlocked { &.unlocked {
.badge-icon { .badge-icon {
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%); background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
color: #fff; color: #fff;
} }
} }
&.locked { &.locked {
opacity: 0.6; opacity: 0.6;
.badge-icon { .badge-icon {
background-color: #F5F7FA; background-color: #F5F7FA;
color: #C0C4CC; color: #C0C4CC;
} }
.badge-name { .badge-name {
color: #909399; color: #909399;
} }
} }
.badge-icon { .badge-icon {
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
} }
.badge-info { .badge-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
.badge-name { .badge-name {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #303133; color: #303133;
margin-bottom: 4px; margin-bottom: 4px;
} }
.badge-desc { .badge-desc {
font-size: 12px; font-size: 12px;
color: #909399; color: #909399;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.badge-reward { .badge-reward {
font-size: 12px; font-size: 12px;
color: #E6A23C; color: #E6A23C;
margin-top: 4px; margin-top: 4px;
} }
.badge-unlock-time { .badge-unlock-time {
font-size: 12px; font-size: 12px;
color: #67C23A; color: #67C23A;
margin-top: 4px; margin-top: 4px;
} }
} }
.badge-status { .badge-status {
color: #C0C4CC; color: #C0C4CC;
font-size: 20px; font-size: 20px;
} }
} }
</style> </style>

View File

@@ -1,100 +1,100 @@
<template> <template>
<div class="exp-progress"> <div class="exp-progress">
<div class="progress-header"> <div class="progress-header">
<span class="label">经验值</span> <span class="label">经验值</span>
<span class="value">{{ currentExp }} / {{ targetExp }}</span> <span class="value">{{ currentExp }} / {{ targetExp }}</span>
</div> </div>
<div class="progress-bar"> <div class="progress-bar">
<div <div
class="progress-fill" class="progress-fill"
:style="{ width: progressPercent + '%', backgroundColor: color }" :style="{ width: progressPercent + '%', backgroundColor: color }"
></div> ></div>
</div> </div>
<div class="progress-footer" v-if="showFooter"> <div class="progress-footer" v-if="showFooter">
<span class="exp-to-next">距下一级还需 {{ expToNext }} 经验</span> <span class="exp-to-next">距下一级还需 {{ expToNext }} 经验</span>
<span class="total-exp" v-if="showTotal">累计 {{ totalExp }}</span> <span class="total-exp" v-if="showTotal">累计 {{ totalExp }}</span>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
interface Props { interface Props {
currentExp: number currentExp: number
targetExp: number targetExp: number
totalExp?: number totalExp?: number
color?: string color?: string
showFooter?: boolean showFooter?: boolean
showTotal?: boolean showTotal?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
totalExp: 0, totalExp: 0,
color: '#409EFF', color: '#409EFF',
showFooter: true, showFooter: true,
showTotal: false showTotal: false
}) })
const progressPercent = computed(() => { const progressPercent = computed(() => {
if (props.targetExp <= 0) return 100 if (props.targetExp <= 0) return 100
const percent = (props.currentExp / props.targetExp) * 100 const percent = (props.currentExp / props.targetExp) * 100
return Math.min(percent, 100) return Math.min(percent, 100)
}) })
const expToNext = computed(() => { const expToNext = computed(() => {
return Math.max(0, props.targetExp - props.currentExp) return Math.max(0, props.targetExp - props.currentExp)
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.exp-progress { .exp-progress {
.progress-header { .progress-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 8px; margin-bottom: 8px;
.label { .label {
font-size: 14px; font-size: 14px;
color: #606266; color: #606266;
} }
.value { .value {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #303133; color: #303133;
} }
} }
.progress-bar { .progress-bar {
height: 8px; height: 8px;
background-color: #EBEEF5; background-color: #EBEEF5;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
.progress-fill { .progress-fill {
height: 100%; height: 100%;
border-radius: 4px; border-radius: 4px;
transition: width 0.3s ease; transition: width 0.3s ease;
} }
} }
.progress-footer { .progress-footer {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: 4px; margin-top: 4px;
.exp-to-next { .exp-to-next {
font-size: 12px; font-size: 12px;
color: #909399; color: #909399;
} }
.total-exp { .total-exp {
font-size: 12px; font-size: 12px;
color: #909399; color: #909399;
} }
} }
} }
</style> </style>

View File

@@ -1,85 +1,85 @@
<template> <template>
<div class="level-badge" :style="{ '--level-color': color }"> <div class="level-badge" :style="{ '--level-color': color }">
<div class="level-icon"> <div class="level-icon">
<span class="level-number">{{ level }}</span> <span class="level-number">{{ level }}</span>
</div> </div>
<div class="level-info" v-if="showInfo"> <div class="level-info" v-if="showInfo">
<span class="level-title">{{ title }}</span> <span class="level-title">{{ title }}</span>
<span class="level-exp" v-if="showExp">{{ exp }}/{{ nextLevelExp }}</span> <span class="level-exp" v-if="showExp">{{ exp }}/{{ nextLevelExp }}</span>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
interface Props { interface Props {
level: number level: number
title?: string title?: string
color?: string color?: string
exp?: number exp?: number
nextLevelExp?: number nextLevelExp?: number
showInfo?: boolean showInfo?: boolean
showExp?: boolean showExp?: boolean
size?: 'small' | 'medium' | 'large' size?: 'small' | 'medium' | 'large'
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
title: '初学者', title: '初学者',
color: '#909399', color: '#909399',
exp: 0, exp: 0,
nextLevelExp: 1000, nextLevelExp: 1000,
showInfo: true, showInfo: true,
showExp: false, showExp: false,
size: 'medium' size: 'medium'
}) })
const sizeMap = { const sizeMap = {
small: { icon: 24, font: 12 }, small: { icon: 24, font: 12 },
medium: { icon: 32, font: 14 }, medium: { icon: 32, font: 14 },
large: { icon: 48, font: 18 } large: { icon: 48, font: 18 }
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.level-badge { .level-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
.level-icon { .level-icon {
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, var(--level-color) 0%, color-mix(in srgb, var(--level-color) 80%, #000) 100%); background: linear-gradient(135deg, var(--level-color) 0%, color-mix(in srgb, var(--level-color) 80%, #000) 100%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
.level-number { .level-number {
color: #fff; color: #fff;
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
} }
} }
.level-info { .level-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
.level-title { .level-title {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--level-color); color: var(--level-color);
} }
.level-exp { .level-exp {
font-size: 12px; font-size: 12px;
color: #909399; color: #909399;
} }
} }
} }
</style> </style>

View File

@@ -1,297 +1,297 @@
<template> <template>
<el-dialog <el-dialog
v-model="visible" v-model="visible"
:show-close="false" :show-close="false"
:close-on-click-modal="false" :close-on-click-modal="false"
width="360px" width="360px"
center center
class="level-up-dialog" class="level-up-dialog"
> >
<div class="dialog-content"> <div class="dialog-content">
<!-- 等级升级 --> <!-- 等级升级 -->
<div class="level-up-section" v-if="leveledUp"> <div class="level-up-section" v-if="leveledUp">
<div class="celebration"> <div class="celebration">
<div class="firework"></div> <div class="firework"></div>
<div class="firework"></div> <div class="firework"></div>
<div class="firework"></div> <div class="firework"></div>
</div> </div>
<div class="level-badge-large"> <div class="level-badge-large">
<span class="level-number">{{ newLevel }}</span> <span class="level-number">{{ newLevel }}</span>
</div> </div>
<div class="congrats-text">恭喜升级!</div> <div class="congrats-text">恭喜升级!</div>
<div class="level-title" :style="{ color: levelColor }">{{ levelTitle }}</div> <div class="level-title" :style="{ color: levelColor }">{{ levelTitle }}</div>
</div> </div>
<!-- 经验值获得 --> <!-- 经验值获得 -->
<div class="exp-section" v-if="expGained > 0 && !leveledUp"> <div class="exp-section" v-if="expGained > 0 && !leveledUp">
<el-icon class="exp-icon" :size="48"><TrendCharts /></el-icon> <el-icon class="exp-icon" :size="48"><TrendCharts /></el-icon>
<div class="exp-text">经验值 +{{ expGained }}</div> <div class="exp-text">经验值 +{{ expGained }}</div>
</div> </div>
<!-- 新获得奖章 --> <!-- 新获得奖章 -->
<div class="badges-section" v-if="newBadges && newBadges.length > 0"> <div class="badges-section" v-if="newBadges && newBadges.length > 0">
<div class="section-title">新解锁奖章</div> <div class="section-title">新解锁奖章</div>
<div class="badges-list"> <div class="badges-list">
<div <div
class="badge-item" class="badge-item"
v-for="badge in newBadges" v-for="badge in newBadges"
:key="badge.code" :key="badge.code"
> >
<div class="badge-icon"> <div class="badge-icon">
<el-icon :size="24"> <el-icon :size="24">
<component :is="getIconComponent(badge.icon)" /> <component :is="getIconComponent(badge.icon)" />
</el-icon> </el-icon>
</div> </div>
<div class="badge-name">{{ badge.name }}</div> <div class="badge-name">{{ badge.name }}</div>
<div class="badge-reward" v-if="badge.exp_reward > 0"> <div class="badge-reward" v-if="badge.exp_reward > 0">
+{{ badge.exp_reward }} +{{ badge.exp_reward }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<template #footer> <template #footer>
<el-button type="primary" @click="handleClose" size="large" round> <el-button type="primary" @click="handleClose" size="large" round>
太棒了! 太棒了!
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { import {
TrendCharts, Medal, Star, Reading, Collection, Files, TrendCharts, Medal, Star, Reading, Collection, Files,
Select, Finished, Trophy, Clock, Timer, Stopwatch, Select, Finished, Trophy, Clock, Timer, Stopwatch,
Operation, Calendar, Rank, Headset, StarFilled Operation, Calendar, Rank, Headset, StarFilled
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
interface Badge { interface Badge {
code: string code: string
name: string name: string
icon: string icon: string
exp_reward?: number exp_reward?: number
} }
interface Props { interface Props {
modelValue: boolean modelValue: boolean
leveledUp?: boolean leveledUp?: boolean
newLevel?: number | null newLevel?: number | null
levelTitle?: string levelTitle?: string
levelColor?: string levelColor?: string
expGained?: number expGained?: number
newBadges?: Badge[] newBadges?: Badge[]
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
leveledUp: false, leveledUp: false,
newLevel: null, newLevel: null,
levelTitle: '', levelTitle: '',
levelColor: '#409EFF', levelColor: '#409EFF',
expGained: 0, expGained: 0,
newBadges: () => [] newBadges: () => []
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void (e: 'update:modelValue', value: boolean): void
(e: 'close'): void (e: 'close'): void
}>() }>()
const visible = computed({ const visible = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (value) => emit('update:modelValue', value) set: (value) => emit('update:modelValue', value)
}) })
// 图标映射 // 图标映射
const iconMap: Record<string, any> = { const iconMap: Record<string, any> = {
Medal, Star, Reading, Collection, Files, Select, Medal, Star, Reading, Collection, Files, Select,
Finished, Trophy, TrendCharts, Clock, Timer, Finished, Trophy, TrendCharts, Clock, Timer,
Stopwatch, Operation, Calendar, Rank, Stopwatch, Operation, Calendar, Rank,
Headset, StarFilled Headset, StarFilled
} }
const getIconComponent = (icon: string) => { const getIconComponent = (icon: string) => {
return iconMap[icon] || Medal return iconMap[icon] || Medal
} }
const handleClose = () => { const handleClose = () => {
visible.value = false visible.value = false
emit('close') emit('close')
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.level-up-dialog { .level-up-dialog {
:deep(.el-dialog__header) { :deep(.el-dialog__header) {
display: none; display: none;
} }
:deep(.el-dialog__body) { :deep(.el-dialog__body) {
padding: 24px; padding: 24px;
} }
} }
.dialog-content { .dialog-content {
text-align: center; text-align: center;
.level-up-section { .level-up-section {
position: relative; position: relative;
padding: 20px 0; padding: 20px 0;
.celebration { .celebration {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
pointer-events: none; pointer-events: none;
overflow: hidden; overflow: hidden;
.firework { .firework {
position: absolute; position: absolute;
width: 4px; width: 4px;
height: 4px; height: 4px;
border-radius: 50%; border-radius: 50%;
animation: firework 1s ease-out infinite; animation: firework 1s ease-out infinite;
&:nth-child(1) { &:nth-child(1) {
left: 20%; left: 20%;
top: 30%; top: 30%;
background-color: #FFD700; background-color: #FFD700;
animation-delay: 0s; animation-delay: 0s;
} }
&:nth-child(2) { &:nth-child(2) {
left: 50%; left: 50%;
top: 20%; top: 20%;
background-color: #FF6B6B; background-color: #FF6B6B;
animation-delay: 0.3s; animation-delay: 0.3s;
} }
&:nth-child(3) { &:nth-child(3) {
left: 80%; left: 80%;
top: 40%; top: 40%;
background-color: #4ECDC4; background-color: #4ECDC4;
animation-delay: 0.6s; animation-delay: 0.6s;
} }
} }
} }
.level-badge-large { .level-badge-large {
width: 80px; width: 80px;
height: 80px; height: 80px;
margin: 0 auto 16px; margin: 0 auto 16px;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%); background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 8px 24px rgba(255, 165, 0, 0.4); box-shadow: 0 8px 24px rgba(255, 165, 0, 0.4);
animation: bounce 0.6s ease; animation: bounce 0.6s ease;
.level-number { .level-number {
font-size: 36px; font-size: 36px;
font-weight: 700; font-weight: 700;
color: #fff; color: #fff;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
} }
} }
.congrats-text { .congrats-text {
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: #303133; color: #303133;
margin-bottom: 8px; margin-bottom: 8px;
} }
.level-title { .level-title {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
} }
} }
.exp-section { .exp-section {
padding: 24px 0; padding: 24px 0;
.exp-icon { .exp-icon {
color: #E6A23C; color: #E6A23C;
margin-bottom: 12px; margin-bottom: 12px;
} }
.exp-text { .exp-text {
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: #E6A23C; color: #E6A23C;
} }
} }
.badges-section { .badges-section {
margin-top: 20px; margin-top: 20px;
padding-top: 20px; padding-top: 20px;
border-top: 1px solid #EBEEF5; border-top: 1px solid #EBEEF5;
.section-title { .section-title {
font-size: 14px; font-size: 14px;
color: #909399; color: #909399;
margin-bottom: 12px; margin-bottom: 12px;
} }
.badges-list { .badges-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
gap: 12px; gap: 12px;
.badge-item { .badge-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
.badge-icon { .badge-icon {
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%); background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #fff; color: #fff;
} }
.badge-name { .badge-name {
font-size: 12px; font-size: 12px;
color: #303133; color: #303133;
font-weight: 500; font-weight: 500;
} }
.badge-reward { .badge-reward {
font-size: 12px; font-size: 12px;
color: #E6A23C; color: #E6A23C;
} }
} }
} }
} }
} }
@keyframes firework { @keyframes firework {
0% { 0% {
transform: scale(1); transform: scale(1);
opacity: 1; opacity: 1;
} }
100% { 100% {
transform: scale(20); transform: scale(20);
opacity: 0; opacity: 0;
} }
} }
@keyframes bounce { @keyframes bounce {
0%, 100% { 0%, 100% {
transform: scale(1); transform: scale(1);
} }
50% { 50% {
transform: scale(1.1); transform: scale(1.1);
} }
} }
</style> </style>

View File

@@ -1,462 +1,462 @@
/** /**
* 语音通话组合式函数 * 语音通话组合式函数
* *
* 功能: * 功能:
* - 整合 WebRTC 管理和信令服务 * - 整合 WebRTC 管理和信令服务
* - 管理通话状态 * - 管理通话状态
* - 处理语音转文字 * - 处理语音转文字
*/ */
import { ref, computed, onUnmounted } from 'vue' import { ref, computed, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { WebRTCManager, createWebRTCManager, type ConnectionState } from '@/utils/webrtc' import { WebRTCManager, createWebRTCManager, type ConnectionState } from '@/utils/webrtc'
import request from '@/api/request' import request from '@/api/request'
export type VoiceCallState = 'idle' | 'requesting' | 'ringing' | 'connecting' | 'connected' | 'ended' export type VoiceCallState = 'idle' | 'requesting' | 'ringing' | 'connecting' | 'connected' | 'ended'
export interface UseVoiceCallOptions { export interface UseVoiceCallOptions {
roomCode: string roomCode: string
onTranscript?: (text: string, isFinal: boolean) => void onTranscript?: (text: string, isFinal: boolean) => void
onRemoteTranscript?: (text: string) => void onRemoteTranscript?: (text: string) => void
} }
export function useVoiceCall(options: UseVoiceCallOptions) { export function useVoiceCall(options: UseVoiceCallOptions) {
const { roomCode, onTranscript, onRemoteTranscript } = options const { roomCode, onTranscript, onRemoteTranscript } = options
// ==================== 状态 ==================== // ==================== 状态 ====================
const callState = ref<VoiceCallState>('idle') const callState = ref<VoiceCallState>('idle')
const connectionState = ref<ConnectionState>('idle') const connectionState = ref<ConnectionState>('idle')
const isMuted = ref(false) const isMuted = ref(false)
const isRemoteMuted = ref(false) const isRemoteMuted = ref(false)
const localAudioLevel = ref(0) const localAudioLevel = ref(0)
const remoteAudioLevel = ref(0) const remoteAudioLevel = ref(0)
const callDuration = ref(0) const callDuration = ref(0)
const errorMessage = ref<string | null>(null) const errorMessage = ref<string | null>(null)
// 语音识别相关 // 语音识别相关
const isTranscribing = ref(false) const isTranscribing = ref(false)
const currentTranscript = ref('') const currentTranscript = ref('')
// 内部状态 // 内部状态
let webrtcManager: WebRTCManager | null = null let webrtcManager: WebRTCManager | null = null
let recognition: any = null // SpeechRecognition let recognition: any = null // SpeechRecognition
let callTimer: number | null = null let callTimer: number | null = null
let audioLevelTimer: number | null = null let audioLevelTimer: number | null = null
// ==================== 计算属性 ==================== // ==================== 计算属性 ====================
const isCallActive = computed(() => const isCallActive = computed(() =>
['requesting', 'ringing', 'connecting', 'connected'].includes(callState.value) ['requesting', 'ringing', 'connecting', 'connected'].includes(callState.value)
) )
const canStartCall = computed(() => callState.value === 'idle') const canStartCall = computed(() => callState.value === 'idle')
const canEndCall = computed(() => isCallActive.value) const canEndCall = computed(() => isCallActive.value)
// ==================== 信令 API ==================== // ==================== 信令 API ====================
async function sendSignal(signalType: string, payload: any) { async function sendSignal(signalType: string, payload: any) {
try { try {
await request.post(`/api/v1/practice/rooms/${roomCode}/signal`, { await request.post(`/api/v1/practice/rooms/${roomCode}/signal`, {
signal_type: signalType, signal_type: signalType,
payload payload
}) })
} catch (error) { } catch (error) {
console.error('[VoiceCall] 发送信令失败:', error) console.error('[VoiceCall] 发送信令失败:', error)
throw error throw error
} }
} }
// ==================== 通话控制 ==================== // ==================== 通话控制 ====================
/** /**
* 发起语音通话 * 发起语音通话
*/ */
async function startCall() { async function startCall() {
if (!canStartCall.value) { if (!canStartCall.value) {
console.warn('[VoiceCall] 无法发起通话,当前状态:', callState.value) console.warn('[VoiceCall] 无法发起通话,当前状态:', callState.value)
return return
} }
try { try {
callState.value = 'requesting' callState.value = 'requesting'
errorMessage.value = null errorMessage.value = null
// 创建 WebRTC 管理器 // 创建 WebRTC 管理器
webrtcManager = createWebRTCManager({ webrtcManager = createWebRTCManager({
onConnectionStateChange: handleConnectionStateChange, onConnectionStateChange: handleConnectionStateChange,
onIceCandidate: handleIceCandidate, onIceCandidate: handleIceCandidate,
onRemoteStream: handleRemoteStream, onRemoteStream: handleRemoteStream,
onError: handleError onError: handleError
}) })
// 创建 Offer // 创建 Offer
const offer = await webrtcManager.createOffer() const offer = await webrtcManager.createOffer()
// 发送开始信令 // 发送开始信令
await sendSignal('voice_start', {}) await sendSignal('voice_start', {})
// 发送 Offer // 发送 Offer
await sendSignal('voice_offer', { await sendSignal('voice_offer', {
type: offer.type, type: offer.type,
sdp: offer.sdp sdp: offer.sdp
}) })
callState.value = 'ringing' callState.value = 'ringing'
ElMessage.info('正在呼叫对方...') ElMessage.info('正在呼叫对方...')
} catch (error: any) { } catch (error: any) {
console.error('[VoiceCall] 发起通话失败:', error) console.error('[VoiceCall] 发起通话失败:', error)
errorMessage.value = error.message || '发起通话失败' errorMessage.value = error.message || '发起通话失败'
callState.value = 'idle' callState.value = 'idle'
webrtcManager?.close() webrtcManager?.close()
webrtcManager = null webrtcManager = null
ElMessage.error(errorMessage.value) ElMessage.error(errorMessage.value)
} }
} }
/** /**
* 接听语音通话 * 接听语音通话
*/ */
async function answerCall(offer: RTCSessionDescriptionInit) { async function answerCall(offer: RTCSessionDescriptionInit) {
try { try {
callState.value = 'connecting' callState.value = 'connecting'
errorMessage.value = null errorMessage.value = null
// 创建 WebRTC 管理器 // 创建 WebRTC 管理器
webrtcManager = createWebRTCManager({ webrtcManager = createWebRTCManager({
onConnectionStateChange: handleConnectionStateChange, onConnectionStateChange: handleConnectionStateChange,
onIceCandidate: handleIceCandidate, onIceCandidate: handleIceCandidate,
onRemoteStream: handleRemoteStream, onRemoteStream: handleRemoteStream,
onError: handleError onError: handleError
}) })
// 处理 Offer 并创建 Answer // 处理 Offer 并创建 Answer
const answer = await webrtcManager.handleOffer(offer) const answer = await webrtcManager.handleOffer(offer)
// 发送 Answer // 发送 Answer
await sendSignal('voice_answer', { await sendSignal('voice_answer', {
type: answer.type, type: answer.type,
sdp: answer.sdp sdp: answer.sdp
}) })
ElMessage.success('已接听通话') ElMessage.success('已接听通话')
} catch (error: any) { } catch (error: any) {
console.error('[VoiceCall] 接听通话失败:', error) console.error('[VoiceCall] 接听通话失败:', error)
errorMessage.value = error.message || '接听通话失败' errorMessage.value = error.message || '接听通话失败'
callState.value = 'idle' callState.value = 'idle'
webrtcManager?.close() webrtcManager?.close()
webrtcManager = null webrtcManager = null
ElMessage.error(errorMessage.value) ElMessage.error(errorMessage.value)
} }
} }
/** /**
* 拒绝来电 * 拒绝来电
*/ */
async function rejectCall() { async function rejectCall() {
try { try {
await sendSignal('voice_end', { reason: 'rejected' }) await sendSignal('voice_end', { reason: 'rejected' })
callState.value = 'idle' callState.value = 'idle'
} catch (error) { } catch (error) {
console.error('[VoiceCall] 拒绝通话失败:', error) console.error('[VoiceCall] 拒绝通话失败:', error)
} }
} }
/** /**
* 结束通话 * 结束通话
*/ */
async function endCall() { async function endCall() {
try { try {
await sendSignal('voice_end', { reason: 'ended' }) await sendSignal('voice_end', { reason: 'ended' })
} catch (error) { } catch (error) {
console.error('[VoiceCall] 发送结束信令失败:', error) console.error('[VoiceCall] 发送结束信令失败:', error)
} }
cleanup() cleanup()
callState.value = 'ended' callState.value = 'ended'
// 延迟恢复到 idle 状态 // 延迟恢复到 idle 状态
setTimeout(() => { setTimeout(() => {
callState.value = 'idle' callState.value = 'idle'
}, 1000) }, 1000)
} }
/** /**
* 切换静音 * 切换静音
*/ */
function toggleMute() { function toggleMute() {
if (webrtcManager) { if (webrtcManager) {
isMuted.value = !isMuted.value isMuted.value = !isMuted.value
webrtcManager.setMuted(isMuted.value) webrtcManager.setMuted(isMuted.value)
} }
} }
// ==================== 信令处理 ==================== // ==================== 信令处理 ====================
/** /**
* 处理接收到的信令消息 * 处理接收到的信令消息
*/ */
async function handleSignal(signalType: string, payload: any, fromUserId: number) { async function handleSignal(signalType: string, payload: any, fromUserId: number) {
console.log('[VoiceCall] 收到信令:', signalType) console.log('[VoiceCall] 收到信令:', signalType)
switch (signalType) { switch (signalType) {
case 'voice_start': case 'voice_start':
// 收到通话请求 // 收到通话请求
if (callState.value === 'idle') { if (callState.value === 'idle') {
callState.value = 'ringing' callState.value = 'ringing'
ElMessage.info('收到语音通话请求') ElMessage.info('收到语音通话请求')
} }
break break
case 'voice_offer': case 'voice_offer':
// 收到 Offer自动接听 // 收到 Offer自动接听
if (callState.value === 'ringing' || callState.value === 'idle') { if (callState.value === 'ringing' || callState.value === 'idle') {
await answerCall({ await answerCall({
type: payload.type, type: payload.type,
sdp: payload.sdp sdp: payload.sdp
}) })
} }
break break
case 'voice_answer': case 'voice_answer':
// 收到 Answer // 收到 Answer
if (webrtcManager && callState.value === 'ringing') { if (webrtcManager && callState.value === 'ringing') {
await webrtcManager.handleAnswer({ await webrtcManager.handleAnswer({
type: payload.type, type: payload.type,
sdp: payload.sdp sdp: payload.sdp
}) })
} }
break break
case 'ice_candidate': case 'ice_candidate':
// 收到 ICE 候选 // 收到 ICE 候选
if (webrtcManager && payload.candidate) { if (webrtcManager && payload.candidate) {
await webrtcManager.addIceCandidate(payload) await webrtcManager.addIceCandidate(payload)
} }
break break
case 'voice_end': case 'voice_end':
// 对方结束通话 // 对方结束通话
cleanup() cleanup()
callState.value = 'ended' callState.value = 'ended'
ElMessage.info('通话已结束') ElMessage.info('通话已结束')
setTimeout(() => { setTimeout(() => {
callState.value = 'idle' callState.value = 'idle'
}, 1000) }, 1000)
break break
} }
} }
// ==================== WebRTC 回调 ==================== // ==================== WebRTC 回调 ====================
function handleConnectionStateChange(state: ConnectionState) { function handleConnectionStateChange(state: ConnectionState) {
connectionState.value = state connectionState.value = state
if (state === 'connected') { if (state === 'connected') {
callState.value = 'connected' callState.value = 'connected'
startCallTimer() startCallTimer()
startAudioLevelMonitor() startAudioLevelMonitor()
startSpeechRecognition() startSpeechRecognition()
ElMessage.success('语音通话已连接') ElMessage.success('语音通话已连接')
} else if (state === 'failed' || state === 'disconnected') { } else if (state === 'failed' || state === 'disconnected') {
if (callState.value === 'connected') { if (callState.value === 'connected') {
ElMessage.warning('通话连接断开') ElMessage.warning('通话连接断开')
} }
} }
} }
async function handleIceCandidate(candidate: RTCIceCandidate) { async function handleIceCandidate(candidate: RTCIceCandidate) {
try { try {
await sendSignal('ice_candidate', candidate.toJSON()) await sendSignal('ice_candidate', candidate.toJSON())
} catch (error) { } catch (error) {
console.error('[VoiceCall] 发送 ICE 候选失败:', error) console.error('[VoiceCall] 发送 ICE 候选失败:', error)
} }
} }
function handleRemoteStream(stream: MediaStream) { function handleRemoteStream(stream: MediaStream) {
console.log('[VoiceCall] 收到远程音频流') console.log('[VoiceCall] 收到远程音频流')
// 播放远程音频 // 播放远程音频
const audio = new Audio() const audio = new Audio()
audio.srcObject = stream audio.srcObject = stream
audio.play().catch(e => console.error('[VoiceCall] 播放远程音频失败:', e)) audio.play().catch(e => console.error('[VoiceCall] 播放远程音频失败:', e))
} }
function handleError(error: Error) { function handleError(error: Error) {
console.error('[VoiceCall] WebRTC 错误:', error) console.error('[VoiceCall] WebRTC 错误:', error)
errorMessage.value = error.message errorMessage.value = error.message
} }
// ==================== 语音识别 ==================== // ==================== 语音识别 ====================
function startSpeechRecognition() { function startSpeechRecognition() {
// 检查浏览器支持 // 检查浏览器支持
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
if (!SpeechRecognition) { if (!SpeechRecognition) {
console.warn('[VoiceCall] 浏览器不支持语音识别') console.warn('[VoiceCall] 浏览器不支持语音识别')
return return
} }
recognition = new SpeechRecognition() recognition = new SpeechRecognition()
recognition.continuous = true recognition.continuous = true
recognition.interimResults = true recognition.interimResults = true
recognition.lang = 'zh-CN' recognition.lang = 'zh-CN'
recognition.onstart = () => { recognition.onstart = () => {
isTranscribing.value = true isTranscribing.value = true
console.log('[VoiceCall] 语音识别已启动') console.log('[VoiceCall] 语音识别已启动')
} }
recognition.onresult = (event: any) => { recognition.onresult = (event: any) => {
let interimTranscript = '' let interimTranscript = ''
let finalTranscript = '' let finalTranscript = ''
for (let i = event.resultIndex; i < event.results.length; i++) { for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript const transcript = event.results[i][0].transcript
if (event.results[i].isFinal) { if (event.results[i].isFinal) {
finalTranscript += transcript finalTranscript += transcript
} else { } else {
interimTranscript += transcript interimTranscript += transcript
} }
} }
currentTranscript.value = interimTranscript || finalTranscript currentTranscript.value = interimTranscript || finalTranscript
if (finalTranscript) { if (finalTranscript) {
onTranscript?.(finalTranscript, true) onTranscript?.(finalTranscript, true)
} else if (interimTranscript) { } else if (interimTranscript) {
onTranscript?.(interimTranscript, false) onTranscript?.(interimTranscript, false)
} }
} }
recognition.onerror = (event: any) => { recognition.onerror = (event: any) => {
console.error('[VoiceCall] 语音识别错误:', event.error) console.error('[VoiceCall] 语音识别错误:', event.error)
if (event.error !== 'no-speech') { if (event.error !== 'no-speech') {
isTranscribing.value = false isTranscribing.value = false
} }
} }
recognition.onend = () => { recognition.onend = () => {
// 如果通话还在进行,重新启动识别 // 如果通话还在进行,重新启动识别
if (callState.value === 'connected' && !isMuted.value) { if (callState.value === 'connected' && !isMuted.value) {
recognition.start() recognition.start()
} else { } else {
isTranscribing.value = false isTranscribing.value = false
} }
} }
try { try {
recognition.start() recognition.start()
} catch (error) { } catch (error) {
console.error('[VoiceCall] 启动语音识别失败:', error) console.error('[VoiceCall] 启动语音识别失败:', error)
} }
} }
function stopSpeechRecognition() { function stopSpeechRecognition() {
if (recognition) { if (recognition) {
recognition.stop() recognition.stop()
recognition = null recognition = null
} }
isTranscribing.value = false isTranscribing.value = false
} }
// ==================== 辅助功能 ==================== // ==================== 辅助功能 ====================
function startCallTimer() { function startCallTimer() {
callDuration.value = 0 callDuration.value = 0
callTimer = window.setInterval(() => { callTimer = window.setInterval(() => {
callDuration.value++ callDuration.value++
}, 1000) }, 1000)
} }
function stopCallTimer() { function stopCallTimer() {
if (callTimer) { if (callTimer) {
clearInterval(callTimer) clearInterval(callTimer)
callTimer = null callTimer = null
} }
} }
function startAudioLevelMonitor() { function startAudioLevelMonitor() {
audioLevelTimer = window.setInterval(async () => { audioLevelTimer = window.setInterval(async () => {
if (webrtcManager) { if (webrtcManager) {
const localStream = webrtcManager.getLocalStream() const localStream = webrtcManager.getLocalStream()
const remoteStream = webrtcManager.getRemoteStream() const remoteStream = webrtcManager.getRemoteStream()
if (localStream) { if (localStream) {
localAudioLevel.value = await webrtcManager.getAudioLevel(localStream) localAudioLevel.value = await webrtcManager.getAudioLevel(localStream)
} }
if (remoteStream) { if (remoteStream) {
remoteAudioLevel.value = await webrtcManager.getAudioLevel(remoteStream) remoteAudioLevel.value = await webrtcManager.getAudioLevel(remoteStream)
} }
} }
}, 100) }, 100)
} }
function stopAudioLevelMonitor() { function stopAudioLevelMonitor() {
if (audioLevelTimer) { if (audioLevelTimer) {
clearInterval(audioLevelTimer) clearInterval(audioLevelTimer)
audioLevelTimer = null audioLevelTimer = null
} }
} }
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60) const mins = Math.floor(seconds / 60)
const secs = seconds % 60 const secs = seconds % 60
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
} }
// ==================== 清理 ==================== // ==================== 清理 ====================
function cleanup() { function cleanup() {
stopCallTimer() stopCallTimer()
stopAudioLevelMonitor() stopAudioLevelMonitor()
stopSpeechRecognition() stopSpeechRecognition()
webrtcManager?.close() webrtcManager?.close()
webrtcManager = null webrtcManager = null
isMuted.value = false isMuted.value = false
isRemoteMuted.value = false isRemoteMuted.value = false
localAudioLevel.value = 0 localAudioLevel.value = 0
remoteAudioLevel.value = 0 remoteAudioLevel.value = 0
currentTranscript.value = '' currentTranscript.value = ''
} }
// 组件卸载时清理 // 组件卸载时清理
onUnmounted(() => { onUnmounted(() => {
if (isCallActive.value) { if (isCallActive.value) {
endCall() endCall()
} }
cleanup() cleanup()
}) })
// ==================== 返回 ==================== // ==================== 返回 ====================
return { return {
// 状态 // 状态
callState, callState,
connectionState, connectionState,
isMuted, isMuted,
isRemoteMuted, isRemoteMuted,
localAudioLevel, localAudioLevel,
remoteAudioLevel, remoteAudioLevel,
callDuration, callDuration,
errorMessage, errorMessage,
isTranscribing, isTranscribing,
currentTranscript, currentTranscript,
// 计算属性 // 计算属性
isCallActive, isCallActive,
canStartCall, canStartCall,
canEndCall, canEndCall,
// 方法 // 方法
startCall, startCall,
answerCall, answerCall,
rejectCall, rejectCall,
endCall, endCall,
toggleMute, toggleMute,
handleSignal, handleSignal,
formatDuration formatDuration
} }
} }

View File

@@ -7,6 +7,7 @@ import { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { authManager } from '@/utils/auth' import { authManager } from '@/utils/auth'
import { loadingManager } from '@/utils/loadingManager' import { loadingManager } from '@/utils/loadingManager'
import { checkTeamMembership, checkCourseAccess, clearPermissionCache } from '@/utils/permissionChecker'
// 白名单路由(不需要登录) // 白名单路由(不需要登录)
const WHITE_LIST = ['/login', '/register', '/404'] const WHITE_LIST = ['/login', '/register', '/404']
@@ -109,13 +110,21 @@ async function handleRouteGuard(
return return
} }
// 检查特殊路由规则 // 检查特殊路由规则(先进行同步检查)
if (!checkSpecialRouteRules(to)) { if (!checkSpecialRouteRules(to)) {
ElMessage.error('访问被拒绝') ElMessage.error('访问被拒绝')
next(authManager.getDefaultRoute()) next(authManager.getDefaultRoute())
return return
} }
// 异步权限检查(团队和课程权限)
const hasSpecialAccess = await checkSpecialRouteRulesAsync(to)
if (!hasSpecialAccess) {
ElMessage.error('您没有访问此资源的权限')
next(authManager.getDefaultRoute())
return
}
next() next()
} }
@@ -142,9 +151,9 @@ function checkRoutePermission(path: string): boolean {
} }
/** /**
* 检查特殊路由规则 * 检查特殊路由规则(异步版本)
*/ */
function checkSpecialRouteRules(to: RouteLocationNormalized): boolean { async function checkSpecialRouteRulesAsync(to: RouteLocationNormalized): Promise<boolean> {
const { path, params } = to const { path, params } = to
// 检查用户ID参数权限只能访问自己的数据管理员除外 // 检查用户ID参数权限只能访问自己的数据管理员除外
@@ -157,14 +166,41 @@ function checkSpecialRouteRules(to: RouteLocationNormalized): boolean {
// 检查团队ID参数权限 // 检查团队ID参数权限
if (params.teamId && !authManager.isAdmin()) { if (params.teamId && !authManager.isAdmin()) {
// 这里可以添加团队权限检查逻辑 const teamId = Number(params.teamId)
// 暂时允许通过,实际项目中需要检查用户是否属于该团队 if (!isNaN(teamId)) {
const isMember = await checkTeamMembership(teamId)
if (!isMember) {
return false
}
}
} }
// 检查课程访问权限 // 检查课程访问权限
if (path.includes('/course/') && params.courseId) { if (path.includes('/course/') && params.courseId) {
// 这里可以添加课程访问权限检查 const courseId = Number(params.courseId)
// 例如检查课程是否分配给用户的岗位 if (!isNaN(courseId)) {
const hasAccess = await checkCourseAccess(courseId)
if (!hasAccess) {
return false
}
}
}
return true
}
/**
* 检查特殊路由规则(同步版本,用于简单检查)
*/
function checkSpecialRouteRules(to: RouteLocationNormalized): boolean {
const { params } = to
// 检查用户ID参数权限只能访问自己的数据管理员除外
if (params.userId && !authManager.isAdmin()) {
const currentUser = authManager.getCurrentUser()
if (currentUser && String(params.userId) !== String(currentUser.id)) {
return false
}
} }
return true return true

View File

@@ -1,413 +1,413 @@
/** /**
* 双人对练状态管理 * 双人对练状态管理
*/ */
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import * as duoPracticeApi from '@/api/duoPractice' import * as duoPracticeApi from '@/api/duoPractice'
import type { import type {
RoomInfo, RoomInfo,
RoomUser, RoomUser,
RoomMessage, RoomMessage,
CreateRoomRequest CreateRoomRequest
} from '@/api/duoPractice' } from '@/api/duoPractice'
export const useDuoPracticeStore = defineStore('duoPractice', () => { export const useDuoPracticeStore = defineStore('duoPractice', () => {
// ==================== 状态 ==================== // ==================== 状态 ====================
/** 房间码 */ /** 房间码 */
const roomCode = ref<string>('') const roomCode = ref<string>('')
/** 房间信息 */ /** 房间信息 */
const roomInfo = ref<RoomInfo | null>(null) const roomInfo = ref<RoomInfo | null>(null)
/** 房主信息 */ /** 房主信息 */
const hostUser = ref<RoomUser | null>(null) const hostUser = ref<RoomUser | null>(null)
/** 嘉宾信息 */ /** 嘉宾信息 */
const guestUser = ref<RoomUser | null>(null) const guestUser = ref<RoomUser | null>(null)
/** 我的角色 */ /** 我的角色 */
const myRole = ref<string>('') const myRole = ref<string>('')
/** 我的角色名称 */ /** 我的角色名称 */
const myRoleName = ref<string>('') const myRoleName = ref<string>('')
/** 是否是房主 */ /** 是否是房主 */
const isHost = ref<boolean>(false) const isHost = ref<boolean>(false)
/** 消息列表 */ /** 消息列表 */
const messages = ref<RoomMessage[]>([]) const messages = ref<RoomMessage[]>([])
/** 最后消息序号(用于轮询) */ /** 最后消息序号(用于轮询) */
const lastSequence = ref<number>(0) const lastSequence = ref<number>(0)
/** 是否正在加载 */ /** 是否正在加载 */
const isLoading = ref<boolean>(false) const isLoading = ref<boolean>(false)
/** 是否已连接(轮询中) */ /** 是否已连接(轮询中) */
const isConnected = ref<boolean>(false) const isConnected = ref<boolean>(false)
/** 轮询定时器 */ /** 轮询定时器 */
let pollingTimer: number | null = null let pollingTimer: number | null = null
/** 输入框内容 */ /** 输入框内容 */
const inputMessage = ref<string>('') const inputMessage = ref<string>('')
// ==================== 计算属性 ==================== // ==================== 计算属性 ====================
/** 房间状态 */ /** 房间状态 */
const roomStatus = computed(() => roomInfo.value?.status || 'unknown') const roomStatus = computed(() => roomInfo.value?.status || 'unknown')
/** 是否等待中 */ /** 是否等待中 */
const isWaiting = computed(() => roomStatus.value === 'waiting') const isWaiting = computed(() => roomStatus.value === 'waiting')
/** 是否就绪 */ /** 是否就绪 */
const isReady = computed(() => roomStatus.value === 'ready') const isReady = computed(() => roomStatus.value === 'ready')
/** 是否对练中 */ /** 是否对练中 */
const isPracticing = computed(() => roomStatus.value === 'practicing') const isPracticing = computed(() => roomStatus.value === 'practicing')
/** 是否已完成 */ /** 是否已完成 */
const isCompleted = computed(() => roomStatus.value === 'completed') const isCompleted = computed(() => roomStatus.value === 'completed')
/** 对方用户 */ /** 对方用户 */
const partnerUser = computed(() => { const partnerUser = computed(() => {
if (isHost.value) { if (isHost.value) {
return guestUser.value return guestUser.value
} else { } else {
return hostUser.value return hostUser.value
} }
}) })
/** 对方角色名称 */ /** 对方角色名称 */
const partnerRoleName = computed(() => { const partnerRoleName = computed(() => {
if (!roomInfo.value) return '' if (!roomInfo.value) return ''
const partnerRole = myRole.value === 'A' ? 'B' : 'A' const partnerRole = myRole.value === 'A' ? 'B' : 'A'
return partnerRole === 'A' ? roomInfo.value.role_a_name : roomInfo.value.role_b_name return partnerRole === 'A' ? roomInfo.value.role_a_name : roomInfo.value.role_b_name
}) })
/** 聊天消息(过滤系统消息) */ /** 聊天消息(过滤系统消息) */
const chatMessages = computed(() => { const chatMessages = computed(() => {
return messages.value.filter(m => m.message_type === 'chat') return messages.value.filter(m => m.message_type === 'chat')
}) })
/** 系统消息 */ /** 系统消息 */
const systemMessages = computed(() => { const systemMessages = computed(() => {
return messages.value.filter(m => m.message_type !== 'chat') return messages.value.filter(m => m.message_type !== 'chat')
}) })
// ==================== 方法 ==================== // ==================== 方法 ====================
/** /**
* 创建房间 * 创建房间
*/ */
const createRoom = async (request: CreateRoomRequest) => { const createRoom = async (request: CreateRoomRequest) => {
isLoading.value = true isLoading.value = true
try { try {
const res: any = await duoPracticeApi.createRoom(request) const res: any = await duoPracticeApi.createRoom(request)
if (res.code === 200) { if (res.code === 200) {
roomCode.value = res.data.room_code roomCode.value = res.data.room_code
myRole.value = res.data.my_role myRole.value = res.data.my_role
myRoleName.value = res.data.my_role_name myRoleName.value = res.data.my_role_name
isHost.value = true isHost.value = true
// 获取房间详情 // 获取房间详情
await fetchRoomDetail() await fetchRoomDetail()
return res.data return res.data
} else { } else {
throw new Error(res.message || '创建房间失败') throw new Error(res.message || '创建房间失败')
} }
} catch (error: any) { } catch (error: any) {
ElMessage.error(error.message || '创建房间失败') ElMessage.error(error.message || '创建房间失败')
throw error throw error
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
/** /**
* 加入房间 * 加入房间
*/ */
const joinRoom = async (code: string) => { const joinRoom = async (code: string) => {
isLoading.value = true isLoading.value = true
try { try {
const res: any = await duoPracticeApi.joinRoom(code.toUpperCase()) const res: any = await duoPracticeApi.joinRoom(code.toUpperCase())
if (res.code === 200) { if (res.code === 200) {
roomCode.value = res.data.room_code roomCode.value = res.data.room_code
myRole.value = res.data.my_role myRole.value = res.data.my_role
myRoleName.value = res.data.my_role_name myRoleName.value = res.data.my_role_name
isHost.value = false isHost.value = false
// 获取房间详情 // 获取房间详情
await fetchRoomDetail() await fetchRoomDetail()
return res.data return res.data
} else { } else {
throw new Error(res.message || '加入房间失败') throw new Error(res.message || '加入房间失败')
} }
} catch (error: any) { } catch (error: any) {
ElMessage.error(error.message || '加入房间失败') ElMessage.error(error.message || '加入房间失败')
throw error throw error
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
/** /**
* 获取房间详情 * 获取房间详情
*/ */
const fetchRoomDetail = async () => { const fetchRoomDetail = async () => {
if (!roomCode.value) return if (!roomCode.value) return
try { try {
const res: any = await duoPracticeApi.getRoomDetail(roomCode.value) const res: any = await duoPracticeApi.getRoomDetail(roomCode.value)
if (res.code === 200) { if (res.code === 200) {
roomInfo.value = res.data.room roomInfo.value = res.data.room
hostUser.value = res.data.host_user hostUser.value = res.data.host_user
guestUser.value = res.data.guest_user guestUser.value = res.data.guest_user
myRole.value = res.data.my_role || myRole.value myRole.value = res.data.my_role || myRole.value
myRoleName.value = res.data.my_role_name || myRoleName.value myRoleName.value = res.data.my_role_name || myRoleName.value
isHost.value = res.data.is_host isHost.value = res.data.is_host
} }
} catch (error) { } catch (error) {
console.error('获取房间详情失败:', error) console.error('获取房间详情失败:', error)
} }
} }
/** /**
* 开始对练 * 开始对练
*/ */
const startPractice = async () => { const startPractice = async () => {
if (!roomCode.value) return if (!roomCode.value) return
isLoading.value = true isLoading.value = true
try { try {
const res: any = await duoPracticeApi.startPractice(roomCode.value) const res: any = await duoPracticeApi.startPractice(roomCode.value)
if (res.code === 200) { if (res.code === 200) {
ElMessage.success('对练开始!') ElMessage.success('对练开始!')
await fetchRoomDetail() await fetchRoomDetail()
} else { } else {
throw new Error(res.message || '开始失败') throw new Error(res.message || '开始失败')
} }
} catch (error: any) { } catch (error: any) {
ElMessage.error(error.message || '开始对练失败') ElMessage.error(error.message || '开始对练失败')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
/** /**
* 结束对练 * 结束对练
*/ */
const endPractice = async () => { const endPractice = async () => {
if (!roomCode.value) return if (!roomCode.value) return
isLoading.value = true isLoading.value = true
try { try {
const res: any = await duoPracticeApi.endPractice(roomCode.value) const res: any = await duoPracticeApi.endPractice(roomCode.value)
if (res.code === 200) { if (res.code === 200) {
ElMessage.success('对练结束') ElMessage.success('对练结束')
await fetchRoomDetail() await fetchRoomDetail()
stopPolling() stopPolling()
return res.data return res.data
} else { } else {
throw new Error(res.message || '结束失败') throw new Error(res.message || '结束失败')
} }
} catch (error: any) { } catch (error: any) {
ElMessage.error(error.message || '结束对练失败') ElMessage.error(error.message || '结束对练失败')
throw error throw error
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
/** /**
* 离开房间 * 离开房间
*/ */
const leaveRoom = async () => { const leaveRoom = async () => {
if (!roomCode.value) return if (!roomCode.value) return
try { try {
await duoPracticeApi.leaveRoom(roomCode.value) await duoPracticeApi.leaveRoom(roomCode.value)
resetState() resetState()
} catch (error) { } catch (error) {
console.error('离开房间失败:', error) console.error('离开房间失败:', error)
} }
} }
/** /**
* 发送消息 * 发送消息
*/ */
const sendMessage = async (content?: string) => { const sendMessage = async (content?: string) => {
const msg = content || inputMessage.value.trim() const msg = content || inputMessage.value.trim()
if (!msg || !roomCode.value) return if (!msg || !roomCode.value) return
try { try {
const res: any = await duoPracticeApi.sendMessage(roomCode.value, msg) const res: any = await duoPracticeApi.sendMessage(roomCode.value, msg)
if (res.code === 200) { if (res.code === 200) {
inputMessage.value = '' inputMessage.value = ''
// 消息会通过轮询获取 // 消息会通过轮询获取
} }
} catch (error: any) { } catch (error: any) {
ElMessage.error(error.message || '发送失败') ElMessage.error(error.message || '发送失败')
} }
} }
/** /**
* 获取消息 * 获取消息
*/ */
const fetchMessages = async () => { const fetchMessages = async () => {
if (!roomCode.value) return if (!roomCode.value) return
try { try {
const res: any = await duoPracticeApi.getMessages(roomCode.value, lastSequence.value) const res: any = await duoPracticeApi.getMessages(roomCode.value, lastSequence.value)
if (res.code === 200) { if (res.code === 200) {
const newMessages = res.data.messages const newMessages = res.data.messages
if (newMessages.length > 0) { if (newMessages.length > 0) {
messages.value.push(...newMessages) messages.value.push(...newMessages)
lastSequence.value = res.data.last_sequence lastSequence.value = res.data.last_sequence
} }
// 检查房间状态变化 // 检查房间状态变化
if (res.data.room_status !== roomInfo.value?.status) { if (res.data.room_status !== roomInfo.value?.status) {
await fetchRoomDetail() await fetchRoomDetail()
} }
} }
} catch (error) { } catch (error) {
console.error('获取消息失败:', error) console.error('获取消息失败:', error)
} }
} }
/** /**
* 开始轮询消息 * 开始轮询消息
*/ */
const startPolling = () => { const startPolling = () => {
if (pollingTimer) return if (pollingTimer) return
isConnected.value = true isConnected.value = true
// 立即获取一次 // 立即获取一次
fetchMessages() fetchMessages()
// 每500ms轮询一次 // 每500ms轮询一次
pollingTimer = window.setInterval(() => { pollingTimer = window.setInterval(() => {
fetchMessages() fetchMessages()
}, 500) }, 500)
console.log('[DuoPractice] 开始轮询消息') console.log('[DuoPractice] 开始轮询消息')
} }
/** /**
* 停止轮询 * 停止轮询
*/ */
const stopPolling = () => { const stopPolling = () => {
if (pollingTimer) { if (pollingTimer) {
clearInterval(pollingTimer) clearInterval(pollingTimer)
pollingTimer = null pollingTimer = null
} }
isConnected.value = false isConnected.value = false
console.log('[DuoPractice] 停止轮询消息') console.log('[DuoPractice] 停止轮询消息')
} }
/** /**
* 重置状态 * 重置状态
*/ */
const resetState = () => { const resetState = () => {
stopPolling() stopPolling()
roomCode.value = '' roomCode.value = ''
roomInfo.value = null roomInfo.value = null
hostUser.value = null hostUser.value = null
guestUser.value = null guestUser.value = null
myRole.value = '' myRole.value = ''
myRoleName.value = '' myRoleName.value = ''
isHost.value = false isHost.value = false
messages.value = [] messages.value = []
lastSequence.value = 0 lastSequence.value = 0
inputMessage.value = '' inputMessage.value = ''
isLoading.value = false isLoading.value = false
} }
/** /**
* 生成分享链接 * 生成分享链接
*/ */
const getShareLink = () => { const getShareLink = () => {
if (!roomCode.value) return '' if (!roomCode.value) return ''
return duoPracticeApi.generateShareLink(roomCode.value) return duoPracticeApi.generateShareLink(roomCode.value)
} }
/** /**
* 复制房间码 * 复制房间码
*/ */
const copyRoomCode = async () => { const copyRoomCode = async () => {
if (!roomCode.value) return if (!roomCode.value) return
try { try {
await navigator.clipboard.writeText(roomCode.value) await navigator.clipboard.writeText(roomCode.value)
ElMessage.success('房间码已复制') ElMessage.success('房间码已复制')
} catch (error) { } catch (error) {
ElMessage.error('复制失败') ElMessage.error('复制失败')
} }
} }
/** /**
* 复制分享链接 * 复制分享链接
*/ */
const copyShareLink = async () => { const copyShareLink = async () => {
const link = getShareLink() const link = getShareLink()
if (!link) return if (!link) return
try { try {
await navigator.clipboard.writeText(link) await navigator.clipboard.writeText(link)
ElMessage.success('链接已复制') ElMessage.success('链接已复制')
} catch (error) { } catch (error) {
ElMessage.error('复制失败') ElMessage.error('复制失败')
} }
} }
// ==================== 返回 ==================== // ==================== 返回 ====================
return { return {
// 状态 // 状态
roomCode, roomCode,
roomInfo, roomInfo,
hostUser, hostUser,
guestUser, guestUser,
myRole, myRole,
myRoleName, myRoleName,
isHost, isHost,
messages, messages,
lastSequence, lastSequence,
isLoading, isLoading,
isConnected, isConnected,
inputMessage, inputMessage,
// 计算属性 // 计算属性
roomStatus, roomStatus,
isWaiting, isWaiting,
isReady, isReady,
isPracticing, isPracticing,
isCompleted, isCompleted,
partnerUser, partnerUser,
partnerRoleName, partnerRoleName,
chatMessages, chatMessages,
systemMessages, systemMessages,
// 方法 // 方法
createRoom, createRoom,
joinRoom, joinRoom,
fetchRoomDetail, fetchRoomDetail,
startPractice, startPractice,
endPractice, endPractice,
leaveRoom, leaveRoom,
sendMessage, sendMessage,
fetchMessages, fetchMessages,
startPolling, startPolling,
stopPolling, stopPolling,
resetState, resetState,
getShareLink, getShareLink,
copyRoomCode, copyRoomCode,
copyShareLink copyShareLink
} }
}) })

View File

@@ -161,6 +161,12 @@ class AuthManager {
localStorage.removeItem(this.userKey) localStorage.removeItem(this.userKey)
localStorage.removeItem(this.tokenKey) localStorage.removeItem(this.tokenKey)
localStorage.removeItem(this.refreshTokenKey) localStorage.removeItem(this.refreshTokenKey)
// 清除权限缓存
import('@/utils/permissionChecker').then(({ clearPermissionCache }) => {
clearPermissionCache()
}).catch(() => {
// 忽略导入错误
})
} }
/** /**

View File

@@ -1,227 +1,227 @@
/** /**
* 钉钉SDK工具类 * 钉钉SDK工具类
* *
* 提供钉钉环境检测、免登授权码获取等功能 * 提供钉钉环境检测、免登授权码获取等功能
*/ */
// 钉钉JSAPI类型声明 // 钉钉JSAPI类型声明
declare global { declare global {
interface Window { interface Window {
dd?: { dd?: {
env: { env: {
platform: 'notInDingTalk' | 'android' | 'ios' | 'pc' platform: 'notInDingTalk' | 'android' | 'ios' | 'pc'
} }
ready: (callback: () => void) => void ready: (callback: () => void) => void
error: (callback: (err: any) => void) => void error: (callback: (err: any) => void) => void
runtime: { runtime: {
permission: { permission: {
requestAuthCode: (options: { requestAuthCode: (options: {
corpId: string corpId: string
onSuccess: (result: { code: string }) => void onSuccess: (result: { code: string }) => void
onFail: (err: any) => void onFail: (err: any) => void
}) => void }) => void
} }
} }
biz: { biz: {
navigation: { navigation: {
setTitle: (options: { title: string }) => void setTitle: (options: { title: string }) => void
} }
} }
} }
} }
} }
/** /**
* 钉钉配置接口 * 钉钉配置接口
*/ */
export interface DingtalkConfig { export interface DingtalkConfig {
enabled: boolean enabled: boolean
corp_id: string | null corp_id: string | null
agent_id: string | null agent_id: string | null
} }
/** /**
* 检测是否在钉钉环境中 * 检测是否在钉钉环境中
*/ */
export function isDingtalkEnv(): boolean { export function isDingtalkEnv(): boolean {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
console.log('[钉钉检测] window 不存在') console.log('[钉钉检测] window 不存在')
return false return false
} }
// 首先通过 User-Agent 检测 // 首先通过 User-Agent 检测
const ua = navigator.userAgent.toLowerCase() const ua = navigator.userAgent.toLowerCase()
const isDingTalkUA = ua.includes('dingtalk') || ua.includes('aliapp') const isDingTalkUA = ua.includes('dingtalk') || ua.includes('aliapp')
console.log('[钉钉检测] UA检测:', isDingTalkUA, 'UA:', ua.substring(0, 100)) console.log('[钉钉检测] UA检测:', isDingTalkUA, 'UA:', ua.substring(0, 100))
if (!window.dd) { if (!window.dd) {
console.log('[钉钉检测] window.dd 不存在但UA检测为:', isDingTalkUA) console.log('[钉钉检测] window.dd 不存在但UA检测为:', isDingTalkUA)
return isDingTalkUA // 如果 UA 显示是钉钉但 SDK 还没加载,也返回 true return isDingTalkUA // 如果 UA 显示是钉钉但 SDK 还没加载,也返回 true
} }
const platform = window.dd.env?.platform const platform = window.dd.env?.platform
console.log('[钉钉检测] dd.env.platform:', platform) console.log('[钉钉检测] dd.env.platform:', platform)
return platform !== 'notInDingTalk' return platform !== 'notInDingTalk'
} }
/** /**
* 获取钉钉平台类型 * 获取钉钉平台类型
*/ */
export function getDingtalkPlatform(): string { export function getDingtalkPlatform(): string {
if (!window.dd) return 'notInDingTalk' if (!window.dd) return 'notInDingTalk'
return window.dd.env.platform return window.dd.env.platform
} }
/** /**
* 等待钉钉SDK就绪带超时 * 等待钉钉SDK就绪带超时
*/ */
export function waitDingtalkReady(timeout: number = 5000): Promise<void> { export function waitDingtalkReady(timeout: number = 5000): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!window.dd) { if (!window.dd) {
reject(new Error('钉钉SDK未加载')) reject(new Error('钉钉SDK未加载'))
return return
} }
let resolved = false let resolved = false
// 超时处理 // 超时处理
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (!resolved) { if (!resolved) {
resolved = true resolved = true
console.warn('钉钉SDK就绪超时尝试继续执行') console.warn('钉钉SDK就绪超时尝试继续执行')
resolve() // 超时后也尝试继续可能SDK已经就绪 resolve() // 超时后也尝试继续可能SDK已经就绪
} }
}, timeout) }, timeout)
window.dd.ready(() => { window.dd.ready(() => {
if (!resolved) { if (!resolved) {
resolved = true resolved = true
clearTimeout(timer) clearTimeout(timer)
console.log('钉钉SDK就绪') console.log('钉钉SDK就绪')
resolve() resolve()
} }
}) })
window.dd.error((err) => { window.dd.error((err) => {
if (!resolved) { if (!resolved) {
resolved = true resolved = true
clearTimeout(timer) clearTimeout(timer)
console.error('钉钉SDK错误:', err) console.error('钉钉SDK错误:', err)
reject(err) reject(err)
} }
}) })
}) })
} }
/** /**
* 获取钉钉免登授权码 * 获取钉钉免登授权码
* *
* @param corpId 企业CorpId * @param corpId 企业CorpId
* @returns 免登授权码 * @returns 免登授权码
*/ */
export function getAuthCode(corpId: string): Promise<string> { export function getAuthCode(corpId: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!window.dd) { if (!window.dd) {
reject(new Error('钉钉SDK未加载')) reject(new Error('钉钉SDK未加载'))
return return
} }
if (!isDingtalkEnv()) { if (!isDingtalkEnv()) {
reject(new Error('当前不在钉钉环境中')) reject(new Error('当前不在钉钉环境中'))
return return
} }
window.dd.runtime.permission.requestAuthCode({ window.dd.runtime.permission.requestAuthCode({
corpId: corpId, corpId: corpId,
onSuccess: (result) => { onSuccess: (result) => {
resolve(result.code) resolve(result.code)
}, },
onFail: (err) => { onFail: (err) => {
console.error('获取钉钉授权码失败:', err) console.error('获取钉钉授权码失败:', err)
reject(new Error(err.message || '获取授权码失败')) reject(new Error(err.message || '获取授权码失败'))
} }
}) })
}) })
} }
/** /**
* 设置钉钉页面标题 * 设置钉钉页面标题
*/ */
export function setDingtalkTitle(title: string): void { export function setDingtalkTitle(title: string): void {
if (!window.dd || !isDingtalkEnv()) return if (!window.dd || !isDingtalkEnv()) return
try { try {
window.dd.biz.navigation.setTitle({ title }) window.dd.biz.navigation.setTitle({ title })
} catch (e) { } catch (e) {
console.warn('设置钉钉标题失败:', e) console.warn('设置钉钉标题失败:', e)
} }
} }
/** /**
* 加载钉钉JSAPI SDK * 加载钉钉JSAPI SDK
* *
* 动态加载钉钉SDK脚本 * 动态加载钉钉SDK脚本
*/ */
export function loadDingtalkSDK(): Promise<void> { export function loadDingtalkSDK(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// 如果已经加载过,直接返回 // 如果已经加载过,直接返回
if (window.dd) { if (window.dd) {
resolve() resolve()
return return
} }
const script = document.createElement('script') const script = document.createElement('script')
script.src = 'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.12/dingtalk.open.js' script.src = 'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.12/dingtalk.open.js'
script.async = true script.async = true
script.onload = () => { script.onload = () => {
console.log('钉钉SDK加载成功') console.log('钉钉SDK加载成功')
resolve() resolve()
} }
script.onerror = () => { script.onerror = () => {
reject(new Error('钉钉SDK加载失败')) reject(new Error('钉钉SDK加载失败'))
} }
document.head.appendChild(script) document.head.appendChild(script)
}) })
} }
/** /**
* 钉钉免密登录完整流程 * 钉钉免密登录完整流程
* *
* @param corpId 企业CorpId * @param corpId 企业CorpId
* @param loginApi 登录API函数 * @param loginApi 登录API函数
* @returns 登录结果 * @returns 登录结果
*/ */
export async function dingtalkAutoLogin( export async function dingtalkAutoLogin(
corpId: string, corpId: string,
loginApi: (code: string) => Promise<any> loginApi: (code: string) => Promise<any>
): Promise<any> { ): Promise<any> {
// 1. 检测钉钉环境 // 1. 检测钉钉环境
if (!isDingtalkEnv()) { if (!isDingtalkEnv()) {
throw new Error('当前不在钉钉环境中,无法使用免密登录') throw new Error('当前不在钉钉环境中,无法使用免密登录')
} }
// 2. 等待SDK就绪 // 2. 等待SDK就绪
await waitDingtalkReady() await waitDingtalkReady()
// 3. 获取授权码 // 3. 获取授权码
const code = await getAuthCode(corpId) const code = await getAuthCode(corpId)
// 4. 调用登录API // 4. 调用登录API
const result = await loginApi(code) const result = await loginApi(code)
return result return result
} }
export default { export default {
isDingtalkEnv, isDingtalkEnv,
getDingtalkPlatform, getDingtalkPlatform,
waitDingtalkReady, waitDingtalkReady,
getAuthCode, getAuthCode,
setDingtalkTitle, setDingtalkTitle,
loadDingtalkSDK, loadDingtalkSDK,
dingtalkAutoLogin dingtalkAutoLogin
} }

View File

@@ -0,0 +1,211 @@
/**
* 权限检查工具
* 用于前端路由守卫和组件级权限控制
*/
import { authManager } from './auth'
// 缓存团队成员关系
const teamMembershipCache = new Map<number, boolean>()
// 缓存课程访问权限
const courseAccessCache = new Map<number, boolean>()
// 缓存过期时间5分钟
const CACHE_TTL = 5 * 60 * 1000
let lastCacheUpdate = 0
/**
* 清除权限缓存
*/
export function clearPermissionCache() {
teamMembershipCache.clear()
courseAccessCache.clear()
lastCacheUpdate = 0
}
/**
* 检查缓存是否过期
*/
function isCacheExpired(): boolean {
return Date.now() - lastCacheUpdate > CACHE_TTL
}
/**
* 更新缓存时间戳
*/
function updateCacheTimestamp() {
lastCacheUpdate = Date.now()
}
/**
* 检查用户是否属于指定团队
* @param teamId 团队ID
*/
export async function checkTeamMembership(teamId: number): Promise<boolean> {
// 管理员可以访问所有团队
if (authManager.isAdmin()) {
return true
}
// 检查缓存
if (!isCacheExpired() && teamMembershipCache.has(teamId)) {
return teamMembershipCache.get(teamId)!
}
try {
const currentUser = authManager.getCurrentUser()
if (!currentUser) {
return false
}
// 检查用户的团队列表
const userTeams = currentUser.teams || []
const isMember = userTeams.some((team: any) => team.id === teamId)
// 更新缓存
teamMembershipCache.set(teamId, isMember)
updateCacheTimestamp()
return isMember
} catch (error) {
console.error('检查团队成员身份失败:', error)
return false
}
}
/**
* 检查用户是否可以访问指定课程
* @param courseId 课程ID
*/
export async function checkCourseAccess(courseId: number): Promise<boolean> {
// 管理员和经理可以访问所有课程
if (authManager.isAdmin() || authManager.isManager()) {
return true
}
// 检查缓存
if (!isCacheExpired() && courseAccessCache.has(courseId)) {
return courseAccessCache.get(courseId)!
}
try {
// 简化检查:学员可以访问所有已发布的课程
// 后端会在 API 层面做更细粒度的权限控制
// 这里暂时放行,让后端决定是否返回 403
const hasAccess = true
// 更新缓存
courseAccessCache.set(courseId, hasAccess)
updateCacheTimestamp()
return hasAccess
} catch (error) {
console.error('检查课程访问权限失败:', error)
return false
}
}
/**
* 检查用户是否有某个权限
* @param permission 权限代码
*/
export function hasPermission(permission: string): boolean {
return authManager.hasPermission(permission)
}
/**
* 检查用户是否有任意一个权限
* @param permissions 权限代码列表
*/
export function hasAnyPermission(permissions: string[]): boolean {
return authManager.hasAnyPermission(permissions)
}
/**
* 检查用户是否有所有权限
* @param permissions 权限代码列表
*/
export function hasAllPermissions(permissions: string[]): boolean {
return authManager.hasAllPermissions(permissions)
}
/**
* 获取用户的所有权限
*/
export function getUserPermissions(): string[] {
return authManager.getUserPermissions()
}
/**
* 权限检查结果接口
*/
export interface PermissionCheckResult {
allowed: boolean
reason?: string
}
/**
* 综合权限检查
* @param options 检查选项
*/
export async function checkPermission(options: {
teamId?: number
courseId?: number
userId?: number
permissions?: string[]
roles?: string[]
}): Promise<PermissionCheckResult> {
const { teamId, courseId, userId, permissions, roles } = options
// 检查角色
if (roles && roles.length > 0) {
const userRole = authManager.getUserRole()
if (!userRole || (!roles.includes(userRole) && !authManager.isAdmin())) {
return { allowed: false, reason: '角色权限不足' }
}
}
// 检查权限
if (permissions && permissions.length > 0) {
if (!hasAnyPermission(permissions)) {
return { allowed: false, reason: '缺少必要权限' }
}
}
// 检查用户ID只能访问自己的数据
if (userId !== undefined) {
const currentUser = authManager.getCurrentUser()
if (!authManager.isAdmin() && currentUser?.id !== userId) {
return { allowed: false, reason: '无权访问其他用户数据' }
}
}
// 检查团队成员身份
if (teamId !== undefined) {
const isMember = await checkTeamMembership(teamId)
if (!isMember) {
return { allowed: false, reason: '不是该团队成员' }
}
}
// 检查课程访问权限
if (courseId !== undefined) {
const hasAccess = await checkCourseAccess(courseId)
if (!hasAccess) {
return { allowed: false, reason: '无权访问该课程' }
}
}
return { allowed: true }
}
export default {
clearPermissionCache,
checkTeamMembership,
checkCourseAccess,
hasPermission,
hasAnyPermission,
hasAllPermissions,
getUserPermissions,
checkPermission,
}

View File

@@ -0,0 +1,294 @@
/**
* 语音识别工具
* 使用 Web Speech API 进行浏览器端语音识别
*/
// Web Speech API 类型声明
interface SpeechRecognitionEvent extends Event {
results: SpeechRecognitionResultList
resultIndex: number
}
interface SpeechRecognitionResultList {
readonly length: number
item(index: number): SpeechRecognitionResult
[index: number]: SpeechRecognitionResult
}
interface SpeechRecognitionResult {
readonly length: number
readonly isFinal: boolean
item(index: number): SpeechRecognitionAlternative
[index: number]: SpeechRecognitionAlternative
}
interface SpeechRecognitionAlternative {
readonly transcript: string
readonly confidence: number
}
interface SpeechRecognitionErrorEvent extends Event {
error: string
message: string
}
declare global {
interface Window {
SpeechRecognition: new () => SpeechRecognition
webkitSpeechRecognition: new () => SpeechRecognition
}
}
interface SpeechRecognition extends EventTarget {
continuous: boolean
interimResults: boolean
lang: string
maxAlternatives: number
start(): void
stop(): void
abort(): void
onresult: ((event: SpeechRecognitionEvent) => void) | null
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
onend: (() => void) | null
onstart: (() => void) | null
onspeechend: (() => void) | null
}
// 语音识别配置
export interface SpeechRecognitionConfig {
continuous?: boolean
interimResults?: boolean
lang?: string
maxAlternatives?: number
}
// 语音识别结果
export interface SpeechRecognitionResult {
transcript: string
isFinal: boolean
confidence: number
}
// 语音识别回调
export interface SpeechRecognitionCallbacks {
onResult?: (result: SpeechRecognitionResult) => void
onError?: (error: string) => void
onStart?: () => void
onEnd?: () => void
}
/**
* 检查浏览器是否支持语音识别
*/
export function isSpeechRecognitionSupported(): boolean {
return !!(window.SpeechRecognition || window.webkitSpeechRecognition)
}
/**
* 创建语音识别实例
*/
export function createSpeechRecognition(
config: SpeechRecognitionConfig = {}
): SpeechRecognition | null {
const SpeechRecognitionConstructor =
window.SpeechRecognition || window.webkitSpeechRecognition
if (!SpeechRecognitionConstructor) {
console.warn('浏览器不支持语音识别')
return null
}
const recognition = new SpeechRecognitionConstructor()
recognition.continuous = config.continuous ?? false
recognition.interimResults = config.interimResults ?? true
recognition.lang = config.lang ?? 'zh-CN'
recognition.maxAlternatives = config.maxAlternatives ?? 1
return recognition
}
/**
* 语音识别管理器类
*/
export class SpeechRecognitionManager {
private recognition: SpeechRecognition | null = null
private isListening = false
private callbacks: SpeechRecognitionCallbacks = {}
constructor(config: SpeechRecognitionConfig = {}) {
this.recognition = createSpeechRecognition(config)
this.setupEventListeners()
}
private setupEventListeners() {
if (!this.recognition) return
this.recognition.onresult = (event: SpeechRecognitionEvent) => {
const lastResult = event.results[event.resultIndex]
if (lastResult) {
const result: SpeechRecognitionResult = {
transcript: lastResult[0].transcript,
isFinal: lastResult.isFinal,
confidence: lastResult[0].confidence,
}
this.callbacks.onResult?.(result)
}
}
this.recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
const errorMessages: Record<string, string> = {
'no-speech': '没有检测到语音',
'audio-capture': '无法访问麦克风',
'not-allowed': '麦克风权限被拒绝',
'network': '网络错误',
'aborted': '识别被中断',
'language-not-supported': '不支持的语言',
}
const message = errorMessages[event.error] || `识别错误: ${event.error}`
this.callbacks.onError?.(message)
this.isListening = false
}
this.recognition.onstart = () => {
this.isListening = true
this.callbacks.onStart?.()
}
this.recognition.onend = () => {
this.isListening = false
this.callbacks.onEnd?.()
}
}
/**
* 设置回调函数
*/
setCallbacks(callbacks: SpeechRecognitionCallbacks) {
this.callbacks = callbacks
}
/**
* 开始语音识别
*/
start(): boolean {
if (!this.recognition) {
this.callbacks.onError?.('浏览器不支持语音识别')
return false
}
if (this.isListening) {
return true
}
try {
this.recognition.start()
return true
} catch (error) {
this.callbacks.onError?.('启动语音识别失败')
return false
}
}
/**
* 停止语音识别
*/
stop() {
if (this.recognition && this.isListening) {
this.recognition.stop()
}
}
/**
* 中止语音识别
*/
abort() {
if (this.recognition) {
this.recognition.abort()
}
}
/**
* 是否正在监听
*/
getIsListening(): boolean {
return this.isListening
}
/**
* 是否支持语音识别
*/
isSupported(): boolean {
return this.recognition !== null
}
/**
* 销毁实例
*/
destroy() {
this.abort()
this.recognition = null
}
}
/**
* 一次性语音识别
* 返回 Promise识别完成后返回结果
*/
export function recognizeSpeech(
config: SpeechRecognitionConfig = {},
timeout = 10000
): Promise<string> {
return new Promise((resolve, reject) => {
const manager = new SpeechRecognitionManager({
...config,
continuous: false,
interimResults: false,
})
if (!manager.isSupported()) {
reject(new Error('浏览器不支持语音识别'))
return
}
let finalTranscript = ''
let timeoutId: number | null = null
manager.setCallbacks({
onResult: (result) => {
if (result.isFinal) {
finalTranscript = result.transcript
}
},
onEnd: () => {
if (timeoutId) {
clearTimeout(timeoutId)
}
manager.destroy()
resolve(finalTranscript)
},
onError: (error) => {
if (timeoutId) {
clearTimeout(timeoutId)
}
manager.destroy()
reject(new Error(error))
},
})
// 设置超时
timeoutId = window.setTimeout(() => {
manager.stop()
}, timeout)
if (!manager.start()) {
reject(new Error('启动语音识别失败'))
}
})
}
export default {
isSpeechRecognitionSupported,
createSpeechRecognition,
SpeechRecognitionManager,
recognizeSpeech,
}

View File

@@ -1,324 +1,324 @@
/** /**
* WebRTC 连接管理模块 * WebRTC 连接管理模块
* *
* 功能: * 功能:
* - 管理 RTCPeerConnection 生命周期 * - 管理 RTCPeerConnection 生命周期
* - 处理 SDP 交换 * - 处理 SDP 交换
* - 处理 ICE 候选收集 * - 处理 ICE 候选收集
* - 音频流管理 * - 音频流管理
*/ */
export type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'failed' export type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'failed'
export interface WebRTCConfig { export interface WebRTCConfig {
iceServers?: RTCIceServer[] iceServers?: RTCIceServer[]
onLocalStream?: (stream: MediaStream) => void onLocalStream?: (stream: MediaStream) => void
onRemoteStream?: (stream: MediaStream) => void onRemoteStream?: (stream: MediaStream) => void
onConnectionStateChange?: (state: ConnectionState) => void onConnectionStateChange?: (state: ConnectionState) => void
onIceCandidate?: (candidate: RTCIceCandidate) => void onIceCandidate?: (candidate: RTCIceCandidate) => void
onError?: (error: Error) => void onError?: (error: Error) => void
} }
// 默认 ICE 服务器配置 // 默认 ICE 服务器配置
const DEFAULT_ICE_SERVERS: RTCIceServer[] = [ const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' } { urls: 'stun:stun2.l.google.com:19302' }
] ]
export class WebRTCManager { export class WebRTCManager {
private peerConnection: RTCPeerConnection | null = null private peerConnection: RTCPeerConnection | null = null
private localStream: MediaStream | null = null private localStream: MediaStream | null = null
private remoteStream: MediaStream | null = null private remoteStream: MediaStream | null = null
private config: WebRTCConfig private config: WebRTCConfig
private connectionState: ConnectionState = 'idle' private connectionState: ConnectionState = 'idle'
private pendingIceCandidates: RTCIceCandidate[] = [] private pendingIceCandidates: RTCIceCandidate[] = []
constructor(config: WebRTCConfig = {}) { constructor(config: WebRTCConfig = {}) {
this.config = { this.config = {
iceServers: DEFAULT_ICE_SERVERS, iceServers: DEFAULT_ICE_SERVERS,
...config ...config
} }
} }
/** /**
* 获取当前连接状态 * 获取当前连接状态
*/ */
getConnectionState(): ConnectionState { getConnectionState(): ConnectionState {
return this.connectionState return this.connectionState
} }
/** /**
* 获取本地音频流 * 获取本地音频流
*/ */
getLocalStream(): MediaStream | null { getLocalStream(): MediaStream | null {
return this.localStream return this.localStream
} }
/** /**
* 获取远程音频流 * 获取远程音频流
*/ */
getRemoteStream(): MediaStream | null { getRemoteStream(): MediaStream | null {
return this.remoteStream return this.remoteStream
} }
/** /**
* 初始化本地音频流 * 初始化本地音频流
*/ */
async initLocalStream(): Promise<MediaStream> { async initLocalStream(): Promise<MediaStream> {
try { try {
this.localStream = await navigator.mediaDevices.getUserMedia({ this.localStream = await navigator.mediaDevices.getUserMedia({
audio: { audio: {
echoCancellation: true, echoCancellation: true,
noiseSuppression: true, noiseSuppression: true,
autoGainControl: true autoGainControl: true
}, },
video: false video: false
}) })
this.config.onLocalStream?.(this.localStream) this.config.onLocalStream?.(this.localStream)
return this.localStream return this.localStream
} catch (error) { } catch (error) {
const err = error instanceof Error ? error : new Error('获取麦克风权限失败') const err = error instanceof Error ? error : new Error('获取麦克风权限失败')
this.config.onError?.(err) this.config.onError?.(err)
throw err throw err
} }
} }
/** /**
* 创建 PeerConnection * 创建 PeerConnection
*/ */
private createPeerConnection(): RTCPeerConnection { private createPeerConnection(): RTCPeerConnection {
const pc = new RTCPeerConnection({ const pc = new RTCPeerConnection({
iceServers: this.config.iceServers iceServers: this.config.iceServers
}) })
// 监听 ICE 候选 // 监听 ICE 候选
pc.onicecandidate = (event) => { pc.onicecandidate = (event) => {
if (event.candidate) { if (event.candidate) {
console.log('[WebRTC] ICE candidate:', event.candidate.candidate?.substring(0, 50)) console.log('[WebRTC] ICE candidate:', event.candidate.candidate?.substring(0, 50))
this.config.onIceCandidate?.(event.candidate) this.config.onIceCandidate?.(event.candidate)
} }
} }
// 监听连接状态变化 // 监听连接状态变化
pc.onconnectionstatechange = () => { pc.onconnectionstatechange = () => {
console.log('[WebRTC] Connection state:', pc.connectionState) console.log('[WebRTC] Connection state:', pc.connectionState)
this.updateConnectionState(pc.connectionState) this.updateConnectionState(pc.connectionState)
} }
// 监听 ICE 连接状态 // 监听 ICE 连接状态
pc.oniceconnectionstatechange = () => { pc.oniceconnectionstatechange = () => {
console.log('[WebRTC] ICE connection state:', pc.iceConnectionState) console.log('[WebRTC] ICE connection state:', pc.iceConnectionState)
if (pc.iceConnectionState === 'failed') { if (pc.iceConnectionState === 'failed') {
this.updateConnectionState('failed') this.updateConnectionState('failed')
} }
} }
// 监听远程流 // 监听远程流
pc.ontrack = (event) => { pc.ontrack = (event) => {
console.log('[WebRTC] Remote track received') console.log('[WebRTC] Remote track received')
if (event.streams && event.streams[0]) { if (event.streams && event.streams[0]) {
this.remoteStream = event.streams[0] this.remoteStream = event.streams[0]
this.config.onRemoteStream?.(this.remoteStream) this.config.onRemoteStream?.(this.remoteStream)
} }
} }
return pc return pc
} }
/** /**
* 更新连接状态 * 更新连接状态
*/ */
private updateConnectionState(state: RTCPeerConnectionState | string) { private updateConnectionState(state: RTCPeerConnectionState | string) {
const stateMap: Record<string, ConnectionState> = { const stateMap: Record<string, ConnectionState> = {
'new': 'connecting', 'new': 'connecting',
'connecting': 'connecting', 'connecting': 'connecting',
'connected': 'connected', 'connected': 'connected',
'disconnected': 'disconnected', 'disconnected': 'disconnected',
'failed': 'failed', 'failed': 'failed',
'closed': 'disconnected' 'closed': 'disconnected'
} }
this.connectionState = stateMap[state] || 'idle' this.connectionState = stateMap[state] || 'idle'
this.config.onConnectionStateChange?.(this.connectionState) this.config.onConnectionStateChange?.(this.connectionState)
} }
/** /**
* 创建 Offer发起方调用 * 创建 Offer发起方调用
*/ */
async createOffer(): Promise<RTCSessionDescriptionInit> { async createOffer(): Promise<RTCSessionDescriptionInit> {
if (!this.localStream) { if (!this.localStream) {
await this.initLocalStream() await this.initLocalStream()
} }
this.peerConnection = this.createPeerConnection() this.peerConnection = this.createPeerConnection()
this.updateConnectionState('connecting') this.updateConnectionState('connecting')
// 添加本地音频轨道 // 添加本地音频轨道
this.localStream!.getTracks().forEach(track => { this.localStream!.getTracks().forEach(track => {
this.peerConnection!.addTrack(track, this.localStream!) this.peerConnection!.addTrack(track, this.localStream!)
}) })
// 创建 Offer // 创建 Offer
const offer = await this.peerConnection.createOffer() const offer = await this.peerConnection.createOffer()
await this.peerConnection.setLocalDescription(offer) await this.peerConnection.setLocalDescription(offer)
console.log('[WebRTC] Offer created') console.log('[WebRTC] Offer created')
return offer return offer
} }
/** /**
* 处理 Offer接收方调用 * 处理 Offer接收方调用
*/ */
async handleOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> { async handleOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
if (!this.localStream) { if (!this.localStream) {
await this.initLocalStream() await this.initLocalStream()
} }
this.peerConnection = this.createPeerConnection() this.peerConnection = this.createPeerConnection()
this.updateConnectionState('connecting') this.updateConnectionState('connecting')
// 添加本地音频轨道 // 添加本地音频轨道
this.localStream!.getTracks().forEach(track => { this.localStream!.getTracks().forEach(track => {
this.peerConnection!.addTrack(track, this.localStream!) this.peerConnection!.addTrack(track, this.localStream!)
}) })
// 设置远程描述 // 设置远程描述
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer)) await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer))
// 处理等待中的 ICE 候选 // 处理等待中的 ICE 候选
for (const candidate of this.pendingIceCandidates) { for (const candidate of this.pendingIceCandidates) {
await this.peerConnection.addIceCandidate(candidate) await this.peerConnection.addIceCandidate(candidate)
} }
this.pendingIceCandidates = [] this.pendingIceCandidates = []
// 创建 Answer // 创建 Answer
const answer = await this.peerConnection.createAnswer() const answer = await this.peerConnection.createAnswer()
await this.peerConnection.setLocalDescription(answer) await this.peerConnection.setLocalDescription(answer)
console.log('[WebRTC] Answer created') console.log('[WebRTC] Answer created')
return answer return answer
} }
/** /**
* 处理 Answer发起方调用 * 处理 Answer发起方调用
*/ */
async handleAnswer(answer: RTCSessionDescriptionInit): Promise<void> { async handleAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
if (!this.peerConnection) { if (!this.peerConnection) {
throw new Error('PeerConnection not initialized') throw new Error('PeerConnection not initialized')
} }
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer)) await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer))
// 处理等待中的 ICE 候选 // 处理等待中的 ICE 候选
for (const candidate of this.pendingIceCandidates) { for (const candidate of this.pendingIceCandidates) {
await this.peerConnection.addIceCandidate(candidate) await this.peerConnection.addIceCandidate(candidate)
} }
this.pendingIceCandidates = [] this.pendingIceCandidates = []
console.log('[WebRTC] Answer handled') console.log('[WebRTC] Answer handled')
} }
/** /**
* 添加 ICE 候选 * 添加 ICE 候选
*/ */
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> { async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
const iceCandidate = new RTCIceCandidate(candidate) const iceCandidate = new RTCIceCandidate(candidate)
if (this.peerConnection && this.peerConnection.remoteDescription) { if (this.peerConnection && this.peerConnection.remoteDescription) {
await this.peerConnection.addIceCandidate(iceCandidate) await this.peerConnection.addIceCandidate(iceCandidate)
console.log('[WebRTC] ICE candidate added') console.log('[WebRTC] ICE candidate added')
} else { } else {
// 如果远程描述还没设置,先缓存候选 // 如果远程描述还没设置,先缓存候选
this.pendingIceCandidates.push(iceCandidate) this.pendingIceCandidates.push(iceCandidate)
console.log('[WebRTC] ICE candidate queued') console.log('[WebRTC] ICE candidate queued')
} }
} }
/** /**
* 静音/取消静音本地音频 * 静音/取消静音本地音频
*/ */
setMuted(muted: boolean): void { setMuted(muted: boolean): void {
if (this.localStream) { if (this.localStream) {
this.localStream.getAudioTracks().forEach(track => { this.localStream.getAudioTracks().forEach(track => {
track.enabled = !muted track.enabled = !muted
}) })
} }
} }
/** /**
* 检查是否静音 * 检查是否静音
*/ */
isMuted(): boolean { isMuted(): boolean {
if (this.localStream) { if (this.localStream) {
const audioTrack = this.localStream.getAudioTracks()[0] const audioTrack = this.localStream.getAudioTracks()[0]
return audioTrack ? !audioTrack.enabled : true return audioTrack ? !audioTrack.enabled : true
} }
return true return true
} }
/** /**
* 获取音频音量级别(用于音量指示器) * 获取音频音量级别(用于音量指示器)
*/ */
async getAudioLevel(stream: MediaStream): Promise<number> { async getAudioLevel(stream: MediaStream): Promise<number> {
return new Promise((resolve) => { return new Promise((resolve) => {
const audioContext = new AudioContext() const audioContext = new AudioContext()
const analyser = audioContext.createAnalyser() const analyser = audioContext.createAnalyser()
const source = audioContext.createMediaStreamSource(stream) const source = audioContext.createMediaStreamSource(stream)
source.connect(analyser) source.connect(analyser)
analyser.fftSize = 256 analyser.fftSize = 256
const dataArray = new Uint8Array(analyser.frequencyBinCount) const dataArray = new Uint8Array(analyser.frequencyBinCount)
analyser.getByteFrequencyData(dataArray) analyser.getByteFrequencyData(dataArray)
// 计算平均音量 // 计算平均音量
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length
audioContext.close() audioContext.close()
resolve(average / 255) // 归一化到 0-1 resolve(average / 255) // 归一化到 0-1
}) })
} }
/** /**
* 关闭连接 * 关闭连接
*/ */
close(): void { close(): void {
console.log('[WebRTC] Closing connection') console.log('[WebRTC] Closing connection')
// 停止本地流 // 停止本地流
if (this.localStream) { if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop()) this.localStream.getTracks().forEach(track => track.stop())
this.localStream = null this.localStream = null
} }
// 停止远程流 // 停止远程流
if (this.remoteStream) { if (this.remoteStream) {
this.remoteStream.getTracks().forEach(track => track.stop()) this.remoteStream.getTracks().forEach(track => track.stop())
this.remoteStream = null this.remoteStream = null
} }
// 关闭 PeerConnection // 关闭 PeerConnection
if (this.peerConnection) { if (this.peerConnection) {
this.peerConnection.close() this.peerConnection.close()
this.peerConnection = null this.peerConnection = null
} }
this.pendingIceCandidates = [] this.pendingIceCandidates = []
this.updateConnectionState('disconnected') this.updateConnectionState('disconnected')
} }
/** /**
* 重置管理器 * 重置管理器
*/ */
reset(): void { reset(): void {
this.close() this.close()
this.connectionState = 'idle' this.connectionState = 'idle'
} }
} }
// 导出单例工厂函数 // 导出单例工厂函数
export function createWebRTCManager(config?: WebRTCConfig): WebRTCManager { export function createWebRTCManager(config?: WebRTCConfig): WebRTCManager {
return new WebRTCManager(config) return new WebRTCManager(config)
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,251 +1,251 @@
<template> <template>
<div class="system-settings-container"> <div class="system-settings-container">
<el-card shadow="hover" class="settings-card"> <el-card shadow="hover" class="settings-card">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>系统设置</span> <span>系统设置</span>
</div> </div>
</template> </template>
<el-tabs v-model="activeTab" type="border-card"> <el-tabs v-model="activeTab" type="border-card">
<!-- 钉钉配置 --> <!-- 钉钉配置 -->
<el-tab-pane label="钉钉免密登录" name="dingtalk"> <el-tab-pane label="钉钉免密登录" name="dingtalk">
<div class="tab-content"> <div class="tab-content">
<el-alert <el-alert
title="钉钉免密登录配置说明" title="钉钉免密登录配置说明"
type="info" type="info"
:closable="false" :closable="false"
show-icon show-icon
style="margin-bottom: 20px;" style="margin-bottom: 20px;"
> >
<template #default> <template #default>
<p>配置后员工可以通过钉钉客户端直接登录系统无需输入用户名密码</p> <p>配置后员工可以通过钉钉客户端直接登录系统无需输入用户名密码</p>
<p style="margin-top: 8px;"> <p style="margin-top: 8px;">
<a href="https://open-dev.dingtalk.com" target="_blank" class="link"> <a href="https://open-dev.dingtalk.com" target="_blank" class="link">
前往钉钉开放平台获取配置 前往钉钉开放平台获取配置
</a> </a>
</p> </p>
</template> </template>
</el-alert> </el-alert>
<el-form <el-form
ref="dingtalkFormRef" ref="dingtalkFormRef"
:model="dingtalkForm" :model="dingtalkForm"
:rules="dingtalkRules" :rules="dingtalkRules"
label-width="140px" label-width="140px"
v-loading="loading" v-loading="loading"
> >
<el-form-item label="启用钉钉登录"> <el-form-item label="启用钉钉登录">
<el-switch <el-switch
v-model="dingtalkForm.enabled" v-model="dingtalkForm.enabled"
active-text="已启用" active-text="已启用"
inactive-text="已禁用" inactive-text="已禁用"
/> />
</el-form-item> </el-form-item>
<el-divider content-position="left">钉钉应用配置</el-divider> <el-divider content-position="left">钉钉应用配置</el-divider>
<el-form-item label="AppKey" prop="app_key"> <el-form-item label="AppKey" prop="app_key">
<el-input <el-input
v-model="dingtalkForm.app_key" v-model="dingtalkForm.app_key"
placeholder="请输入钉钉应用的AppKey" placeholder="请输入钉钉应用的AppKey"
style="width: 400px;" style="width: 400px;"
/> />
</el-form-item> </el-form-item>
<el-form-item label="AppSecret" prop="app_secret"> <el-form-item label="AppSecret" prop="app_secret">
<el-input <el-input
v-model="dingtalkForm.app_secret" v-model="dingtalkForm.app_secret"
type="password" type="password"
show-password show-password
:placeholder="dingtalkForm.app_secret_masked || '请输入钉钉应用的AppSecret'" :placeholder="dingtalkForm.app_secret_masked || '请输入钉钉应用的AppSecret'"
style="width: 400px;" style="width: 400px;"
/> />
<span class="form-tip" v-if="dingtalkForm.app_secret_masked && !dingtalkForm.app_secret"> <span class="form-tip" v-if="dingtalkForm.app_secret_masked && !dingtalkForm.app_secret">
当前值: {{ dingtalkForm.app_secret_masked }}如需修改请重新输入 当前值: {{ dingtalkForm.app_secret_masked }}如需修改请重新输入
</span> </span>
</el-form-item> </el-form-item>
<el-form-item label="AgentId" prop="agent_id"> <el-form-item label="AgentId" prop="agent_id">
<el-input <el-input
v-model="dingtalkForm.agent_id" v-model="dingtalkForm.agent_id"
placeholder="请输入钉钉应用的AgentId" placeholder="请输入钉钉应用的AgentId"
style="width: 400px;" style="width: 400px;"
/> />
</el-form-item> </el-form-item>
<el-form-item label="CorpId" prop="corp_id"> <el-form-item label="CorpId" prop="corp_id">
<el-input <el-input
v-model="dingtalkForm.corp_id" v-model="dingtalkForm.corp_id"
placeholder="请输入钉钉企业的CorpId" placeholder="请输入钉钉企业的CorpId"
style="width: 400px;" style="width: 400px;"
/> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="saveDingtalkConfig" :loading="saving"> <el-button type="primary" @click="saveDingtalkConfig" :loading="saving">
保存配置 保存配置
</el-button> </el-button>
<el-button @click="loadDingtalkConfig">重置</el-button> <el-button @click="loadDingtalkConfig">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</div> </div>
</el-tab-pane> </el-tab-pane>
<!-- 其他设置预留 --> <!-- 其他设置预留 -->
<el-tab-pane label="其他设置" name="other" disabled> <el-tab-pane label="其他设置" name="other" disabled>
<div class="tab-content"> <div class="tab-content">
<el-empty description="暂无其他设置项" /> <el-empty description="暂无其他设置项" />
</div> </div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
</el-card> </el-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import { request } from '@/api/request' import { request } from '@/api/request'
const activeTab = ref('dingtalk') const activeTab = ref('dingtalk')
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const dingtalkFormRef = ref<FormInstance>() const dingtalkFormRef = ref<FormInstance>()
// 钉钉配置表单 // 钉钉配置表单
const dingtalkForm = reactive({ const dingtalkForm = reactive({
enabled: false, enabled: false,
app_key: '', app_key: '',
app_secret: '', app_secret: '',
app_secret_masked: '', // 用于显示脱敏后的值 app_secret_masked: '', // 用于显示脱敏后的值
agent_id: '', agent_id: '',
corp_id: '', corp_id: '',
}) })
// 表单验证规则 // 表单验证规则
const dingtalkRules = reactive<FormRules>({ const dingtalkRules = reactive<FormRules>({
app_key: [ app_key: [
{ required: false, message: '请输入AppKey', trigger: 'blur' } { required: false, message: '请输入AppKey', trigger: 'blur' }
], ],
agent_id: [ agent_id: [
{ required: false, message: '请输入AgentId', trigger: 'blur' } { required: false, message: '请输入AgentId', trigger: 'blur' }
], ],
corp_id: [ corp_id: [
{ required: false, message: '请输入CorpId', trigger: 'blur' } { required: false, message: '请输入CorpId', trigger: 'blur' }
] ]
}) })
/** /**
* 加载钉钉配置 * 加载钉钉配置
*/ */
const loadDingtalkConfig = async () => { const loadDingtalkConfig = async () => {
loading.value = true loading.value = true
try { try {
const response = await request.get('/api/v1/settings/dingtalk') const response = await request.get('/api/v1/settings/dingtalk')
if (response.code === 200 && response.data) { if (response.code === 200 && response.data) {
dingtalkForm.enabled = response.data.enabled || false dingtalkForm.enabled = response.data.enabled || false
dingtalkForm.app_key = response.data.app_key || '' dingtalkForm.app_key = response.data.app_key || ''
dingtalkForm.app_secret = '' // 不回显密钥 dingtalkForm.app_secret = '' // 不回显密钥
dingtalkForm.app_secret_masked = response.data.app_secret_masked || '' dingtalkForm.app_secret_masked = response.data.app_secret_masked || ''
dingtalkForm.agent_id = response.data.agent_id || '' dingtalkForm.agent_id = response.data.agent_id || ''
dingtalkForm.corp_id = response.data.corp_id || '' dingtalkForm.corp_id = response.data.corp_id || ''
} }
} catch (error: any) { } catch (error: any) {
console.error('加载钉钉配置失败:', error) console.error('加载钉钉配置失败:', error)
ElMessage.error('加载配置失败') ElMessage.error('加载配置失败')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
/** /**
* 保存钉钉配置 * 保存钉钉配置
*/ */
const saveDingtalkConfig = async () => { const saveDingtalkConfig = async () => {
if (!dingtalkFormRef.value) return if (!dingtalkFormRef.value) return
await dingtalkFormRef.value.validate(async (valid) => { await dingtalkFormRef.value.validate(async (valid) => {
if (valid) { if (valid) {
saving.value = true saving.value = true
try { try {
// 构建更新数据,只发送有值的字段 // 构建更新数据,只发送有值的字段
const updateData: any = { const updateData: any = {
enabled: dingtalkForm.enabled, enabled: dingtalkForm.enabled,
} }
if (dingtalkForm.app_key) { if (dingtalkForm.app_key) {
updateData.app_key = dingtalkForm.app_key updateData.app_key = dingtalkForm.app_key
} }
if (dingtalkForm.app_secret) { if (dingtalkForm.app_secret) {
updateData.app_secret = dingtalkForm.app_secret updateData.app_secret = dingtalkForm.app_secret
} }
if (dingtalkForm.agent_id) { if (dingtalkForm.agent_id) {
updateData.agent_id = dingtalkForm.agent_id updateData.agent_id = dingtalkForm.agent_id
} }
if (dingtalkForm.corp_id) { if (dingtalkForm.corp_id) {
updateData.corp_id = dingtalkForm.corp_id updateData.corp_id = dingtalkForm.corp_id
} }
const response = await request.put('/api/v1/settings/dingtalk', updateData) const response = await request.put('/api/v1/settings/dingtalk', updateData)
if (response.code === 200) { if (response.code === 200) {
ElMessage.success('配置保存成功') ElMessage.success('配置保存成功')
// 重新加载配置 // 重新加载配置
await loadDingtalkConfig() await loadDingtalkConfig()
} else { } else {
ElMessage.error(response.message || '保存失败') ElMessage.error(response.message || '保存失败')
} }
} catch (error: any) { } catch (error: any) {
console.error('保存钉钉配置失败:', error) console.error('保存钉钉配置失败:', error)
ElMessage.error('保存配置失败') ElMessage.error('保存配置失败')
} finally { } finally {
saving.value = false saving.value = false
} }
} }
}) })
} }
// 页面加载时获取配置 // 页面加载时获取配置
onMounted(() => { onMounted(() => {
loadDingtalkConfig() loadDingtalkConfig()
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.system-settings-container { .system-settings-container {
padding: 20px; padding: 20px;
.settings-card { .settings-card {
.card-header { .card-header {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
} }
} }
.tab-content { .tab-content {
padding: 20px; padding: 20px;
min-height: 400px; min-height: 400px;
} }
.form-tip { .form-tip {
font-size: 12px; font-size: 12px;
color: #909399; color: #909399;
margin-left: 12px; margin-left: 12px;
} }
.link { .link {
color: #409eff; color: #409eff;
text-decoration: none; text-decoration: none;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
} }
:deep(.el-divider__text) { :deep(.el-divider__text) {
font-size: 14px; font-size: 14px;
color: #606266; color: #606266;
} }
} }
</style> </style>

View File

@@ -183,6 +183,11 @@ import {
type CozeSession, type CozeSession,
type StreamEvent type StreamEvent
} from '@/api/coze' } from '@/api/coze'
import {
SpeechRecognitionManager,
isSpeechRecognitionSupported,
type SpeechRecognitionResult
} from '@/utils/speechRecognition'
const router = useRouter() const router = useRouter()
@@ -205,6 +210,11 @@ const voiceStatusText = ref('点击开始按钮进行语音陪练')
const mediaRecorder = ref<MediaRecorder | null>(null) const mediaRecorder = ref<MediaRecorder | null>(null)
const audioChunks = ref<Blob[]>([]) const audioChunks = ref<Blob[]>([])
// 语音识别相关
const speechRecognition = ref<SpeechRecognitionManager | null>(null)
const recognizedText = ref('')
const isSpeechSupported = isSpeechRecognitionSupported()
// DOM引用 // DOM引用
const messageContainer = ref<HTMLElement>() const messageContainer = ref<HTMLElement>()
@@ -380,9 +390,21 @@ const toggleRecording = async () => {
} }
/** /**
* 开始录音 * 开始录音(同时启动语音识别)
*/ */
const startRecording = async () => { const startRecording = async () => {
if (!cozeSession.value) {
ElMessage.warning('请先开始陪练会话')
return
}
// 优先使用 Web Speech API 进行实时语音识别
if (isSpeechSupported) {
startSpeechRecognition()
return
}
// 降级到录音模式(需要后端语音识别服务)
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
@@ -400,7 +422,7 @@ const startRecording = async () => {
mediaRecorder.value.start() mediaRecorder.value.start()
isRecording.value = true isRecording.value = true
voiceStatusText.value = '正在录音...' voiceStatusText.value = '正在录音(浏览器不支持实时识别,录音结束后将发送到服务器识别)...'
} catch (error) { } catch (error) {
ElMessage.error('无法访问麦克风') ElMessage.error('无法访问麦克风')
} }
@@ -410,6 +432,13 @@ const startRecording = async () => {
* 停止录音 * 停止录音
*/ */
const stopRecording = () => { const stopRecording = () => {
// 如果使用的是 Web Speech API
if (speechRecognition.value) {
stopSpeechRecognition()
return
}
// 如果使用的是录音模式
if (mediaRecorder.value && isRecording.value) { if (mediaRecorder.value && isRecording.value) {
mediaRecorder.value.stop() mediaRecorder.value.stop()
mediaRecorder.value.stream.getTracks().forEach(track => track.stop()) mediaRecorder.value.stream.getTracks().forEach(track => track.stop())
@@ -420,13 +449,116 @@ const stopRecording = () => {
} }
/** /**
* 处理录音 * 处理录音(使用 Web Speech API 已识别的文本)
*/ */
const processAudio = async (_audioBlob: Blob) => { const processAudio = async (_audioBlob: Blob) => {
// TODO: 实现音频转文本和发送逻辑 try {
isProcessing.value = false // 检查是否有识别结果
voiceStatusText.value = '点击开始按钮进行语音陪练' const text = recognizedText.value.trim()
ElMessage.info('语音功能正在开发中') if (!text) {
ElMessage.warning('没有检测到语音内容')
return
}
// 清空识别结果
recognizedText.value = ''
// 发送识别的文本消息
if (cozeSession.value) {
// 添加用户消息
messages.value.push({
role: 'user',
content: text,
timestamp: new Date()
})
await scrollToBottom()
isLoading.value = true
// 创建AI回复消息占位
const assistantMessage = {
role: 'assistant',
content: '',
timestamp: new Date()
}
messages.value.push(assistantMessage)
// 流式发送消息
await sendCozeMessage(
cozeSession.value.sessionId,
text,
(event: StreamEvent) => {
if (event.type === 'message.delta') {
assistantMessage.content += event.content
scrollToBottom()
} else if (event.type === 'message.completed') {
isLoading.value = false
}
}
)
}
} catch (error: any) {
ElMessage.error('发送消息失败:' + (error.message || '未知错误'))
} finally {
isProcessing.value = false
voiceStatusText.value = '点击开始按钮进行语音陪练'
}
}
/**
* 开始语音识别
*/
const startSpeechRecognition = () => {
if (!isSpeechSupported) {
ElMessage.warning('您的浏览器不支持语音识别,请使用 Chrome 或 Edge 浏览器')
return
}
// 创建语音识别管理器
speechRecognition.value = new SpeechRecognitionManager({
continuous: true,
interimResults: true,
lang: 'zh-CN'
})
speechRecognition.value.setCallbacks({
onResult: (result: SpeechRecognitionResult) => {
recognizedText.value = result.transcript
voiceStatusText.value = result.isFinal
? `识别结果: ${result.transcript}`
: `正在识别: ${result.transcript}`
},
onError: (error: string) => {
ElMessage.error(error)
stopSpeechRecognition()
},
onStart: () => {
isRecording.value = true
voiceStatusText.value = '正在监听,请说话...'
},
onEnd: () => {
// 识别结束后自动处理
if (recognizedText.value.trim()) {
processAudio(new Blob())
} else {
isRecording.value = false
voiceStatusText.value = '点击开始按钮进行语音陪练'
}
}
})
speechRecognition.value.start()
}
/**
* 停止语音识别
*/
const stopSpeechRecognition = () => {
if (speechRecognition.value) {
speechRecognition.value.stop()
speechRecognition.value = null
}
isRecording.value = false
} }
/** /**
@@ -456,6 +588,10 @@ onUnmounted(() => {
if (mediaRecorder.value && isRecording.value) { if (mediaRecorder.value && isRecording.value) {
stopRecording() stopRecording()
} }
if (speechRecognition.value) {
speechRecognition.value.destroy()
speechRecognition.value = null
}
}) })
</script> </script>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,401 +1,401 @@
<template> <template>
<div class="duo-practice-page"> <div class="duo-practice-page">
<div class="page-header"> <div class="page-header">
<h1>双人对练</h1> <h1>双人对练</h1>
<p class="subtitle">与伙伴一起进行角色扮演对练提升实战能力</p> <p class="subtitle">与伙伴一起进行角色扮演对练提升实战能力</p>
</div> </div>
<div class="main-content"> <div class="main-content">
<!-- 创建/加入房间卡片 --> <!-- 创建/加入房间卡片 -->
<div class="action-cards"> <div class="action-cards">
<!-- 创建房间 --> <!-- 创建房间 -->
<div class="action-card create-card" @click="showCreateDialog = true"> <div class="action-card create-card" @click="showCreateDialog = true">
<div class="card-icon"> <div class="card-icon">
<el-icon :size="48"><Plus /></el-icon> <el-icon :size="48"><Plus /></el-icon>
</div> </div>
<h3>创建房间</h3> <h3>创建房间</h3>
<p>创建对练房间邀请伙伴加入</p> <p>创建对练房间邀请伙伴加入</p>
</div> </div>
<!-- 加入房间 --> <!-- 加入房间 -->
<div class="action-card join-card" @click="showJoinDialog = true"> <div class="action-card join-card" @click="showJoinDialog = true">
<div class="card-icon"> <div class="card-icon">
<el-icon :size="48"><Connection /></el-icon> <el-icon :size="48"><Connection /></el-icon>
</div> </div>
<h3>加入房间</h3> <h3>加入房间</h3>
<p>输入房间码加入对练</p> <p>输入房间码加入对练</p>
</div> </div>
</div> </div>
<!-- 我的房间列表 --> <!-- 我的房间列表 -->
<div class="my-rooms" v-if="myRooms.length > 0"> <div class="my-rooms" v-if="myRooms.length > 0">
<h2>我的对练记录</h2> <h2>我的对练记录</h2>
<div class="room-list"> <div class="room-list">
<div <div
v-for="room in myRooms" v-for="room in myRooms"
:key="room.id" :key="room.id"
class="room-item" class="room-item"
@click="enterRoom(room)" @click="enterRoom(room)"
> >
<div class="room-info"> <div class="room-info">
<span class="room-name">{{ room.room_name || room.scene_name || '双人对练' }}</span> <span class="room-name">{{ room.room_name || room.scene_name || '双人对练' }}</span>
<span class="room-code">{{ room.room_code }}</span> <span class="room-code">{{ room.room_code }}</span>
</div> </div>
<div class="room-meta"> <div class="room-meta">
<el-tag :type="getStatusType(room.status)" size="small"> <el-tag :type="getStatusType(room.status)" size="small">
{{ getStatusText(room.status) }} {{ getStatusText(room.status) }}
</el-tag> </el-tag>
<span class="room-time">{{ formatTime(room.created_at) }}</span> <span class="room-time">{{ formatTime(room.created_at) }}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- 创建房间对话框 --> <!-- 创建房间对话框 -->
<el-dialog <el-dialog
v-model="showCreateDialog" v-model="showCreateDialog"
title="创建对练房间" title="创建对练房间"
width="500px" width="500px"
:close-on-click-modal="false" :close-on-click-modal="false"
> >
<el-form :model="createForm" label-width="100px"> <el-form :model="createForm" label-width="100px">
<el-form-item label="场景名称"> <el-form-item label="场景名称">
<el-input v-model="createForm.scene_name" placeholder="如:销售场景对练" /> <el-input v-model="createForm.scene_name" placeholder="如:销售场景对练" />
</el-form-item> </el-form-item>
<el-form-item label="角色A名称"> <el-form-item label="角色A名称">
<el-input v-model="createForm.role_a_name" placeholder="如:销售顾问" /> <el-input v-model="createForm.role_a_name" placeholder="如:销售顾问" />
</el-form-item> </el-form-item>
<el-form-item label="角色B名称"> <el-form-item label="角色B名称">
<el-input v-model="createForm.role_b_name" placeholder="如:顾客" /> <el-input v-model="createForm.role_b_name" placeholder="如:顾客" />
</el-form-item> </el-form-item>
<el-form-item label="我扮演"> <el-form-item label="我扮演">
<el-radio-group v-model="createForm.host_role"> <el-radio-group v-model="createForm.host_role">
<el-radio label="A">{{ createForm.role_a_name || '角色A' }}</el-radio> <el-radio label="A">{{ createForm.role_a_name || '角色A' }}</el-radio>
<el-radio label="B">{{ createForm.role_b_name || '角色B' }}</el-radio> <el-radio label="B">{{ createForm.role_b_name || '角色B' }}</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="场景背景"> <el-form-item label="场景背景">
<el-input <el-input
v-model="createForm.scene_background" v-model="createForm.scene_background"
type="textarea" type="textarea"
:rows="3" :rows="3"
placeholder="描述对练场景的背景信息(可选)" placeholder="描述对练场景的背景信息(可选)"
/> />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="showCreateDialog = false">取消</el-button> <el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="handleCreateRoom" :loading="isCreating"> <el-button type="primary" @click="handleCreateRoom" :loading="isCreating">
创建房间 创建房间
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 加入房间对话框 --> <!-- 加入房间对话框 -->
<el-dialog <el-dialog
v-model="showJoinDialog" v-model="showJoinDialog"
title="加入对练房间" title="加入对练房间"
width="400px" width="400px"
:close-on-click-modal="false" :close-on-click-modal="false"
> >
<el-form> <el-form>
<el-form-item label="房间码"> <el-form-item label="房间码">
<el-input <el-input
v-model="joinRoomCode" v-model="joinRoomCode"
placeholder="请输入6位房间码" placeholder="请输入6位房间码"
maxlength="6" maxlength="6"
style="font-size: 24px; text-align: center; letter-spacing: 8px;" style="font-size: 24px; text-align: center; letter-spacing: 8px;"
@keyup.enter="handleJoinRoom" @keyup.enter="handleJoinRoom"
/> />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="showJoinDialog = false">取消</el-button> <el-button @click="showJoinDialog = false">取消</el-button>
<el-button type="primary" @click="handleJoinRoom" :loading="isJoining"> <el-button type="primary" @click="handleJoinRoom" :loading="isJoining">
加入房间 加入房间
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Plus, Connection } from '@element-plus/icons-vue' import { Plus, Connection } from '@element-plus/icons-vue'
import { useDuoPracticeStore } from '@/stores/duoPracticeStore' import { useDuoPracticeStore } from '@/stores/duoPracticeStore'
import { getMyRooms, type RoomListItem } from '@/api/duoPractice' import { getMyRooms, type RoomListItem } from '@/api/duoPractice'
const router = useRouter() const router = useRouter()
const store = useDuoPracticeStore() const store = useDuoPracticeStore()
// 状态 // 状态
const showCreateDialog = ref(false) const showCreateDialog = ref(false)
const showJoinDialog = ref(false) const showJoinDialog = ref(false)
const isCreating = ref(false) const isCreating = ref(false)
const isJoining = ref(false) const isJoining = ref(false)
const joinRoomCode = ref('') const joinRoomCode = ref('')
const myRooms = ref<RoomListItem[]>([]) const myRooms = ref<RoomListItem[]>([])
// 创建表单 // 创建表单
const createForm = ref({ const createForm = ref({
scene_name: '', scene_name: '',
role_a_name: '销售顾问', role_a_name: '销售顾问',
role_b_name: '顾客', role_b_name: '顾客',
host_role: 'A' as 'A' | 'B', host_role: 'A' as 'A' | 'B',
scene_background: '' scene_background: ''
}) })
// 加载我的房间列表 // 加载我的房间列表
const loadMyRooms = async () => { const loadMyRooms = async () => {
try { try {
const res: any = await getMyRooms() const res: any = await getMyRooms()
if (res.code === 200) { if (res.code === 200) {
myRooms.value = res.data.rooms myRooms.value = res.data.rooms
} }
} catch (error) { } catch (error) {
console.error('加载房间列表失败:', error) console.error('加载房间列表失败:', error)
} }
} }
// 创建房间 // 创建房间
const handleCreateRoom = async () => { const handleCreateRoom = async () => {
if (!createForm.value.scene_name) { if (!createForm.value.scene_name) {
ElMessage.warning('请输入场景名称') ElMessage.warning('请输入场景名称')
return return
} }
isCreating.value = true isCreating.value = true
try { try {
await store.createRoom(createForm.value) await store.createRoom(createForm.value)
showCreateDialog.value = false showCreateDialog.value = false
// 跳转到房间页面 // 跳转到房间页面
router.push(`/trainee/duo-practice/room/${store.roomCode}`) router.push(`/trainee/duo-practice/room/${store.roomCode}`)
} catch (error) { } catch (error) {
// 错误已在 store 中处理 // 错误已在 store 中处理
} finally { } finally {
isCreating.value = false isCreating.value = false
} }
} }
// 加入房间 // 加入房间
const handleJoinRoom = async () => { const handleJoinRoom = async () => {
if (!joinRoomCode.value || joinRoomCode.value.length < 6) { if (!joinRoomCode.value || joinRoomCode.value.length < 6) {
ElMessage.warning('请输入6位房间码') ElMessage.warning('请输入6位房间码')
return return
} }
isJoining.value = true isJoining.value = true
try { try {
await store.joinRoom(joinRoomCode.value) await store.joinRoom(joinRoomCode.value)
showJoinDialog.value = false showJoinDialog.value = false
// 跳转到房间页面 // 跳转到房间页面
router.push(`/trainee/duo-practice/room/${store.roomCode}`) router.push(`/trainee/duo-practice/room/${store.roomCode}`)
} catch (error) { } catch (error) {
// 错误已在 store 中处理 // 错误已在 store 中处理
} finally { } finally {
isJoining.value = false isJoining.value = false
} }
} }
// 进入房间 // 进入房间
const enterRoom = (room: RoomListItem) => { const enterRoom = (room: RoomListItem) => {
router.push(`/trainee/duo-practice/room/${room.room_code}`) router.push(`/trainee/duo-practice/room/${room.room_code}`)
} }
// 获取状态标签类型 // 获取状态标签类型
const getStatusType = (status: string) => { const getStatusType = (status: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
'waiting': 'warning', 'waiting': 'warning',
'ready': 'info', 'ready': 'info',
'practicing': 'success', 'practicing': 'success',
'completed': '', 'completed': '',
'canceled': 'danger' 'canceled': 'danger'
} }
return map[status] || '' return map[status] || ''
} }
// 获取状态文本 // 获取状态文本
const getStatusText = (status: string) => { const getStatusText = (status: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
'waiting': '等待加入', 'waiting': '等待加入',
'ready': '准备就绪', 'ready': '准备就绪',
'practicing': '对练中', 'practicing': '对练中',
'completed': '已完成', 'completed': '已完成',
'canceled': '已取消' 'canceled': '已取消'
} }
return map[status] || status return map[status] || status
} }
// 格式化时间 // 格式化时间
const formatTime = (time?: string) => { const formatTime = (time?: string) => {
if (!time) return '' if (!time) return ''
const date = new Date(time) const date = new Date(time)
return date.toLocaleString('zh-CN', { return date.toLocaleString('zh-CN', {
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
}) })
} }
onMounted(() => { onMounted(() => {
loadMyRooms() loadMyRooms()
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.duo-practice-page { .duo-practice-page {
padding: 24px; padding: 24px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
.page-header { .page-header {
text-align: center; text-align: center;
margin-bottom: 40px; margin-bottom: 40px;
h1 { h1 {
font-size: 28px; font-size: 28px;
font-weight: 600; font-weight: 600;
margin-bottom: 8px; margin-bottom: 8px;
} }
.subtitle { .subtitle {
color: #666; color: #666;
font-size: 16px; font-size: 16px;
} }
} }
.action-cards { .action-cards {
display: flex; display: flex;
gap: 24px; gap: 24px;
justify-content: center; justify-content: center;
margin-bottom: 48px; margin-bottom: 48px;
.action-card { .action-card {
width: 280px; width: 280px;
padding: 40px 24px; padding: 40px 24px;
background: #fff; background: #fff;
border-radius: 16px; border-radius: 16px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
border: 2px solid transparent; border: 2px solid transparent;
&:hover { &:hover {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1); box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
} }
&.create-card { &.create-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff; color: #fff;
.card-icon { .card-icon {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
} }
p { p {
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
} }
} }
&.join-card { &.join-card {
border-color: #667eea; border-color: #667eea;
.card-icon { .card-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff; color: #fff;
} }
&:hover { &:hover {
border-color: #764ba2; border-color: #764ba2;
} }
} }
.card-icon { .card-icon {
width: 80px; width: 80px;
height: 80px; height: 80px;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: 0 auto 20px; margin: 0 auto 20px;
} }
h3 { h3 {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
margin-bottom: 8px; margin-bottom: 8px;
} }
p { p {
font-size: 14px; font-size: 14px;
color: #666; color: #666;
} }
} }
} }
.my-rooms { .my-rooms {
h2 { h2 {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
margin-bottom: 16px; margin-bottom: 16px;
} }
.room-list { .room-list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px; gap: 16px;
} }
.room-item { .room-item {
background: #fff; background: #fff;
border-radius: 12px; border-radius: 12px;
padding: 16px; padding: 16px;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
&:hover { &:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
.room-info { .room-info {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 8px; margin-bottom: 8px;
.room-name { .room-name {
font-weight: 500; font-weight: 500;
font-size: 16px; font-size: 16px;
} }
.room-code { .room-code {
font-family: monospace; font-family: monospace;
font-size: 14px; font-size: 14px;
color: #667eea; color: #667eea;
background: #f0f2ff; background: #f0f2ff;
padding: 2px 8px; padding: 2px 8px;
border-radius: 4px; border-radius: 4px;
} }
} }
.room-meta { .room-meta {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
.room-time { .room-time {
font-size: 12px; font-size: 12px;
color: #999; color: #999;
} }
} }
} }
} }
</style> </style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff