Compare commits

..

2 Commits

Author SHA1 Message Date
En
6e1758a44d Merge remote-tracking branch 'origin/master'
# Conflicts:
#	reading-platform-frontend/openapi.json
#	reading-platform-frontend/src/api/generated/index.ts
#	reading-platform-frontend/src/api/generated/model/index.ts
#	reading-platform-frontend/src/api/generated/model/taskCreateRequest.ts
2026-03-21 12:54:02 +08:00
En
6f64723428 feat: 教师端数据看板与学校端课程统计功能
教师端数据看板:
- 新增 TeacherDashboardResponse/TeacherLessonVO/TeacherLessonTrendVO
- 新增 TeacherWeeklyStatsResponse 周统计响应
- 新增 TeacherActivityLevel 枚举和 TeacherActivityRankResponse 活跃度排行
- 实现教师端课程统计、任务完成详情、任务反馈接口

学校端课程统计:
- 新增 CourseUsageVO/CourseUsageStatsVO/CoursePackageVO
- 新增 SchoolCourseResponse 和学校端课程使用查询接口
- 实现学校端统计数据和课程趋势接口

用户资料功能:
- 新增 UpdateProfileRequest/UpdateProfileResponse
- 实现用户资料更新接口

前后端对齐:
- 更新 OpenAPI 规范和前端 API 类型生成
- 优化 DashboardView 组件和 API 调用

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 12:45:56 +08:00
76 changed files with 3895 additions and 2329 deletions

View File

@ -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`

View File

@ -2,7 +2,8 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(mvn compile:*)", "Bash(mvn compile:*)",
"Bash(sed:*)" "Bash(sed:*)",
"Bash(grep:*)"
] ]
} }
} }

View File

@ -349,7 +349,7 @@
│ │ │ │
│ 📊 平台整体数据 │ │ 📊 平台整体数据 │
│ ┌─────────────┬─────────────┬─────────────┬─────────────┐ │ │ ┌─────────────┬─────────────┬─────────────┬─────────────┐ │
│ │ 租户总数 │ 活跃租户 │ 月授课次数 │ 覆盖学生数 │ │ │ │ 租户总数 │ 活跃租户 │ 月授课次数 │ 学生总数数 │ │
│ │ 1,254所 │ 986所 │ 45,678次 │ 234,567人 │ │ │ │ 1,254所 │ 986所 │ 45,678次 │ 234,567人 │ │
│ └─────────────┴─────────────┴─────────────┴─────────────┘ │ │ └─────────────┴─────────────┴─────────────┴─────────────┘ │
│ │ │ │

View File

@ -54,7 +54,7 @@
- ✅ 账号不存在登录失败 - ✅ 账号不存在登录失败
#### 02-dashboard.spec.ts - 数据看板测试 (7 个用例) #### 02-dashboard.spec.ts - 数据看板测试 (7 个用例)
- ✅ 验证统计卡片显示(租户数、课程包数、月授课次数、覆盖学生) - ✅ 验证统计卡片显示(租户数、课程包数、月授课次数、学生总数
- ✅ 验证趋势图加载 - ✅ 验证趋势图加载
- ✅ 验证活跃租户 TOP5 列表 - ✅ 验证活跃租户 TOP5 列表
- ✅ 验证热门课程包 TOP5 列表 - ✅ 验证热门课程包 TOP5 列表

View 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. 更新测试日志

View 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
View 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. **性能优化**:大数据量时考虑缓存

View File

@ -114,7 +114,7 @@
| 租户总数 | 显示真实数据 | 显示 2 | ✅ | | 租户总数 | 显示真实数据 | 显示 2 | ✅ |
| 课程包总数 | 显示真实数据 | 显示 5 | ✅ | | 课程包总数 | 显示真实数据 | 显示 5 | ✅ |
| 月授课次数 | 显示真实数据 | 显示 22 | ✅ | | 月授课次数 | 显示真实数据 | 显示 22 | ✅ |
| 覆盖学生 | 显示真实数据 | 显示 5 | ✅ | | 学生总数 | 显示真实数据 | 显示 5 | ✅ |
### 3.2 使用趋势图 ### 3.2 使用趋势图
| 测试项 | 预期结果 | 实际结果 | 状态 | | 测试项 | 预期结果 | 实际结果 | 状态 |

View File

@ -51,7 +51,7 @@ reading-platform-frontend/tests/e2e/admin/
| 测试项 | 状态 | 说明 | | 测试项 | 状态 | 说明 |
|--------|------|------| |--------|------|------|
| 验证统计卡片显示 | ✅ | 租户数、课程包数、月授课次数、覆盖学生 | | 验证统计卡片显示 | ✅ | 租户数、课程包数、月授课次数、学生总数 |
| 验证趋势图加载 | ✅ | 验证图表容器显示 | | 验证趋势图加载 | ✅ | 验证图表容器显示 |
| 验证活跃租户 TOP5 列表 | ✅ | 验证列表数据 | | 验证活跃租户 TOP5 列表 | ✅ | 验证列表数据 |
| 验证热门课程包 TOP5 列表 | ✅ | 验证列表数据 | | 验证热门课程包 TOP5 列表 | ✅ | 验证列表数据 |

File diff suppressed because it is too large Load Diff

View File

@ -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 替代
})); }));
}; };

View File

@ -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 },
});
}

View File

@ -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;
} }

View File

@ -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;
}

View File

@ -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;
}

View File

@ -7,5 +7,5 @@
*/ */
export type GetLessonTrend1Params = { export type GetLessonTrend1Params = {
months?: number; days?: number;
}; };

View File

@ -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;
}

View File

@ -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[];
} }

View File

@ -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[];
}; }

View File

@ -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[];
}

View File

@ -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[];
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -22,4 +22,6 @@ export interface StatsResponse {
totalCourses?: number; totalCourses?: number;
/** 课时总数 */ /** 课时总数 */
totalLessons?: number; totalLessons?: number;
/** 月授课次数(本月 COMPLETED 状态的 lesson 总数) */
monthlyLessons?: number;
} }

View File

@ -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[];
} }

View File

@ -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[];
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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');

View File

@ -319,59 +319,69 @@ export function batchSaveStudentRecords(
// ==================== 教师首页 API ==================== // ==================== 教师首页 API ====================
export interface DashboardData { export interface TeacherDashboardStats {
stats: {
classCount: number; classCount: number;
studentCount: number; studentCount: number;
lessonCount: number; lessonCount: number;
courseCount: number; courseCount: number;
}; }
todayLessons: Array<{
export interface TeacherLessonItem {
id: number; id: number;
tenantId: number;
courseId: number; courseId: number;
courseName: string;
pictureBookName?: string;
classId: number; classId: number;
courseName: string;
className: string; className: string;
plannedDatetime: string; teacherId: number;
title: string;
lessonDate: string;
startTime: string;
endTime: string;
location: string;
status: string; status: string;
duration: number; notes?: string;
}>; createdAt: string;
recommendedCourses: Array<{ updatedAt: string;
}
export interface TeacherCoursePackageItem {
id: number; id: number;
name: string; name: string;
pictureBookName?: string; description?: string;
coverImagePath?: string; gradeLevel?: string;
duration: number; courseCount?: number;
usageCount: number; status: string;
avgRating: number; usageCount?: number;
gradeTags: string[]; createdAt: string;
}>; updatedAt: string;
weeklyStats: { }
export interface TeacherWeeklyStatsData {
lessonCount: number; lessonCount: number;
studentParticipation: number; studentParticipation: number;
avgRating: number; avgRating: number;
totalDuration: number; totalDuration: number;
}; }
recentActivities: Array<{
id: number; export interface DashboardData {
type: string; stats: TeacherDashboardStats;
description: string; todayLessons: TeacherLessonItem[];
time: string; recommendedCourses: TeacherCoursePackageItem[];
}>; weeklyStats: TeacherWeeklyStatsData;
} }
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 ====================

View File

@ -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) {

View File

@ -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>

View File

@ -53,40 +53,8 @@
</div> </div>
</div> </div>
<!-- 主要内容区域 --> <!-- 教师活跃度排行全宽 -->
<div class="content-grid"> <div class="teachers-card-full">
<!-- 近期活动 -->
<div class="content-card activities-card">
<div class="card-header">
<span class="card-icon"><CalendarOutlined /></span>
<h3>近期课程活动</h3>
</div>
<div class="card-body" :class="{ 'is-loading': loading }">
<a-spin v-if="loading" />
<div v-else-if="recentActivities.length === 0" class="empty-state">
<span class="empty-icon"><InboxOutlined /></span>
<p>暂无近期活动</p>
</div>
<div v-else class="activity-list">
<div
v-for="item in recentActivities"
:key="item.id"
class="activity-item"
>
<div class="activity-avatar">
<BookOutlined />
</div>
<div class="activity-content">
<div class="activity-title">{{ item.title }}</div>
<div class="activity-time">{{ formatTime(item.time) }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 教师活跃度排行 -->
<div class="content-card teachers-card">
<div class="card-header"> <div class="card-header">
<span class="card-icon"><TrophyOutlined /></span> <span class="card-icon"><TrophyOutlined /></span>
<h3>教师活跃度排行</h3> <h3>教师活跃度排行</h3>
@ -100,17 +68,36 @@
<div v-else class="teacher-list"> <div v-else class="teacher-list">
<div <div
v-for="(item, index) in activeTeachers" v-for="(item, index) in activeTeachers"
:key="item.id" :key="item.teacherId"
class="teacher-item" class="teacher-item"
> >
<div class="rank-badge" :class="'rank-' + (index + 1)"> <div class="rank-badge" :class="'rank-' + (index + 1)">
{{ index + 1 }} {{ index + 1 }}
</div> </div>
<div class="teacher-info"> <div class="teacher-info">
<div class="teacher-name">{{ item.name }}</div> <div class="teacher-name-row">
<div class="teacher-lessons"> <span class="teacher-name">{{ item.teacherName }}</span>
<span class="lesson-icon"><ReadOutlined /></span> <a-tag :color="getActivityLevelColor(item.activityLevelCode)" size="small">
{{ item.activityLevelDesc }}
</a-tag>
</div>
<div class="teacher-details">
<span class="detail-item">
<HomeOutlined />
{{ item.classNames || '未分配班级' }}
</span>
<span class="detail-item">
<ReadOutlined />
授课 {{ item.lessonCount }} 授课 {{ 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 class="teacher-medal"> <div class="teacher-medal">
@ -120,7 +107,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- 课程使用统计 --> <!-- 课程使用统计 -->
<div class="course-stats-card"> <div class="course-stats-card">
@ -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,9 +543,16 @@ const initDistributionChart = (data: CourseDistributionItem[]) => {
} }
// //
const validData = data.map((item, index) => ({ const validData = data.map((item, index) => {
name: item.name || `课程${index + 1}`, // 10
const displayName = item.name && item.name.length > 10
? item.name.substring(0, 10) + '...'
: (item.name || `课程${index + 1}`);
return {
name: displayName,
value: item.value || 0, value: item.value || 0,
fullName: item.name, // tooltip
itemStyle: { itemStyle: {
color: [ color: [
'#FF8C42', '#FF8C42',
@ -530,12 +565,17 @@ const initDistributionChart = (data: CourseDistributionItem[]) => {
'#30cfd0', '#30cfd0',
][index % 8], ][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 {

View File

@ -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 />

View File

@ -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 {

View File

@ -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();
}); });

View File

@ -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;
}
}

View File

@ -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;
}
}
} }

View File

@ -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;
}
} }

View File

@ -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));
}
} }

View File

@ -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());

View File

@ -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")

View File

@ -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));
} }

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
} }

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -33,4 +33,7 @@ public class StatsResponse {
@Schema(description = "课时总数") @Schema(description = "课时总数")
private Long totalLessons; private Long totalLessons;
@Schema(description = "月授课次数(本月 COMPLETED 状态的 lesson 总数)")
private Long monthlyLessons;
} }

View File

@ -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;
} }

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
} }

View File

@ -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);
} }

View File

@ -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);
} }

View File

@ -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);
}

View File

@ -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);
} }

View File

@ -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

View File

@ -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);
} }

View File

@ -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);
} }

View File

@ -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;
}
} }

View File

@ -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;
if (startDate != null) {
startTime = startDate.atStartOfDay();
} else {
// 默认本月 1
startTime = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0);
} }
@Override if (endDate != null) {
public List<Map<String, Object>> getRecentActivities(Long tenantId, int limit) { endTime = endDate.atTime(23, 59, 59);
List<Map<String, Object>> activities = new ArrayList<>(); } else {
// For now, return empty list // 默认今天
return activities; 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;
} }
} }

View File

@ -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;
}
} }

View File

@ -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};
}
} }