Compare commits
2 Commits
c55b2266fb
...
6e1758a44d
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e1758a44d | |||
| 6f64723428 |
@ -497,6 +497,63 @@ definePage({
|
|||||||
- **命名**: `YYYY-MM-DD.md`
|
- **命名**: `YYYY-MM-DD.md`
|
||||||
- **创建时机**: 每天开始开发时检查并创建
|
- **创建时机**: 每天开始开发时检查并创建
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件写入最佳实践(JetBrains 环境)
|
||||||
|
|
||||||
|
> **重要**: 在 JetBrains IDE 插件中使用 Claude Code 时,按以下优先级选择写入方式:
|
||||||
|
|
||||||
|
### 写入方式优先级
|
||||||
|
|
||||||
|
| 优先级 | 方式 | 适用场景 | 示例 |
|
||||||
|
|--------|------|----------|------|
|
||||||
|
| **1** | Write 工具 | 简单文件、单文件写入 | `Write(file_path="...", content="...")` |
|
||||||
|
| **2** | Bash heredoc | 复杂文件、批量写入、Write 失败时 | `cat > /path/to/file << 'EOF'` |
|
||||||
|
| **3** | Python 写入 | 需要复杂逻辑处理时 | `python3 -c "..."` |
|
||||||
|
| **❌ 避免** | sed/awk | Windows Git Bash 中兼容性差 | — |
|
||||||
|
|
||||||
|
### Write 工具使用规范
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ✅ 正确:使用 Unix 风格路径
|
||||||
|
Write(file_path="/f/LesingleProject/.../file.txt", content="...")
|
||||||
|
|
||||||
|
# ❌ 错误:不要使用 Windows 路径
|
||||||
|
Write(file_path="F:\\LesingleProject\\...\\file.txt", content="...")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bash heredoc 方式(推荐备选)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 多行文件写入
|
||||||
|
cat > /f/LesingleProject/.../file.txt << 'EOF'
|
||||||
|
第一行内容
|
||||||
|
第二行内容
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 单行追加
|
||||||
|
echo "追加内容" >> /f/LesingleProject/.../file.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 写入前检查清单
|
||||||
|
|
||||||
|
- [ ] 确保目录存在:`mkdir -p /f/.../父目录`
|
||||||
|
- [ ] 使用 Unix 路径格式:`/f/...` 不是 `F:\...`
|
||||||
|
- [ ] 检查文件是否被占用(IDE 索引、格式化中)
|
||||||
|
|
||||||
|
### 写入失败诊断
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查目录是否存在
|
||||||
|
ls -la /f/路径/到的/父目录/
|
||||||
|
|
||||||
|
# 检查文件属性
|
||||||
|
ls -la /f/路径/到/文件.txt
|
||||||
|
|
||||||
|
# 测试写入权限
|
||||||
|
echo 'test' > /f/路径/到/文件.txt && echo '成功' || echo '失败'
|
||||||
|
```
|
||||||
|
|
||||||
### 测试记录
|
### 测试记录
|
||||||
- **位置**: `/docs/test-logs/{端}/`
|
- **位置**: `/docs/test-logs/{端}/`
|
||||||
- **命名**: `YYYY-MM-DD.md`
|
- **命名**: `YYYY-MM-DD.md`
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(mvn compile:*)",
|
"Bash(mvn compile:*)",
|
||||||
"Bash(sed:*)"
|
"Bash(sed:*)",
|
||||||
|
"Bash(grep:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -349,7 +349,7 @@
|
|||||||
│ │
|
│ │
|
||||||
│ 📊 平台整体数据 │
|
│ 📊 平台整体数据 │
|
||||||
│ ┌─────────────┬─────────────┬─────────────┬─────────────┐ │
|
│ ┌─────────────┬─────────────┬─────────────┬─────────────┐ │
|
||||||
│ │ 租户总数 │ 活跃租户 │ 月授课次数 │ 覆盖学生数 │ │
|
│ │ 租户总数 │ 活跃租户 │ 月授课次数 │ 学生总数数 │ │
|
||||||
│ │ 1,254所 │ 986所 │ 45,678次 │ 234,567人 │ │
|
│ │ 1,254所 │ 986所 │ 45,678次 │ 234,567人 │ │
|
||||||
│ └─────────────┴─────────────┴─────────────┴─────────────┘ │
|
│ └─────────────┴─────────────┴─────────────┴─────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
|
|||||||
@ -54,7 +54,7 @@
|
|||||||
- ✅ 账号不存在登录失败
|
- ✅ 账号不存在登录失败
|
||||||
|
|
||||||
#### 02-dashboard.spec.ts - 数据看板测试 (7 个用例)
|
#### 02-dashboard.spec.ts - 数据看板测试 (7 个用例)
|
||||||
- ✅ 验证统计卡片显示(租户数、课程包数、月授课次数、覆盖学生)
|
- ✅ 验证统计卡片显示(租户数、课程包数、月授课次数、学生总数)
|
||||||
- ✅ 验证趋势图加载
|
- ✅ 验证趋势图加载
|
||||||
- ✅ 验证活跃租户 TOP5 列表
|
- ✅ 验证活跃租户 TOP5 列表
|
||||||
- ✅ 验证热门课程包 TOP5 列表
|
- ✅ 验证热门课程包 TOP5 列表
|
||||||
|
|||||||
176
docs/dev-logs/2026-03-20-profile-feature.md
Normal file
176
docs/dev-logs/2026-03-20-profile-feature.md
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
# 个人中心功能实现记录
|
||||||
|
|
||||||
|
## 日期:2026-03-20
|
||||||
|
|
||||||
|
## 实现内容
|
||||||
|
|
||||||
|
### 需求目标
|
||||||
|
在个人中心页面新增:
|
||||||
|
1. **密码修改功能**:输入旧密码和新密码进行修改
|
||||||
|
2. **信息编辑功能**:可修改姓名、手机号、邮箱
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后端实现
|
||||||
|
|
||||||
|
### 1. 新建 DTO 文件
|
||||||
|
|
||||||
|
#### `UpdateProfileRequest.java`
|
||||||
|
- 路径:`reading-platform-java/src/main/java/com/reading/platform/dto/request/UpdateProfileRequest.java`
|
||||||
|
- 字段:name(姓名)、phone(手机号)、email(邮箱)
|
||||||
|
- 校验注解:
|
||||||
|
- name: `@Pattern(regexp = "^[\u4e00-\u9fa5a-zA-Z\s]{2,20}$")`
|
||||||
|
- phone: `@Pattern(regexp = "^1[3-9]\d{9}$")`
|
||||||
|
- email: `@Email`
|
||||||
|
|
||||||
|
#### `UpdateProfileResponse.java`
|
||||||
|
- 路径:`reading-platform-java/src/main/java/com/reading/platform/dto/response/UpdateProfileResponse.java`
|
||||||
|
- 字段:userInfo(用户信息)、token(新 token)
|
||||||
|
|
||||||
|
### 2. 修改 Service 接口
|
||||||
|
|
||||||
|
#### `AuthService.java`
|
||||||
|
新增方法:
|
||||||
|
```java
|
||||||
|
UpdateProfileResponse updateProfile(UpdateProfileRequest request);
|
||||||
|
void changePassword(String oldPassword, String newPassword, String currentToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 修改 Service 实现
|
||||||
|
|
||||||
|
#### `AuthServiceImpl.java`
|
||||||
|
- 新增 `updateProfile()` 方法:
|
||||||
|
- 根据当前用户角色更新对应表(AdminUser/Tenant/Teacher/Parent)
|
||||||
|
- 学校管理员(Tenant)更新 `contactName/contactPhone/contactEmail` 字段
|
||||||
|
- 其他角色更新 `name/phone/email` 字段
|
||||||
|
- 生成新 token 并更新 Redis
|
||||||
|
- 返回更新后的用户信息和新 token
|
||||||
|
|
||||||
|
- 新增 `changePassword(String oldPassword, String newPassword, String currentToken)` 方法:
|
||||||
|
- 调用原有 `changePassword()` 方法修改密码
|
||||||
|
- 将当前 token 加入黑名单(使旧 token 失效)
|
||||||
|
|
||||||
|
- 新增 `convertToUserInfoResponse(Object userInfo, String role)` 私有方法:
|
||||||
|
- 将 Entity 对象转换为 UserInfoResponse
|
||||||
|
|
||||||
|
### 4. 修改 Controller
|
||||||
|
|
||||||
|
#### `AuthController.java`
|
||||||
|
- 新增接口 `PUT /api/v1/auth/profile`:修改个人信息
|
||||||
|
- 修改接口 `POST /api/v1/auth/change-password`:增加 HttpServletRequest 参数获取 token
|
||||||
|
- 新增 `resolveToken(HttpServletRequest request)` 辅助方法:从 Authorization header 获取 token
|
||||||
|
|
||||||
|
### 5. 扩展 JwtTokenProvider
|
||||||
|
|
||||||
|
#### `JwtTokenProvider.java`
|
||||||
|
新增方法:
|
||||||
|
```java
|
||||||
|
public long getRemainingExpiration(String token)
|
||||||
|
```
|
||||||
|
- 用于获取 token 剩余过期时间(秒)
|
||||||
|
- 用于将 token 加入黑名单时设置过期时间
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前端实现
|
||||||
|
|
||||||
|
### 1. 扩展 API 文件
|
||||||
|
|
||||||
|
#### `src/api/auth.ts`
|
||||||
|
新增类型和方法:
|
||||||
|
```typescript
|
||||||
|
export interface UpdateProfileDto {
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfileResponse {
|
||||||
|
userInfo: UserProfile;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateProfile(data: UpdateProfileDto): Promise<UpdateProfileResponse>
|
||||||
|
export function changePassword(oldPassword: string, newPassword: string): Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 修改个人中心页面
|
||||||
|
|
||||||
|
#### `src/views/profile/ProfileView.vue`
|
||||||
|
- 新增「编辑资料」按钮:点击后弹出编辑弹窗
|
||||||
|
- 新增「修改密码」按钮:点击后弹出密码修改弹窗
|
||||||
|
- 编辑表单:
|
||||||
|
- 姓名输入框(必填,2-20 位中文或英文)
|
||||||
|
- 手机号输入框(选填,11 位数字正则校验)
|
||||||
|
- 邮箱输入框(选填,email 格式校验)
|
||||||
|
- 密码修改表单:
|
||||||
|
- 旧密码输入框(必填)
|
||||||
|
- 新密码输入框(必填,最少 6 位)
|
||||||
|
- 确认密码输入框(必填,与 new password 一致)
|
||||||
|
- 修改信息成功后:刷新本地用户信息
|
||||||
|
- 修改密码成功后:清除 token 和用户信息,跳转到登录页
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后端 API 列表
|
||||||
|
|
||||||
|
| 接口 | 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 获取个人信息 | GET | `/api/v1/auth/profile` | 已有 |
|
||||||
|
| 修改个人信息 | PUT | `/api/v1/auth/profile` | 新增,返回新 token |
|
||||||
|
| 修改密码 | POST | `/api/v1/auth/change-password` | 已有,修改后 token 失效 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证方案
|
||||||
|
|
||||||
|
### 后端验证
|
||||||
|
1. 启动后端服务(端口 8480)
|
||||||
|
2. 使用 Swagger 测试接口:
|
||||||
|
- `GET /api/v1/auth/profile` - 获取个人信息
|
||||||
|
- `PUT /api/v1/auth/profile` - 修改个人信息
|
||||||
|
- `POST /api/v1/auth/change-password?oldPassword=xxx&newPassword=yyy` - 修改密码
|
||||||
|
|
||||||
|
### 前端验证
|
||||||
|
1. 启动前端服务(端口 5173)
|
||||||
|
2. 登录任意角色账户
|
||||||
|
3. 访问个人中心页面
|
||||||
|
4. 测试编辑信息功能
|
||||||
|
5. 测试修改密码功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **字段映射**:
|
||||||
|
- 前端统一使用 `name/phone/email`
|
||||||
|
- 后端 Service 层根据角色映射到对应字段
|
||||||
|
- 学校管理员(Tenant)映射到 `contactName/contactPhone/contactEmail`
|
||||||
|
|
||||||
|
2. **Token 处理**:
|
||||||
|
- 修改个人信息:返回新 token,前端替换 localStorage 中的旧 token
|
||||||
|
- 修改密码:将当前 token 加入黑名单,前端清除 token 并跳转到登录页
|
||||||
|
|
||||||
|
3. **表单校验**:
|
||||||
|
- 前端:手机号正则 `/^1[3-9]\d{9}$/`,邮箱使用 Ant Design 内置校验
|
||||||
|
- 后端:使用 `@Valid` + `@Pattern`/`@Email` 注解校验
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 待验证事项
|
||||||
|
|
||||||
|
- [ ] 超管角色修改信息功能
|
||||||
|
- [ ] 学校管理员修改信息功能
|
||||||
|
- [ ] 教师角色修改信息功能
|
||||||
|
- [ ] 家长角色修改信息功能
|
||||||
|
- [ ] 所有角色修改密码功能
|
||||||
|
- [ ] 修改信息后 token 是否正确刷新
|
||||||
|
- [ ] 修改密码后 token 是否失效并需重新登录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步计划
|
||||||
|
|
||||||
|
1. 启动服务进行功能验证
|
||||||
|
2. 修复可能发现的问题
|
||||||
|
3. 更新测试日志
|
||||||
204
docs/dev-logs/2026-03-20-teacher-course-usage-stats.md
Normal file
204
docs/dev-logs/2026-03-20-teacher-course-usage-stats.md
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
# 开发日志 - 2026-03-20
|
||||||
|
|
||||||
|
## 教师端课程使用统计功能实现
|
||||||
|
|
||||||
|
### 实现内容
|
||||||
|
|
||||||
|
实现了教师端课程使用统计功能(增强版),支持按时间周期筛选和更多维度的统计。
|
||||||
|
|
||||||
|
### 后端改动
|
||||||
|
|
||||||
|
#### 1. 新增 DTO 类
|
||||||
|
|
||||||
|
**CourseUsageQuery.java** - 请求参数 DTO
|
||||||
|
- `periodType`: 统计周期类型(TODAY/WEEK/MONTH/CUSTOM)
|
||||||
|
- `startDate`: 自定义周期开始日期
|
||||||
|
- `endDate`: 自定义周期结束日期
|
||||||
|
|
||||||
|
**CourseUsageStatsVO.java** - 响应对象 DTO(增强版)
|
||||||
|
- `coursePackageId`: 课程包 ID
|
||||||
|
- `coursePackageName`: 课程包名称
|
||||||
|
- `usageCount`: 使用次数(基于实际授课记录统计)
|
||||||
|
- `studentCount`: 参与学生数(去重)
|
||||||
|
- `avgDuration`: 平均时长(分钟)
|
||||||
|
- `lastUsedAt`: 最后使用时间
|
||||||
|
|
||||||
|
#### 2. LessonMapper.java
|
||||||
|
|
||||||
|
新增 `getCourseUsageStats()` 方法:
|
||||||
|
- 使用自定义 SQL 统计课程包使用情况
|
||||||
|
- 基于 `lesson` 表实际授课记录
|
||||||
|
- 支持时间范围筛选
|
||||||
|
- 支持教师 ID 筛选
|
||||||
|
- 只统计 `COMPLETED` 状态的课程
|
||||||
|
- 返回 TOP 10 课程包
|
||||||
|
|
||||||
|
#### 3. TeacherStatsService.java
|
||||||
|
|
||||||
|
新增接口方法:
|
||||||
|
```java
|
||||||
|
List<CourseUsageStatsVO> getCourseUsageStats(Long tenantId, Long teacherId, CourseUsageQuery query);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. TeacherStatsServiceImpl.java
|
||||||
|
|
||||||
|
实现 `getCourseUsageStats()` 方法:
|
||||||
|
- 调用 `calculateTimeRange()` 计算时间范围
|
||||||
|
- 支持四种周期类型:TODAY、WEEK、MONTH、CUSTOM
|
||||||
|
- 调用 Mapper 获取统计数据
|
||||||
|
|
||||||
|
实现 `calculateTimeRange()` 辅助方法:
|
||||||
|
- TODAY: 当天 00:00:00 至今
|
||||||
|
- WEEK: 本周一至今
|
||||||
|
- MONTH: 本月 1 号至今
|
||||||
|
- CUSTOM: 自定义日期范围
|
||||||
|
|
||||||
|
#### 5. TeacherStatsController.java
|
||||||
|
|
||||||
|
新增接口:
|
||||||
|
- `GET /api/v1/teacher/course-usage-stats` - 增强版课程使用统计
|
||||||
|
- 支持参数:`periodType`、`startDate`、`endDate`
|
||||||
|
- 保留旧接口 `/api/v1/teacher/course-usage`(标记为 @Deprecated)
|
||||||
|
|
||||||
|
### 前端改动
|
||||||
|
|
||||||
|
#### 1. src/api/teacher.ts
|
||||||
|
|
||||||
|
新增类型定义:
|
||||||
|
- `CourseUsageQueryParams` - 查询参数类型
|
||||||
|
- `CourseUsageStatsItem` - 响应数据类型
|
||||||
|
|
||||||
|
新增 API 方法:
|
||||||
|
- `getTeacherCourseUsageStats()` - 获取增强版课程使用统计
|
||||||
|
|
||||||
|
保留旧 API(向后兼容):
|
||||||
|
- `getTeacherCourseUsage()`
|
||||||
|
|
||||||
|
#### 2. src/views/teacher/DashboardView.vue
|
||||||
|
|
||||||
|
**新增响应式数据**:
|
||||||
|
- `usagePeriodType`: 当前选择的周期类型
|
||||||
|
- `courseUsageStatsData`: 增强版课程使用统计数据
|
||||||
|
- `periodOptions`: 周期选项(今日/本周/本月)
|
||||||
|
|
||||||
|
**UI 改动**:
|
||||||
|
- 在课程使用卡片添加周期选择器(a-segmented)
|
||||||
|
- 用户可通过点击切换统计周期
|
||||||
|
|
||||||
|
**图表增强**:
|
||||||
|
- `initUsageChart()` 支持新旧两种数据类型
|
||||||
|
- tooltip 显示更详细信息:
|
||||||
|
- 使用次数
|
||||||
|
- 参与学生数
|
||||||
|
- 平均时长
|
||||||
|
- 最后使用时间
|
||||||
|
|
||||||
|
**数据加载**:
|
||||||
|
- `loadUsageData()` 调用新 API `getTeacherCourseUsageStats()`
|
||||||
|
- 周期切换时自动重新加载数据
|
||||||
|
|
||||||
|
### 统计逻辑
|
||||||
|
|
||||||
|
1. **使用次数**:统计周期内 COMPLETED 状态的 lesson 数量
|
||||||
|
2. **参与学生数**:去重统计 student_record 中的 student_id
|
||||||
|
3. **平均时长**:计算 lesson 的 start_datetime 到 end_datetime 的分钟差平均值
|
||||||
|
4. **最后使用时间**:最近一次授课完成时间
|
||||||
|
|
||||||
|
### 数据流向
|
||||||
|
|
||||||
|
```
|
||||||
|
用户选择周期 → 前端调用 API → Controller 接收参数
|
||||||
|
→ Service 计算时间范围 → Mapper 执行 SQL 统计
|
||||||
|
→ 返回 CourseUsageStatsVO 列表 → 前端图表展示
|
||||||
|
```
|
||||||
|
|
||||||
|
### SQL 统计逻辑
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
cp.id AS coursePackageId,
|
||||||
|
cp.name AS coursePackageName,
|
||||||
|
COUNT(l.id) AS usageCount,
|
||||||
|
COUNT(DISTINCT sr.student_id) AS studentCount,
|
||||||
|
AVG(TIMESTAMPDIFF(MINUTE, l.start_datetime, l.end_datetime)) AS avgDuration,
|
||||||
|
MAX(l.end_datetime) AS lastUsedAt
|
||||||
|
FROM lesson l
|
||||||
|
INNER JOIN course_package cp ON l.course_id = cp.id
|
||||||
|
LEFT JOIN student_record sr ON l.id = sr.lesson_id
|
||||||
|
WHERE l.tenant_id = #{tenantId}
|
||||||
|
AND l.end_datetime >= #{startTime}
|
||||||
|
AND l.end_datetime <= #{endTime}
|
||||||
|
AND l.status = 'COMPLETED'
|
||||||
|
AND l.teacher_id = #{teacherId}
|
||||||
|
GROUP BY cp.id, cp.name
|
||||||
|
ORDER BY usageCount DESC
|
||||||
|
LIMIT 10
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试验证
|
||||||
|
|
||||||
|
- [x] 后端编译通过 ✅
|
||||||
|
- [x] 启动后端服务测试 API ✅
|
||||||
|
- [x] API 返回数据正确 ✅
|
||||||
|
- [ ] 前端展示周期选择器
|
||||||
|
- [ ] 切换周期数据正确刷新
|
||||||
|
- [ ] tooltip 显示完整信息
|
||||||
|
|
||||||
|
### API 测试响应示例
|
||||||
|
|
||||||
|
**请求**: `GET /api/v1/teacher/course-usage-stats?periodType=MONTH`
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"coursePackageId": "17",
|
||||||
|
"coursePackageName": "课程简介课程简介课程简介课程简介课程简介课程简介课程简介",
|
||||||
|
"usageCount": 15,
|
||||||
|
"studentCount": 5,
|
||||||
|
"avgDuration": 0,
|
||||||
|
"lastUsedAt": "2026-03-16T00:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"coursePackageId": "16",
|
||||||
|
"coursePackageName": "T 色他",
|
||||||
|
"usageCount": 1,
|
||||||
|
"studentCount": 0,
|
||||||
|
"avgDuration": 0,
|
||||||
|
"lastUsedAt": "2026-03-16T00:00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 技术难点解决
|
||||||
|
|
||||||
|
**问题**: MyBatis `@SelectProvider` 不支持 XML 格式的 `<if>` 动态标签
|
||||||
|
|
||||||
|
**解决方案**: 使用 SQL 条件 `AND (#{teacherId} IS NULL OR l.teacher_id = #{teacherId})` 替代动态 SQL,实现可选参数过滤。
|
||||||
|
|
||||||
|
### 文件清单
|
||||||
|
|
||||||
|
**后端新增**:
|
||||||
|
- `dto/request/CourseUsageQuery.java`
|
||||||
|
- `dto/response/CourseUsageStatsVO.java`
|
||||||
|
|
||||||
|
**后端修改**:
|
||||||
|
- `mapper/LessonMapper.java`
|
||||||
|
- `service/TeacherStatsService.java`
|
||||||
|
- `service/impl/TeacherStatsServiceImpl.java`
|
||||||
|
- `controller/teacher/TeacherStatsController.java`
|
||||||
|
|
||||||
|
**前端修改**:
|
||||||
|
- `src/api/teacher.ts`
|
||||||
|
- `src/views/teacher/DashboardView.vue`
|
||||||
|
|
||||||
|
### 后续优化建议
|
||||||
|
|
||||||
|
1. **前端展示优化**:可以考虑将饼图改为条形图,更适合展示 TOP 排行
|
||||||
|
2. **导出功能**:支持导出统计数据为 Excel
|
||||||
|
3. **更多维度**:增加课程类型分布、班级使用对比等
|
||||||
|
4. **缓存优化**:统计数据可以缓存在 Redis 中,提高查询性能
|
||||||
169
docs/dev-logs/2026-03-21.md
Normal file
169
docs/dev-logs/2026-03-21.md
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
# 开发日志 2026-03-21
|
||||||
|
|
||||||
|
## 学校端 - 课程使用统计功能实现
|
||||||
|
|
||||||
|
### 需求背景
|
||||||
|
|
||||||
|
学校端 Dashboard 页面已有"课程使用统计"卡片组件,但后端返回空数据。需要实现该功能,让学校管理员能够查看本校各课程包的使用情况。
|
||||||
|
|
||||||
|
### 实现内容
|
||||||
|
|
||||||
|
#### 1. 后端修改
|
||||||
|
|
||||||
|
**Controller 层** (`SchoolStatsController.java`)
|
||||||
|
|
||||||
|
- 添加日期范围参数支持,允许前端传入 `startDate` 和 `endDate`
|
||||||
|
- 不传参数时默认统计本月数据
|
||||||
|
|
||||||
|
```java
|
||||||
|
@GetMapping("/courses")
|
||||||
|
@Operation(summary = "获取课程使用统计")
|
||||||
|
public Result<List<Map<String, Object>>> getCourseUsageStats(
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
|
||||||
|
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||||
|
return Result.success(schoolStatsService.getCourseUsageStats(tenantId, startDate, endDate));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Service 接口** (`SchoolStatsService.java`)
|
||||||
|
|
||||||
|
- 更新方法签名,添加日期参数
|
||||||
|
- 添加 `LocalDate` 导入
|
||||||
|
|
||||||
|
```java
|
||||||
|
List<Map<String, Object>> getCourseUsageStats(Long tenantId, LocalDate startDate, LocalDate endDate);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Service 实现** (`SchoolStatsServiceImpl.java`)
|
||||||
|
|
||||||
|
- 使用 `LessonMapper.getCourseUsageStats()` 查询实际数据
|
||||||
|
- 支持日期范围筛选,默认统计本月
|
||||||
|
- 将 `CourseUsageStatsVO` 转换为 `Map` 格式返回
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Override
|
||||||
|
public List<Map<String, Object>> getCourseUsageStats(Long tenantId, LocalDate startDate, LocalDate endDate) {
|
||||||
|
// 计算时间范围
|
||||||
|
LocalDateTime startTime;
|
||||||
|
LocalDateTime endTime;
|
||||||
|
|
||||||
|
if (startDate != null) {
|
||||||
|
startTime = startDate.atStartOfDay();
|
||||||
|
} else {
|
||||||
|
startTime = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate != null) {
|
||||||
|
endTime = endDate.atTime(23, 59, 59);
|
||||||
|
} else {
|
||||||
|
endTime = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 Mapper 查询
|
||||||
|
List<CourseUsageStatsVO> stats = lessonMapper.getCourseUsageStats(
|
||||||
|
tenantId, null, startTime, endTime
|
||||||
|
);
|
||||||
|
|
||||||
|
// 转换为 Map 格式返回
|
||||||
|
return stats.stream().map(vo -> {
|
||||||
|
Map<String, Object> map = new HashMap<>();
|
||||||
|
map.put("courseId", vo.getCoursePackageId());
|
||||||
|
map.put("courseName", vo.getCoursePackageName());
|
||||||
|
map.put("usageCount", vo.getUsageCount());
|
||||||
|
map.put("studentCount", vo.getStudentCount() != null ? vo.getStudentCount() : 0);
|
||||||
|
map.put("avgDuration", vo.getAvgDuration() != null ? vo.getAvgDuration() : 0);
|
||||||
|
map.put("lastUsedAt", vo.getLastUsedAt());
|
||||||
|
return map;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 前端修改
|
||||||
|
|
||||||
|
**API 层** (`src/api/school.ts`)
|
||||||
|
|
||||||
|
- 更新 `getCourseUsageStats` 支持日期参数
|
||||||
|
- 增强返回类型定义
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const getCourseUsageStats = (startDate?: string, endDate?: string) => {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (startDate) params.startDate = startDate;
|
||||||
|
if (endDate) params.endDate = endDate;
|
||||||
|
return http.get<Array<{
|
||||||
|
courseId: number;
|
||||||
|
courseName: string;
|
||||||
|
usageCount: number;
|
||||||
|
studentCount?: number;
|
||||||
|
avgDuration?: number;
|
||||||
|
lastUsedAt?: string;
|
||||||
|
}>>('/v1/school/stats/courses', { params });
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**组件层** (`src/views/school/DashboardView.vue`)
|
||||||
|
|
||||||
|
- `loadCourseStats` 函数传递日期范围参数
|
||||||
|
- `onMounted` 中设置默认日期范围为当月
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const loadCourseStats = async () => {
|
||||||
|
courseStatsLoading.value = true;
|
||||||
|
try {
|
||||||
|
const startDate = dateRange.value?.[0]?.format('YYYY-MM-DD');
|
||||||
|
const endDate = dateRange.value?.[1]?.format('YYYY-MM-DD');
|
||||||
|
const data = await getCourseUsageStats(startDate, endDate);
|
||||||
|
courseStats.value = data.slice(0, 10);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load course stats:', error);
|
||||||
|
} finally {
|
||||||
|
courseStatsLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// ... 其他初始化
|
||||||
|
// 设置默认日期范围为当月
|
||||||
|
const now = dayjs();
|
||||||
|
const monthStart = now.startOf('month');
|
||||||
|
const monthEnd = now.endOf('month');
|
||||||
|
dateRange.value = [monthStart, monthEnd];
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据来源
|
||||||
|
|
||||||
|
课程使用统计基于 `lesson` 表的实际授课记录统计:
|
||||||
|
|
||||||
|
- **usageCount**: 统计周期内 COMPLETED 状态的 lesson 数量
|
||||||
|
- **studentCount**: 参与学生数(去重统计 student_record 中的 student_id)
|
||||||
|
- **avgDuration**: 平均时长(lesson 的 start_datetime 到 end_datetime 的分钟差平均值)
|
||||||
|
- **lastUsedAt**: 最后使用时间(最近一次授课完成时间)
|
||||||
|
|
||||||
|
### 文件变更列表
|
||||||
|
|
||||||
|
| 文件 | 变更说明 |
|
||||||
|
|------|---------|
|
||||||
|
| `reading-platform-java/src/main/java/com/reading/platform/controller/school/SchoolStatsController.java` | 添加日期范围参数 |
|
||||||
|
| `reading-platform-java/src/main/java/com/reading/platform/service/SchoolStatsService.java` | 更新方法签名 |
|
||||||
|
| `reading-platform-java/src/main/java/com/reading/platform/service/impl/SchoolStatsServiceImpl.java` | 实现实际查询逻辑 |
|
||||||
|
| `reading-platform-frontend/src/api/school.ts` | 更新 API 函数和类型 |
|
||||||
|
| `reading-platform-frontend/src/views/school/DashboardView.vue` | 传递日期参数,设置默认日期范围 |
|
||||||
|
|
||||||
|
### 测试验证
|
||||||
|
|
||||||
|
- [ ] 后端编译通过
|
||||||
|
- [ ] 启动后端服务(端口 8480)
|
||||||
|
- [ ] 启动前端服务(端口 5173)
|
||||||
|
- [ ] 登录学校管理员账号
|
||||||
|
- [ ] 访问 Dashboard 页面,查看"课程使用统计"卡片
|
||||||
|
- [ ] 验证日期范围筛选功能
|
||||||
|
- [ ] 验证数据显示正确性
|
||||||
|
|
||||||
|
### 后续优化建议
|
||||||
|
|
||||||
|
1. **增加更多统计维度**:按班级、按教师统计
|
||||||
|
2. **可视化增强**:趋势图、热力图
|
||||||
|
3. **导出功能**:Excel 导出课程使用明细
|
||||||
|
4. **性能优化**:大数据量时考虑缓存
|
||||||
@ -114,7 +114,7 @@
|
|||||||
| 租户总数 | 显示真实数据 | 显示 2 | ✅ |
|
| 租户总数 | 显示真实数据 | 显示 2 | ✅ |
|
||||||
| 课程包总数 | 显示真实数据 | 显示 5 | ✅ |
|
| 课程包总数 | 显示真实数据 | 显示 5 | ✅ |
|
||||||
| 月授课次数 | 显示真实数据 | 显示 22 | ✅ |
|
| 月授课次数 | 显示真实数据 | 显示 22 | ✅ |
|
||||||
| 覆盖学生 | 显示真实数据 | 显示 5 | ✅ |
|
| 学生总数 | 显示真实数据 | 显示 5 | ✅ |
|
||||||
|
|
||||||
### 3.2 使用趋势图
|
### 3.2 使用趋势图
|
||||||
| 测试项 | 预期结果 | 实际结果 | 状态 |
|
| 测试项 | 预期结果 | 实际结果 | 状态 |
|
||||||
|
|||||||
@ -51,7 +51,7 @@ reading-platform-frontend/tests/e2e/admin/
|
|||||||
|
|
||||||
| 测试项 | 状态 | 说明 |
|
| 测试项 | 状态 | 说明 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| 验证统计卡片显示 | ✅ | 租户数、课程包数、月授课次数、覆盖学生 |
|
| 验证统计卡片显示 | ✅ | 租户数、课程包数、月授课次数、学生总数 |
|
||||||
| 验证趋势图加载 | ✅ | 验证图表容器显示 |
|
| 验证趋势图加载 | ✅ | 验证图表容器显示 |
|
||||||
| 验证活跃租户 TOP5 列表 | ✅ | 验证列表数据 |
|
| 验证活跃租户 TOP5 列表 | ✅ | 验证列表数据 |
|
||||||
| 验证热门课程包 TOP5 列表 | ✅ | 验证列表数据 |
|
| 验证热门课程包 TOP5 列表 | ✅ | 验证列表数据 |
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -107,6 +107,7 @@ export interface AdminStatsResponse {
|
|||||||
totalStudents: number;
|
totalStudents: number;
|
||||||
totalTeachers: number;
|
totalTeachers: number;
|
||||||
totalLessons: number;
|
totalLessons: number;
|
||||||
|
monthlyLessons: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 前端使用的统计数据结构
|
// 前端使用的统计数据结构
|
||||||
@ -124,9 +125,8 @@ export interface AdminStats {
|
|||||||
// 后端返回的趋势数据结构(分离数组格式)
|
// 后端返回的趋势数据结构(分离数组格式)
|
||||||
export interface StatsTrendResponse {
|
export interface StatsTrendResponse {
|
||||||
dates: string[];
|
dates: string[];
|
||||||
newStudents: number[];
|
lessonCounts: number[];
|
||||||
newTeachers: number[];
|
studentCounts: number[];
|
||||||
newCourses: number[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 前端使用的趋势数据结构
|
// 前端使用的趋势数据结构
|
||||||
@ -141,17 +141,16 @@ export interface TrendData {
|
|||||||
export interface ActiveTenantResponse {
|
export interface ActiveTenantResponse {
|
||||||
tenantId: number;
|
tenantId: number;
|
||||||
tenantName: string;
|
tenantName: string;
|
||||||
activeUsers: number;
|
activeTeacherCount: number;
|
||||||
courseCount: number;
|
completedLessonCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 前端使用的活跃租户结构
|
// 前端使用的活跃租户结构
|
||||||
export interface ActiveTenant {
|
export interface ActiveTenant {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
lessonCount: number;
|
activeTeacherCount: number;
|
||||||
teacherCount: number | string;
|
completedLessonCount: number;
|
||||||
studentCount: number | string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 后端返回的热门课程结构
|
// 后端返回的热门课程结构
|
||||||
@ -273,7 +272,7 @@ const mapStatsData = (data: AdminStatsResponse): AdminStats => ({
|
|||||||
teacherCount: data.totalTeachers || 0,
|
teacherCount: data.totalTeachers || 0,
|
||||||
lessonCount: data.totalLessons || 0,
|
lessonCount: data.totalLessons || 0,
|
||||||
publishedCourseCount: 0, // 后端暂无此数据
|
publishedCourseCount: 0, // 后端暂无此数据
|
||||||
monthlyLessons: 0, // 后端暂无此数据
|
monthlyLessons: data.monthlyLessons || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 趋势数据映射:分离数组 -> 对象数组
|
// 趋势数据映射:分离数组 -> 对象数组
|
||||||
@ -282,8 +281,8 @@ const mapTrendData = (data: StatsTrendResponse): TrendData[] => {
|
|||||||
return data.dates.map((date, index) => ({
|
return data.dates.map((date, index) => ({
|
||||||
month: date,
|
month: date,
|
||||||
tenantCount: 0, // 后端无此数据
|
tenantCount: 0, // 后端无此数据
|
||||||
lessonCount: data.newCourses?.[index] || 0,
|
lessonCount: data.lessonCounts?.[index] || 0,
|
||||||
studentCount: data.newStudents?.[index] || 0,
|
studentCount: data.studentCounts?.[index] || 0,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -293,9 +292,8 @@ const mapActiveTenants = (data: ActiveTenantResponse[]): ActiveTenant[] => {
|
|||||||
return data.map(item => ({
|
return data.map(item => ({
|
||||||
id: item.tenantId,
|
id: item.tenantId,
|
||||||
name: item.tenantName,
|
name: item.tenantName,
|
||||||
teacherCount: '-', // 后端无单独字段
|
activeTeacherCount: item.activeTeacherCount ?? 0,
|
||||||
studentCount: '-', // 后端无单独字段
|
completedLessonCount: item.completedLessonCount ?? 0,
|
||||||
lessonCount: item.courseCount, // 使用 courseCount 替代
|
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -55,3 +55,26 @@ export function refreshToken(): Promise<{ token: string }> {
|
|||||||
export function getProfile(): Promise<UserProfile> {
|
export function getProfile(): Promise<UserProfile> {
|
||||||
return http.get('/v1/auth/profile');
|
return http.get('/v1/auth/profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 修改个人信息
|
||||||
|
export interface UpdateProfileDto {
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfileResponse {
|
||||||
|
userInfo: UserProfile;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateProfile(data: UpdateProfileDto): Promise<UpdateProfileResponse> {
|
||||||
|
return http.put('/v1/auth/profile', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改密码(修改成功后 token 失效,需重新登录)
|
||||||
|
export function changePassword(oldPassword: string, newPassword: string): Promise<void> {
|
||||||
|
return http.post('/v1/auth/change-password', null, {
|
||||||
|
params: { oldPassword, newPassword },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -14,8 +14,8 @@ export interface ActiveTenantItemResponse {
|
|||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
/** 租户名称 */
|
/** 租户名称 */
|
||||||
tenantName?: string;
|
tenantName?: string;
|
||||||
/** 活跃用户数 */
|
/** 活跃教师数(近 30 天有完成课程的老师数) */
|
||||||
activeUsers?: number;
|
activeTeacherCount?: number;
|
||||||
/** 课程使用数 */
|
/** 完成课次数(近 30 天 COMPLETED 状态的 lesson 总数) */
|
||||||
courseCount?: number;
|
completedLessonCount?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Reading Platform API
|
||||||
|
* Reading Platform Backend Service API Documentation
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课程包视图对象
|
||||||
|
*/
|
||||||
|
export interface CoursePackageVO {
|
||||||
|
/** ID */
|
||||||
|
id?: number;
|
||||||
|
/** 名称 */
|
||||||
|
name?: string;
|
||||||
|
/** 描述 */
|
||||||
|
description?: string;
|
||||||
|
/** 适用年级 */
|
||||||
|
gradeLevel?: string;
|
||||||
|
/** 课程数量 */
|
||||||
|
courseCount?: number;
|
||||||
|
/** 状态 */
|
||||||
|
status?: string;
|
||||||
|
/** 使用次数 */
|
||||||
|
usageCount?: number;
|
||||||
|
/** 创建时间 */
|
||||||
|
createdAt?: string;
|
||||||
|
/** 更新时间 */
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
@ -6,4 +6,12 @@
|
|||||||
* OpenAPI spec version: 1.0.0
|
* OpenAPI spec version: 1.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type GetRecentActivities1200DataItem = { [key: string]: unknown };
|
/**
|
||||||
|
* 课程使用统计视图对象
|
||||||
|
*/
|
||||||
|
export interface CourseUsageVO {
|
||||||
|
/** 课程包名称 */
|
||||||
|
name?: string;
|
||||||
|
/** 使用次数 */
|
||||||
|
value?: number;
|
||||||
|
}
|
||||||
@ -7,5 +7,5 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type GetLessonTrend1Params = {
|
export type GetLessonTrend1Params = {
|
||||||
months?: number;
|
days?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v8.5.3 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* Reading Platform API
|
|
||||||
* Reading Platform Backend Service API Documentation
|
|
||||||
* OpenAPI spec version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 最近活动项响应
|
|
||||||
*/
|
|
||||||
export interface RecentActivityItemResponse {
|
|
||||||
/** 活动 ID */
|
|
||||||
activityId?: number;
|
|
||||||
/** 活动类型 */
|
|
||||||
activityType?: string;
|
|
||||||
/** 活动描述 */
|
|
||||||
description?: string;
|
|
||||||
/** 操作人 ID */
|
|
||||||
operatorId?: number;
|
|
||||||
/** 操作人名称 */
|
|
||||||
operatorName?: string;
|
|
||||||
/** 操作时间 */
|
|
||||||
operationTime?: string;
|
|
||||||
}
|
|
||||||
@ -5,10 +5,10 @@
|
|||||||
* Reading Platform Backend Service API Documentation
|
* Reading Platform Backend Service API Documentation
|
||||||
* OpenAPI spec version: 1.0.0
|
* OpenAPI spec version: 1.0.0
|
||||||
*/
|
*/
|
||||||
import type { RecentActivityItemResponse } from './recentActivityItemResponse';
|
import type { CoursePackageVO } from './coursePackageVO';
|
||||||
|
|
||||||
export interface ResultListRecentActivityItemResponse {
|
export interface ResultListCoursePackageVO {
|
||||||
code?: number;
|
code?: number;
|
||||||
message?: string;
|
message?: string;
|
||||||
data?: RecentActivityItemResponse[];
|
data?: CoursePackageVO[];
|
||||||
}
|
}
|
||||||
@ -5,10 +5,10 @@
|
|||||||
* Reading Platform Backend Service API Documentation
|
* Reading Platform Backend Service API Documentation
|
||||||
* OpenAPI spec version: 1.0.0
|
* OpenAPI spec version: 1.0.0
|
||||||
*/
|
*/
|
||||||
import type { GetRecentActivities1200DataItem } from './getRecentActivities1200DataItem';
|
import type { CourseUsageVO } from './courseUsageVO';
|
||||||
|
|
||||||
export type GetRecentActivities1200 = {
|
export interface ResultListCourseUsageVO {
|
||||||
code?: number;
|
code?: number;
|
||||||
message?: string;
|
message?: string;
|
||||||
data?: GetRecentActivities1200DataItem[];
|
data?: CourseUsageVO[];
|
||||||
};
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Reading Platform API
|
||||||
|
* Reading Platform Backend Service API Documentation
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { TeacherLessonTrendVO } from './teacherLessonTrendVO';
|
||||||
|
|
||||||
|
export interface ResultListTeacherLessonTrendVO {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
data?: TeacherLessonTrendVO[];
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Reading Platform API
|
||||||
|
* Reading Platform Backend Service API Documentation
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { TeacherLessonVO } from './teacherLessonVO';
|
||||||
|
|
||||||
|
export interface ResultListTeacherLessonVO {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
data?: TeacherLessonVO[];
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Reading Platform API
|
||||||
|
* Reading Platform Backend Service API Documentation
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { TeacherDashboardResponse } from './teacherDashboardResponse';
|
||||||
|
|
||||||
|
export interface ResultTeacherDashboardResponse {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
data?: TeacherDashboardResponse;
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Reading Platform API
|
||||||
|
* Reading Platform Backend Service API Documentation
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { TeacherWeeklyStatsResponse } from './teacherWeeklyStatsResponse';
|
||||||
|
|
||||||
|
export interface ResultTeacherWeeklyStatsResponse {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
data?: TeacherWeeklyStatsResponse;
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Reading Platform API
|
||||||
|
* Reading Platform Backend Service API Documentation
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { UpdateProfileResponse } from './updateProfileResponse';
|
||||||
|
|
||||||
|
export interface ResultUpdateProfileResponse {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
data?: UpdateProfileResponse;
|
||||||
|
}
|
||||||
@ -22,4 +22,6 @@ export interface StatsResponse {
|
|||||||
totalCourses?: number;
|
totalCourses?: number;
|
||||||
/** 课时总数 */
|
/** 课时总数 */
|
||||||
totalLessons?: number;
|
totalLessons?: number;
|
||||||
|
/** 月授课次数(本月 COMPLETED 状态的 lesson 总数) */
|
||||||
|
monthlyLessons?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,10 +12,8 @@
|
|||||||
export interface StatsTrendResponse {
|
export interface StatsTrendResponse {
|
||||||
/** 日期列表 */
|
/** 日期列表 */
|
||||||
dates?: string[];
|
dates?: string[];
|
||||||
/** 新增学生数列表 */
|
/** 授课次数列表(近 7 天每天完成的课程数) */
|
||||||
newStudents?: number[];
|
lessonCounts?: number[];
|
||||||
/** 新增教师数列表 */
|
/** 活跃学生数列表(近 7 天每天有上课记录的去重学生数) */
|
||||||
newTeachers?: number[];
|
studentCounts?: number[];
|
||||||
/** 新增课程数列表 */
|
|
||||||
newCourses?: number[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Reading Platform API
|
||||||
|
* Reading Platform Backend Service API Documentation
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { CoursePackageVO } from './coursePackageVO';
|
||||||
|
import type { TeacherDashboardResponseRecentActivitiesItem } from './teacherDashboardResponseRecentActivitiesItem';
|
||||||
|
import type { TeacherLessonVO } from './teacherLessonVO';
|
||||||
|
import type { TeacherStats } from './teacherStats';
|
||||||
|
import type { TeacherWeeklyStatsResponse } from './teacherWeeklyStatsResponse';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师仪表盘响应
|
||||||
|
*/
|
||||||
|
export interface TeacherDashboardResponse {
|
||||||
|
stats?: TeacherStats;
|
||||||
|
/** 今日课程 */
|
||||||
|
todayLessons?: TeacherLessonVO[];
|
||||||
|
/** 推荐课程 */
|
||||||
|
recommendedCourses?: CoursePackageVO[];
|
||||||
|
weeklyStats?: TeacherWeeklyStatsResponse;
|
||||||
|
/** 近期活动 */
|
||||||
|
recentActivities?: TeacherDashboardResponseRecentActivitiesItem[];
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Reading Platform API
|
||||||
|
* Reading Platform Backend Service API Documentation
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师课程趋势视图对象
|
||||||
|
*/
|
||||||
|
export interface TeacherLessonTrendVO {
|
||||||
|
/** 月份(yyyy-MM 格式) */
|
||||||
|
month?: string;
|
||||||
|
/** 课时数量 */
|
||||||
|
lessonCount?: number;
|
||||||
|
/** 平均评分 */
|
||||||
|
avgRating?: number;
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Reading Platform API
|
||||||
|
* Reading Platform Backend Service API Documentation
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { LocalTime } from './localTime';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师课程视图对象
|
||||||
|
*/
|
||||||
|
export interface TeacherLessonVO {
|
||||||
|
/** ID */
|
||||||
|
id?: number;
|
||||||
|
/** 租户 ID */
|
||||||
|
tenantId?: number;
|
||||||
|
/** 课程 ID */
|
||||||
|
courseId?: number;
|
||||||
|
/** 班级 ID */
|
||||||
|
classId?: number;
|
||||||
|
/** 课程名称 */
|
||||||
|
courseName?: string;
|
||||||
|
/** 班级名称 */
|
||||||
|
className?: string;
|
||||||
|
/** 教师 ID */
|
||||||
|
teacherId?: number;
|
||||||
|
/** 标题 */
|
||||||
|
title?: string;
|
||||||
|
/** 课时日期 */
|
||||||
|
lessonDate?: string;
|
||||||
|
startTime?: LocalTime;
|
||||||
|
endTime?: LocalTime;
|
||||||
|
/** 地点 */
|
||||||
|
location?: string;
|
||||||
|
/** 状态 */
|
||||||
|
status?: string;
|
||||||
|
/** 备注 */
|
||||||
|
notes?: string;
|
||||||
|
/** 创建时间 */
|
||||||
|
createdAt?: string;
|
||||||
|
/** 更新时间 */
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Reading Platform API
|
||||||
|
* Reading Platform Backend Service API Documentation
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础统计
|
||||||
|
*/
|
||||||
|
export interface TeacherStats {
|
||||||
|
/** 班级数量 */
|
||||||
|
classCount?: number;
|
||||||
|
/** 学生数量 */
|
||||||
|
studentCount?: number;
|
||||||
|
/** 课程包数量 */
|
||||||
|
courseCount?: number;
|
||||||
|
/** 课时数量 */
|
||||||
|
lessonCount?: number;
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Reading Platform API
|
||||||
|
* Reading Platform Backend Service API Documentation
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师本周统计响应
|
||||||
|
*/
|
||||||
|
export interface TeacherWeeklyStatsResponse {
|
||||||
|
/** 本周课时数量 */
|
||||||
|
lessonCount?: number;
|
||||||
|
/** 学生参与率 */
|
||||||
|
studentParticipation?: number;
|
||||||
|
/** 平均评分 */
|
||||||
|
avgRating?: number;
|
||||||
|
/** 总时长(分钟) */
|
||||||
|
totalDuration?: number;
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Reading Platform API
|
||||||
|
* Reading Platform Backend Service API Documentation
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改个人信息请求
|
||||||
|
*/
|
||||||
|
export interface UpdateProfileRequest {
|
||||||
|
/**
|
||||||
|
* 姓名
|
||||||
|
* @pattern ^[一-龥a-zA-Z\s]{2,20}$
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
/**
|
||||||
|
* 手机号
|
||||||
|
* @pattern ^1[3-9]\d{9}$
|
||||||
|
*/
|
||||||
|
phone?: string;
|
||||||
|
/** 邮箱 */
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.5.3 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Reading Platform API
|
||||||
|
* Reading Platform Backend Service API Documentation
|
||||||
|
* OpenAPI spec version: 1.0.0
|
||||||
|
*/
|
||||||
|
import type { UserInfoResponse } from './userInfoResponse';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改个人信息响应
|
||||||
|
*/
|
||||||
|
export interface UpdateProfileResponse {
|
||||||
|
userInfo?: UserInfoResponse;
|
||||||
|
/** 新的 Token(用于刷新) */
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
@ -247,13 +247,23 @@ export const getSchoolStats = () =>
|
|||||||
http.get<SchoolStats>('/v1/school/stats');
|
http.get<SchoolStats>('/v1/school/stats');
|
||||||
|
|
||||||
export const getActiveTeachers = (limit?: number) =>
|
export const getActiveTeachers = (limit?: number) =>
|
||||||
http.get<Array<{ id: number; name: string; lessonCount: number }>>('/v1/school/stats/teachers', { params: { limit } });
|
http.get<Array<{
|
||||||
|
teacherId: number | string;
|
||||||
|
teacherName: string;
|
||||||
|
classNames: string;
|
||||||
|
lessonCount: number;
|
||||||
|
courseCount: number;
|
||||||
|
lastActiveAt?: string;
|
||||||
|
activityLevelCode: string;
|
||||||
|
activityLevelDesc: string;
|
||||||
|
}>>('/v1/school/stats/teachers', { params: { limit } });
|
||||||
|
|
||||||
export const getCourseUsageStats = () =>
|
export const getCourseUsageStats = (startDate?: string, endDate?: string) => {
|
||||||
http.get<Array<{ courseId: number; courseName: string; usageCount: number }>>('/v1/school/stats/courses');
|
const params: Record<string, string> = {};
|
||||||
|
if (startDate) params.startDate = startDate;
|
||||||
export const getRecentActivities = (limit?: number) =>
|
if (endDate) params.endDate = endDate;
|
||||||
http.get<Array<{ id: number; type: string; title: string; time: string }>>('/v1/school/stats/activities', { params: { limit } });
|
return http.get<Array<{ courseId: number; courseName: string; usageCount: number; studentCount?: number; avgDuration?: number; lastUsedAt?: string }>>('/v1/school/stats/courses', { params });
|
||||||
|
};
|
||||||
|
|
||||||
// ==================== 套餐信息(旧 API,保留兼容) ====================
|
// ==================== 套餐信息(旧 API,保留兼容) ====================
|
||||||
|
|
||||||
@ -632,7 +642,7 @@ export const getCalendarViewData = (params?: {
|
|||||||
// ==================== 趋势与分布统计 ====================
|
// ==================== 趋势与分布统计 ====================
|
||||||
|
|
||||||
export interface LessonTrendItem {
|
export interface LessonTrendItem {
|
||||||
month: string;
|
date: string; // 日期(MM-dd 格式)
|
||||||
lessonCount: number;
|
lessonCount: number;
|
||||||
studentCount: number;
|
studentCount: number;
|
||||||
}
|
}
|
||||||
@ -642,8 +652,11 @@ export interface CourseDistributionItem {
|
|||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getLessonTrend = (months?: number) =>
|
// 后端趋势数据响应(对象数组格式)
|
||||||
http.get<LessonTrendItem[]>('/v1/school/stats/lesson-trend', { params: { months } });
|
export type LessonTrendResponse = LessonTrendItem[];
|
||||||
|
|
||||||
|
export const getLessonTrend = (days?: number) =>
|
||||||
|
http.get<LessonTrendResponse>('/v1/school/stats/lesson-trend', { params: { days } });
|
||||||
|
|
||||||
export const getCourseDistribution = () =>
|
export const getCourseDistribution = () =>
|
||||||
http.get<CourseDistributionItem[]>('/v1/school/stats/course-distribution');
|
http.get<CourseDistributionItem[]>('/v1/school/stats/course-distribution');
|
||||||
|
|||||||
@ -319,59 +319,69 @@ export function batchSaveStudentRecords(
|
|||||||
|
|
||||||
// ==================== 教师首页 API ====================
|
// ==================== 教师首页 API ====================
|
||||||
|
|
||||||
|
export interface TeacherDashboardStats {
|
||||||
|
classCount: number;
|
||||||
|
studentCount: number;
|
||||||
|
lessonCount: number;
|
||||||
|
courseCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeacherLessonItem {
|
||||||
|
id: number;
|
||||||
|
tenantId: number;
|
||||||
|
courseId: number;
|
||||||
|
classId: number;
|
||||||
|
courseName: string;
|
||||||
|
className: string;
|
||||||
|
teacherId: number;
|
||||||
|
title: string;
|
||||||
|
lessonDate: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
location: string;
|
||||||
|
status: string;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeacherCoursePackageItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
gradeLevel?: string;
|
||||||
|
courseCount?: number;
|
||||||
|
status: string;
|
||||||
|
usageCount?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeacherWeeklyStatsData {
|
||||||
|
lessonCount: number;
|
||||||
|
studentParticipation: number;
|
||||||
|
avgRating: number;
|
||||||
|
totalDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
stats: {
|
stats: TeacherDashboardStats;
|
||||||
classCount: number;
|
todayLessons: TeacherLessonItem[];
|
||||||
studentCount: number;
|
recommendedCourses: TeacherCoursePackageItem[];
|
||||||
lessonCount: number;
|
weeklyStats: TeacherWeeklyStatsData;
|
||||||
courseCount: number;
|
|
||||||
};
|
|
||||||
todayLessons: Array<{
|
|
||||||
id: number;
|
|
||||||
courseId: number;
|
|
||||||
courseName: string;
|
|
||||||
pictureBookName?: string;
|
|
||||||
classId: number;
|
|
||||||
className: string;
|
|
||||||
plannedDatetime: string;
|
|
||||||
status: string;
|
|
||||||
duration: number;
|
|
||||||
}>;
|
|
||||||
recommendedCourses: Array<{
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
pictureBookName?: string;
|
|
||||||
coverImagePath?: string;
|
|
||||||
duration: number;
|
|
||||||
usageCount: number;
|
|
||||||
avgRating: number;
|
|
||||||
gradeTags: string[];
|
|
||||||
}>;
|
|
||||||
weeklyStats: {
|
|
||||||
lessonCount: number;
|
|
||||||
studentParticipation: number;
|
|
||||||
avgRating: number;
|
|
||||||
totalDuration: number;
|
|
||||||
};
|
|
||||||
recentActivities: Array<{
|
|
||||||
id: number;
|
|
||||||
type: string;
|
|
||||||
description: string;
|
|
||||||
time: string;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTeacherDashboard = () =>
|
export const getTeacherDashboard = () =>
|
||||||
http.get('/v1/teacher/dashboard') as any;
|
http.get<DashboardData>('/v1/teacher/dashboard');
|
||||||
|
|
||||||
export const getTodayLessons = () =>
|
export const getTodayLessons = () =>
|
||||||
http.get('/v1/teacher/today-lessons') as any;
|
http.get<TeacherLessonItem[]>('/v1/teacher/today-lessons');
|
||||||
|
|
||||||
export const getRecommendedCourses = () =>
|
export const getRecommendedCourses = () =>
|
||||||
http.get('/v1/teacher/recommended-courses') as any;
|
http.get<TeacherCoursePackageItem[]>('/v1/teacher/recommended-courses');
|
||||||
|
|
||||||
export const getWeeklyStats = () =>
|
export const getWeeklyStats = () =>
|
||||||
http.get('/v1/teacher/weekly-stats') as any;
|
http.get<TeacherWeeklyStatsData>('/v1/teacher/weekly-stats');
|
||||||
|
|
||||||
// ==================== 教师统计趋势 ====================
|
// ==================== 教师统计趋势 ====================
|
||||||
|
|
||||||
@ -386,12 +396,33 @@ export interface TeacherCourseUsageItem {
|
|||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 增强版课程使用统计类型
|
||||||
|
export interface CourseUsageQueryParams {
|
||||||
|
periodType?: 'TODAY' | 'WEEK' | 'MONTH' | 'CUSTOM';
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourseUsageStatsItem {
|
||||||
|
coursePackageId: number;
|
||||||
|
coursePackageName: string;
|
||||||
|
usageCount: number;
|
||||||
|
studentCount: number;
|
||||||
|
avgDuration: number;
|
||||||
|
lastUsedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const getTeacherLessonTrend = (months?: number) => {
|
export const getTeacherLessonTrend = (months?: number) => {
|
||||||
return http.get('/v1/teacher/lesson-trend', { params: { months } }) as any;
|
return http.get<TeacherLessonTrendItem[]>('/v1/teacher/lesson-trend', { params: { months } });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 旧版 API(保留向后兼容)
|
||||||
export const getTeacherCourseUsage = () =>
|
export const getTeacherCourseUsage = () =>
|
||||||
http.get('/v1/teacher/course-usage') as any;
|
http.get<TeacherCourseUsageItem[]>('/v1/teacher/course-usage');
|
||||||
|
|
||||||
|
// 增强版课程使用统计 API
|
||||||
|
export const getTeacherCourseUsageStats = (params?: CourseUsageQueryParams) =>
|
||||||
|
http.get<CourseUsageStatsItem[]>('/v1/teacher/course-usage-stats', { params });
|
||||||
|
|
||||||
// ==================== 课程反馈 API ====================
|
// ==================== 课程反馈 API ====================
|
||||||
|
|
||||||
|
|||||||
@ -107,28 +107,6 @@
|
|||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
<!-- 最近活动 -->
|
|
||||||
<a-row :gutter="24" style="margin-top: 24px">
|
|
||||||
<a-col :span="24">
|
|
||||||
<a-card title="最近活动" :bordered="false" :loading="activitiesLoading" class="modern-card">
|
|
||||||
<div v-if="recentActivities.length > 0" class="activity-timeline">
|
|
||||||
<div v-for="activity in recentActivities" :key="activity.id" class="activity-item">
|
|
||||||
<div class="activity-dot" :class="'type-' + activity.type"></div>
|
|
||||||
<div class="activity-content">
|
|
||||||
<div class="activity-header">
|
|
||||||
<a-tag :color="getActivityTagColor(activity.type)" class="activity-tag">
|
|
||||||
{{ getActivityTypeText(activity.type) }}
|
|
||||||
</a-tag>
|
|
||||||
<span class="activity-title">{{ activity.title }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="activity-time">{{ formatTime(activity.time) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a-empty v-else description="暂无活动记录" />
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -167,7 +145,6 @@ let trendChart: echarts.ECharts | null = null;
|
|||||||
const trendLoading = ref(false);
|
const trendLoading = ref(false);
|
||||||
const tenantsLoading = ref(false);
|
const tenantsLoading = ref(false);
|
||||||
const coursesLoading = ref(false);
|
const coursesLoading = ref(false);
|
||||||
const activitiesLoading = ref(false);
|
|
||||||
|
|
||||||
// 统计数据
|
// 统计数据
|
||||||
const statsData = ref<AdminStats>({
|
const statsData = ref<AdminStats>({
|
||||||
@ -187,37 +164,6 @@ const activeTenants = ref<ActiveTenant[]>([]);
|
|||||||
// 热门课程包
|
// 热门课程包
|
||||||
const popularCourses = ref<PopularCourse[]>([]);
|
const popularCourses = ref<PopularCourse[]>([]);
|
||||||
|
|
||||||
// 最近活动
|
|
||||||
const recentActivities = ref<Array<{
|
|
||||||
id: number;
|
|
||||||
type: string;
|
|
||||||
title: string;
|
|
||||||
time: string;
|
|
||||||
}>>([]);
|
|
||||||
|
|
||||||
// Activity colors
|
|
||||||
const getActivityTagColor = (type: string) => {
|
|
||||||
const colors: Record<string, string> = {
|
|
||||||
lesson: 'blue',
|
|
||||||
tenant: 'green',
|
|
||||||
course: 'orange',
|
|
||||||
};
|
|
||||||
return colors[type] || 'default';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getActivityTypeText = (type: string) => {
|
|
||||||
const texts: Record<string, string> = {
|
|
||||||
lesson: '授课',
|
|
||||||
tenant: '租户',
|
|
||||||
course: '课程',
|
|
||||||
};
|
|
||||||
return texts[type] || '其他';
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (time: string) => {
|
|
||||||
return dayjs(time).format('YYYY-MM-DD HH:mm');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
const viewTenantDetail = (id: number) => {
|
const viewTenantDetail = (id: number) => {
|
||||||
router.push(`/admin/tenants?id=${id}`);
|
router.push(`/admin/tenants?id=${id}`);
|
||||||
@ -393,24 +339,6 @@ const fetchPopularCourses = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchRecentActivities = async () => {
|
|
||||||
activitiesLoading.value = true;
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/admin/stats/activities?limit=10', {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
recentActivities.value = await response.json();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch recent activities:', error);
|
|
||||||
} finally {
|
|
||||||
activitiesLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle window resize
|
// Handle window resize
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
trendChart?.resize();
|
trendChart?.resize();
|
||||||
@ -421,7 +349,6 @@ onMounted(() => {
|
|||||||
fetchTrendData();
|
fetchTrendData();
|
||||||
fetchActiveTenants();
|
fetchActiveTenants();
|
||||||
fetchPopularCourses();
|
fetchPopularCourses();
|
||||||
fetchRecentActivities();
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
});
|
});
|
||||||
@ -624,65 +551,6 @@ $bg-light: #F9FAFB;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 活动时间线
|
|
||||||
.activity-timeline {
|
|
||||||
.activity-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 12px 0;
|
|
||||||
border-bottom: 1px solid $border-color;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-top: 6px;
|
|
||||||
margin-right: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&.type-lesson {
|
|
||||||
background: $primary-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.type-tenant {
|
|
||||||
background: $success-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.type-course {
|
|
||||||
background: $warning-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-content {
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
.activity-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
|
|
||||||
.activity-tag {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-title {
|
|
||||||
color: $text-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-time {
|
|
||||||
font-size: 12px;
|
|
||||||
color: $text-secondary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
|
|||||||
@ -34,27 +34,145 @@
|
|||||||
<a-descriptions-item label="邮箱">
|
<a-descriptions-item label="邮箱">
|
||||||
{{ profile.email || '-' }}
|
{{ profile.email || '-' }}
|
||||||
</a-descriptions-item>
|
</a-descriptions-item>
|
||||||
<!-- <a-descriptions-item label="所属机构" v-if="profile.tenantId">
|
|
||||||
{{ profile.tenantName || `租户ID: ${profile.tenantId}` }}
|
|
||||||
</a-descriptions-item> -->
|
|
||||||
</a-descriptions>
|
</a-descriptions>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="action-section">
|
||||||
|
<a-button type="primary" @click="enterEditMode">
|
||||||
|
<EditOutlined /> 编辑资料
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="showChangePasswordModal" style="margin-left: 12px">
|
||||||
|
<LockOutlined /> 修改密码
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-empty v-else-if="!loading" description="加载失败,请刷新重试" />
|
<a-empty v-else-if="!loading" description="加载失败,请刷新重试" />
|
||||||
</a-spin>
|
</a-spin>
|
||||||
|
|
||||||
|
<!-- 编辑资料弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="editModalOpen"
|
||||||
|
title="编辑个人信息"
|
||||||
|
@ok="submitEdit"
|
||||||
|
:confirmLoading="editLoading"
|
||||||
|
width="480px"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
:model="editForm"
|
||||||
|
:label-col="{ span: 6 }"
|
||||||
|
:wrapper-col="{ span: 16 }"
|
||||||
|
>
|
||||||
|
<a-form-item
|
||||||
|
label="姓名"
|
||||||
|
name="name"
|
||||||
|
:rules="[
|
||||||
|
{ required: true, message: '请输入姓名' },
|
||||||
|
{ pattern: /^[\u4e00-\u9fa5a-zA-Z\s]{2,20}$/, message: '姓名长度为 2-20 位,只能包含中文或英文' }
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<a-input v-model:value="editForm.name" placeholder="请输入姓名" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item
|
||||||
|
label="手机号"
|
||||||
|
name="phone"
|
||||||
|
:rules="[
|
||||||
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' }
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<a-input v-model:value="editForm.phone" placeholder="请输入手机号" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item
|
||||||
|
label="邮箱"
|
||||||
|
name="email"
|
||||||
|
:rules="[
|
||||||
|
{ type: 'email', message: '请输入正确的邮箱格式' }
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<a-input v-model:value="editForm.email" placeholder="请输入邮箱" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<!-- 修改密码弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="passwordModalOpen"
|
||||||
|
title="修改密码"
|
||||||
|
@ok="submitChangePassword"
|
||||||
|
:confirmLoading="passwordLoading"
|
||||||
|
width="480px"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
:model="passwordForm"
|
||||||
|
:label-col="{ span: 6 }"
|
||||||
|
:wrapper-col="{ span: 16 }"
|
||||||
|
>
|
||||||
|
<a-form-item
|
||||||
|
label="旧密码"
|
||||||
|
name="oldPassword"
|
||||||
|
:rules="[{ required: true, message: '请输入旧密码' }]"
|
||||||
|
>
|
||||||
|
<a-input-password v-model:value="passwordForm.oldPassword" placeholder="请输入旧密码" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item
|
||||||
|
label="新密码"
|
||||||
|
name="newPassword"
|
||||||
|
:rules="[
|
||||||
|
{ required: true, message: '请输入新密码' },
|
||||||
|
{ min: 6, message: '密码长度不能少于 6 位' }
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<a-input-password v-model:value="passwordForm.newPassword" placeholder="请输入新密码" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item
|
||||||
|
label="确认密码"
|
||||||
|
name="confirmPassword"
|
||||||
|
:rules="[
|
||||||
|
{ required: true, message: '请确认新密码' },
|
||||||
|
{ validator: validateConfirmPassword }
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<a-input-password v-model:value="passwordForm.confirmPassword" placeholder="请再次输入新密码" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { UserOutlined } from '@ant-design/icons-vue';
|
import { UserOutlined, EditOutlined, LockOutlined } from '@ant-design/icons-vue';
|
||||||
import { getProfile, type UserProfile } from '@/api/auth';
|
import { message } from 'ant-design-vue';
|
||||||
|
import { getProfile, type UserProfile, updateProfile, changePassword, type UpdateProfileDto } from '@/api/auth';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const profile = ref<UserProfile | null>(null);
|
const profile = ref<UserProfile | null>(null);
|
||||||
|
|
||||||
|
// 编辑资料相关
|
||||||
|
const editModalOpen = ref(false);
|
||||||
|
const editLoading = ref(false);
|
||||||
|
const editForm = ref<UpdateProfileDto>({
|
||||||
|
name: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 修改密码相关
|
||||||
|
const passwordModalOpen = ref(false);
|
||||||
|
const passwordLoading = ref(false);
|
||||||
|
const passwordForm = ref({
|
||||||
|
oldPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
});
|
||||||
|
|
||||||
const avatarUrl = computed(() => {
|
const avatarUrl = computed(() => {
|
||||||
const p = profile.value;
|
const p = profile.value;
|
||||||
if (!p) return '';
|
if (!p) return '';
|
||||||
@ -84,6 +202,73 @@ const loadProfile = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 进入编辑模式
|
||||||
|
const enterEditMode = () => {
|
||||||
|
if (profile.value) {
|
||||||
|
editForm.value = {
|
||||||
|
name: profile.value.name || '',
|
||||||
|
phone: profile.value.phone || '',
|
||||||
|
email: profile.value.email || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
editModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交编辑
|
||||||
|
const submitEdit = async () => {
|
||||||
|
try {
|
||||||
|
editLoading.value = true;
|
||||||
|
await updateProfile(editForm.value);
|
||||||
|
message.success('修改成功');
|
||||||
|
editModalOpen.value = false;
|
||||||
|
await loadProfile();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('修改失败', error);
|
||||||
|
message.error(error.message || '修改失败,请重试');
|
||||||
|
} finally {
|
||||||
|
editLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 显示修改密码弹窗
|
||||||
|
const showChangePasswordModal = () => {
|
||||||
|
passwordForm.value = {
|
||||||
|
oldPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
};
|
||||||
|
passwordModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 验证确认密码
|
||||||
|
const validateConfirmPassword = async (_rule: any, value: string) => {
|
||||||
|
if (value !== passwordForm.value.newPassword) {
|
||||||
|
throw new Error('两次输入的密码不一致');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交修改密码
|
||||||
|
const submitChangePassword = async () => {
|
||||||
|
try {
|
||||||
|
passwordLoading.value = true;
|
||||||
|
await changePassword(passwordForm.value.oldPassword, passwordForm.value.newPassword);
|
||||||
|
message.success('密码修改成功,请重新登录');
|
||||||
|
passwordModalOpen.value = false;
|
||||||
|
|
||||||
|
// 清除本地存储并跳转到登录页
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('userInfo');
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/login');
|
||||||
|
}, 1000);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('修改密码失败', error);
|
||||||
|
message.error(error.message || '修改密码失败,请重试');
|
||||||
|
} finally {
|
||||||
|
passwordLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadProfile();
|
loadProfile();
|
||||||
});
|
});
|
||||||
@ -139,4 +324,11 @@ onMounted(() => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
margin-top: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -53,69 +53,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 主要内容区域 -->
|
<!-- 教师活跃度排行(全宽) -->
|
||||||
<div class="content-grid">
|
<div class="teachers-card-full">
|
||||||
<!-- 近期活动 -->
|
<div class="card-header">
|
||||||
<div class="content-card activities-card">
|
<span class="card-icon"><TrophyOutlined /></span>
|
||||||
<div class="card-header">
|
<h3>教师活跃度排行</h3>
|
||||||
<span class="card-icon"><CalendarOutlined /></span>
|
</div>
|
||||||
<h3>近期课程活动</h3>
|
<div class="card-body" :class="{ 'is-loading': loading }">
|
||||||
|
<a-spin v-if="loading" />
|
||||||
|
<div v-else-if="activeTeachers.length === 0" class="empty-state">
|
||||||
|
<span class="empty-icon"><TeamOutlined /></span>
|
||||||
|
<p>暂无数据</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body" :class="{ 'is-loading': loading }">
|
<div v-else class="teacher-list">
|
||||||
<a-spin v-if="loading" />
|
<div
|
||||||
<div v-else-if="recentActivities.length === 0" class="empty-state">
|
v-for="(item, index) in activeTeachers"
|
||||||
<span class="empty-icon"><InboxOutlined /></span>
|
:key="item.teacherId"
|
||||||
<p>暂无近期活动</p>
|
class="teacher-item"
|
||||||
</div>
|
>
|
||||||
<div v-else class="activity-list">
|
<div class="rank-badge" :class="'rank-' + (index + 1)">
|
||||||
<div
|
{{ index + 1 }}
|
||||||
v-for="item in recentActivities"
|
</div>
|
||||||
:key="item.id"
|
<div class="teacher-info">
|
||||||
class="activity-item"
|
<div class="teacher-name-row">
|
||||||
>
|
<span class="teacher-name">{{ item.teacherName }}</span>
|
||||||
<div class="activity-avatar">
|
<a-tag :color="getActivityLevelColor(item.activityLevelCode)" size="small">
|
||||||
<BookOutlined />
|
{{ item.activityLevelDesc }}
|
||||||
|
</a-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="activity-content">
|
<div class="teacher-details">
|
||||||
<div class="activity-title">{{ item.title }}</div>
|
<span class="detail-item">
|
||||||
<div class="activity-time">{{ formatTime(item.time) }}</div>
|
<HomeOutlined />
|
||||||
|
{{ item.classNames || '未分配班级' }}
|
||||||
|
</span>
|
||||||
|
<span class="detail-item">
|
||||||
|
<ReadOutlined />
|
||||||
|
授课 {{ item.lessonCount }} 次
|
||||||
|
</span>
|
||||||
|
<span class="detail-item">
|
||||||
|
<BookOutlined />
|
||||||
|
课程 {{ item.courseCount }} 个
|
||||||
|
</span>
|
||||||
|
<span class="detail-item" v-if="item.lastActiveAt">
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
{{ formatLastActive(item.lastActiveAt) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="teacher-medal">
|
||||||
</div>
|
<component :is="getMedalIcon(index)" :style="getMedalStyle(index)" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 教师活跃度排行 -->
|
|
||||||
<div class="content-card teachers-card">
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="card-icon"><TrophyOutlined /></span>
|
|
||||||
<h3>教师活跃度排行</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body" :class="{ 'is-loading': loading }">
|
|
||||||
<a-spin v-if="loading" />
|
|
||||||
<div v-else-if="activeTeachers.length === 0" class="empty-state">
|
|
||||||
<span class="empty-icon"><TeamOutlined /></span>
|
|
||||||
<p>暂无数据</p>
|
|
||||||
</div>
|
|
||||||
<div v-else class="teacher-list">
|
|
||||||
<div
|
|
||||||
v-for="(item, index) in activeTeachers"
|
|
||||||
:key="item.id"
|
|
||||||
class="teacher-item"
|
|
||||||
>
|
|
||||||
<div class="rank-badge" :class="'rank-' + (index + 1)">
|
|
||||||
{{ index + 1 }}
|
|
||||||
</div>
|
|
||||||
<div class="teacher-info">
|
|
||||||
<div class="teacher-name">{{ item.name }}</div>
|
|
||||||
<div class="teacher-lessons">
|
|
||||||
<span class="lesson-icon"><ReadOutlined /></span>
|
|
||||||
授课 {{ item.lessonCount }} 次
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="teacher-medal">
|
|
||||||
<component :is="getMedalIcon(index)" :style="getMedalStyle(index)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -225,21 +211,19 @@ import {
|
|||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
ReadOutlined,
|
ReadOutlined,
|
||||||
CalendarOutlined,
|
|
||||||
InboxOutlined,
|
|
||||||
TrophyOutlined,
|
TrophyOutlined,
|
||||||
TrophyFilled,
|
TrophyFilled,
|
||||||
StarFilled,
|
StarFilled,
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
LineChartOutlined,
|
LineChartOutlined,
|
||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import {
|
import {
|
||||||
getSchoolStats,
|
getSchoolStats,
|
||||||
getActiveTeachers,
|
getActiveTeachers,
|
||||||
getRecentActivities,
|
|
||||||
getCourseUsageStats,
|
getCourseUsageStats,
|
||||||
getLessonTrend,
|
getLessonTrend,
|
||||||
getCourseDistribution,
|
getCourseDistribution,
|
||||||
@ -249,7 +233,7 @@ import {
|
|||||||
} from '@/api/school';
|
} from '@/api/school';
|
||||||
import type { SchoolStats, LessonTrendItem, CourseDistributionItem } from '@/api/school';
|
import type { SchoolStats, LessonTrendItem, CourseDistributionItem } from '@/api/school';
|
||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue';
|
||||||
import { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const courseStatsLoading = ref(false);
|
const courseStatsLoading = ref(false);
|
||||||
@ -269,8 +253,16 @@ const stats = ref<SchoolStats>({
|
|||||||
lessonCount: 0,
|
lessonCount: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const recentActivities = ref<Array<{ id: number; type: string; title: string; time: string }>>([]);
|
const activeTeachers = ref<Array<{
|
||||||
const activeTeachers = ref<Array<{ id: number; name: string; lessonCount: number }>>([]);
|
teacherId: number | string;
|
||||||
|
teacherName: string;
|
||||||
|
classNames: string;
|
||||||
|
lessonCount: number;
|
||||||
|
courseCount: number;
|
||||||
|
lastActiveAt?: string;
|
||||||
|
activityLevelCode: string;
|
||||||
|
activityLevelDesc: string;
|
||||||
|
}>>([]);
|
||||||
|
|
||||||
const courseStats = ref<Array<{ courseId: number; courseName: string; usageCount: number }>>([]);
|
const courseStats = ref<Array<{ courseId: number; courseName: string; usageCount: number }>>([]);
|
||||||
const dateRange = ref<[Dayjs, Dayjs] | undefined>(undefined);
|
const dateRange = ref<[Dayjs, Dayjs] | undefined>(undefined);
|
||||||
@ -331,6 +323,42 @@ const getMedalIcon = (index: number): Component => {
|
|||||||
return icons[index] || StarOutlined;
|
return icons[index] || StarOutlined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据活跃度等级获取标签颜色
|
||||||
|
*/
|
||||||
|
const getActivityLevelColor = (code: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
HIGH: 'red',
|
||||||
|
MEDIUM: 'orange',
|
||||||
|
LOW: 'blue',
|
||||||
|
INACTIVE: 'default',
|
||||||
|
};
|
||||||
|
return colors[code] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化最后活跃时间
|
||||||
|
*/
|
||||||
|
const formatLastActive = (time: string) => {
|
||||||
|
if (!time) return '';
|
||||||
|
const date = new Date(time);
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const yesterday = new Date(today.getTime() - 86400000);
|
||||||
|
const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
|
||||||
|
if (dateOnly.getTime() >= today.getTime()) {
|
||||||
|
return `今天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
} else if (dateOnly.getTime() >= yesterday.getTime()) {
|
||||||
|
return `昨天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
} else if (now.getTime() - date.getTime() < 7 * 86400000) {
|
||||||
|
const days = Math.floor((now.getTime() - date.getTime()) / 86400000);
|
||||||
|
return `${days} 天前`;
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getMedalStyle = (index: number) => {
|
const getMedalStyle = (index: number) => {
|
||||||
const styles = [
|
const styles = [
|
||||||
{ color: '#FFD700', fontSize: '20px' },
|
{ color: '#FFD700', fontSize: '20px' },
|
||||||
@ -394,7 +422,7 @@ const initTrendChart = (data: LessonTrendItem[]) => {
|
|||||||
|
|
||||||
// 确保数据格式正确
|
// 确保数据格式正确
|
||||||
const validData = data.map(d => ({
|
const validData = data.map(d => ({
|
||||||
month: d.month || '',
|
date: d.date || '',
|
||||||
lessonCount: d.lessonCount || 0,
|
lessonCount: d.lessonCount || 0,
|
||||||
studentCount: d.studentCount || 0,
|
studentCount: d.studentCount || 0,
|
||||||
}));
|
}));
|
||||||
@ -426,7 +454,7 @@ const initTrendChart = (data: LessonTrendItem[]) => {
|
|||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: validData.map((d) => d.month),
|
data: validData.map((d) => d.date),
|
||||||
axisLine: {
|
axisLine: {
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
color: '#E5E7EB',
|
color: '#E5E7EB',
|
||||||
@ -515,27 +543,39 @@ const initDistributionChart = (data: CourseDistributionItem[]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 确保数据格式正确
|
// 确保数据格式正确
|
||||||
const validData = data.map((item, index) => ({
|
const validData = data.map((item, index) => {
|
||||||
name: item.name || `课程${index + 1}`,
|
// 限制名称长度,超过 10 个字则缩略显示
|
||||||
value: item.value || 0,
|
const displayName = item.name && item.name.length > 10
|
||||||
itemStyle: {
|
? item.name.substring(0, 10) + '...'
|
||||||
color: [
|
: (item.name || `课程${index + 1}`);
|
||||||
'#FF8C42',
|
|
||||||
'#667eea',
|
return {
|
||||||
'#f093fb',
|
name: displayName,
|
||||||
'#4facfe',
|
value: item.value || 0,
|
||||||
'#43e97b',
|
fullName: item.name, // 保存完整名称用于 tooltip 显示
|
||||||
'#fa709a',
|
itemStyle: {
|
||||||
'#fee140',
|
color: [
|
||||||
'#30cfd0',
|
'#FF8C42',
|
||||||
][index % 8],
|
'#667eea',
|
||||||
},
|
'#f093fb',
|
||||||
}));
|
'#4facfe',
|
||||||
|
'#43e97b',
|
||||||
|
'#fa709a',
|
||||||
|
'#fee140',
|
||||||
|
'#30cfd0',
|
||||||
|
][index % 8],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const option: echarts.EChartsOption = {
|
const option: echarts.EChartsOption = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
formatter: '{b}: {c}次 ({d}%)',
|
formatter: (params: any) => {
|
||||||
|
// 使用完整名称显示 tooltip
|
||||||
|
const fullName = params.data.fullName || params.name;
|
||||||
|
return `${fullName}: ${params.value}次 (${params.percent}%)`;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
orient: 'vertical',
|
orient: 'vertical',
|
||||||
@ -584,15 +624,13 @@ const handleResize = () => {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const [statsData, teachersData, activitiesData] = await Promise.all([
|
const [statsData, teachersData] = await Promise.all([
|
||||||
getSchoolStats(),
|
getSchoolStats(),
|
||||||
getActiveTeachers(5),
|
getActiveTeachers(10), // 获取 TOP10
|
||||||
getRecentActivities(10),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
stats.value = statsData;
|
stats.value = statsData;
|
||||||
activeTeachers.value = teachersData;
|
activeTeachers.value = teachersData;
|
||||||
recentActivities.value = activitiesData;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load dashboard data:', error);
|
console.error('Failed to load dashboard data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -603,7 +641,10 @@ const loadData = async () => {
|
|||||||
const loadCourseStats = async () => {
|
const loadCourseStats = async () => {
|
||||||
courseStatsLoading.value = true;
|
courseStatsLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await getCourseUsageStats();
|
const startDate = dateRange.value?.[0]?.format('YYYY-MM-DD');
|
||||||
|
const endDate = dateRange.value?.[1]?.format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
const data = await getCourseUsageStats(startDate, endDate);
|
||||||
courseStats.value = data.slice(0, 10);
|
courseStats.value = data.slice(0, 10);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load course stats:', error);
|
console.error('Failed to load course stats:', error);
|
||||||
@ -616,7 +657,7 @@ const loadCourseStats = async () => {
|
|||||||
const loadTrendData = async () => {
|
const loadTrendData = async () => {
|
||||||
trendLoading.value = true;
|
trendLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await getLessonTrend(6);
|
const data = await getLessonTrend(7);
|
||||||
lessonTrendData.value = data;
|
lessonTrendData.value = data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load trend data:', error);
|
console.error('Failed to load trend data:', error);
|
||||||
@ -685,6 +726,12 @@ onMounted(() => {
|
|||||||
loadTrendData();
|
loadTrendData();
|
||||||
loadDistributionData();
|
loadDistributionData();
|
||||||
|
|
||||||
|
// 设置默认日期范围为当月
|
||||||
|
const now = dayjs();
|
||||||
|
const monthStart = now.startOf('month');
|
||||||
|
const monthEnd = now.endOf('month');
|
||||||
|
dateRange.value = [monthStart, monthEnd];
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -808,11 +855,20 @@ onUnmounted(() => {
|
|||||||
/* 内容网格 */
|
/* 内容网格 */
|
||||||
.content-grid {
|
.content-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: 1fr;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 教师活跃度排行(全宽) */
|
||||||
|
.teachers-card-full {
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 图表网格 */
|
/* 图表网格 */
|
||||||
.charts-grid {
|
.charts-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -905,50 +961,6 @@ onUnmounted(() => {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 活动列表 */
|
|
||||||
.activity-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #FAFAFA;
|
|
||||||
border-radius: 12px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item:hover {
|
|
||||||
background: #FFF8F0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: linear-gradient(135deg, #FF8C42 0%, #FFB347 100%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #2D3436;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-time {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #B2BEC3;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 教师列表 */
|
/* 教师列表 */
|
||||||
.teacher-list {
|
.teacher-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -991,26 +1003,37 @@ onUnmounted(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teacher-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.teacher-name {
|
.teacher-name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: #2D3436;
|
color: #2D3436;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teacher-lessons {
|
.teacher-details {
|
||||||
font-size: 12px;
|
display: flex;
|
||||||
color: #636E72;
|
align-items: center;
|
||||||
margin-top: 4px;
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #636E72;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lesson-icon {
|
.detail-item .anticon {
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
color: #636E72;
|
color: #999;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.teacher-medal {
|
.teacher-medal {
|
||||||
|
|||||||
@ -331,7 +331,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="detail-stat-item">
|
<div class="detail-stat-item">
|
||||||
<div class="stat-number">{{ selectedCourse.studentCount }}</div>
|
<div class="stat-number">{{ selectedCourse.studentCount }}</div>
|
||||||
<div class="stat-label">覆盖学生</div>
|
<div class="stat-label">学生总数</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a-divider />
|
<a-divider />
|
||||||
|
|||||||
@ -91,6 +91,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<span>课程使用</span>
|
<span>课程使用</span>
|
||||||
</div>
|
</div>
|
||||||
|
<a-segmented
|
||||||
|
v-model:value="usagePeriodType"
|
||||||
|
:options="periodOptions"
|
||||||
|
@change="loadUsageData"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body" :class="{ 'is-loading': usageLoading }">
|
<div class="card-body" :class="{ 'is-loading': usageLoading }">
|
||||||
<a-spin v-if="usageLoading" />
|
<a-spin v-if="usageLoading" />
|
||||||
@ -131,8 +137,8 @@
|
|||||||
:class="{ 'finished': lesson.status === 'FINISHED' }"
|
:class="{ 'finished': lesson.status === 'FINISHED' }"
|
||||||
>
|
>
|
||||||
<div class="lesson-time">
|
<div class="lesson-time">
|
||||||
<div class="time-value">{{ formatTime(lesson.plannedDatetime) }}</div>
|
<div class="time-value">{{ formatTime(lesson.startTime || lesson.lessonDate) }}</div>
|
||||||
<div class="time-duration">{{ lesson.duration }}分钟</div>
|
<div class="time-duration">30 分钟</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lesson-info">
|
<div class="lesson-info">
|
||||||
<div class="lesson-name">{{ lesson.courseName }}</div>
|
<div class="lesson-name">{{ lesson.courseName }}</div>
|
||||||
@ -193,23 +199,23 @@
|
|||||||
>
|
>
|
||||||
<div class="recommend-cover">
|
<div class="recommend-cover">
|
||||||
<img
|
<img
|
||||||
v-if="course.coverImagePath"
|
v-if="(course as any).coverImagePath || course.description"
|
||||||
:src="getImageUrl(course.coverImagePath)"
|
:src="getImageUrl((course as any).coverImagePath || course.description || '')"
|
||||||
class="cover-img"
|
class="cover-img"
|
||||||
/>
|
/>
|
||||||
<div v-else class="cover-placeholder">
|
<div v-else class="cover-placeholder">
|
||||||
<BookFilled />
|
<BookFilled />
|
||||||
</div>
|
</div>
|
||||||
<div class="duration-tag">{{ course.duration || 30 }}分钟</div>
|
<div class="duration-tag">{{ course.courseCount || 30 }}分钟</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recommend-info">
|
<div class="recommend-info">
|
||||||
<div class="recommend-name">{{ course.name }}</div>
|
<div class="recommend-name">{{ course.name }}</div>
|
||||||
<div class="recommend-meta">
|
<div class="recommend-meta">
|
||||||
<span class="meta-item">
|
<span class="meta-item">
|
||||||
<FireOutlined /> {{ course.usageCount }}次使用
|
<FireOutlined /> {{ course.usageCount || 0 }}次使用
|
||||||
</span>
|
</span>
|
||||||
<span v-if="course.avgRating > 0" class="meta-item">
|
<span v-if="course.gradeLevel" class="meta-item">
|
||||||
<StarFilled class="star-icon" /> {{ course.avgRating.toFixed(1) }}
|
<StarFilled class="star-icon" /> {{ course.gradeLevel }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -219,46 +225,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 近期活动 -->
|
|
||||||
<div class="activity-section">
|
|
||||||
<div class="content-card activity-card">
|
|
||||||
<div class="card-header">
|
|
||||||
<div class="card-title">
|
|
||||||
<div class="title-icon-wrapper list">
|
|
||||||
<UnorderedListOutlined />
|
|
||||||
</div>
|
|
||||||
<span>近期活动</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body" :class="{ 'is-loading': loading }">
|
|
||||||
<a-spin :spinning="loading">
|
|
||||||
<div v-if="recentActivities.length === 0" class="empty-state-horizontal">
|
|
||||||
<div class="empty-icon-wrapper small">
|
|
||||||
<FileTextOutlined />
|
|
||||||
</div>
|
|
||||||
<p class="empty-text">暂无近期活动</p>
|
|
||||||
</div>
|
|
||||||
<div v-else class="activity-timeline">
|
|
||||||
<div
|
|
||||||
v-for="(item, index) in recentActivities"
|
|
||||||
:key="item.id"
|
|
||||||
class="activity-item"
|
|
||||||
:class="'type-' + item.type"
|
|
||||||
>
|
|
||||||
<div class="activity-dot">
|
|
||||||
<component :is="getActivityIcon(item.type)" />
|
|
||||||
</div>
|
|
||||||
<div class="activity-content">
|
|
||||||
<div class="activity-text">{{ item.description }}</div>
|
|
||||||
<div class="activity-time">{{ item.time }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-spin>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -284,11 +250,6 @@ import {
|
|||||||
FireOutlined,
|
FireOutlined,
|
||||||
InboxOutlined,
|
InboxOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
UnorderedListOutlined,
|
|
||||||
FileTextOutlined,
|
|
||||||
MessageOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
FolderOutlined,
|
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
LineChartOutlined,
|
LineChartOutlined,
|
||||||
PieChartOutlined,
|
PieChartOutlined,
|
||||||
@ -296,9 +257,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
getTeacherDashboard,
|
getTeacherDashboard,
|
||||||
getTeacherLessonTrend,
|
getTeacherLessonTrend,
|
||||||
getTeacherCourseUsage,
|
getTeacherCourseUsageStats,
|
||||||
|
type DashboardData,
|
||||||
|
type TeacherLessonItem,
|
||||||
|
type TeacherCoursePackageItem,
|
||||||
|
type TeacherLessonTrendItem,
|
||||||
|
type TeacherCourseUsageItem,
|
||||||
|
type CourseUsageStatsItem,
|
||||||
} from '@/api/teacher';
|
} from '@/api/teacher';
|
||||||
import type { TeacherLessonTrendItem, TeacherCourseUsageItem } from '@/api/teacher';
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -314,15 +280,25 @@ let usageChart: echarts.ECharts | null = null;
|
|||||||
|
|
||||||
// Chart data
|
// Chart data
|
||||||
const lessonTrendData = ref<TeacherLessonTrendItem[]>([]);
|
const lessonTrendData = ref<TeacherLessonTrendItem[]>([]);
|
||||||
const courseUsageData = ref<TeacherCourseUsageItem[]>([]);
|
|
||||||
|
|
||||||
const stats = ref({
|
const stats = ref<DashboardData['stats']>({
|
||||||
classCount: 0,
|
classCount: 0,
|
||||||
studentCount: 0,
|
studentCount: 0,
|
||||||
lessonCount: 0,
|
lessonCount: 0,
|
||||||
courseCount: 0,
|
courseCount: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 课程使用统计周期选择
|
||||||
|
const usagePeriodType = ref<'TODAY' | 'WEEK' | 'MONTH' | 'CUSTOM'>('MONTH');
|
||||||
|
const periodOptions = [
|
||||||
|
{ label: '今日', value: 'TODAY' },
|
||||||
|
{ label: '本周', value: 'WEEK' },
|
||||||
|
{ label: '本月', value: 'MONTH' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 增强版课程使用统计数据
|
||||||
|
const courseUsageStatsData = ref<CourseUsageStatsItem[]>([]);
|
||||||
|
|
||||||
const currentDate = computed(() => {
|
const currentDate = computed(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||||
@ -332,39 +308,8 @@ const currentDate = computed(() => {
|
|||||||
return `${month}月${date}日 ${day}`;
|
return `${month}月${date}日 ${day}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
interface TodayLesson {
|
const todayLessons = ref<TeacherLessonItem[]>([]);
|
||||||
id: number;
|
const recommendedCourses = ref<TeacherCoursePackageItem[]>([]);
|
||||||
courseId: number;
|
|
||||||
courseName: string;
|
|
||||||
pictureBookName?: string;
|
|
||||||
classId: number;
|
|
||||||
className: string;
|
|
||||||
plannedDatetime: string;
|
|
||||||
status: string;
|
|
||||||
duration: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecommendedCourse {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
pictureBookName?: string;
|
|
||||||
coverImagePath?: string;
|
|
||||||
duration: number;
|
|
||||||
usageCount: number;
|
|
||||||
avgRating: number;
|
|
||||||
gradeTags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecentActivity {
|
|
||||||
id: number;
|
|
||||||
type: string;
|
|
||||||
description: string;
|
|
||||||
time: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const todayLessons = ref<TodayLesson[]>([]);
|
|
||||||
const recommendedCourses = ref<RecommendedCourse[]>([]);
|
|
||||||
const recentActivities = ref<RecentActivity[]>([]);
|
|
||||||
|
|
||||||
const getImageUrl = (path: string) => {
|
const getImageUrl = (path: string) => {
|
||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
@ -379,36 +324,6 @@ const formatTime = (datetime: string) => {
|
|||||||
return `${hours}:${minutes}`;
|
return `${hours}:${minutes}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatActivityTime = (time: string) => {
|
|
||||||
const date = new Date(time);
|
|
||||||
const now = new Date();
|
|
||||||
const diff = now.getTime() - date.getTime();
|
|
||||||
|
|
||||||
const minutes = Math.floor(diff / 60000);
|
|
||||||
const hours = Math.floor(diff / 3600000);
|
|
||||||
const days = Math.floor(diff / 86400000);
|
|
||||||
|
|
||||||
if (minutes < 60) {
|
|
||||||
return `${minutes}分钟前`;
|
|
||||||
} else if (hours < 24) {
|
|
||||||
return `${hours}小时前`;
|
|
||||||
} else if (days < 7) {
|
|
||||||
return `${days}天前`;
|
|
||||||
} else {
|
|
||||||
return date.toLocaleDateString();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getActivityIcon = (type: string) => {
|
|
||||||
const iconMap: Record<string, any> = {
|
|
||||||
lesson: ReadOutlined,
|
|
||||||
feedback: MessageOutlined,
|
|
||||||
student: UserOutlined,
|
|
||||||
course: FolderOutlined,
|
|
||||||
};
|
|
||||||
return iconMap[type] || FileTextOutlined;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始化授课趋势图表
|
// 初始化授课趋势图表
|
||||||
const initTrendChart = (data: TeacherLessonTrendItem[]) => {
|
const initTrendChart = (data: TeacherLessonTrendItem[]) => {
|
||||||
if (!trendChartRef.value) return;
|
if (!trendChartRef.value) return;
|
||||||
@ -526,8 +441,8 @@ const initTrendChart = (data: TeacherLessonTrendItem[]) => {
|
|||||||
trendChart.setOption(option);
|
trendChart.setOption(option);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 初始化课程使用饼图
|
// 初始化课程使用饼图(支持旧版数据类型)
|
||||||
const initUsageChart = (data: TeacherCourseUsageItem[]) => {
|
const initUsageChart = (data: TeacherCourseUsageItem[] | CourseUsageStatsItem[]) => {
|
||||||
if (!usageChartRef.value) return;
|
if (!usageChartRef.value) return;
|
||||||
|
|
||||||
if (usageChart) {
|
if (usageChart) {
|
||||||
@ -536,10 +451,45 @@ const initUsageChart = (data: TeacherCourseUsageItem[]) => {
|
|||||||
|
|
||||||
usageChart = echarts.init(usageChartRef.value);
|
usageChart = echarts.init(usageChartRef.value);
|
||||||
|
|
||||||
|
// 转换为统一的格式
|
||||||
|
const chartData = data.map(item => {
|
||||||
|
if ('coursePackageName' in item) {
|
||||||
|
// CourseUsageStatsItem 类型
|
||||||
|
return {
|
||||||
|
name: (item as CourseUsageStatsItem).coursePackageName,
|
||||||
|
value: (item as CourseUsageStatsItem).usageCount,
|
||||||
|
studentCount: (item as CourseUsageStatsItem).studentCount,
|
||||||
|
avgDuration: (item as CourseUsageStatsItem).avgDuration,
|
||||||
|
lastUsedAt: (item as CourseUsageStatsItem).lastUsedAt,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// TeacherCourseUsageItem 类型(旧版)
|
||||||
|
return {
|
||||||
|
name: (item as TeacherCourseUsageItem).name,
|
||||||
|
value: (item as TeacherCourseUsageItem).value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const option: echarts.EChartsOption = {
|
const option: echarts.EChartsOption = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
formatter: '{b}: {c}次 ({d}%)',
|
formatter: (params: any) => {
|
||||||
|
const data = params.data;
|
||||||
|
let tooltip = `${data.name}: ${data.value}次 (${params.percent}%)`;
|
||||||
|
if (data.studentCount !== undefined) {
|
||||||
|
tooltip += `\n参与学生:${data.studentCount}人`;
|
||||||
|
}
|
||||||
|
if (data.avgDuration !== undefined) {
|
||||||
|
tooltip += `\n平均时长:${data.avgDuration}分钟`;
|
||||||
|
}
|
||||||
|
if (data.lastUsedAt) {
|
||||||
|
const lastUsed = new Date(data.lastUsedAt);
|
||||||
|
const lastUsedStr = `${lastUsed.getMonth() + 1}月${lastUsed.getDate()}日 ${lastUsed.getHours().toString().padStart(2, '0')}:${lastUsed.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
tooltip += `\n最后使用:${lastUsedStr}`;
|
||||||
|
}
|
||||||
|
return tooltip;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
orient: 'vertical',
|
orient: 'vertical',
|
||||||
@ -571,7 +521,7 @@ const initUsageChart = (data: TeacherCourseUsageItem[]) => {
|
|||||||
labelLine: {
|
labelLine: {
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
data: data.map((item, index) => ({
|
data: chartData.map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: [
|
color: [
|
||||||
@ -609,22 +559,20 @@ const loadTrendData = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载课程使用数据
|
// 加载课程使用数据(增强版)
|
||||||
const loadUsageData = async () => {
|
const loadUsageData = async () => {
|
||||||
usageLoading.value = true;
|
usageLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await getTeacherCourseUsage();
|
const data = await getTeacherCourseUsageStats({
|
||||||
courseUsageData.value = data;
|
periodType: usagePeriodType.value
|
||||||
|
});
|
||||||
|
courseUsageStatsData.value = data;
|
||||||
|
initUsageChart(courseUsageStatsData.value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load usage data:', error);
|
console.error('Failed to load usage data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
usageLoading.value = false;
|
usageLoading.value = false;
|
||||||
}
|
}
|
||||||
// 在 loading 结束后初始化图表
|
|
||||||
await nextTick();
|
|
||||||
if (courseUsageData.value.length > 0) {
|
|
||||||
initUsageChart(courseUsageData.value);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 窗口大小变化时重绘图表
|
// 窗口大小变化时重绘图表
|
||||||
@ -639,36 +587,8 @@ const loadDashboard = async () => {
|
|||||||
const data = await getTeacherDashboard();
|
const data = await getTeacherDashboard();
|
||||||
stats.value = data.stats || { classCount: 0, studentCount: 0, lessonCount: 0, courseCount: 0 };
|
stats.value = data.stats || { classCount: 0, studentCount: 0, lessonCount: 0, courseCount: 0 };
|
||||||
|
|
||||||
// 映射今日课程数据,处理可能缺失的字段
|
todayLessons.value = data.todayLessons || [];
|
||||||
todayLessons.value = (data.todayLessons || []).map((lesson: any) => ({
|
recommendedCourses.value = data.recommendedCourses || [];
|
||||||
id: lesson.id,
|
|
||||||
courseId: lesson.courseId,
|
|
||||||
courseName: lesson.courseName || lesson.course?.name || '未命名课程',
|
|
||||||
pictureBookName: lesson.pictureBookName,
|
|
||||||
classId: lesson.classId,
|
|
||||||
className: lesson.className || lesson.class?.name || '未命名班级',
|
|
||||||
plannedDatetime: lesson.plannedDatetime || lesson.startDatetime || lesson.lessonDate,
|
|
||||||
status: lesson.status,
|
|
||||||
duration: lesson.duration || lesson.actualDuration || 30, // 默认30分钟
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 映射推荐课程数据,处理可能缺失的字段
|
|
||||||
recommendedCourses.value = (data.recommendedCourses || []).map((course: any) => ({
|
|
||||||
id: course.id,
|
|
||||||
name: course.name || '未命名课程',
|
|
||||||
pictureBookName: course.pictureBookName,
|
|
||||||
coverImagePath: course.coverImagePath,
|
|
||||||
duration: course.duration || 30, // 默认30分钟
|
|
||||||
usageCount: course.usageCount || 0,
|
|
||||||
avgRating: course.avgRating || 0, // 默认0分
|
|
||||||
gradeTags: course.gradeTags || [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 处理近期活动数据
|
|
||||||
recentActivities.value = (data.recentActivities || []).map((item: RecentActivity) => ({
|
|
||||||
...item,
|
|
||||||
time: formatActivityTime(item.time),
|
|
||||||
}));
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to load dashboard:', error);
|
console.error('Failed to load dashboard:', error);
|
||||||
message.error(error?.response?.data?.message || '加载数据失败');
|
message.error(error?.response?.data?.message || '加载数据失败');
|
||||||
@ -677,11 +597,11 @@ const loadDashboard = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startLesson = (lesson: TodayLesson) => {
|
const startLesson = (lesson: TeacherLessonItem) => {
|
||||||
router.push(`/teacher/lessons/${lesson.id}`);
|
router.push(`/teacher/lessons/${lesson.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewCourse = (course: RecommendedCourse) => {
|
const viewCourse = (course: TeacherCoursePackageItem) => {
|
||||||
router.push(`/teacher/courses/${course.id}`);
|
router.push(`/teacher/courses/${course.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1244,75 +1164,6 @@ onUnmounted(() => {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 活动区域 */
|
|
||||||
.activity-section {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-card .card-body {
|
|
||||||
min-height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-timeline {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-dot {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item.type-lesson .activity-dot {
|
|
||||||
background: linear-gradient(135deg, #FFE4C9 0%, #FFF0E0 100%);
|
|
||||||
color: #FF8C42;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item.type-feedback .activity-dot {
|
|
||||||
background: linear-gradient(135deg, #D6E4FF 0%, #E8F0FF 100%);
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item.type-student .activity-dot {
|
|
||||||
background: linear-gradient(135deg, #D9F7BE 0%, #E8FFD4 100%);
|
|
||||||
color: #52c41a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item.type-course .activity-dot {
|
|
||||||
background: linear-gradient(135deg, #FFE7BA 0%, #FFF1D6 100%);
|
|
||||||
color: #FA8C16;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-content {
|
|
||||||
flex: 1;
|
|
||||||
padding-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-text {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-time {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 响应式 */
|
/* 响应式 */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
|
|||||||
@ -27,8 +27,8 @@ test.describe('数据看板', () => {
|
|||||||
const lessonCard = page.getByText(/月授课|授课次数/).first();
|
const lessonCard = page.getByText(/月授课|授课次数/).first();
|
||||||
await expect(lessonCard).toBeVisible();
|
await expect(lessonCard).toBeVisible();
|
||||||
|
|
||||||
// 验证覆盖学生卡片
|
// 验证学生总数卡片
|
||||||
const studentCard = page.getByText(/覆盖学生|学生数/).first();
|
const studentCard = page.getByText(/学生总数|学生数/).first();
|
||||||
await expect(studentCard).toBeVisible();
|
await expect(studentCard).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,74 @@
|
|||||||
|
package com.reading.platform.common.enums;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师活跃度等级枚举
|
||||||
|
* 根据统计周期内的授课次数划分活跃程度
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public enum TeacherActivityLevel {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 高频活跃:统计周期内授课次数 >= 10 次
|
||||||
|
*/
|
||||||
|
HIGH("HIGH", "高频", 10),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中频活跃:统计周期内授课次数 5-9 次
|
||||||
|
*/
|
||||||
|
MEDIUM("MEDIUM", "中频", 5),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 低频活跃:统计周期内授课次数 1-4 次
|
||||||
|
*/
|
||||||
|
LOW("LOW", "低频", 1),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未活跃:统计周期内授课次数为 0 次
|
||||||
|
*/
|
||||||
|
INACTIVE("INACTIVE", "未活跃", 0);
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
private final String description;
|
||||||
|
private final int minLessonCount;
|
||||||
|
|
||||||
|
TeacherActivityLevel(String code, String description, int minLessonCount) {
|
||||||
|
this.code = code;
|
||||||
|
this.description = description;
|
||||||
|
this.minLessonCount = minLessonCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据授课次数获取活跃度等级
|
||||||
|
*
|
||||||
|
* @param lessonCount 授课次数
|
||||||
|
* @return 活跃度等级
|
||||||
|
*/
|
||||||
|
public static TeacherActivityLevel fromLessonCount(int lessonCount) {
|
||||||
|
if (lessonCount >= HIGH.minLessonCount) {
|
||||||
|
return HIGH;
|
||||||
|
} else if (lessonCount >= MEDIUM.minLessonCount) {
|
||||||
|
return MEDIUM;
|
||||||
|
} else if (lessonCount >= LOW.minLessonCount) {
|
||||||
|
return LOW;
|
||||||
|
} else {
|
||||||
|
return INACTIVE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据编码获取活跃度等级
|
||||||
|
*
|
||||||
|
* @param code 编码
|
||||||
|
* @return 活跃度等级
|
||||||
|
*/
|
||||||
|
public static TeacherActivityLevel fromCode(String code) {
|
||||||
|
for (TeacherActivityLevel level : values()) {
|
||||||
|
if (level.getCode().equals(code)) {
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return INACTIVE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -119,4 +119,25 @@ public class JwtTokenProvider {
|
|||||||
.getSubject();
|
.getSubject();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 token 剩余过期时间(秒)
|
||||||
|
* @param token token 字符串
|
||||||
|
* @return 剩余秒数,如果 token 无效则返回 0
|
||||||
|
*/
|
||||||
|
public long getRemainingExpiration(String token) {
|
||||||
|
try {
|
||||||
|
Date expiryDate = Jwts.parser()
|
||||||
|
.verifyWith(secretKey)
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload()
|
||||||
|
.getExpiration();
|
||||||
|
long remaining = (expiryDate.getTime() - System.currentTimeMillis()) / 1000;
|
||||||
|
return remaining > 0 ? remaining : 0;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("获取 token 剩余过期时间失败:{}", e.getMessage());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,8 +6,10 @@ import com.reading.platform.common.mapper.TenantMapper;
|
|||||||
import com.reading.platform.common.mapper.TeacherMapper;
|
import com.reading.platform.common.mapper.TeacherMapper;
|
||||||
import com.reading.platform.common.response.Result;
|
import com.reading.platform.common.response.Result;
|
||||||
import com.reading.platform.dto.request.LoginRequest;
|
import com.reading.platform.dto.request.LoginRequest;
|
||||||
|
import com.reading.platform.dto.request.UpdateProfileRequest;
|
||||||
import com.reading.platform.dto.response.LoginResponse;
|
import com.reading.platform.dto.response.LoginResponse;
|
||||||
import com.reading.platform.dto.response.TokenResponse;
|
import com.reading.platform.dto.response.TokenResponse;
|
||||||
|
import com.reading.platform.dto.response.UpdateProfileResponse;
|
||||||
import com.reading.platform.dto.response.UserInfoResponse;
|
import com.reading.platform.dto.response.UserInfoResponse;
|
||||||
import com.reading.platform.entity.AdminUser;
|
import com.reading.platform.entity.AdminUser;
|
||||||
import com.reading.platform.entity.Parent;
|
import com.reading.platform.entity.Parent;
|
||||||
@ -16,8 +18,10 @@ import com.reading.platform.entity.Teacher;
|
|||||||
import com.reading.platform.service.AuthService;
|
import com.reading.platform.service.AuthService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@Tag(name = "认证管理", description = "Authentication APIs")
|
@Tag(name = "认证管理", description = "Authentication APIs")
|
||||||
@ -63,11 +67,21 @@ public class AuthController {
|
|||||||
@PostMapping("/change-password")
|
@PostMapping("/change-password")
|
||||||
public Result<Void> changePassword(
|
public Result<Void> changePassword(
|
||||||
@RequestParam String oldPassword,
|
@RequestParam String oldPassword,
|
||||||
@RequestParam String newPassword) {
|
@RequestParam String newPassword,
|
||||||
authService.changePassword(oldPassword, newPassword);
|
HttpServletRequest request) {
|
||||||
|
String token = resolveToken(request);
|
||||||
|
authService.changePassword(oldPassword, newPassword, token);
|
||||||
return Result.success();
|
return Result.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "修改个人信息")
|
||||||
|
@PutMapping("/profile")
|
||||||
|
public Result<UpdateProfileResponse> updateProfile(
|
||||||
|
@Valid @RequestBody UpdateProfileRequest request) {
|
||||||
|
UpdateProfileResponse response = authService.updateProfile(request);
|
||||||
|
return Result.success(response);
|
||||||
|
}
|
||||||
|
|
||||||
private UserInfoResponse convertToUserInfoResponse(Object userInfo) {
|
private UserInfoResponse convertToUserInfoResponse(Object userInfo) {
|
||||||
if (userInfo instanceof Tenant) {
|
if (userInfo instanceof Tenant) {
|
||||||
Tenant tenant = (Tenant) userInfo;
|
Tenant tenant = (Tenant) userInfo;
|
||||||
@ -121,4 +135,15 @@ public class AuthController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求头获取 token
|
||||||
|
*/
|
||||||
|
private String resolveToken(HttpServletRequest request) {
|
||||||
|
String bearerToken = request.getHeader("Authorization");
|
||||||
|
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
|
||||||
|
return bearerToken.substring(7);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,10 +5,8 @@ import com.reading.platform.common.enums.UserRole;
|
|||||||
import com.reading.platform.common.response.Result;
|
import com.reading.platform.common.response.Result;
|
||||||
import com.reading.platform.dto.request.ActiveTenantsQueryRequest;
|
import com.reading.platform.dto.request.ActiveTenantsQueryRequest;
|
||||||
import com.reading.platform.dto.request.PopularCoursesQueryRequest;
|
import com.reading.platform.dto.request.PopularCoursesQueryRequest;
|
||||||
import com.reading.platform.dto.request.RecentActivitiesQueryRequest;
|
|
||||||
import com.reading.platform.dto.response.ActiveTenantItemResponse;
|
import com.reading.platform.dto.response.ActiveTenantItemResponse;
|
||||||
import com.reading.platform.dto.response.PopularCourseItemResponse;
|
import com.reading.platform.dto.response.PopularCourseItemResponse;
|
||||||
import com.reading.platform.dto.response.RecentActivityItemResponse;
|
|
||||||
import com.reading.platform.dto.response.StatsResponse;
|
import com.reading.platform.dto.response.StatsResponse;
|
||||||
import com.reading.platform.dto.response.StatsTrendResponse;
|
import com.reading.platform.dto.response.StatsTrendResponse;
|
||||||
import com.reading.platform.service.StatsService;
|
import com.reading.platform.service.StatsService;
|
||||||
@ -58,10 +56,4 @@ public class AdminStatsController {
|
|||||||
return Result.success(statsService.getPopularCourses(request));
|
return Result.success(statsService.getPopularCourses(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/activities")
|
|
||||||
@Operation(summary = "获取最近活动")
|
|
||||||
public Result<List<RecentActivityItemResponse>> getRecentActivities(@ModelAttribute RecentActivitiesQueryRequest request) {
|
|
||||||
return Result.success(statsService.getRecentActivities(request));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -9,6 +9,7 @@ import com.reading.platform.dto.response.LessonTagResponse;
|
|||||||
import com.reading.platform.dto.response.SchoolCourseResponse;
|
import com.reading.platform.dto.response.SchoolCourseResponse;
|
||||||
import com.reading.platform.entity.CourseLesson;
|
import com.reading.platform.entity.CourseLesson;
|
||||||
import com.reading.platform.entity.CoursePackage;
|
import com.reading.platform.entity.CoursePackage;
|
||||||
|
import com.reading.platform.mapper.LessonMapper;
|
||||||
import com.reading.platform.service.CourseLessonService;
|
import com.reading.platform.service.CourseLessonService;
|
||||||
import com.reading.platform.service.CoursePackageService;
|
import com.reading.platform.service.CoursePackageService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@ -19,6 +20,7 @@ import org.springframework.util.StringUtils;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -34,6 +36,7 @@ public class SchoolCourseController {
|
|||||||
|
|
||||||
private final CoursePackageService courseService;
|
private final CoursePackageService courseService;
|
||||||
private final CourseLessonService courseLessonService;
|
private final CourseLessonService courseLessonService;
|
||||||
|
private final LessonMapper lessonMapper;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "获取学校课程包列表(分页)")
|
@Operation(summary = "获取学校课程包列表(分页)")
|
||||||
@ -56,6 +59,25 @@ public class SchoolCourseController {
|
|||||||
.map(SchoolCourseResponse::toSchoolCourseResponse)
|
.map(SchoolCourseResponse::toSchoolCourseResponse)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 按租户 ID 统计每个课程的使用次数(已完成状态的 Lesson 数量)
|
||||||
|
if (!list.isEmpty()) {
|
||||||
|
List<Long> courseIds = list.stream().map(SchoolCourseResponse::getId).collect(Collectors.toList());
|
||||||
|
List<Map<String, Object>> usageStats = lessonMapper.countUsageByTenantAndCourseIds(tenantId, courseIds);
|
||||||
|
|
||||||
|
// 构建课程 ID -> 使用次数的映射
|
||||||
|
Map<Long, Integer> usageCountMap = usageStats.stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
m -> ((Number) m.get("course_id")).longValue(),
|
||||||
|
m -> ((Number) m.get("usageCount")).intValue(),
|
||||||
|
(v1, v2) -> v1 // 如果有重复,取第一个值
|
||||||
|
));
|
||||||
|
|
||||||
|
// 填充 usageCount,如果没有统计数据显示为 0
|
||||||
|
for (SchoolCourseResponse vo : list) {
|
||||||
|
vo.setUsageCount(usageCountMap.getOrDefault(vo.getId(), 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 填充 lessonTags
|
// 填充 lessonTags
|
||||||
for (SchoolCourseResponse vo : list) {
|
for (SchoolCourseResponse vo : list) {
|
||||||
List<CourseLesson> lessons = courseLessonService.findByCourseId(vo.getId());
|
List<CourseLesson> lessons = courseLessonService.findByCourseId(vo.getId());
|
||||||
|
|||||||
@ -2,12 +2,15 @@ package com.reading.platform.controller.school;
|
|||||||
|
|
||||||
import com.reading.platform.common.response.Result;
|
import com.reading.platform.common.response.Result;
|
||||||
import com.reading.platform.common.security.SecurityUtils;
|
import com.reading.platform.common.security.SecurityUtils;
|
||||||
|
import com.reading.platform.dto.response.TeacherActivityRankResponse;
|
||||||
import com.reading.platform.service.SchoolStatsService;
|
import com.reading.platform.service.SchoolStatsService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -31,33 +34,27 @@ public class SchoolStatsController {
|
|||||||
|
|
||||||
@GetMapping("/teachers")
|
@GetMapping("/teachers")
|
||||||
@Operation(summary = "获取活跃教师排行")
|
@Operation(summary = "获取活跃教师排行")
|
||||||
public Result<List<Map<String, Object>>> getActiveTeachers(
|
public Result<List<TeacherActivityRankResponse>> getActiveTeachers(
|
||||||
@RequestParam(defaultValue = "5") int limit) {
|
@RequestParam(defaultValue = "10") int limit) {
|
||||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||||
return Result.success(schoolStatsService.getActiveTeachers(tenantId, limit));
|
return Result.success(schoolStatsService.getActiveTeachers(tenantId, limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/courses")
|
@GetMapping("/courses")
|
||||||
@Operation(summary = "获取课程使用统计")
|
@Operation(summary = "获取课程使用统计")
|
||||||
public Result<List<Map<String, Object>>> getCourseUsageStats() {
|
public Result<List<Map<String, Object>>> getCourseUsageStats(
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
|
||||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||||
return Result.success(schoolStatsService.getCourseUsageStats(tenantId));
|
return Result.success(schoolStatsService.getCourseUsageStats(tenantId, startDate, endDate));
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/activities")
|
|
||||||
@Operation(summary = "获取近期活动")
|
|
||||||
public Result<List<Map<String, Object>>> getRecentActivities(
|
|
||||||
@RequestParam(defaultValue = "10") int limit) {
|
|
||||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
|
||||||
return Result.success(schoolStatsService.getRecentActivities(tenantId, limit));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/lesson-trend")
|
@GetMapping("/lesson-trend")
|
||||||
@Operation(summary = "获取授课趋势")
|
@Operation(summary = "获取授课趋势")
|
||||||
public Result<List<Map<String, Object>>> getLessonTrend(
|
public Result<List<Map<String, Object>>> getLessonTrend(
|
||||||
@RequestParam(defaultValue = "6") int months) {
|
@RequestParam(defaultValue = "7") int days) {
|
||||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||||
return Result.success(schoolStatsService.getLessonTrend(tenantId, months));
|
return Result.success(schoolStatsService.getLessonTrend(tenantId, days));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/course-distribution")
|
@GetMapping("/course-distribution")
|
||||||
|
|||||||
@ -2,16 +2,16 @@ package com.reading.platform.controller.teacher;
|
|||||||
|
|
||||||
import com.reading.platform.common.response.Result;
|
import com.reading.platform.common.response.Result;
|
||||||
import com.reading.platform.common.security.SecurityUtils;
|
import com.reading.platform.common.security.SecurityUtils;
|
||||||
import com.reading.platform.entity.CoursePackage;
|
import com.reading.platform.dto.request.CourseUsageQuery;
|
||||||
import com.reading.platform.entity.Lesson;
|
import com.reading.platform.dto.response.*;
|
||||||
import com.reading.platform.service.TeacherStatsService;
|
import com.reading.platform.service.TeacherStatsService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计数据控制器(教师端)
|
* 统计数据控制器(教师端)
|
||||||
@ -26,7 +26,7 @@ public class TeacherStatsController {
|
|||||||
|
|
||||||
@GetMapping("/dashboard")
|
@GetMapping("/dashboard")
|
||||||
@Operation(summary = "获取教师端首页统计数据")
|
@Operation(summary = "获取教师端首页统计数据")
|
||||||
public Result<Map<String, Object>> getDashboard() {
|
public Result<TeacherDashboardResponse> getDashboard() {
|
||||||
Long teacherId = SecurityUtils.getCurrentUserId();
|
Long teacherId = SecurityUtils.getCurrentUserId();
|
||||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||||
return Result.success(teacherStatsService.getDashboard(teacherId, tenantId));
|
return Result.success(teacherStatsService.getDashboard(teacherId, tenantId));
|
||||||
@ -34,36 +34,56 @@ public class TeacherStatsController {
|
|||||||
|
|
||||||
@GetMapping("/today-lessons")
|
@GetMapping("/today-lessons")
|
||||||
@Operation(summary = "获取今日课程")
|
@Operation(summary = "获取今日课程")
|
||||||
public Result<List<Lesson>> getTodayLessons() {
|
public Result<List<TeacherLessonVO>> getTodayLessons() {
|
||||||
Long teacherId = SecurityUtils.getCurrentUserId();
|
Long teacherId = SecurityUtils.getCurrentUserId();
|
||||||
return Result.success(teacherStatsService.getTodayLessons(teacherId));
|
return Result.success(teacherStatsService.getTodayLessons(teacherId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/recommended-courses")
|
@GetMapping("/recommended-courses")
|
||||||
@Operation(summary = "获取推荐课程")
|
@Operation(summary = "获取推荐课程")
|
||||||
public Result<List<CoursePackage>> getRecommendedCourses() {
|
public Result<List<CoursePackageVO>> getRecommendedCourses() {
|
||||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||||
return Result.success(teacherStatsService.getRecommendedCourses(tenantId));
|
return Result.success(teacherStatsService.getRecommendedCourses(tenantId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/weekly-stats")
|
@GetMapping("/weekly-stats")
|
||||||
@Operation(summary = "获取本周统计")
|
@Operation(summary = "获取本周统计")
|
||||||
public Result<Map<String, Object>> getWeeklyStats() {
|
public Result<TeacherWeeklyStatsResponse> getWeeklyStats() {
|
||||||
Long teacherId = SecurityUtils.getCurrentUserId();
|
Long teacherId = SecurityUtils.getCurrentUserId();
|
||||||
return Result.success(teacherStatsService.getWeeklyStats(teacherId));
|
return Result.success(teacherStatsService.getWeeklyStats(teacherId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/lesson-trend")
|
@GetMapping("/lesson-trend")
|
||||||
@Operation(summary = "获取授课趋势")
|
@Operation(summary = "获取授课趋势")
|
||||||
public Result<List<Map<String, Object>>> getLessonTrend(
|
public Result<List<TeacherLessonTrendVO>> getLessonTrend(
|
||||||
@RequestParam(defaultValue = "6") int months) {
|
@RequestParam(defaultValue = "6") int months) {
|
||||||
Long teacherId = SecurityUtils.getCurrentUserId();
|
Long teacherId = SecurityUtils.getCurrentUserId();
|
||||||
return Result.success(teacherStatsService.getLessonTrend(teacherId, months));
|
return Result.success(teacherStatsService.getLessonTrend(teacherId, months));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/course-usage-stats")
|
||||||
|
@Operation(summary = "获取课程使用统计(增强版)")
|
||||||
|
public Result<List<CourseUsageStatsVO>> getCourseUsageStats(
|
||||||
|
@RequestParam(required = false) String periodType,
|
||||||
|
@RequestParam(required = false) LocalDate startDate,
|
||||||
|
@RequestParam(required = false) LocalDate endDate) {
|
||||||
|
|
||||||
|
Long teacherId = SecurityUtils.getCurrentUserId();
|
||||||
|
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||||
|
|
||||||
|
CourseUsageQuery query = CourseUsageQuery.builder()
|
||||||
|
.periodType(periodType)
|
||||||
|
.startDate(startDate)
|
||||||
|
.endDate(endDate)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return Result.success(teacherStatsService.getCourseUsageStats(tenantId, teacherId, query));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/course-usage")
|
@GetMapping("/course-usage")
|
||||||
@Operation(summary = "获取课程使用统计")
|
@Operation(summary = "获取课程使用统计(旧版)")
|
||||||
public Result<List<Map<String, Object>>> getCourseUsage() {
|
@Deprecated
|
||||||
|
public Result<List<CourseUsageVO>> getCourseUsage() {
|
||||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||||
return Result.success(teacherStatsService.getCourseUsage(tenantId));
|
return Result.success(teacherStatsService.getCourseUsage(tenantId));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
package com.reading.platform.dto.request;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课程使用统计查询参数
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "课程使用统计查询参数")
|
||||||
|
public class CourseUsageQuery {
|
||||||
|
|
||||||
|
@Schema(description = "统计周期类型:TODAY-今日,WEEK-本周,MONTH-本月,CUSTOM-自定义")
|
||||||
|
private String periodType;
|
||||||
|
|
||||||
|
@Schema(description = "自定义周期开始日期")
|
||||||
|
private LocalDate startDate;
|
||||||
|
|
||||||
|
@Schema(description = "自定义周期结束日期")
|
||||||
|
private LocalDate endDate;
|
||||||
|
}
|
||||||
@ -1,15 +0,0 @@
|
|||||||
package com.reading.platform.dto.request;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 最近活动查询请求
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@Schema(description = "最近活动查询请求")
|
|
||||||
public class RecentActivitiesQueryRequest {
|
|
||||||
|
|
||||||
@Schema(description = "返回数量限制", example = "10")
|
|
||||||
private Integer limit = 10;
|
|
||||||
}
|
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
package com.reading.platform.dto.request;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改个人信息请求 DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "修改个人信息请求")
|
||||||
|
public class UpdateProfileRequest {
|
||||||
|
|
||||||
|
@Schema(description = "姓名", example = "张三")
|
||||||
|
@Pattern(regexp = "^[\u4e00-\u9fa5a-zA-Z\\s]{2,20}$", message = "姓名长度为 2-20 位,只能包含中文或英文")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Schema(description = "手机号", example = "13800138000")
|
||||||
|
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Schema(description = "邮箱", example = "zhangsan@example.com")
|
||||||
|
@Email(message = "邮箱格式不正确")
|
||||||
|
private String email;
|
||||||
|
}
|
||||||
@ -22,9 +22,9 @@ public class ActiveTenantItemResponse {
|
|||||||
@Schema(description = "租户名称")
|
@Schema(description = "租户名称")
|
||||||
private String tenantName;
|
private String tenantName;
|
||||||
|
|
||||||
@Schema(description = "活跃用户数")
|
@Schema(description = "活跃教师数(近 30 天有完成课程的老师数)")
|
||||||
private Integer activeUsers;
|
private Integer activeTeacherCount;
|
||||||
|
|
||||||
@Schema(description = "课程使用数")
|
@Schema(description = "完成课次数(近 30 天 COMPLETED 状态的 lesson 总数)")
|
||||||
private Integer courseCount;
|
private Integer completedLessonCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
package com.reading.platform.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课程包视图对象
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "课程包视图对象")
|
||||||
|
public class CoursePackageVO {
|
||||||
|
|
||||||
|
@Schema(description = "ID")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Schema(description = "名称")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Schema(description = "描述")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Schema(description = "适用年级")
|
||||||
|
private String gradeLevel;
|
||||||
|
|
||||||
|
@Schema(description = "课程数量")
|
||||||
|
private Integer courseCount;
|
||||||
|
|
||||||
|
@Schema(description = "状态")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Schema(description = "使用次数")
|
||||||
|
private Integer usageCount;
|
||||||
|
|
||||||
|
@Schema(description = "创建时间")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Schema(description = "更新时间")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
package com.reading.platform.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课程使用统计响应对象(增强版)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "课程使用统计响应对象")
|
||||||
|
public class CourseUsageStatsVO {
|
||||||
|
|
||||||
|
@Schema(description = "课程包 ID")
|
||||||
|
private Long coursePackageId;
|
||||||
|
|
||||||
|
@Schema(description = "课程包名称")
|
||||||
|
private String coursePackageName;
|
||||||
|
|
||||||
|
@Schema(description = "使用次数")
|
||||||
|
private Integer usageCount;
|
||||||
|
|
||||||
|
@Schema(description = "参与学生数(去重)")
|
||||||
|
private Integer studentCount;
|
||||||
|
|
||||||
|
@Schema(description = "平均时长(分钟)")
|
||||||
|
private Integer avgDuration;
|
||||||
|
|
||||||
|
@Schema(description = "最后使用时间")
|
||||||
|
private LocalDateTime lastUsedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.reading.platform.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课程使用统计视图对象
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "课程使用统计视图对象")
|
||||||
|
public class CourseUsageVO {
|
||||||
|
|
||||||
|
@Schema(description = "课程包名称")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Schema(description = "使用次数")
|
||||||
|
private Integer value;
|
||||||
|
}
|
||||||
@ -1,38 +0,0 @@
|
|||||||
package com.reading.platform.dto.response;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 最近活动项响应
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@Builder
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
@Schema(description = "最近活动项响应")
|
|
||||||
public class RecentActivityItemResponse {
|
|
||||||
|
|
||||||
@Schema(description = "活动 ID")
|
|
||||||
private Long activityId;
|
|
||||||
|
|
||||||
@Schema(description = "活动类型")
|
|
||||||
private String activityType;
|
|
||||||
|
|
||||||
@Schema(description = "活动描述")
|
|
||||||
private String description;
|
|
||||||
|
|
||||||
@Schema(description = "操作人 ID")
|
|
||||||
private Long operatorId;
|
|
||||||
|
|
||||||
@Schema(description = "操作人名称")
|
|
||||||
private String operatorName;
|
|
||||||
|
|
||||||
@Schema(description = "操作时间")
|
|
||||||
private LocalDateTime operationTime;
|
|
||||||
}
|
|
||||||
@ -33,4 +33,7 @@ public class StatsResponse {
|
|||||||
|
|
||||||
@Schema(description = "课时总数")
|
@Schema(description = "课时总数")
|
||||||
private Long totalLessons;
|
private Long totalLessons;
|
||||||
|
|
||||||
|
@Schema(description = "月授课次数(本月 COMPLETED 状态的 lesson 总数)")
|
||||||
|
private Long monthlyLessons;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,12 +21,9 @@ public class StatsTrendResponse {
|
|||||||
@Schema(description = "日期列表")
|
@Schema(description = "日期列表")
|
||||||
private List<String> dates;
|
private List<String> dates;
|
||||||
|
|
||||||
@Schema(description = "新增学生数列表")
|
@Schema(description = "授课次数列表(近 7 天每天完成的课程数)")
|
||||||
private List<Integer> newStudents;
|
private List<Integer> lessonCounts;
|
||||||
|
|
||||||
@Schema(description = "新增教师数列表")
|
@Schema(description = "活跃学生数列表(近 7 天每天有上课记录的去重学生数)")
|
||||||
private List<Integer> newTeachers;
|
private List<Integer> studentCounts;
|
||||||
|
|
||||||
@Schema(description = "新增课程数列表")
|
|
||||||
private List<Integer> newCourses;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,44 @@
|
|||||||
|
package com.reading.platform.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师活跃度排行响应
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "教师活跃度排行响应")
|
||||||
|
public class TeacherActivityRankResponse {
|
||||||
|
|
||||||
|
@Schema(description = "教师 ID")
|
||||||
|
private Long teacherId;
|
||||||
|
|
||||||
|
@Schema(description = "教师姓名")
|
||||||
|
private String teacherName;
|
||||||
|
|
||||||
|
@Schema(description = "负责班级名称列表,逗号分隔")
|
||||||
|
private String classNames;
|
||||||
|
|
||||||
|
@Schema(description = "统计周期内授课次数")
|
||||||
|
private Integer lessonCount;
|
||||||
|
|
||||||
|
@Schema(description = "统计周期内上课课程数(去重)")
|
||||||
|
private Integer courseCount;
|
||||||
|
|
||||||
|
@Schema(description = "最后活跃时间")
|
||||||
|
private LocalDateTime lastActiveAt;
|
||||||
|
|
||||||
|
@Schema(description = "活跃状态编码:HIGH-高频,MEDIUM-中频,LOW-低频,INACTIVE-未使用")
|
||||||
|
private String activityLevelCode;
|
||||||
|
|
||||||
|
@Schema(description = "活跃状态描述")
|
||||||
|
private String activityLevelDesc;
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package com.reading.platform.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师仪表盘响应
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "教师仪表盘响应")
|
||||||
|
public class TeacherDashboardResponse {
|
||||||
|
|
||||||
|
@Schema(description = "基础统计")
|
||||||
|
private TeacherStats stats;
|
||||||
|
|
||||||
|
@Schema(description = "今日课程")
|
||||||
|
private List<TeacherLessonVO> todayLessons;
|
||||||
|
|
||||||
|
@Schema(description = "推荐课程")
|
||||||
|
private List<CoursePackageVO> recommendedCourses;
|
||||||
|
|
||||||
|
@Schema(description = "本周统计")
|
||||||
|
private TeacherWeeklyStatsResponse weeklyStats;
|
||||||
|
|
||||||
|
@Schema(description = "近期活动")
|
||||||
|
private List<Object> recentActivities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础统计
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "基础统计")
|
||||||
|
public static class TeacherStats {
|
||||||
|
@Schema(description = "班级数量")
|
||||||
|
private Long classCount;
|
||||||
|
|
||||||
|
@Schema(description = "学生数量")
|
||||||
|
private Long studentCount;
|
||||||
|
|
||||||
|
@Schema(description = "课程包数量")
|
||||||
|
private Long courseCount;
|
||||||
|
|
||||||
|
@Schema(description = "课时数量")
|
||||||
|
private Long lessonCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
package com.reading.platform.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师课程趋势视图对象
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "教师课程趋势视图对象")
|
||||||
|
public class TeacherLessonTrendVO {
|
||||||
|
|
||||||
|
@Schema(description = "月份(yyyy-MM 格式)")
|
||||||
|
private String month;
|
||||||
|
|
||||||
|
@Schema(description = "课时数量")
|
||||||
|
private Long lessonCount;
|
||||||
|
|
||||||
|
@Schema(description = "平均评分")
|
||||||
|
private Double avgRating;
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
package com.reading.platform.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师课程视图对象
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "教师课程视图对象")
|
||||||
|
public class TeacherLessonVO {
|
||||||
|
|
||||||
|
@Schema(description = "ID")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Schema(description = "租户 ID")
|
||||||
|
private Long tenantId;
|
||||||
|
|
||||||
|
@Schema(description = "课程 ID")
|
||||||
|
private Long courseId;
|
||||||
|
|
||||||
|
@Schema(description = "班级 ID")
|
||||||
|
private Long classId;
|
||||||
|
|
||||||
|
@Schema(description = "课程名称")
|
||||||
|
private String courseName;
|
||||||
|
|
||||||
|
@Schema(description = "班级名称")
|
||||||
|
private String className;
|
||||||
|
|
||||||
|
@Schema(description = "教师 ID")
|
||||||
|
private Long teacherId;
|
||||||
|
|
||||||
|
@Schema(description = "标题")
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Schema(description = "课时日期")
|
||||||
|
private LocalDate lessonDate;
|
||||||
|
|
||||||
|
@Schema(description = "开始时间")
|
||||||
|
private LocalTime startTime;
|
||||||
|
|
||||||
|
@Schema(description = "结束时间")
|
||||||
|
private LocalTime endTime;
|
||||||
|
|
||||||
|
@Schema(description = "地点")
|
||||||
|
private String location;
|
||||||
|
|
||||||
|
@Schema(description = "状态")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Schema(description = "备注")
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
@Schema(description = "创建时间")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Schema(description = "更新时间")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package com.reading.platform.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师本周统计响应
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "教师本周统计响应")
|
||||||
|
public class TeacherWeeklyStatsResponse {
|
||||||
|
|
||||||
|
@Schema(description = "本周课时数量")
|
||||||
|
private Long lessonCount;
|
||||||
|
|
||||||
|
@Schema(description = "学生参与率")
|
||||||
|
private Integer studentParticipation;
|
||||||
|
|
||||||
|
@Schema(description = "平均评分")
|
||||||
|
private Double avgRating;
|
||||||
|
|
||||||
|
@Schema(description = "总时长(分钟)")
|
||||||
|
private Integer totalDuration;
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.reading.platform.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改个人信息响应 DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "修改个人信息响应")
|
||||||
|
public class UpdateProfileResponse {
|
||||||
|
|
||||||
|
@Schema(description = "更新后的用户信息")
|
||||||
|
private UserInfoResponse userInfo;
|
||||||
|
|
||||||
|
@Schema(description = "新的 Token(用于刷新)", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
|
||||||
|
private String token;
|
||||||
|
}
|
||||||
@ -4,8 +4,12 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|||||||
import com.reading.platform.entity.CoursePackage;
|
import com.reading.platform.entity.CoursePackage;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
import org.apache.ibatis.annotations.Update;
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface CoursePackageMapper extends BaseMapper<CoursePackage> {
|
public interface CoursePackageMapper extends BaseMapper<CoursePackage> {
|
||||||
|
|
||||||
@ -36,4 +40,18 @@ public interface CoursePackageMapper extends BaseMapper<CoursePackage> {
|
|||||||
") " +
|
") " +
|
||||||
"WHERE cp.id = #{coursePackageId}")
|
"WHERE cp.id = #{coursePackageId}")
|
||||||
void updateTeacherCount(@Param("coursePackageId") Long coursePackageId);
|
void updateTeacherCount(@Param("coursePackageId") Long coursePackageId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取学校端课程分布统计
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @return 课程包名称和使用次数的列表
|
||||||
|
*/
|
||||||
|
@Select("SELECT cp.name, COUNT(l.id) as value " +
|
||||||
|
"FROM course_package cp " +
|
||||||
|
"INNER JOIN lesson l ON cp.id = l.course_id " +
|
||||||
|
"WHERE l.tenant_id = #{tenantId} " +
|
||||||
|
"AND l.status = 'COMPLETED' " +
|
||||||
|
"GROUP BY cp.id, cp.name " +
|
||||||
|
"ORDER BY value DESC")
|
||||||
|
List<Map<String, Object>> getCourseDistribution(@Param("tenantId") Long tenantId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,137 @@
|
|||||||
package com.reading.platform.mapper;
|
package com.reading.platform.mapper;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.reading.platform.dto.response.CourseUsageStatsVO;
|
||||||
import com.reading.platform.entity.Lesson;
|
import com.reading.platform.entity.Lesson;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.SelectProvider;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface LessonMapper extends BaseMapper<Lesson> {
|
public interface LessonMapper extends BaseMapper<Lesson> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取学校端教师活跃度排行
|
||||||
|
* <p>
|
||||||
|
* 统计逻辑:
|
||||||
|
* 1. 统计周期:自然月内(由 startTime 参数控制,传入当月 1 号)
|
||||||
|
* 2. 授课次数:统计周期内 COMPLETED 状态的 lesson 数量
|
||||||
|
* 3. 课程数:统计周期内上过的不同课程(course_id)数量
|
||||||
|
* 4. 最后活跃时间:统计周期内最近一次授课完成时间
|
||||||
|
* 5. 排除已停用教师(teacher.status != 'ACTIVE')
|
||||||
|
* 6. 包含负责班级名称(逗号分隔)
|
||||||
|
*
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param startTime 统计周期开始时间(自然月 1 号)
|
||||||
|
* @param limit 返回数量限制
|
||||||
|
* @return 教师活跃度排行列表
|
||||||
|
*/
|
||||||
|
@Select("SELECT " +
|
||||||
|
" t.id AS teacherId, " +
|
||||||
|
" t.name AS teacherName, " +
|
||||||
|
" t.status AS teacherStatus, " +
|
||||||
|
" COUNT(l.id) AS lessonCount, " +
|
||||||
|
" COUNT(DISTINCT l.course_id) AS courseCount, " +
|
||||||
|
" MAX(l.end_datetime) AS lastActiveAt, " +
|
||||||
|
" GROUP_CONCAT(DISTINCT c.name ORDER BY c.name SEPARATOR ',') AS classNames " +
|
||||||
|
"FROM teacher t " +
|
||||||
|
"LEFT JOIN lesson l ON t.id = l.teacher_id " +
|
||||||
|
" AND l.status = 'COMPLETED' " +
|
||||||
|
" AND l.end_datetime >= #{startTime} " +
|
||||||
|
"LEFT JOIN class_teacher ct ON t.id = ct.teacher_id " +
|
||||||
|
"LEFT JOIN clazz c ON ct.class_id = c.id " +
|
||||||
|
"WHERE t.tenant_id = #{tenantId} " +
|
||||||
|
" AND t.deleted = 0 " +
|
||||||
|
" AND t.status = 'ACTIVE' " +
|
||||||
|
"GROUP BY t.id, t.name, t.status " +
|
||||||
|
"HAVING lessonCount > 0 " +
|
||||||
|
"ORDER BY lessonCount DESC, lastActiveAt DESC " +
|
||||||
|
"LIMIT #{limit}")
|
||||||
|
List<Map<String, Object>> getTeacherActivityRank(
|
||||||
|
@Param("tenantId") Long tenantId,
|
||||||
|
@Param("startTime") LocalDateTime startTime,
|
||||||
|
@Param("limit") int limit
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取教师端课程使用统计
|
||||||
|
* <p>
|
||||||
|
* 统计逻辑:
|
||||||
|
* 1. 基于 lesson 表实际授课记录统计
|
||||||
|
* 2. 统计周期内 COMPLETED 状态的 lesson 数量
|
||||||
|
* 3. 参与学生数:去重统计 student_record 中的 student_id
|
||||||
|
* 4. 平均时长:计算 lesson 的 start_datetime 到 end_datetime 的分钟差平均值
|
||||||
|
* 5. 最后使用时间:最近一次授课完成时间
|
||||||
|
*
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param teacherId 教师 ID(可选,为 null 时统计该租户下所有)
|
||||||
|
* @param startTime 统计开始时间
|
||||||
|
* @param endTime 统计结束时间
|
||||||
|
* @return 课程使用统计列表(TOP 10)
|
||||||
|
*/
|
||||||
|
@SelectProvider(type = SqlProvider.class, method = "getCourseUsageStats")
|
||||||
|
List<CourseUsageStatsVO> getCourseUsageStats(
|
||||||
|
@Param("tenantId") Long tenantId,
|
||||||
|
@Param("teacherId") Long teacherId,
|
||||||
|
@Param("startTime") LocalDateTime startTime,
|
||||||
|
@Param("endTime") LocalDateTime endTime
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL 提供者类(静态内部类)
|
||||||
|
*/
|
||||||
|
class SqlProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取课程使用统计 SQL
|
||||||
|
*/
|
||||||
|
public String getCourseUsageStats() {
|
||||||
|
return "SELECT " +
|
||||||
|
" cp.id AS coursePackageId, " +
|
||||||
|
" cp.name AS coursePackageName, " +
|
||||||
|
" COUNT(l.id) AS usageCount, " +
|
||||||
|
" COUNT(DISTINCT sr.student_id) AS studentCount, " +
|
||||||
|
" COALESCE(AVG(TIMESTAMPDIFF(MINUTE, l.start_datetime, l.end_datetime)), 0) AS avgDuration, " +
|
||||||
|
" MAX(l.end_datetime) AS lastUsedAt " +
|
||||||
|
"FROM lesson l " +
|
||||||
|
"INNER JOIN course_package cp ON l.course_id = cp.id " +
|
||||||
|
"LEFT JOIN student_record sr ON l.id = sr.lesson_id " +
|
||||||
|
"WHERE l.tenant_id = #{tenantId} " +
|
||||||
|
" AND l.end_datetime >= #{startTime} " +
|
||||||
|
" AND l.end_datetime <= #{endTime} " +
|
||||||
|
" AND l.status = 'COMPLETED' " +
|
||||||
|
" AND (#{teacherId} IS NULL OR l.teacher_id = #{teacherId}) " +
|
||||||
|
"GROUP BY cp.id, cp.name " +
|
||||||
|
"ORDER BY usageCount DESC " +
|
||||||
|
"LIMIT 10";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按租户 ID 和课程 ID 列表统计课程使用次数(已完成状态)
|
||||||
|
* 注意:此方法统计的是指定课程 ID 在指定租户下的使用次数
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param courseIds 课程 ID 列表
|
||||||
|
* @return 课程 ID 和使用次数的映射列表
|
||||||
|
*/
|
||||||
|
@Select("<script>" +
|
||||||
|
"SELECT l.course_id, COUNT(l.id) AS usageCount " +
|
||||||
|
"FROM lesson l " +
|
||||||
|
"INNER JOIN course_package cp ON l.course_id = cp.id " + // 确保只统计存在的课程包
|
||||||
|
"WHERE l.tenant_id = #{tenantId} " +
|
||||||
|
"AND l.status = 'COMPLETED' " +
|
||||||
|
"AND l.course_id IN " +
|
||||||
|
"<foreach item='id' collection='courseIds' open='(' separator=',' close=')'>" +
|
||||||
|
"#{id}" +
|
||||||
|
"</foreach>" +
|
||||||
|
"GROUP BY l.course_id" +
|
||||||
|
"</script>")
|
||||||
|
List<Map<String, Object>> countUsageByTenantAndCourseIds(
|
||||||
|
@Param("tenantId") Long tenantId,
|
||||||
|
@Param("courseIds") List<Long> courseIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,48 @@ package com.reading.platform.mapper;
|
|||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.reading.platform.entity.StudentRecord;
|
import com.reading.platform.entity.StudentRecord;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface StudentRecordMapper extends BaseMapper<StudentRecord> {
|
public interface StudentRecordMapper extends BaseMapper<StudentRecord> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计指定时间段内有上课记录的去重学生数
|
||||||
|
* @param startTime 开始时间
|
||||||
|
* @param endTime 结束时间
|
||||||
|
* @return 活跃学生数
|
||||||
|
*/
|
||||||
|
@Select("SELECT COUNT(DISTINCT sr.student_id) " +
|
||||||
|
"FROM student_record sr " +
|
||||||
|
"INNER JOIN lesson l ON sr.lesson_id = l.id " +
|
||||||
|
"WHERE l.status = 'COMPLETED' " +
|
||||||
|
" AND l.end_datetime >= #{startTime} " +
|
||||||
|
" AND l.end_datetime < #{endTime} " +
|
||||||
|
" AND l.deleted = 0 " +
|
||||||
|
" AND sr.deleted = 0")
|
||||||
|
Long countActiveStudents(@Param("startTime") LocalDateTime startTime,
|
||||||
|
@Param("endTime") LocalDateTime endTime);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计指定租户、指定时间段内有上课记录的去重学生数
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param startTime 开始时间
|
||||||
|
* @param endTime 结束时间
|
||||||
|
* @return 活跃学生数
|
||||||
|
*/
|
||||||
|
@Select("SELECT COUNT(DISTINCT sr.student_id) " +
|
||||||
|
"FROM student_record sr " +
|
||||||
|
"INNER JOIN lesson l ON sr.lesson_id = l.id " +
|
||||||
|
"WHERE l.tenant_id = #{tenantId} " +
|
||||||
|
" AND l.status = 'COMPLETED' " +
|
||||||
|
" AND l.end_datetime >= #{startTime} " +
|
||||||
|
" AND l.end_datetime < #{endTime} " +
|
||||||
|
" AND l.deleted = 0 " +
|
||||||
|
" AND sr.deleted = 0")
|
||||||
|
Long countActiveStudentsByTenant(@Param("tenantId") Long tenantId,
|
||||||
|
@Param("startTime") LocalDateTime startTime,
|
||||||
|
@Param("endTime") LocalDateTime endTime);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
package com.reading.platform.mapper.struct;
|
||||||
|
|
||||||
|
import com.reading.platform.dto.response.*;
|
||||||
|
import com.reading.platform.entity.CoursePackage;
|
||||||
|
import com.reading.platform.entity.Lesson;
|
||||||
|
import org.mapstruct.Mapper;
|
||||||
|
import org.mapstruct.factory.Mappers;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师统计转换器
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface TeacherStatsMapperStruct {
|
||||||
|
|
||||||
|
TeacherStatsMapperStruct INSTANCE = Mappers.getMapper(TeacherStatsMapperStruct.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesson 转 TeacherLessonVO
|
||||||
|
*/
|
||||||
|
TeacherLessonVO toLessonVO(Lesson lesson);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List<Lesson> 转 List<TeacherLessonVO>
|
||||||
|
*/
|
||||||
|
List<TeacherLessonVO> toLessonVOList(List<Lesson> lessons);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CoursePackage 转 CoursePackageVO
|
||||||
|
*/
|
||||||
|
CoursePackageVO toCoursePackageVO(CoursePackage coursePackage);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List<CoursePackage> 转 List<CoursePackageVO>
|
||||||
|
*/
|
||||||
|
List<CoursePackageVO> toCoursePackageVOList(List<CoursePackage> coursePackages);
|
||||||
|
}
|
||||||
@ -1,8 +1,10 @@
|
|||||||
package com.reading.platform.service;
|
package com.reading.platform.service;
|
||||||
|
|
||||||
import com.reading.platform.dto.request.LoginRequest;
|
import com.reading.platform.dto.request.LoginRequest;
|
||||||
|
import com.reading.platform.dto.request.UpdateProfileRequest;
|
||||||
import com.reading.platform.dto.response.LoginResponse;
|
import com.reading.platform.dto.response.LoginResponse;
|
||||||
import com.reading.platform.dto.response.TokenResponse;
|
import com.reading.platform.dto.response.TokenResponse;
|
||||||
|
import com.reading.platform.dto.response.UpdateProfileResponse;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth Service Interface
|
* Auth Service Interface
|
||||||
@ -17,10 +19,30 @@ public interface AuthService {
|
|||||||
*/
|
*/
|
||||||
Object getCurrentUserInfo();
|
Object getCurrentUserInfo();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码
|
||||||
|
* @param oldPassword 旧密码
|
||||||
|
* @param newPassword 新密码
|
||||||
|
*/
|
||||||
void changePassword(String oldPassword, String newPassword);
|
void changePassword(String oldPassword, String newPassword);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码(带 token 失效)
|
||||||
|
* @param oldPassword 旧密码
|
||||||
|
* @param newPassword 新密码
|
||||||
|
* @param currentToken 当前 token(用于加入黑名单)
|
||||||
|
*/
|
||||||
|
void changePassword(String oldPassword, String newPassword, String currentToken);
|
||||||
|
|
||||||
void logout();
|
void logout();
|
||||||
|
|
||||||
TokenResponse refreshToken();
|
TokenResponse refreshToken();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改个人信息
|
||||||
|
* @param request 修改请求
|
||||||
|
* @return 更新后的用户信息和新 token
|
||||||
|
*/
|
||||||
|
UpdateProfileResponse updateProfile(UpdateProfileRequest request);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
package com.reading.platform.service;
|
package com.reading.platform.service;
|
||||||
|
|
||||||
|
import com.reading.platform.dto.response.TeacherActivityRankResponse;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -14,19 +17,21 @@ public interface SchoolStatsService {
|
|||||||
Map<String, Object> getSchoolStats(Long tenantId);
|
Map<String, Object> getSchoolStats(Long tenantId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get active teachers ranking
|
* 获取活跃教师排行(自然月统计)
|
||||||
|
*
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param limit 返回数量限制
|
||||||
|
* @return 教师活跃度排行列表
|
||||||
*/
|
*/
|
||||||
List<Map<String, Object>> getActiveTeachers(Long tenantId, int limit);
|
List<TeacherActivityRankResponse> getActiveTeachers(Long tenantId, int limit);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get course usage statistics
|
* Get course usage statistics
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param startDate 开始日期(可选,默认本月 1 号)
|
||||||
|
* @param endDate 结束日期(可选,默认今天)
|
||||||
*/
|
*/
|
||||||
List<Map<String, Object>> getCourseUsageStats(Long tenantId);
|
List<Map<String, Object>> getCourseUsageStats(Long tenantId, LocalDate startDate, LocalDate endDate);
|
||||||
|
|
||||||
/**
|
|
||||||
* Get recent activities
|
|
||||||
*/
|
|
||||||
List<Map<String, Object>> getRecentActivities(Long tenantId, int limit);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get lesson trend by month
|
* Get lesson trend by month
|
||||||
|
|||||||
@ -2,10 +2,8 @@ package com.reading.platform.service;
|
|||||||
|
|
||||||
import com.reading.platform.dto.request.ActiveTenantsQueryRequest;
|
import com.reading.platform.dto.request.ActiveTenantsQueryRequest;
|
||||||
import com.reading.platform.dto.request.PopularCoursesQueryRequest;
|
import com.reading.platform.dto.request.PopularCoursesQueryRequest;
|
||||||
import com.reading.platform.dto.request.RecentActivitiesQueryRequest;
|
|
||||||
import com.reading.platform.dto.response.ActiveTenantItemResponse;
|
import com.reading.platform.dto.response.ActiveTenantItemResponse;
|
||||||
import com.reading.platform.dto.response.PopularCourseItemResponse;
|
import com.reading.platform.dto.response.PopularCourseItemResponse;
|
||||||
import com.reading.platform.dto.response.RecentActivityItemResponse;
|
|
||||||
import com.reading.platform.dto.response.StatsResponse;
|
import com.reading.platform.dto.response.StatsResponse;
|
||||||
import com.reading.platform.dto.response.StatsTrendResponse;
|
import com.reading.platform.dto.response.StatsTrendResponse;
|
||||||
|
|
||||||
@ -35,9 +33,4 @@ public interface StatsService {
|
|||||||
* 获取热门课程
|
* 获取热门课程
|
||||||
*/
|
*/
|
||||||
List<PopularCourseItemResponse> getPopularCourses(PopularCoursesQueryRequest request);
|
List<PopularCourseItemResponse> getPopularCourses(PopularCoursesQueryRequest request);
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取最近活动
|
|
||||||
*/
|
|
||||||
List<RecentActivityItemResponse> getRecentActivities(RecentActivitiesQueryRequest request);
|
|
||||||
}
|
}
|
||||||
@ -1,43 +1,57 @@
|
|||||||
package com.reading.platform.service;
|
package com.reading.platform.service;
|
||||||
|
|
||||||
import com.reading.platform.entity.CoursePackage;
|
import com.reading.platform.dto.request.CourseUsageQuery;
|
||||||
import com.reading.platform.entity.Lesson;
|
import com.reading.platform.dto.response.CoursePackageVO;
|
||||||
|
import com.reading.platform.dto.response.CourseUsageStatsVO;
|
||||||
|
import com.reading.platform.dto.response.CourseUsageVO;
|
||||||
|
import com.reading.platform.dto.response.TeacherDashboardResponse;
|
||||||
|
import com.reading.platform.dto.response.TeacherLessonTrendVO;
|
||||||
|
import com.reading.platform.dto.response.TeacherLessonVO;
|
||||||
|
import com.reading.platform.dto.response.TeacherWeeklyStatsResponse;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Teacher Statistics Service Interface
|
* 教师统计服务接口
|
||||||
*/
|
*/
|
||||||
public interface TeacherStatsService {
|
public interface TeacherStatsService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get teacher dashboard statistics
|
* 获取教师仪表盘统计数据
|
||||||
*/
|
*/
|
||||||
Map<String, Object> getDashboard(Long teacherId, Long tenantId);
|
TeacherDashboardResponse getDashboard(Long teacherId, Long tenantId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get today's lessons
|
* 获取今日课程
|
||||||
*/
|
*/
|
||||||
List<Lesson> getTodayLessons(Long teacherId);
|
List<TeacherLessonVO> getTodayLessons(Long teacherId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get recommended courses
|
* 获取推荐课程
|
||||||
*/
|
*/
|
||||||
List<CoursePackage> getRecommendedCourses(Long tenantId);
|
List<CoursePackageVO> getRecommendedCourses(Long tenantId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get weekly statistics
|
* 获取本周统计
|
||||||
*/
|
*/
|
||||||
Map<String, Object> getWeeklyStats(Long teacherId);
|
TeacherWeeklyStatsResponse getWeeklyStats(Long teacherId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get lesson trend by month
|
* 获取授课趋势
|
||||||
*/
|
*/
|
||||||
List<Map<String, Object>> getLessonTrend(Long teacherId, int months);
|
List<TeacherLessonTrendVO> getLessonTrend(Long teacherId, int months);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get course usage statistics
|
* 获取课程使用统计(旧版)
|
||||||
*/
|
*/
|
||||||
List<Map<String, Object>> getCourseUsage(Long tenantId);
|
List<CourseUsageVO> getCourseUsage(Long tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取课程使用统计(增强版)
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param teacherId 教师 ID
|
||||||
|
* @param query 查询参数
|
||||||
|
* @return 课程使用统计列表
|
||||||
|
*/
|
||||||
|
List<CourseUsageStatsVO> getCourseUsageStats(Long tenantId, Long teacherId, CourseUsageQuery query);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,8 +10,11 @@ import com.reading.platform.common.security.JwtTokenProvider;
|
|||||||
import com.reading.platform.common.security.JwtTokenRedisService;
|
import com.reading.platform.common.security.JwtTokenRedisService;
|
||||||
import com.reading.platform.common.security.SecurityUtils;
|
import com.reading.platform.common.security.SecurityUtils;
|
||||||
import com.reading.platform.dto.request.LoginRequest;
|
import com.reading.platform.dto.request.LoginRequest;
|
||||||
|
import com.reading.platform.dto.request.UpdateProfileRequest;
|
||||||
import com.reading.platform.dto.response.LoginResponse;
|
import com.reading.platform.dto.response.LoginResponse;
|
||||||
import com.reading.platform.dto.response.TokenResponse;
|
import com.reading.platform.dto.response.TokenResponse;
|
||||||
|
import com.reading.platform.dto.response.UpdateProfileResponse;
|
||||||
|
import com.reading.platform.dto.response.UserInfoResponse;
|
||||||
import com.reading.platform.entity.AdminUser;
|
import com.reading.platform.entity.AdminUser;
|
||||||
import com.reading.platform.entity.Parent;
|
import com.reading.platform.entity.Parent;
|
||||||
import com.reading.platform.entity.Tenant;
|
import com.reading.platform.entity.Tenant;
|
||||||
@ -25,6 +28,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@ -459,6 +463,20 @@ public class AuthServiceImpl implements AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void changePassword(String oldPassword, String newPassword, String currentToken) {
|
||||||
|
// 先修改密码
|
||||||
|
changePassword(oldPassword, newPassword);
|
||||||
|
|
||||||
|
// 将当前 token 加入黑名单
|
||||||
|
if (StringUtils.hasText(currentToken)) {
|
||||||
|
// 获取 token 剩余过期时间
|
||||||
|
long remainingTime = jwtTokenProvider.getRemainingExpiration(currentToken);
|
||||||
|
jwtTokenRedisService.addToBlacklist(currentToken, remainingTime);
|
||||||
|
log.info("Token 已加入黑名单,token: {}, 剩余过期时间:{}秒", currentToken, remainingTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void logout() {
|
public void logout() {
|
||||||
JwtPayload payload = SecurityUtils.getCurrentUser();
|
JwtPayload payload = SecurityUtils.getCurrentUser();
|
||||||
@ -480,4 +498,146 @@ public class AuthServiceImpl implements AuthService {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UpdateProfileResponse updateProfile(UpdateProfileRequest request) {
|
||||||
|
log.info("开始修改个人信息,用户 ID: {}", SecurityUtils.getCurrentUser().getUserId());
|
||||||
|
|
||||||
|
JwtPayload payload = SecurityUtils.getCurrentUser();
|
||||||
|
String role = payload.getRole();
|
||||||
|
Long userId = payload.getUserId();
|
||||||
|
|
||||||
|
// 根据角色更新对应表的字段
|
||||||
|
switch (role) {
|
||||||
|
case "admin" -> {
|
||||||
|
AdminUser adminUser = adminUserMapper.selectById(userId);
|
||||||
|
if (request.getName() != null) {
|
||||||
|
adminUser.setName(request.getName());
|
||||||
|
}
|
||||||
|
if (request.getPhone() != null) {
|
||||||
|
adminUser.setPhone(request.getPhone());
|
||||||
|
}
|
||||||
|
if (request.getEmail() != null) {
|
||||||
|
adminUser.setEmail(request.getEmail());
|
||||||
|
}
|
||||||
|
adminUserMapper.updateById(adminUser);
|
||||||
|
log.info("管理员信息修改成功,用户 ID: {}", userId);
|
||||||
|
}
|
||||||
|
case "school" -> {
|
||||||
|
Tenant tenant = tenantMapper.selectById(userId);
|
||||||
|
if (request.getName() != null) {
|
||||||
|
tenant.setContactName(request.getName());
|
||||||
|
}
|
||||||
|
if (request.getPhone() != null) {
|
||||||
|
tenant.setContactPhone(request.getPhone());
|
||||||
|
}
|
||||||
|
if (request.getEmail() != null) {
|
||||||
|
tenant.setContactEmail(request.getEmail());
|
||||||
|
}
|
||||||
|
tenantMapper.updateById(tenant);
|
||||||
|
log.info("租户信息修改成功,用户 ID: {}", userId);
|
||||||
|
}
|
||||||
|
case "teacher" -> {
|
||||||
|
Teacher teacher = teacherMapper.selectById(userId);
|
||||||
|
if (request.getName() != null) {
|
||||||
|
teacher.setName(request.getName());
|
||||||
|
}
|
||||||
|
if (request.getPhone() != null) {
|
||||||
|
teacher.setPhone(request.getPhone());
|
||||||
|
}
|
||||||
|
if (request.getEmail() != null) {
|
||||||
|
teacher.setEmail(request.getEmail());
|
||||||
|
}
|
||||||
|
teacherMapper.updateById(teacher);
|
||||||
|
log.info("教师信息修改成功,用户 ID: {}", userId);
|
||||||
|
}
|
||||||
|
case "parent" -> {
|
||||||
|
Parent parent = parentMapper.selectById(userId);
|
||||||
|
if (request.getName() != null) {
|
||||||
|
parent.setName(request.getName());
|
||||||
|
}
|
||||||
|
if (request.getPhone() != null) {
|
||||||
|
parent.setPhone(request.getPhone());
|
||||||
|
}
|
||||||
|
if (request.getEmail() != null) {
|
||||||
|
parent.setEmail(request.getEmail());
|
||||||
|
}
|
||||||
|
parentMapper.updateById(parent);
|
||||||
|
log.info("家长信息修改成功,用户 ID: {}", userId);
|
||||||
|
}
|
||||||
|
default -> throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成新的 token
|
||||||
|
JwtPayload currentPayload = SecurityUtils.getCurrentUser();
|
||||||
|
String newToken = jwtTokenProvider.generateToken(currentPayload);
|
||||||
|
|
||||||
|
// 更新 Redis 中的 token
|
||||||
|
jwtTokenRedisService.storeToken(currentPayload.getUsername(), newToken, jwtTokenProvider.getExpiration());
|
||||||
|
|
||||||
|
// 获取更新后的用户信息
|
||||||
|
Object updatedUserInfo = getCurrentUserInfo();
|
||||||
|
UserInfoResponse userInfoResponse = convertToUserInfoResponse(updatedUserInfo, role);
|
||||||
|
|
||||||
|
return UpdateProfileResponse.builder()
|
||||||
|
.userInfo(userInfoResponse)
|
||||||
|
.token(newToken)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Entity 对象转换为 UserInfoResponse
|
||||||
|
*/
|
||||||
|
private UserInfoResponse convertToUserInfoResponse(Object userInfo, String role) {
|
||||||
|
if (userInfo instanceof Tenant) {
|
||||||
|
Tenant tenant = (Tenant) userInfo;
|
||||||
|
return UserInfoResponse.builder()
|
||||||
|
.id(tenant.getId())
|
||||||
|
.username(tenant.getUsername())
|
||||||
|
.name(tenant.getName())
|
||||||
|
.email(tenant.getContactEmail())
|
||||||
|
.phone(tenant.getContactPhone())
|
||||||
|
.avatarUrl(tenant.getLogoUrl())
|
||||||
|
.role("school")
|
||||||
|
.tenantId(tenant.getId())
|
||||||
|
.build();
|
||||||
|
} else if (userInfo instanceof Teacher) {
|
||||||
|
Teacher teacher = (Teacher) userInfo;
|
||||||
|
return UserInfoResponse.builder()
|
||||||
|
.id(teacher.getId())
|
||||||
|
.username(teacher.getUsername())
|
||||||
|
.name(teacher.getName())
|
||||||
|
.email(teacher.getEmail())
|
||||||
|
.phone(teacher.getPhone())
|
||||||
|
.avatarUrl(teacher.getAvatarUrl())
|
||||||
|
.role("teacher")
|
||||||
|
.tenantId(teacher.getTenantId())
|
||||||
|
.build();
|
||||||
|
} else if (userInfo instanceof Parent) {
|
||||||
|
Parent parent = (Parent) userInfo;
|
||||||
|
return UserInfoResponse.builder()
|
||||||
|
.id(parent.getId())
|
||||||
|
.username(parent.getUsername())
|
||||||
|
.name(parent.getName())
|
||||||
|
.email(parent.getEmail())
|
||||||
|
.phone(parent.getPhone())
|
||||||
|
.avatarUrl(parent.getAvatarUrl())
|
||||||
|
.role("parent")
|
||||||
|
.tenantId(parent.getTenantId())
|
||||||
|
.build();
|
||||||
|
} else if (userInfo instanceof AdminUser) {
|
||||||
|
AdminUser adminUser = (AdminUser) userInfo;
|
||||||
|
return UserInfoResponse.builder()
|
||||||
|
.id(adminUser.getId())
|
||||||
|
.username(adminUser.getUsername())
|
||||||
|
.name(adminUser.getName())
|
||||||
|
.email(adminUser.getEmail())
|
||||||
|
.phone(adminUser.getPhone())
|
||||||
|
.avatarUrl(adminUser.getAvatarUrl())
|
||||||
|
.role("admin")
|
||||||
|
.tenantId(null)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,30 @@
|
|||||||
package com.reading.platform.service.impl;
|
package com.reading.platform.service.impl;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.reading.platform.dto.response.TeacherActivityRankResponse;
|
||||||
|
import com.reading.platform.dto.response.CourseUsageStatsVO;
|
||||||
import com.reading.platform.entity.Clazz;
|
import com.reading.platform.entity.Clazz;
|
||||||
import com.reading.platform.entity.Student;
|
import com.reading.platform.entity.Student;
|
||||||
import com.reading.platform.entity.Teacher;
|
import com.reading.platform.entity.Teacher;
|
||||||
|
import com.reading.platform.entity.Lesson;
|
||||||
|
import com.reading.platform.common.enums.LessonStatus;
|
||||||
|
import com.reading.platform.common.enums.TeacherActivityLevel;
|
||||||
import com.reading.platform.mapper.ClazzMapper;
|
import com.reading.platform.mapper.ClazzMapper;
|
||||||
import com.reading.platform.mapper.StudentMapper;
|
import com.reading.platform.mapper.StudentMapper;
|
||||||
import com.reading.platform.mapper.TeacherMapper;
|
import com.reading.platform.mapper.TeacherMapper;
|
||||||
|
import com.reading.platform.mapper.LessonMapper;
|
||||||
|
import com.reading.platform.mapper.StudentRecordMapper;
|
||||||
|
import com.reading.platform.mapper.CoursePackageMapper;
|
||||||
import com.reading.platform.service.SchoolStatsService;
|
import com.reading.platform.service.SchoolStatsService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学校统计服务实现类
|
* 学校统计服务实现类
|
||||||
@ -27,6 +37,9 @@ public class SchoolStatsServiceImpl implements SchoolStatsService {
|
|||||||
private final TeacherMapper teacherMapper;
|
private final TeacherMapper teacherMapper;
|
||||||
private final StudentMapper studentMapper;
|
private final StudentMapper studentMapper;
|
||||||
private final ClazzMapper clazzMapper;
|
private final ClazzMapper clazzMapper;
|
||||||
|
private final LessonMapper lessonMapper;
|
||||||
|
private final StudentRecordMapper studentRecordMapper;
|
||||||
|
private final CoursePackageMapper coursePackageMapper;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> getSchoolStats(Long tenantId) {
|
public Map<String, Object> getSchoolStats(Long tenantId) {
|
||||||
@ -40,44 +53,157 @@ public class SchoolStatsServiceImpl implements SchoolStatsService {
|
|||||||
stats.put("classCount", clazzMapper.selectCount(
|
stats.put("classCount", clazzMapper.selectCount(
|
||||||
new LambdaQueryWrapper<Clazz>().eq(Clazz::getTenantId, tenantId)
|
new LambdaQueryWrapper<Clazz>().eq(Clazz::getTenantId, tenantId)
|
||||||
));
|
));
|
||||||
stats.put("lessonCount", 0); // TODO: implement lesson count
|
|
||||||
|
// 月授课次数:本月自然月内该租户 COMPLETED 状态的 lesson 总数
|
||||||
|
LocalDateTime monthStart = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0);
|
||||||
|
LocalDateTime monthEnd = monthStart.plusMonths(1);
|
||||||
|
Long monthlyLessons = lessonMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<Lesson>()
|
||||||
|
.eq(Lesson::getTenantId, tenantId)
|
||||||
|
.eq(Lesson::getStatus, LessonStatus.COMPLETED.getCode())
|
||||||
|
.ge(Lesson::getEndDatetime, monthStart)
|
||||||
|
.lt(Lesson::getEndDatetime, monthEnd)
|
||||||
|
);
|
||||||
|
stats.put("lessonCount", monthlyLessons != null ? monthlyLessons.intValue() : 0);
|
||||||
|
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Map<String, Object>> getActiveTeachers(Long tenantId, int limit) {
|
public List<TeacherActivityRankResponse> getActiveTeachers(Long tenantId, int limit) {
|
||||||
List<Map<String, Object>> teachers = new ArrayList<>();
|
// 自然月统计:从当月 1 号开始
|
||||||
// For now, return empty list
|
LocalDateTime monthStart = LocalDateTime.now()
|
||||||
return teachers;
|
.withDayOfMonth(1)
|
||||||
|
.withHour(0)
|
||||||
|
.withMinute(0)
|
||||||
|
.withSecond(0)
|
||||||
|
.withNano(0);
|
||||||
|
|
||||||
|
log.info("获取活跃教师排行,租户 ID: {}, 统计周期开始时间:{}", tenantId, monthStart);
|
||||||
|
|
||||||
|
// 调用 Mapper 查询原始数据
|
||||||
|
List<Map<String, Object>> rawData = lessonMapper.getTeacherActivityRank(tenantId, monthStart, limit);
|
||||||
|
|
||||||
|
if (rawData == null || rawData.isEmpty()) {
|
||||||
|
log.info("未查询到活跃教师数据");
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为响应对象
|
||||||
|
return rawData.stream().map(row -> {
|
||||||
|
Integer lessonCount = convertToInt(row.get("lessonCount"));
|
||||||
|
Integer courseCount = convertToInt(row.get("courseCount"));
|
||||||
|
LocalDateTime lastActiveAt = (LocalDateTime) row.get("lastActiveAt");
|
||||||
|
String classNames = (String) row.get("classNames");
|
||||||
|
|
||||||
|
// 根据授课次数计算活跃度等级
|
||||||
|
TeacherActivityLevel activityLevel = TeacherActivityLevel.fromLessonCount(lessonCount);
|
||||||
|
|
||||||
|
return TeacherActivityRankResponse.builder()
|
||||||
|
.teacherId((Long) row.get("teacherId"))
|
||||||
|
.teacherName((String) row.get("teacherName"))
|
||||||
|
.classNames(classNames != null ? classNames : "")
|
||||||
|
.lessonCount(lessonCount)
|
||||||
|
.courseCount(courseCount)
|
||||||
|
.lastActiveAt(lastActiveAt)
|
||||||
|
.activityLevelCode(activityLevel.getCode())
|
||||||
|
.activityLevelDesc(activityLevel.getDescription())
|
||||||
|
.build();
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Object 转换为 Integer,处理 null 和 Number 类型
|
||||||
|
*/
|
||||||
|
private Integer convertToInt(Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (value instanceof Number) {
|
||||||
|
return ((Number) value).intValue();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Map<String, Object>> getCourseUsageStats(Long tenantId) {
|
public List<Map<String, Object>> getCourseUsageStats(Long tenantId, LocalDate startDate, LocalDate endDate) {
|
||||||
List<Map<String, Object>> courses = new ArrayList<>();
|
// 计算时间范围
|
||||||
// For now, return empty list
|
LocalDateTime startTime;
|
||||||
return courses;
|
LocalDateTime endTime;
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
if (startDate != null) {
|
||||||
public List<Map<String, Object>> getRecentActivities(Long tenantId, int limit) {
|
startTime = startDate.atStartOfDay();
|
||||||
List<Map<String, Object>> activities = new ArrayList<>();
|
} else {
|
||||||
// For now, return empty list
|
// 默认本月 1 号
|
||||||
return activities;
|
startTime = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate != null) {
|
||||||
|
endTime = endDate.atTime(23, 59, 59);
|
||||||
|
} else {
|
||||||
|
// 默认今天
|
||||||
|
endTime = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 Mapper 查询(教师 ID 为 null 表示统计全校)
|
||||||
|
List<CourseUsageStatsVO> stats = lessonMapper.getCourseUsageStats(
|
||||||
|
tenantId, null, startTime, endTime
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info("获取学校课程使用统计,租户 ID: {}, 统计周期:{} ~ {}, 返回{}条数据",
|
||||||
|
tenantId, startTime, endTime, stats != null ? stats.size() : 0);
|
||||||
|
|
||||||
|
// 转换为 Map 格式返回(兼容前端现有类型)
|
||||||
|
if (stats == null || stats.isEmpty()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats.stream().map(vo -> {
|
||||||
|
Map<String, Object> map = new HashMap<>();
|
||||||
|
map.put("courseId", vo.getCoursePackageId());
|
||||||
|
map.put("courseName", vo.getCoursePackageName());
|
||||||
|
map.put("usageCount", vo.getUsageCount());
|
||||||
|
map.put("studentCount", vo.getStudentCount() != null ? vo.getStudentCount() : 0);
|
||||||
|
map.put("avgDuration", vo.getAvgDuration() != null ? vo.getAvgDuration() : 0);
|
||||||
|
map.put("lastUsedAt", vo.getLastUsedAt());
|
||||||
|
return map;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Map<String, Object>> getLessonTrend(Long tenantId, int months) {
|
public List<Map<String, Object>> getLessonTrend(Long tenantId, int months) {
|
||||||
List<Map<String, Object>> trend = new ArrayList<>();
|
// 转换为天数,与超管端保持一致(最近 7 天)
|
||||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM");
|
int days = (months <= 0) ? 7 : Math.min(months, 30); // 限制最多 30 天
|
||||||
|
|
||||||
for (int i = months - 1; i >= 0; i--) {
|
LocalDateTime now = LocalDateTime.now();
|
||||||
LocalDate date = LocalDate.now().minusMonths(i);
|
List<Map<String, Object>> trend = new ArrayList<>();
|
||||||
String month = date.format(formatter);
|
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MM-dd");
|
||||||
|
|
||||||
|
// 获取最近 N 天的趋势数据
|
||||||
|
for (int i = days - 1; i >= 0; i--) {
|
||||||
|
LocalDate date = now.minusDays(i).toLocalDate();
|
||||||
|
String dateStr = date.format(dateFormatter);
|
||||||
|
|
||||||
|
// 当天时间范围
|
||||||
|
LocalDateTime dayStart = date.atStartOfDay();
|
||||||
|
LocalDateTime dayEnd = date.plusDays(1).atStartOfDay();
|
||||||
|
|
||||||
|
// 当天完成的授课次数(按租户过滤,使用 LambdaQueryWrapper 和 Entity)
|
||||||
|
Long lessons = lessonMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<Lesson>()
|
||||||
|
.eq(Lesson::getTenantId, tenantId)
|
||||||
|
.eq(Lesson::getStatus, LessonStatus.COMPLETED.getCode())
|
||||||
|
.ge(Lesson::getEndDatetime, dayStart)
|
||||||
|
.lt(Lesson::getEndDatetime, dayEnd)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当天活跃学生数(按租户过滤)
|
||||||
|
Long activeStudents = studentRecordMapper.countActiveStudentsByTenant(tenantId, dayStart, dayEnd);
|
||||||
|
|
||||||
Map<String, Object> item = new HashMap<>();
|
Map<String, Object> item = new HashMap<>();
|
||||||
item.put("month", month);
|
item.put("date", dateStr);
|
||||||
item.put("lessonCount", 0);
|
item.put("lessonCount", lessons != null ? lessons.intValue() : 0);
|
||||||
item.put("studentCount", 0);
|
item.put("studentCount", activeStudents != null ? activeStudents.intValue() : 0);
|
||||||
trend.add(item);
|
trend.add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,8 +212,19 @@ public class SchoolStatsServiceImpl implements SchoolStatsService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Map<String, Object>> getCourseDistribution(Long tenantId) {
|
public List<Map<String, Object>> getCourseDistribution(Long tenantId) {
|
||||||
List<Map<String, Object>> distribution = new ArrayList<>();
|
// 调用 Mapper 查询课程分布数据
|
||||||
// For now, return empty list
|
List<Map<String, Object>> result = coursePackageMapper.getCourseDistribution(tenantId);
|
||||||
return distribution;
|
|
||||||
|
// 处理空数据情况,确保返回空列表而不是 null
|
||||||
|
if (result == null) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制返回数量,最多返回前 10 个课程包
|
||||||
|
if (result.size() > 10) {
|
||||||
|
return result.subList(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,22 +3,24 @@ package com.reading.platform.service.impl;
|
|||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.reading.platform.dto.request.ActiveTenantsQueryRequest;
|
import com.reading.platform.dto.request.ActiveTenantsQueryRequest;
|
||||||
import com.reading.platform.dto.request.PopularCoursesQueryRequest;
|
import com.reading.platform.dto.request.PopularCoursesQueryRequest;
|
||||||
import com.reading.platform.dto.request.RecentActivitiesQueryRequest;
|
|
||||||
import com.reading.platform.dto.response.ActiveTenantItemResponse;
|
import com.reading.platform.dto.response.ActiveTenantItemResponse;
|
||||||
import com.reading.platform.dto.response.PopularCourseItemResponse;
|
import com.reading.platform.dto.response.PopularCourseItemResponse;
|
||||||
import com.reading.platform.dto.response.RecentActivityItemResponse;
|
|
||||||
import com.reading.platform.dto.response.StatsResponse;
|
import com.reading.platform.dto.response.StatsResponse;
|
||||||
import com.reading.platform.dto.response.StatsTrendResponse;
|
import com.reading.platform.dto.response.StatsTrendResponse;
|
||||||
import com.reading.platform.entity.CoursePackage;
|
import com.reading.platform.entity.CoursePackage;
|
||||||
import com.reading.platform.entity.CourseLesson;
|
|
||||||
import com.reading.platform.entity.Student;
|
import com.reading.platform.entity.Student;
|
||||||
import com.reading.platform.entity.Teacher;
|
import com.reading.platform.entity.Teacher;
|
||||||
import com.reading.platform.entity.Tenant;
|
import com.reading.platform.entity.Tenant;
|
||||||
import com.reading.platform.mapper.CourseLessonMapper;
|
import com.reading.platform.entity.Lesson;
|
||||||
|
import com.reading.platform.common.enums.LessonStatus;
|
||||||
|
import com.reading.platform.common.enums.TenantStatus;
|
||||||
import com.reading.platform.mapper.CoursePackageMapper;
|
import com.reading.platform.mapper.CoursePackageMapper;
|
||||||
|
import com.reading.platform.mapper.CourseLessonMapper;
|
||||||
|
import com.reading.platform.mapper.LessonMapper;
|
||||||
import com.reading.platform.mapper.StudentMapper;
|
import com.reading.platform.mapper.StudentMapper;
|
||||||
import com.reading.platform.mapper.TeacherMapper;
|
import com.reading.platform.mapper.TeacherMapper;
|
||||||
import com.reading.platform.mapper.TenantMapper;
|
import com.reading.platform.mapper.TenantMapper;
|
||||||
|
import com.reading.platform.mapper.StudentRecordMapper;
|
||||||
import com.reading.platform.service.StatsService;
|
import com.reading.platform.service.StatsService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@ -28,7 +30,6 @@ import java.time.LocalDate;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@ -45,6 +46,21 @@ public class StatsServiceImpl implements StatsService {
|
|||||||
private final StudentMapper studentMapper;
|
private final StudentMapper studentMapper;
|
||||||
private final CoursePackageMapper courseMapper;
|
private final CoursePackageMapper courseMapper;
|
||||||
private final CourseLessonMapper courseLessonMapper;
|
private final CourseLessonMapper courseLessonMapper;
|
||||||
|
private final LessonMapper lessonMapper;
|
||||||
|
private final StudentRecordMapper studentRecordMapper;
|
||||||
|
|
||||||
|
// ==================== 常量定义 ====================
|
||||||
|
/** 活跃教师统计时间窗口(天) */
|
||||||
|
private static final int ACTIVE_TEACHER_WINDOW_DAYS = 30;
|
||||||
|
|
||||||
|
/** 活跃租户 TOP 数量 */
|
||||||
|
private static final int DEFAULT_TOP_LIMIT = 5;
|
||||||
|
|
||||||
|
/** 活跃教师数权重 */
|
||||||
|
private static final double WEIGHT_ACTIVE_TEACHER = 1.0;
|
||||||
|
|
||||||
|
/** 完成课次数权重 */
|
||||||
|
private static final double WEIGHT_COMPLETED_LESSON = 0.1;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public StatsResponse getStats() {
|
public StatsResponse getStats() {
|
||||||
@ -55,7 +71,7 @@ public class StatsServiceImpl implements StatsService {
|
|||||||
|
|
||||||
// 活跃租户数
|
// 活跃租户数
|
||||||
Long activeTenants = tenantMapper.selectCount(
|
Long activeTenants = tenantMapper.selectCount(
|
||||||
new LambdaQueryWrapper<Tenant>().eq(Tenant::getStatus, "ACTIVE")
|
new LambdaQueryWrapper<Tenant>().eq(Tenant::getStatus, TenantStatus.ACTIVE.getCode())
|
||||||
);
|
);
|
||||||
|
|
||||||
// 教师总数
|
// 教师总数
|
||||||
@ -67,9 +83,19 @@ public class StatsServiceImpl implements StatsService {
|
|||||||
// 课程总数
|
// 课程总数
|
||||||
Long totalCourses = courseMapper.selectCount(null);
|
Long totalCourses = courseMapper.selectCount(null);
|
||||||
|
|
||||||
// 课时总数
|
// 课时总数(course_lesson 表记录数)
|
||||||
Long totalLessons = courseLessonMapper.selectCount(null);
|
Long totalLessons = courseLessonMapper.selectCount(null);
|
||||||
|
|
||||||
|
// 月授课次数:本月自然月内 COMPLETED 状态的 lesson 总数
|
||||||
|
LocalDateTime monthStart = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0);
|
||||||
|
LocalDateTime monthEnd = monthStart.plusMonths(1);
|
||||||
|
Long monthlyLessons = lessonMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<Lesson>()
|
||||||
|
.eq(Lesson::getStatus, LessonStatus.COMPLETED.getCode())
|
||||||
|
.ge(Lesson::getEndDatetime, monthStart)
|
||||||
|
.lt(Lesson::getEndDatetime, monthEnd)
|
||||||
|
);
|
||||||
|
|
||||||
return StatsResponse.builder()
|
return StatsResponse.builder()
|
||||||
.totalTenants(totalTenants)
|
.totalTenants(totalTenants)
|
||||||
.activeTenants(activeTenants)
|
.activeTenants(activeTenants)
|
||||||
@ -77,6 +103,7 @@ public class StatsServiceImpl implements StatsService {
|
|||||||
.totalStudents(totalStudents)
|
.totalStudents(totalStudents)
|
||||||
.totalCourses(totalCourses)
|
.totalCourses(totalCourses)
|
||||||
.totalLessons(totalLessons)
|
.totalLessons(totalLessons)
|
||||||
|
.monthlyLessons(monthlyLessons)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,86 +113,94 @@ public class StatsServiceImpl implements StatsService {
|
|||||||
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
List<String> dates = new ArrayList<>();
|
List<String> dates = new ArrayList<>();
|
||||||
List<Integer> newStudents = new ArrayList<>();
|
List<Integer> lessonCounts = new ArrayList<>();
|
||||||
List<Integer> newTeachers = new ArrayList<>();
|
List<Integer> studentCounts = new ArrayList<>();
|
||||||
List<Integer> newCourses = new ArrayList<>();
|
|
||||||
|
|
||||||
// 获取最近 7 天的趋势数据
|
// 获取最近 7 天的趋势数据
|
||||||
for (int i = 6; i >= 0; i--) {
|
for (int i = 6; i >= 0; i--) {
|
||||||
LocalDate date = now.minusDays(i).toLocalDate();
|
LocalDate date = now.minusDays(i).toLocalDate();
|
||||||
dates.add(date.format(DateTimeFormatter.ofPattern("MM-dd")));
|
dates.add(date.format(DateTimeFormatter.ofPattern("MM-dd")));
|
||||||
|
|
||||||
// 当天新增学生数
|
// 当天时间范围
|
||||||
LocalDateTime dayStart = date.atStartOfDay();
|
LocalDateTime dayStart = date.atStartOfDay();
|
||||||
LocalDateTime dayEnd = date.plusDays(1).atStartOfDay();
|
LocalDateTime dayEnd = date.plusDays(1).atStartOfDay();
|
||||||
|
|
||||||
Long students = studentMapper.selectCount(
|
// 当天完成的授课次数
|
||||||
new LambdaQueryWrapper<Student>()
|
Long lessons = lessonMapper.selectCount(
|
||||||
.ge(Student::getCreatedAt, dayStart)
|
new LambdaQueryWrapper<Lesson>()
|
||||||
.lt(Student::getCreatedAt, dayEnd)
|
.eq(Lesson::getStatus, LessonStatus.COMPLETED.getCode())
|
||||||
|
.ge(Lesson::getEndDatetime, dayStart)
|
||||||
|
.lt(Lesson::getEndDatetime, dayEnd)
|
||||||
);
|
);
|
||||||
newStudents.add(students != null ? students.intValue() : 0);
|
lessonCounts.add(lessons != null ? lessons.intValue() : 0);
|
||||||
|
|
||||||
// 当天新增教师数
|
// 当天活跃学生数(有完成课程记录的去重学生数)
|
||||||
Long teachers = teacherMapper.selectCount(
|
Long activeStudents = studentRecordMapper.countActiveStudents(dayStart, dayEnd);
|
||||||
new LambdaQueryWrapper<Teacher>()
|
studentCounts.add(activeStudents != null ? activeStudents.intValue() : 0);
|
||||||
.ge(Teacher::getCreatedAt, dayStart)
|
|
||||||
.lt(Teacher::getCreatedAt, dayEnd)
|
|
||||||
);
|
|
||||||
newTeachers.add(teachers != null ? teachers.intValue() : 0);
|
|
||||||
|
|
||||||
// 当天新增课程数
|
|
||||||
Long courses = courseMapper.selectCount(
|
|
||||||
new LambdaQueryWrapper<CoursePackage>()
|
|
||||||
.ge(CoursePackage::getCreatedAt, dayStart)
|
|
||||||
.lt(CoursePackage::getCreatedAt, dayEnd)
|
|
||||||
);
|
|
||||||
newCourses.add(courses != null ? courses.intValue() : 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return StatsTrendResponse.builder()
|
return StatsTrendResponse.builder()
|
||||||
.dates(dates)
|
.dates(dates)
|
||||||
.newStudents(newStudents)
|
.lessonCounts(lessonCounts)
|
||||||
.newTeachers(newTeachers)
|
.studentCounts(studentCounts)
|
||||||
.newCourses(newCourses)
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<ActiveTenantItemResponse> getActiveTenants(ActiveTenantsQueryRequest request) {
|
public List<ActiveTenantItemResponse> getActiveTenants(ActiveTenantsQueryRequest request) {
|
||||||
log.info("获取活跃租户,limit={}", request != null ? request.getLimit() : 10);
|
int limit = request != null && request.getLimit() != null ? request.getLimit() : DEFAULT_TOP_LIMIT;
|
||||||
|
|
||||||
int limit = request != null && request.getLimit() != null ? request.getLimit() : 10;
|
// 计算时间窗口:近 30 天
|
||||||
|
LocalDateTime windowStart = LocalDateTime.now().minusDays(ACTIVE_TEACHER_WINDOW_DAYS);
|
||||||
|
|
||||||
// 查询所有活跃租户
|
// 1. 查询所有状态为 ACTIVE 的租户
|
||||||
List<Tenant> tenants = tenantMapper.selectList(
|
List<Tenant> tenants = tenantMapper.selectList(
|
||||||
new LambdaQueryWrapper<Tenant>()
|
new LambdaQueryWrapper<Tenant>()
|
||||||
.eq(Tenant::getStatus, "ACTIVE")
|
.eq(Tenant::getStatus, TenantStatus.ACTIVE.getCode())
|
||||||
.orderByDesc(Tenant::getUpdatedAt)
|
|
||||||
.last("LIMIT " + limit)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return tenants.stream().map(tenant -> {
|
// 2. 对每个租户统计活跃教师数和完成课次数
|
||||||
// 查询该租户的活跃用户数(教师+学生)
|
List<ActiveTenantItemResponse> resultList = tenants.stream().map(tenant -> {
|
||||||
Long teacherCount = teacherMapper.selectCount(
|
// 2.1 查询该租户下近 30 天完成状态的课程记录
|
||||||
new LambdaQueryWrapper<Teacher>().eq(Teacher::getTenantId, tenant.getId())
|
List<Lesson> lessons = lessonMapper.selectList(
|
||||||
);
|
new LambdaQueryWrapper<Lesson>()
|
||||||
Long studentCount = studentMapper.selectCount(
|
.eq(Lesson::getTenantId, tenant.getId())
|
||||||
new LambdaQueryWrapper<Student>().eq(Student::getTenantId, tenant.getId())
|
.eq(Lesson::getStatus, LessonStatus.COMPLETED.getCode())
|
||||||
|
.ge(Lesson::getEndDatetime, windowStart)
|
||||||
|
.isNotNull(Lesson::getTeacherId)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 查询该租户使用的课程数(通过租户套餐)
|
// 2.2 去重统计活跃教师数(近 30 天有完成课程的老师数)
|
||||||
// 简化处理,返回 0
|
long distinctTeacherCount = lessons.stream()
|
||||||
int courseCount = 0;
|
.map(Lesson::getTeacherId)
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.distinct()
|
||||||
|
.count();
|
||||||
|
|
||||||
|
// 2.3 统计完成课次数(近 30 天 COMPLETED 状态的 lesson 总数)
|
||||||
|
long completedLessonCount = lessons.size();
|
||||||
|
|
||||||
return ActiveTenantItemResponse.builder()
|
return ActiveTenantItemResponse.builder()
|
||||||
.tenantId(tenant.getId())
|
.tenantId(tenant.getId())
|
||||||
.tenantName(tenant.getName())
|
.tenantName(tenant.getName())
|
||||||
.activeUsers((teacherCount != null ? teacherCount.intValue() : 0) +
|
.activeTeacherCount((int) distinctTeacherCount)
|
||||||
(studentCount != null ? studentCount.intValue() : 0))
|
.completedLessonCount((int) completedLessonCount)
|
||||||
.courseCount(courseCount)
|
|
||||||
.build();
|
.build();
|
||||||
}).collect(Collectors.toList());
|
}).collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 3. 按加权分数降序排序:score = activeTeacherCount * 1.0 + completedLessonCount * 0.1
|
||||||
|
resultList.sort((a, b) -> {
|
||||||
|
double scoreA = a.getActiveTeacherCount() * WEIGHT_ACTIVE_TEACHER +
|
||||||
|
a.getCompletedLessonCount() * WEIGHT_COMPLETED_LESSON;
|
||||||
|
double scoreB = b.getActiveTeacherCount() * WEIGHT_ACTIVE_TEACHER +
|
||||||
|
b.getCompletedLessonCount() * WEIGHT_COMPLETED_LESSON;
|
||||||
|
return Double.compare(scoreB, scoreA); // 降序
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 返回 TOP N
|
||||||
|
return resultList.stream()
|
||||||
|
.limit(limit)
|
||||||
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -190,35 +225,4 @@ public class StatsServiceImpl implements StatsService {
|
|||||||
.build()
|
.build()
|
||||||
).collect(Collectors.toList());
|
).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@Override
|
|
||||||
public List<RecentActivityItemResponse> getRecentActivities(RecentActivitiesQueryRequest request) {
|
|
||||||
log.info("获取最近活动,limit={}", request != null ? request.getLimit() : 10);
|
|
||||||
|
|
||||||
int limit = request != null && request.getLimit() != null ? request.getLimit() : 10;
|
|
||||||
|
|
||||||
// 由于没有专门的活动记录表,这里返回空列表
|
|
||||||
// 实际项目中应该从操作日志表获取
|
|
||||||
List<RecentActivityItemResponse> activities = new ArrayList<>();
|
|
||||||
|
|
||||||
// 可以从最近的课程更新中生成一些活动记录
|
|
||||||
List<CoursePackage> recentCourses = courseMapper.selectList(
|
|
||||||
new LambdaQueryWrapper<CoursePackage>()
|
|
||||||
.orderByDesc(CoursePackage::getUpdatedAt)
|
|
||||||
.last("LIMIT " + limit)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (CoursePackage course : recentCourses) {
|
|
||||||
activities.add(RecentActivityItemResponse.builder()
|
|
||||||
.activityId(course.getId())
|
|
||||||
.activityType("COURSE_UPDATE")
|
|
||||||
.description("课程「" + course.getName() + "」已更新")
|
|
||||||
.operatorId(course.getSubmittedBy())
|
|
||||||
.operatorName("系统")
|
|
||||||
.operationTime(course.getUpdatedAt())
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
return activities;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,6 +2,8 @@ package com.reading.platform.service.impl;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.reading.platform.common.enums.CourseStatus;
|
import com.reading.platform.common.enums.CourseStatus;
|
||||||
|
import com.reading.platform.dto.request.CourseUsageQuery;
|
||||||
|
import com.reading.platform.dto.response.*;
|
||||||
import com.reading.platform.entity.Clazz;
|
import com.reading.platform.entity.Clazz;
|
||||||
import com.reading.platform.entity.CoursePackage;
|
import com.reading.platform.entity.CoursePackage;
|
||||||
import com.reading.platform.entity.Lesson;
|
import com.reading.platform.entity.Lesson;
|
||||||
@ -10,12 +12,15 @@ import com.reading.platform.mapper.ClazzMapper;
|
|||||||
import com.reading.platform.mapper.CoursePackageMapper;
|
import com.reading.platform.mapper.CoursePackageMapper;
|
||||||
import com.reading.platform.mapper.LessonMapper;
|
import com.reading.platform.mapper.LessonMapper;
|
||||||
import com.reading.platform.mapper.StudentMapper;
|
import com.reading.platform.mapper.StudentMapper;
|
||||||
|
import com.reading.platform.mapper.struct.TeacherStatsMapperStruct;
|
||||||
import com.reading.platform.service.TeacherStatsService;
|
import com.reading.platform.service.TeacherStatsService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.DayOfWeek;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
@ -33,20 +38,19 @@ public class TeacherStatsServiceImpl implements TeacherStatsService {
|
|||||||
private final LessonMapper lessonMapper;
|
private final LessonMapper lessonMapper;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> getDashboard(Long teacherId, Long tenantId) {
|
public TeacherDashboardResponse getDashboard(Long teacherId, Long tenantId) {
|
||||||
Map<String, Object> dashboard = new HashMap<>();
|
|
||||||
|
|
||||||
// 基础统计
|
// 基础统计
|
||||||
Map<String, Object> stats = new HashMap<>();
|
TeacherDashboardResponse.TeacherStats stats = TeacherDashboardResponse.TeacherStats.builder()
|
||||||
stats.put("classCount", clazzMapper.selectCount(
|
.classCount(clazzMapper.selectCount(
|
||||||
new LambdaQueryWrapper<Clazz>().eq(Clazz::getTenantId, tenantId)
|
new LambdaQueryWrapper<Clazz>().eq(Clazz::getTenantId, tenantId)
|
||||||
));
|
))
|
||||||
stats.put("studentCount", studentMapper.selectCount(
|
.studentCount(studentMapper.selectCount(
|
||||||
new LambdaQueryWrapper<Student>().eq(Student::getTenantId, tenantId)
|
new LambdaQueryWrapper<Student>().eq(Student::getTenantId, tenantId)
|
||||||
));
|
))
|
||||||
stats.put("courseCount", courseMapper.selectCount(
|
.courseCount(courseMapper.selectCount(
|
||||||
new LambdaQueryWrapper<CoursePackage>().eq(CoursePackage::getTenantId, tenantId)
|
new LambdaQueryWrapper<CoursePackage>().eq(CoursePackage::getTenantId, tenantId)
|
||||||
));
|
))
|
||||||
|
.build();
|
||||||
|
|
||||||
// Lesson count (handle missing table gracefully)
|
// Lesson count (handle missing table gracefully)
|
||||||
long lessonCount = 0;
|
long lessonCount = 0;
|
||||||
@ -57,62 +61,31 @@ public class TeacherStatsServiceImpl implements TeacherStatsService {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to query lessons table: {}", e.getMessage());
|
log.warn("Failed to query lessons table: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
stats.put("lessonCount", lessonCount);
|
stats.setLessonCount(lessonCount);
|
||||||
dashboard.put("stats", stats);
|
|
||||||
|
|
||||||
// 今日课程
|
// 今日课程
|
||||||
LocalDate today = LocalDate.now();
|
List<TeacherLessonVO> todayLessons = getTodayLessons(teacherId);
|
||||||
List<Lesson> todayLessons = new ArrayList<>();
|
|
||||||
try {
|
|
||||||
todayLessons = lessonMapper.selectList(
|
|
||||||
new LambdaQueryWrapper<Lesson>()
|
|
||||||
.eq(Lesson::getTeacherId, teacherId)
|
|
||||||
.eq(Lesson::getLessonDate, today)
|
|
||||||
.orderByAsc(Lesson::getStartTime)
|
|
||||||
);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to query today lessons: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
dashboard.put("todayLessons", todayLessons);
|
|
||||||
|
|
||||||
// 推荐课程(热门课程)
|
// 推荐课程(热门课程)
|
||||||
List<CoursePackage> recommendedCourses = courseMapper.selectList(
|
List<CoursePackageVO> recommendedCourses = getRecommendedCourses(tenantId);
|
||||||
new LambdaQueryWrapper<CoursePackage>()
|
|
||||||
.eq(CoursePackage::getTenantId, tenantId)
|
|
||||||
.eq(CoursePackage::getStatus, CourseStatus.PUBLISHED.getCode())
|
|
||||||
.orderByDesc(CoursePackage::getUsageCount)
|
|
||||||
.last("LIMIT 5")
|
|
||||||
);
|
|
||||||
dashboard.put("recommendedCourses", recommendedCourses);
|
|
||||||
|
|
||||||
// 本周统计
|
// 本周统计
|
||||||
Map<String, Object> weeklyStats = new HashMap<>();
|
TeacherWeeklyStatsResponse weeklyStats = getWeeklyStats(teacherId);
|
||||||
LocalDate weekAgo = LocalDate.now().minusDays(7);
|
|
||||||
long weeklyLessonCount = 0;
|
|
||||||
try {
|
|
||||||
weeklyLessonCount = lessonMapper.selectCount(
|
|
||||||
new LambdaQueryWrapper<Lesson>()
|
|
||||||
.eq(Lesson::getTeacherId, teacherId)
|
|
||||||
.ge(Lesson::getLessonDate, weekAgo)
|
|
||||||
);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to query weekly lessons: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
weeklyStats.put("lessonCount", weeklyLessonCount);
|
|
||||||
weeklyStats.put("studentParticipation", 85); // TODO: calculate actual participation
|
|
||||||
weeklyStats.put("avgRating", 4.5); // TODO: calculate actual rating
|
|
||||||
weeklyStats.put("totalDuration", 300); // TODO: calculate actual duration
|
|
||||||
dashboard.put("weeklyStats", weeklyStats);
|
|
||||||
|
|
||||||
// 近期活动
|
// 近期活动(暂空)
|
||||||
List<Map<String, Object>> recentActivities = new ArrayList<>();
|
List<Object> recentActivities = new ArrayList<>();
|
||||||
dashboard.put("recentActivities", recentActivities);
|
|
||||||
|
|
||||||
return dashboard;
|
return TeacherDashboardResponse.builder()
|
||||||
|
.stats(stats)
|
||||||
|
.todayLessons(todayLessons)
|
||||||
|
.recommendedCourses(recommendedCourses)
|
||||||
|
.weeklyStats(weeklyStats)
|
||||||
|
.recentActivities(recentActivities)
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Lesson> getTodayLessons(Long teacherId) {
|
public List<TeacherLessonVO> getTodayLessons(Long teacherId) {
|
||||||
LocalDate today = LocalDate.now();
|
LocalDate today = LocalDate.now();
|
||||||
List<Lesson> lessons = new ArrayList<>();
|
List<Lesson> lessons = new ArrayList<>();
|
||||||
try {
|
try {
|
||||||
@ -125,24 +98,24 @@ public class TeacherStatsServiceImpl implements TeacherStatsService {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to query today lessons: {}", e.getMessage());
|
log.warn("Failed to query today lessons: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
return lessons;
|
return TeacherStatsMapperStruct.INSTANCE.toLessonVOList(lessons);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<CoursePackage> getRecommendedCourses(Long tenantId) {
|
public List<CoursePackageVO> getRecommendedCourses(Long tenantId) {
|
||||||
return courseMapper.selectList(
|
List<CoursePackage> coursePackages = courseMapper.selectList(
|
||||||
new LambdaQueryWrapper<CoursePackage>()
|
new LambdaQueryWrapper<CoursePackage>()
|
||||||
.eq(CoursePackage::getTenantId, tenantId)
|
.eq(CoursePackage::getTenantId, tenantId)
|
||||||
.eq(CoursePackage::getStatus, CourseStatus.PUBLISHED.getCode())
|
.eq(CoursePackage::getStatus, CourseStatus.PUBLISHED.getCode())
|
||||||
.orderByDesc(CoursePackage::getUsageCount)
|
.orderByDesc(CoursePackage::getUsageCount)
|
||||||
.last("LIMIT 10")
|
.last("LIMIT 10")
|
||||||
);
|
);
|
||||||
|
return TeacherStatsMapperStruct.INSTANCE.toCoursePackageVOList(coursePackages);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> getWeeklyStats(Long teacherId) {
|
public TeacherWeeklyStatsResponse getWeeklyStats(Long teacherId) {
|
||||||
LocalDate weekAgo = LocalDate.now().minusDays(7);
|
LocalDate weekAgo = LocalDate.now().minusDays(7);
|
||||||
Map<String, Object> stats = new HashMap<>();
|
|
||||||
long lessonCount = 0;
|
long lessonCount = 0;
|
||||||
try {
|
try {
|
||||||
lessonCount = lessonMapper.selectCount(
|
lessonCount = lessonMapper.selectCount(
|
||||||
@ -153,16 +126,18 @@ public class TeacherStatsServiceImpl implements TeacherStatsService {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to query weekly lesson count: {}", e.getMessage());
|
log.warn("Failed to query weekly lesson count: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
stats.put("lessonCount", lessonCount);
|
|
||||||
stats.put("studentParticipation", 85);
|
return TeacherWeeklyStatsResponse.builder()
|
||||||
stats.put("avgRating", 4.5);
|
.lessonCount(lessonCount)
|
||||||
stats.put("totalDuration", 300);
|
.studentParticipation(85) // TODO: calculate actual participation
|
||||||
return stats;
|
.avgRating(4.5) // TODO: calculate actual rating
|
||||||
|
.totalDuration(300) // TODO: calculate actual duration
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Map<String, Object>> getLessonTrend(Long teacherId, int months) {
|
public List<TeacherLessonTrendVO> getLessonTrend(Long teacherId, int months) {
|
||||||
List<Map<String, Object>> trend = new ArrayList<>();
|
List<TeacherLessonTrendVO> trend = new ArrayList<>();
|
||||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM");
|
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||||
|
|
||||||
for (int i = months - 1; i >= 0; i--) {
|
for (int i = months - 1; i >= 0; i--) {
|
||||||
@ -183,19 +158,19 @@ public class TeacherStatsServiceImpl implements TeacherStatsService {
|
|||||||
log.warn("Failed to query lesson trend for {}: {}", month, e.getMessage());
|
log.warn("Failed to query lesson trend for {}: {}", month, e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> item = new HashMap<>();
|
trend.add(TeacherLessonTrendVO.builder()
|
||||||
item.put("month", month);
|
.month(month)
|
||||||
item.put("lessonCount", lessonCount);
|
.lessonCount(lessonCount)
|
||||||
item.put("avgRating", 4.5); // TODO: calculate actual rating
|
.avgRating(4.5) // TODO: calculate actual rating
|
||||||
trend.add(item);
|
.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
return trend;
|
return trend;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Map<String, Object>> getCourseUsage(Long tenantId) {
|
public List<CourseUsageVO> getCourseUsage(Long tenantId) {
|
||||||
List<Map<String, Object>> usage = new ArrayList<>();
|
List<CourseUsageVO> usage = new ArrayList<>();
|
||||||
|
|
||||||
List<CoursePackage> courses = courseMapper.selectList(
|
List<CoursePackage> courses = courseMapper.selectList(
|
||||||
new LambdaQueryWrapper<CoursePackage>()
|
new LambdaQueryWrapper<CoursePackage>()
|
||||||
@ -205,12 +180,64 @@ public class TeacherStatsServiceImpl implements TeacherStatsService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (CoursePackage course : courses) {
|
for (CoursePackage course : courses) {
|
||||||
Map<String, Object> item = new HashMap<>();
|
usage.add(CourseUsageVO.builder()
|
||||||
item.put("name", course.getName());
|
.name(course.getName())
|
||||||
item.put("value", course.getUsageCount() != null ? course.getUsageCount() : 0);
|
.value(course.getUsageCount() != null ? course.getUsageCount() : 0)
|
||||||
usage.add(item);
|
.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
return usage;
|
return usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<CourseUsageStatsVO> getCourseUsageStats(Long tenantId, Long teacherId, CourseUsageQuery query) {
|
||||||
|
// 计算时间范围
|
||||||
|
LocalDateTime[] timeRange = calculateTimeRange(query);
|
||||||
|
|
||||||
|
// 调用 Mapper 查询
|
||||||
|
return lessonMapper.getCourseUsageStats(tenantId, teacherId, timeRange[0], timeRange[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据周期类型计算时间范围
|
||||||
|
*/
|
||||||
|
private LocalDateTime[] calculateTimeRange(CourseUsageQuery query) {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
LocalDateTime startTime;
|
||||||
|
LocalDateTime endTime = now;
|
||||||
|
|
||||||
|
String periodType = query != null ? query.getPeriodType() : "MONTH";
|
||||||
|
|
||||||
|
switch (periodType) {
|
||||||
|
case "TODAY":
|
||||||
|
startTime = now.toLocalDate().atStartOfDay();
|
||||||
|
break;
|
||||||
|
case "WEEK":
|
||||||
|
// 本周一
|
||||||
|
startTime = now.toLocalDate()
|
||||||
|
.with(DayOfWeek.MONDAY)
|
||||||
|
.atStartOfDay();
|
||||||
|
break;
|
||||||
|
case "MONTH":
|
||||||
|
// 本月 1 号
|
||||||
|
startTime = now.toLocalDate()
|
||||||
|
.withDayOfMonth(1)
|
||||||
|
.atStartOfDay();
|
||||||
|
break;
|
||||||
|
case "CUSTOM":
|
||||||
|
if (query.getStartDate() != null) {
|
||||||
|
startTime = query.getStartDate().atStartOfDay();
|
||||||
|
} else {
|
||||||
|
startTime = now.minusMonths(1);
|
||||||
|
}
|
||||||
|
if (query.getEndDate() != null) {
|
||||||
|
endTime = query.getEndDate().atTime(23, 59, 59);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
startTime = now.toLocalDate().withDayOfMonth(1).atStartOfDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LocalDateTime[]{startTime, endTime};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user