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:
144
.gitignore
vendored
144
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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
48
backend/.env.production
Normal 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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
470
backend/app/api/v1/endpoints/progress.py
Normal file
470
backend/app/api/v1/endpoints/progress.py
Normal 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()
|
||||
157
backend/app/api/v1/endpoints/recommendation.py
Normal file
157
backend/app/api/v1/endpoints/recommendation.py
Normal 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,
|
||||
}
|
||||
}
|
||||
145
backend/app/api/v1/endpoints/speech.py
Normal file
145
backend/app/api/v1/endpoints/speech.py
Normal 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
@@ -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), # 是否已配置
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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("✅ 安全配置检查通过")
|
||||
|
||||
|
||||
# ============================================
|
||||
# 动态配置获取(支持从数据库读取)
|
||||
# ============================================
|
||||
|
||||
@@ -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()
|
||||
|
||||
71
backend/app/migrations/add_user_course_progress.sql
Normal file
71
backend/app/migrations/add_user_course_progress.sql
Normal 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';
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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" # 时长
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
201
backend/app/models/user_course_progress.py
Normal file
201
backend/app/models/user_course_progress.py
Normal 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")
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
151
backend/app/services/permission_service.py
Normal file
151
backend/app/services/permission_service.py
Normal 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
379
backend/app/services/recommendation_service.py
Normal file
379
backend/app/services/recommendation_service.py
Normal 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)
|
||||
273
backend/app/services/scheduler_service.py
Normal file
273
backend/app/services/scheduler_service.py
Normal 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
|
||||
256
backend/app/services/speech_recognition.py
Normal file
256
backend/app/services/speech_recognition.py
Normal 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)
|
||||
@@ -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; -- 应该等于用户数
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`;
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
434
docs/测试环境配置.md
434
docs/测试环境配置.md
@@ -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.43(SCRM服务器)
|
||||
- `*.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.43(SCRM服务器)
|
||||
- `*.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 # 部署生产
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -175,4 +175,4 @@ export function getTeamDashboard() {
|
||||
*/
|
||||
export function getFullDashboardData() {
|
||||
return request.get<FullDashboardData>('/dashboard/all')
|
||||
}
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
158
frontend/src/api/progress.ts
Normal file
158
frontend/src/api/progress.ts
Normal 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,
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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(() => {
|
||||
// 忽略导入错误
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
211
frontend/src/utils/permissionChecker.ts
Normal file
211
frontend/src/utils/permissionChecker.ts
Normal 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,
|
||||
}
|
||||
294
frontend/src/utils/speechRecognition.ts
Normal file
294
frontend/src/utils/speechRecognition.ts
Normal 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,
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user