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:
En 2026-03-23 09:46:08 +08:00
parent e8219e5aab
commit c1f5b5085e
12 changed files with 1335 additions and 216 deletions

View File

@ -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 个统计接口 + 前端页面)

View 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

View File

@ -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 });
};
// ==================== 家长管理 ====================

View File

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

View File

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

View File

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

View File

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

View File

@ -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 &gt;= #{startTime} AND end_datetime &lt;= #{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 &gt;= #{startTime} AND tc.created_at &lt;= #{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 &gt;= #{startTime} AND l.end_datetime &lt;= #{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 &gt;= #{startTime} AND tc.created_at &lt;= #{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 &gt;= #{startTime} AND l.end_datetime &lt;= #{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 &gt;= #{startTime} AND tc.created_at &lt;= #{endTime} " +
"LEFT JOIN growth_record gr ON s.id = gr.student_id " +
" AND gr.deleted = 0 " +
" AND gr.created_at &gt;= #{startTime} AND gr.created_at &lt;= #{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
);
}

View File

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

View File

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

View File

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

View File

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