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

View File

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

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.models.user import User
from app.models.course import Course, CourseStatus
from app.models.user_course_progress import UserCourseProgress, ProgressStatus
from app.schemas.base import ResponseModel
router = APIRouter(prefix="/admin")
@@ -61,18 +62,32 @@ async def get_dashboard_stats(
.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
avg_score = 0.0
pass_rate = "0%"
# 学习时长统计(如果有学习记录表的话)
total_learning_hours = 0
avg_learning_hours = 0.0
active_rate = "0%"
# 学习时长统计 - 从用户课程进度表获取
total_study_seconds = await db.scalar(
select(func.coalesce(func.sum(UserCourseProgress.total_study_time), 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 = {
@@ -195,10 +210,28 @@ async def get_course_completion_data(
for course_name, course_id in courses:
course_names.append(course_name)
# TODO: 根据用户课程进度表计算完成率
# 这里暂时生成模拟数据
import random
completion_rate = random.randint(60, 95)
# 根据用户课程进度表计算完成率
# 统计该课程的完成用户数和总学习用户数
stats_result = await db.execute(
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)
return ResponseModel(

View File

@@ -1,304 +1,329 @@
"""
证书管理 API 端点
提供证书相关的 RESTful API
- 获取证书列表
- 获取证书详情
- 下载证书
- 验证证书
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Response, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
import io
from app.core.deps import get_db, get_current_user
from app.models.user import User
from app.services.certificate_service import CertificateService
router = APIRouter()
@router.get("/templates")
async def get_certificate_templates(
cert_type: Optional[str] = Query(None, description="证书类型: course/exam/achievement"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取证书模板列表"""
service = CertificateService(db)
templates = await service.get_templates(cert_type)
return {
"code": 200,
"message": "success",
"data": templates
}
@router.get("/me")
async def get_my_certificates(
cert_type: Optional[str] = Query(None, description="证书类型过滤"),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取当前用户的证书列表"""
service = CertificateService(db)
result = await service.get_user_certificates(
user_id=current_user.id,
cert_type=cert_type,
offset=offset,
limit=limit
)
return {
"code": 200,
"message": "success",
"data": result
}
@router.get("/user/{user_id}")
async def get_user_certificates(
user_id: int,
cert_type: Optional[str] = Query(None),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取指定用户的证书列表(需要管理员权限)"""
# 只允许查看自己的证书或管理员查看
if current_user.id != user_id and current_user.role not in ["admin", "enterprise_admin"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权查看其他用户的证书"
)
service = CertificateService(db)
result = await service.get_user_certificates(
user_id=user_id,
cert_type=cert_type,
offset=offset,
limit=limit
)
return {
"code": 200,
"message": "success",
"data": result
}
@router.get("/{cert_id}")
async def get_certificate_detail(
cert_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取证书详情"""
service = CertificateService(db)
cert = await service.get_certificate_by_id(cert_id)
if not cert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="证书不存在"
)
return {
"code": 200,
"message": "success",
"data": cert
}
@router.get("/{cert_id}/image")
async def get_certificate_image(
cert_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取证书分享图片"""
service = CertificateService(db)
try:
# 获取基础URL
base_url = "https://kpl.example.com/certificates" # 可从配置读取
image_bytes = await service.generate_certificate_image(cert_id, base_url)
return StreamingResponse(
io.BytesIO(image_bytes),
media_type="image/png",
headers={
"Content-Disposition": f"inline; filename=certificate_{cert_id}.png"
}
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"生成证书图片失败: {str(e)}"
)
@router.get("/{cert_id}/download")
async def download_certificate_pdf(
cert_id: int,
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:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="证书不存在"
)
# 如果已有PDF URL则重定向
if cert.get("pdf_url"):
return {
"code": 200,
"message": "success",
"data": {
"download_url": cert["pdf_url"]
}
}
# 否则返回图片作为替代
try:
base_url = "https://kpl.example.com/certificates"
image_bytes = await service.generate_certificate_image(cert_id, base_url)
return StreamingResponse(
io.BytesIO(image_bytes),
media_type="image/png",
headers={
"Content-Disposition": f"attachment; filename=certificate_{cert['certificate_no']}.png"
}
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"下载失败: {str(e)}"
)
@router.get("/verify/{cert_no}")
async def verify_certificate(
cert_no: str,
db: AsyncSession = Depends(get_db)
):
"""
验证证书真伪
此接口无需登录,可用于公开验证证书
"""
service = CertificateService(db)
cert = await service.get_certificate_by_no(cert_no)
if not cert:
return {
"code": 404,
"message": "证书不存在或编号错误",
"data": {
"valid": False,
"certificate_no": cert_no
}
}
return {
"code": 200,
"message": "证书验证通过",
"data": {
"valid": True,
"certificate_no": cert_no,
"title": cert.get("title"),
"type_name": cert.get("type_name"),
"issued_at": cert.get("issued_at"),
"user": cert.get("user", {}),
}
}
@router.post("/issue/course")
async def issue_course_certificate(
course_id: int,
course_name: str,
completion_rate: float = 100.0,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
颁发课程结业证书
通常由系统在用户完成课程时自动调用
"""
service = CertificateService(db)
try:
cert = await service.issue_course_certificate(
user_id=current_user.id,
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()
return {
"code": 200,
"message": "证书颁发成功",
"data": cert
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/issue/exam")
async def issue_exam_certificate(
exam_id: int,
exam_name: str,
score: float,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
颁发考试合格证书
通常由系统在用户考试通过时自动调用
"""
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)
)
"""
证书管理 API 端点
提供证书相关的 RESTful API
- 获取证书列表
- 获取证书详情
- 下载证书
- 验证证书
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Response, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
import io
from app.core.deps import get_db, get_current_user
from app.models.user import User
from app.services.certificate_service import CertificateService
router = APIRouter()
@router.get("/templates")
async def get_certificate_templates(
cert_type: Optional[str] = Query(None, description="证书类型: course/exam/achievement"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取证书模板列表"""
service = CertificateService(db)
templates = await service.get_templates(cert_type)
return {
"code": 200,
"message": "success",
"data": templates
}
@router.get("/me")
async def get_my_certificates(
cert_type: Optional[str] = Query(None, description="证书类型过滤"),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取当前用户的证书列表"""
service = CertificateService(db)
result = await service.get_user_certificates(
user_id=current_user.id,
cert_type=cert_type,
offset=offset,
limit=limit
)
return {
"code": 200,
"message": "success",
"data": result
}
@router.get("/user/{user_id}")
async def get_user_certificates(
user_id: int,
cert_type: Optional[str] = Query(None),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取指定用户的证书列表(需要管理员权限)"""
# 只允许查看自己的证书或管理员查看
if current_user.id != user_id and current_user.role not in ["admin", "enterprise_admin"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权查看其他用户的证书"
)
service = CertificateService(db)
result = await service.get_user_certificates(
user_id=user_id,
cert_type=cert_type,
offset=offset,
limit=limit
)
return {
"code": 200,
"message": "success",
"data": result
}
@router.get("/{cert_id}")
async def get_certificate_detail(
cert_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取证书详情"""
service = CertificateService(db)
cert = await service.get_certificate_by_id(cert_id)
if not cert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="证书不存在"
)
return {
"code": 200,
"message": "success",
"data": cert
}
@router.get("/{cert_id}/image")
async def get_certificate_image(
cert_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取证书分享图片"""
service = CertificateService(db)
try:
# 获取基础URL
base_url = "https://kpl.example.com/certificates" # 可从配置读取
image_bytes = await service.generate_certificate_image(cert_id, base_url)
return StreamingResponse(
io.BytesIO(image_bytes),
media_type="image/png",
headers={
"Content-Disposition": f"inline; filename=certificate_{cert_id}.png"
}
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"生成证书图片失败: {str(e)}"
)
@router.get("/{cert_id}/download")
async def download_certificate(
cert_id: int,
format: str = Query("pdf", description="下载格式: pdf 或 png"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
下载证书
支持 PDF 和 PNG 两种格式
- PDF: 高质量打印版本(需要安装 weasyprint
- PNG: 图片版本
"""
service = CertificateService(db)
cert = await service.get_certificate_by_id(cert_id)
if not cert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="证书不存在"
)
# 如果已有缓存的 PDF/图片 URL 则返回
if format.lower() == "pdf" and cert.get("pdf_url"):
return {
"code": 200,
"message": "success",
"data": {
"download_url": cert["pdf_url"]
}
}
if format.lower() == "png" and cert.get("image_url"):
return {
"code": 200,
"message": "success",
"data": {
"download_url": cert["image_url"]
}
}
# 动态生成证书文件
try:
from app.core.config import settings
base_url = settings.PUBLIC_DOMAIN + "/certificates"
content, filename, mime_type = await service.download_certificate(
cert_id, format, base_url
)
return StreamingResponse(
io.BytesIO(content),
media_type=mime_type,
headers={
"Content-Disposition": f"attachment; filename={filename}"
}
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"下载失败: {str(e)}"
)
@router.get("/verify/{cert_no}")
async def verify_certificate(
cert_no: str,
db: AsyncSession = Depends(get_db)
):
"""
验证证书真伪
此接口无需登录,可用于公开验证证书
"""
service = CertificateService(db)
cert = await service.get_certificate_by_no(cert_no)
if not cert:
return {
"code": 404,
"message": "证书不存在或编号错误",
"data": {
"valid": False,
"certificate_no": cert_no
}
}
return {
"code": 200,
"message": "证书验证通过",
"data": {
"valid": True,
"certificate_no": cert_no,
"title": cert.get("title"),
"type_name": cert.get("type_name"),
"issued_at": cert.get("issued_at"),
"user": cert.get("user", {}),
}
}
@router.post("/issue/course")
async def issue_course_certificate(
course_id: int,
course_name: str,
completion_rate: float = 100.0,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
颁发课程结业证书
通常由系统在用户完成课程时自动调用
"""
service = CertificateService(db)
try:
cert = await service.issue_course_certificate(
user_id=current_user.id,
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()
return {
"code": 200,
"message": "证书颁发成功",
"data": cert
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/issue/exam")
async def issue_exam_certificate(
exam_id: int,
exam_name: str,
score: float,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
颁发考试合格证书
通常由系统在用户考试通过时自动调用
"""
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 端点
提供企业级和团队级数据大屏接口
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.models.user import User
from app.services.dashboard_service import DashboardService
router = APIRouter()
@router.get("/enterprise/overview")
async def get_enterprise_overview(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取企业级数据概览
需要管理员或企业管理员权限
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_enterprise_overview()
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/departments")
async def get_department_comparison(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取部门/团队学习对比数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_department_comparison()
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/trend")
async def get_learning_trend(
days: int = Query(7, ge=1, le=30),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取学习趋势数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_learning_trend(days)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/level-distribution")
async def get_level_distribution(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取等级分布数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_level_distribution()
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/activities")
async def get_realtime_activities(
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取实时动态
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_realtime_activities(limit)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/course-ranking")
async def get_course_ranking(
limit: int = Query(10, ge=1, le=50),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取课程热度排行
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_course_ranking(limit)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/team")
async def get_team_dashboard(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取团队级数据大屏
面向团队负责人,显示其管理团队的数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要团队负责人权限"
)
service = DashboardService(db)
data = await service.get_team_dashboard(current_user.id)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/all")
async def get_all_dashboard_data(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取完整的大屏数据(一次性获取所有数据)
用于大屏初始化加载
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
# 并行获取所有数据
overview = await service.get_enterprise_overview()
departments = await service.get_department_comparison()
trend = await service.get_learning_trend(7)
level_dist = await service.get_level_distribution()
activities = await service.get_realtime_activities(20)
course_ranking = await service.get_course_ranking(10)
return {
"code": 200,
"message": "success",
"data": {
"overview": overview,
"departments": departments,
"trend": trend,
"level_distribution": level_dist,
"activities": activities,
"course_ranking": course_ranking,
}
}
"""
数据大屏 API 端点
提供企业级和团队级数据大屏接口
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.models.user import User
from app.services.dashboard_service import DashboardService
router = APIRouter()
@router.get("/enterprise/overview")
async def get_enterprise_overview(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取企业级数据概览
需要管理员或企业管理员权限
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_enterprise_overview()
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/departments")
async def get_department_comparison(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取部门/团队学习对比数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_department_comparison()
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/trend")
async def get_learning_trend(
days: int = Query(7, ge=1, le=30),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取学习趋势数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_learning_trend(days)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/level-distribution")
async def get_level_distribution(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取等级分布数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_level_distribution()
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/activities")
async def get_realtime_activities(
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取实时动态
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_realtime_activities(limit)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/course-ranking")
async def get_course_ranking(
limit: int = Query(10, ge=1, le=50),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取课程热度排行
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_course_ranking(limit)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/team")
async def get_team_dashboard(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取团队级数据大屏
面向团队负责人,显示其管理团队的数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要团队负责人权限"
)
service = DashboardService(db)
data = await service.get_team_dashboard(current_user.id)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/all")
async def get_all_dashboard_data(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取完整的大屏数据(一次性获取所有数据)
用于大屏初始化加载
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
# 并行获取所有数据
overview = await service.get_enterprise_overview()
departments = await service.get_department_comparison()
trend = await service.get_learning_trend(7)
level_dist = await service.get_level_distribution()
activities = await service.get_realtime_activities(20)
course_ranking = await service.get_course_ranking(10)
return {
"code": 200,
"message": "success",
"data": {
"overview": overview,
"departments": departments,
"trend": trend,
"level_distribution": level_dist,
"activities": activities,
"course_ranking": course_ranking,
}
}

View File

@@ -1,277 +1,277 @@
"""
等级与奖章 API
提供等级查询、奖章查询、排行榜、签到等接口
"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.schemas.base import ResponseModel
from app.services.level_service import LevelService
from app.services.badge_service import BadgeService
from app.models.user import User
router = APIRouter()
# ============================================
# 等级相关接口
# ============================================
@router.get("/me", response_model=ResponseModel)
async def get_my_level(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取当前用户等级信息
返回用户的等级、经验值、称号、连续登录天数等信息
"""
level_service = LevelService(db)
level_info = await level_service.get_user_level_info(current_user.id)
return ResponseModel(
message="获取成功",
data=level_info
)
@router.get("/user/{user_id}", response_model=ResponseModel)
async def get_user_level(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取指定用户等级信息
Args:
user_id: 用户ID
"""
level_service = LevelService(db)
level_info = await level_service.get_user_level_info(user_id)
return ResponseModel(
message="获取成功",
data=level_info
)
@router.post("/checkin", response_model=ResponseModel)
async def daily_checkin(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
每日签到
每天首次签到获得经验值,连续签到有额外奖励
"""
level_service = LevelService(db)
badge_service = BadgeService(db)
# 执行签到
checkin_result = await level_service.daily_checkin(current_user.id)
# 检查是否解锁新奖章
new_badges = []
if checkin_result["success"]:
new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit()
return ResponseModel(
message=checkin_result["message"],
data={
**checkin_result,
"new_badges": new_badges
}
)
@router.get("/exp-history", response_model=ResponseModel)
async def get_exp_history(
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
exp_type: Optional[str] = Query(default=None, description="经验值类型筛选"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取经验值变化历史
Args:
limit: 每页数量默认50最大100
offset: 偏移量
exp_type: 类型筛选exam/practice/training/task/login/badge/other
"""
level_service = LevelService(db)
history, total = await level_service.get_exp_history(
user_id=current_user.id,
limit=limit,
offset=offset,
exp_type=exp_type
)
return ResponseModel(
message="获取成功",
data={
"items": history,
"total": total,
"limit": limit,
"offset": offset
}
)
@router.get("/leaderboard", response_model=ResponseModel)
async def get_leaderboard(
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取等级排行榜
Args:
limit: 每页数量默认50最大100
offset: 偏移量
"""
level_service = LevelService(db)
# 获取排行榜
leaderboard, total = await level_service.get_leaderboard(limit=limit, offset=offset)
# 获取当前用户排名
my_rank = await level_service.get_user_rank(current_user.id)
# 获取当前用户等级信息
my_level_info = await level_service.get_user_level_info(current_user.id)
return ResponseModel(
message="获取成功",
data={
"items": leaderboard,
"total": total,
"limit": limit,
"offset": offset,
"my_rank": my_rank,
"my_level_info": my_level_info
}
)
# ============================================
# 奖章相关接口
# ============================================
@router.get("/badges/all", response_model=ResponseModel)
async def get_all_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取所有奖章定义
返回所有可获得的奖章列表
"""
badge_service = BadgeService(db)
badges = await badge_service.get_all_badges()
return ResponseModel(
message="获取成功",
data=badges
)
@router.get("/badges/me", response_model=ResponseModel)
async def get_my_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取当前用户的奖章(含解锁状态)
返回所有奖章及用户是否已解锁
"""
badge_service = BadgeService(db)
badges = await badge_service.get_user_badges_with_status(current_user.id)
# 统计已解锁数量
unlocked_count = sum(1 for b in badges if b["unlocked"])
return ResponseModel(
message="获取成功",
data={
"badges": badges,
"total": len(badges),
"unlocked_count": unlocked_count
}
)
@router.get("/badges/unnotified", response_model=ResponseModel)
async def get_unnotified_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取未通知的新奖章
用于前端显示新获得奖章的弹窗提示
"""
badge_service = BadgeService(db)
badges = await badge_service.get_unnotified_badges(current_user.id)
return ResponseModel(
message="获取成功",
data=badges
)
@router.post("/badges/mark-notified", response_model=ResponseModel)
async def mark_badges_notified(
badge_ids: Optional[list[int]] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
标记奖章为已通知
Args:
badge_ids: 要标记的奖章ID列表为空则标记全部
"""
badge_service = BadgeService(db)
await badge_service.mark_badges_notified(current_user.id, badge_ids)
await db.commit()
return ResponseModel(
message="标记成功"
)
@router.post("/check-badges", response_model=ResponseModel)
async def check_and_award_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
检查并授予符合条件的奖章
手动触发奖章检查,返回新获得的奖章
"""
badge_service = BadgeService(db)
new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit()
return ResponseModel(
message="检查完成",
data={
"new_badges": new_badges,
"count": len(new_badges)
}
)
"""
等级与奖章 API
提供等级查询、奖章查询、排行榜、签到等接口
"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.schemas.base import ResponseModel
from app.services.level_service import LevelService
from app.services.badge_service import BadgeService
from app.models.user import User
router = APIRouter()
# ============================================
# 等级相关接口
# ============================================
@router.get("/me", response_model=ResponseModel)
async def get_my_level(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取当前用户等级信息
返回用户的等级、经验值、称号、连续登录天数等信息
"""
level_service = LevelService(db)
level_info = await level_service.get_user_level_info(current_user.id)
return ResponseModel(
message="获取成功",
data=level_info
)
@router.get("/user/{user_id}", response_model=ResponseModel)
async def get_user_level(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取指定用户等级信息
Args:
user_id: 用户ID
"""
level_service = LevelService(db)
level_info = await level_service.get_user_level_info(user_id)
return ResponseModel(
message="获取成功",
data=level_info
)
@router.post("/checkin", response_model=ResponseModel)
async def daily_checkin(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
每日签到
每天首次签到获得经验值,连续签到有额外奖励
"""
level_service = LevelService(db)
badge_service = BadgeService(db)
# 执行签到
checkin_result = await level_service.daily_checkin(current_user.id)
# 检查是否解锁新奖章
new_badges = []
if checkin_result["success"]:
new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit()
return ResponseModel(
message=checkin_result["message"],
data={
**checkin_result,
"new_badges": new_badges
}
)
@router.get("/exp-history", response_model=ResponseModel)
async def get_exp_history(
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
exp_type: Optional[str] = Query(default=None, description="经验值类型筛选"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取经验值变化历史
Args:
limit: 每页数量默认50最大100
offset: 偏移量
exp_type: 类型筛选exam/practice/training/task/login/badge/other
"""
level_service = LevelService(db)
history, total = await level_service.get_exp_history(
user_id=current_user.id,
limit=limit,
offset=offset,
exp_type=exp_type
)
return ResponseModel(
message="获取成功",
data={
"items": history,
"total": total,
"limit": limit,
"offset": offset
}
)
@router.get("/leaderboard", response_model=ResponseModel)
async def get_leaderboard(
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取等级排行榜
Args:
limit: 每页数量默认50最大100
offset: 偏移量
"""
level_service = LevelService(db)
# 获取排行榜
leaderboard, total = await level_service.get_leaderboard(limit=limit, offset=offset)
# 获取当前用户排名
my_rank = await level_service.get_user_rank(current_user.id)
# 获取当前用户等级信息
my_level_info = await level_service.get_user_level_info(current_user.id)
return ResponseModel(
message="获取成功",
data={
"items": leaderboard,
"total": total,
"limit": limit,
"offset": offset,
"my_rank": my_rank,
"my_level_info": my_level_info
}
)
# ============================================
# 奖章相关接口
# ============================================
@router.get("/badges/all", response_model=ResponseModel)
async def get_all_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取所有奖章定义
返回所有可获得的奖章列表
"""
badge_service = BadgeService(db)
badges = await badge_service.get_all_badges()
return ResponseModel(
message="获取成功",
data=badges
)
@router.get("/badges/me", response_model=ResponseModel)
async def get_my_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取当前用户的奖章(含解锁状态)
返回所有奖章及用户是否已解锁
"""
badge_service = BadgeService(db)
badges = await badge_service.get_user_badges_with_status(current_user.id)
# 统计已解锁数量
unlocked_count = sum(1 for b in badges if b["unlocked"])
return ResponseModel(
message="获取成功",
data={
"badges": badges,
"total": len(badges),
"unlocked_count": unlocked_count
}
)
@router.get("/badges/unnotified", response_model=ResponseModel)
async def get_unnotified_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取未通知的新奖章
用于前端显示新获得奖章的弹窗提示
"""
badge_service = BadgeService(db)
badges = await badge_service.get_unnotified_badges(current_user.id)
return ResponseModel(
message="获取成功",
data=badges
)
@router.post("/badges/mark-notified", response_model=ResponseModel)
async def mark_badges_notified(
badge_ids: Optional[list[int]] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
标记奖章为已通知
Args:
badge_ids: 要标记的奖章ID列表为空则标记全部
"""
badge_service = BadgeService(db)
await badge_service.mark_badges_notified(current_user.id, badge_ids)
await db.commit()
return ResponseModel(
message="标记成功"
)
@router.post("/check-badges", response_model=ResponseModel)
async def check_and_award_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
检查并授予符合条件的奖章
手动触发奖章检查,返回新获得的奖章
"""
badge_service = BadgeService(db)
new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit()
return ResponseModel(
message="检查完成",
data={
"new_badges": 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
供企业管理员admin角色配置系统级别的设置如钉钉免密登录等
"""
from typing import Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from app.core.deps import get_current_active_user, get_db
from app.core.logger import logger
from app.models.user import User
from app.schemas.base import ResponseModel
router = APIRouter()
# ============================================
# Schema 定义
# ============================================
class DingtalkConfigUpdate(BaseModel):
"""钉钉配置更新请求"""
app_key: Optional[str] = Field(None, description="钉钉AppKey")
app_secret: Optional[str] = Field(None, description="钉钉AppSecret")
agent_id: Optional[str] = Field(None, description="钉钉AgentId")
corp_id: Optional[str] = Field(None, description="钉钉CorpId")
enabled: Optional[bool] = Field(None, description="是否启用钉钉免密登录")
class DingtalkConfigResponse(BaseModel):
"""钉钉配置响应"""
app_key: Optional[str] = None
app_secret_masked: Optional[str] = None # 脱敏显示
agent_id: Optional[str] = None
corp_id: Optional[str] = None
enabled: bool = False
# ============================================
# 辅助函数
# ============================================
def check_admin_permission(user: User):
"""检查是否为管理员"""
if user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
async def get_or_create_tenant_id(db: AsyncSession) -> int:
"""获取或创建默认租户ID简化版假设单租户"""
# 对于考培练系统简化处理使用固定的租户ID=1
return 1
async def get_system_config(db: AsyncSession, tenant_id: int, config_group: str, config_key: str) -> Optional[str]:
"""获取系统配置值"""
result = await db.execute(
text("""
SELECT config_value FROM tenant_configs
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}
)
row = result.fetchone()
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):
"""设置系统配置值"""
# 检查是否已存在
existing = await get_system_config(db, tenant_id, config_group, config_key)
if existing is not None:
# 更新
await db.execute(
text("""
UPDATE tenant_configs
SET config_value = :config_value
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}
)
else:
# 插入
await db.execute(
text("""
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)
"""),
{
"tenant_id": tenant_id,
"config_group": config_group,
"config_key": config_key,
"config_value": config_value,
"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:
"""获取功能开关状态"""
# 先查租户级别
result = await db.execute(
text("""
SELECT is_enabled FROM feature_switches
WHERE feature_code = :feature_code AND tenant_id = :tenant_id
"""),
{"tenant_id": tenant_id, "feature_code": feature_code}
)
row = result.fetchone()
if row:
return bool(row[0])
# 再查默认值
result = await db.execute(
text("""
SELECT is_enabled FROM feature_switches
WHERE feature_code = :feature_code AND tenant_id IS NULL
"""),
{"feature_code": feature_code}
)
row = result.fetchone()
return bool(row[0]) if row else False
async def set_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str, is_enabled: bool):
"""设置功能开关状态"""
# 检查是否已存在租户级配置
result = await db.execute(
text("""
SELECT id FROM feature_switches
WHERE feature_code = :feature_code AND tenant_id = :tenant_id
"""),
{"tenant_id": tenant_id, "feature_code": feature_code}
)
row = result.fetchone()
if row:
# 更新
await db.execute(
text("""
UPDATE feature_switches
SET is_enabled = :is_enabled
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}
)
else:
# 获取默认配置信息
result = await db.execute(
text("""
SELECT feature_name, feature_group, description FROM feature_switches
WHERE feature_code = :feature_code AND tenant_id IS NULL
"""),
{"feature_code": feature_code}
)
default_row = result.fetchone()
if default_row:
# 插入租户级配置
await db.execute(
text("""
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)
"""),
{
"tenant_id": tenant_id,
"feature_code": feature_code,
"feature_name": default_row[0],
"feature_group": default_row[1],
"is_enabled": 1 if is_enabled else 0,
"description": default_row[2]
}
)
# ============================================
# API 端点
# ============================================
@router.get("/dingtalk", response_model=ResponseModel)
async def get_dingtalk_config(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> ResponseModel:
"""
获取钉钉配置
仅限管理员访问
"""
check_admin_permission(current_user)
tenant_id = await get_or_create_tenant_id(db)
# 获取配置
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')
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')
enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login')
# 脱敏处理 app_secret
app_secret_masked = None
if app_secret:
if len(app_secret) > 8:
app_secret_masked = app_secret[:4] + '****' + app_secret[-4:]
else:
app_secret_masked = '****'
return ResponseModel(
message="获取成功",
data={
"app_key": app_key,
"app_secret_masked": app_secret_masked,
"agent_id": agent_id,
"corp_id": corp_id,
"enabled": enabled,
}
)
@router.put("/dingtalk", response_model=ResponseModel)
async def update_dingtalk_config(
config: DingtalkConfigUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> ResponseModel:
"""
更新钉钉配置
仅限管理员访问
"""
check_admin_permission(current_user)
tenant_id = await get_or_create_tenant_id(db)
try:
# 更新配置
if config.app_key is not None:
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY', config.app_key)
if config.app_secret is not None:
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET', config.app_secret)
if config.agent_id is not None:
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_AGENT_ID', config.agent_id)
if config.corp_id is not None:
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID', config.corp_id)
if config.enabled is not None:
await set_feature_switch(db, tenant_id, 'dingtalk_login', config.enabled)
await db.commit()
logger.info(
"钉钉配置已更新",
user_id=current_user.id,
username=current_user.username,
)
return ResponseModel(message="配置已保存")
except Exception as e:
await db.rollback()
logger.error(f"更新钉钉配置失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="保存配置失败"
)
@router.get("/all", response_model=ResponseModel)
async def get_all_settings(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> ResponseModel:
"""
获取所有系统设置概览
仅限管理员访问
"""
check_admin_permission(current_user)
tenant_id = await get_or_create_tenant_id(db)
# 钉钉配置状态
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')
return ResponseModel(
message="获取成功",
data={
"dingtalk": {
"enabled": dingtalk_enabled,
"configured": bool(dingtalk_corp_id), # 是否已配置
}
}
)
"""
系统设置 API
供企业管理员admin角色配置系统级别的设置如钉钉免密登录等
"""
from typing import Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from app.core.deps import get_current_active_user, get_db
from app.core.logger import logger
from app.models.user import User
from app.schemas.base import ResponseModel
router = APIRouter()
# ============================================
# Schema 定义
# ============================================
class DingtalkConfigUpdate(BaseModel):
"""钉钉配置更新请求"""
app_key: Optional[str] = Field(None, description="钉钉AppKey")
app_secret: Optional[str] = Field(None, description="钉钉AppSecret")
agent_id: Optional[str] = Field(None, description="钉钉AgentId")
corp_id: Optional[str] = Field(None, description="钉钉CorpId")
enabled: Optional[bool] = Field(None, description="是否启用钉钉免密登录")
class DingtalkConfigResponse(BaseModel):
"""钉钉配置响应"""
app_key: Optional[str] = None
app_secret_masked: Optional[str] = None # 脱敏显示
agent_id: Optional[str] = None
corp_id: Optional[str] = None
enabled: bool = False
# ============================================
# 辅助函数
# ============================================
def check_admin_permission(user: User):
"""检查是否为管理员"""
if user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
async def get_or_create_tenant_id(db: AsyncSession) -> int:
"""获取或创建默认租户ID简化版假设单租户"""
# 对于考培练系统简化处理使用固定的租户ID=1
return 1
async def get_system_config(db: AsyncSession, tenant_id: int, config_group: str, config_key: str) -> Optional[str]:
"""获取系统配置值"""
result = await db.execute(
text("""
SELECT config_value FROM tenant_configs
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}
)
row = result.fetchone()
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):
"""设置系统配置值"""
# 检查是否已存在
existing = await get_system_config(db, tenant_id, config_group, config_key)
if existing is not None:
# 更新
await db.execute(
text("""
UPDATE tenant_configs
SET config_value = :config_value
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}
)
else:
# 插入
await db.execute(
text("""
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)
"""),
{
"tenant_id": tenant_id,
"config_group": config_group,
"config_key": config_key,
"config_value": config_value,
"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:
"""获取功能开关状态"""
# 先查租户级别
result = await db.execute(
text("""
SELECT is_enabled FROM feature_switches
WHERE feature_code = :feature_code AND tenant_id = :tenant_id
"""),
{"tenant_id": tenant_id, "feature_code": feature_code}
)
row = result.fetchone()
if row:
return bool(row[0])
# 再查默认值
result = await db.execute(
text("""
SELECT is_enabled FROM feature_switches
WHERE feature_code = :feature_code AND tenant_id IS NULL
"""),
{"feature_code": feature_code}
)
row = result.fetchone()
return bool(row[0]) if row else False
async def set_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str, is_enabled: bool):
"""设置功能开关状态"""
# 检查是否已存在租户级配置
result = await db.execute(
text("""
SELECT id FROM feature_switches
WHERE feature_code = :feature_code AND tenant_id = :tenant_id
"""),
{"tenant_id": tenant_id, "feature_code": feature_code}
)
row = result.fetchone()
if row:
# 更新
await db.execute(
text("""
UPDATE feature_switches
SET is_enabled = :is_enabled
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}
)
else:
# 获取默认配置信息
result = await db.execute(
text("""
SELECT feature_name, feature_group, description FROM feature_switches
WHERE feature_code = :feature_code AND tenant_id IS NULL
"""),
{"feature_code": feature_code}
)
default_row = result.fetchone()
if default_row:
# 插入租户级配置
await db.execute(
text("""
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)
"""),
{
"tenant_id": tenant_id,
"feature_code": feature_code,
"feature_name": default_row[0],
"feature_group": default_row[1],
"is_enabled": 1 if is_enabled else 0,
"description": default_row[2]
}
)
# ============================================
# API 端点
# ============================================
@router.get("/dingtalk", response_model=ResponseModel)
async def get_dingtalk_config(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> ResponseModel:
"""
获取钉钉配置
仅限管理员访问
"""
check_admin_permission(current_user)
tenant_id = await get_or_create_tenant_id(db)
# 获取配置
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')
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')
enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login')
# 脱敏处理 app_secret
app_secret_masked = None
if app_secret:
if len(app_secret) > 8:
app_secret_masked = app_secret[:4] + '****' + app_secret[-4:]
else:
app_secret_masked = '****'
return ResponseModel(
message="获取成功",
data={
"app_key": app_key,
"app_secret_masked": app_secret_masked,
"agent_id": agent_id,
"corp_id": corp_id,
"enabled": enabled,
}
)
@router.put("/dingtalk", response_model=ResponseModel)
async def update_dingtalk_config(
config: DingtalkConfigUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> ResponseModel:
"""
更新钉钉配置
仅限管理员访问
"""
check_admin_permission(current_user)
tenant_id = await get_or_create_tenant_id(db)
try:
# 更新配置
if config.app_key is not None:
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY', config.app_key)
if config.app_secret is not None:
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET', config.app_secret)
if config.agent_id is not None:
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_AGENT_ID', config.agent_id)
if config.corp_id is not None:
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID', config.corp_id)
if config.enabled is not None:
await set_feature_switch(db, tenant_id, 'dingtalk_login', config.enabled)
await db.commit()
logger.info(
"钉钉配置已更新",
user_id=current_user.id,
username=current_user.username,
)
return ResponseModel(message="配置已保存")
except Exception as e:
await db.rollback()
logger.error(f"更新钉钉配置失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="保存配置失败"
)
@router.get("/all", response_model=ResponseModel)
async def get_all_settings(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> ResponseModel:
"""
获取所有系统设置概览
仅限管理员访问
"""
check_admin_permission(current_user)
tenant_id = await get_or_create_tenant_id(db)
# 钉钉配置状态
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')
return ResponseModel(
message="获取成功",
data={
"dingtalk": {
"enabled": dingtalk_enabled,
"configured": bool(dingtalk_corp_id), # 是否已配置
}
}
)

View File

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

View File

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

View File

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

View File

@@ -1,122 +1,122 @@
"""
双人对练房间模型
功能:
- 房间管理(创建、加入、状态)
- 参与者管理
- 实时消息同步
"""
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.models.base import Base
class PracticeRoom(Base):
"""双人对练房间模型"""
__tablename__ = "practice_rooms"
id = Column(Integer, primary_key=True, index=True, comment="房间ID")
room_code = Column(String(10), unique=True, nullable=False, index=True, comment="6位邀请码")
room_name = Column(String(200), comment="房间名称")
# 场景信息
scene_id = Column(Integer, ForeignKey("practice_scenes.id", ondelete="SET NULL"), comment="关联场景ID")
scene_name = Column(String(200), comment="场景名称")
scene_type = Column(String(50), comment="场景类型")
scene_background = Column(Text, comment="场景背景")
# 角色设置
role_a_name = Column(String(50), default="角色A", comment="角色A名称如销售顾问")
role_b_name = Column(String(50), default="角色B", comment="角色B名称如顾客")
role_a_description = Column(Text, comment="角色A描述")
role_b_description = Column(Text, comment="角色B描述")
# 参与者信息
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")
host_role = Column(String(10), default="A", comment="房主选择的角色(A/B)")
max_participants = Column(Integer, default=2, comment="最大参与人数")
# 状态和时间
status = Column(String(20), default="waiting", index=True, comment="状态: waiting/ready/practicing/completed/canceled")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
started_at = Column(DateTime, comment="开始时间")
ended_at = Column(DateTime, comment="结束时间")
duration_seconds = Column(Integer, default=0, comment="对练时长(秒)")
# 对话统计
total_turns = Column(Integer, default=0, comment="总对话轮次")
role_a_turns = Column(Integer, default=0, comment="角色A发言次数")
role_b_turns = Column(Integer, default=0, comment="角色B发言次数")
# 软删除
is_deleted = Column(Boolean, default=False, comment="是否删除")
deleted_at = Column(DateTime, comment="删除时间")
def __repr__(self):
return f"<PracticeRoom(code='{self.room_code}', status='{self.status}')>"
@property
def is_full(self) -> bool:
"""房间是否已满"""
return self.guest_user_id is not None
@property
def participant_count(self) -> int:
"""当前参与人数"""
count = 1 # 房主
if self.guest_user_id:
count += 1
return count
def get_user_role(self, user_id: int) -> str:
"""获取用户在房间中的角色"""
if user_id == self.host_user_id:
return self.host_role
elif user_id == self.guest_user_id:
return "B" if self.host_role == "A" else "A"
return None
def get_role_name(self, role: str) -> str:
"""获取角色名称"""
if role == "A":
return self.role_a_name
elif role == "B":
return self.role_b_name
return None
def get_user_role_name(self, user_id: int) -> str:
"""获取用户的角色名称"""
role = self.get_user_role(user_id)
return self.get_role_name(role) if role else None
class PracticeRoomMessage(Base):
"""房间实时消息模型"""
__tablename__ = "practice_room_messages"
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")
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")
content = Column(Text, comment="消息内容")
role_name = Column(String(50), comment="角色名称")
sequence = Column(Integer, nullable=False, comment="消息序号")
created_at = Column(DateTime(3), server_default=func.now(3), comment="创建时间")
def __repr__(self):
return f"<PracticeRoomMessage(room_id={self.room_id}, type='{self.message_type}', seq={self.sequence})>"
def to_dict(self) -> dict:
"""转换为字典用于SSE推送"""
return {
"id": self.id,
"room_id": self.room_id,
"user_id": self.user_id,
"message_type": self.message_type,
"content": self.content,
"role_name": self.role_name,
"sequence": self.sequence,
"created_at": self.created_at.isoformat() if self.created_at else None
}
"""
双人对练房间模型
功能:
- 房间管理(创建、加入、状态)
- 参与者管理
- 实时消息同步
"""
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.models.base import Base
class PracticeRoom(Base):
"""双人对练房间模型"""
__tablename__ = "practice_rooms"
id = Column(Integer, primary_key=True, index=True, comment="房间ID")
room_code = Column(String(10), unique=True, nullable=False, index=True, comment="6位邀请码")
room_name = Column(String(200), comment="房间名称")
# 场景信息
scene_id = Column(Integer, ForeignKey("practice_scenes.id", ondelete="SET NULL"), comment="关联场景ID")
scene_name = Column(String(200), comment="场景名称")
scene_type = Column(String(50), comment="场景类型")
scene_background = Column(Text, comment="场景背景")
# 角色设置
role_a_name = Column(String(50), default="角色A", comment="角色A名称如销售顾问")
role_b_name = Column(String(50), default="角色B", comment="角色B名称如顾客")
role_a_description = Column(Text, comment="角色A描述")
role_b_description = Column(Text, comment="角色B描述")
# 参与者信息
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")
host_role = Column(String(10), default="A", comment="房主选择的角色(A/B)")
max_participants = Column(Integer, default=2, comment="最大参与人数")
# 状态和时间
status = Column(String(20), default="waiting", index=True, comment="状态: waiting/ready/practicing/completed/canceled")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
started_at = Column(DateTime, comment="开始时间")
ended_at = Column(DateTime, comment="结束时间")
duration_seconds = Column(Integer, default=0, comment="对练时长(秒)")
# 对话统计
total_turns = Column(Integer, default=0, comment="总对话轮次")
role_a_turns = Column(Integer, default=0, comment="角色A发言次数")
role_b_turns = Column(Integer, default=0, comment="角色B发言次数")
# 软删除
is_deleted = Column(Boolean, default=False, comment="是否删除")
deleted_at = Column(DateTime, comment="删除时间")
def __repr__(self):
return f"<PracticeRoom(code='{self.room_code}', status='{self.status}')>"
@property
def is_full(self) -> bool:
"""房间是否已满"""
return self.guest_user_id is not None
@property
def participant_count(self) -> int:
"""当前参与人数"""
count = 1 # 房主
if self.guest_user_id:
count += 1
return count
def get_user_role(self, user_id: int) -> str:
"""获取用户在房间中的角色"""
if user_id == self.host_user_id:
return self.host_role
elif user_id == self.guest_user_id:
return "B" if self.host_role == "A" else "A"
return None
def get_role_name(self, role: str) -> str:
"""获取角色名称"""
if role == "A":
return self.role_a_name
elif role == "B":
return self.role_b_name
return None
def get_user_role_name(self, user_id: int) -> str:
"""获取用户的角色名称"""
role = self.get_user_role(user_id)
return self.get_role_name(role) if role else None
class PracticeRoomMessage(Base):
"""房间实时消息模型"""
__tablename__ = "practice_room_messages"
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")
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")
content = Column(Text, comment="消息内容")
role_name = Column(String(50), comment="角色名称")
sequence = Column(Integer, nullable=False, comment="消息序号")
created_at = Column(DateTime(3), server_default=func.now(3), comment="创建时间")
def __repr__(self):
return f"<PracticeRoomMessage(room_id={self.room_id}, type='{self.message_type}', seq={self.sequence})>"
def to_dict(self) -> dict:
"""转换为字典用于SSE推送"""
return {
"id": self.id,
"room_id": self.room_id,
"user_id": self.user_id,
"message_type": self.message_type,
"content": self.content,
"role_name": self.role_name,
"sequence": self.sequence,
"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 logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from app.services.ai.ai_service import AIService
from app.services.ai.prompts.duo_practice_prompts import SYSTEM_PROMPT, USER_PROMPT
logger = logging.getLogger(__name__)
@dataclass
class UserEvaluation:
"""用户评估结果"""
user_name: str
role_name: str
total_score: int
dimensions: Dict[str, Dict[str, Any]]
highlights: List[str]
improvements: List[Dict[str, str]]
@dataclass
class DuoPracticeAnalysisResult:
"""双人对练分析结果"""
# 整体评估
interaction_quality: int = 0
scene_restoration: int = 0
overall_comment: str = ""
# 用户A评估
user_a_evaluation: Optional[UserEvaluation] = None
# 用户B评估
user_b_evaluation: Optional[UserEvaluation] = None
# 对话标注
dialogue_annotations: List[Dict[str, Any]] = field(default_factory=list)
# AI 元数据
raw_response: str = ""
ai_provider: str = ""
ai_model: str = ""
ai_latency_ms: int = 0
class DuoPracticeAnalysisService:
"""
双人对练分析服务
使用示例:
```python
service = DuoPracticeAnalysisService()
result = await service.analyze(
scene_name="销售场景",
scene_background="客户咨询产品",
role_a_name="销售顾问",
role_b_name="顾客",
user_a_name="张三",
user_b_name="李四",
dialogue_history=dialogue_list,
duration_seconds=300,
total_turns=20
)
```
"""
MODULE_CODE = "duo_practice_analysis"
async def analyze(
self,
scene_name: str,
scene_background: str,
role_a_name: str,
role_b_name: str,
role_a_description: str,
role_b_description: str,
user_a_name: str,
user_b_name: str,
dialogue_history: List[Dict[str, Any]],
duration_seconds: int,
total_turns: int,
db: Any = None
) -> DuoPracticeAnalysisResult:
"""
分析双人对练
Args:
scene_name: 场景名称
scene_background: 场景背景
role_a_name: 角色A名称
role_b_name: 角色B名称
role_a_description: 角色A描述
role_b_description: 角色B描述
user_a_name: 用户A名称
user_b_name: 用户B名称
dialogue_history: 对话历史列表
duration_seconds: 对练时长(秒)
total_turns: 总对话轮次
db: 数据库会话
Returns:
DuoPracticeAnalysisResult: 分析结果
"""
try:
logger.info(f"开始双人对练分析: {scene_name}, 轮次={total_turns}")
# 格式化对话历史
dialogue_text = self._format_dialogue_history(dialogue_history)
# 创建 AI 服务
ai_service = AIService(module_code=self.MODULE_CODE, db_session=db)
# 构建用户提示词
user_prompt = USER_PROMPT.format(
scene_name=scene_name,
scene_background=scene_background or "未设置",
role_a_name=role_a_name,
role_b_name=role_b_name,
role_a_description=role_a_description or f"扮演{role_a_name}角色",
role_b_description=role_b_description or f"扮演{role_b_name}角色",
user_a_name=user_a_name,
user_b_name=user_b_name,
dialogue_history=dialogue_text,
duration_seconds=duration_seconds,
total_turns=total_turns
)
# 调用 AI
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt}
]
ai_response = await ai_service.chat(
messages=messages,
model="gemini-3-flash-preview", # 使用快速模型
temperature=0.3,
prompt_name="duo_practice_analysis"
)
logger.info(f"AI 分析完成: provider={ai_response.provider}, latency={ai_response.latency_ms}ms")
# 解析 AI 输出
result = self._parse_analysis_result(
ai_response.content,
user_a_name=user_a_name,
user_b_name=user_b_name,
role_a_name=role_a_name,
role_b_name=role_b_name
)
# 填充 AI 元数据
result.raw_response = ai_response.content
result.ai_provider = ai_response.provider
result.ai_model = ai_response.model
result.ai_latency_ms = ai_response.latency_ms
return result
except Exception as e:
logger.error(f"双人对练分析失败: {e}", exc_info=True)
# 返回空结果
return DuoPracticeAnalysisResult(
overall_comment=f"分析失败: {str(e)}"
)
def _format_dialogue_history(self, dialogues: List[Dict[str, Any]]) -> str:
"""格式化对话历史"""
lines = []
for d in dialogues:
speaker = d.get("role_name") or d.get("speaker", "未知")
content = d.get("content", "")
seq = d.get("sequence", 0)
lines.append(f"[{seq}] {speaker}{content}")
return "\n".join(lines)
def _parse_analysis_result(
self,
ai_output: str,
user_a_name: str,
user_b_name: str,
role_a_name: str,
role_b_name: str
) -> DuoPracticeAnalysisResult:
"""解析 AI 输出"""
result = DuoPracticeAnalysisResult()
try:
# 尝试提取 JSON
json_str = ai_output
# 如果输出包含 markdown 代码块,提取其中的 JSON
if "```json" in ai_output:
start = ai_output.find("```json") + 7
end = ai_output.find("```", start)
json_str = ai_output[start:end].strip()
elif "```" in ai_output:
start = ai_output.find("```") + 3
end = ai_output.find("```", start)
json_str = ai_output[start:end].strip()
data = json.loads(json_str)
# 解析整体评估
overall = data.get("overall_evaluation", {})
result.interaction_quality = overall.get("interaction_quality", 0)
result.scene_restoration = overall.get("scene_restoration", 0)
result.overall_comment = overall.get("overall_comment", "")
# 解析用户A评估
user_a_data = data.get("user_a_evaluation", {})
if user_a_data:
result.user_a_evaluation = UserEvaluation(
user_name=user_a_data.get("user_name", user_a_name),
role_name=user_a_data.get("role_name", role_a_name),
total_score=user_a_data.get("total_score", 0),
dimensions=user_a_data.get("dimensions", {}),
highlights=user_a_data.get("highlights", []),
improvements=user_a_data.get("improvements", [])
)
# 解析用户B评估
user_b_data = data.get("user_b_evaluation", {})
if user_b_data:
result.user_b_evaluation = UserEvaluation(
user_name=user_b_data.get("user_name", user_b_name),
role_name=user_b_data.get("role_name", role_b_name),
total_score=user_b_data.get("total_score", 0),
dimensions=user_b_data.get("dimensions", {}),
highlights=user_b_data.get("highlights", []),
improvements=user_b_data.get("improvements", [])
)
# 解析对话标注
result.dialogue_annotations = data.get("dialogue_annotations", [])
except json.JSONDecodeError as e:
logger.warning(f"JSON 解析失败: {e}")
result.overall_comment = "AI 输出格式异常,请重试"
except Exception as e:
logger.error(f"解析分析结果失败: {e}")
result.overall_comment = f"解析失败: {str(e)}"
return result
def result_to_dict(self, result: DuoPracticeAnalysisResult) -> Dict[str, Any]:
"""将结果转换为字典(用于 API 响应)"""
return {
"overall_evaluation": {
"interaction_quality": result.interaction_quality,
"scene_restoration": result.scene_restoration,
"overall_comment": result.overall_comment
},
"user_a_evaluation": {
"user_name": result.user_a_evaluation.user_name,
"role_name": result.user_a_evaluation.role_name,
"total_score": result.user_a_evaluation.total_score,
"dimensions": result.user_a_evaluation.dimensions,
"highlights": result.user_a_evaluation.highlights,
"improvements": result.user_a_evaluation.improvements
} if result.user_a_evaluation else None,
"user_b_evaluation": {
"user_name": result.user_b_evaluation.user_name,
"role_name": result.user_b_evaluation.role_name,
"total_score": result.user_b_evaluation.total_score,
"dimensions": result.user_b_evaluation.dimensions,
"highlights": result.user_b_evaluation.highlights,
"improvements": result.user_b_evaluation.improvements
} if result.user_b_evaluation else None,
"dialogue_annotations": result.dialogue_annotations,
"ai_metadata": {
"provider": result.ai_provider,
"model": result.ai_model,
"latency_ms": result.ai_latency_ms
}
}
# ==================== 全局实例 ====================
duo_practice_analysis_service = DuoPracticeAnalysisService()
# ==================== 便捷函数 ====================
async def analyze_duo_practice(
scene_name: str,
scene_background: str,
role_a_name: str,
role_b_name: str,
role_a_description: str,
role_b_description: str,
user_a_name: str,
user_b_name: str,
dialogue_history: List[Dict[str, Any]],
duration_seconds: int,
total_turns: int,
db: Any = None
) -> DuoPracticeAnalysisResult:
"""便捷函数:分析双人对练"""
return await duo_practice_analysis_service.analyze(
scene_name=scene_name,
scene_background=scene_background,
role_a_name=role_a_name,
role_b_name=role_b_name,
role_a_description=role_a_description,
role_b_description=role_b_description,
user_a_name=user_a_name,
user_b_name=user_b_name,
dialogue_history=dialogue_history,
duration_seconds=duration_seconds,
total_turns=total_turns,
db=db
)
"""
双人对练分析服务
功能:
- 分析双人对练对话
- 生成双方评估报告
- 对话标注和建议
"""
import json
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from app.services.ai.ai_service import AIService
from app.services.ai.prompts.duo_practice_prompts import SYSTEM_PROMPT, USER_PROMPT
logger = logging.getLogger(__name__)
@dataclass
class UserEvaluation:
"""用户评估结果"""
user_name: str
role_name: str
total_score: int
dimensions: Dict[str, Dict[str, Any]]
highlights: List[str]
improvements: List[Dict[str, str]]
@dataclass
class DuoPracticeAnalysisResult:
"""双人对练分析结果"""
# 整体评估
interaction_quality: int = 0
scene_restoration: int = 0
overall_comment: str = ""
# 用户A评估
user_a_evaluation: Optional[UserEvaluation] = None
# 用户B评估
user_b_evaluation: Optional[UserEvaluation] = None
# 对话标注
dialogue_annotations: List[Dict[str, Any]] = field(default_factory=list)
# AI 元数据
raw_response: str = ""
ai_provider: str = ""
ai_model: str = ""
ai_latency_ms: int = 0
class DuoPracticeAnalysisService:
"""
双人对练分析服务
使用示例:
```python
service = DuoPracticeAnalysisService()
result = await service.analyze(
scene_name="销售场景",
scene_background="客户咨询产品",
role_a_name="销售顾问",
role_b_name="顾客",
user_a_name="张三",
user_b_name="李四",
dialogue_history=dialogue_list,
duration_seconds=300,
total_turns=20
)
```
"""
MODULE_CODE = "duo_practice_analysis"
async def analyze(
self,
scene_name: str,
scene_background: str,
role_a_name: str,
role_b_name: str,
role_a_description: str,
role_b_description: str,
user_a_name: str,
user_b_name: str,
dialogue_history: List[Dict[str, Any]],
duration_seconds: int,
total_turns: int,
db: Any = None
) -> DuoPracticeAnalysisResult:
"""
分析双人对练
Args:
scene_name: 场景名称
scene_background: 场景背景
role_a_name: 角色A名称
role_b_name: 角色B名称
role_a_description: 角色A描述
role_b_description: 角色B描述
user_a_name: 用户A名称
user_b_name: 用户B名称
dialogue_history: 对话历史列表
duration_seconds: 对练时长(秒)
total_turns: 总对话轮次
db: 数据库会话
Returns:
DuoPracticeAnalysisResult: 分析结果
"""
try:
logger.info(f"开始双人对练分析: {scene_name}, 轮次={total_turns}")
# 格式化对话历史
dialogue_text = self._format_dialogue_history(dialogue_history)
# 创建 AI 服务
ai_service = AIService(module_code=self.MODULE_CODE, db_session=db)
# 构建用户提示词
user_prompt = USER_PROMPT.format(
scene_name=scene_name,
scene_background=scene_background or "未设置",
role_a_name=role_a_name,
role_b_name=role_b_name,
role_a_description=role_a_description or f"扮演{role_a_name}角色",
role_b_description=role_b_description or f"扮演{role_b_name}角色",
user_a_name=user_a_name,
user_b_name=user_b_name,
dialogue_history=dialogue_text,
duration_seconds=duration_seconds,
total_turns=total_turns
)
# 调用 AI
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt}
]
ai_response = await ai_service.chat(
messages=messages,
model="gemini-3-flash-preview", # 使用快速模型
temperature=0.3,
prompt_name="duo_practice_analysis"
)
logger.info(f"AI 分析完成: provider={ai_response.provider}, latency={ai_response.latency_ms}ms")
# 解析 AI 输出
result = self._parse_analysis_result(
ai_response.content,
user_a_name=user_a_name,
user_b_name=user_b_name,
role_a_name=role_a_name,
role_b_name=role_b_name
)
# 填充 AI 元数据
result.raw_response = ai_response.content
result.ai_provider = ai_response.provider
result.ai_model = ai_response.model
result.ai_latency_ms = ai_response.latency_ms
return result
except Exception as e:
logger.error(f"双人对练分析失败: {e}", exc_info=True)
# 返回空结果
return DuoPracticeAnalysisResult(
overall_comment=f"分析失败: {str(e)}"
)
def _format_dialogue_history(self, dialogues: List[Dict[str, Any]]) -> str:
"""格式化对话历史"""
lines = []
for d in dialogues:
speaker = d.get("role_name") or d.get("speaker", "未知")
content = d.get("content", "")
seq = d.get("sequence", 0)
lines.append(f"[{seq}] {speaker}{content}")
return "\n".join(lines)
def _parse_analysis_result(
self,
ai_output: str,
user_a_name: str,
user_b_name: str,
role_a_name: str,
role_b_name: str
) -> DuoPracticeAnalysisResult:
"""解析 AI 输出"""
result = DuoPracticeAnalysisResult()
try:
# 尝试提取 JSON
json_str = ai_output
# 如果输出包含 markdown 代码块,提取其中的 JSON
if "```json" in ai_output:
start = ai_output.find("```json") + 7
end = ai_output.find("```", start)
json_str = ai_output[start:end].strip()
elif "```" in ai_output:
start = ai_output.find("```") + 3
end = ai_output.find("```", start)
json_str = ai_output[start:end].strip()
data = json.loads(json_str)
# 解析整体评估
overall = data.get("overall_evaluation", {})
result.interaction_quality = overall.get("interaction_quality", 0)
result.scene_restoration = overall.get("scene_restoration", 0)
result.overall_comment = overall.get("overall_comment", "")
# 解析用户A评估
user_a_data = data.get("user_a_evaluation", {})
if user_a_data:
result.user_a_evaluation = UserEvaluation(
user_name=user_a_data.get("user_name", user_a_name),
role_name=user_a_data.get("role_name", role_a_name),
total_score=user_a_data.get("total_score", 0),
dimensions=user_a_data.get("dimensions", {}),
highlights=user_a_data.get("highlights", []),
improvements=user_a_data.get("improvements", [])
)
# 解析用户B评估
user_b_data = data.get("user_b_evaluation", {})
if user_b_data:
result.user_b_evaluation = UserEvaluation(
user_name=user_b_data.get("user_name", user_b_name),
role_name=user_b_data.get("role_name", role_b_name),
total_score=user_b_data.get("total_score", 0),
dimensions=user_b_data.get("dimensions", {}),
highlights=user_b_data.get("highlights", []),
improvements=user_b_data.get("improvements", [])
)
# 解析对话标注
result.dialogue_annotations = data.get("dialogue_annotations", [])
except json.JSONDecodeError as e:
logger.warning(f"JSON 解析失败: {e}")
result.overall_comment = "AI 输出格式异常,请重试"
except Exception as e:
logger.error(f"解析分析结果失败: {e}")
result.overall_comment = f"解析失败: {str(e)}"
return result
def result_to_dict(self, result: DuoPracticeAnalysisResult) -> Dict[str, Any]:
"""将结果转换为字典(用于 API 响应)"""
return {
"overall_evaluation": {
"interaction_quality": result.interaction_quality,
"scene_restoration": result.scene_restoration,
"overall_comment": result.overall_comment
},
"user_a_evaluation": {
"user_name": result.user_a_evaluation.user_name,
"role_name": result.user_a_evaluation.role_name,
"total_score": result.user_a_evaluation.total_score,
"dimensions": result.user_a_evaluation.dimensions,
"highlights": result.user_a_evaluation.highlights,
"improvements": result.user_a_evaluation.improvements
} if result.user_a_evaluation else None,
"user_b_evaluation": {
"user_name": result.user_b_evaluation.user_name,
"role_name": result.user_b_evaluation.role_name,
"total_score": result.user_b_evaluation.total_score,
"dimensions": result.user_b_evaluation.dimensions,
"highlights": result.user_b_evaluation.highlights,
"improvements": result.user_b_evaluation.improvements
} if result.user_b_evaluation else None,
"dialogue_annotations": result.dialogue_annotations,
"ai_metadata": {
"provider": result.ai_provider,
"model": result.ai_model,
"latency_ms": result.ai_latency_ms
}
}
# ==================== 全局实例 ====================
duo_practice_analysis_service = DuoPracticeAnalysisService()
# ==================== 便捷函数 ====================
async def analyze_duo_practice(
scene_name: str,
scene_background: str,
role_a_name: str,
role_b_name: str,
role_a_description: str,
role_b_description: str,
user_a_name: str,
user_b_name: str,
dialogue_history: List[Dict[str, Any]],
duration_seconds: int,
total_turns: int,
db: Any = None
) -> DuoPracticeAnalysisResult:
"""便捷函数:分析双人对练"""
return await duo_practice_analysis_service.analyze(
scene_name=scene_name,
scene_background=scene_background,
role_a_name=role_a_name,
role_b_name=role_b_name,
role_a_description=role_a_description,
role_b_description=role_b_description,
user_a_name=user_a_name,
user_b_name=user_b_name,
dialogue_history=dialogue_history,
duration_seconds=duration_seconds,
total_turns=total_turns,
db=db
)

View File

@@ -1,207 +1,207 @@
"""
双人对练评估提示词模板
功能:评估双人角色扮演对练的表现
"""
# ==================== 元数据 ====================
PROMPT_META = {
"name": "duo_practice_analysis",
"display_name": "双人对练评估",
"description": "评估双人角色扮演对练中双方的表现",
"module": "kaopeilian",
"variables": [
"scene_name", "scene_background",
"role_a_name", "role_b_name",
"role_a_description", "role_b_description",
"user_a_name", "user_b_name",
"dialogue_history",
"duration_seconds", "total_turns"
],
"version": "1.0.0",
"author": "kaopeilian-team",
}
# ==================== 系统提示词 ====================
SYSTEM_PROMPT = """你是一位资深的销售培训专家和沟通教练,擅长评估角色扮演对练的表现。
你需要观察双人对练的对话记录,分别对两位参与者的表现进行专业评估。
评估原则:
1. 客观公正,基于对话内容给出评价
2. 突出亮点,指出不足
3. 给出具体、可操作的改进建议
4. 考虑角色特点,评估角色代入度
输出格式要求:
- 必须返回有效的 JSON 格式
- 分数范围 0-100
- 建议具体可行"""
# ==================== 用户提示词模板 ====================
USER_PROMPT = """# 双人对练评估任务
## 场景信息
- **场景名称**{scene_name}
- **场景背景**{scene_background}
## 角色设置
### {role_a_name}
- **扮演者**{user_a_name}
- **角色描述**{role_a_description}
### {role_b_name}
- **扮演者**{user_b_name}
- **角色描述**{role_b_description}
## 对练数据
- **对练时长**{duration_seconds}
- **总对话轮次**{total_turns}
## 对话记录
{dialogue_history}
---
## 评估要求
请按以下 JSON 格式输出评估结果:
```json
{{
"overall_evaluation": {{
"interaction_quality": 85,
"scene_restoration": 80,
"overall_comment": "整体评价..."
}},
"user_a_evaluation": {{
"user_name": "{user_a_name}",
"role_name": "{role_a_name}",
"total_score": 85,
"dimensions": {{
"role_immersion": {{
"score": 85,
"comment": "角色代入度评价..."
}},
"communication": {{
"score": 80,
"comment": "沟通表达能力评价..."
}},
"professional_knowledge": {{
"score": 75,
"comment": "专业知识运用评价..."
}},
"response_quality": {{
"score": 82,
"comment": "回应质量评价..."
}},
"goal_achievement": {{
"score": 78,
"comment": "目标达成度评价..."
}}
}},
"highlights": [
"亮点1...",
"亮点2..."
],
"improvements": [
{{
"issue": "问题描述",
"suggestion": "改进建议",
"example": "示例话术"
}}
]
}},
"user_b_evaluation": {{
"user_name": "{user_b_name}",
"role_name": "{role_b_name}",
"total_score": 82,
"dimensions": {{
"role_immersion": {{
"score": 80,
"comment": "角色代入度评价..."
}},
"communication": {{
"score": 85,
"comment": "沟通表达能力评价..."
}},
"professional_knowledge": {{
"score": 78,
"comment": "专业知识运用评价..."
}},
"response_quality": {{
"score": 80,
"comment": "回应质量评价..."
}},
"goal_achievement": {{
"score": 75,
"comment": "目标达成度评价..."
}}
}},
"highlights": [
"亮点1...",
"亮点2..."
],
"improvements": [
{{
"issue": "问题描述",
"suggestion": "改进建议",
"example": "示例话术"
}}
]
}},
"dialogue_annotations": [
{{
"sequence": 1,
"speaker": "{role_a_name}",
"tags": ["good_opening"],
"comment": "开场白自然得体"
}},
{{
"sequence": 3,
"speaker": "{role_b_name}",
"tags": ["needs_improvement"],
"comment": "可以更主动表达需求"
}}
]
}}
```
请基于对话内容,给出客观、专业的评估。"""
# ==================== 维度说明 ====================
DIMENSION_DESCRIPTIONS = {
"role_immersion": "角色代入度:是否完全进入角色,语言风格、态度是否符合角色设定",
"communication": "沟通表达:语言是否清晰、逻辑是否通顺、表达是否得体",
"professional_knowledge": "专业知识:是否展现出角色应有的专业素养和知识储备",
"response_quality": "回应质量:对对方发言的回应是否及时、恰当、有针对性",
"goal_achievement": "目标达成:是否朝着对练目标推进,是否达成预期效果"
}
# ==================== 对话标签 ====================
DIALOGUE_TAGS = {
# 正面标签
"good_opening": "开场良好",
"active_listening": "积极倾听",
"empathy": "共情表达",
"professional": "专业表现",
"good_closing": "结束得体",
"creative_response": "创意回应",
"problem_solving": "问题解决",
# 需改进标签
"needs_improvement": "需要改进",
"off_topic": "偏离主题",
"too_passive": "过于被动",
"lack_detail": "缺乏细节",
"missed_opportunity": "错失机会",
"unclear_expression": "表达不清"
}
"""
双人对练评估提示词模板
功能:评估双人角色扮演对练的表现
"""
# ==================== 元数据 ====================
PROMPT_META = {
"name": "duo_practice_analysis",
"display_name": "双人对练评估",
"description": "评估双人角色扮演对练中双方的表现",
"module": "kaopeilian",
"variables": [
"scene_name", "scene_background",
"role_a_name", "role_b_name",
"role_a_description", "role_b_description",
"user_a_name", "user_b_name",
"dialogue_history",
"duration_seconds", "total_turns"
],
"version": "1.0.0",
"author": "kaopeilian-team",
}
# ==================== 系统提示词 ====================
SYSTEM_PROMPT = """你是一位资深的销售培训专家和沟通教练,擅长评估角色扮演对练的表现。
你需要观察双人对练的对话记录,分别对两位参与者的表现进行专业评估。
评估原则:
1. 客观公正,基于对话内容给出评价
2. 突出亮点,指出不足
3. 给出具体、可操作的改进建议
4. 考虑角色特点,评估角色代入度
输出格式要求:
- 必须返回有效的 JSON 格式
- 分数范围 0-100
- 建议具体可行"""
# ==================== 用户提示词模板 ====================
USER_PROMPT = """# 双人对练评估任务
## 场景信息
- **场景名称**{scene_name}
- **场景背景**{scene_background}
## 角色设置
### {role_a_name}
- **扮演者**{user_a_name}
- **角色描述**{role_a_description}
### {role_b_name}
- **扮演者**{user_b_name}
- **角色描述**{role_b_description}
## 对练数据
- **对练时长**{duration_seconds}
- **总对话轮次**{total_turns}
## 对话记录
{dialogue_history}
---
## 评估要求
请按以下 JSON 格式输出评估结果:
```json
{{
"overall_evaluation": {{
"interaction_quality": 85,
"scene_restoration": 80,
"overall_comment": "整体评价..."
}},
"user_a_evaluation": {{
"user_name": "{user_a_name}",
"role_name": "{role_a_name}",
"total_score": 85,
"dimensions": {{
"role_immersion": {{
"score": 85,
"comment": "角色代入度评价..."
}},
"communication": {{
"score": 80,
"comment": "沟通表达能力评价..."
}},
"professional_knowledge": {{
"score": 75,
"comment": "专业知识运用评价..."
}},
"response_quality": {{
"score": 82,
"comment": "回应质量评价..."
}},
"goal_achievement": {{
"score": 78,
"comment": "目标达成度评价..."
}}
}},
"highlights": [
"亮点1...",
"亮点2..."
],
"improvements": [
{{
"issue": "问题描述",
"suggestion": "改进建议",
"example": "示例话术"
}}
]
}},
"user_b_evaluation": {{
"user_name": "{user_b_name}",
"role_name": "{role_b_name}",
"total_score": 82,
"dimensions": {{
"role_immersion": {{
"score": 80,
"comment": "角色代入度评价..."
}},
"communication": {{
"score": 85,
"comment": "沟通表达能力评价..."
}},
"professional_knowledge": {{
"score": 78,
"comment": "专业知识运用评价..."
}},
"response_quality": {{
"score": 80,
"comment": "回应质量评价..."
}},
"goal_achievement": {{
"score": 75,
"comment": "目标达成度评价..."
}}
}},
"highlights": [
"亮点1...",
"亮点2..."
],
"improvements": [
{{
"issue": "问题描述",
"suggestion": "改进建议",
"example": "示例话术"
}}
]
}},
"dialogue_annotations": [
{{
"sequence": 1,
"speaker": "{role_a_name}",
"tags": ["good_opening"],
"comment": "开场白自然得体"
}},
{{
"sequence": 3,
"speaker": "{role_b_name}",
"tags": ["needs_improvement"],
"comment": "可以更主动表达需求"
}}
]
}}
```
请基于对话内容,给出客观、专业的评估。"""
# ==================== 维度说明 ====================
DIMENSION_DESCRIPTIONS = {
"role_immersion": "角色代入度:是否完全进入角色,语言风格、态度是否符合角色设定",
"communication": "沟通表达:语言是否清晰、逻辑是否通顺、表达是否得体",
"professional_knowledge": "专业知识:是否展现出角色应有的专业素养和知识储备",
"response_quality": "回应质量:对对方发言的回应是否及时、恰当、有针对性",
"goal_achievement": "目标达成:是否朝着对练目标推进,是否达成预期效果"
}
# ==================== 对话标签 ====================
DIALOGUE_TAGS = {
# 正面标签
"good_opening": "开场良好",
"active_listening": "积极倾听",
"empathy": "共情表达",
"professional": "专业表现",
"good_closing": "结束得体",
"creative_response": "创意回应",
"problem_solving": "问题解决",
# 需改进标签
"needs_improvement": "需要改进",
"off_topic": "偏离主题",
"too_passive": "过于被动",
"lack_detail": "缺乏细节",
"missed_opportunity": "错失机会",
"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 typing import Optional, List, Dict, Any
from sqlalchemy import select, func, and_, or_, desc, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logger import get_logger
from app.models.user import User
from app.models.course import Course, CourseMaterial
from app.models.exam import Exam
from app.models.practice import PracticeSession
from app.models.training import TrainingSession, TrainingReport
from app.models.level import UserLevel, ExpHistory, UserBadge
from app.models.position import Position
from app.models.position_member import PositionMember
logger = get_logger(__name__)
class DashboardService:
"""数据大屏服务"""
def __init__(self, db: AsyncSession):
self.db = db
async def get_enterprise_overview(self, enterprise_id: Optional[int] = None) -> Dict[str, Any]:
"""
获取企业级数据概览
Args:
enterprise_id: 企业ID可选用于多租户
Returns:
企业级数据概览
"""
today = date.today()
week_ago = today - timedelta(days=7)
month_ago = today - timedelta(days=30)
# 基础统计
# 1. 总学员数
result = await self.db.execute(
select(func.count(User.id))
.where(User.is_deleted == False, User.role == 'trainee')
)
total_users = result.scalar() or 0
# 2. 今日活跃用户(有经验值记录)
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(func.date(ExpHistory.created_at) == today)
)
today_active = result.scalar() or 0
# 3. 本周活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(ExpHistory.created_at >= datetime.combine(week_ago, datetime.min.time()))
)
week_active = result.scalar() or 0
# 4. 本月活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(ExpHistory.created_at >= datetime.combine(month_ago, datetime.min.time()))
)
month_active = result.scalar() or 0
# 5. 总学习时长(小时)
result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(PracticeSession.status == 'completed')
)
practice_hours = (result.scalar() or 0) / 3600
result = await self.db.execute(
select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0))
.where(TrainingSession.status == 'COMPLETED')
)
training_hours = (result.scalar() or 0) / 3600
total_hours = round(practice_hours + training_hours, 1)
# 6. 考试统计
result = await self.db.execute(
select(
func.count(Exam.id),
func.count(case((Exam.is_passed == True, 1))),
func.avg(Exam.score)
)
.where(Exam.status == 'submitted')
)
exam_row = result.first()
exam_count = exam_row[0] or 0
exam_passed = exam_row[1] or 0
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
# 7. 满分人数
result = await self.db.execute(
select(func.count(func.distinct(Exam.user_id)))
.where(Exam.status == 'submitted', Exam.score >= Exam.total_score)
)
perfect_users = result.scalar() or 0
# 8. 签到率(今日签到人数/总用户数)
result = await self.db.execute(
select(func.count(UserLevel.id))
.where(func.date(UserLevel.last_login_date) == today)
)
today_checkin = result.scalar() or 0
checkin_rate = round(today_checkin / total_users * 100, 1) if total_users > 0 else 0
return {
"overview": {
"total_users": total_users,
"today_active": today_active,
"week_active": week_active,
"month_active": month_active,
"total_hours": total_hours,
"checkin_rate": checkin_rate,
},
"exam": {
"total_count": exam_count,
"pass_rate": exam_pass_rate,
"avg_score": exam_avg_score,
"perfect_users": perfect_users,
},
"updated_at": datetime.now().isoformat()
}
async def get_department_comparison(self) -> List[Dict[str, Any]]:
"""
获取部门/团队学习对比数据
Returns:
部门对比列表
"""
# 获取所有岗位及其成员的学习数据
result = await self.db.execute(
select(Position)
.where(Position.is_deleted == False)
.order_by(Position.name)
)
positions = result.scalars().all()
departments = []
for pos in positions:
# 获取该岗位的成员数
result = await self.db.execute(
select(func.count(PositionMember.id))
.where(PositionMember.position_id == pos.id)
)
member_count = result.scalar() or 0
if member_count == 0:
continue
# 获取成员ID列表
result = await self.db.execute(
select(PositionMember.user_id)
.where(PositionMember.position_id == pos.id)
)
member_ids = [row[0] for row in result.all()]
# 统计该岗位成员的学习数据
# 考试通过率
result = await self.db.execute(
select(
func.count(Exam.id),
func.count(case((Exam.is_passed == True, 1)))
)
.where(
Exam.user_id.in_(member_ids),
Exam.status == 'submitted'
)
)
exam_row = result.first()
exam_total = exam_row[0] or 0
exam_passed = exam_row[1] or 0
pass_rate = round(exam_passed / exam_total * 100, 1) if exam_total > 0 else 0
# 平均学习时长
result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(
PracticeSession.user_id.in_(member_ids),
PracticeSession.status == 'completed'
)
)
total_seconds = result.scalar() or 0
avg_hours = round(total_seconds / 3600 / member_count, 1) if member_count > 0 else 0
# 平均等级
result = await self.db.execute(
select(func.avg(UserLevel.level))
.where(UserLevel.user_id.in_(member_ids))
)
avg_level = round(result.scalar() or 1, 1)
departments.append({
"id": pos.id,
"name": pos.name,
"member_count": member_count,
"pass_rate": pass_rate,
"avg_hours": avg_hours,
"avg_level": avg_level,
})
# 按通过率排序
departments.sort(key=lambda x: x["pass_rate"], reverse=True)
return departments
async def get_learning_trend(self, days: int = 7) -> Dict[str, Any]:
"""
获取学习趋势数据
Args:
days: 统计天数
Returns:
趋势数据
"""
today = date.today()
dates = [(today - timedelta(days=i)) for i in range(days-1, -1, -1)]
trend_data = []
for d in dates:
# 当日活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(func.date(ExpHistory.created_at) == d)
)
active_users = result.scalar() or 0
# 当日新增学习时长
result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(
func.date(PracticeSession.created_at) == d,
PracticeSession.status == 'completed'
)
)
hours = round((result.scalar() or 0) / 3600, 1)
# 当日考试次数
result = await self.db.execute(
select(func.count(Exam.id))
.where(
func.date(Exam.created_at) == d,
Exam.status == 'submitted'
)
)
exams = result.scalar() or 0
trend_data.append({
"date": d.isoformat(),
"active_users": active_users,
"learning_hours": hours,
"exam_count": exams,
})
return {
"dates": [d.isoformat() for d in dates],
"trend": trend_data
}
async def get_level_distribution(self) -> Dict[str, Any]:
"""
获取等级分布数据
Returns:
等级分布
"""
result = await self.db.execute(
select(UserLevel.level, func.count(UserLevel.id))
.group_by(UserLevel.level)
.order_by(UserLevel.level)
)
rows = result.all()
distribution = {row[0]: row[1] for row in rows}
# 补全1-10级
for i in range(1, 11):
if i not in distribution:
distribution[i] = 0
return {
"levels": list(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]]:
"""
获取实时动态
Args:
limit: 数量限制
Returns:
实时动态列表
"""
activities = []
# 获取最近的经验值记录
result = await self.db.execute(
select(ExpHistory, User)
.join(User, ExpHistory.user_id == User.id)
.order_by(ExpHistory.created_at.desc())
.limit(limit)
)
rows = result.all()
for exp, user in rows:
activity_type = "学习"
if "考试" in (exp.description or ""):
activity_type = "考试"
elif "签到" in (exp.description or ""):
activity_type = "签到"
elif "陪练" in (exp.description or ""):
activity_type = "陪练"
elif "奖章" in (exp.description or ""):
activity_type = "奖章"
activities.append({
"id": exp.id,
"user_id": user.id,
"user_name": user.full_name or user.username,
"type": activity_type,
"description": exp.description,
"exp_amount": exp.exp_amount,
"created_at": exp.created_at.isoformat() if exp.created_at else None,
})
return activities
async def get_team_dashboard(self, team_leader_id: int) -> Dict[str, Any]:
"""
获取团队级数据大屏
Args:
team_leader_id: 团队负责人ID
Returns:
团队数据
"""
# 获取团队负责人管理的岗位
result = await self.db.execute(
select(Position)
.where(
Position.is_deleted == False,
or_(
Position.manager_id == team_leader_id,
Position.created_by == team_leader_id
)
)
)
positions = result.scalars().all()
position_ids = [p.id for p in positions]
if not position_ids:
return {
"members": [],
"overview": {
"total_members": 0,
"avg_level": 0,
"avg_exp": 0,
"total_badges": 0,
},
"pending_tasks": []
}
# 获取团队成员
result = await self.db.execute(
select(PositionMember.user_id)
.where(PositionMember.position_id.in_(position_ids))
)
member_ids = [row[0] for row in result.all()]
if not member_ids:
return {
"members": [],
"overview": {
"total_members": 0,
"avg_level": 0,
"avg_exp": 0,
"total_badges": 0,
},
"pending_tasks": []
}
# 获取成员详细信息
result = await self.db.execute(
select(User, UserLevel)
.outerjoin(UserLevel, User.id == UserLevel.user_id)
.where(User.id.in_(member_ids))
.order_by(UserLevel.total_exp.desc().nullslast())
)
rows = result.all()
members = []
total_exp = 0
total_level = 0
for user, level in rows:
user_level = level.level if level else 1
user_exp = level.total_exp if level else 0
total_level += user_level
total_exp += user_exp
# 获取用户奖章数
result = await self.db.execute(
select(func.count(UserBadge.id))
.where(UserBadge.user_id == user.id)
)
badge_count = result.scalar() or 0
members.append({
"id": user.id,
"username": user.username,
"full_name": user.full_name,
"avatar_url": user.avatar_url,
"level": user_level,
"total_exp": user_exp,
"badge_count": badge_count,
})
total_members = len(members)
# 获取团队总奖章数
result = await self.db.execute(
select(func.count(UserBadge.id))
.where(UserBadge.user_id.in_(member_ids))
)
total_badges = result.scalar() or 0
return {
"members": members,
"overview": {
"total_members": total_members,
"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,
"total_badges": total_badges,
},
"positions": [{"id": p.id, "name": p.name} for p in positions]
}
async def get_course_ranking(self, limit: int = 10) -> List[Dict[str, Any]]:
"""
获取课程热度排行
Args:
limit: 数量限制
Returns:
课程排行列表
"""
# 这里简化实现,实际应该统计课程学习次数
result = await self.db.execute(
select(Course)
.where(Course.is_deleted == False, Course.is_published == True)
.order_by(Course.created_at.desc())
.limit(limit)
)
courses = result.scalars().all()
ranking = []
for i, course in enumerate(courses, 1):
ranking.append({
"rank": i,
"id": course.id,
"name": course.name,
"description": course.description,
# 这里可以添加实际的学习人数统计
"learners": 0,
})
return ranking
"""
数据大屏服务
提供企业级和团队级数据大屏功能:
- 学习数据概览
- 部门/团队对比
- 趋势分析
- 实时动态
"""
from datetime import datetime, timedelta, date
from typing import Optional, List, Dict, Any
from sqlalchemy import select, func, and_, or_, desc, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logger import get_logger
from app.models.user import User
from app.models.course import Course, CourseMaterial
from app.models.exam import Exam
from app.models.practice import PracticeSession
from app.models.training import TrainingSession, TrainingReport
from app.models.level import UserLevel, ExpHistory, UserBadge
from app.models.position import Position
from app.models.position_member import PositionMember
logger = get_logger(__name__)
class DashboardService:
"""数据大屏服务"""
def __init__(self, db: AsyncSession):
self.db = db
async def get_enterprise_overview(self, enterprise_id: Optional[int] = None) -> Dict[str, Any]:
"""
获取企业级数据概览
Args:
enterprise_id: 企业ID可选用于多租户
Returns:
企业级数据概览
"""
today = date.today()
week_ago = today - timedelta(days=7)
month_ago = today - timedelta(days=30)
# 基础统计
# 1. 总学员数
result = await self.db.execute(
select(func.count(User.id))
.where(User.is_deleted == False, User.role == 'trainee')
)
total_users = result.scalar() or 0
# 2. 今日活跃用户(有经验值记录)
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(func.date(ExpHistory.created_at) == today)
)
today_active = result.scalar() or 0
# 3. 本周活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(ExpHistory.created_at >= datetime.combine(week_ago, datetime.min.time()))
)
week_active = result.scalar() or 0
# 4. 本月活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(ExpHistory.created_at >= datetime.combine(month_ago, datetime.min.time()))
)
month_active = result.scalar() or 0
# 5. 总学习时长(小时)
result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(PracticeSession.status == 'completed')
)
practice_hours = (result.scalar() or 0) / 3600
result = await self.db.execute(
select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0))
.where(TrainingSession.status == 'COMPLETED')
)
training_hours = (result.scalar() or 0) / 3600
total_hours = round(practice_hours + training_hours, 1)
# 6. 考试统计
result = await self.db.execute(
select(
func.count(Exam.id),
func.count(case((Exam.is_passed == True, 1))),
func.avg(Exam.score)
)
.where(Exam.status == 'submitted')
)
exam_row = result.first()
exam_count = exam_row[0] or 0
exam_passed = exam_row[1] or 0
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
# 7. 满分人数
result = await self.db.execute(
select(func.count(func.distinct(Exam.user_id)))
.where(Exam.status == 'submitted', Exam.score >= Exam.total_score)
)
perfect_users = result.scalar() or 0
# 8. 签到率(今日签到人数/总用户数)
result = await self.db.execute(
select(func.count(UserLevel.id))
.where(func.date(UserLevel.last_login_date) == today)
)
today_checkin = result.scalar() or 0
checkin_rate = round(today_checkin / total_users * 100, 1) if total_users > 0 else 0
return {
"overview": {
"total_users": total_users,
"today_active": today_active,
"week_active": week_active,
"month_active": month_active,
"total_hours": total_hours,
"checkin_rate": checkin_rate,
},
"exam": {
"total_count": exam_count,
"pass_rate": exam_pass_rate,
"avg_score": exam_avg_score,
"perfect_users": perfect_users,
},
"updated_at": datetime.now().isoformat()
}
async def get_department_comparison(self) -> List[Dict[str, Any]]:
"""
获取部门/团队学习对比数据
Returns:
部门对比列表
"""
# 获取所有岗位及其成员的学习数据
result = await self.db.execute(
select(Position)
.where(Position.is_deleted == False)
.order_by(Position.name)
)
positions = result.scalars().all()
departments = []
for pos in positions:
# 获取该岗位的成员数
result = await self.db.execute(
select(func.count(PositionMember.id))
.where(PositionMember.position_id == pos.id)
)
member_count = result.scalar() or 0
if member_count == 0:
continue
# 获取成员ID列表
result = await self.db.execute(
select(PositionMember.user_id)
.where(PositionMember.position_id == pos.id)
)
member_ids = [row[0] for row in result.all()]
# 统计该岗位成员的学习数据
# 考试通过率
result = await self.db.execute(
select(
func.count(Exam.id),
func.count(case((Exam.is_passed == True, 1)))
)
.where(
Exam.user_id.in_(member_ids),
Exam.status == 'submitted'
)
)
exam_row = result.first()
exam_total = exam_row[0] or 0
exam_passed = exam_row[1] or 0
pass_rate = round(exam_passed / exam_total * 100, 1) if exam_total > 0 else 0
# 平均学习时长
result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(
PracticeSession.user_id.in_(member_ids),
PracticeSession.status == 'completed'
)
)
total_seconds = result.scalar() or 0
avg_hours = round(total_seconds / 3600 / member_count, 1) if member_count > 0 else 0
# 平均等级
result = await self.db.execute(
select(func.avg(UserLevel.level))
.where(UserLevel.user_id.in_(member_ids))
)
avg_level = round(result.scalar() or 1, 1)
departments.append({
"id": pos.id,
"name": pos.name,
"member_count": member_count,
"pass_rate": pass_rate,
"avg_hours": avg_hours,
"avg_level": avg_level,
})
# 按通过率排序
departments.sort(key=lambda x: x["pass_rate"], reverse=True)
return departments
async def get_learning_trend(self, days: int = 7) -> Dict[str, Any]:
"""
获取学习趋势数据
Args:
days: 统计天数
Returns:
趋势数据
"""
today = date.today()
dates = [(today - timedelta(days=i)) for i in range(days-1, -1, -1)]
trend_data = []
for d in dates:
# 当日活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(func.date(ExpHistory.created_at) == d)
)
active_users = result.scalar() or 0
# 当日新增学习时长
result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(
func.date(PracticeSession.created_at) == d,
PracticeSession.status == 'completed'
)
)
hours = round((result.scalar() or 0) / 3600, 1)
# 当日考试次数
result = await self.db.execute(
select(func.count(Exam.id))
.where(
func.date(Exam.created_at) == d,
Exam.status == 'submitted'
)
)
exams = result.scalar() or 0
trend_data.append({
"date": d.isoformat(),
"active_users": active_users,
"learning_hours": hours,
"exam_count": exams,
})
return {
"dates": [d.isoformat() for d in dates],
"trend": trend_data
}
async def get_level_distribution(self) -> Dict[str, Any]:
"""
获取等级分布数据
Returns:
等级分布
"""
result = await self.db.execute(
select(UserLevel.level, func.count(UserLevel.id))
.group_by(UserLevel.level)
.order_by(UserLevel.level)
)
rows = result.all()
distribution = {row[0]: row[1] for row in rows}
# 补全1-10级
for i in range(1, 11):
if i not in distribution:
distribution[i] = 0
return {
"levels": list(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]]:
"""
获取实时动态
Args:
limit: 数量限制
Returns:
实时动态列表
"""
activities = []
# 获取最近的经验值记录
result = await self.db.execute(
select(ExpHistory, User)
.join(User, ExpHistory.user_id == User.id)
.order_by(ExpHistory.created_at.desc())
.limit(limit)
)
rows = result.all()
for exp, user in rows:
activity_type = "学习"
if "考试" in (exp.description or ""):
activity_type = "考试"
elif "签到" in (exp.description or ""):
activity_type = "签到"
elif "陪练" in (exp.description or ""):
activity_type = "陪练"
elif "奖章" in (exp.description or ""):
activity_type = "奖章"
activities.append({
"id": exp.id,
"user_id": user.id,
"user_name": user.full_name or user.username,
"type": activity_type,
"description": exp.description,
"exp_amount": exp.exp_amount,
"created_at": exp.created_at.isoformat() if exp.created_at else None,
})
return activities
async def get_team_dashboard(self, team_leader_id: int) -> Dict[str, Any]:
"""
获取团队级数据大屏
Args:
team_leader_id: 团队负责人ID
Returns:
团队数据
"""
# 获取团队负责人管理的岗位
result = await self.db.execute(
select(Position)
.where(
Position.is_deleted == False,
or_(
Position.manager_id == team_leader_id,
Position.created_by == team_leader_id
)
)
)
positions = result.scalars().all()
position_ids = [p.id for p in positions]
if not position_ids:
return {
"members": [],
"overview": {
"total_members": 0,
"avg_level": 0,
"avg_exp": 0,
"total_badges": 0,
},
"pending_tasks": []
}
# 获取团队成员
result = await self.db.execute(
select(PositionMember.user_id)
.where(PositionMember.position_id.in_(position_ids))
)
member_ids = [row[0] for row in result.all()]
if not member_ids:
return {
"members": [],
"overview": {
"total_members": 0,
"avg_level": 0,
"avg_exp": 0,
"total_badges": 0,
},
"pending_tasks": []
}
# 获取成员详细信息
result = await self.db.execute(
select(User, UserLevel)
.outerjoin(UserLevel, User.id == UserLevel.user_id)
.where(User.id.in_(member_ids))
.order_by(UserLevel.total_exp.desc().nullslast())
)
rows = result.all()
members = []
total_exp = 0
total_level = 0
for user, level in rows:
user_level = level.level if level else 1
user_exp = level.total_exp if level else 0
total_level += user_level
total_exp += user_exp
# 获取用户奖章数
result = await self.db.execute(
select(func.count(UserBadge.id))
.where(UserBadge.user_id == user.id)
)
badge_count = result.scalar() or 0
members.append({
"id": user.id,
"username": user.username,
"full_name": user.full_name,
"avatar_url": user.avatar_url,
"level": user_level,
"total_exp": user_exp,
"badge_count": badge_count,
})
total_members = len(members)
# 获取团队总奖章数
result = await self.db.execute(
select(func.count(UserBadge.id))
.where(UserBadge.user_id.in_(member_ids))
)
total_badges = result.scalar() or 0
return {
"members": members,
"overview": {
"total_members": total_members,
"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,
"total_badges": total_badges,
},
"positions": [{"id": p.id, "name": p.name} for p in positions]
}
async def get_course_ranking(self, limit: int = 10) -> List[Dict[str, Any]]:
"""
获取课程热度排行
Args:
limit: 数量限制
Returns:
课程排行列表
"""
# 这里简化实现,实际应该统计课程学习次数
result = await self.db.execute(
select(Course)
.where(Course.is_deleted == False, Course.is_published == True)
.order_by(Course.created_at.desc())
.limit(limit)
)
courses = result.scalars().all()
ranking = []
for i, course in enumerate(courses, 1):
ranking.append({
"rank": i,
"id": course.id,
"name": course.name,
"description": course.description,
# 这里可以添加实际的学习人数统计
"learners": 0,
})
return ranking

View File

@@ -1,302 +1,302 @@
"""
钉钉认证服务
提供钉钉免密登录功能,从数据库读取配置
"""
import json
import time
from typing import Optional, Dict, Any, Tuple
import httpx
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logger import get_logger
from app.core.security import create_access_token, create_refresh_token
from app.models.user import User
from app.schemas.auth import Token
from app.services.user_service import UserService
logger = get_logger(__name__)
# 钉钉API地址
DINGTALK_API_BASE = "https://oapi.dingtalk.com"
class DingtalkAuthService:
"""钉钉认证服务"""
def __init__(self, db: AsyncSession):
self.db = db
self.user_service = UserService(db)
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]:
"""
从数据库获取钉钉配置
Args:
tenant_id: 租户ID
Returns:
配置字典 {app_key, app_secret, agent_id, corp_id}
"""
result = await self.db.execute(
text("""
SELECT config_key, config_value
FROM tenant_configs
WHERE tenant_id = :tenant_id AND config_group = 'dingtalk'
"""),
{"tenant_id": tenant_id}
)
rows = result.fetchall()
config = {}
key_mapping = {
"DINGTALK_APP_KEY": "app_key",
"DINGTALK_APP_SECRET": "app_secret",
"DINGTALK_AGENT_ID": "agent_id",
"DINGTALK_CORP_ID": "corp_id",
}
for row in rows:
if row[0] in key_mapping:
config[key_mapping[row[0]]] = row[1]
return config
async def is_dingtalk_login_enabled(self, tenant_id: int) -> bool:
"""
检查钉钉免密登录功能是否启用
Args:
tenant_id: 租户ID
Returns:
是否启用
"""
# 先查租户级别的配置
result = await self.db.execute(
text("""
SELECT is_enabled FROM feature_switches
WHERE feature_code = 'dingtalk_login'
AND (tenant_id = :tenant_id OR tenant_id IS NULL)
ORDER BY tenant_id DESC
LIMIT 1
"""),
{"tenant_id": tenant_id}
)
row = result.fetchone()
if row:
return bool(row[0])
return False
async def get_access_token(self, tenant_id: int) -> str:
"""
获取钉钉访问令牌(带内存缓存)
Args:
tenant_id: 租户ID
Returns:
access_token
Raises:
Exception: 获取失败时抛出异常
"""
# 检查缓存
if tenant_id in self._access_token_cache:
token, expire_time = self._access_token_cache[tenant_id]
if time.time() < expire_time - 300: # 提前5分钟刷新
return token
# 获取配置
config = await self.get_dingtalk_config(tenant_id)
if not config.get("app_key") or not config.get("app_secret"):
raise ValueError("钉钉配置不完整请在管理后台配置AppKey和AppSecret")
# 调用钉钉API获取token
url = f"{DINGTALK_API_BASE}/gettoken"
params = {
"appkey": config["app_key"],
"appsecret": config["app_secret"],
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, params=params)
data = response.json()
if data.get("errcode") != 0:
error_msg = data.get("errmsg", "未知错误")
logger.error(f"获取钉钉access_token失败: {error_msg}")
raise Exception(f"获取钉钉access_token失败: {error_msg}")
access_token = data["access_token"]
expires_in = data.get("expires_in", 7200)
# 缓存token
self._access_token_cache[tenant_id] = (access_token, time.time() + expires_in)
logger.info(f"获取钉钉access_token成功有效期: {expires_in}")
return access_token
async def get_user_info_by_code(self, tenant_id: int, code: str) -> Dict[str, Any]:
"""
通过免登码获取钉钉用户信息
Args:
tenant_id: 租户ID
code: 免登授权码
Returns:
用户信息 {userid, name, ...}
Raises:
Exception: 获取失败时抛出异常
"""
access_token = await self.get_access_token(tenant_id)
url = f"{DINGTALK_API_BASE}/topapi/v2/user/getuserinfo"
params = {"access_token": access_token}
payload = {"code": code}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, params=params, json=payload)
data = response.json()
if data.get("errcode") != 0:
error_msg = data.get("errmsg", "未知错误")
logger.error(f"通过code获取钉钉用户信息失败: {error_msg}")
raise Exception(f"获取钉钉用户信息失败: {error_msg}")
result = data.get("result", {})
logger.info(f"获取钉钉用户信息成功: userid={result.get('userid')}, name={result.get('name')}")
return result
async def get_user_detail(self, tenant_id: int, userid: str) -> Dict[str, Any]:
"""
获取钉钉用户详细信息
Args:
tenant_id: 租户ID
userid: 钉钉用户ID
Returns:
用户详细信息
"""
access_token = await self.get_access_token(tenant_id)
url = f"{DINGTALK_API_BASE}/topapi/v2/user/get"
params = {"access_token": access_token}
payload = {"userid": userid}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, params=params, json=payload)
data = response.json()
if data.get("errcode") != 0:
error_msg = data.get("errmsg", "未知错误")
logger.warning(f"获取钉钉用户详情失败: {error_msg}")
return {}
return data.get("result", {})
async def dingtalk_login(self, tenant_id: int, code: str) -> Tuple[User, Token]:
"""
钉钉免密登录主流程
Args:
tenant_id: 租户ID
code: 免登授权码
Returns:
(用户对象, Token对象)
Raises:
Exception: 登录失败时抛出异常
"""
# 1. 检查功能是否启用
if not await self.is_dingtalk_login_enabled(tenant_id):
raise Exception("钉钉免密登录功能未启用")
# 2. 通过code获取钉钉用户信息
dingtalk_user = await self.get_user_info_by_code(tenant_id, code)
dingtalk_userid = dingtalk_user.get("userid")
if not dingtalk_userid:
raise Exception("无法获取钉钉用户ID")
# 3. 根据dingtalk_id查找系统用户
logger.info(f"开始查找用户钉钉userid: {dingtalk_userid}")
user = await self.user_service.get_by_dingtalk_id(dingtalk_userid)
if not user:
logger.info(f"通过dingtalk_id未找到用户尝试手机号匹配")
# 尝试通过手机号匹配
user_detail = await self.get_user_detail(tenant_id, dingtalk_userid)
mobile = user_detail.get("mobile")
logger.info(f"获取到钉钉用户手机号: {mobile}")
if mobile:
user = await self.user_service.get_by_phone(mobile)
if user:
# 绑定dingtalk_id
user.dingtalk_id = dingtalk_userid
await self.db.commit()
logger.info(f"通过手机号匹配成功已绑定dingtalk_id: {dingtalk_userid}")
else:
logger.warning(f"通过手机号 {mobile} 也未找到用户")
else:
logger.warning("无法获取钉钉用户手机号")
if not user:
logger.error(f"钉钉登录失败dingtalk_userid={dingtalk_userid}, 未找到对应用户")
raise Exception("未找到对应的系统用户,请联系管理员")
if not user.is_active:
raise Exception("用户已被禁用")
# 4. 生成JWT Token
access_token = create_access_token(subject=user.id)
refresh_token = create_refresh_token(subject=user.id)
# 5. 更新最后登录时间
await self.user_service.update_last_login(user.id)
logger.info(f"钉钉免密登录成功: user_id={user.id}, username={user.username}")
return user, Token(
access_token=access_token,
refresh_token=refresh_token,
)
async def get_public_config(self, tenant_id: int) -> Dict[str, Any]:
"""
获取钉钉公开配置前端需要用于初始化JSDK
Args:
tenant_id: 租户ID
Returns:
{corp_id, agent_id, enabled}
"""
enabled = await self.is_dingtalk_login_enabled(tenant_id)
if not enabled:
return {
"enabled": False,
"corp_id": None,
"agent_id": None,
}
config = await self.get_dingtalk_config(tenant_id)
return {
"enabled": True,
"corp_id": config.get("corp_id"),
"agent_id": config.get("agent_id"),
}
"""
钉钉认证服务
提供钉钉免密登录功能,从数据库读取配置
"""
import json
import time
from typing import Optional, Dict, Any, Tuple
import httpx
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logger import get_logger
from app.core.security import create_access_token, create_refresh_token
from app.models.user import User
from app.schemas.auth import Token
from app.services.user_service import UserService
logger = get_logger(__name__)
# 钉钉API地址
DINGTALK_API_BASE = "https://oapi.dingtalk.com"
class DingtalkAuthService:
"""钉钉认证服务"""
def __init__(self, db: AsyncSession):
self.db = db
self.user_service = UserService(db)
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]:
"""
从数据库获取钉钉配置
Args:
tenant_id: 租户ID
Returns:
配置字典 {app_key, app_secret, agent_id, corp_id}
"""
result = await self.db.execute(
text("""
SELECT config_key, config_value
FROM tenant_configs
WHERE tenant_id = :tenant_id AND config_group = 'dingtalk'
"""),
{"tenant_id": tenant_id}
)
rows = result.fetchall()
config = {}
key_mapping = {
"DINGTALK_APP_KEY": "app_key",
"DINGTALK_APP_SECRET": "app_secret",
"DINGTALK_AGENT_ID": "agent_id",
"DINGTALK_CORP_ID": "corp_id",
}
for row in rows:
if row[0] in key_mapping:
config[key_mapping[row[0]]] = row[1]
return config
async def is_dingtalk_login_enabled(self, tenant_id: int) -> bool:
"""
检查钉钉免密登录功能是否启用
Args:
tenant_id: 租户ID
Returns:
是否启用
"""
# 先查租户级别的配置
result = await self.db.execute(
text("""
SELECT is_enabled FROM feature_switches
WHERE feature_code = 'dingtalk_login'
AND (tenant_id = :tenant_id OR tenant_id IS NULL)
ORDER BY tenant_id DESC
LIMIT 1
"""),
{"tenant_id": tenant_id}
)
row = result.fetchone()
if row:
return bool(row[0])
return False
async def get_access_token(self, tenant_id: int) -> str:
"""
获取钉钉访问令牌(带内存缓存)
Args:
tenant_id: 租户ID
Returns:
access_token
Raises:
Exception: 获取失败时抛出异常
"""
# 检查缓存
if tenant_id in self._access_token_cache:
token, expire_time = self._access_token_cache[tenant_id]
if time.time() < expire_time - 300: # 提前5分钟刷新
return token
# 获取配置
config = await self.get_dingtalk_config(tenant_id)
if not config.get("app_key") or not config.get("app_secret"):
raise ValueError("钉钉配置不完整请在管理后台配置AppKey和AppSecret")
# 调用钉钉API获取token
url = f"{DINGTALK_API_BASE}/gettoken"
params = {
"appkey": config["app_key"],
"appsecret": config["app_secret"],
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, params=params)
data = response.json()
if data.get("errcode") != 0:
error_msg = data.get("errmsg", "未知错误")
logger.error(f"获取钉钉access_token失败: {error_msg}")
raise Exception(f"获取钉钉access_token失败: {error_msg}")
access_token = data["access_token"]
expires_in = data.get("expires_in", 7200)
# 缓存token
self._access_token_cache[tenant_id] = (access_token, time.time() + expires_in)
logger.info(f"获取钉钉access_token成功有效期: {expires_in}")
return access_token
async def get_user_info_by_code(self, tenant_id: int, code: str) -> Dict[str, Any]:
"""
通过免登码获取钉钉用户信息
Args:
tenant_id: 租户ID
code: 免登授权码
Returns:
用户信息 {userid, name, ...}
Raises:
Exception: 获取失败时抛出异常
"""
access_token = await self.get_access_token(tenant_id)
url = f"{DINGTALK_API_BASE}/topapi/v2/user/getuserinfo"
params = {"access_token": access_token}
payload = {"code": code}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, params=params, json=payload)
data = response.json()
if data.get("errcode") != 0:
error_msg = data.get("errmsg", "未知错误")
logger.error(f"通过code获取钉钉用户信息失败: {error_msg}")
raise Exception(f"获取钉钉用户信息失败: {error_msg}")
result = data.get("result", {})
logger.info(f"获取钉钉用户信息成功: userid={result.get('userid')}, name={result.get('name')}")
return result
async def get_user_detail(self, tenant_id: int, userid: str) -> Dict[str, Any]:
"""
获取钉钉用户详细信息
Args:
tenant_id: 租户ID
userid: 钉钉用户ID
Returns:
用户详细信息
"""
access_token = await self.get_access_token(tenant_id)
url = f"{DINGTALK_API_BASE}/topapi/v2/user/get"
params = {"access_token": access_token}
payload = {"userid": userid}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, params=params, json=payload)
data = response.json()
if data.get("errcode") != 0:
error_msg = data.get("errmsg", "未知错误")
logger.warning(f"获取钉钉用户详情失败: {error_msg}")
return {}
return data.get("result", {})
async def dingtalk_login(self, tenant_id: int, code: str) -> Tuple[User, Token]:
"""
钉钉免密登录主流程
Args:
tenant_id: 租户ID
code: 免登授权码
Returns:
(用户对象, Token对象)
Raises:
Exception: 登录失败时抛出异常
"""
# 1. 检查功能是否启用
if not await self.is_dingtalk_login_enabled(tenant_id):
raise Exception("钉钉免密登录功能未启用")
# 2. 通过code获取钉钉用户信息
dingtalk_user = await self.get_user_info_by_code(tenant_id, code)
dingtalk_userid = dingtalk_user.get("userid")
if not dingtalk_userid:
raise Exception("无法获取钉钉用户ID")
# 3. 根据dingtalk_id查找系统用户
logger.info(f"开始查找用户钉钉userid: {dingtalk_userid}")
user = await self.user_service.get_by_dingtalk_id(dingtalk_userid)
if not user:
logger.info(f"通过dingtalk_id未找到用户尝试手机号匹配")
# 尝试通过手机号匹配
user_detail = await self.get_user_detail(tenant_id, dingtalk_userid)
mobile = user_detail.get("mobile")
logger.info(f"获取到钉钉用户手机号: {mobile}")
if mobile:
user = await self.user_service.get_by_phone(mobile)
if user:
# 绑定dingtalk_id
user.dingtalk_id = dingtalk_userid
await self.db.commit()
logger.info(f"通过手机号匹配成功已绑定dingtalk_id: {dingtalk_userid}")
else:
logger.warning(f"通过手机号 {mobile} 也未找到用户")
else:
logger.warning("无法获取钉钉用户手机号")
if not user:
logger.error(f"钉钉登录失败dingtalk_userid={dingtalk_userid}, 未找到对应用户")
raise Exception("未找到对应的系统用户,请联系管理员")
if not user.is_active:
raise Exception("用户已被禁用")
# 4. 生成JWT Token
access_token = create_access_token(subject=user.id)
refresh_token = create_refresh_token(subject=user.id)
# 5. 更新最后登录时间
await self.user_service.update_last_login(user.id)
logger.info(f"钉钉免密登录成功: user_id={user.id}, username={user.username}")
return user, Token(
access_token=access_token,
refresh_token=refresh_token,
)
async def get_public_config(self, tenant_id: int) -> Dict[str, Any]:
"""
获取钉钉公开配置前端需要用于初始化JSDK
Args:
tenant_id: 租户ID
Returns:
{corp_id, agent_id, enabled}
"""
enabled = await self.is_dingtalk_login_enabled(tenant_id)
if not enabled:
return {
"enabled": False,
"corp_id": None,
"agent_id": None,
}
config = await self.get_dingtalk_config(tenant_id)
return {
"enabled": True,
"corp_id": config.get("corp_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
from sqlalchemy import select, and_, desc, func, update
from sqlalchemy.orm import selectinload
import os
import json
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
import httpx
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.schemas.notification import (
NotificationCreate,
NotificationBatchCreate,
NotificationResponse,
NotificationType,
)
from app.services.base_service import BaseService
from app.models.notification import Notification
logger = get_logger(__name__)
logger = logging.getLogger(__name__)
class NotificationService(BaseService[Notification]):
"""
站内消息通知服务
class NotificationChannel:
"""通知渠道基类"""
提供通知的创建、查询、标记已读等功能
"""
def __init__(self):
super().__init__(Notification)
async def create_notification(
async def send(
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,
skip: int = 0,
limit: int = 20,
is_read: Optional[bool] = None,
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
title: str,
content: str,
**kwargs
) -> bool:
"""
删除通知
发送通知
Args:
db: 数据库会话
user_id: 用户ID
notification_id: 通知ID
title: 通知标题
content: 通知内容
Returns:
是否删除成功
是否发送成功
"""
stmt = select(Notification).where(
and_(
Notification.id == notification_id,
Notification.user_id == user_id
raise NotImplementedError
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)
notification = result.scalar_one_or_none()
if notification:
await db.delete(notification)
await db.commit()
logger.info(
"删除通知成功",
notification_id=notification_id,
user_id=user_id
)
return True
return False
return await self.send_notification(
user_id=user_id,
title=title,
content=content,
channels=["inapp", "dingtalk", "wework"],
notification_type="weekly_report",
)
async def _get_user(self, user_id: int) -> Optional[User]:
"""获取用户信息"""
result = await self.db.execute(
select(User).where(User.id == user_id)
)
return result.scalar_one_or_none()
# 创建服务实例
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 考培练系统的数据库迁移脚本。
## 迁移脚本列表
| 脚本 | 说明 | 创建时间 |
|------|------|----------|
| `add_level_badge_system.sql` | 等级与奖章系统 | 2026-01-29 |
## 执行迁移
### 测试环境Docker
KPL 测试环境数据库在服务器 Docker 容器中运行:
```bash
# 1. SSH 登录 KPL 服务器
ssh root@<KPL服务器IP>
# 2. 进入项目目录
cd /www/wwwroot/kpl.ireborn.com.cn
# 3. 执行迁移(方法一:直接执行)
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
# 然后复制粘贴 SQL 脚本内容执行
# 方法三从本地执行需要先上传SQL文件到服务器
# 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"
```
**注意**MySQL 容器密码为 `nj861021`(之前通过 `docker exec kpl-mysql-dev env | grep MYSQL` 确认)
### 生产环境
生产环境迁移前请确保:
1. 已备份数据库
2. 在低峰期执行
3. 测试环境验证通过
```bash
# 执行迁移(替换为实际的生产数据库配置)
mysql -h<host> -u<user> -p<password> <database> < backend/migrations/add_level_badge_system.sql
```
## 回滚方法
如需回滚,执行以下 SQL
```sql
DROP TABLE IF EXISTS user_badges;
DROP TABLE IF EXISTS badge_definitions;
DROP TABLE IF EXISTS exp_history;
DROP TABLE IF EXISTS level_configs;
DROP TABLE IF EXISTS user_levels;
```
## 验证迁移
执行以下查询验证表是否创建成功:
```sql
SHOW TABLES LIKE '%level%';
SHOW TABLES LIKE '%badge%';
SHOW TABLES LIKE '%exp%';
-- 查看表结构
DESCRIBE user_levels;
DESCRIBE exp_history;
DESCRIBE badge_definitions;
DESCRIBE user_badges;
DESCRIBE level_configs;
-- 验证初始数据
SELECT COUNT(*) FROM level_configs; -- 应该是 10 条
SELECT COUNT(*) FROM badge_definitions; -- 应该是 20 条
SELECT COUNT(*) FROM user_levels; -- 应该等于用户数
```
# 数据库迁移说明
本目录包含 KPL 考培练系统的数据库迁移脚本。
## 迁移脚本列表
| 脚本 | 说明 | 创建时间 |
|------|------|----------|
| `add_level_badge_system.sql` | 等级与奖章系统 | 2026-01-29 |
## 执行迁移
### 测试环境Docker
KPL 测试环境数据库在服务器 Docker 容器中运行:
```bash
# 1. SSH 登录 KPL 服务器
ssh root@<KPL服务器IP>
# 2. 进入项目目录
cd /www/wwwroot/kpl.ireborn.com.cn
# 3. 执行迁移(方法一:直接执行)
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
# 然后复制粘贴 SQL 脚本内容执行
# 方法三从本地执行需要先上传SQL文件到服务器
# 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"
```
**注意**MySQL 容器密码为 `nj861021`(之前通过 `docker exec kpl-mysql-dev env | grep MYSQL` 确认)
### 生产环境
生产环境迁移前请确保:
1. 已备份数据库
2. 在低峰期执行
3. 测试环境验证通过
```bash
# 执行迁移(替换为实际的生产数据库配置)
mysql -h<host> -u<user> -p<password> <database> < backend/migrations/add_level_badge_system.sql
```
## 回滚方法
如需回滚,执行以下 SQL
```sql
DROP TABLE IF EXISTS user_badges;
DROP TABLE IF EXISTS badge_definitions;
DROP TABLE IF EXISTS exp_history;
DROP TABLE IF EXISTS level_configs;
DROP TABLE IF EXISTS user_levels;
```
## 验证迁移
执行以下查询验证表是否创建成功:
```sql
SHOW TABLES LIKE '%level%';
SHOW TABLES LIKE '%badge%';
SHOW TABLES LIKE '%exp%';
-- 查看表结构
DESCRIBE user_levels;
DESCRIBE exp_history;
DESCRIBE badge_definitions;
DESCRIBE user_badges;
DESCRIBE level_configs;
-- 验证初始数据
SELECT COUNT(*) FROM level_configs; -- 应该是 10 条
SELECT COUNT(*) FROM badge_definitions; -- 应该是 20 条
SELECT COUNT(*) FROM user_levels; -- 应该等于用户数
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,182 +1,182 @@
/**
* 等级与奖章 API
*/
import request from '@/api/request'
// 类型定义
export interface LevelInfo {
user_id: number
level: number
exp: number
total_exp: number
title: string
color: string
login_streak: number
max_login_streak: number
last_checkin_at: string | null
next_level_exp: number
exp_to_next_level: number
is_max_level: boolean
}
export interface ExpHistoryItem {
id: number
exp_change: number
exp_type: string
description: string
level_before: number | null
level_after: number | null
created_at: string
}
export interface LeaderboardItem {
rank: number
user_id: number
username: string
full_name: string | null
avatar_url: string | null
level: number
title: string
color: string
total_exp: number
login_streak: number
}
export interface Badge {
id: number
code: string
name: string
description: string
icon: string
category: string
condition_type?: string
condition_value?: number
exp_reward: number
unlocked?: boolean
unlocked_at?: string | null
}
export interface CheckinResult {
success: boolean
message: string
exp_gained: number
base_exp?: number
bonus_exp?: number
login_streak: number
leveled_up?: boolean
new_level?: number | null
already_checked_in?: boolean
new_badges?: Badge[]
}
// API 函数
/**
* 获取当前用户等级信息
*/
export function getMyLevel() {
return request.get<LevelInfo>('/level/me')
}
/**
* 获取指定用户等级信息
*/
export function getUserLevel(userId: number) {
return request.get<LevelInfo>(`/level/user/${userId}`)
}
/**
* 每日签到
*/
export function dailyCheckin() {
return request.post<CheckinResult>('/level/checkin')
}
/**
* 获取经验值历史
*/
export function getExpHistory(params?: {
limit?: number
offset?: number
exp_type?: string
}) {
return request.get<{
items: ExpHistoryItem[]
total: number
limit: number
offset: number
}>('/level/exp-history', { params })
}
/**
* 获取等级排行榜
*/
export function getLeaderboard(params?: {
limit?: number
offset?: number
}) {
return request.get<{
items: LeaderboardItem[]
total: number
limit: number
offset: number
my_rank: number
my_level_info: LevelInfo
}>('/level/leaderboard', { params })
}
/**
* 获取所有奖章定义
*/
export function getAllBadges() {
return request.get<Badge[]>('/level/badges/all')
}
/**
* 获取用户奖章(含解锁状态)
*/
export function getMyBadges() {
return request.get<{
badges: Badge[]
total: number
unlocked_count: number
}>('/level/badges/me')
}
/**
* 获取未通知的新奖章
*/
export function getUnnotifiedBadges() {
return request.get<Badge[]>('/level/badges/unnotified')
}
/**
* 标记奖章为已通知
*/
export function markBadgesNotified(badgeIds?: number[]) {
return request.post('/level/badges/mark-notified', badgeIds)
}
/**
* 手动检查并授予奖章
*/
export function checkAndAwardBadges() {
return request.post<{
new_badges: Badge[]
count: number
}>('/level/check-badges')
}
export default {
getMyLevel,
getUserLevel,
dailyCheckin,
getExpHistory,
getLeaderboard,
getAllBadges,
getMyBadges,
getUnnotifiedBadges,
markBadgesNotified,
checkAndAwardBadges
}
/**
* 等级与奖章 API
*/
import request from '@/api/request'
// 类型定义
export interface LevelInfo {
user_id: number
level: number
exp: number
total_exp: number
title: string
color: string
login_streak: number
max_login_streak: number
last_checkin_at: string | null
next_level_exp: number
exp_to_next_level: number
is_max_level: boolean
}
export interface ExpHistoryItem {
id: number
exp_change: number
exp_type: string
description: string
level_before: number | null
level_after: number | null
created_at: string
}
export interface LeaderboardItem {
rank: number
user_id: number
username: string
full_name: string | null
avatar_url: string | null
level: number
title: string
color: string
total_exp: number
login_streak: number
}
export interface Badge {
id: number
code: string
name: string
description: string
icon: string
category: string
condition_type?: string
condition_value?: number
exp_reward: number
unlocked?: boolean
unlocked_at?: string | null
}
export interface CheckinResult {
success: boolean
message: string
exp_gained: number
base_exp?: number
bonus_exp?: number
login_streak: number
leveled_up?: boolean
new_level?: number | null
already_checked_in?: boolean
new_badges?: Badge[]
}
// API 函数
/**
* 获取当前用户等级信息
*/
export function getMyLevel() {
return request.get<LevelInfo>('/level/me')
}
/**
* 获取指定用户等级信息
*/
export function getUserLevel(userId: number) {
return request.get<LevelInfo>(`/level/user/${userId}`)
}
/**
* 每日签到
*/
export function dailyCheckin() {
return request.post<CheckinResult>('/level/checkin')
}
/**
* 获取经验值历史
*/
export function getExpHistory(params?: {
limit?: number
offset?: number
exp_type?: string
}) {
return request.get<{
items: ExpHistoryItem[]
total: number
limit: number
offset: number
}>('/level/exp-history', { params })
}
/**
* 获取等级排行榜
*/
export function getLeaderboard(params?: {
limit?: number
offset?: number
}) {
return request.get<{
items: LeaderboardItem[]
total: number
limit: number
offset: number
my_rank: number
my_level_info: LevelInfo
}>('/level/leaderboard', { params })
}
/**
* 获取所有奖章定义
*/
export function getAllBadges() {
return request.get<Badge[]>('/level/badges/all')
}
/**
* 获取用户奖章(含解锁状态)
*/
export function getMyBadges() {
return request.get<{
badges: Badge[]
total: number
unlocked_count: number
}>('/level/badges/me')
}
/**
* 获取未通知的新奖章
*/
export function getUnnotifiedBadges() {
return request.get<Badge[]>('/level/badges/unnotified')
}
/**
* 标记奖章为已通知
*/
export function markBadgesNotified(badgeIds?: number[]) {
return request.post('/level/badges/mark-notified', badgeIds)
}
/**
* 手动检查并授予奖章
*/
export function checkAndAwardBadges() {
return request.post<{
new_badges: Badge[]
count: number
}>('/level/check-badges')
}
export default {
getMyLevel,
getUserLevel,
dailyCheckin,
getExpHistory,
getLeaderboard,
getAllBadges,
getMyBadges,
getUnnotifiedBadges,
markBadgesNotified,
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>
<div
class="badge-card"
:class="{ unlocked, locked: !unlocked }"
@click="handleClick"
>
<div class="badge-icon">
<el-icon :size="iconSize">
<component :is="iconComponent" />
</el-icon>
</div>
<div class="badge-info">
<div class="badge-name">{{ name }}</div>
<div class="badge-desc">{{ description }}</div>
<div class="badge-reward" v-if="expReward > 0 && !unlocked">
+{{ expReward }} 经验
</div>
<div class="badge-unlock-time" v-if="unlocked && unlockedAt">
{{ formatDate(unlockedAt) }}解锁
</div>
</div>
<div class="badge-status" v-if="!unlocked">
<el-icon><Lock /></el-icon>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
Lock, Medal, Star, Reading, Collection, Files,
Select, Finished, Trophy, TrendCharts, Clock,
Timer, Stopwatch, Operation, Calendar, Rank,
Headset, StarFilled
} from '@element-plus/icons-vue'
interface Props {
code: string
name: string
description: string
icon: string
category: string
expReward?: number
unlocked?: boolean
unlockedAt?: string | null
size?: 'small' | 'medium' | 'large'
}
const props = withDefaults(defineProps<Props>(), {
expReward: 0,
unlocked: false,
unlockedAt: null,
size: 'medium'
})
const emit = defineEmits<{
(e: 'click', badge: Props): void
}>()
// 图标映射
const iconMap: Record<string, any> = {
Medal, Star, Reading, Collection, Files, Select,
Finished, Trophy, TrendCharts, Clock, Timer,
Stopwatch, Operation, Calendar, Rank,
Headset, StarFilled, Lock
}
const iconComponent = computed(() => {
return iconMap[props.icon] || Medal
})
const sizeMap = {
small: 24,
medium: 32,
large: 48
}
const iconSize = computed(() => sizeMap[props.size])
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return `${date.getMonth() + 1}/${date.getDate()}`
}
const handleClick = () => {
emit('click', props)
}
</script>
<style scoped lang="scss">
.badge-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 8px;
background-color: #fff;
border: 1px solid #EBEEF5;
cursor: pointer;
transition: all 0.3s;
position: relative;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.unlocked {
.badge-icon {
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
color: #fff;
}
}
&.locked {
opacity: 0.6;
.badge-icon {
background-color: #F5F7FA;
color: #C0C4CC;
}
.badge-name {
color: #909399;
}
}
.badge-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.badge-info {
flex: 1;
min-width: 0;
.badge-name {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.badge-desc {
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge-reward {
font-size: 12px;
color: #E6A23C;
margin-top: 4px;
}
.badge-unlock-time {
font-size: 12px;
color: #67C23A;
margin-top: 4px;
}
}
.badge-status {
color: #C0C4CC;
font-size: 20px;
}
}
</style>
<template>
<div
class="badge-card"
:class="{ unlocked, locked: !unlocked }"
@click="handleClick"
>
<div class="badge-icon">
<el-icon :size="iconSize">
<component :is="iconComponent" />
</el-icon>
</div>
<div class="badge-info">
<div class="badge-name">{{ name }}</div>
<div class="badge-desc">{{ description }}</div>
<div class="badge-reward" v-if="expReward > 0 && !unlocked">
+{{ expReward }} 经验
</div>
<div class="badge-unlock-time" v-if="unlocked && unlockedAt">
{{ formatDate(unlockedAt) }}解锁
</div>
</div>
<div class="badge-status" v-if="!unlocked">
<el-icon><Lock /></el-icon>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
Lock, Medal, Star, Reading, Collection, Files,
Select, Finished, Trophy, TrendCharts, Clock,
Timer, Stopwatch, Operation, Calendar, Rank,
Headset, StarFilled
} from '@element-plus/icons-vue'
interface Props {
code: string
name: string
description: string
icon: string
category: string
expReward?: number
unlocked?: boolean
unlockedAt?: string | null
size?: 'small' | 'medium' | 'large'
}
const props = withDefaults(defineProps<Props>(), {
expReward: 0,
unlocked: false,
unlockedAt: null,
size: 'medium'
})
const emit = defineEmits<{
(e: 'click', badge: Props): void
}>()
// 图标映射
const iconMap: Record<string, any> = {
Medal, Star, Reading, Collection, Files, Select,
Finished, Trophy, TrendCharts, Clock, Timer,
Stopwatch, Operation, Calendar, Rank,
Headset, StarFilled, Lock
}
const iconComponent = computed(() => {
return iconMap[props.icon] || Medal
})
const sizeMap = {
small: 24,
medium: 32,
large: 48
}
const iconSize = computed(() => sizeMap[props.size])
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return `${date.getMonth() + 1}/${date.getDate()}`
}
const handleClick = () => {
emit('click', props)
}
</script>
<style scoped lang="scss">
.badge-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 8px;
background-color: #fff;
border: 1px solid #EBEEF5;
cursor: pointer;
transition: all 0.3s;
position: relative;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.unlocked {
.badge-icon {
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
color: #fff;
}
}
&.locked {
opacity: 0.6;
.badge-icon {
background-color: #F5F7FA;
color: #C0C4CC;
}
.badge-name {
color: #909399;
}
}
.badge-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.badge-info {
flex: 1;
min-width: 0;
.badge-name {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.badge-desc {
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge-reward {
font-size: 12px;
color: #E6A23C;
margin-top: 4px;
}
.badge-unlock-time {
font-size: 12px;
color: #67C23A;
margin-top: 4px;
}
}
.badge-status {
color: #C0C4CC;
font-size: 20px;
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router
import { ElMessage } from 'element-plus'
import { authManager } from '@/utils/auth'
import { loadingManager } from '@/utils/loadingManager'
import { checkTeamMembership, checkCourseAccess, clearPermissionCache } from '@/utils/permissionChecker'
// 白名单路由(不需要登录)
const WHITE_LIST = ['/login', '/register', '/404']
@@ -109,13 +110,21 @@ async function handleRouteGuard(
return
}
// 检查特殊路由规则
// 检查特殊路由规则(先进行同步检查)
if (!checkSpecialRouteRules(to)) {
ElMessage.error('访问被拒绝')
next(authManager.getDefaultRoute())
return
}
// 异步权限检查(团队和课程权限)
const hasSpecialAccess = await checkSpecialRouteRulesAsync(to)
if (!hasSpecialAccess) {
ElMessage.error('您没有访问此资源的权限')
next(authManager.getDefaultRoute())
return
}
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
// 检查用户ID参数权限只能访问自己的数据管理员除外
@@ -157,14 +166,41 @@ function checkSpecialRouteRules(to: RouteLocationNormalized): boolean {
// 检查团队ID参数权限
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) {
// 这里可以添加课程访问权限检查
// 例如检查课程是否分配给用户的岗位
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

View File

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

View File

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

View File

@@ -1,227 +1,227 @@
/**
* 钉钉SDK工具类
*
* 提供钉钉环境检测、免登授权码获取等功能
*/
// 钉钉JSAPI类型声明
declare global {
interface Window {
dd?: {
env: {
platform: 'notInDingTalk' | 'android' | 'ios' | 'pc'
}
ready: (callback: () => void) => void
error: (callback: (err: any) => void) => void
runtime: {
permission: {
requestAuthCode: (options: {
corpId: string
onSuccess: (result: { code: string }) => void
onFail: (err: any) => void
}) => void
}
}
biz: {
navigation: {
setTitle: (options: { title: string }) => void
}
}
}
}
}
/**
* 钉钉配置接口
*/
export interface DingtalkConfig {
enabled: boolean
corp_id: string | null
agent_id: string | null
}
/**
* 检测是否在钉钉环境中
*/
export function isDingtalkEnv(): boolean {
if (typeof window === 'undefined') {
console.log('[钉钉检测] window 不存在')
return false
}
// 首先通过 User-Agent 检测
const ua = navigator.userAgent.toLowerCase()
const isDingTalkUA = ua.includes('dingtalk') || ua.includes('aliapp')
console.log('[钉钉检测] UA检测:', isDingTalkUA, 'UA:', ua.substring(0, 100))
if (!window.dd) {
console.log('[钉钉检测] window.dd 不存在但UA检测为:', isDingTalkUA)
return isDingTalkUA // 如果 UA 显示是钉钉但 SDK 还没加载,也返回 true
}
const platform = window.dd.env?.platform
console.log('[钉钉检测] dd.env.platform:', platform)
return platform !== 'notInDingTalk'
}
/**
* 获取钉钉平台类型
*/
export function getDingtalkPlatform(): string {
if (!window.dd) return 'notInDingTalk'
return window.dd.env.platform
}
/**
* 等待钉钉SDK就绪带超时
*/
export function waitDingtalkReady(timeout: number = 5000): Promise<void> {
return new Promise((resolve, reject) => {
if (!window.dd) {
reject(new Error('钉钉SDK未加载'))
return
}
let resolved = false
// 超时处理
const timer = setTimeout(() => {
if (!resolved) {
resolved = true
console.warn('钉钉SDK就绪超时尝试继续执行')
resolve() // 超时后也尝试继续可能SDK已经就绪
}
}, timeout)
window.dd.ready(() => {
if (!resolved) {
resolved = true
clearTimeout(timer)
console.log('钉钉SDK就绪')
resolve()
}
})
window.dd.error((err) => {
if (!resolved) {
resolved = true
clearTimeout(timer)
console.error('钉钉SDK错误:', err)
reject(err)
}
})
})
}
/**
* 获取钉钉免登授权码
*
* @param corpId 企业CorpId
* @returns 免登授权码
*/
export function getAuthCode(corpId: string): Promise<string> {
return new Promise((resolve, reject) => {
if (!window.dd) {
reject(new Error('钉钉SDK未加载'))
return
}
if (!isDingtalkEnv()) {
reject(new Error('当前不在钉钉环境中'))
return
}
window.dd.runtime.permission.requestAuthCode({
corpId: corpId,
onSuccess: (result) => {
resolve(result.code)
},
onFail: (err) => {
console.error('获取钉钉授权码失败:', err)
reject(new Error(err.message || '获取授权码失败'))
}
})
})
}
/**
* 设置钉钉页面标题
*/
export function setDingtalkTitle(title: string): void {
if (!window.dd || !isDingtalkEnv()) return
try {
window.dd.biz.navigation.setTitle({ title })
} catch (e) {
console.warn('设置钉钉标题失败:', e)
}
}
/**
* 加载钉钉JSAPI SDK
*
* 动态加载钉钉SDK脚本
*/
export function loadDingtalkSDK(): Promise<void> {
return new Promise((resolve, reject) => {
// 如果已经加载过,直接返回
if (window.dd) {
resolve()
return
}
const script = document.createElement('script')
script.src = 'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.12/dingtalk.open.js'
script.async = true
script.onload = () => {
console.log('钉钉SDK加载成功')
resolve()
}
script.onerror = () => {
reject(new Error('钉钉SDK加载失败'))
}
document.head.appendChild(script)
})
}
/**
* 钉钉免密登录完整流程
*
* @param corpId 企业CorpId
* @param loginApi 登录API函数
* @returns 登录结果
*/
export async function dingtalkAutoLogin(
corpId: string,
loginApi: (code: string) => Promise<any>
): Promise<any> {
// 1. 检测钉钉环境
if (!isDingtalkEnv()) {
throw new Error('当前不在钉钉环境中,无法使用免密登录')
}
// 2. 等待SDK就绪
await waitDingtalkReady()
// 3. 获取授权码
const code = await getAuthCode(corpId)
// 4. 调用登录API
const result = await loginApi(code)
return result
}
export default {
isDingtalkEnv,
getDingtalkPlatform,
waitDingtalkReady,
getAuthCode,
setDingtalkTitle,
loadDingtalkSDK,
dingtalkAutoLogin
}
/**
* 钉钉SDK工具类
*
* 提供钉钉环境检测、免登授权码获取等功能
*/
// 钉钉JSAPI类型声明
declare global {
interface Window {
dd?: {
env: {
platform: 'notInDingTalk' | 'android' | 'ios' | 'pc'
}
ready: (callback: () => void) => void
error: (callback: (err: any) => void) => void
runtime: {
permission: {
requestAuthCode: (options: {
corpId: string
onSuccess: (result: { code: string }) => void
onFail: (err: any) => void
}) => void
}
}
biz: {
navigation: {
setTitle: (options: { title: string }) => void
}
}
}
}
}
/**
* 钉钉配置接口
*/
export interface DingtalkConfig {
enabled: boolean
corp_id: string | null
agent_id: string | null
}
/**
* 检测是否在钉钉环境中
*/
export function isDingtalkEnv(): boolean {
if (typeof window === 'undefined') {
console.log('[钉钉检测] window 不存在')
return false
}
// 首先通过 User-Agent 检测
const ua = navigator.userAgent.toLowerCase()
const isDingTalkUA = ua.includes('dingtalk') || ua.includes('aliapp')
console.log('[钉钉检测] UA检测:', isDingTalkUA, 'UA:', ua.substring(0, 100))
if (!window.dd) {
console.log('[钉钉检测] window.dd 不存在但UA检测为:', isDingTalkUA)
return isDingTalkUA // 如果 UA 显示是钉钉但 SDK 还没加载,也返回 true
}
const platform = window.dd.env?.platform
console.log('[钉钉检测] dd.env.platform:', platform)
return platform !== 'notInDingTalk'
}
/**
* 获取钉钉平台类型
*/
export function getDingtalkPlatform(): string {
if (!window.dd) return 'notInDingTalk'
return window.dd.env.platform
}
/**
* 等待钉钉SDK就绪带超时
*/
export function waitDingtalkReady(timeout: number = 5000): Promise<void> {
return new Promise((resolve, reject) => {
if (!window.dd) {
reject(new Error('钉钉SDK未加载'))
return
}
let resolved = false
// 超时处理
const timer = setTimeout(() => {
if (!resolved) {
resolved = true
console.warn('钉钉SDK就绪超时尝试继续执行')
resolve() // 超时后也尝试继续可能SDK已经就绪
}
}, timeout)
window.dd.ready(() => {
if (!resolved) {
resolved = true
clearTimeout(timer)
console.log('钉钉SDK就绪')
resolve()
}
})
window.dd.error((err) => {
if (!resolved) {
resolved = true
clearTimeout(timer)
console.error('钉钉SDK错误:', err)
reject(err)
}
})
})
}
/**
* 获取钉钉免登授权码
*
* @param corpId 企业CorpId
* @returns 免登授权码
*/
export function getAuthCode(corpId: string): Promise<string> {
return new Promise((resolve, reject) => {
if (!window.dd) {
reject(new Error('钉钉SDK未加载'))
return
}
if (!isDingtalkEnv()) {
reject(new Error('当前不在钉钉环境中'))
return
}
window.dd.runtime.permission.requestAuthCode({
corpId: corpId,
onSuccess: (result) => {
resolve(result.code)
},
onFail: (err) => {
console.error('获取钉钉授权码失败:', err)
reject(new Error(err.message || '获取授权码失败'))
}
})
})
}
/**
* 设置钉钉页面标题
*/
export function setDingtalkTitle(title: string): void {
if (!window.dd || !isDingtalkEnv()) return
try {
window.dd.biz.navigation.setTitle({ title })
} catch (e) {
console.warn('设置钉钉标题失败:', e)
}
}
/**
* 加载钉钉JSAPI SDK
*
* 动态加载钉钉SDK脚本
*/
export function loadDingtalkSDK(): Promise<void> {
return new Promise((resolve, reject) => {
// 如果已经加载过,直接返回
if (window.dd) {
resolve()
return
}
const script = document.createElement('script')
script.src = 'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.12/dingtalk.open.js'
script.async = true
script.onload = () => {
console.log('钉钉SDK加载成功')
resolve()
}
script.onerror = () => {
reject(new Error('钉钉SDK加载失败'))
}
document.head.appendChild(script)
})
}
/**
* 钉钉免密登录完整流程
*
* @param corpId 企业CorpId
* @param loginApi 登录API函数
* @returns 登录结果
*/
export async function dingtalkAutoLogin(
corpId: string,
loginApi: (code: string) => Promise<any>
): Promise<any> {
// 1. 检测钉钉环境
if (!isDingtalkEnv()) {
throw new Error('当前不在钉钉环境中,无法使用免密登录')
}
// 2. 等待SDK就绪
await waitDingtalkReady()
// 3. 获取授权码
const code = await getAuthCode(corpId)
// 4. 调用登录API
const result = await loginApi(code)
return result
}
export default {
isDingtalkEnv,
getDingtalkPlatform,
waitDingtalkReady,
getAuthCode,
setDingtalkTitle,
loadDingtalkSDK,
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 连接管理模块
*
* 功能:
* - 管理 RTCPeerConnection 生命周期
* - 处理 SDP 交换
* - 处理 ICE 候选收集
* - 音频流管理
*/
export type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'failed'
export interface WebRTCConfig {
iceServers?: RTCIceServer[]
onLocalStream?: (stream: MediaStream) => void
onRemoteStream?: (stream: MediaStream) => void
onConnectionStateChange?: (state: ConnectionState) => void
onIceCandidate?: (candidate: RTCIceCandidate) => void
onError?: (error: Error) => void
}
// 默认 ICE 服务器配置
const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' }
]
export class WebRTCManager {
private peerConnection: RTCPeerConnection | null = null
private localStream: MediaStream | null = null
private remoteStream: MediaStream | null = null
private config: WebRTCConfig
private connectionState: ConnectionState = 'idle'
private pendingIceCandidates: RTCIceCandidate[] = []
constructor(config: WebRTCConfig = {}) {
this.config = {
iceServers: DEFAULT_ICE_SERVERS,
...config
}
}
/**
* 获取当前连接状态
*/
getConnectionState(): ConnectionState {
return this.connectionState
}
/**
* 获取本地音频流
*/
getLocalStream(): MediaStream | null {
return this.localStream
}
/**
* 获取远程音频流
*/
getRemoteStream(): MediaStream | null {
return this.remoteStream
}
/**
* 初始化本地音频流
*/
async initLocalStream(): Promise<MediaStream> {
try {
this.localStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
},
video: false
})
this.config.onLocalStream?.(this.localStream)
return this.localStream
} catch (error) {
const err = error instanceof Error ? error : new Error('获取麦克风权限失败')
this.config.onError?.(err)
throw err
}
}
/**
* 创建 PeerConnection
*/
private createPeerConnection(): RTCPeerConnection {
const pc = new RTCPeerConnection({
iceServers: this.config.iceServers
})
// 监听 ICE 候选
pc.onicecandidate = (event) => {
if (event.candidate) {
console.log('[WebRTC] ICE candidate:', event.candidate.candidate?.substring(0, 50))
this.config.onIceCandidate?.(event.candidate)
}
}
// 监听连接状态变化
pc.onconnectionstatechange = () => {
console.log('[WebRTC] Connection state:', pc.connectionState)
this.updateConnectionState(pc.connectionState)
}
// 监听 ICE 连接状态
pc.oniceconnectionstatechange = () => {
console.log('[WebRTC] ICE connection state:', pc.iceConnectionState)
if (pc.iceConnectionState === 'failed') {
this.updateConnectionState('failed')
}
}
// 监听远程流
pc.ontrack = (event) => {
console.log('[WebRTC] Remote track received')
if (event.streams && event.streams[0]) {
this.remoteStream = event.streams[0]
this.config.onRemoteStream?.(this.remoteStream)
}
}
return pc
}
/**
* 更新连接状态
*/
private updateConnectionState(state: RTCPeerConnectionState | string) {
const stateMap: Record<string, ConnectionState> = {
'new': 'connecting',
'connecting': 'connecting',
'connected': 'connected',
'disconnected': 'disconnected',
'failed': 'failed',
'closed': 'disconnected'
}
this.connectionState = stateMap[state] || 'idle'
this.config.onConnectionStateChange?.(this.connectionState)
}
/**
* 创建 Offer发起方调用
*/
async createOffer(): Promise<RTCSessionDescriptionInit> {
if (!this.localStream) {
await this.initLocalStream()
}
this.peerConnection = this.createPeerConnection()
this.updateConnectionState('connecting')
// 添加本地音频轨道
this.localStream!.getTracks().forEach(track => {
this.peerConnection!.addTrack(track, this.localStream!)
})
// 创建 Offer
const offer = await this.peerConnection.createOffer()
await this.peerConnection.setLocalDescription(offer)
console.log('[WebRTC] Offer created')
return offer
}
/**
* 处理 Offer接收方调用
*/
async handleOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
if (!this.localStream) {
await this.initLocalStream()
}
this.peerConnection = this.createPeerConnection()
this.updateConnectionState('connecting')
// 添加本地音频轨道
this.localStream!.getTracks().forEach(track => {
this.peerConnection!.addTrack(track, this.localStream!)
})
// 设置远程描述
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer))
// 处理等待中的 ICE 候选
for (const candidate of this.pendingIceCandidates) {
await this.peerConnection.addIceCandidate(candidate)
}
this.pendingIceCandidates = []
// 创建 Answer
const answer = await this.peerConnection.createAnswer()
await this.peerConnection.setLocalDescription(answer)
console.log('[WebRTC] Answer created')
return answer
}
/**
* 处理 Answer发起方调用
*/
async handleAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
if (!this.peerConnection) {
throw new Error('PeerConnection not initialized')
}
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer))
// 处理等待中的 ICE 候选
for (const candidate of this.pendingIceCandidates) {
await this.peerConnection.addIceCandidate(candidate)
}
this.pendingIceCandidates = []
console.log('[WebRTC] Answer handled')
}
/**
* 添加 ICE 候选
*/
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
const iceCandidate = new RTCIceCandidate(candidate)
if (this.peerConnection && this.peerConnection.remoteDescription) {
await this.peerConnection.addIceCandidate(iceCandidate)
console.log('[WebRTC] ICE candidate added')
} else {
// 如果远程描述还没设置,先缓存候选
this.pendingIceCandidates.push(iceCandidate)
console.log('[WebRTC] ICE candidate queued')
}
}
/**
* 静音/取消静音本地音频
*/
setMuted(muted: boolean): void {
if (this.localStream) {
this.localStream.getAudioTracks().forEach(track => {
track.enabled = !muted
})
}
}
/**
* 检查是否静音
*/
isMuted(): boolean {
if (this.localStream) {
const audioTrack = this.localStream.getAudioTracks()[0]
return audioTrack ? !audioTrack.enabled : true
}
return true
}
/**
* 获取音频音量级别(用于音量指示器)
*/
async getAudioLevel(stream: MediaStream): Promise<number> {
return new Promise((resolve) => {
const audioContext = new AudioContext()
const analyser = audioContext.createAnalyser()
const source = audioContext.createMediaStreamSource(stream)
source.connect(analyser)
analyser.fftSize = 256
const dataArray = new Uint8Array(analyser.frequencyBinCount)
analyser.getByteFrequencyData(dataArray)
// 计算平均音量
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length
audioContext.close()
resolve(average / 255) // 归一化到 0-1
})
}
/**
* 关闭连接
*/
close(): void {
console.log('[WebRTC] Closing connection')
// 停止本地流
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop())
this.localStream = null
}
// 停止远程流
if (this.remoteStream) {
this.remoteStream.getTracks().forEach(track => track.stop())
this.remoteStream = null
}
// 关闭 PeerConnection
if (this.peerConnection) {
this.peerConnection.close()
this.peerConnection = null
}
this.pendingIceCandidates = []
this.updateConnectionState('disconnected')
}
/**
* 重置管理器
*/
reset(): void {
this.close()
this.connectionState = 'idle'
}
}
// 导出单例工厂函数
export function createWebRTCManager(config?: WebRTCConfig): WebRTCManager {
return new WebRTCManager(config)
}
/**
* WebRTC 连接管理模块
*
* 功能:
* - 管理 RTCPeerConnection 生命周期
* - 处理 SDP 交换
* - 处理 ICE 候选收集
* - 音频流管理
*/
export type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'failed'
export interface WebRTCConfig {
iceServers?: RTCIceServer[]
onLocalStream?: (stream: MediaStream) => void
onRemoteStream?: (stream: MediaStream) => void
onConnectionStateChange?: (state: ConnectionState) => void
onIceCandidate?: (candidate: RTCIceCandidate) => void
onError?: (error: Error) => void
}
// 默认 ICE 服务器配置
const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' }
]
export class WebRTCManager {
private peerConnection: RTCPeerConnection | null = null
private localStream: MediaStream | null = null
private remoteStream: MediaStream | null = null
private config: WebRTCConfig
private connectionState: ConnectionState = 'idle'
private pendingIceCandidates: RTCIceCandidate[] = []
constructor(config: WebRTCConfig = {}) {
this.config = {
iceServers: DEFAULT_ICE_SERVERS,
...config
}
}
/**
* 获取当前连接状态
*/
getConnectionState(): ConnectionState {
return this.connectionState
}
/**
* 获取本地音频流
*/
getLocalStream(): MediaStream | null {
return this.localStream
}
/**
* 获取远程音频流
*/
getRemoteStream(): MediaStream | null {
return this.remoteStream
}
/**
* 初始化本地音频流
*/
async initLocalStream(): Promise<MediaStream> {
try {
this.localStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
},
video: false
})
this.config.onLocalStream?.(this.localStream)
return this.localStream
} catch (error) {
const err = error instanceof Error ? error : new Error('获取麦克风权限失败')
this.config.onError?.(err)
throw err
}
}
/**
* 创建 PeerConnection
*/
private createPeerConnection(): RTCPeerConnection {
const pc = new RTCPeerConnection({
iceServers: this.config.iceServers
})
// 监听 ICE 候选
pc.onicecandidate = (event) => {
if (event.candidate) {
console.log('[WebRTC] ICE candidate:', event.candidate.candidate?.substring(0, 50))
this.config.onIceCandidate?.(event.candidate)
}
}
// 监听连接状态变化
pc.onconnectionstatechange = () => {
console.log('[WebRTC] Connection state:', pc.connectionState)
this.updateConnectionState(pc.connectionState)
}
// 监听 ICE 连接状态
pc.oniceconnectionstatechange = () => {
console.log('[WebRTC] ICE connection state:', pc.iceConnectionState)
if (pc.iceConnectionState === 'failed') {
this.updateConnectionState('failed')
}
}
// 监听远程流
pc.ontrack = (event) => {
console.log('[WebRTC] Remote track received')
if (event.streams && event.streams[0]) {
this.remoteStream = event.streams[0]
this.config.onRemoteStream?.(this.remoteStream)
}
}
return pc
}
/**
* 更新连接状态
*/
private updateConnectionState(state: RTCPeerConnectionState | string) {
const stateMap: Record<string, ConnectionState> = {
'new': 'connecting',
'connecting': 'connecting',
'connected': 'connected',
'disconnected': 'disconnected',
'failed': 'failed',
'closed': 'disconnected'
}
this.connectionState = stateMap[state] || 'idle'
this.config.onConnectionStateChange?.(this.connectionState)
}
/**
* 创建 Offer发起方调用
*/
async createOffer(): Promise<RTCSessionDescriptionInit> {
if (!this.localStream) {
await this.initLocalStream()
}
this.peerConnection = this.createPeerConnection()
this.updateConnectionState('connecting')
// 添加本地音频轨道
this.localStream!.getTracks().forEach(track => {
this.peerConnection!.addTrack(track, this.localStream!)
})
// 创建 Offer
const offer = await this.peerConnection.createOffer()
await this.peerConnection.setLocalDescription(offer)
console.log('[WebRTC] Offer created')
return offer
}
/**
* 处理 Offer接收方调用
*/
async handleOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
if (!this.localStream) {
await this.initLocalStream()
}
this.peerConnection = this.createPeerConnection()
this.updateConnectionState('connecting')
// 添加本地音频轨道
this.localStream!.getTracks().forEach(track => {
this.peerConnection!.addTrack(track, this.localStream!)
})
// 设置远程描述
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer))
// 处理等待中的 ICE 候选
for (const candidate of this.pendingIceCandidates) {
await this.peerConnection.addIceCandidate(candidate)
}
this.pendingIceCandidates = []
// 创建 Answer
const answer = await this.peerConnection.createAnswer()
await this.peerConnection.setLocalDescription(answer)
console.log('[WebRTC] Answer created')
return answer
}
/**
* 处理 Answer发起方调用
*/
async handleAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
if (!this.peerConnection) {
throw new Error('PeerConnection not initialized')
}
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer))
// 处理等待中的 ICE 候选
for (const candidate of this.pendingIceCandidates) {
await this.peerConnection.addIceCandidate(candidate)
}
this.pendingIceCandidates = []
console.log('[WebRTC] Answer handled')
}
/**
* 添加 ICE 候选
*/
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
const iceCandidate = new RTCIceCandidate(candidate)
if (this.peerConnection && this.peerConnection.remoteDescription) {
await this.peerConnection.addIceCandidate(iceCandidate)
console.log('[WebRTC] ICE candidate added')
} else {
// 如果远程描述还没设置,先缓存候选
this.pendingIceCandidates.push(iceCandidate)
console.log('[WebRTC] ICE candidate queued')
}
}
/**
* 静音/取消静音本地音频
*/
setMuted(muted: boolean): void {
if (this.localStream) {
this.localStream.getAudioTracks().forEach(track => {
track.enabled = !muted
})
}
}
/**
* 检查是否静音
*/
isMuted(): boolean {
if (this.localStream) {
const audioTrack = this.localStream.getAudioTracks()[0]
return audioTrack ? !audioTrack.enabled : true
}
return true
}
/**
* 获取音频音量级别(用于音量指示器)
*/
async getAudioLevel(stream: MediaStream): Promise<number> {
return new Promise((resolve) => {
const audioContext = new AudioContext()
const analyser = audioContext.createAnalyser()
const source = audioContext.createMediaStreamSource(stream)
source.connect(analyser)
analyser.fftSize = 256
const dataArray = new Uint8Array(analyser.frequencyBinCount)
analyser.getByteFrequencyData(dataArray)
// 计算平均音量
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length
audioContext.close()
resolve(average / 255) // 归一化到 0-1
})
}
/**
* 关闭连接
*/
close(): void {
console.log('[WebRTC] Closing connection')
// 停止本地流
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop())
this.localStream = null
}
// 停止远程流
if (this.remoteStream) {
this.remoteStream.getTracks().forEach(track => track.stop())
this.remoteStream = null
}
// 关闭 PeerConnection
if (this.peerConnection) {
this.peerConnection.close()
this.peerConnection = null
}
this.pendingIceCandidates = []
this.updateConnectionState('disconnected')
}
/**
* 重置管理器
*/
reset(): void {
this.close()
this.connectionState = 'idle'
}
}
// 导出单例工厂函数
export function createWebRTCManager(config?: WebRTCConfig): WebRTCManager {
return new WebRTCManager(config)
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -183,6 +183,11 @@ import {
type CozeSession,
type StreamEvent
} from '@/api/coze'
import {
SpeechRecognitionManager,
isSpeechRecognitionSupported,
type SpeechRecognitionResult
} from '@/utils/speechRecognition'
const router = useRouter()
@@ -205,6 +210,11 @@ const voiceStatusText = ref('点击开始按钮进行语音陪练')
const mediaRecorder = ref<MediaRecorder | null>(null)
const audioChunks = ref<Blob[]>([])
// 语音识别相关
const speechRecognition = ref<SpeechRecognitionManager | null>(null)
const recognizedText = ref('')
const isSpeechSupported = isSpeechRecognitionSupported()
// DOM引用
const messageContainer = ref<HTMLElement>()
@@ -380,9 +390,21 @@ const toggleRecording = async () => {
}
/**
* 开始录音
* 开始录音(同时启动语音识别)
*/
const startRecording = async () => {
if (!cozeSession.value) {
ElMessage.warning('请先开始陪练会话')
return
}
// 优先使用 Web Speech API 进行实时语音识别
if (isSpeechSupported) {
startSpeechRecognition()
return
}
// 降级到录音模式(需要后端语音识别服务)
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
@@ -400,7 +422,7 @@ const startRecording = async () => {
mediaRecorder.value.start()
isRecording.value = true
voiceStatusText.value = '正在录音...'
voiceStatusText.value = '正在录音(浏览器不支持实时识别,录音结束后将发送到服务器识别)...'
} catch (error) {
ElMessage.error('无法访问麦克风')
}
@@ -410,6 +432,13 @@ const startRecording = async () => {
* 停止录音
*/
const stopRecording = () => {
// 如果使用的是 Web Speech API
if (speechRecognition.value) {
stopSpeechRecognition()
return
}
// 如果使用的是录音模式
if (mediaRecorder.value && isRecording.value) {
mediaRecorder.value.stop()
mediaRecorder.value.stream.getTracks().forEach(track => track.stop())
@@ -420,13 +449,116 @@ const stopRecording = () => {
}
/**
* 处理录音
* 处理录音(使用 Web Speech API 已识别的文本)
*/
const processAudio = async (_audioBlob: Blob) => {
// TODO: 实现音频转文本和发送逻辑
isProcessing.value = false
voiceStatusText.value = '点击开始按钮进行语音陪练'
ElMessage.info('语音功能正在开发中')
try {
// 检查是否有识别结果
const text = recognizedText.value.trim()
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) {
stopRecording()
}
if (speechRecognition.value) {
speechRecognition.value.destroy()
speechRecognition.value = null
}
})
</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>
<div class="duo-practice-page">
<div class="page-header">
<h1>双人对练</h1>
<p class="subtitle">与伙伴一起进行角色扮演对练提升实战能力</p>
</div>
<div class="main-content">
<!-- 创建/加入房间卡片 -->
<div class="action-cards">
<!-- 创建房间 -->
<div class="action-card create-card" @click="showCreateDialog = true">
<div class="card-icon">
<el-icon :size="48"><Plus /></el-icon>
</div>
<h3>创建房间</h3>
<p>创建对练房间邀请伙伴加入</p>
</div>
<!-- 加入房间 -->
<div class="action-card join-card" @click="showJoinDialog = true">
<div class="card-icon">
<el-icon :size="48"><Connection /></el-icon>
</div>
<h3>加入房间</h3>
<p>输入房间码加入对练</p>
</div>
</div>
<!-- 我的房间列表 -->
<div class="my-rooms" v-if="myRooms.length > 0">
<h2>我的对练记录</h2>
<div class="room-list">
<div
v-for="room in myRooms"
:key="room.id"
class="room-item"
@click="enterRoom(room)"
>
<div class="room-info">
<span class="room-name">{{ room.room_name || room.scene_name || '双人对练' }}</span>
<span class="room-code">{{ room.room_code }}</span>
</div>
<div class="room-meta">
<el-tag :type="getStatusType(room.status)" size="small">
{{ getStatusText(room.status) }}
</el-tag>
<span class="room-time">{{ formatTime(room.created_at) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 创建房间对话框 -->
<el-dialog
v-model="showCreateDialog"
title="创建对练房间"
width="500px"
:close-on-click-modal="false"
>
<el-form :model="createForm" label-width="100px">
<el-form-item label="场景名称">
<el-input v-model="createForm.scene_name" placeholder="如:销售场景对练" />
</el-form-item>
<el-form-item label="角色A名称">
<el-input v-model="createForm.role_a_name" placeholder="如:销售顾问" />
</el-form-item>
<el-form-item label="角色B名称">
<el-input v-model="createForm.role_b_name" placeholder="如:顾客" />
</el-form-item>
<el-form-item label="我扮演">
<el-radio-group v-model="createForm.host_role">
<el-radio label="A">{{ createForm.role_a_name || '角色A' }}</el-radio>
<el-radio label="B">{{ createForm.role_b_name || '角色B' }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="场景背景">
<el-input
v-model="createForm.scene_background"
type="textarea"
:rows="3"
placeholder="描述对练场景的背景信息(可选)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="handleCreateRoom" :loading="isCreating">
创建房间
</el-button>
</template>
</el-dialog>
<!-- 加入房间对话框 -->
<el-dialog
v-model="showJoinDialog"
title="加入对练房间"
width="400px"
:close-on-click-modal="false"
>
<el-form>
<el-form-item label="房间码">
<el-input
v-model="joinRoomCode"
placeholder="请输入6位房间码"
maxlength="6"
style="font-size: 24px; text-align: center; letter-spacing: 8px;"
@keyup.enter="handleJoinRoom"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showJoinDialog = false">取消</el-button>
<el-button type="primary" @click="handleJoinRoom" :loading="isJoining">
加入房间
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus, Connection } from '@element-plus/icons-vue'
import { useDuoPracticeStore } from '@/stores/duoPracticeStore'
import { getMyRooms, type RoomListItem } from '@/api/duoPractice'
const router = useRouter()
const store = useDuoPracticeStore()
// 状态
const showCreateDialog = ref(false)
const showJoinDialog = ref(false)
const isCreating = ref(false)
const isJoining = ref(false)
const joinRoomCode = ref('')
const myRooms = ref<RoomListItem[]>([])
// 创建表单
const createForm = ref({
scene_name: '',
role_a_name: '销售顾问',
role_b_name: '顾客',
host_role: 'A' as 'A' | 'B',
scene_background: ''
})
// 加载我的房间列表
const loadMyRooms = async () => {
try {
const res: any = await getMyRooms()
if (res.code === 200) {
myRooms.value = res.data.rooms
}
} catch (error) {
console.error('加载房间列表失败:', error)
}
}
// 创建房间
const handleCreateRoom = async () => {
if (!createForm.value.scene_name) {
ElMessage.warning('请输入场景名称')
return
}
isCreating.value = true
try {
await store.createRoom(createForm.value)
showCreateDialog.value = false
// 跳转到房间页面
router.push(`/trainee/duo-practice/room/${store.roomCode}`)
} catch (error) {
// 错误已在 store 中处理
} finally {
isCreating.value = false
}
}
// 加入房间
const handleJoinRoom = async () => {
if (!joinRoomCode.value || joinRoomCode.value.length < 6) {
ElMessage.warning('请输入6位房间码')
return
}
isJoining.value = true
try {
await store.joinRoom(joinRoomCode.value)
showJoinDialog.value = false
// 跳转到房间页面
router.push(`/trainee/duo-practice/room/${store.roomCode}`)
} catch (error) {
// 错误已在 store 中处理
} finally {
isJoining.value = false
}
}
// 进入房间
const enterRoom = (room: RoomListItem) => {
router.push(`/trainee/duo-practice/room/${room.room_code}`)
}
// 获取状态标签类型
const getStatusType = (status: string) => {
const map: Record<string, string> = {
'waiting': 'warning',
'ready': 'info',
'practicing': 'success',
'completed': '',
'canceled': 'danger'
}
return map[status] || ''
}
// 获取状态文本
const getStatusText = (status: string) => {
const map: Record<string, string> = {
'waiting': '等待加入',
'ready': '准备就绪',
'practicing': '对练中',
'completed': '已完成',
'canceled': '已取消'
}
return map[status] || status
}
// 格式化时间
const formatTime = (time?: string) => {
if (!time) return ''
const date = new Date(time)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
onMounted(() => {
loadMyRooms()
})
</script>
<style scoped lang="scss">
.duo-practice-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
text-align: center;
margin-bottom: 40px;
h1 {
font-size: 28px;
font-weight: 600;
margin-bottom: 8px;
}
.subtitle {
color: #666;
font-size: 16px;
}
}
.action-cards {
display: flex;
gap: 24px;
justify-content: center;
margin-bottom: 48px;
.action-card {
width: 280px;
padding: 40px 24px;
background: #fff;
border-radius: 16px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
&.create-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
.card-icon {
background: rgba(255, 255, 255, 0.2);
}
p {
color: rgba(255, 255, 255, 0.8);
}
}
&.join-card {
border-color: #667eea;
.card-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
&:hover {
border-color: #764ba2;
}
}
.card-icon {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
h3 {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
p {
font-size: 14px;
color: #666;
}
}
}
.my-rooms {
h2 {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
}
.room-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.room-item {
background: #fff;
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.room-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.room-name {
font-weight: 500;
font-size: 16px;
}
.room-code {
font-family: monospace;
font-size: 14px;
color: #667eea;
background: #f0f2ff;
padding: 2px 8px;
border-radius: 4px;
}
}
.room-meta {
display: flex;
justify-content: space-between;
align-items: center;
.room-time {
font-size: 12px;
color: #999;
}
}
}
}
</style>
<template>
<div class="duo-practice-page">
<div class="page-header">
<h1>双人对练</h1>
<p class="subtitle">与伙伴一起进行角色扮演对练提升实战能力</p>
</div>
<div class="main-content">
<!-- 创建/加入房间卡片 -->
<div class="action-cards">
<!-- 创建房间 -->
<div class="action-card create-card" @click="showCreateDialog = true">
<div class="card-icon">
<el-icon :size="48"><Plus /></el-icon>
</div>
<h3>创建房间</h3>
<p>创建对练房间邀请伙伴加入</p>
</div>
<!-- 加入房间 -->
<div class="action-card join-card" @click="showJoinDialog = true">
<div class="card-icon">
<el-icon :size="48"><Connection /></el-icon>
</div>
<h3>加入房间</h3>
<p>输入房间码加入对练</p>
</div>
</div>
<!-- 我的房间列表 -->
<div class="my-rooms" v-if="myRooms.length > 0">
<h2>我的对练记录</h2>
<div class="room-list">
<div
v-for="room in myRooms"
:key="room.id"
class="room-item"
@click="enterRoom(room)"
>
<div class="room-info">
<span class="room-name">{{ room.room_name || room.scene_name || '双人对练' }}</span>
<span class="room-code">{{ room.room_code }}</span>
</div>
<div class="room-meta">
<el-tag :type="getStatusType(room.status)" size="small">
{{ getStatusText(room.status) }}
</el-tag>
<span class="room-time">{{ formatTime(room.created_at) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 创建房间对话框 -->
<el-dialog
v-model="showCreateDialog"
title="创建对练房间"
width="500px"
:close-on-click-modal="false"
>
<el-form :model="createForm" label-width="100px">
<el-form-item label="场景名称">
<el-input v-model="createForm.scene_name" placeholder="如:销售场景对练" />
</el-form-item>
<el-form-item label="角色A名称">
<el-input v-model="createForm.role_a_name" placeholder="如:销售顾问" />
</el-form-item>
<el-form-item label="角色B名称">
<el-input v-model="createForm.role_b_name" placeholder="如:顾客" />
</el-form-item>
<el-form-item label="我扮演">
<el-radio-group v-model="createForm.host_role">
<el-radio label="A">{{ createForm.role_a_name || '角色A' }}</el-radio>
<el-radio label="B">{{ createForm.role_b_name || '角色B' }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="场景背景">
<el-input
v-model="createForm.scene_background"
type="textarea"
:rows="3"
placeholder="描述对练场景的背景信息(可选)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="handleCreateRoom" :loading="isCreating">
创建房间
</el-button>
</template>
</el-dialog>
<!-- 加入房间对话框 -->
<el-dialog
v-model="showJoinDialog"
title="加入对练房间"
width="400px"
:close-on-click-modal="false"
>
<el-form>
<el-form-item label="房间码">
<el-input
v-model="joinRoomCode"
placeholder="请输入6位房间码"
maxlength="6"
style="font-size: 24px; text-align: center; letter-spacing: 8px;"
@keyup.enter="handleJoinRoom"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showJoinDialog = false">取消</el-button>
<el-button type="primary" @click="handleJoinRoom" :loading="isJoining">
加入房间
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus, Connection } from '@element-plus/icons-vue'
import { useDuoPracticeStore } from '@/stores/duoPracticeStore'
import { getMyRooms, type RoomListItem } from '@/api/duoPractice'
const router = useRouter()
const store = useDuoPracticeStore()
// 状态
const showCreateDialog = ref(false)
const showJoinDialog = ref(false)
const isCreating = ref(false)
const isJoining = ref(false)
const joinRoomCode = ref('')
const myRooms = ref<RoomListItem[]>([])
// 创建表单
const createForm = ref({
scene_name: '',
role_a_name: '销售顾问',
role_b_name: '顾客',
host_role: 'A' as 'A' | 'B',
scene_background: ''
})
// 加载我的房间列表
const loadMyRooms = async () => {
try {
const res: any = await getMyRooms()
if (res.code === 200) {
myRooms.value = res.data.rooms
}
} catch (error) {
console.error('加载房间列表失败:', error)
}
}
// 创建房间
const handleCreateRoom = async () => {
if (!createForm.value.scene_name) {
ElMessage.warning('请输入场景名称')
return
}
isCreating.value = true
try {
await store.createRoom(createForm.value)
showCreateDialog.value = false
// 跳转到房间页面
router.push(`/trainee/duo-practice/room/${store.roomCode}`)
} catch (error) {
// 错误已在 store 中处理
} finally {
isCreating.value = false
}
}
// 加入房间
const handleJoinRoom = async () => {
if (!joinRoomCode.value || joinRoomCode.value.length < 6) {
ElMessage.warning('请输入6位房间码')
return
}
isJoining.value = true
try {
await store.joinRoom(joinRoomCode.value)
showJoinDialog.value = false
// 跳转到房间页面
router.push(`/trainee/duo-practice/room/${store.roomCode}`)
} catch (error) {
// 错误已在 store 中处理
} finally {
isJoining.value = false
}
}
// 进入房间
const enterRoom = (room: RoomListItem) => {
router.push(`/trainee/duo-practice/room/${room.room_code}`)
}
// 获取状态标签类型
const getStatusType = (status: string) => {
const map: Record<string, string> = {
'waiting': 'warning',
'ready': 'info',
'practicing': 'success',
'completed': '',
'canceled': 'danger'
}
return map[status] || ''
}
// 获取状态文本
const getStatusText = (status: string) => {
const map: Record<string, string> = {
'waiting': '等待加入',
'ready': '准备就绪',
'practicing': '对练中',
'completed': '已完成',
'canceled': '已取消'
}
return map[status] || status
}
// 格式化时间
const formatTime = (time?: string) => {
if (!time) return ''
const date = new Date(time)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
onMounted(() => {
loadMyRooms()
})
</script>
<style scoped lang="scss">
.duo-practice-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
text-align: center;
margin-bottom: 40px;
h1 {
font-size: 28px;
font-weight: 600;
margin-bottom: 8px;
}
.subtitle {
color: #666;
font-size: 16px;
}
}
.action-cards {
display: flex;
gap: 24px;
justify-content: center;
margin-bottom: 48px;
.action-card {
width: 280px;
padding: 40px 24px;
background: #fff;
border-radius: 16px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
&.create-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
.card-icon {
background: rgba(255, 255, 255, 0.2);
}
p {
color: rgba(255, 255, 255, 0.8);
}
}
&.join-card {
border-color: #667eea;
.card-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
&:hover {
border-color: #764ba2;
}
}
.card-icon {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
h3 {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
p {
font-size: 14px;
color: #666;
}
}
}
.my-rooms {
h2 {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
}
.room-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.room-item {
background: #fff;
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.room-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.room-name {
font-weight: 500;
font-size: 16px;
}
.room-code {
font-family: monospace;
font-size: 14px;
color: #667eea;
background: #f0f2ff;
padding: 2px 8px;
border-radius: 4px;
}
}
.room-meta {
display: flex;
justify-content: space-between;
align-items: center;
.room-time {
font-size: 12px;
color: #999;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff