feat: 学校端数据报告功能实现
主要变更: 1. 新建 ReportMapper - 数据报告统计查询 - getOverviewStats: 概览统计(教师/学生/班级总数、本月授课次数) - getTeacherReports: 教师教学数据统计 - getCourseReports: 课程使用排行统计 - getStudentReports: 学生学习数据统计 2. 新建 SchoolReportService - 数据报告服务层 - 4 个报告查询接口实现 3. 修改 SchoolStatsController - 调整统计接口参数 - getLessonTrend 改为支持 startDate 和 endDate 参数 4. 前端更新 ReportView.vue - 对接 4 个报告接口 - 优化图表展示和数据表格 - 支持日期范围筛选 5. 更新开发日志和测试记录 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e8219e5aab
commit
c1f5b5085e
@ -304,3 +304,225 @@ onMounted(() => {
|
||||
---
|
||||
|
||||
**今日完成**: 学校端数据导出功能(3 个导出接口)
|
||||
|
||||
---
|
||||
|
||||
## 学校端 - 数据报告功能实现
|
||||
|
||||
### 需求背景
|
||||
|
||||
学校端"数据中心"菜单下的"数据报告"子菜单功能之前未实现。前端 `ReportView.vue` 页面已存在,但调用的 API 返回空数据。需要实现完整的数据报告功能,让学校管理员能够查看:
|
||||
1. **整体概览** - 教师总数、学生总数、班级总数、本月授课次数
|
||||
2. **教师报告** - 教师教学数据统计(授课次数、任务数、评分)
|
||||
3. **课程报告** - 课程使用排行(授课次数、学生数、完成率)
|
||||
4. **学生报告** - 学生学习数据(完成任务、成长记录、出勤率)
|
||||
|
||||
### 实现内容
|
||||
|
||||
#### 1. 新建后端 Mapper
|
||||
|
||||
**ReportMapper.java** - 数据报告 Mapper
|
||||
|
||||
```java
|
||||
@Mapper
|
||||
public interface ReportMapper extends BaseMapper<Lesson> {
|
||||
// 4 个统计查询方法
|
||||
Map<String, Object> getOverviewStats(...) // 概览统计
|
||||
List<Map<String, Object>> getTeacherReports(...) // 教师报告
|
||||
List<Map<String, Object>> getCourseReports(...) // 课程报告
|
||||
List<Map<String, Object>> getStudentReports(...) // 学生报告
|
||||
}
|
||||
```
|
||||
|
||||
**SQL 查询逻辑**:
|
||||
- **概览统计**: 统计教师数、学生数、班级数、本月授课次数、本月任务完成数
|
||||
- **教师报告**: 按教师分组,统计授课次数、任务完成数、平均评分、最后活跃时间
|
||||
- **课程报告**: 按课程分组,统计授课次数、学生数、平均评分、完成率
|
||||
- **学生报告**: 按学生分组,统计任务完成数、成长记录数、出勤率
|
||||
|
||||
#### 2. 新建后端 Service
|
||||
|
||||
**SchoolReportService.java** - 服务接口
|
||||
|
||||
```java
|
||||
public interface SchoolReportService {
|
||||
ReportOverviewResponse getOverview(Long tenantId, LocalDate startDate, LocalDate endDate);
|
||||
List<TeacherReportResponse> getTeacherReports(Long tenantId, LocalDate startDate, LocalDate endDate, int limit);
|
||||
List<CourseReportResponse> getCourseReports(Long tenantId, LocalDate startDate, LocalDate endDate, int limit);
|
||||
List<StudentReportResponse> getStudentReports(Long tenantId, LocalDate startDate, LocalDate endDate, int limit);
|
||||
}
|
||||
```
|
||||
|
||||
**SchoolReportServiceImpl.java** - 服务实现
|
||||
|
||||
```java
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SchoolReportServiceImpl implements SchoolReportService {
|
||||
private final ReportMapper reportMapper;
|
||||
|
||||
@Override
|
||||
public ReportOverviewResponse getOverview(Long tenantId, LocalDate startDate, LocalDate endDate) {
|
||||
// 计算时间范围(默认本月)
|
||||
// 调用 Mapper 查询
|
||||
// 转换为 ReportOverviewResponse 返回
|
||||
}
|
||||
// ... 其他方法
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 修改 Controller
|
||||
|
||||
**SchoolReportController.java** - 添加日期范围参数
|
||||
|
||||
```java
|
||||
@GetMapping("/overview")
|
||||
public Result<ReportOverviewResponse> getOverview(
|
||||
@RequestParam(required = false) LocalDate startDate,
|
||||
@RequestParam(required = false) LocalDate endDate) {
|
||||
return Result.success(schoolReportService.getOverview(tenantId, startDate, endDate));
|
||||
}
|
||||
// ... 其他接口
|
||||
```
|
||||
|
||||
#### 4. 前端 API 对齐
|
||||
|
||||
**src/api/school.ts** - 更新类型定义和 API 函数
|
||||
|
||||
```typescript
|
||||
// 更新接口定义
|
||||
export interface ReportOverview {
|
||||
reportDate: string;
|
||||
totalTeachers: number;
|
||||
totalStudents: number;
|
||||
totalClasses: number;
|
||||
monthlyLessons: number;
|
||||
monthlyTasksCompleted: number;
|
||||
}
|
||||
|
||||
export interface TeacherReport {
|
||||
teacherId: number;
|
||||
teacherName: string;
|
||||
lessonCount: number;
|
||||
taskCount: number;
|
||||
averageRating: number;
|
||||
lastLessonTime?: string;
|
||||
}
|
||||
// ... CourseReport, StudentReport
|
||||
|
||||
// 更新 API 函数支持日期参数
|
||||
export const getReportOverview = (startDate?: string, endDate?: string) => {
|
||||
const params: Record<string, string> = {};
|
||||
if (startDate) params.startDate = startDate;
|
||||
if (endDate) params.endDate = endDate;
|
||||
return http.get<ReportOverview>('/v1/school/reports/overview', { params });
|
||||
};
|
||||
// ... 其他 API
|
||||
```
|
||||
|
||||
#### 5. 前端页面完善
|
||||
|
||||
**ReportView.vue** - 更新数据绑定和显示
|
||||
|
||||
```typescript
|
||||
// 概览数据
|
||||
const overviewData = ref<ReportOverview>({
|
||||
reportDate: '',
|
||||
totalTeachers: 0,
|
||||
totalStudents: 0,
|
||||
totalClasses: 0,
|
||||
monthlyLessons: 0,
|
||||
monthlyTasksCompleted: 0,
|
||||
});
|
||||
|
||||
// 加载数据时传递日期参数
|
||||
const loadData = async () => {
|
||||
const startDate = dateRange.value?.[0]?.format('YYYY-MM-DD');
|
||||
const endDate = dateRange.value?.[1]?.format('YYYY-MM-DD');
|
||||
|
||||
const [overview, teachers, courses, students] = await Promise.all([
|
||||
getReportOverview(startDate, endDate),
|
||||
getTeacherReports(startDate, endDate),
|
||||
getCourseReports(startDate, endDate),
|
||||
getStudentReports(startDate, endDate),
|
||||
]);
|
||||
// ...
|
||||
};
|
||||
|
||||
// 设置默认日期范围为当月
|
||||
onMounted(() => {
|
||||
setDefaultDateRange();
|
||||
loadData();
|
||||
});
|
||||
```
|
||||
|
||||
### 字段映射
|
||||
|
||||
#### 概览卡片
|
||||
| 显示项 | 数据字段 |
|
||||
|--------|---------|
|
||||
| 教师总数 | totalTeachers |
|
||||
| 学生总数 | totalStudents |
|
||||
| 班级总数 | totalClasses |
|
||||
| 本月授课 | monthlyLessons |
|
||||
|
||||
#### 教师报告
|
||||
| 显示项 | 数据字段 |
|
||||
|--------|---------|
|
||||
| 教师姓名 | teacherName |
|
||||
| 授课次数 | lessonCount |
|
||||
| 任务完成数 | taskCount |
|
||||
| 平均评分 | averageRating |
|
||||
| 最后活跃 | lastLessonTime |
|
||||
|
||||
#### 课程报告
|
||||
| 显示项 | 数据字段 |
|
||||
|--------|---------|
|
||||
| 课程名称 | courseName |
|
||||
| 授课次数 | lessonCount |
|
||||
| 参与学生 | studentCount |
|
||||
| 完成率 | completionRate |
|
||||
| 平均评分 | averageRating |
|
||||
|
||||
#### 学生报告
|
||||
| 显示项 | 数据字段 |
|
||||
|--------|---------|
|
||||
| 学生姓名 | studentName |
|
||||
| 班级 | className |
|
||||
| 完成任务 | taskCount |
|
||||
| 成长记录 | growthRecordCount |
|
||||
| 出勤率 | attendanceRate |
|
||||
|
||||
### 功能特性
|
||||
|
||||
1. **日期范围筛选**: 支持选择日期范围,默认统计当月数据
|
||||
2. **实时刷新**: 选择日期范围后自动刷新数据
|
||||
3. **详情弹窗**: 点击教师/课程可查看详细信息
|
||||
4. **响应式设计**: 支持不同屏幕尺寸
|
||||
|
||||
### 文件变更列表
|
||||
|
||||
| 文件 | 变更说明 |
|
||||
|------|---------|
|
||||
| `reading-platform-java/src/main/java/com/reading/platform/mapper/ReportMapper.java` | 新建,4 个统计查询方法 |
|
||||
| `reading-platform-java/src/main/java/com/reading/platform/service/SchoolReportService.java` | 新建,服务接口 |
|
||||
| `reading-platform-java/src/main/java/com/reading/platform/service/impl/SchoolReportServiceImpl.java` | 新建,服务实现 |
|
||||
| `reading-platform-java/src/main/java/com/reading/platform/controller/school/SchoolReportController.java` | 修改,调用 Service |
|
||||
| `reading-platform-frontend/src/api/school.ts` | 更新,类型定义和 API 函数 |
|
||||
| `reading-platform-frontend/src/views/school/ReportView.vue` | 更新,数据绑定和显示 |
|
||||
|
||||
### 测试验证
|
||||
|
||||
- [x] 后端编译通过
|
||||
- [x] ReportView.vue 类型检查通过
|
||||
- [ ] 启动后端服务(端口 8480)
|
||||
- [ ] 启动前端服务(端口 5173)
|
||||
- [ ] 登录学校管理员账号
|
||||
- [ ] 访问数据报告页面
|
||||
- [ ] 验证日期范围筛选
|
||||
- [ ] 验证各 Tab 数据显示
|
||||
|
||||
---
|
||||
|
||||
**今日完成**: 学校端数据报告功能(4 个统计接口 + 前端页面)
|
||||
|
||||
156
docs/test-logs/school/2026-03-22-report-date-range.md
Normal file
156
docs/test-logs/school/2026-03-22-report-date-range.md
Normal file
@ -0,0 +1,156 @@
|
||||
# 测试记录 2026-03-22 - 学校端数据报告日期范围筛选功能
|
||||
|
||||
## 测试背景
|
||||
|
||||
学校端数据中心 - 数据报告页面的时间范围筛选器之前不影响"课程使用趋势"和"教师活跃度"图表数据。本次修复实现了这两个图表根据日期范围筛选器动态加载数据的功能。
|
||||
|
||||
## 修改内容
|
||||
|
||||
### 后端修改
|
||||
|
||||
1. **SchoolStatsController.java**
|
||||
- `getLessonTrend()` - 添加 `startDate` 和 `endDate` 参数
|
||||
- `getActiveTeachers()` - 添加 `startDate` 和 `endDate` 参数
|
||||
- 添加 `@Parameter` 注解导入
|
||||
|
||||
2. **SchoolStatsService.java**
|
||||
- 更新 `getLessonTrend()` 方法签名,使用日期范围参数
|
||||
- 更新 `getActiveTeachers()` 方法签名,使用日期范围参数
|
||||
|
||||
3. **SchoolStatsServiceImpl.java**
|
||||
- 重写 `getLessonTrend()` 方法,支持日期范围遍历统计
|
||||
- 重写 `getActiveTeachers()` 方法,使用日期范围过滤
|
||||
|
||||
4. **LessonMapper.java**
|
||||
- 更新 `getTeacherActivityRank()` 方法,添加 `endTime` 参数
|
||||
- 更新 SQL 查询,添加 `l.end_datetime <= #{endTime}` 条件
|
||||
|
||||
### 前端修改
|
||||
|
||||
1. **src/api/school.ts**
|
||||
- `getLessonTrend()` - 改为接受 `startDate` 和 `endDate` 参数
|
||||
- `getTeacherStats()` - 添加 `startDate` 和 `endDate` 参数
|
||||
|
||||
2. **src/views/school/ReportView.vue**
|
||||
- `loadData()` 函数 - 传递日期范围参数给图表 API
|
||||
|
||||
## 测试验证
|
||||
|
||||
### 1. 后端编译测试
|
||||
|
||||
```bash
|
||||
export JAVA_HOME="/f/Java/jdk-17"
|
||||
cd reading-platform-java
|
||||
mvn clean compile -DskipTests
|
||||
```
|
||||
|
||||
**结果**: ✅ 编译成功
|
||||
|
||||
### 2. API 接口测试
|
||||
|
||||
#### 课程趋势 API(带日期参数)
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8481/api/v1/school/stats/lesson-trend?startDate=2026-03-01&endDate=2026-03-22"
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": [
|
||||
{"date": "03-01", "lessonCount": 0, "studentCount": 0},
|
||||
{"date": "03-02", "lessonCount": 0, "studentCount": 0},
|
||||
...
|
||||
{"date": "03-16", "lessonCount": 2, "studentCount": 5},
|
||||
...
|
||||
{"date": "03-21", "lessonCount": 0, "studentCount": 0}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**结果**: ✅ 返回指定日期范围内的数据
|
||||
|
||||
#### 教师活跃度 API(带日期参数)
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8481/api/v1/school/stats/teachers?startDate=2026-03-01&endDate=2026-03-22&limit=10"
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": [
|
||||
{
|
||||
"teacherId": "1",
|
||||
"teacherName": "李老师",
|
||||
"classNames": "小一班",
|
||||
"lessonCount": 2,
|
||||
"courseCount": 2,
|
||||
"lastActiveAt": "2026-03-16T00:00:00",
|
||||
"activityLevelCode": "LOW",
|
||||
"activityLevelDesc": "低频"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**结果**: ✅ 返回指定日期范围内的数据
|
||||
|
||||
#### 默认参数测试
|
||||
|
||||
```bash
|
||||
# 课程趋势(默认最近 7 天)
|
||||
curl "http://localhost:8481/api/v1/school/stats/lesson-trend"
|
||||
|
||||
# 教师活跃度(默认本月 1 号至今)
|
||||
curl "http://localhost:8481/api/v1/school/stats/teachers?limit=10"
|
||||
```
|
||||
|
||||
**结果**: ✅ 两个接口在不传参数时都使用默认值
|
||||
|
||||
### 3. 前端编译测试
|
||||
|
||||
```bash
|
||||
cd reading-platform-frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
**结果**: ✅ ReportView.vue 无编译错误
|
||||
|
||||
### 4. 前端服务测试
|
||||
|
||||
- 后端服务端口:8481 ✅ 运行中
|
||||
- 前端服务端口:5179 ✅ 运行中
|
||||
- 访问地址:http://localhost:5179/school/reports
|
||||
|
||||
## 功能验证清单
|
||||
|
||||
| 测试项 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| 后端编译 | ✅ | 无编译错误 |
|
||||
| 课程趋势 API | ✅ | 支持日期范围参数 |
|
||||
| 教师活跃度 API | ✅ | 支持日期范围参数 |
|
||||
| 默认参数 | ✅ | 不传参数时使用默认值 |
|
||||
| 前端编译 | ✅ | ReportView.vue 无错误 |
|
||||
| 前端服务 | ✅ | 正常运行 |
|
||||
|
||||
## 已知问题
|
||||
|
||||
无
|
||||
|
||||
## 测试结论
|
||||
|
||||
✅ **测试通过** - 日期范围筛选功能已正确实现,课程使用趋势和教师活跃度图表现在会根据用户选择的日期范围动态加载数据。
|
||||
|
||||
## 后续建议
|
||||
|
||||
1. 可以在日期选择器旁边添加"快捷选项",如"最近 7 天"、"最近 30 天"、"本月"等
|
||||
2. 考虑添加数据导出功能,允许用户导出选定日期范围内的报告数据
|
||||
|
||||
---
|
||||
|
||||
**测试人员**: AI Assistant
|
||||
**测试日期**: 2026-03-22
|
||||
**测试环境**: Windows 10, JDK 17, Node.js
|
||||
@ -673,8 +673,12 @@ export interface CourseDistributionItem {
|
||||
// 后端趋势数据响应(对象数组格式)
|
||||
export type LessonTrendResponse = LessonTrendItem[];
|
||||
|
||||
export const getLessonTrend = (days?: number) =>
|
||||
http.get<LessonTrendResponse>('/v1/school/stats/lesson-trend', { params: { days } });
|
||||
export const getLessonTrend = (startDate?: string, endDate?: string) => {
|
||||
const params: Record<string, string> = {};
|
||||
if (startDate) params.startDate = startDate;
|
||||
if (endDate) params.endDate = endDate;
|
||||
return http.get<LessonTrendResponse>('/v1/school/stats/lesson-trend', { params });
|
||||
};
|
||||
|
||||
export const getCourseDistribution = () =>
|
||||
http.get<CourseDistributionItem[]>('/v1/school/stats/course-distribution');
|
||||
@ -1210,50 +1214,92 @@ export const getSchoolClasses = () =>
|
||||
// ==================== 数据报告 API ====================
|
||||
|
||||
export interface ReportOverview {
|
||||
totalLessons: number;
|
||||
activeTeacherCount: number;
|
||||
usedCourseCount: number;
|
||||
avgRating: number;
|
||||
reportDate: string;
|
||||
totalTeachers: number;
|
||||
totalStudents: number;
|
||||
totalClasses: number;
|
||||
monthlyLessons: number;
|
||||
monthlyTasksCompleted: number;
|
||||
}
|
||||
|
||||
export interface TeacherReport {
|
||||
id: number;
|
||||
name: string;
|
||||
teacherId: number;
|
||||
teacherName: string;
|
||||
lessonCount: number;
|
||||
courseCount: number;
|
||||
feedbackCount: number;
|
||||
avgRating: number;
|
||||
taskCount: number;
|
||||
averageRating: number;
|
||||
lastLessonTime?: string;
|
||||
}
|
||||
|
||||
export interface CourseReport {
|
||||
id: number;
|
||||
name: string;
|
||||
courseId: number;
|
||||
courseName: string;
|
||||
lessonCount: number;
|
||||
teacherCount: number;
|
||||
studentCount: number;
|
||||
avgRating: number;
|
||||
averageRating: number;
|
||||
completionRate: number;
|
||||
}
|
||||
|
||||
export interface StudentReport {
|
||||
id: number;
|
||||
name: string;
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
className: string;
|
||||
lessonCount: number;
|
||||
avgFocus: number;
|
||||
avgParticipation: number;
|
||||
taskCount: number;
|
||||
readingCount: number;
|
||||
growthRecordCount: number;
|
||||
attendanceRate: number;
|
||||
}
|
||||
|
||||
export const getReportOverview = () =>
|
||||
http.get<ReportOverview>('/v1/school/reports/overview');
|
||||
export const getReportOverview = (startDate?: string, endDate?: string) => {
|
||||
const params: Record<string, string> = {};
|
||||
if (startDate) params.startDate = startDate;
|
||||
if (endDate) params.endDate = endDate;
|
||||
return http.get<ReportOverview>('/v1/school/reports/overview', { params });
|
||||
};
|
||||
|
||||
export const getTeacherReports = () =>
|
||||
http.get<TeacherReport[]>('/v1/school/reports/teachers');
|
||||
export const getTeacherReports = (startDate?: string, endDate?: string, limit: number = 50) => {
|
||||
const params: Record<string, string | number> = { limit };
|
||||
if (startDate) params.startDate = startDate;
|
||||
if (endDate) params.endDate = endDate;
|
||||
return http.get<TeacherReport[]>('/v1/school/reports/teachers', { params });
|
||||
};
|
||||
|
||||
export const getCourseReports = () =>
|
||||
http.get<CourseReport[]>('/v1/school/reports/courses');
|
||||
export const getCourseReports = (startDate?: string, endDate?: string, limit: number = 50) => {
|
||||
const params: Record<string, string | number> = { limit };
|
||||
if (startDate) params.startDate = startDate;
|
||||
if (endDate) params.endDate = endDate;
|
||||
return http.get<CourseReport[]>('/v1/school/reports/courses', { params });
|
||||
};
|
||||
|
||||
export const getStudentReports = () =>
|
||||
http.get<StudentReport[]>('/v1/school/reports/students');
|
||||
export const getStudentReports = (startDate?: string, endDate?: string, limit: number = 50) => {
|
||||
const params: Record<string, string | number> = { limit };
|
||||
if (startDate) params.startDate = startDate;
|
||||
if (endDate) params.endDate = endDate;
|
||||
return http.get<StudentReport[]>('/v1/school/reports/students', { params });
|
||||
};
|
||||
|
||||
// ==================== 数据报告 - 图表 API ====================
|
||||
|
||||
export interface TeacherActivityItem {
|
||||
teacherId: number;
|
||||
teacherName: string;
|
||||
classNames: string;
|
||||
lessonCount: number;
|
||||
courseCount: number;
|
||||
lastActiveAt?: string;
|
||||
activityLevelCode: string;
|
||||
activityLevelDesc: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活跃教师排行
|
||||
*/
|
||||
export const getTeacherStats = (startDate?: string, endDate?: string, limit: number = 10) => {
|
||||
const params: Record<string, string | number> = { limit };
|
||||
if (startDate) params.startDate = startDate;
|
||||
if (endDate) params.endDate = endDate;
|
||||
return http.get<TeacherActivityItem[]>('/v1/school/stats/teachers', { params });
|
||||
};
|
||||
|
||||
// ==================== 家长管理 ====================
|
||||
|
||||
|
||||
@ -13,8 +13,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a-range-picker v-model:value="dateRange" :placeholder="['开始日期', '结束日期']" style="width: 240px;" />
|
||||
<a-button class="export-btn">
|
||||
<a-range-picker
|
||||
v-model:value="dateRange"
|
||||
:placeholder="['开始日期', '结束日期']"
|
||||
style="width: 240px;"
|
||||
@change="handleDateRangeChange"
|
||||
/>
|
||||
<a-button class="export-btn" @click="handleExport">
|
||||
<DownloadOutlined class="btn-icon" />
|
||||
导出报告
|
||||
</a-button>
|
||||
@ -27,38 +32,38 @@
|
||||
<div class="overview-cards">
|
||||
<div class="overview-card">
|
||||
<div class="card-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
|
||||
<BookOutlined />
|
||||
<SolutionOutlined />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ totalLessons }}</div>
|
||||
<div class="card-label">总授课次数</div>
|
||||
<div class="card-value">{{ overviewData.totalTeachers }}</div>
|
||||
<div class="card-label">教师总数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-card">
|
||||
<div class="card-icon" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
|
||||
<SolutionOutlined />
|
||||
<UserOutlined />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ activeTeacherCount }}</div>
|
||||
<div class="card-label">活跃教师</div>
|
||||
<div class="card-value">{{ overviewData.totalStudents }}</div>
|
||||
<div class="card-label">学生总数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-card">
|
||||
<div class="card-icon" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);">
|
||||
<ReadOutlined />
|
||||
<HomeOutlined />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ usedCourseCount }}</div>
|
||||
<div class="card-label">使用课程</div>
|
||||
<div class="card-value">{{ overviewData.totalClasses }}</div>
|
||||
<div class="card-label">班级总数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-card">
|
||||
<div class="card-icon" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
|
||||
<StarFilled />
|
||||
<BookOutlined />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-value">{{ avgRating }}</div>
|
||||
<div class="card-label">平均评分</div>
|
||||
<div class="card-value">{{ overviewData.monthlyLessons }}</div>
|
||||
<div class="card-label">本月授课</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -80,18 +85,30 @@
|
||||
<div class="tab-content">
|
||||
<!-- 整体概览 -->
|
||||
<div v-if="activeTab === 'overview'" class="overview-content">
|
||||
<a-spin :spinning="chartLoading">
|
||||
<div class="chart-grid">
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<LineChartOutlined class="chart-icon" />
|
||||
<h4>课程使用趋势</h4>
|
||||
</div>
|
||||
<div class="chart-placeholder">
|
||||
<div class="placeholder-bars">
|
||||
<div class="bar" v-for="i in 7" :key="i" :style="{ height: Math.random() * 80 + 20 + '%' }"></div>
|
||||
<div class="lesson-trend-chart">
|
||||
<div class="trend-bars">
|
||||
<div
|
||||
v-for="(item, index) in lessonTrendData"
|
||||
:key="index"
|
||||
class="trend-bar-wrapper"
|
||||
>
|
||||
<div class="trend-value">{{ item.lessonCount }}</div>
|
||||
<div
|
||||
class="trend-bar"
|
||||
:style="{
|
||||
height: maxLessonCount > 0 ? (item.lessonCount / maxLessonCount * 100) + '%' : '8px',
|
||||
background: item.lessonCount > 0 ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : '#e0e0e0'
|
||||
}"
|
||||
></div>
|
||||
<div class="trend-label">{{ item.date }}</div>
|
||||
</div>
|
||||
<div class="placeholder-labels">
|
||||
<span v-for="day in ['一', '二', '三', '四', '五', '六', '日']" :key="day">周{{ day }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -100,12 +117,18 @@
|
||||
<AimOutlined class="chart-icon" />
|
||||
<h4>教师活跃度</h4>
|
||||
</div>
|
||||
<div class="chart-placeholder circle">
|
||||
<div class="teacher-activity-chart">
|
||||
<div class="circle-chart">
|
||||
<svg viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="40" fill="none" stroke="#F0F0F0" stroke-width="12" />
|
||||
<circle cx="50" cy="50" r="40" fill="none" stroke="url(#gradient1)" stroke-width="12"
|
||||
stroke-dasharray="188 251" stroke-linecap="round" transform="rotate(-90 50 50)" />
|
||||
<circle
|
||||
cx="50" cy="50" r="40" fill="none"
|
||||
stroke="url(#gradient1)"
|
||||
stroke-width="12"
|
||||
:stroke-dasharray="activeRateProgress"
|
||||
stroke-linecap="round"
|
||||
transform="rotate(-90 50 50)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="gradient1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#667eea" />
|
||||
@ -114,29 +137,40 @@
|
||||
</defs>
|
||||
</svg>
|
||||
<div class="circle-text">
|
||||
<span class="percent">75%</span>
|
||||
<span class="percent">{{ teacherActiveRate }}%</span>
|
||||
<span class="label">活跃率</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">活跃教师</span>
|
||||
<span class="stat-value">{{ teacherStats.filter(t => t.lessonCount > 0).length }}人</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">教师总数</span>
|
||||
<span class="stat-value">{{ overviewData.totalTeachers }}人</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 教师报告 -->
|
||||
<div v-if="activeTab === 'teacher'" class="table-content">
|
||||
<div class="teacher-cards" v-if="teacherData.length > 0">
|
||||
<div v-for="teacher in teacherData" :key="teacher.id" class="teacher-report-card">
|
||||
<div v-for="teacher in teacherData" :key="teacher.teacherId" class="teacher-report-card">
|
||||
<div class="teacher-header">
|
||||
<div class="teacher-avatar">
|
||||
<SolutionOutlined />
|
||||
</div>
|
||||
<div class="teacher-info">
|
||||
<div class="teacher-name">{{ teacher.name }}</div>
|
||||
<div class="teacher-name">{{ teacher.teacherName }}</div>
|
||||
<div class="teacher-rating">
|
||||
<StarFilled v-for="i in 5" :key="i" class="star"
|
||||
:class="{ 'filled': i <= Math.round(teacher.avgRating) }" />
|
||||
<span class="rating-value">{{ teacher.avgRating.toFixed(1) }}</span>
|
||||
:class="{ 'filled': i <= Math.round(teacher.averageRating) }" />
|
||||
<span class="rating-value">{{ teacher.averageRating.toFixed(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -148,13 +182,13 @@
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<ReadOutlined class="stat-icon" />
|
||||
<span class="stat-value">{{ teacher.courseCount }}</span>
|
||||
<span class="stat-label">使用课程</span>
|
||||
<span class="stat-value">{{ teacher.taskCount }}</span>
|
||||
<span class="stat-label">任务数</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<MessageOutlined class="stat-icon" />
|
||||
<span class="stat-value">{{ teacher.feedbackCount }}</span>
|
||||
<span class="stat-label">反馈次数</span>
|
||||
<AimOutlined class="stat-icon" />
|
||||
<span class="stat-value">{{ teacher.lastLessonTime ? '活跃' : '未活跃' }}</span>
|
||||
<span class="stat-label">最后活跃</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="teacher-action">
|
||||
@ -171,28 +205,28 @@
|
||||
<!-- 课程报告 -->
|
||||
<div v-if="activeTab === 'course'" class="table-content">
|
||||
<div class="course-report-list" v-if="courseData.length > 0">
|
||||
<div v-for="(course, index) in courseData" :key="course.id" class="course-report-item">
|
||||
<div v-for="(course, index) in courseData" :key="course.courseId" class="course-report-item">
|
||||
<div class="course-rank" :class="'rank-' + (index + 1)">
|
||||
<TrophyOutlined v-if="index < 3" class="rank-icon" :class="'rank-' + (index + 1)" />
|
||||
<span v-else>{{ index + 1 }}</span>
|
||||
</div>
|
||||
<div class="course-info">
|
||||
<div class="course-name">{{ course.name }}</div>
|
||||
<div class="course-name">{{ course.courseName }}</div>
|
||||
<div class="course-stats-inline">
|
||||
<span>
|
||||
<BookOutlined style="margin-right: 4px;" />授课{{ course.lessonCount }}次
|
||||
</span>
|
||||
<span>
|
||||
<SolutionOutlined style="margin-right: 4px;" />{{ course.teacherCount }}位教师
|
||||
<TeamOutlined style="margin-right: 4px;" />{{ course.studentCount }}名学生
|
||||
</span>
|
||||
<span>
|
||||
<TeamOutlined style="margin-right: 4px;" />{{ course.studentCount }}名学生
|
||||
<AimOutlined style="margin-right: 4px;" />完成率{{ course.completionRate?.toFixed(0) || 0 }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="course-rating">
|
||||
<StarFilled class="rating-stars" />
|
||||
<span class="rating-value">{{ course.avgRating.toFixed(1) }}</span>
|
||||
<span class="rating-value">{{ course.averageRating.toFixed(1) }}</span>
|
||||
</div>
|
||||
<div class="course-action">
|
||||
<a-button type="link" size="small" @click="viewCourseDetail(course)">
|
||||
@ -207,40 +241,37 @@
|
||||
<!-- 学生报告 -->
|
||||
<div v-if="activeTab === 'student'" class="table-content">
|
||||
<div class="student-report-grid" v-if="studentData.length > 0">
|
||||
<div v-for="student in studentData" :key="student.id" class="student-report-card">
|
||||
<div v-for="student in studentData" :key="student.studentId" class="student-report-card">
|
||||
<div class="student-header">
|
||||
<div class="student-avatar">
|
||||
<UserOutlined />
|
||||
</div>
|
||||
<div class="student-info">
|
||||
<div class="student-name">{{ student.name }}</div>
|
||||
<div class="student-name">{{ student.studentName }}</div>
|
||||
<div class="student-class">{{ student.className }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="student-stats">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">
|
||||
<BookOutlined style="margin-right: 4px;" />参与课程
|
||||
<BookOutlined style="margin-right: 4px;" />完成任务
|
||||
</span>
|
||||
<span class="stat-value">{{ student.lessonCount }} 次</span>
|
||||
<span class="stat-value">{{ student.taskCount }} 次</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">
|
||||
<AimOutlined style="margin-right: 4px;" />专注度
|
||||
<FileTextOutlined style="margin-right: 4px;" />成长记录
|
||||
</span>
|
||||
<span class="stat-value">{{ student.growthRecordCount }} 条</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">
|
||||
<AimOutlined style="margin-right: 4px;" />出勤率
|
||||
</span>
|
||||
<div class="progress-mini">
|
||||
<div class="progress-fill" :style="{ width: student.avgFocus * 20 + '%' }"></div>
|
||||
<div class="progress-fill" :style="{ width: student.attendanceRate + '%' }"></div>
|
||||
</div>
|
||||
<span class="stat-value">{{ student.avgFocus }}/5</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">
|
||||
<FireOutlined style="margin-right: 4px;" />参与度
|
||||
</span>
|
||||
<div class="progress-mini pink">
|
||||
<div class="progress-fill" :style="{ width: student.avgParticipation * 20 + '%' }"></div>
|
||||
</div>
|
||||
<span class="stat-value">{{ student.avgParticipation }}/5</span>
|
||||
<span class="stat-value">{{ student.attendanceRate?.toFixed(0) || 0 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -251,7 +282,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 教师详情弹窗 -->
|
||||
<a-modal v-model:open="teacherDetailVisible" :title="`${selectedTeacher?.name} - 教师报告详情`" width="600px"
|
||||
<a-modal v-model:open="teacherDetailVisible" :title="`${selectedTeacher?.teacherName} - 教师报告详情`" width="600px"
|
||||
:footer="null">
|
||||
<div v-if="selectedTeacher" class="detail-content">
|
||||
<div class="detail-header">
|
||||
@ -259,10 +290,10 @@
|
||||
<SolutionOutlined />
|
||||
</div>
|
||||
<div class="detail-info">
|
||||
<h3>{{ selectedTeacher.name }}</h3>
|
||||
<h3>{{ selectedTeacher.teacherName }}</h3>
|
||||
<div class="detail-rating">
|
||||
<StarFilled v-for="i in 5" :key="i" :class="{ 'filled': i <= Math.round(selectedTeacher.avgRating) }" />
|
||||
<span class="rating-text">{{ selectedTeacher.avgRating.toFixed(1) }} 分</span>
|
||||
<StarFilled v-for="i in 5" :key="i" :class="{ 'filled': i <= Math.round(selectedTeacher.averageRating) }" />
|
||||
<span class="rating-text">{{ selectedTeacher.averageRating.toFixed(1) }} 分</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -273,12 +304,12 @@
|
||||
<div class="stat-label">授课次数</div>
|
||||
</div>
|
||||
<div class="detail-stat-item">
|
||||
<div class="stat-number">{{ selectedTeacher.courseCount }}</div>
|
||||
<div class="stat-label">使用课程数</div>
|
||||
<div class="stat-number">{{ selectedTeacher.taskCount }}</div>
|
||||
<div class="stat-label">任务完成数</div>
|
||||
</div>
|
||||
<div class="detail-stat-item">
|
||||
<div class="stat-number">{{ selectedTeacher.feedbackCount }}</div>
|
||||
<div class="stat-label">反馈次数</div>
|
||||
<div class="stat-number">{{ selectedTeacher.lastLessonTime ? '活跃' : '未活跃' }}</div>
|
||||
<div class="stat-label">最后活跃</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-divider />
|
||||
@ -287,12 +318,11 @@
|
||||
<BookOutlined style="margin-right: 8px;" />教学概况
|
||||
</h4>
|
||||
<p class="detail-desc">
|
||||
{{ selectedTeacher.name }} 老师共完成 {{ selectedTeacher.lessonCount }} 次授课,
|
||||
使用了 {{ selectedTeacher.courseCount }} 门不同的课程,
|
||||
累计获得 {{ selectedTeacher.feedbackCount }} 次教学反馈。
|
||||
<template v-if="selectedTeacher.avgRating > 0">
|
||||
平均评分为 {{ selectedTeacher.avgRating.toFixed(1) }} 分,
|
||||
{{ selectedTeacher.avgRating >= 4.5 ? '教学效果优秀!' : selectedTeacher.avgRating >= 3.5 ? '教学效果良好。' : '继续努力!'
|
||||
{{ selectedTeacher.teacherName }} 老师共完成 {{ selectedTeacher.lessonCount }} 次授课,
|
||||
参与 {{ selectedTeacher.taskCount }} 次任务评价。
|
||||
<template v-if="selectedTeacher.averageRating > 0">
|
||||
平均评分为 {{ selectedTeacher.averageRating.toFixed(1) }} 分,
|
||||
{{ selectedTeacher.averageRating >= 4.5 ? '教学效果优秀!' : selectedTeacher.averageRating >= 3.5 ? '教学效果良好。' : '继续努力!'
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
@ -304,7 +334,7 @@
|
||||
</a-modal>
|
||||
|
||||
<!-- 课程详情弹窗 -->
|
||||
<a-modal v-model:open="courseDetailVisible" :title="`${selectedCourse?.name} - 课程报告详情`" width="600px"
|
||||
<a-modal v-model:open="courseDetailVisible" :title="`${selectedCourse?.courseName} - 课程报告详情`" width="600px"
|
||||
:footer="null">
|
||||
<div v-if="selectedCourse" class="detail-content">
|
||||
<div class="detail-header">
|
||||
@ -312,10 +342,10 @@
|
||||
<ReadOutlined />
|
||||
</div>
|
||||
<div class="detail-info">
|
||||
<h3>{{ selectedCourse.name }}</h3>
|
||||
<h3>{{ selectedCourse.courseName }}</h3>
|
||||
<div class="detail-rating">
|
||||
<StarFilled v-for="i in 5" :key="i" :class="{ 'filled': i <= Math.round(selectedCourse.avgRating) }" />
|
||||
<span class="rating-text">{{ selectedCourse.avgRating.toFixed(1) }} 分</span>
|
||||
<StarFilled v-for="i in 5" :key="i" :class="{ 'filled': i <= Math.round(selectedCourse.averageRating) }" />
|
||||
<span class="rating-text">{{ selectedCourse.averageRating.toFixed(1) }} 分</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -326,12 +356,12 @@
|
||||
<div class="stat-label">授课次数</div>
|
||||
</div>
|
||||
<div class="detail-stat-item">
|
||||
<div class="stat-number">{{ selectedCourse.teacherCount }}</div>
|
||||
<div class="stat-label">授课教师</div>
|
||||
<div class="stat-number">{{ selectedCourse.studentCount }}</div>
|
||||
<div class="stat-label">参与学生</div>
|
||||
</div>
|
||||
<div class="detail-stat-item">
|
||||
<div class="stat-number">{{ selectedCourse.studentCount }}</div>
|
||||
<div class="stat-label">学生总数</div>
|
||||
<div class="stat-number">{{ selectedCourse.completionRate?.toFixed(0) || 0 }}%</div>
|
||||
<div class="stat-label">完成率</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-divider />
|
||||
@ -340,12 +370,12 @@
|
||||
<ReadOutlined style="margin-right: 8px;" />课程概况
|
||||
</h4>
|
||||
<p class="detail-desc">
|
||||
《{{ selectedCourse.name }}》共被授课 {{ selectedCourse.lessonCount }} 次,
|
||||
有 {{ selectedCourse.teacherCount }} 位教师使用该课程进行教学,
|
||||
累计覆盖 {{ selectedCourse.studentCount }} 名学生。
|
||||
<template v-if="selectedCourse.avgRating > 0">
|
||||
课程平均评分为 {{ selectedCourse.avgRating.toFixed(1) }} 分,
|
||||
{{ selectedCourse.avgRating >= 4.5 ? '深受师生好评!' : selectedCourse.avgRating >= 3.5 ? '反馈良好。' : '有待改进。' }}
|
||||
《{{ selectedCourse.courseName }}》共被授课 {{ selectedCourse.lessonCount }} 次,
|
||||
累计覆盖 {{ selectedCourse.studentCount }} 名学生,
|
||||
课程完成率 {{ selectedCourse.completionRate?.toFixed(0) || 0 }}%。
|
||||
<template v-if="selectedCourse.averageRating > 0">
|
||||
课程平均评分为 {{ selectedCourse.averageRating.toFixed(1) }} 分,
|
||||
{{ selectedCourse.averageRating >= 4.5 ? '深受师生好评!' : selectedCourse.averageRating >= 3.5 ? '反馈良好。' : '有待改进。' }}
|
||||
</template>
|
||||
<template v-else>
|
||||
暂无评分数据。
|
||||
@ -370,21 +400,25 @@ import {
|
||||
StarFilled,
|
||||
LineChartOutlined,
|
||||
AimOutlined,
|
||||
MessageOutlined,
|
||||
FileTextOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
TrophyOutlined,
|
||||
FireOutlined,
|
||||
HomeOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
getReportOverview,
|
||||
getTeacherReports,
|
||||
getCourseReports,
|
||||
getStudentReports,
|
||||
getLessonTrend,
|
||||
getTeacherStats,
|
||||
type ReportOverview,
|
||||
type TeacherReport,
|
||||
type CourseReport,
|
||||
type StudentReport,
|
||||
type LessonTrendItem,
|
||||
type TeacherActivityItem,
|
||||
} from '@/api/school';
|
||||
|
||||
const activeTab = ref('overview');
|
||||
@ -399,40 +433,80 @@ const tabs = [
|
||||
];
|
||||
|
||||
// 概览数据
|
||||
const overviewData = ref({
|
||||
totalLessons: 0,
|
||||
activeTeacherCount: 0,
|
||||
usedCourseCount: 0,
|
||||
avgRating: 0,
|
||||
const overviewData = ref<ReportOverview>({
|
||||
reportDate: '',
|
||||
totalTeachers: 0,
|
||||
totalStudents: 0,
|
||||
totalClasses: 0,
|
||||
monthlyLessons: 0,
|
||||
monthlyTasksCompleted: 0,
|
||||
});
|
||||
|
||||
const teacherData = ref<TeacherReport[]>([]);
|
||||
const courseData = ref<CourseReport[]>([]);
|
||||
const studentData = ref<StudentReport[]>([]);
|
||||
|
||||
const totalLessons = computed(() => overviewData.value.totalLessons);
|
||||
const activeTeacherCount = computed(() => overviewData.value.activeTeacherCount);
|
||||
const usedCourseCount = computed(() => overviewData.value.usedCourseCount);
|
||||
const avgRating = computed(() => (overviewData.value.avgRating || 0).toFixed(1));
|
||||
// 图表数据
|
||||
const lessonTrendData = ref<LessonTrendItem[]>([]);
|
||||
const teacherStats = ref<TeacherActivityItem[]>([]);
|
||||
const chartLoading = ref(false);
|
||||
|
||||
// 计算课程趋势最高值用于柱状图比例
|
||||
const maxLessonCount = computed(() => {
|
||||
if (lessonTrendData.value.length === 0) return 0;
|
||||
return Math.max(...lessonTrendData.value.map(item => item.lessonCount), 1);
|
||||
});
|
||||
|
||||
// 计算教师活跃率
|
||||
const teacherActiveRate = computed(() => {
|
||||
if (teacherStats.value.length === 0) return 0;
|
||||
// 需要获取教师总数来计算活跃率
|
||||
const activeTeachers = teacherStats.value.filter(t => t.lessonCount > 0).length;
|
||||
const totalTeachers = overviewData.value.totalTeachers || teacherStats.value.length;
|
||||
return Math.round((activeTeachers / totalTeachers) * 100);
|
||||
});
|
||||
|
||||
// 计算环形进度条的 stroke-dasharray 值
|
||||
const activeRateProgress = computed(() => {
|
||||
const circumference = 2 * Math.PI * 40; // r=40
|
||||
const progress = (teacherActiveRate.value / 100) * circumference;
|
||||
return `${progress} ${circumference - progress}`;
|
||||
});
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
chartLoading.value = true;
|
||||
try {
|
||||
const [overview, teachers, courses, students] = await Promise.all([
|
||||
getReportOverview(),
|
||||
getTeacherReports(),
|
||||
getCourseReports(),
|
||||
getStudentReports(),
|
||||
const startDate = dateRange.value?.[0]?.format('YYYY-MM-DD');
|
||||
const endDate = dateRange.value?.[1]?.format('YYYY-MM-DD');
|
||||
|
||||
console.log('加载数据,日期范围:', startDate, 'to', endDate);
|
||||
|
||||
const [overview, teachers, courses, students, lessonTrend, teacherStatsData] = await Promise.all([
|
||||
getReportOverview(startDate, endDate),
|
||||
getTeacherReports(startDate, endDate),
|
||||
getCourseReports(startDate, endDate),
|
||||
getStudentReports(startDate, endDate),
|
||||
getLessonTrend(startDate, endDate),
|
||||
getTeacherStats(startDate, endDate, 10),
|
||||
]);
|
||||
|
||||
console.log('课程趋势数据:', lessonTrend);
|
||||
console.log('教师活跃度数据:', teacherStatsData);
|
||||
|
||||
overviewData.value = overview;
|
||||
teacherData.value = teachers;
|
||||
courseData.value = courses;
|
||||
studentData.value = students;
|
||||
lessonTrendData.value = lessonTrend;
|
||||
teacherStats.value = teacherStatsData;
|
||||
} catch (error: any) {
|
||||
console.error('加载数据失败:', error);
|
||||
message.error(error.response?.data?.message || '加载数据失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
chartLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@ -454,7 +528,31 @@ const viewCourseDetail = (course: CourseReport) => {
|
||||
courseDetailVisible.value = true;
|
||||
};
|
||||
|
||||
// 导出报告功能
|
||||
const handleExport = () => {
|
||||
message.info('导出功能开发中,敬请期待...');
|
||||
// TODO: 实现导出功能,调用后端导出接口
|
||||
};
|
||||
|
||||
// 设置默认日期范围为当月
|
||||
const setDefaultDateRange = () => {
|
||||
const now = dayjs();
|
||||
const monthStart = now.startOf('month');
|
||||
const monthEnd = now.endOf('month');
|
||||
dateRange.value = [monthStart, monthEnd];
|
||||
};
|
||||
|
||||
// 处理日期范围变化
|
||||
const handleDateRangeChange = (dates: any) => {
|
||||
console.log('日期范围变化:', dates);
|
||||
if (dates && dates.length === 2) {
|
||||
dateRange.value = [dates[0], dates[1]];
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setDefaultDateRange();
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
@ -669,31 +767,96 @@ onMounted(() => {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.placeholder-bars {
|
||||
/* 课程使用趋势图表 */
|
||||
.lesson-trend-chart {
|
||||
height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.trend-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-around;
|
||||
height: 200px;
|
||||
padding: 0 20px;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
padding: 0 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.placeholder-bars .bar {
|
||||
width: 30px;
|
||||
background: linear-gradient(180deg, #FF8C42 0%, #FFB347 100%);
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.placeholder-labels {
|
||||
.trend-bar-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 12px 20px 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.trend-bar {
|
||||
width: 24px;
|
||||
min-height: 4px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.trend-label {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: #636E72;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trend-value {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #2D3436;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 教师活跃度图表 */
|
||||
.teacher-activity-chart {
|
||||
height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.activity-stats {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
padding: 16px 24px;
|
||||
background: #F8F9FA;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.activity-stats .stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.activity-stats .stat-label {
|
||||
font-size: 12px;
|
||||
color: #636E72;
|
||||
}
|
||||
|
||||
.chart-placeholder.circle {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.activity-stats .stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #2D3436;
|
||||
}
|
||||
|
||||
.circle-chart {
|
||||
|
||||
@ -8,13 +8,15 @@ import com.reading.platform.dto.response.CourseReportResponse;
|
||||
import com.reading.platform.dto.response.ReportOverviewResponse;
|
||||
import com.reading.platform.dto.response.StudentReportResponse;
|
||||
import com.reading.platform.dto.response.TeacherReportResponse;
|
||||
import com.reading.platform.service.SchoolReportService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@ -27,43 +29,55 @@ import java.util.List;
|
||||
@RequireRole(UserRole.SCHOOL)
|
||||
public class SchoolReportController {
|
||||
|
||||
private final SchoolReportService schoolReportService;
|
||||
|
||||
@GetMapping("/overview")
|
||||
@Operation(summary = "获取报告概览")
|
||||
public Result<ReportOverviewResponse> getOverview() {
|
||||
public Result<ReportOverviewResponse> getOverview(
|
||||
@Parameter(description = "开始日期(YYYY-MM-DD),默认本月 1 号")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@Parameter(description = "结束日期(YYYY-MM-DD),默认今天")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
|
||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||
// TODO: 实现报告概览,根据 tenantId 查询
|
||||
return Result.success(ReportOverviewResponse.builder()
|
||||
.reportDate(LocalDate.now())
|
||||
.totalTeachers(0)
|
||||
.totalStudents(0)
|
||||
.totalClasses(0)
|
||||
.monthlyLessons(0)
|
||||
.monthlyTasksCompleted(0)
|
||||
.courseStats(new HashMap<>())
|
||||
.build());
|
||||
return Result.success(schoolReportService.getOverview(tenantId, startDate, endDate));
|
||||
}
|
||||
|
||||
@GetMapping("/teachers")
|
||||
@Operation(summary = "获取教师报告")
|
||||
public Result<List<TeacherReportResponse>> getTeacherReports() {
|
||||
public Result<List<TeacherReportResponse>> getTeacherReports(
|
||||
@Parameter(description = "开始日期(YYYY-MM-DD),默认本月 1 号")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@Parameter(description = "结束日期(YYYY-MM-DD),默认今天")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@Parameter(description = "返回数量限制,默认 50")
|
||||
@RequestParam(defaultValue = "50") int limit) {
|
||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||
// TODO: 实现教师报告,根据 tenantId 查询
|
||||
return Result.success(List.of());
|
||||
return Result.success(schoolReportService.getTeacherReports(tenantId, startDate, endDate, limit));
|
||||
}
|
||||
|
||||
@GetMapping("/courses")
|
||||
@Operation(summary = "获取课程报告")
|
||||
public Result<List<CourseReportResponse>> getCourseReports() {
|
||||
public Result<List<CourseReportResponse>> getCourseReports(
|
||||
@Parameter(description = "开始日期(YYYY-MM-DD),默认本月 1 号")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@Parameter(description = "结束日期(YYYY-MM-DD),默认今天")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@Parameter(description = "返回数量限制,默认 50")
|
||||
@RequestParam(defaultValue = "50") int limit) {
|
||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||
// TODO: 实现课程报告,根据 tenantId 查询
|
||||
return Result.success(List.of());
|
||||
return Result.success(schoolReportService.getCourseReports(tenantId, startDate, endDate, limit));
|
||||
}
|
||||
|
||||
@GetMapping("/students")
|
||||
@Operation(summary = "获取学生报告")
|
||||
public Result<List<StudentReportResponse>> getStudentReports() {
|
||||
public Result<List<StudentReportResponse>> getStudentReports(
|
||||
@Parameter(description = "开始日期(YYYY-MM-DD),默认本月 1 号")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@Parameter(description = "结束日期(YYYY-MM-DD),默认今天")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@Parameter(description = "返回数量限制,默认 50")
|
||||
@RequestParam(defaultValue = "50") int limit) {
|
||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||
// TODO: 实现学生报告,根据 tenantId 查询
|
||||
return Result.success(List.of());
|
||||
return Result.success(schoolReportService.getStudentReports(tenantId, startDate, endDate, limit));
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import com.reading.platform.common.security.SecurityUtils;
|
||||
import com.reading.platform.dto.response.TeacherActivityRankResponse;
|
||||
import com.reading.platform.service.SchoolStatsService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
@ -35,9 +36,14 @@ public class SchoolStatsController {
|
||||
@GetMapping("/teachers")
|
||||
@Operation(summary = "获取活跃教师排行")
|
||||
public Result<List<TeacherActivityRankResponse>> getActiveTeachers(
|
||||
@Parameter(description = "开始日期(YYYY-MM-DD),默认本月 1 号")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@Parameter(description = "结束日期(YYYY-MM-DD),默认今天")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@Parameter(description = "返回数量限制,默认 10")
|
||||
@RequestParam(defaultValue = "10") int limit) {
|
||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||
return Result.success(schoolStatsService.getActiveTeachers(tenantId, limit));
|
||||
return Result.success(schoolStatsService.getActiveTeachers(tenantId, startDate, endDate, limit));
|
||||
}
|
||||
|
||||
@GetMapping("/courses")
|
||||
@ -52,9 +58,12 @@ public class SchoolStatsController {
|
||||
@GetMapping("/lesson-trend")
|
||||
@Operation(summary = "获取授课趋势")
|
||||
public Result<List<Map<String, Object>>> getLessonTrend(
|
||||
@RequestParam(defaultValue = "7") int days) {
|
||||
@Parameter(description = "开始日期(YYYY-MM-DD),默认 7 天前")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@Parameter(description = "结束日期(YYYY-MM-DD),默认今天")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
|
||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||
return Result.success(schoolStatsService.getLessonTrend(tenantId, days));
|
||||
return Result.success(schoolStatsService.getLessonTrend(tenantId, startDate, endDate));
|
||||
}
|
||||
|
||||
@GetMapping("/course-distribution")
|
||||
|
||||
@ -20,7 +20,7 @@ public interface LessonMapper extends BaseMapper<Lesson> {
|
||||
* 获取学校端教师活跃度排行
|
||||
* <p>
|
||||
* 统计逻辑:
|
||||
* 1. 统计周期:自然月内(由 startTime 参数控制,传入当月 1 号)
|
||||
* 1. 统计周期:由 startTime 和 endTime 参数控制
|
||||
* 2. 授课次数:统计周期内 COMPLETED 状态的 lesson 数量
|
||||
* 3. 课程数:统计周期内上过的不同课程(course_id)数量
|
||||
* 4. 最后活跃时间:统计周期内最近一次授课完成时间
|
||||
@ -28,7 +28,8 @@ public interface LessonMapper extends BaseMapper<Lesson> {
|
||||
* 6. 包含负责班级名称(逗号分隔)
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param startTime 统计周期开始时间(自然月 1 号)
|
||||
* @param startTime 统计周期开始时间
|
||||
* @param endTime 统计周期结束时间
|
||||
* @param limit 返回数量限制
|
||||
* @return 教师活跃度排行列表
|
||||
*/
|
||||
@ -44,6 +45,7 @@ public interface LessonMapper extends BaseMapper<Lesson> {
|
||||
"LEFT JOIN lesson l ON t.id = l.teacher_id " +
|
||||
" AND l.status = 'COMPLETED' " +
|
||||
" AND l.end_datetime >= #{startTime} " +
|
||||
" AND l.end_datetime <= #{endTime} " +
|
||||
"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} " +
|
||||
@ -56,6 +58,7 @@ public interface LessonMapper extends BaseMapper<Lesson> {
|
||||
List<Map<String, Object>> getTeacherActivityRank(
|
||||
@Param("tenantId") Long tenantId,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime,
|
||||
@Param("limit") int limit
|
||||
);
|
||||
|
||||
|
||||
@ -0,0 +1,171 @@
|
||||
package com.reading.platform.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.reading.platform.dto.response.CourseReportResponse;
|
||||
import com.reading.platform.dto.response.StudentReportResponse;
|
||||
import com.reading.platform.dto.response.TeacherReportResponse;
|
||||
import com.reading.platform.entity.Lesson;
|
||||
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 ReportMapper extends BaseMapper<Lesson> {
|
||||
|
||||
/**
|
||||
* 获取报告概览统计数据
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param startTime 统计开始时间
|
||||
* @param endTime 统计结束时间
|
||||
* @return 概览统计数据
|
||||
*/
|
||||
@Select("<script>" +
|
||||
"SELECT " +
|
||||
" (SELECT COUNT(*) FROM teacher WHERE tenant_id = #{tenantId} AND deleted = 0 AND status = 'ACTIVE') AS totalTeachers, " +
|
||||
" (SELECT COUNT(*) FROM student WHERE tenant_id = #{tenantId} AND deleted = 0) AS totalStudents, " +
|
||||
" (SELECT COUNT(*) FROM clazz WHERE tenant_id = #{tenantId} AND deleted = 0) AS totalClasses, " +
|
||||
" (SELECT COUNT(*) FROM lesson WHERE tenant_id = #{tenantId} AND deleted = 0 AND status = 'COMPLETED' " +
|
||||
" AND end_datetime >= #{startTime} AND end_datetime <= #{endTime}) AS monthlyLessons, " +
|
||||
" (SELECT COUNT(*) FROM task_completion tc " +
|
||||
" INNER JOIN task t ON tc.task_id = t.id " +
|
||||
" WHERE t.tenant_id = #{tenantId} AND tc.deleted = 0 " +
|
||||
" AND tc.created_at >= #{startTime} AND tc.created_at <= #{endTime}) AS monthlyTasksCompleted " +
|
||||
"FROM DUAL" +
|
||||
"</script>")
|
||||
Map<String, Object> getOverviewStats(
|
||||
@Param("tenantId") Long tenantId,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取教师报告统计
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param startTime 统计开始时间
|
||||
* @param endTime 统计结束时间
|
||||
* @param limit 返回数量限制
|
||||
* @return 教师报告列表
|
||||
*/
|
||||
@Select("<script>" +
|
||||
"SELECT " +
|
||||
" t.id AS teacherId, " +
|
||||
" t.name AS teacherName, " +
|
||||
" COUNT(DISTINCT l.id) AS lessonCount, " +
|
||||
" COALESCE(AVG(sr.participation), 0) AS averageRating, " +
|
||||
" MAX(l.end_datetime) AS lastLessonTime, " +
|
||||
" COUNT(DISTINCT tc.id) AS taskCount " +
|
||||
"FROM teacher t " +
|
||||
"LEFT JOIN lesson l ON t.id = l.teacher_id " +
|
||||
" AND l.deleted = 0 AND l.status = 'COMPLETED' " +
|
||||
" AND l.end_datetime >= #{startTime} AND l.end_datetime <= #{endTime} " +
|
||||
"LEFT JOIN student_record sr ON l.id = sr.lesson_id " +
|
||||
" AND sr.deleted = 0 " +
|
||||
"LEFT JOIN student s ON sr.student_id = s.id " +
|
||||
" AND s.deleted = 0 " +
|
||||
"LEFT JOIN parent_student ps ON s.id = ps.student_id " +
|
||||
" AND ps.deleted = 0 " +
|
||||
"LEFT JOIN task_completion tc ON ps.student_id = tc.student_id " +
|
||||
" AND tc.deleted = 0 " +
|
||||
" AND tc.created_at >= #{startTime} AND tc.created_at <= #{endTime} " +
|
||||
"WHERE t.tenant_id = #{tenantId} " +
|
||||
" AND t.deleted = 0 " +
|
||||
" AND t.status = 'ACTIVE' " +
|
||||
"GROUP BY t.id, t.name " +
|
||||
"ORDER BY lessonCount DESC, lastLessonTime DESC " +
|
||||
"LIMIT #{limit}" +
|
||||
"</script>")
|
||||
List<Map<String, Object>> getTeacherReports(
|
||||
@Param("tenantId") Long tenantId,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime,
|
||||
@Param("limit") int limit
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取课程报告统计
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param startTime 统计开始时间
|
||||
* @param endTime 统计结束时间
|
||||
* @param limit 返回数量限制
|
||||
* @return 课程报告列表
|
||||
*/
|
||||
@Select("<script>" +
|
||||
"SELECT " +
|
||||
" cp.id AS courseId, " +
|
||||
" cp.name AS courseName, " +
|
||||
" COUNT(DISTINCT l.id) AS lessonCount, " +
|
||||
" COUNT(DISTINCT sr.student_id) AS studentCount, " +
|
||||
" COALESCE(AVG(sr.participation), 0) AS averageRating, " +
|
||||
" CAST(COUNT(DISTINCT CASE WHEN l.status = 'COMPLETED' THEN l.id END) AS DECIMAL(10,2)) / " +
|
||||
" NULLIF(COUNT(DISTINCT l.id), 0) * 100 AS completionRate " +
|
||||
"FROM course_package cp " +
|
||||
"LEFT JOIN lesson l ON cp.id = l.course_id " +
|
||||
" AND l.deleted = 0 " +
|
||||
" AND l.end_datetime >= #{startTime} AND l.end_datetime <= #{endTime} " +
|
||||
"LEFT JOIN student_record sr ON l.id = sr.lesson_id " +
|
||||
" AND sr.deleted = 0 " +
|
||||
"WHERE cp.tenant_id = #{tenantId} " +
|
||||
" AND cp.deleted = 0 " +
|
||||
"GROUP BY cp.id, cp.name " +
|
||||
"ORDER BY lessonCount DESC " +
|
||||
"LIMIT #{limit}" +
|
||||
"</script>")
|
||||
List<Map<String, Object>> getCourseReports(
|
||||
@Param("tenantId") Long tenantId,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime,
|
||||
@Param("limit") int limit
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取学生报告统计
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param startTime 统计开始时间
|
||||
* @param endTime 统计结束时间
|
||||
* @param limit 返回数量限制
|
||||
* @return 学生报告列表
|
||||
*/
|
||||
@Select("<script>" +
|
||||
"SELECT " +
|
||||
" s.id AS studentId, " +
|
||||
" s.name AS studentName, " +
|
||||
" s.grade AS className, " +
|
||||
" COUNT(DISTINCT tc.id) AS taskCount, " +
|
||||
" 0 AS readingCount, " +
|
||||
" COUNT(DISTINCT gr.id) AS growthRecordCount, " +
|
||||
" CAST(COUNT(DISTINCT CASE WHEN sr.attendance = 'present' THEN sr.id END) AS DECIMAL(10,2)) / " +
|
||||
" NULLIF(COUNT(DISTINCT sr.id), 0) * 100 AS attendanceRate " +
|
||||
"FROM student s " +
|
||||
"LEFT JOIN task_completion tc ON s.id = tc.student_id " +
|
||||
" AND tc.deleted = 0 " +
|
||||
" AND tc.created_at >= #{startTime} AND tc.created_at <= #{endTime} " +
|
||||
"LEFT JOIN growth_record gr ON s.id = gr.student_id " +
|
||||
" AND gr.deleted = 0 " +
|
||||
" AND gr.created_at >= #{startTime} AND gr.created_at <= #{endTime} " +
|
||||
"LEFT JOIN student_record sr ON s.id = sr.student_id " +
|
||||
" AND sr.deleted = 0 " +
|
||||
"WHERE s.tenant_id = #{tenantId} " +
|
||||
" AND s.deleted = 0 " +
|
||||
"GROUP BY s.id, s.name, s.grade " +
|
||||
"ORDER BY taskCount DESC " +
|
||||
"LIMIT #{limit}" +
|
||||
"</script>")
|
||||
List<Map<String, Object>> getStudentReports(
|
||||
@Param("tenantId") Long tenantId,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime,
|
||||
@Param("limit") int limit
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package com.reading.platform.service;
|
||||
|
||||
import com.reading.platform.dto.response.CourseReportResponse;
|
||||
import com.reading.platform.dto.response.ReportOverviewResponse;
|
||||
import com.reading.platform.dto.response.StudentReportResponse;
|
||||
import com.reading.platform.dto.response.TeacherReportResponse;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 学校端 - 数据报告服务接口
|
||||
*/
|
||||
public interface SchoolReportService {
|
||||
|
||||
/**
|
||||
* 获取报告概览
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param startDate 开始日期(可选,默认本月 1 号)
|
||||
* @param endDate 结束日期(可选,默认今天)
|
||||
* @return 报告概览数据
|
||||
*/
|
||||
ReportOverviewResponse getOverview(Long tenantId, LocalDate startDate, LocalDate endDate);
|
||||
|
||||
/**
|
||||
* 获取教师报告列表
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param startDate 开始日期(可选,默认本月 1 号)
|
||||
* @param endDate 结束日期(可选,默认今天)
|
||||
* @param limit 返回数量限制
|
||||
* @return 教师报告列表
|
||||
*/
|
||||
List<TeacherReportResponse> getTeacherReports(Long tenantId, LocalDate startDate, LocalDate endDate, int limit);
|
||||
|
||||
/**
|
||||
* 获取课程报告列表
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param startDate 开始日期(可选,默认本月 1 号)
|
||||
* @param endDate 结束日期(可选,默认今天)
|
||||
* @param limit 返回数量限制
|
||||
* @return 课程报告列表
|
||||
*/
|
||||
List<CourseReportResponse> getCourseReports(Long tenantId, LocalDate startDate, LocalDate endDate, int limit);
|
||||
|
||||
/**
|
||||
* 获取学生报告列表
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param startDate 开始日期(可选,默认本月 1 号)
|
||||
* @param endDate 结束日期(可选,默认今天)
|
||||
* @param limit 返回数量限制
|
||||
* @return 学生报告列表
|
||||
*/
|
||||
List<StudentReportResponse> getStudentReports(Long tenantId, LocalDate startDate, LocalDate endDate, int limit);
|
||||
}
|
||||
@ -20,10 +20,12 @@ public interface SchoolStatsService {
|
||||
* 获取活跃教师排行(自然月统计)
|
||||
*
|
||||
* @param tenantId 租户 ID
|
||||
* @param startDate 开始日期(可选,默认本月 1 号)
|
||||
* @param endDate 结束日期(可选,默认今天)
|
||||
* @param limit 返回数量限制
|
||||
* @return 教师活跃度排行列表
|
||||
*/
|
||||
List<TeacherActivityRankResponse> getActiveTeachers(Long tenantId, int limit);
|
||||
List<TeacherActivityRankResponse> getActiveTeachers(Long tenantId, LocalDate startDate, LocalDate endDate, int limit);
|
||||
|
||||
/**
|
||||
* Get course usage statistics
|
||||
@ -34,9 +36,12 @@ public interface SchoolStatsService {
|
||||
List<Map<String, Object>> getCourseUsageStats(Long tenantId, LocalDate startDate, LocalDate endDate);
|
||||
|
||||
/**
|
||||
* Get lesson trend by month
|
||||
* Get lesson trend by date range
|
||||
* @param tenantId 租户 ID
|
||||
* @param startDate 开始日期(可选,默认 7 天前)
|
||||
* @param endDate 结束日期(可选,默认今天)
|
||||
*/
|
||||
List<Map<String, Object>> getLessonTrend(Long tenantId, int months);
|
||||
List<Map<String, Object>> getLessonTrend(Long tenantId, LocalDate startDate, LocalDate endDate);
|
||||
|
||||
/**
|
||||
* Get course distribution
|
||||
|
||||
@ -0,0 +1,240 @@
|
||||
package com.reading.platform.service.impl;
|
||||
|
||||
import com.reading.platform.dto.response.CourseReportResponse;
|
||||
import com.reading.platform.dto.response.ReportOverviewResponse;
|
||||
import com.reading.platform.dto.response.StudentReportResponse;
|
||||
import com.reading.platform.dto.response.TeacherReportResponse;
|
||||
import com.reading.platform.mapper.ReportMapper;
|
||||
import com.reading.platform.service.SchoolReportService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 学校端 - 数据报告服务实现类
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SchoolReportServiceImpl implements SchoolReportService {
|
||||
|
||||
private final ReportMapper reportMapper;
|
||||
|
||||
@Override
|
||||
public ReportOverviewResponse getOverview(Long tenantId, LocalDate startDate, LocalDate endDate) {
|
||||
// 计算时间范围
|
||||
LocalDateTime startTime;
|
||||
LocalDateTime endTime;
|
||||
|
||||
if (startDate != null) {
|
||||
startTime = startDate.atStartOfDay();
|
||||
} else {
|
||||
// 默认本月 1 号
|
||||
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();
|
||||
}
|
||||
|
||||
log.info("获取报告概览,租户 ID: {}, 统计周期:{} ~ {}", tenantId, startTime, endTime);
|
||||
|
||||
// 查询概览数据
|
||||
Map<String, Object> stats = reportMapper.getOverviewStats(tenantId, startTime, endTime);
|
||||
|
||||
if (stats == null) {
|
||||
return ReportOverviewResponse.builder()
|
||||
.reportDate(LocalDate.now())
|
||||
.totalTeachers(0)
|
||||
.totalStudents(0)
|
||||
.totalClasses(0)
|
||||
.monthlyLessons(0)
|
||||
.monthlyTasksCompleted(0)
|
||||
.build();
|
||||
}
|
||||
|
||||
return ReportOverviewResponse.builder()
|
||||
.reportDate(LocalDate.now())
|
||||
.totalTeachers(convertToInt(stats.get("totalTeachers")))
|
||||
.totalStudents(convertToInt(stats.get("totalStudents")))
|
||||
.totalClasses(convertToInt(stats.get("totalClasses")))
|
||||
.monthlyLessons(convertToInt(stats.get("monthlyLessons")))
|
||||
.monthlyTasksCompleted(convertToInt(stats.get("monthlyTasksCompleted")))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TeacherReportResponse> getTeacherReports(Long tenantId, LocalDate startDate, LocalDate endDate, int limit) {
|
||||
// 计算时间范围
|
||||
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();
|
||||
}
|
||||
|
||||
log.info("获取教师报告,租户 ID: {}, 统计周期:{} ~ {}", tenantId, startTime, endTime);
|
||||
|
||||
List<Map<String, Object>> rawData = reportMapper.getTeacherReports(tenantId, startTime, endTime, limit);
|
||||
|
||||
if (rawData == null || rawData.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
return rawData.stream().map(row -> {
|
||||
Integer lessonCount = convertToInt(row.get("lessonCount"));
|
||||
Integer taskCount = convertToInt(row.get("taskCount"));
|
||||
Double averageRating = convertToDouble(row.get("averageRating"));
|
||||
LocalDateTime lastLessonTime = (LocalDateTime) row.get("lastLessonTime");
|
||||
|
||||
return TeacherReportResponse.builder()
|
||||
.teacherId((Long) row.get("teacherId"))
|
||||
.teacherName((String) row.get("teacherName"))
|
||||
.lessonCount(lessonCount)
|
||||
.taskCount(taskCount)
|
||||
.averageRating(averageRating)
|
||||
.lastLessonTime(lastLessonTime)
|
||||
.build();
|
||||
}).collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CourseReportResponse> getCourseReports(Long tenantId, LocalDate startDate, LocalDate endDate, int limit) {
|
||||
// 计算时间范围
|
||||
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();
|
||||
}
|
||||
|
||||
log.info("获取课程报告,租户 ID: {}, 统计周期:{} ~ {}", tenantId, startTime, endTime);
|
||||
|
||||
List<Map<String, Object>> rawData = reportMapper.getCourseReports(tenantId, startTime, endTime, limit);
|
||||
|
||||
if (rawData == null || rawData.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
return rawData.stream().map(row -> {
|
||||
Integer lessonCount = convertToInt(row.get("lessonCount"));
|
||||
Integer studentCount = convertToInt(row.get("studentCount"));
|
||||
Double averageRating = convertToDouble(row.get("averageRating"));
|
||||
Double completionRate = convertToDouble(row.get("completionRate"));
|
||||
|
||||
return CourseReportResponse.builder()
|
||||
.courseId((Long) row.get("courseId"))
|
||||
.courseName((String) row.get("courseName"))
|
||||
.lessonCount(lessonCount)
|
||||
.studentCount(studentCount)
|
||||
.averageRating(averageRating)
|
||||
.completionRate(completionRate)
|
||||
.build();
|
||||
}).collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<StudentReportResponse> getStudentReports(Long tenantId, LocalDate startDate, LocalDate endDate, int limit) {
|
||||
// 计算时间范围
|
||||
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();
|
||||
}
|
||||
|
||||
log.info("获取学生报告,租户 ID: {}, 统计周期:{} ~ {}", tenantId, startTime, endTime);
|
||||
|
||||
List<Map<String, Object>> rawData = reportMapper.getStudentReports(tenantId, startTime, endTime, limit);
|
||||
|
||||
if (rawData == null || rawData.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
return rawData.stream().map(row -> {
|
||||
Integer taskCount = convertToInt(row.get("taskCount"));
|
||||
Integer readingCount = convertToInt(row.get("readingCount"));
|
||||
Integer growthRecordCount = convertToInt(row.get("growthRecordCount"));
|
||||
Double attendanceRate = convertToDouble(row.get("attendanceRate"));
|
||||
|
||||
return StudentReportResponse.builder()
|
||||
.studentId((Long) row.get("studentId"))
|
||||
.studentName((String) row.get("studentName"))
|
||||
.className((String) row.get("className"))
|
||||
.taskCount(taskCount)
|
||||
.readingCount(readingCount)
|
||||
.growthRecordCount(growthRecordCount)
|
||||
.attendanceRate(attendanceRate)
|
||||
.build();
|
||||
}).collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Object 转换为 Integer,处理 null 和 Number 类型
|
||||
*/
|
||||
private Integer convertToInt(Object value) {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).intValue();
|
||||
}
|
||||
// 处理 BigDecimal 等类型
|
||||
try {
|
||||
return Integer.parseInt(value.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Object 转换为 Double,处理 null 和 Number 类型
|
||||
*/
|
||||
private Double convertToDouble(Object value) {
|
||||
if (value == null) {
|
||||
return 0.0;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).doubleValue();
|
||||
}
|
||||
// 处理 BigDecimal 等类型
|
||||
try {
|
||||
return Double.parseDouble(value.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -70,19 +70,34 @@ public class SchoolStatsServiceImpl implements SchoolStatsService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TeacherActivityRankResponse> getActiveTeachers(Long tenantId, int limit) {
|
||||
// 自然月统计:从当月 1 号开始
|
||||
LocalDateTime monthStart = LocalDateTime.now()
|
||||
public List<TeacherActivityRankResponse> getActiveTeachers(Long tenantId, LocalDate startDate, LocalDate endDate, int limit) {
|
||||
// 计算时间范围
|
||||
LocalDateTime startTime;
|
||||
LocalDateTime endTime;
|
||||
|
||||
if (startDate != null) {
|
||||
startTime = startDate.atStartOfDay();
|
||||
} else {
|
||||
// 默认本月 1 号
|
||||
startTime = LocalDateTime.now()
|
||||
.withDayOfMonth(1)
|
||||
.withHour(0)
|
||||
.withMinute(0)
|
||||
.withSecond(0)
|
||||
.withNano(0);
|
||||
}
|
||||
|
||||
log.info("获取活跃教师排行,租户 ID: {}, 统计周期开始时间:{}", tenantId, monthStart);
|
||||
if (endDate != null) {
|
||||
endTime = endDate.atTime(23, 59, 59);
|
||||
} else {
|
||||
// 默认今天
|
||||
endTime = LocalDateTime.now();
|
||||
}
|
||||
|
||||
log.info("获取活跃教师排行,租户 ID: {}, 统计周期:{} ~ {}", tenantId, startTime, endTime);
|
||||
|
||||
// 调用 Mapper 查询原始数据
|
||||
List<Map<String, Object>> rawData = lessonMapper.getTeacherActivityRank(tenantId, monthStart, limit);
|
||||
List<Map<String, Object>> rawData = lessonMapper.getTeacherActivityRank(tenantId, startTime, endTime, limit);
|
||||
|
||||
if (rawData == null || rawData.isEmpty()) {
|
||||
log.info("未查询到活跃教师数据");
|
||||
@ -171,24 +186,39 @@ public class SchoolStatsServiceImpl implements SchoolStatsService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> getLessonTrend(Long tenantId, int months) {
|
||||
// 转换为天数,与超管端保持一致(最近 7 天)
|
||||
int days = (months <= 0) ? 7 : Math.min(months, 30); // 限制最多 30 天
|
||||
public List<Map<String, Object>> getLessonTrend(Long tenantId, LocalDate startDate, LocalDate endDate) {
|
||||
// 计算时间范围
|
||||
LocalDateTime startTime;
|
||||
LocalDateTime endTime;
|
||||
|
||||
if (startDate != null) {
|
||||
startTime = startDate.atStartOfDay();
|
||||
} else {
|
||||
// 默认 7 天前
|
||||
startTime = LocalDateTime.now().minusDays(7).withHour(0).withMinute(0).withSecond(0).withNano(0);
|
||||
}
|
||||
|
||||
if (endDate != null) {
|
||||
endTime = endDate.atTime(23, 59, 59);
|
||||
} else {
|
||||
// 默认今天
|
||||
endTime = LocalDateTime.now();
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<Map<String, Object>> trend = new ArrayList<>();
|
||||
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);
|
||||
// 按天统计
|
||||
LocalDate currentDate = startTime.toLocalDate();
|
||||
LocalDate endDateLocal = endTime.toLocalDate();
|
||||
while (!currentDate.isAfter(endDateLocal)) {
|
||||
String dateStr = currentDate.format(dateFormatter);
|
||||
|
||||
// 当天时间范围
|
||||
LocalDateTime dayStart = date.atStartOfDay();
|
||||
LocalDateTime dayEnd = date.plusDays(1).atStartOfDay();
|
||||
LocalDateTime dayStart = currentDate.atStartOfDay();
|
||||
LocalDateTime dayEnd = currentDate.plusDays(1).atStartOfDay();
|
||||
|
||||
// 当天完成的授课次数(按租户过滤,使用 LambdaQueryWrapper 和 Entity)
|
||||
// 当天完成的授课次数(按租户过滤)
|
||||
Long lessons = lessonMapper.selectCount(
|
||||
new LambdaQueryWrapper<Lesson>()
|
||||
.eq(Lesson::getTenantId, tenantId)
|
||||
@ -205,6 +235,8 @@ public class SchoolStatsServiceImpl implements SchoolStatsService {
|
||||
item.put("lessonCount", lessons != null ? lessons.intValue() : 0);
|
||||
item.put("studentCount", activeStudents != null ? activeStudents.intValue() : 0);
|
||||
trend.add(item);
|
||||
|
||||
currentDate = currentDate.plusDays(1);
|
||||
}
|
||||
|
||||
return trend;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user