# Conflicts:
#	reading-platform-frontend/src/api/school.ts
#	reading-platform-frontend/src/api/teacher.ts
#	reading-platform-frontend/src/views/school/tasks/TaskListView.vue
#	reading-platform-frontend/src/views/teacher/tasks/TaskListView.vue
#	reading-platform-java/src/main/java/com/reading/platform/controller/school/SchoolTaskController.java
#	reading-platform-java/src/main/java/com/reading/platform/controller/teacher/TeacherTaskController.java
#	reading-platform-java/src/main/java/com/reading/platform/dto/response/TaskCompletionDetailResponse.java
#	reading-platform-java/src/main/java/com/reading/platform/service/TaskService.java
#	reading-platform-java/src/main/java/com/reading/platform/service/impl/TaskServiceImpl.java
This commit is contained in:
zhonghua 2026-03-20 14:27:28 +08:00
commit 26f55da670
29 changed files with 4848 additions and 866 deletions

View File

@ -0,0 +1,991 @@
# 阅读任务模块 - 详细设计文档
> 版本v2.0
> 日期2026-03-20
> 状态:待开发
---
## 目录
1. [需求背景与目标](#一需求背景与目标)
2. [用户角色与权限](#二用户角色与权限)
3. [核心业务流程](#三核心业务流程)
4. [功能需求详解](#四功能需求详解)
5. [数据模型设计](#五数据模型设计)
6. [API 接口设计](#六api-接口设计)
7. [UI/UX 设计规范](#七uiux-设计规范)
8. [代码重构计划](#八代码重构计划)
9. [开发任务清单](#九开发任务清单)
10. [验收标准](#十验收标准)
---
## 一、需求背景与目标
### 1.1 业务背景
幼儿园教师在完成绘本课后,需要给学生布置家庭阅读任务,建议孩子回家自主阅读相关绘本。本模块旨在实现:
- 教师端:创建/管理阅读任务,查看家长提交,给出评价反馈
- 家长端:接收任务通知,提交完成证明(照片/视频/心得),查看教师评价
- 学校端:查看全校阅读任务数据,统计分析,监督教学质量
### 1.2 产品目标
| 目标 | 指标 | 优先级 |
|------|------|--------|
| 简化任务发布流程 | 教师发布任务时间 < 2分钟 | P0 |
| 提升家长参与度 | 任务完成率 > 80% | P0 |
| 增强家校互动 | 教师反馈率 > 90% | P0 |
| 数据可追溯 | 学校可查看所有历史记录 | P1 |
### 1.3 本期范围
| 包含 | 不包含(后续迭代) |
|------|-------------------|
| ✅ 教师端任务管理 | ❌ 家长端绘本阅读功能 |
| ✅ 家长端任务查看/提交(照片/视频/心得) | ❌ 绘本付费订阅 |
| ✅ 教师端评价反馈(评语/评分) | ❌ 学生端独立 App |
| ✅ 学校端数据查看(只读) | ❌ 绘本库/自动推荐 |
| ✅ 关联绘本名称(手动填写) | ❌ |
### 1.4 核心设计原则
1. **学校端只读**:学校端不创建/编辑任务,仅查看和统计
2. **教师端闭环**:创建 → 查看 → 评价 → 推送反馈
3. **家长端简洁**:查看 → 提交 → 接收反馈
---
## 二、用户角色与权限
### 2.1 角色定义
| 角色 | 使用端 | 职责描述 |
|------|--------|---------|
| **教师** | 教师端 (Web) | 创建和管理阅读任务,查看提交内容,评价反馈 |
| **家长** | 家长端 (H5) | 接收任务,提交完成证明,查看教师评价 |
| **学校管理员** | 学校端 (Web) | 查看全校数据,统计分析,导出报表(**只读** |
| **学生** | - | 任务执行对象,通过家长端操作 |
### 2.2 权限矩阵
| 功能 | 教师 | 家长 | 学校 |
|------|------|------|------|
| 创建/编辑/删除任务 | ✅ | ❌ | ❌ |
| 查看自己创建的任务 | ✅ | - | - |
| 查看孩子的任务 | - | ✅ | - |
| 查看全校所有任务 | - | - | ✅ |
| 提交完成证明 | - | ✅ | ❌ |
| 查看提交内容 | ✅ | 自己的 | ✅ |
| 评价反馈 | ✅ | ❌ | ❌ |
| 查看评价内容 | - | ✅ | ✅ |
| 发送提醒 | ✅ | - | ❌ |
| 导出报表 | - | - | ✅ |
---
## 三、核心业务流程
### 3.1 主流程图
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 阅读任务完整流程 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ 1. 教师创建任务│ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 2. 系统推送 │─────►│ 3. 家长接收 │ │
│ │ 通知家长 │ │ 任务通知 │ │
│ └──────────────┘ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 4. 家长陪伴 │ │
│ │ 孩子阅读 │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 5. 家长提交 │ │
│ │ 照片/视频/心得│ │
│ └──────┬───────┘ │
│ │ │
│ ┌─────────────────────┴─────────────────────┐ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 6a. 教师收到 │ │ 6b. 学校可查看│ │
│ │ 提交通知 │ │ 提交记录 │ │
│ └──────┬───────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 7. 教师评价 │ │
│ │ 优秀/通过/需改进│ │
│ │ + 评语 + 评分 │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 8. 系统推送 │ │
│ │ 评价通知 │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 9. 家长查看 │ │
│ │ 教师评价 │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 3.2 状态流转
#### 任务状态
```
DRAFT草稿→ PUBLISHED已发布→ ARCHIVED已归档
```
#### 完成记录状态
```
PENDING待提交→ SUBMITTED已提交→ REVIEWED已评价
```
#### 评价结果
```
EXCELLENT优秀 / PASSED通过 / NEEDS_WORK需改进
```
---
## 四、功能需求详解
### 4.1 教师端
#### 4.1.1 任务列表页
**页面路径**`/teacher/tasks`
**功能点**
| 功能 | 描述 | 优先级 |
|------|------|--------|
| 任务列表 | 卡片式展示,显示标题、类型、状态、时间、完成率 | P0 |
| 状态筛选 | 进行中/草稿/已归档 | P0 |
| 类型筛选 | 阅读/活动/作业 | P0 |
| 关键字搜索 | 按标题搜索 | P0 |
| 新建任务 | 打开创建弹窗 | P0 |
| 编辑任务 | 修改任务内容(未截止前) | P0 |
| 删除任务 | 确认后删除 | P0 |
| 归档任务 | 将进行中任务归档 | P1 |
| 发送提醒 | 向未完成家长发送提醒 | P1 |
| 查看完成情况 | 打开完成情况弹窗 | P0 |
**数据字段**
```typescript
interface TeacherTask {
id: number;
title: string;
description?: string;
taskType: 'READING' | 'ACTIVITY' | 'HOMEWORK';
relatedBookName?: string; // 关联绘本名称
relatedCourseId?: number;
relatedCourseName?: string;
targetType: 'CLASS' | 'STUDENT';
targetIds: number[];
targetNames?: string[]; // 班级或学生名称
targetCount: number; // 目标人数
completionCount: number; // 已提交人数
feedbackCount: number; // 已评价人数
startDate: string;
endDate: string;
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
createdAt: string;
}
```
#### 4.1.2 创建/编辑任务弹窗
**功能点**
| 字段 | 类型 | 必填 | 描述 |
|------|------|------|------|
| 任务标题 | 文本 | ✅ | 最多50字 |
| 任务描述 | 文本域 | ❌ | 最多500字 |
| 任务类型 | 下拉 | ✅ | 阅读/活动/作业 |
| **关联绘本** | 文本 | ❌ | **新增**,手动填写绘本名称 |
| 目标类型 | 单选 | ✅ | 班级/学生 |
| 选择目标 | 多选 | ✅ | 根据类型显示班级或学生列表 |
| 关联课程 | 下拉 | ❌ | 可选,关联课程包 |
| 任务时间 | 日期范围 | ✅ | 开始日期-截止日期 |
#### 4.1.3 完成情况弹窗(重点改造)
**功能点**
| 功能 | 描述 | 优先级 |
|------|------|--------|
| 状态统计 | 待完成/已提交/已评价 数量 | P0 |
| 状态筛选 | 按提交状态筛选 | P0 |
| 学生列表 | 显示学生姓名、班级、提交状态 | P0 |
| **查看提交内容** | 点击查看照片/视频/心得详情 | P0 |
| **快捷评价** | 一键标记 优秀/通过/需改进 | P0 |
| **详细评价** | 弹窗填写评语和评分 | P0 |
**学生完成记录数据**
```typescript
interface TaskCompletion {
id: number;
taskId: number;
studentId: number;
student: {
id: number;
name: string;
avatar?: string;
gender: 'MALE' | 'FEMALE';
class: {
id: number;
name: string;
};
};
status: 'PENDING' | 'SUBMITTED' | 'REVIEWED';
photos: string[]; // 照片URL数组
videoUrl?: string; // 视频URL
audioUrl?: string; // 语音URL
content?: string; // 文字心得
submittedAt?: string; // 提交时间
reviewedAt?: string; // 评价时间
feedback?: TaskFeedback; // 教师评价
}
interface TaskFeedback {
id: number;
completionId: number;
teacherId: number;
teacherName: string;
result: 'EXCELLENT' | 'PASSED' | 'NEEDS_WORK';
rating?: number; // 1-5星可选
comment?: string; // 评语
createdAt: string;
}
```
#### 4.1.4 提交详情+评价弹窗(新增)
```
┌─────────────────────────────────────────────────────────────────┐
│ 张小明的提交 - 《好饿的毛毛虫》 [返回] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 📷 阅读照片 │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ 📸 │ │ 📸 │ │ 📸 │ 点击查看大图 │
│ └─────┘ └─────┘ └─────┘ │
│ │
│ 🎬 阅读视频(如有) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ ▶️ 播放视频 │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ 📝 阅读心得 │
│ "孩子很喜欢这本书,读了好几遍..." │
│ │
├─────────────────────────────────────────────────────────────────┤
│ 教师评价 │
│ │
│ 评价结果: │
│ [⭐ 优秀] [✓ 通过] [⚠ 需改进] │
│ │
│ 评分(可选): ⭐ ⭐ ⭐ ⭐ ⭐ │
│ │
│ 评语: │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 阅读很认真,能够复述故事内容,继续保持! │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ [取消] [保存并发送给家长] │
└─────────────────────────────────────────────────────────────────┘
```
---
### 4.2 家长端H5
#### 4.2.1 任务列表页
**页面路径**`/parent/tasks`
**功能点**
| 功能 | 描述 | 优先级 |
|------|------|--------|
| 任务列表 | 卡片式展示,按截止时间排序 | P0 |
| 状态显示 | 待完成/已提交/已评价 | P0 |
| 截止倒计时 | 显示剩余时间,即将截止标红 | P1 |
| 孩子切换 | 多孩子家庭切换查看 | P0 |
| 点击进入详情 | 跳转任务详情页 | P0 |
**任务卡片显示**
```
┌───────────────────────────────────────────────┐
│ ⏰ 明天截止 [待完成] │
│ │
│ 《好饿的毛毛虫》阅读任务 │
│ 李老师 | 大一班 │
│ │
│ [去完成 →] │
└───────────────────────────────────────────────┘
┌───────────────────────────────────────────────┐
│ ✅ 已完成 优秀 ⭐⭐⭐⭐⭐ │
│ │
│ 《小蝌蚪找妈妈》阅读任务 │
│ 王老师 | 大一班 │
│ │
│ [查看反馈 →] │
└───────────────────────────────────────────────┘
```
#### 4.2.2 任务提交页(重写)
**页面路径**`/parent/tasks/:id/submit`
**功能点**
| 功能 | 描述 | 优先级 |
|------|------|--------|
| 任务信息展示 | 标题、描述、绘本名称、截止时间 | P0 |
| **拍照上传** | 支持拍照或从相册选择最多9张 | P0 |
| **视频上传** | 录制或选择视频限60秒 | P0 |
| **语音上传** | 录制语音限60秒P1 | P1 |
| **文字心得** | 文本输入最多500字 | P0 |
| 预览已上传内容 | 显示已上传的照片/视频缩略图 | P0 |
| 删除已上传内容 | 可删除已上传的照片/视频 | P0 |
| 提交按钮 | 校验后提交 | P0 |
| 重新提交 | 截止前可修改 | P0 |
**提交表单数据**
```typescript
interface TaskSubmitRequest {
photos: string[]; // OSS URL数组
videoUrl?: string; // OSS URL
audioUrl?: string; // OSS URL
content?: string; // 文字心得
}
```
#### 4.2.3 评价查看页(新增)
**页面路径**`/parent/tasks/:id/feedback`
**显示内容**
- 教师评价结果(优秀/通过/需改进)
- 评分(如有)
- 教师评语
- 评价时间
---
### 4.3 学校端- 只读
> **重要**:学校端**不能**创建/编辑/删除任务,仅查看数据
#### 4.3.1 任务列表页(重写)
**页面路径**`/school/reading-tasks`
**筛选功能**(核心):
| 筛选项 | 类型 | 说明 |
|--------|------|------|
| 时间范围 | 快捷+自定义 | 今日/本周/本月/本学期/自定义日期 |
| 班级 | 多选下拉 | 支持选择多个班级 |
| 教师 | 多选下拉 | 支持选择多个教师 |
| 任务类型 | 单选下拉 | 阅读/活动/作业/全部 |
| 任务状态 | 单选下拉 | 进行中/已截止/已归档/全部 |
| 完成率 | 单选下拉 | 高(>80%)/中(50-80%)/低(<50%)/全部 |
| 搜索 | 文本框 | 按标题搜索 |
**已选条件显示**
- 在筛选区下方显示已选条件的标签
- 点击标签可移除该条件
- 提供"清空全部"按钮
**列表字段**
| 列 | 说明 | 排序 |
|----|------|------|
| 任务标题 | 点击可查看详情 | ✅ |
| 发布教师 | 教师姓名 | ✅ |
| 班级 | 目标班级(多个用逗号分隔) | - |
| 类型 | 阅读/活动/作业 | - |
| 目标人数 | 学生总数 | - |
| 提交数 | 已提交/总人数 | ✅ |
| 完成率 | 进度条 | ✅ |
| 反馈率 | 教师已评价比例 | ✅ |
| 发布时间 | yyyy-MM-dd | ✅ |
| 截止时间 | yyyy-MM-dd | ✅ |
| 状态 | 进行中/已截止 | - |
| 操作 | 查看详情 | - |
#### 4.3.2 任务详情页(新增)
**页面路径**`/school/reading-tasks/:id`
**显示内容**
1. 基本信息
- 任务标题、描述
- 发布教师、班级
- 任务类型、关联绘本
- 发布时间、截止时间
2. 完成统计
- 目标人数、提交人数、完成率
- 待完成/已提交/已评价 数量
3. 学生完成列表(可下钻查看)
- 学生姓名、班级
- 提交时间、提交状态
- 评价结果(如有)
- 操作:查看提交内容
#### 4.3.3 学生提交详情页(新增)
**页面路径**`/school/reading-tasks/:taskId/completions/:completionId`
**显示内容**
- 提交的照片(可放大查看)
- 提交的视频(可播放)
- 提交的心得文字
- 教师评价(如有)
> 注意:学校端此页面为**纯只读**,不能进行任何编辑操作
---
## 五、数据模型设计
### 5.1 表结构变更
#### 5.1.1 Task 表 - 新增字段
```sql
ALTER TABLE task ADD COLUMN related_book_name VARCHAR(200) COMMENT '关联绘本名称';
```
#### 5.1.2 TaskCompletion 表 - 扩展字段
```sql
-- 修改 photos 字段类型(如果原来是 VARCHAR
ALTER TABLE task_completion MODIFY COLUMN photos JSON COMMENT '照片URL数组(JSON)';
-- 新增字段
ALTER TABLE task_completion ADD COLUMN video_url VARCHAR(500) COMMENT '视频URL';
ALTER TABLE task_completion ADD COLUMN audio_url VARCHAR(500) COMMENT '语音URL';
```
#### 5.1.3 TaskFeedback 表 - 新建
```sql
CREATE TABLE task_feedback (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
completion_id BIGINT NOT NULL COMMENT '完成记录ID',
task_id BIGINT NOT NULL COMMENT '任务ID冗余方便查询',
student_id BIGINT NOT NULL COMMENT '学生ID冗余',
teacher_id BIGINT NOT NULL COMMENT '教师ID',
result VARCHAR(20) NOT NULL COMMENT '评价结果EXCELLENT/PASSED/NEEDS_WORK',
rating INT COMMENT '评分 1-5可选',
comment TEXT COMMENT '评语',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted_at DATETIME DEFAULT NULL COMMENT '删除时间',
INDEX idx_completion_id (completion_id),
INDEX idx_task_id (task_id),
INDEX idx_teacher_id (teacher_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务评价表';
```
### 5.2 实体类定义
#### Task.java变更
```java
@Data
@TableName("task")
public class Task extends BaseEntity {
private Long tenantId;
private String title;
private String description;
private String type; // taskType
private String relatedBookName; // 【新增】关联绘本名称
private Long courseId;
private Long creatorId;
private String creatorRole;
private LocalDate startDate;
private LocalDate dueDate;
private String status;
private String attachments;
}
```
#### TaskCompletion.java变更
```java
@Data
@TableName("task_completion")
public class TaskCompletion extends BaseEntity {
private Long taskId;
private Long studentId;
private String status;
private String photos; // JSON数组
private String videoUrl; // 【新增】
private String audioUrl; // 【新增】
private String content;
private LocalDateTime submittedAt;
private LocalDateTime reviewedAt;
}
```
#### TaskFeedback.java新增
```java
@Data
@TableName("task_feedback")
public class TaskFeedback extends BaseEntity {
private Long completionId;
private Long taskId;
private Long studentId;
private Long teacherId;
private String result; // EXCELLENT/PASSED/NEEDS_WORK
private Integer rating; // 1-5可选
private String comment;
}
```
### 5.3 DTO 定义
#### TaskCompletionResponse.java
```java
@Data
@Builder
public class TaskCompletionResponse {
private Long id;
private Long taskId;
private StudentInfo student;
private String status;
private List<String> photos;
private String videoUrl;
private String audioUrl;
private String content;
private LocalDateTime submittedAt;
private LocalDateTime reviewedAt;
private TaskFeedbackResponse feedback;
@Data
public static class StudentInfo {
private Long id;
private String name;
private String avatar;
private String gender;
private ClassInfo classInfo;
@Data
public static class ClassInfo {
private Long id;
private String name;
}
}
}
```
#### TaskFeedbackRequest.java
```java
@Data
public class TaskFeedbackRequest {
@NotBlank(message = "评价结果不能为空")
private String result; // EXCELLENT/PASSED/NEEDS_WORK
@Min(1) @Max(5)
private Integer rating;
@Size(max = 500, message = "评语最多500字")
private String comment;
}
```
#### TaskFeedbackResponse.java
```java
@Data
@Builder
public class TaskFeedbackResponse {
private Long id;
private String result;
private String resultText; // 优秀/通过/需改进
private Integer rating;
private String comment;
private Long teacherId;
private String teacherName;
private LocalDateTime createdAt;
}
```
---
## 六、API 接口设计
### 6.1 教师端 API
#### 6.1.1 任务管理
| 方法 | 路径 | 说明 | 变更 |
|------|------|------|------|
| GET | `/api/v1/teacher/tasks` | 任务列表 | 无变更 |
| POST | `/api/v1/teacher/tasks` | 创建任务 | **新增 relatedBookName** |
| GET | `/api/v1/teacher/tasks/{id}` | 任务详情 | 无变更 |
| PUT | `/api/v1/teacher/tasks/{id}` | 更新任务 | **新增 relatedBookName** |
| DELETE | `/api/v1/teacher/tasks/{id}` | 删除任务 | 无变更 |
| POST | `/api/v1/teacher/tasks/{id}/archive` | 归档任务 | **新增** |
| POST | `/api/v1/teacher/tasks/{id}/remind` | 发送提醒 | 无变更 |
#### 6.1.2 完成情况与评价(重点变更)
| 方法 | 路径 | 说明 | 变更 |
|------|------|------|------|
| GET | `/api/v1/teacher/tasks/{id}/completions` | 完成情况列表 | **返回内容扩展** |
| GET | `/api/v1/teacher/completions/{id}` | 提交详情 | **新增** |
| POST | `/api/v1/teacher/completions/{id}/feedback` | 提交评价 | **新增** |
| PUT | `/api/v1/teacher/completions/{id}/feedback` | 修改评价 | **新增** |
#### 请求/响应示例
**创建任务请求**
```json
{
"title": "《好饿的毛毛虫》亲子阅读",
"description": "和孩子一起阅读这本经典绘本...",
"taskType": "READING",
"relatedBookName": "《好饿的毛毛虫》",
"targetType": "CLASS",
"targetIds": [1, 2],
"relatedCourseId": 10,
"startDate": "2026-03-20",
"endDate": "2026-03-27"
}
```
**提交评价请求**
```json
{
"result": "EXCELLENT",
"rating": 5,
"comment": "阅读很认真,能够完整复述故事内容,继续保持!"
}
```
### 6.2 家长端 API
| 方法 | 路径 | 说明 | 变更 |
|------|------|------|------|
| GET | `/api/v1/parent/tasks` | 孩子的任务列表 | 无变更 |
| GET | `/api/v1/parent/tasks/{id}` | 任务详情 | 无变更 |
| **POST** | `/api/v1/parent/tasks/{id}/submit` | 提交完成 | **重写,支持文件** |
| **PUT** | `/api/v1/parent/tasks/{id}/submit` | 修改提交 | **新增** |
| **GET** | `/api/v1/parent/completions/{id}/feedback` | 查看评价 | **新增** |
#### 请求示例
**提交完成请求**
```json
{
"photos": [
"https://oss.example.com/photo1.jpg",
"https://oss.example.com/photo2.jpg"
],
"videoUrl": "https://oss.example.com/video.mp4",
"content": "孩子很喜欢这本书,读了好几遍..."
}
```
### 6.3 学校端 API
| 方法 | 路径 | 说明 | 变更 |
|------|------|------|------|
| **GET** | `/api/v1/school/reading-tasks` | 任务列表(支持多维度筛选) | **重写** |
| **GET** | `/api/v1/school/reading-tasks/{id}` | 任务详情 | **新增** |
| **GET** | `/api/v1/school/reading-tasks/{id}/completions` | 完成情况列表 | **新增** |
| **GET** | `/api/v1/school/completions/{id}` | 学生提交详情 | **新增** |
| **GET** | `/api/v1/school/reading-tasks/statistics` | 统计数据 | **新增** |
| **GET** | `/api/v1/school/reading-tasks/export` | 导出报表 | **新增** |
#### 学校端筛选参数
```typescript
interface SchoolTaskQueryParams {
// 分页
pageNum?: number;
pageSize?: number;
// 时间筛选
dateType?: 'today' | 'week' | 'month' | 'semester' | 'custom';
startDate?: string; // 自定义开始日期
endDate?: string; // 自定义结束日期
// 组织筛选
classIds?: number[]; // 班级ID数组
teacherIds?: number[]; // 教师ID数组
// 状态筛选
taskType?: 'READING' | 'ACTIVITY' | 'HOMEWORK';
taskStatus?: 'PUBLISHED' | 'ARCHIVED';
completionRate?: 'high' | 'medium' | 'low';
// 搜索
keyword?: string;
// 排序
sortBy?: 'createdAt' | 'completionRate' | 'feedbackRate';
sortOrder?: 'asc' | 'desc';
}
```
---
## 七、UI/UX 设计规范
### 7.1 评价结果颜色规范
| 结果 | 颜色 | 标签样式 |
|------|------|---------|
| EXCELLENT优秀 | 绿色 `#52c41a` | `<a-tag color="success">` |
| PASSED通过 | 蓝色 `#1890ff` | `<a-tag color="blue">` |
| NEEDS_WORK需改进 | 橙色 `#fa8c16` | `<a-tag color="warning">` |
### 7.2 评分星星
- 使用 Ant Design 的 `<a-rate>` 组件
- 默认禁用(仅展示),在评价时可编辑
- 颜色:金色 `#fadb14`
### 7.3 照片上传组件
- 使用 Ant Design 的 `<a-upload>` 组件
- 列表模式:`list-type="picture-card"`
- 最大数量9张
- 支持预览和删除
### 7.4 视频上传
- 限制时长60秒
- 支持格式mp4, mov
- 文件大小:不超过 50MB
### 7.5 筛选组件规范
```
时间快捷选择:
┌─────────────────────────────────────────────────────┐
│ [今日] [本周 ✓] [本月] [本学期] [自定义 ▼] │
└─────────────────────────────────────────────────────┘
多选下拉:
┌─────────────────────────────────────────────────────┐
│ 班级 ▼ │
│ ┌───────────────────────────────────────────────┐ │
│ │ ☑ 全选 │ │
│ │ ☑ 大一班 │ │
│ │ ☑ 大二班 │ │
│ │ ☐ 中一班 │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
已选条件标签:
┌─────────────────────────────────────────────────────┐
│ 已选条件: │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 本周 ✕ │ │ 大一班 ✕ │ │ 李老师 ✕ │ │
│ └──────────┘ └──────────┘ └──────────┘ [清空全部] │
└─────────────────────────────────────────────────────┘
```
---
## 八、代码重构计划
### 8.1 需要删除的文件/代码
| 文件 | 原因 |
|------|------|
| `school/TaskListView.vue` | 完全重写,改为只读模式 |
| `api/school.ts` 中的 `createSchoolTask`, `updateSchoolTask`, `deleteSchoolTask` | 学校端不再需要这些API |
### 8.2 需要修改的文件
| 文件 | 修改内容 |
|------|---------|
| `entity/Task.java` | 新增 `relatedBookName` 字段 |
| `entity/TaskCompletion.java` | 新增 `videoUrl`, `audioUrl` 字段 |
| `dto/request/TaskCreateRequest.java` | 新增 `relatedBookName` 字段 |
| `dto/response/TaskResponse.java` | 新增 `relatedBookName` 字段 |
| `service/TaskService.java` | 新增评价相关方法 |
| `service/impl/TaskServiceImpl.java` | 实现评价相关方法 |
| `controller/teacher/TeacherTaskController.java` | 新增评价API |
| `controller/parent/ParentTaskController.java` | 重写提交API新增查看评价API |
| `controller/school/SchoolTaskController.java` | 重写为只读API |
| `views/teacher/tasks/TaskListView.vue` | 新增关联绘本字段,改造评价功能 |
| `views/parent/tasks/TaskListView.vue` | 改造提交功能 |
### 8.3 需要新增的文件
| 文件 | 用途 |
|------|------|
| `entity/TaskFeedback.java` | 评价实体类 |
| `mapper/TaskFeedbackMapper.java` | 评价Mapper |
| `service/TaskFeedbackService.java` | 评价服务接口 |
| `service/impl/TaskFeedbackServiceImpl.java` | 评价服务实现 |
| `dto/request/TaskFeedbackRequest.java` | 评价请求DTO |
| `dto/response/TaskFeedbackResponse.java` | 评价响应DTO |
| `dto/response/TaskCompletionResponse.java` | 完成记录响应DTO扩展 |
| `common/mapper/TaskFeedbackMapper.java` | MapStruct Mapper |
| `views/school/reading-tasks/TaskListView.vue` | 学校端任务列表(重写) |
| `views/school/reading-tasks/TaskDetailView.vue` | 学校端任务详情(新增) |
| `views/school/reading-tasks/CompletionDetailView.vue` | 学校端提交详情(新增) |
| `views/parent/tasks/TaskSubmitView.vue` | 家长端提交页面(重写) |
| `views/parent/tasks/TaskFeedbackView.vue` | 家长端查看评价(新增) |
| `views/teacher/tasks/components/CompletionDetailModal.vue` | 教师端提交详情弹窗(新增) |
| `views/teacher/tasks/components/FeedbackModal.vue` | 教师端评价弹窗(新增) |
| `api/reading-task.ts` | 阅读任务API新模块独立于原task |
| `components/TaskFilterPanel.vue` | 通用筛选面板组件 |
---
## 九、开发任务清单
### Phase 1后端基础改造2-3天
| 任务 | 预估时间 | 依赖 |
|------|---------|------|
| 数据库迁移脚本TaskFeedback表、字段扩展 | 0.5天 | 无 |
| TaskFeedback 实体类和 Mapper | 0.5天 | 数据库 |
| TaskFeedbackService 接口和实现 | 1天 | 实体类 |
| 扩展 TaskService评价相关方法 | 0.5天 | TaskFeedback |
| 教师端评价 API | 0.5天 | Service |
| 家长端提交 API支持文件 | 0.5天 | Service |
| 学校端只读 API多维度筛选 | 1天 | Service |
### Phase 2教师端改造2天
| 任务 | 预估时间 | 依赖 |
|------|---------|------|
| 创建任务弹窗添加绘本字段 | 0.5天 | 无 |
| 完成情况弹窗改造(显示提交内容) | 0.5天 | 无 |
| 新增提交详情弹窗组件 | 0.5天 | 无 |
| 新增评价弹窗组件 | 0.5天 | 后端API |
### Phase 3家长端改造2天
| 任务 | 预估时间 | 依赖 |
|------|---------|------|
| 重写任务提交页面(照片/视频上传) | 1天 | 后端API |
| 新增查看评价页面 | 0.5天 | 后端API |
| 完善任务列表页 | 0.5天 | 无 |
### Phase 4学校端重写2天
| 任务 | 预估时间 | 依赖 |
|------|---------|------|
| 多维度筛选组件 | 0.5天 | 无 |
| 任务列表页(只读) | 0.5天 | 筛选组件 |
| 任务详情页 | 0.5天 | 后端API |
| 学生提交详情页 | 0.5天 | 后端API |
### Phase 5测试与优化1-2天
| 任务 | 预估时间 |
|------|---------|
| 三端联调测试 | 0.5天 |
| Bug修复 | 0.5天 |
| 性能优化 | 0.5天 |
---
## 十、验收标准
### 10.1 功能验收
#### 教师端
- [ ] 可以创建任务,填写关联绘本名称
- [ ] 可以查看所有学生的提交情况
- [ ] 可以查看学生提交的照片和视频
- [ ] 可以给学生评价(优秀/通过/需改进)
- [ ] 可以填写评语和评分
- [ ] 评价后家长端能收到通知
#### 家长端
- [ ] 可以查看孩子的任务列表
- [ ] 可以上传照片最多9张
- [ ] 可以上传视频
- [ ] 可以填写文字心得
- [ ] 可以查看教师的评价
#### 学校端
- [ ] **不能**创建/编辑/删除任务
- [ ] 可以按时间范围筛选
- [ ] 可以按班级筛选(多选)
- [ ] 可以按教师筛选(多选)
- [ ] 可以按任务类型/状态筛选
- [ ] 可以按完成率筛选
- [ ] 可以搜索任务标题
- [ ] 可以查看任务详情
- [ ] 可以查看学生提交内容
- [ ] 可以查看教师评价
### 10.2 技术验收
- [ ] 所有 API 有 Swagger 文档
- [ ] 前端代码有 TypeScript 类型定义
- [ ] 数据库迁移脚本可重复执行
- [ ] 无 console.log 残留
- [ ] 无未使用的导入和变量
### 10.3 UI/UX 验收
- [ ] 符合 Ant Design 设计规范
- [ ] 移动端响应式适配
- [ ] 加载状态有 loading 提示
- [ ] 操作有成功/失败反馈
- [ ] 表单有必填校验
---
## 附录:关键决策记录
| 决策 | 原因 | 日期 |
|------|------|------|
| 学校端只读,不创建任务 | 学校是管理监督角色,不是执行角色 | 2026-03-20 |
| 关联绘本手动填写而非选择 | 绘本库功能复杂,先简化实现 | 2026-03-20 |
| 评价结果用三档而非五档 | 简化教师操作,符合实际场景 | 2026-03-20 |
| 评分设为可选 | 避免过度评价压力 | 2026-03-20 |
| 学校端筛选参数用数组 | 支持多选,更灵活 | 2026-03-20 |
---
*文档版本历史:*
- v2.0 (2026-03-20): 完整需求分析和设计文档

View File

@ -0,0 +1,655 @@
# 阅读任务模块 PRD产品需求文档
> 版本v1.1
> 日期2026-03-20
> 状态:讨论稿
---
## 一、背景与目标
### 1.1 业务背景
幼儿园教师在完成绘本课后,需要给学生布置家庭阅读任务,建议孩子回家自主阅读相关绘本。目前缺乏一个系统化的工具来:
- 管理阅读任务的发布和追踪
- 让家长反馈孩子的完成情况
- 让教师对学生完成情况进行评价
- 让学校管理层了解全校阅读任务执行情况
### 1.2 产品目标
| 目标 | 指标 |
|------|------|
| 简化任务发布流程 | 教师发布任务时间 < 2分钟 |
| 提升家长参与度 | 任务完成率 > 80% |
| 增强家校互动 | 教师反馈率 > 90% |
| 数据可追溯 | 学校可查看所有历史记录 |
### 1.3 本期范围
| 包含 | 不包含(后续迭代) |
|------|-------------------|
| ✅ 教师端任务管理 | ❌ 家长端绘本阅读功能 |
| ✅ 家长端任务查看/提交 | ❌ 绘本付费订阅 |
| ✅ 教师端反馈评价 | ❌ 学生端独立 App |
| ✅ 学校端数据查看 | ❌ 绘本库/自动推荐 |
| ✅ 关联绘本(手动填写) | ❌ |
---
## 二、竞品分析
### 2.1 Seesaw核心参考
**定位**K-12 学生的数字学习档案平台
| 功能模块 | 说明 | 可借鉴点 |
|---------|------|---------|
| **作品提交** | 学生/家长上传照片、视频、绘画、语音 | 多种提交形式 |
| **教师反馈** | 文字、语音、手写批注 | 多维度反馈方式 |
| **数字档案** | 自动归档学生作品形成成长记录 | 生成成长档案 |
| **家校消息** | 单向公告 + 双向消息 | 灵活的沟通模式 |
| **进度报告** | 可生成并分享给家长 | 数据可视化 |
### 2.2 ClassDojo
**定位**:课堂行为管理 + 家校沟通
| 功能模块 | 说明 | 可借鉴点 |
|---------|------|---------|
| **积分系统** | 行为激励,学生获得积分/徽章 | 游戏化激励 |
| **班级故事** | 教师分享班级动态 | 内容分享 |
| **电子档案** | 家长帮助孩子上传作业 | 亲子协作 |
| **多语言支持** | 130+ 语言自动翻译 | 降低使用门槛 |
### 2.3 关键洞察
1. **提交形式多样化** - 不仅限于文字,支持照片/视频/语音
2. **反馈即时性** - 教师反馈能及时触达家长
3. **成长可追溯** - 形成学生阅读成长档案
4. **操作简便性** - 家长端必须极简,避免使用门槛
---
## 三、用户角色与场景
### 3.1 角色定义
| 角色 | 职责 | 使用端 |
|------|------|--------|
| **教师** | 创建任务、查看提交、评价反馈 | 教师端Web |
| **家长** | 接收任务、提交完成、查看反馈 | 家长端H5/小程序) |
| **学校管理员** | 查看全校数据、统计分析 | 学校端Web |
| **学生** | 实际完成任务的对象 | 通过家长端操作 |
### 3.2 核心场景
```
场景一:课后布置阅读任务
┌─────────────────────────────────────────────────────────┐
│ 教师 → 完成绘本课 → 创建阅读任务 → 填写关联绘本名称 │
│ → 设置截止日期 → 指定班级/学生 → 发布 │
└─────────────────────────────────────────────────────────┘
场景二:家长提交完成情况
┌─────────────────────────────────────────────────────────┐
│ 家长 → 收到任务通知 → 陪伴孩子阅读 → 拍照/录视频 │
│ → 上传完成证明 → 填写简单心得 → 提交 │
└─────────────────────────────────────────────────────────┘
场景三:教师评价反馈
┌─────────────────────────────────────────────────────────┐
│ 教师 → 查看待审核列表 → 查看提交内容 → 评分/写评语 │
│ → 发送反馈 → 家长收到通知 → 查看教师评价 │
└─────────────────────────────────────────────────────────┘
场景四:学校查看数据
┌─────────────────────────────────────────────────────────┐
│ 学校 → 多维度筛选 → 查看任务列表 → 查看完成情况 │
│ → 查看教师反馈 → 导出报表 │
└─────────────────────────────────────────────────────────┘
```
---
## 四、功能需求
### 4.1 教师端Web
#### 4.1.1 任务管理
| 功能 | 描述 | 优先级 |
|------|------|--------|
| **创建任务** | 填写标题、描述、关联绘本名称、截止日期 | P0 |
| **选择目标** | 按班级或按学生分配任务 | P0 |
| **任务模板** | 使用模板快速创建,支持保存为模板 | P1 |
| **编辑/删除** | 修改任务内容或撤回(未截止前) | P0 |
| **任务列表** | 按状态(进行中/已截止/草稿)筛选查看 | P0 |
#### 4.1.2 完成情况管理
| 功能 | 描述 | 优先级 |
|------|------|--------|
| **提交列表** | 按任务查看所有学生提交情况 | P0 |
| **状态筛选** | 按待审核/已通过/需改进筛选 | P0 |
| **查看详情** | 查看照片/视频/文字内容 | P0 |
| **快捷评价** | 一键标记"优秀"/"通过"/"需改进" | P0 |
| **详细评语** | 输入文字评价 | P1 |
| **评分打星** | 1-5 星评分(可选) | P2 |
| **批量操作** | 批量标记通过 | P1 |
#### 4.1.3 提醒通知
| 功能 | 描述 | 优先级 |
|------|------|--------|
| **一键提醒** | 向未完成家长发送提醒 | P0 |
| **自动提醒** | 截止前 1 天自动提醒 | P1 |
---
### 4.2 家长端H5/小程序)
> **现状分析**:已有基础框架,需完善以下功能
#### 4.2.1 任务查看
| 功能 | 描述 | 优先级 | 现状 |
|------|------|--------|------|
| **任务列表** | 显示孩子收到的所有阅读任务 | P0 | ✅ 已有基础 |
| **任务详情** | 标题、描述、关联绘本、截止日期 | P0 | ✅ 已有基础 |
| **状态显示** | 待完成/已提交/已评价 | P0 | ✅ 已有基础 |
| **倒计时** | 显示剩余时间,过期标红 | P1 | ❌ 需新增 |
#### 4.2.2 完成提交(重点完善)
| 功能 | 描述 | 优先级 | 现状 |
|------|------|--------|------|
| **拍照上传** | 拍摄孩子阅读照片(最多 9 张) | P0 | ❌ 需新增 |
| **视频上传** | 录制孩子阅读视频(限 60 秒) | P0 | ❌ 需新增 |
| **语音上传** | 录制孩子朗读语音(限 60 秒) | P1 | ❌ 需新增 |
| **文字心得** | 填写阅读心得(可选) | P1 | ⚠️ 需完善 |
| **重新提交** | 截止前可修改提交内容 | P0 | ❌ 需新增 |
#### 4.2.3 反馈查看
| 功能 | 描述 | 优先级 | 现状 |
|------|------|--------|------|
| **评价通知** | 收到教师评价的推送通知 | P0 | ❌ 需新增 |
| **评价详情** | 查看评分、评语 | P0 | ❌ 需新增 |
| **历史记录** | 查看所有任务的完成和评价记录 | P1 | ⚠️ 需完善 |
#### 4.2.4 消息通知
| 功能 | 描述 | 优先级 | 现状 |
|------|------|--------|------|
| **新任务通知** | 推送新任务提醒 | P0 | ❌ 需新增 |
| **截止提醒** | 推送截止前提醒 | P0 | ❌ 需新增 |
| **评价通知** | 推送教师评价提醒 | P0 | ❌ 需新增 |
---
### 4.3 学校端Web- 重点设计
> **核心原则**:学校端 **不创建任务**,仅查看数据和统计分析
> **核心挑战**:数据量大(多班级 × 多教师 × 每日任务),需高效筛选
#### 4.3.1 筛选系统设计(参考最佳实践)
基于竞品分析和教育管理系统的最佳实践,设计 **多维度组合筛选** 系统:
```
┌─────────────────────────────────────────────────────────────────┐
│ 🔍 筛选条件 │
├─────────────────────────────────────────────────────────────────┤
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 时间范围 │ │
│ │ [本周 ▼] [2026年3月 ▼] 或 [03-01] ~ [03-20] 自定义 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ 班级 │ │ 教师 │ │ 任务类型 │ │
│ │ [全部 ▼] │ │ [全部 ▼] │ │ [全部 ▼] │ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │
│ ┌────────────┐ ┌────────────┐ ┌─────────────────────────┐ │
│ │ 任务状态 │ │ 完成状态 │ │ 搜索任务标题... │ │
│ │ [全部 ▼] │ │ [全部 ▼] │ │ 🔍 │ │
│ └────────────┘ └────────────┘ └─────────────────────────┘ │
│ │
│ [重置筛选] [已选: 5 个条件] │
└─────────────────────────────────────────────────────────────────┘
```
##### 筛选维度详解
| 维度 | 选项 | 说明 |
|------|------|------|
| **时间范围** | 今日/本周/本月/本学期/自定义 | 快捷选择 + 日期选择器 |
| **班级** | 全部/大一班/大二班/... | 支持多选 |
| **教师** | 全部/李老师/王老师/... | 支持多选 |
| **任务类型** | 全部/阅读/活动/作业 | 单选 |
| **任务状态** | 全部/进行中/已截止/已归档 | 单选 |
| **完成状态** | 全部/高完成率(>80%)/中完成率(50-80%)/低完成率(<50%) | 单选 |
| **搜索** | 任务标题关键字 | 模糊搜索 |
##### 筛选交互设计
1. **实时筛选**:选择条件后立即刷新结果(无需点击搜索按钮)
2. **已选标签**:在筛选区下方显示已选条件的标签,可点击移除
3. **条件持久化**:筛选条件保存在 URL 参数中,刷新页面不丢失
4. **智能默认**:默认显示"本周"任务,避免数据量过大
#### 4.3.2 任务总览
```
┌─────────────────────────────────────────────────────────────────┐
│ 📊 阅读任务总览 2026年3月 第3周 │
├─────────────────────────────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 12 │ │ 85% │ │ 92% │ │ 156 │ │
│ │ 发布任务 │ │ 平均完成率│ │ 教师反馈率│ │ 提交总数 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ 📈 趋势图(按天/按班级) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ ▓▓▓ │ │
│ │ ▓▓▓▓▓ ▓▓▓ │ │
│ │ ▓▓▓▓▓▓▓ ▓▓▓▓▓ ▓▓▓▓ ▓▓▓ │ │
│ │ ───────┬───────┬───────┬───────┬─────── │ │
│ │ 周一 周二 周三 周四 周五 │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
#### 4.3.3 任务列表(表格视图)
| 列名 | 说明 | 排序 |
|------|------|------|
| 任务标题 | 点击可查看详情 | ✅ |
| 发布教师 | 教师姓名 | ✅ |
| 班级 | 目标班级 | ✅ |
| 类型 | 阅读/活动/作业 | - |
| 目标人数 | 学生总数 | - |
| 提交数 | 已提交/总人数 | ✅ |
| 完成率 | 进度条显示 | ✅ |
| 反馈率 | 教师已评价比例 | ✅ |
| 发布时间 | yyyy-MM-dd | ✅ |
| 截止时间 | yyyy-MM-dd | ✅ |
| 状态 | 进行中/已截止 | - |
| 操作 | 查看详情 | - |
#### 4.3.4 任务详情(只读)
```
┌─────────────────────────────────────────────────────────────────┐
│ 《好饿的毛毛虫》阅读任务 [返回列表] │
├─────────────────────────────────────────────────────────────────┤
│ 基本信息 │
│ 发布教师: 李老师 班级: 大一班 类型: 阅读 │
│ 发布时间: 2026-03-18 截止时间: 2026-03-20 │
│ 关联绘本: 《好饿的毛毛虫》 │
│ 任务描述: 和孩子一起阅读这本经典绘本,完成后拍照上传... │
├─────────────────────────────────────────────────────────────────┤
│ 完成情况 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 目标: 25人 已提交: 20人 完成率: 80% │ │
│ │ ████████████████░░░░ 80% │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 学生列表 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 学生 │ 班级 │ 提交时间 │ 状态 │ 教师评价 │ │
│ ├──────────────────────────────────────────────────────────┤ │
│ │ 张小明 │ 大一班│ 03-19 18:30│ ✅优秀 │ 很棒!阅读认真... │ │
│ │ 李小红 │ 大一班│ 03-19 19:20│ ✅通过 │ 继续加油! │ │
│ │ 王小刚 │ 大一班│ 03-20 09:15│ ⏳待审 │ - │ │
│ │ 赵小丽 │ 大一班│ - │ ⭕未提交│ - │ │
│ │ ... │ ... │ ... │ ... │ ... │ │
│ └──────────────────────────────────────────────────────────┘ │
│ [导出明细] │
└─────────────────────────────────────────────────────────────────┘
```
#### 4.3.5 学生提交详情(只读)
学校端可查看具体学生的提交内容:
```
┌─────────────────────────────────────────────────────────────────┐
│ 张小明的提交 [返回] [导出] │
├─────────────────────────────────────────────────────────────────┤
│ 任务: 《好饿的毛毛虫》阅读任务 │
│ 提交时间: 2026-03-19 18:30 │
├─────────────────────────────────────────────────────────────────┤
│ 📷 照片 │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ 📸 │ │ 📸 │ │ 📸 │ 点击查看大图 │
│ └─────┘ └─────┘ └─────┘ │
│ │
│ 📝 阅读心得 │
│ "孩子很喜欢这本书,读了好几遍,还给我讲了毛毛虫变蝴蝶的故事..." │
├─────────────────────────────────────────────────────────────────┤
│ 教师评价 │
│ 评价结果: ⭐⭐⭐⭐⭐ 优秀 │
│ 评价内容: "很棒!阅读很认真,能复述故事内容,继续保持!" │
│ 评价时间: 2026-03-19 20:15 │
└─────────────────────────────────────────────────────────────────┘
```
#### 4.3.6 统计报表
| 报表类型 | 内容 | 导出格式 |
|---------|------|---------|
| **任务统计** | 按时间/班级/教师的任务发布和完成情况 | Excel |
| **班级对比** | 各班级完成率、反馈率对比 | Excel + 图表 |
| **教师排行** | 发布数、反馈率、平均评分 | Excel |
| **学生明细** | 指定条件下的学生完成明细 | Excel |
---
## 五、数据模型设计
### 5.1 实体关系图
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Teacher │────>│ Task │<────│ Student │
└──────────────┘ └──────────────┘ └──────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ TaskTarget │ │TaskCompletion│
└──────────────┘ └──────────────┘
┌──────────────┐
│TaskFeedback │
└──────────────┘
```
### 5.2 表结构设计
#### Task任务表- 新增字段
| 字段 | 类型 | 说明 | 变更 |
|------|------|------|------|
| id | BIGINT | 主键 | - |
| tenant_id | BIGINT | 租户ID学校 | - |
| title | VARCHAR(100) | 任务标题 | - |
| description | TEXT | 任务描述 | - |
| task_type | VARCHAR(20) | 类型READING/ACTIVITY/HOMEWORK | - |
| **related_book_name** | VARCHAR(200) | 关联绘本名称 | **新增** |
| course_id | BIGINT | 关联课程ID可选 | - |
| course_lesson_id | BIGINT | 关联课程环节ID可选 | - |
| creator_id | BIGINT | 创建人ID教师 | - |
| start_date | DATE | 开始日期 | - |
| due_date | DATE | 截止日期 | - |
| status | VARCHAR(20) | 状态DRAFT/PUBLISHED/ARCHIVED | - |
| created_at | DATETIME | 创建时间 | - |
| updated_at | DATETIME | 更新时间 | - |
#### TaskTarget任务目标表- 无变更
| 字段 | 类型 | 说明 |
|------|------|------|
| id | BIGINT | 主键 |
| task_id | BIGINT | 任务ID |
| target_type | VARCHAR(20) | 目标类型CLASS/STUDENT |
| target_id | BIGINT | 目标ID班级ID或学生ID |
#### TaskCompletion任务完成表- 扩展字段
| 字段 | 类型 | 说明 | 变更 |
|------|------|------|------|
| id | BIGINT | 主键 | - |
| task_id | BIGINT | 任务ID | - |
| student_id | BIGINT | 学生ID | - |
| status | VARCHAR(20) | 状态PENDING/SUBMITTED/REVIEWED | - |
| **photos** | JSON | 照片URL数组 | **扩展** |
| **video_url** | VARCHAR(500) | 视频URL | **新增** |
| **audio_url** | VARCHAR(500) | 语音URL | **新增** |
| content | TEXT | 文字心得 | - |
| submitted_at | DATETIME | 提交时间 | - |
| reviewed_at | DATETIME | 审核时间 | - |
| created_at | DATETIME | 创建时间 | - |
| updated_at | DATETIME | 更新时间 | - |
#### TaskFeedback任务反馈表- 新增
| 字段 | 类型 | 说明 |
|------|------|------|
| id | BIGINT | 主键 |
| completion_id | BIGINT | 完成记录ID |
| teacher_id | BIGINT | 教师ID |
| rating | INT | 评分 1-5可选 |
| result | VARCHAR(20) | 结果EXCELLENT/PASSED/NEEDS_WORK |
| comment | TEXT | 评语 |
| created_at | DATETIME | 创建时间 |
---
## 六、API 接口设计
### 6.1 教师端 API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/teacher/tasks` | 任务列表(支持筛选) |
| POST | `/api/v1/teacher/tasks` | 创建任务 |
| GET | `/api/v1/teacher/tasks/{id}` | 任务详情 |
| PUT | `/api/v1/teacher/tasks/{id}` | 更新任务 |
| DELETE | `/api/v1/teacher/tasks/{id}` | 删除任务 |
| GET | `/api/v1/teacher/tasks/{id}/completions` | 完成情况列表 |
| POST | `/api/v1/teacher/completions/{id}/feedback` | 提交评价 |
| POST | `/api/v1/teacher/tasks/{id}/remind` | 发送提醒 |
### 6.2 家长端 API完善
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/parent/tasks` | 孩子的任务列表 |
| GET | `/api/v1/parent/tasks/{id}` | 任务详情 |
| POST | `/api/v1/parent/tasks/{id}/submit` | 提交完成(含上传) |
| PUT | `/api/v1/parent/tasks/{id}/submit` | 修改提交 |
| GET | `/api/v1/parent/tasks/{id}/feedback` | 查看教师评价 |
### 6.3 学校端 API新增/完善)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/school/tasks` | 全校任务列表(支持多维度筛选) |
| GET | `/api/v1/school/tasks/{id}` | 任务详情 |
| GET | `/api/v1/school/tasks/{id}/completions` | 完成情况列表 |
| GET | `/api/v1/school/completions/{id}` | 学生提交详情 |
| GET | `/api/v1/school/tasks/statistics` | 统计数据 |
| GET | `/api/v1/school/tasks/statistics/trend` | 趋势数据 |
| GET | `/api/v1/school/tasks/export` | 导出报表 |
#### 学校端筛选参数
```typescript
// GET /api/v1/school/tasks
interface SchoolTaskQuery {
// 分页
pageNum?: number;
pageSize?: number;
// 时间筛选
dateType?: 'today' | 'week' | 'month' | 'semester' | 'custom';
startDate?: string; // 自定义开始日期
endDate?: string; // 自定义结束日期
// 组织筛选
classIds?: number[]; // 班级ID数组
teacherIds?: number[]; // 教师ID数组
// 状态筛选
taskType?: 'READING' | 'ACTIVITY' | 'HOMEWORK';
taskStatus?: 'PUBLISHED' | 'ARCHIVED';
completionRate?: 'high' | 'medium' | 'low'; // >80% / 50-80% / <50%
// 搜索
keyword?: string;
// 排序
sortBy?: 'createTime' | 'completionRate' | 'feedbackRate';
sortOrder?: 'asc' | 'desc';
}
```
---
## 七、UI 设计要点
### 7.1 学校端筛选区交互规范
#### 快捷时间选择器
```
┌─────────────────────────────────────────────────────┐
│ [今日] [本周 ✓] [本月] [本学期] [自定义] │
└─────────────────────────────────────────────────────┘
```
#### 已选条件标签
```
┌─────────────────────────────────────────────────────┐
│ 已选条件: │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 本周 ✕ │ │ 大一班 ✕ │ │ 李老师 ✕ │ │
│ └──────────┘ └──────────┘ └──────────┘ [清空全部] │
└─────────────────────────────────────────────────────┘
```
#### 班级/教师多选下拉
```
┌─────────────────────────────────────────────────────┐
│ 班级 ▼ │
│ ┌───────────────────────────────────────────────┐ │
│ │ ☑ 全选 │ │
│ │ ☑ 大一班 │ │
│ │ ☑ 大二班 │ │
│ │ ☐ 中一班 │ │
│ │ ☐ 中二班 │ │
│ │ │ │
│ │ [确定] [取消] │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
### 7.2 家长端提交页面
```
┌───────────────────────────────────────┐
│ 《好饿的毛毛虫》阅读任务 │
│ 截止时间: 明天 18:00 │
├───────────────────────────────────────┤
│ 📷 上传阅读照片 │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌───┐ │
│ │ 📸 │ │ 📸 │ │ 📸 │ │ + │ │
│ └─────┘ └─────┘ └─────┘ └───┘ │
│ 最多上传 9 张照片 │
├───────────────────────────────────────┤
│ 🎬 上传阅读视频(选填) │
│ ┌───────────────────────────────┐ │
│ │ 📹 │ │
│ │ 点击上传视频 │ │
│ │ 限 60 秒以内 │ │
│ └───────────────────────────────┘ │
├───────────────────────────────────────┤
│ 📝 阅读心得(选填) │
│ ┌───────────────────────────────┐ │
│ │ 孩子很喜欢这本书... │ │
│ │ │ │
│ └───────────────────────────────┘ │
│ 0/500 │
├───────────────────────────────────────┤
│ │
│ [ 提交完成 ] │
│ │
└───────────────────────────────────────┘
```
---
## 八、开发计划
### Phase 1核心功能2周
| 模块 | 功能 | 优先级 |
|------|------|--------|
| **后端** | Task 表新增 related_book_name 字段 | P0 |
| | TaskCompletion 表扩展 photos/video_url/audio_url | P0 |
| | 新增 TaskFeedback 表 | P0 |
| | 完善教师端 API评价、提醒 | P0 |
| | 完善家长端 API提交、查看评价 | P0 |
| | 新增学校端 API多维度筛选 | P0 |
| **前端-教师端** | 完善任务创建(添加绘本名称字段) | P0 |
| | 完善完成情况管理(评价功能) | P0 |
| **前端-家长端** | 新增任务提交页面(照片/视频上传) | P0 |
| | 新增评价查看功能 | P0 |
| **前端-学校端** | 新增多维度筛选组件 | P0 |
| | 新增任务列表(只读) | P0 |
| | 新增任务详情和学生提交详情 | P0 |
### Phase 2完善功能1周
| 模块 | 功能 | 优先级 |
|------|------|--------|
| **前端-教师端** | 任务模板功能 | P1 |
| | 批量评价 | P1 |
| **前端-家长端** | 视频录制和上传 | P1 |
| | 语音录制和上传 | P1 |
| | 历史记录完善 | P1 |
| **前端-学校端** | 统计图表 | P1 |
| | 导出报表 | P1 |
### Phase 3通知系统1周
| 模块 | 功能 | 优先级 |
|------|------|--------|
| **通知** | 新任务通知(推送至家长端) | P0 |
| | 截止提醒(推送至家长端) | P0 |
| | 评价通知(推送至家长端) | P0 |
| | 自动提醒截止前1天 | P1 |
---
## 九、现有代码改造点
### 9.1 家长端后端
**文件**: `ParentTaskController.java`
**现状**: `getMyTasks` 方法返回空数据TODO
**改造**:
1. 实现根据家长ID获取关联学生的任务
2. 新增 `POST /api/v1/parent/tasks/{id}/submit` 接口处理提交
3. 新增 `GET /api/v1/parent/tasks/{id}/feedback` 接口获取评价
### 9.2 家长端前端
**文件**: `TaskListView.vue`
**现状**: 有基础框架,仅支持文字反馈
**改造**:
1. 新增提交页面组件(支持照片/视频上传)
2. 新增评价查看组件
3. 完善任务状态显示和倒计时
---
## 十、风险与依赖
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 文件上传量增加 | OSS 存储成本 | 限制单个任务上传数量和大小 |
| 推送通知依赖 | 消息触达率 | 确保家长端已集成推送 SDK |
| 数据量增长 | 查询性能 | 添加索引,学校端分页查询 |
---
*文档版本历史:*
- v1.0 (2026-03-20): 初稿
- v1.1 (2026-03-20): 完善学校端筛选设计、家长端功能、开发计划

320
docs/dev-logs/2026-03-20.md Normal file
View File

@ -0,0 +1,320 @@
# 开发日志 - 2026-03-20
## 阅读任务模块重写 Phase 1 - 后端基础改造
### 完成的工作
#### 1. 数据库迁移脚本
- 文件: `V43__add_reading_task_features.sql`
- 创建 `task_feedback` 表(评价表)
- 为 `task` 表添加 `related_book_name` 字段
- 为 `task_completion` 表添加 `video_url`, `audio_url` 字段
#### 2. 实体类
- `TaskFeedback.java` - 新增评价实体
- `Task.java` - 添加 `relatedBookName` 字段(已存在)
- `TaskCompletion.java` - 添加新字段(已存在)
#### 3. DTO
- `TaskFeedbackRequest.java` - 评价请求 DTO
- `TaskFeedbackResponse.java` - 评价响应 DTO
- `TaskCompletionDetailResponse.java` - 完成详情响应 DTO
- `TaskSubmitRequest.java` - 家长提交请求 DTO
#### 4. Service 层
**新增**:
- `TaskFeedbackService.java` - 评价服务接口
- `TaskFeedbackServiceImpl.java` - 评价服务实现
**扩展**:
- `TaskService.java` - 添加以下方法:
- `getTaskCompletions()` - 获取任务完成情况列表
- `getCompletionDetail()` - 获取提交详情
- `submitTaskCompletion()` - 家长提交任务完成
- `getSchoolTaskList()` - 学校端任务列表(多维度筛选)
- `getTaskDetailForSchool()` - 学校端任务详情
- `ClassService.java` - 添加:
- `getPrimaryClassByStudentId()` - 获取学生当前所在班级
#### 5. Controller 层
**教师端** (`TeacherTaskController.java`):
- `GET /api/v1/teacher/tasks/{taskId}/completions` - 完成情况列表
- `GET /api/v1/teacher/tasks/completions/{completionId}` - 提交详情
- `POST /api/v1/teacher/tasks/completions/{completionId}/feedback` - 提交评价
- `PUT /api/v1/teacher/tasks/completions/{completionId}/feedback` - 修改评价
**家长端** (`ParentTaskController.java`):
- `POST /api/v1/parent/tasks/{taskId}/submit` - 提交任务完成
- `PUT /api/v1/parent/tasks/{taskId}/submit` - 修改提交
- `GET /api/v1/parent/tasks/completions/{completionId}/feedback` - 获取评价
- `GET /api/v1/parent/tasks/completions/{completionId}` - 获取提交详情
**学校端** (`SchoolTaskController.java`) - **完全重写为只读**:
- `GET /api/v1/school/reading-tasks` - 任务列表(多维度筛选)
- `GET /api/v1/school/reading-tasks/{taskId}` - 任务详情
- `GET /api/v1/school/reading-tasks/{taskId}/completions` - 完成情况列表
- `GET /api/v1/school/reading-tasks/completions/{completionId}` - 学生提交详情
- `GET /api/v1/school/reading-tasks/statistics` - 统计数据TODO
**移除的学校端 API**:
- ~~POST 创建任务~~
- ~~PUT 更新任务~~
- ~~DELETE 删除任务~~
### 关键设计决策
1. **学校端只读**: 学校端 API 路径从 `/api/v1/school/tasks` 改为 `/api/v1/school/reading-tasks`,完全只读
2. **评价结果枚举**: `EXCELLENT`(优秀)/ `PASSED`(通过)/ `NEEDS_WORK`(需改进)
3. **完成状态**: `PENDING`(待提交)/ `SUBMITTED`(已提交)/ `REVIEWED`(已评价)
4. **文件存储**: 继续使用现有 OSS照片以 JSON 数组存储
---
## 阅读任务模块重写 Phase 2 - 教师端前端改造 ✅
### 完成的工作
#### 1. API 类型更新 (`src/api/teacher.ts`)
- 更新 `TaskCompletion` 接口添加新字段:
- `photos: string[]` - 照片列表
- `videoUrl: string` - 视频链接
- `audioUrl: string` - 音频链接
- `content: string` - 提交内容
- `submittedAt: string` - 提交时间
- `reviewedAt: string` - 评价时间
- `feedback: TaskFeedback` - 关联评价
- 添加 `TaskFeedbackDto` 接口(评价请求)
- 添加新 API 函数:
- `getTeacherTaskCompletions()` - 获取完成情况列表
- `getCompletionDetail()` - 获取提交详情
- `submitCompletionFeedback()` - 提交评价
- `updateCompletionFeedback()` - 修改评价
#### 2. TaskListView.vue 改造
**创建任务弹窗**:
- [x] 添加 `relatedBookName`(关联绘本名称)字段
- [x] 编辑任务时加载已有绘本名称
**完成情况弹窗改造**:
- [x] 状态筛选改为新状态值PENDING/SUBMITTED/REVIEWED
- [x] 显示提交内容预览(照片数量、视频、文字内容)
- [x] 统计标签更新为:待提交/已提交/已评价
**评价弹窗组件**:
- [x] 新增评价弹窗 UI
- [x] 提交内容预览区域(照片、视频、音频、文字)
- [x] 评价结果选择(优秀/通过/需改进)
- [x] 评分组件1-5 星)
- [x] 评语输入框
- [x] 支持新建和编辑评价
**状态显示逻辑**:
- [x] `getCompletionStatusColor()` - 新状态颜色映射
- [x] `getCompletionStatusText()` - 新状态文本映射
- [x] `completionStats` computed 使用新状态值
### 文件变更清单
**修改文件**:
- `src/api/teacher.ts` - API 类型和函数
- `src/views/teacher/tasks/TaskListView.vue` - 教师任务管理页面
### TypeScript 编译验证
- `src/views/teacher/tasks/TaskListView.vue` ✅ 无错误
---
## 下一步
### Phase 3: 家长端前端改造
- [ ] 改造提交功能
- [ ] 新增查看评价页面
### Phase 4: 学校端前端重写
- [ ] 重写任务列表页(只读+多维度筛选)
- [ ] 新增任务详情页
- [ ] 新增学生提交详情页
---
## 阅读任务模块重写 Phase 3 - 家长端前端改造 ✅
### 完成的工作
#### 1. API 类型更新 (`src/api/parent.ts`)
- 添加新的类型定义:
- `TaskCompletionStatus` - 完成状态类型
- `FeedbackResult` - 评价结果类型
- `TeacherFeedback` - 教师评价接口
- `TaskCompletion` - 任务完成记录接口
- `TaskSubmitRequest` - 提交请求接口
- 更新 `TaskWithCompletion` 接口添加新字段:
- `photos: string[]` - 照片列表
- `videoUrl: string` - 视频链接
- `audioUrl: string` - 音频链接
- `content: string` - 提交内容
- `submittedAt: string` - 提交时间
- `reviewedAt: string` - 评价时间
- `teacherFeedback: TeacherFeedback` - 教师评价
- 添加新 API 函数:
- `submitTaskCompletion()` - 提交任务完成
- `updateTaskCompletion()` - 修改提交
- `getCompletionDetail()` - 获取提交详情
- `getCompletionFeedback()` - 获取教师评价
#### 2. TaskListView.vue 完全重写
**任务卡片展示**:
- [x] 显示关联绘本名称
- [x] 状态标签更新为新状态(待提交/已提交/已评价)
- [x] 已提交内容预览(照片数量、视频、音频、文字)
- [x] 教师评价显示区域(评价结果、评分、评语)
**提交功能改造**:
- [x] 照片上传最多9张
- [x] 视频链接输入
- [x] 音频链接输入
- [x] 阅读心得文字输入
- [x] 支持新建和修改提交
**查看评价功能**:
- [x] 评价详情弹窗
- [x] 评价结果展示(优秀/通过/需改进)
- [x] 评分显示1-5星
- [x] 教师评语展示
- [x] 提交内容回顾(照片网格、链接、文字)
- [x] 图片预览功能
**状态驱动操作**:
- [x] 待提交状态 → 显示"提交完成"按钮
- [x] 已提交状态 → 显示"修改提交"按钮
- [x] 已评价状态 → 显示"查看评价详情"按钮
### 文件变更清单
**修改文件**:
- `src/api/parent.ts` - API 类型和函数
- `src/views/parent/tasks/TaskListView.vue` - 家长任务管理页面
### TypeScript 编译验证
- `src/api/parent.ts` ✅ 无错误
- `src/views/parent/tasks/TaskListView.vue` ✅ 无错误
---
## 阅读任务模块重写 Phase 4 - 学校端前端重写 ✅
### 完成的工作
#### 1. API 层更新 (`src/api/school.ts`)
**新增只读接口类型**:
- `TaskCompletionStatus` - 完成状态PENDING/SUBMITTED/REVIEWED
- `FeedbackResult` - 评价结果EXCELLENT/PASSED/NEEDS_WORK
- `TaskTeacherFeedback` - 教师评价接口
- `TaskCompletionRecord` - 任务完成记录(新设计)
- `SchoolTaskQueryParams` - 多维度筛选参数
**新增 API 函数**:
- `getReadingTaskList()` - 获取任务列表(多维度筛选)
- `getReadingTaskDetail()` - 获取任务详情
- `getReadingTaskCompletions()` - 获取完成情况列表
- `getCompletionDetail()` - 获取提交详情
**保留旧接口**: 保留 create/update/delete 函数以保持向后兼容
#### 2. TaskListView.vue 完全重写(只读模式)
**移除的功能**:
- ❌ 创建任务
- ❌ 编辑任务
- ❌ 删除任务
- ❌ 发布任务按钮
**新增/改进的功能**:
**统计卡片**:
- [x] 全部任务数
- [x] 进行中任务数
- [x] 已提交数量
- [x] 已评价数量
**多维度筛选**:
- [x] 关键字搜索
- [x] 任务类型筛选(阅读/活动/作业)
- [x] 任务状态筛选(进行中/草稿/已归档)
- [x] 日期范围筛选
- [x] 排序方式选择
**任务列表**:
- [x] 卡片式展示
- [x] 显示关联绘本名称
- [x] 显示创建人
- [x] 完成情况统计(待提交/已提交/已评价)
- [x] 完成率进度条
- [x] 点击查看详情
**任务详情弹窗**:
- [x] 基本信息(类型、状态、时间等)
- [x] 关联绘本
- [x] 完成情况统计
- [x] 查看完成列表入口
**完成情况列表弹窗**:
- [x] 学生信息(姓名、班级、头像)
- [x] 状态标签
- [x] 提交内容预览(照片数量、视频、音频)
- [x] 教师评价显示
- [x] 筛选和分页
- [x] 查看详情按钮
**提交详情弹窗**:
- [x] 学生信息卡片
- [x] 提交状态显示
- [x] 照片网格展示(支持预览)
- [x] 视频/音频链接
- [x] 阅读心得文字
- [x] 提交时间
- [x] 教师评价详情(结果、评分、评语)
- [x] 评价时间
### 文件变更清单
**修改文件**:
- `src/api/school.ts` - 新增只读接口类型和函数
- `src/views/school/tasks/TaskListView.vue` - 完全重写为只读模式
### TypeScript 编译验证
- `src/api/school.ts` ✅ 无错误
- `src/views/school/tasks/TaskListView.vue` ✅ 无错误
---
## 总结
### 阅读任务模块重写全部完成 ✅
| Phase | 端 | 状态 |
|-------|-----|------|
| Phase 1 | 后端基础改造 | ✅ 完成 |
| Phase 2 | 教师端前端 | ✅ 完成 |
| Phase 3 | 家长端前端 | ✅ 完成 |
| Phase 4 | 学校端前端 | ✅ 完成 |
### 关键设计决策
1. **学校端只读**: 学校端 API 路径改为 `/api/v1/school/reading-tasks`,完全只读
2. **新状态值**: `PENDING``SUBMITTED``REVIEWED`
3. **评价结果**: `EXCELLENT`(优秀)/ `PASSED`(通过)/ `NEEDS_WORK`(需改进)
4. **多维度筛选**: 支持关键字、类型、状态、日期范围、排序等筛选条件
---
*Last updated: 2026-03-20 19:30*

View File

@ -46,12 +46,63 @@ export interface LessonRecord {
createdAt: string;
}
// 任务完成状态
export type TaskCompletionStatus = 'PENDING' | 'SUBMITTED' | 'REVIEWED';
// 教师评价结果
export type FeedbackResult = 'EXCELLENT' | 'PASSED' | 'NEEDS_WORK';
// 教师评价
export interface TeacherFeedback {
id: number;
result: FeedbackResult;
resultText?: string;
rating?: number;
comment?: string;
createdAt: string;
teacherName?: string;
}
// 任务完成记录(新设计)
export interface TaskCompletion {
id: number;
taskId: number;
studentId: number;
status: TaskCompletionStatus;
statusText?: string;
photos?: string[];
videoUrl?: string;
audioUrl?: string;
content?: string;
submittedAt?: string;
reviewedAt?: string;
createdAt: string;
feedback?: TeacherFeedback;
}
// 任务完成提交请求
export interface TaskSubmitRequest {
studentId: number;
photos?: string[];
videoUrl?: string;
audioUrl?: string;
content?: string;
}
// 带完成信息的任务(兼容旧接口)
export interface TaskWithCompletion {
id: number;
status: string;
status: TaskCompletionStatus;
completedAt?: string;
feedback?: string;
parentFeedback?: string;
submittedAt?: string;
reviewedAt?: string;
photos?: string[];
videoUrl?: string;
audioUrl?: string;
content?: string;
teacherFeedback?: TeacherFeedback;
task: {
id: number;
title: string;
@ -59,6 +110,7 @@ export interface TaskWithCompletion {
taskType: string;
startDate: string;
endDate: string;
relatedBookName?: string;
course?: {
id: number;
name: string;
@ -120,6 +172,33 @@ export const getChildTasks = (
pageSize: res.pageSize || 10,
}));
// 提交任务完成(新版)
export const submitTaskCompletion = (
taskId: number,
data: TaskSubmitRequest
): Promise<TaskCompletion> =>
http.post(`/v1/parent/tasks/${taskId}/submit`, data) as any;
// 修改任务提交
export const updateTaskCompletion = (
taskId: number,
data: TaskSubmitRequest
): Promise<TaskCompletion> =>
http.put(`/v1/parent/tasks/${taskId}/submit`, data) as any;
// 获取提交详情
export const getCompletionDetail = (
completionId: number
): Promise<TaskCompletion> =>
http.get(`/v1/parent/tasks/completions/${completionId}`) as any;
// 获取教师评价
export const getCompletionFeedback = (
completionId: number
): Promise<TeacherFeedback> =>
http.get(`/v1/parent/tasks/completions/${completionId}/feedback`) as any;
// 兼容旧接口(保留向后兼容)
export const submitTaskFeedback = (
childId: number,
taskId: number,

View File

@ -889,11 +889,11 @@ export const getTaskTemplates = (params?: {
keyword?: string;
}) => http.get<{ list: TaskTemplate[]; total: number; pageNum: number; pageSize: number; pages: number }>('/v1/school/task-templates', { params })
.then(res => ({
list: res.list || res.records || [],
total: Number(res.total) || 0,
pageNum: Number(res.pageNum) || 1,
pageSize: Number(res.pageSize) || 10,
pages: Number(res.pages) || 0,
list: res.list || [],
total: res.total || 0,
pageNum: res.pageNum || 1,
pageSize: res.pageSize || 10,
pages: res.pages || 0,
}));
export const getTaskTemplate = (id: number) =>
@ -971,6 +971,51 @@ export const getMonthlyTaskStats = (_months?: number) =>
// ==================== 任务管理 API ====================
// 任务完成状态(新设计)
export type TaskCompletionStatus = 'PENDING' | 'SUBMITTED' | 'REVIEWED';
// 评价结果
export type FeedbackResult = 'EXCELLENT' | 'PASSED' | 'NEEDS_WORK';
// 教师评价
export interface TaskTeacherFeedback {
id: number;
result: FeedbackResult;
resultText?: string;
rating?: number;
comment?: string;
createdAt: string;
teacherName?: string;
}
// 任务完成记录(新设计)
export interface TaskCompletionRecord {
id: number;
taskId: number;
studentId: number;
status: TaskCompletionStatus;
statusText?: string;
photos?: string[];
videoUrl?: string;
audioUrl?: string;
content?: string;
submittedAt?: string;
reviewedAt?: string;
createdAt: string;
student?: {
id: number;
name: string;
avatar?: string;
gender?: string;
classInfo?: {
id: number;
name: string;
grade?: string;
};
};
feedback?: TaskTeacherFeedback;
}
export interface SchoolTask {
id: number;
tenantId: number;
@ -979,6 +1024,7 @@ export interface SchoolTask {
taskType: 'READING' | 'ACTIVITY' | 'HOMEWORK';
targetType: 'CLASS' | 'STUDENT';
relatedCourseId?: number;
relatedBookName?: string;
course?: {
id: number;
name: string;
@ -987,27 +1033,24 @@ export interface SchoolTask {
endDate: string;
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
createdBy: number;
creatorName?: string;
targetCount?: number;
completionCount?: number;
createdAt: string;
updatedAt: string;
}
// 旧接口类型(兼容)
export interface TaskCompletion {
id: number;
taskId: number;
studentId: number;
studentName: string;
className: string;
status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED';
completedAt?: string;
feedback?: string;
parentFeedback?: string;
rating?: number;
student?: {
id: number;
name: string;
gender?: string;
class?: { id: number; name: string };
};
}
export interface CreateSchoolTaskDto {
@ -1031,6 +1074,61 @@ export interface UpdateSchoolTaskDto {
status?: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
}
// 学校端任务查询参数(多维度筛选)
export interface SchoolTaskQueryParams {
pageNum?: number;
pageSize?: number;
keyword?: string;
type?: string;
status?: string;
classIds?: number[];
teacherIds?: number[];
dateType?: 'startDate' | 'endDate' | 'deadline';
startDate?: string;
endDate?: string;
completionRate?: 'high' | 'medium' | 'low';
sortBy?: 'createdAt' | 'startDate' | 'endDate' | 'completionRate';
sortOrder?: 'asc' | 'desc';
}
// ========== 新 API只读接口 ==========
// 获取学校端任务列表(只读,多维度筛选)
export const getReadingTaskList = (params?: SchoolTaskQueryParams) =>
http.get<{ list: SchoolTask[]; total: number; pageNum: number; pageSize: number; pages: number }>('/v1/school/reading-tasks', { params })
.then(res => ({
items: res.list || [],
total: res.total || 0,
pageNum: res.pageNum || 1,
pageSize: res.pageSize || 10,
pages: res.pages || 0,
}));
// 获取任务详情(只读)
export const getReadingTaskDetail = (taskId: number) =>
http.get<SchoolTask>(`/v1/school/reading-tasks/${taskId}`);
// 获取任务完成情况列表
export const getReadingTaskCompletions = (taskId: number, params?: {
pageNum?: number;
pageSize?: number;
status?: string;
}) => http.get<{ list: TaskCompletionRecord[]; total: number; pageNum: number; pageSize: number }>(
`/v1/school/reading-tasks/${taskId}/completions`,
{ params }
).then(res => ({
items: res.list || [],
total: res.total || 0,
pageNum: res.pageNum || 1,
pageSize: res.pageSize || 10,
}));
// 获取学生提交详情
export const getCompletionDetail = (completionId: number) =>
http.get<TaskCompletionRecord>(`/v1/school/reading-tasks/completions/${completionId}`);
// ========== 旧 API保留向后兼容但不再使用 ==========
export const getSchoolTasks = (params?: {
pageNum?: number;
pageSize?: number;
@ -1051,27 +1149,8 @@ export const updateSchoolTask = (id: number, data: UpdateSchoolTaskDto) =>
export const deleteSchoolTask = (id: number) =>
http.delete<{ message: string }>(`/v1/school/tasks/${id}`);
export interface TaskCompletionListResponse {
items: TaskCompletion[];
total: number;
page: number;
pageSize: number;
stats: { PENDING: number; IN_PROGRESS: number; COMPLETED: number };
}
export const getSchoolTaskCompletions = (
taskId: number,
params?: { page?: number; pageSize?: number; status?: string }
) =>
http.get<TaskCompletionListResponse>(`/v1/school/tasks/${taskId}/completions`, {
params: { page: params?.page ?? 1, pageSize: params?.pageSize ?? 20, status: params?.status },
}).then((res: TaskCompletionListResponse) => ({
items: res.items ?? [],
total: res.total ?? 0,
page: res.page ?? 1,
pageSize: res.pageSize ?? 20,
stats: res.stats ?? { PENDING: 0, IN_PROGRESS: 0, COMPLETED: 0 },
}));
export const getSchoolTaskCompletions = (taskId: number) =>
http.get<TaskCompletion[]>(`/v1/school/tasks/${taskId}/completions`);
export const getSchoolClasses = () =>
http.get<ClassInfo[]>('/v1/school/classes');

View File

@ -1,9 +1,4 @@
import { http } from './index';
import type {
TeacherCourseControllerGetTeacherSchedulesParams,
TeacherCourseControllerGetTeacherTimetableParams,
TeacherFeedbackControllerFindAllParams,
} from './generated/model';
// ============= 类型定义(保持向后兼容) =============
@ -644,6 +639,7 @@ export interface TeacherTask {
targetType: 'CLASS' | 'STUDENT';
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
relatedCourseId?: number;
relatedBookName?: string; // 关联绘本名称
startDate: string;
endDate: string;
createdBy: number;
@ -661,20 +657,47 @@ export interface TaskCompletion {
id: number;
taskId: number;
studentId: number;
status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED';
completedAt?: string;
feedback?: string;
parentFeedback?: string;
status: 'PENDING' | 'SUBMITTED' | 'REVIEWED';
statusText?: string;
photos?: string[];
videoUrl?: string;
audioUrl?: string;
content?: string;
parentFeedback?: string; // 家长反馈(兼容旧字段)
submittedAt?: string;
reviewedAt?: string;
completedAt?: string; // 完成时间(兼容旧字段)
createdAt: string;
student: {
id: number;
name: string;
avatar?: string;
gender?: string;
class?: {
id: number;
name: string;
grade?: string;
};
classInfo?: {
id: number;
name: string;
grade?: string;
};
};
feedback?: TaskFeedback;
}
export interface TaskFeedback {
id: number;
completionId: number;
result: 'EXCELLENT' | 'PASSED' | 'NEEDS_WORK';
resultText?: string;
rating?: number;
comment?: string;
teacherId?: number;
teacherName?: string;
teacherAvatar?: string;
createdAt?: string;
}
export interface CreateTeacherTaskDto {
@ -684,10 +707,17 @@ export interface CreateTeacherTaskDto {
targetType: 'CLASS' | 'STUDENT';
targetIds: number[];
relatedCourseId?: number;
relatedBookName?: string; // 关联绘本名称
startDate: string;
endDate: string;
}
export interface TaskFeedbackDto {
result: 'EXCELLENT' | 'PASSED' | 'NEEDS_WORK';
rating?: number;
comment?: string;
}
export interface UpdateTaskCompletionDto {
status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED';
feedback?: string;
@ -706,18 +736,34 @@ export const getTeacherTasks = (params?: { pageNum?: number; pageSize?: number;
export const getTeacherTask = (id: number) =>
http.get(`/v1/teacher/tasks/${id}`) as any;
// 获取教师任务完成情况
export const getTeacherTaskCompletions = (taskId: number, params?: { page?: number; pageSize?: number; status?: string }) =>
http.get<{ items: TaskCompletion[]; total: number; page: number; pageSize: number }>(
// 获取任务完成情况列表(新版,支持分页和状态筛选)
export const getTeacherTaskCompletions = (taskId: number, params?: {
pageNum?: number;
pageSize?: number;
status?: string;
}) =>
http.get<{ list: any[]; total: number; pageNum: number; pageSize: number }>(
`/v1/teacher/tasks/${taskId}/completions`,
{ params: { page: params?.page ?? 1, pageSize: params?.pageSize ?? 20, status: params?.status } }
{ params }
).then(res => ({
items: res.items ?? res.list ?? [],
total: res.total ?? 0,
page: res.page ?? res.pageNum ?? 1,
pageSize: res.pageSize ?? 20,
items: res.list || [],
total: res.total || 0,
page: res.pageNum || 1,
pageSize: res.pageSize || 10,
}));
// 获取提交详情
export const getCompletionDetail = (completionId: number) =>
http.get<TaskCompletion>(`/v1/teacher/tasks/completions/${completionId}`) as any;
// 提交评价
export const submitCompletionFeedback = (completionId: number, data: TaskFeedbackDto) =>
http.post<TaskFeedback>(`/v1/teacher/tasks/completions/${completionId}/feedback`, data) as any;
// 修改评价
export const updateCompletionFeedback = (completionId: number, data: TaskFeedbackDto) =>
http.put<TaskFeedback>(`/v1/teacher/tasks/completions/${completionId}/feedback`, data) as any;
export const createTeacherTask = (data: CreateTeacherTaskDto) =>
http.post('/v1/teacher/tasks', data) as any;
@ -727,12 +773,12 @@ export const updateTeacherTask = (id: number, data: Partial<CreateTeacherTaskDto
export const deleteTeacherTask = (id: number) =>
http.delete(`/v1/teacher/tasks/${id}`) as any;
// 更新任务完成状态
export const updateTaskCompletion = (taskId: number, studentId: number, data: UpdateTaskCompletionDto) =>
http.put(`/v1/teacher/tasks/${taskId}/completions/${studentId}`, data) as Promise<any>;
// 兼容旧代码的函数
export const updateTaskCompletion = (_taskId: number, _studentId: number, _data: UpdateTaskCompletionDto) =>
Promise.reject(new Error('请使用 submitCompletionFeedback 或 updateCompletionFeedback'));
export const sendTaskReminder = (_taskId: number) =>
Promise.reject(new Error('接口未实现'));
http.post(`/v1/teacher/tasks/${_taskId}/remind`) as any;
// ==================== 任务模板 API ====================
@ -772,8 +818,8 @@ export interface CreateTaskFromTemplateDto {
startDate?: string;
}
export const getTaskTemplates = () =>
http.get<{ records: any[]; total: number }>('/v1/teacher/task-templates')
export const getTaskTemplates = (params?: { pageNum?: number; pageSize?: number }) =>
http.get<{ records: any[]; total: number }>('/v1/teacher/task-templates', { params })
.then(res => ({
items: res.records || [],
total: res.total || 0,

View File

@ -2,7 +2,7 @@
<div class="task-list-view">
<div class="page-header">
<h2><CheckSquareOutlined /> 阅读任务</h2>
<p class="page-desc">查看孩子的阅读任务并提交反馈</p>
<p class="page-desc">查看孩子的阅读任务并提交完成情况</p>
</div>
<a-spin :spinning="loading">
@ -10,10 +10,15 @@
<div v-for="task in tasks" :key="task.id" class="task-card">
<div class="card-header">
<div class="task-info">
<h3>{{ task.task.title }}</h3>
<div class="task-title-row">
<h3>{{ task.task.title }}</h3>
<a-tag v-if="task.task.relatedBookName" color="purple" size="small">
<BookOutlined /> {{ task.task.relatedBookName }}
</a-tag>
</div>
<div class="task-meta">
<span v-if="task.task.course?.name">
<BookOutlined /> {{ task.task.course.name }}
<FolderOutlined /> {{ task.task.course.name }}
</span>
<span>
<ClockCircleOutlined />
@ -30,18 +35,68 @@
<p>{{ task.task.description }}</p>
</div>
<div class="card-footer">
<div class="feedback-section" v-if="task.parentFeedback">
<span class="feedback-label">您的反馈:</span>
<span class="feedback-content">{{ task.parentFeedback }}</span>
<!-- 已提交内容预览 -->
<div class="submission-preview" v-if="task.status !== 'PENDING' && (task.photos?.length || task.videoUrl || task.audioUrl || task.content)">
<div class="preview-title">已提交内容</div>
<div class="preview-items">
<span v-if="task.photos?.length" class="preview-item">
<PictureOutlined /> {{ task.photos.length }} 张照片
</span>
<span v-if="task.videoUrl" class="preview-item">
<VideoCameraOutlined /> 有视频
</span>
<span v-if="task.audioUrl" class="preview-item">
<SoundOutlined /> 有音频
</span>
<span v-if="task.content" class="preview-item">
<FileTextOutlined /> {{ task.content.substring(0, 30) }}{{ task.content.length > 30 ? '...' : '' }}
</span>
</div>
<div class="submission-time" v-if="task.submittedAt">
提交于 {{ formatDateTime(task.submittedAt) }}
</div>
</div>
<!-- 教师评价显示 -->
<div class="teacher-feedback" v-if="task.teacherFeedback">
<div class="feedback-header">
<StarFilled style="color: #faad14;" />
<span>教师评价</span>
<a-tag :color="getFeedbackResultColor(task.teacherFeedback.result)" size="small">
{{ getFeedbackResultText(task.teacherFeedback.result) }}
</a-tag>
</div>
<div class="feedback-rating" v-if="task.teacherFeedback.rating">
<a-rate :value="task.teacherFeedback.rating" disabled :count="5" />
</div>
<div class="feedback-comment" v-if="task.teacherFeedback.comment">
{{ task.teacherFeedback.comment }}
</div>
</div>
<div class="card-footer">
<div class="action-buttons">
<!-- 待提交状态 -->
<template v-if="task.status === 'PENDING'">
<a-button type="primary" @click="openSubmitModal(task)">
<EditOutlined /> 提交完成
</a-button>
</template>
<!-- 已提交但未评价 -->
<template v-else-if="task.status === 'SUBMITTED'">
<a-button @click="openSubmitModal(task)">
<EditOutlined /> 修改提交
</a-button>
</template>
<!-- 已评价 -->
<template v-else-if="task.status === 'REVIEWED'">
<a-button type="link" @click="openFeedbackDetail(task)">
<EyeOutlined /> 查看评价详情
</a-button>
</template>
</div>
<a-button
v-else
type="link"
@click="openFeedbackModal(task)"
>
<EditOutlined /> 提交反馈
</a-button>
</div>
</div>
</div>
@ -52,36 +107,172 @@
</div>
</a-spin>
<!-- 反馈弹窗 -->
<!-- 提交任务弹窗 -->
<a-modal
v-model:open="submitModalVisible"
:title="isEditSubmit ? '修改提交' : '提交任务完成'"
@ok="handleSubmit"
:confirm-loading="submitting"
width="600px"
okText="提交"
cancelText="取消"
>
<div class="task-info-header" v-if="selectedTask">
<h4>{{ selectedTask.task.title }}</h4>
<p v-if="selectedTask.task.relatedBookName">
<BookOutlined /> {{ selectedTask.task.relatedBookName }}
</p>
</div>
<a-form layout="vertical">
<a-form-item label="照片">
<div class="photo-upload-area">
<div class="photo-list">
<div v-for="(photo, index) in submitForm.photos" :key="index" class="photo-item">
<img :src="photo" alt="照片" />
<div class="photo-remove" @click="removePhoto(index)">
<CloseCircleOutlined />
</div>
</div>
</div>
<a-upload
:customRequest="handlePhotoUpload"
:showUploadList="false"
accept="image/*"
multiple
>
<div class="upload-btn">
<PlusOutlined />
<span>添加照片</span>
</div>
</a-upload>
</div>
<div class="form-tip">最多上传9张照片</div>
</a-form-item>
<a-form-item label="视频链接">
<a-input
v-model:value="submitForm.videoUrl"
placeholder="请输入视频链接(可选,如腾讯视频、优酷等分享链接)"
allow-clear
/>
</a-form-item>
<a-form-item label="音频链接">
<a-input
v-model:value="submitForm.audioUrl"
placeholder="请输入音频链接(可选)"
allow-clear
/>
</a-form-item>
<a-form-item label="阅读心得">
<a-textarea
v-model:value="submitForm.content"
placeholder="请描述孩子的阅读情况、感受等(可选)"
:rows="4"
:maxlength="500"
show-count
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 评价详情弹窗 -->
<a-modal
v-model:open="feedbackModalVisible"
title="提交家长反馈"
@ok="submitFeedback"
:confirm-loading="submitting"
title="教师评价详情"
:footer="null"
width="500px"
>
<a-textarea
v-model:value="feedbackContent"
placeholder="请输入您对孩子完成任务的反馈..."
:rows="4"
:maxlength="500"
show-count
/>
<div class="feedback-detail" v-if="selectedTask?.teacherFeedback">
<div class="feedback-result-section">
<div class="result-label">评价结果</div>
<a-tag :color="getFeedbackResultColor(selectedTask.teacherFeedback.result)" size="large">
{{ getFeedbackResultText(selectedTask.teacherFeedback.result) }}
</a-tag>
</div>
<div class="feedback-rating-section" v-if="selectedTask.teacherFeedback.rating">
<div class="rating-label">评分</div>
<a-rate :value="selectedTask.teacherFeedback.rating" disabled :count="5" />
<span class="rating-text">{{ selectedTask.teacherFeedback.rating }} </span>
</div>
<div class="feedback-comment-section" v-if="selectedTask.teacherFeedback.comment">
<div class="comment-label">教师评语</div>
<div class="comment-content">{{ selectedTask.teacherFeedback.comment }}</div>
</div>
<div class="feedback-time">
评价时间: {{ formatDateTime(selectedTask.teacherFeedback.createdAt) }}
</div>
</div>
<!-- 提交内容回顾 -->
<div class="submission-review" v-if="selectedTask">
<a-divider>提交内容</a-divider>
<div class="review-photos" v-if="selectedTask.photos?.length">
<div class="photos-grid">
<img v-for="(photo, idx) in selectedTask.photos" :key="idx" :src="photo" alt="照片" @click="previewImage(photo)" />
</div>
</div>
<div class="review-links" v-if="selectedTask.videoUrl || selectedTask.audioUrl">
<a v-if="selectedTask.videoUrl" :href="selectedTask.videoUrl" target="_blank">
<VideoCameraOutlined /> 查看视频
</a>
<a v-if="selectedTask.audioUrl" :href="selectedTask.audioUrl" target="_blank">
<SoundOutlined /> 收听音频
</a>
</div>
<div class="review-content" v-if="selectedTask.content">
<div class="content-label">阅读心得</div>
<p>{{ selectedTask.content }}</p>
</div>
</div>
</a-modal>
<!-- 图片预览 -->
<a-image
:style="{ display: 'none' }"
:preview="{
visible: imagePreviewVisible,
onVisibleChange: (visible: boolean) => { imagePreviewVisible = visible; },
}"
:src="previewImageUrl"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, reactive, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { message, Upload } from 'ant-design-vue';
import type { UploadRequestOption } from 'ant-design-vue/es/vc-upload/interface';
import {
CheckSquareOutlined,
BookOutlined,
ClockCircleOutlined,
EditOutlined,
InboxOutlined,
EyeOutlined,
PlusOutlined,
CloseCircleOutlined,
PictureOutlined,
VideoCameraOutlined,
SoundOutlined,
FileTextOutlined,
FolderOutlined,
StarFilled,
} from '@ant-design/icons-vue';
import { getChildTasks, submitTaskFeedback, getChildren, type TaskWithCompletion } from '@/api/parent';
import {
getChildTasks,
submitTaskCompletion,
updateTaskCompletion,
type TaskWithCompletion,
type TaskSubmitRequest,
} from '@/api/parent';
import { uploadFile } from '@/api/file';
import dayjs from 'dayjs';
const route = useRoute();
@ -90,32 +281,86 @@ const loading = ref(false);
const tasks = ref<TaskWithCompletion[]>([]);
const currentChildId = ref<number | null>(null);
const feedbackModalVisible = ref(false);
const feedbackContent = ref('');
const selectedTask = ref<TaskWithCompletion | null>(null);
//
const submitModalVisible = ref(false);
const submitting = ref(false);
const selectedTask = ref<TaskWithCompletion | null>(null);
const isEditSubmit = ref(false);
const submitForm = reactive({
photos: [] as string[],
videoUrl: '',
audioUrl: '',
content: '',
});
//
const feedbackModalVisible = ref(false);
//
const imagePreviewVisible = ref(false);
const previewImageUrl = ref('');
//
const statusMap: Record<string, { text: string; color: string }> = {
PENDING: { text: '待完成', color: 'orange' },
IN_PROGRESS: { text: '进行中', color: 'blue' },
COMPLETED: { text: '已完成', color: 'green' },
PENDING: { text: '待提交', color: 'orange' },
SUBMITTED: { text: '已提交', color: 'blue' },
REVIEWED: { text: '已评价', color: 'green' },
};
//
const feedbackResultMap: Record<string, { text: string; color: string }> = {
EXCELLENT: { text: '优秀', color: 'gold' },
PASSED: { text: '通过', color: 'green' },
NEEDS_WORK: { text: '需改进', color: 'orange' },
};
const getStatusText = (status: string) => statusMap[status]?.text || status;
const getStatusColor = (status: string) => statusMap[status]?.color || 'default';
const formatDate = (date: string) => dayjs(date).format('YYYY-MM-DD');
const getFeedbackResultText = (result: string) => feedbackResultMap[result]?.text || result;
const getFeedbackResultColor = (result: string) => feedbackResultMap[result]?.color || 'default';
const openFeedbackModal = (task: TaskWithCompletion) => {
const formatDate = (date?: string) => date ? dayjs(date).format('YYYY-MM-DD') : '-';
const formatDateTime = (date?: string) => date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '-';
//
const openSubmitModal = (task: TaskWithCompletion) => {
selectedTask.value = task;
feedbackContent.value = task.parentFeedback || '';
feedbackModalVisible.value = true;
isEditSubmit.value = task.status !== 'PENDING';
//
submitForm.photos = task.photos || [];
submitForm.videoUrl = task.videoUrl || '';
submitForm.audioUrl = task.audioUrl || '';
submitForm.content = task.content || '';
submitModalVisible.value = true;
};
const submitFeedback = async () => {
if (!selectedTask.value || !feedbackContent.value.trim()) {
message.warning('请输入反馈内容');
return;
//
const handlePhotoUpload = async (options: UploadRequestOption) => {
const { file } = options;
try {
const formData = new FormData();
formData.append('file', file as File);
const url = await uploadFile(formData);
if (submitForm.photos.length < 9) {
submitForm.photos.push(url);
} else {
message.warning('最多上传9张照片');
}
} catch (error: any) {
message.error('上传失败');
}
};
//
const removePhoto = (index: number) => {
submitForm.photos.splice(index, 1);
};
//
const handleSubmit = async () => {
if (!selectedTask.value) return;
const childId = currentChildId.value || (route.query.childId ? Number(route.query.childId) : null);
if (!childId) {
@ -125,9 +370,23 @@ const submitFeedback = async () => {
submitting.value = true;
try {
await submitTaskFeedback(childId, selectedTask.value.task.id, feedbackContent.value);
message.success('反馈提交成功');
feedbackModalVisible.value = false;
const data: TaskSubmitRequest = {
studentId: childId,
photos: submitForm.photos.length > 0 ? submitForm.photos : undefined,
videoUrl: submitForm.videoUrl || undefined,
audioUrl: submitForm.audioUrl || undefined,
content: submitForm.content || undefined,
};
if (isEditSubmit.value) {
await updateTaskCompletion(selectedTask.value.task.id, data);
message.success('修改成功');
} else {
await submitTaskCompletion(selectedTask.value.task.id, data);
message.success('提交成功');
}
submitModalVisible.value = false;
loadTasks();
} catch (error: any) {
message.error(error.response?.data?.message || '提交失败');
@ -136,6 +395,19 @@ const submitFeedback = async () => {
}
};
//
const openFeedbackDetail = (task: TaskWithCompletion) => {
selectedTask.value = task;
feedbackModalVisible.value = true;
};
//
const previewImage = (url: string) => {
previewImageUrl.value = url;
imagePreviewVisible.value = true;
};
//
const loadTasks = async () => {
loading.value = true;
try {
@ -143,21 +415,18 @@ const loadTasks = async () => {
// childId
if (!childId) {
const { getChildren } = await import('@/api/parent');
const children = await getChildren();
if (children && children.length > 0) {
childId = children[0].id;
// URL便使
router.replace({ query: { childId: String(childId) } });
} else {
//
loading.value = false;
return;
}
}
// childId使
currentChildId.value = childId;
const data = await getChildTasks(childId);
tasks.value = data.items;
} catch (error: any) {
@ -216,6 +485,13 @@ $primary-light: #f6ffed;
align-items: flex-start;
margin-bottom: 12px;
.task-title-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
h3 {
margin: 0;
font-size: 16px;
@ -247,21 +523,74 @@ $primary-light: #f6ffed;
}
}
.submission-preview {
background: #f6ffed;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
.preview-title {
font-size: 13px;
color: #999;
margin-bottom: 8px;
}
.preview-items {
display: flex;
flex-wrap: wrap;
gap: 12px;
.preview-item {
font-size: 13px;
color: #666;
display: flex;
align-items: center;
gap: 4px;
}
}
.submission-time {
font-size: 12px;
color: #999;
margin-top: 8px;
}
}
.teacher-feedback {
background: #fffbe6;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
.feedback-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
span {
font-weight: 500;
}
}
.feedback-rating {
margin-bottom: 8px;
}
.feedback-comment {
font-size: 14px;
color: #666;
line-height: 1.6;
}
}
.card-footer {
padding-top: 12px;
border-top: 1px solid #f0f0f0;
.feedback-section {
.feedback-label {
font-size: 13px;
color: #999;
margin-right: 8px;
}
.feedback-content {
font-size: 14px;
color: #333;
}
.action-buttons {
display: flex;
gap: 8px;
}
}
}
@ -278,6 +607,181 @@ $primary-light: #f6ffed;
}
}
//
.task-info-header {
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
h4 {
margin: 0 0 8px;
}
p {
margin: 0;
color: #999;
font-size: 13px;
}
}
.photo-upload-area {
display: flex;
flex-wrap: wrap;
gap: 8px;
.photo-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.photo-item {
position: relative;
width: 80px;
height: 80px;
border-radius: 8px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-remove {
position: absolute;
top: -6px;
right: -6px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #ff4d4f;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
.upload-btn {
width: 80px;
height: 80px;
border: 1px dashed #d9d9d9;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
color: #999;
transition: all 0.3s;
&:hover {
border-color: #1890ff;
color: #1890ff;
}
span {
font-size: 12px;
margin-top: 4px;
}
}
}
.form-tip {
font-size: 12px;
color: #999;
margin-top: 4px;
}
//
.feedback-detail {
.feedback-result-section,
.feedback-rating-section {
margin-bottom: 16px;
.result-label,
.rating-label {
font-size: 13px;
color: #999;
margin-bottom: 8px;
}
.rating-text {
margin-left: 8px;
color: #faad14;
}
}
.feedback-comment-section {
margin-bottom: 16px;
.comment-label {
font-size: 13px;
color: #999;
margin-bottom: 8px;
}
.comment-content {
background: #fafafa;
padding: 12px;
border-radius: 8px;
line-height: 1.6;
}
}
.feedback-time {
font-size: 12px;
color: #999;
}
}
.submission-review {
.review-photos {
.photos-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
img {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 8px;
cursor: pointer;
}
}
}
.review-links {
display: flex;
gap: 16px;
margin: 12px 0;
a {
display: flex;
align-items: center;
gap: 4px;
}
}
.review-content {
.content-label {
font-size: 13px;
color: #999;
margin-bottom: 8px;
}
p {
margin: 0;
line-height: 1.6;
white-space: pre-wrap;
}
}
}
// =============== ===============
@media screen and (max-width: 768px) {
.task-list-view {
@ -326,17 +830,16 @@ $primary-light: #f6ffed;
}
}
.submission-preview,
.teacher-feedback {
padding: 10px;
}
.card-footer {
padding-top: 10px;
.feedback-section {
.feedback-label {
font-size: 12px;
}
.feedback-content {
font-size: 13px;
}
.action-buttons {
flex-wrap: wrap;
}
}
}
@ -349,13 +852,12 @@ $primary-light: #f6ffed;
}
}
}
}
//
@media screen and (min-width: 769px) and (max-width: 1024px) {
.task-list-view {
.task-card {
padding: 18px;
.photo-upload-area {
.photo-item,
.upload-btn {
width: 60px;
height: 60px;
}
}
}

View File

@ -2,9 +2,7 @@
<div class="task-list-view">
<div class="page-header">
<div class="header-left">
<h2>
<CheckSquareOutlined /> 阅读任务
</h2>
<h2><CheckSquareOutlined /> 阅读任务</h2>
<p class="page-desc">管理班级阅读任务跟踪学生完成情况</p>
</div>
<a-button type="primary" @click="openCreateModal">
@ -46,20 +44,35 @@
<!-- 筛选区域 -->
<div class="filter-section">
<a-space :size="16">
<a-select v-model:value="filters.status" placeholder="任务状态" style="width: 120px;" allowClear
@change="loadTasks">
<a-select
v-model:value="filters.status"
placeholder="任务状态"
style="width: 120px;"
allowClear
@change="loadTasks"
>
<a-select-option value="PUBLISHED">进行中</a-select-option>
<a-select-option value="DRAFT">草稿</a-select-option>
<a-select-option value="ARCHIVED">已归档</a-select-option>
</a-select>
<a-select v-model:value="filters.taskType" placeholder="任务类型" style="width: 120px;" allowClear
@change="loadTasks">
<a-select
v-model:value="filters.taskType"
placeholder="任务类型"
style="width: 120px;"
allowClear
@change="loadTasks"
>
<a-select-option value="READING">阅读</a-select-option>
<a-select-option value="ACTIVITY">活动</a-select-option>
<a-select-option value="HOMEWORK">作业</a-select-option>
</a-select>
<a-input-search v-model:value="filters.keyword" placeholder="搜索任务标题" style="width: 200px;" @search="loadTasks"
allow-clear />
<a-input-search
v-model:value="filters.keyword"
placeholder="搜索任务标题"
style="width: 200px;"
@search="loadTasks"
allow-clear
/>
</a-space>
</div>
@ -92,8 +105,12 @@
<div class="card-footer">
<div class="progress-info">
<a-progress :percent="getCompletionRate(task)" :stroke-color="{ '0%': '#52c41a', '100%': '#73d13d' }"
size="small" style="width: 150px;" />
<a-progress
:percent="getCompletionRate(task)"
:stroke-color="{ '0%': '#52c41a', '100%': '#73d13d' }"
size="small"
style="width: 150px;"
/>
<span class="progress-text">{{ getCompletedCount(task) }}/{{ task.targetCount || 0 }} 人完成</span>
</div>
<div class="card-actions">
@ -137,18 +154,34 @@
</a-spin>
<div class="pagination-section" v-if="total > pageSize">
<a-pagination v-model:current="currentPage" :total="total" :page-size="pageSize" @change="onPageChange"
show-quick-jumper :show-total="(total: number) => `共 ${total} 条`" />
<a-pagination
v-model:current="currentPage"
:total="total"
:page-size="pageSize"
@change="onPageChange"
show-quick-jumper
:show-total="(total: number) => `共 ${total} 条`"
/>
</div>
<!-- 创建/编辑任务弹窗 -->
<a-modal v-model:open="createModalVisible" :title="isEdit ? '编辑任务' : '新建阅读任务'" @ok="handleCreate"
:confirm-loading="creating" width="600px">
<a-modal
v-model:open="createModalVisible"
:title="isEdit ? '编辑任务' : '新建阅读任务'"
@ok="handleCreate"
:confirm-loading="creating"
width="600px"
>
<a-form :model="createForm" layout="vertical">
<!-- 模板选择仅新建时显示 -->
<a-form-item label="使用模板" v-if="!isEdit">
<a-select v-model:value="selectedTemplateId" placeholder="选择模板快速填充(可选)" style="width: 100%;" allowClear
@change="onTemplateSelect">
<a-select
v-model:value="selectedTemplateId"
placeholder="选择模板快速填充(可选)"
style="width: 100%;"
allowClear
@change="onTemplateSelect as any"
>
<a-select-option v-for="tpl in templates" :key="tpl.id" :value="tpl.id">
{{ tpl.name }}
<a-tag size="small" :color="getTaskTypeColor(tpl.taskType)" style="margin-left: 8px;">
@ -183,50 +216,86 @@
</a-col>
</a-row>
<a-form-item label="选择目标" required v-if="createForm.targetType === 'CLASS'">
<a-select v-model:value="createForm.targetIds" mode="multiple" placeholder="请选择班级" style="width: 100%;">
<a-select
v-model:value="createForm.targetIds"
mode="multiple"
placeholder="请选择班级"
style="width: 100%;"
>
<a-select-option v-for="cls in classes" :key="cls.id" :value="cls.id">
{{ cls.name }} ({{ cls.grade }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="选择学生" required v-if="createForm.targetType === 'STUDENT'">
<a-select v-model:value="createForm.targetIds" mode="multiple" placeholder="请选择学生" style="width: 100%;"
:filter-option="filterStudentOption" show-search>
<a-select
v-model:value="createForm.targetIds"
mode="multiple"
placeholder="请选择学生"
style="width: 100%;"
:filter-option="filterStudentOption"
show-search
>
<a-select-option v-for="student in students" :key="student.id" :value="student.id">
{{ student.name }} - {{ student.class?.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="关联课程">
<a-select v-model:value="createForm.relatedCourseId" placeholder="可选,关联课程包" style="width: 100%;" allowClear
show-search :filter-option="filterCourseOption">
<a-select
v-model:value="createForm.relatedCourseId"
placeholder="可选,关联课程包"
style="width: 100%;"
allowClear
show-search
:filter-option="filterCourseOption"
>
<a-select-option v-for="course in courses" :key="course.id" :value="course.id">
{{ course.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="关联绘本">
<a-input
v-model:value="createForm.relatedBookName"
placeholder="请输入关联的绘本名称(可选)"
allow-clear
/>
</a-form-item>
<a-form-item label="任务时间" required>
<a-range-picker v-model:value="createForm.dateRange" style="width: 100%;" />
<a-range-picker
v-model:value="createForm.dateRange as any"
style="width: 100%;"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 完成情况详情弹窗 -->
<a-modal v-model:open="completionModalVisible" :title="`完成情况 - ${selectedTask?.title || ''}`" width="800px"
:footer="null">
<a-modal
v-model:open="completionModalVisible"
:title="`完成情况 - ${selectedTask?.title || ''}`"
width="800px"
:footer="null"
>
<div class="completion-header">
<a-space>
<a-select v-model:value="completionFilter.status" placeholder="筛选状态" style="width: 120px;" allowClear
@change="loadCompletions">
<a-select-option value="PENDING">待完成</a-select-option>
<a-select-option value="IN_PROGRESS">进行中</a-select-option>
<a-select-option value="COMPLETED">已完成</a-select-option>
<a-select
v-model:value="completionFilter.status"
placeholder="筛选状态"
style="width: 120px;"
allowClear
@change="loadCompletions"
>
<a-select-option value="PENDING">待提交</a-select-option>
<a-select-option value="SUBMITTED">已提交</a-select-option>
<a-select-option value="REVIEWED">已评价</a-select-option>
</a-select>
</a-space>
<div class="completion-stats">
<a-tag color="orange">{{ completionStats.pending }} 待完成</a-tag>
<a-tag color="blue">{{ completionStats.inProgress }} 进行中</a-tag>
<a-tag color="green">{{ completionStats.completed }} 已完成</a-tag>
<a-tag color="orange">{{ completionStats.pending }} 提交</a-tag>
<a-tag color="blue">{{ completionStats.submitted }} 已提交</a-tag>
<a-tag color="green">{{ completionStats.reviewed }} 已评价</a-tag>
</div>
</div>
@ -239,31 +308,45 @@
</a-avatar>
<div class="student-detail">
<div class="student-name">{{ completion.student.name }}</div>
<div class="student-class">{{ completion.student.class?.name || '-' }}</div>
<div class="student-class">{{ completion.student.class?.name || completion.student.classInfo?.name || '-' }}</div>
</div>
</div>
<div class="completion-status">
<a-select :value="completion.status" style="width: 100px;" size="small"
@change="(val: string) => updateCompletionStatus(completion, val)">
<a-select-option value="PENDING">待完成</a-select-option>
<a-select-option value="IN_PROGRESS">进行中</a-select-option>
<a-select-option value="COMPLETED">已完成</a-select-option>
</a-select>
<a-tag :color="getCompletionStatusColor(completion.status)">
{{ getCompletionStatusText(completion.status) }}
</a-tag>
</div>
<div class="completion-content">
<div v-if="completion.photos && completion.photos.length > 0" class="content-preview">
<PictureOutlined /> {{ completion.photos.length }} 张照片
</div>
<div v-if="completion.videoUrl" class="content-preview">
<VideoCameraOutlined /> 有视频
</div>
<div v-if="completion.content" class="content-preview">
<FileTextFilled /> {{ completion.content.substring(0, 20) }}...
</div>
</div>
<div class="completion-feedback">
<div v-if="completion.parentFeedback" class="parent-feedback">
<div v-if="completion.parentFeedback || completion.feedback?.comment" class="parent-feedback">
<MessageOutlined class="feedback-icon" />
<a-tooltip :title="completion.parentFeedback">
<span class="feedback-text">{{ completion.parentFeedback.substring(0, 30) }}{{
completion.parentFeedback.length > 30 ? '...' : '' }}</span>
<a-tooltip :title="completion.parentFeedback || completion.feedback?.comment">
<span class="feedback-text">{{ (completion.parentFeedback || completion.feedback?.comment || '').substring(0, 30) }}{{ (completion.parentFeedback || completion.feedback?.comment || '').length > 30 ? '...' : '' }}</span>
</a-tooltip>
</div>
<span v-else class="no-feedback">暂无家长反馈</span>
<span v-else class="no-feedback">暂无反馈</span>
</div>
<div class="completion-actions">
<a-button type="link" size="small" @click="openFeedbackModal(completion)">
<StarFilled v-if="completion.feedback" style="color: #faad14;" />
<EditOutlined v-else />
{{ completion.feedback ? '查看评价' : '评价' }}
</a-button>
</div>
<div class="completion-time">
<span v-if="completion.completedAt">
<span v-if="completion.submittedAt || completion.completedAt">
<CheckCircleOutlined style="color: #52c41a;" />
{{ formatDate(completion.completedAt) }}
{{ formatDate(completion.submittedAt || completion.completedAt) }}
</span>
</div>
</div>
@ -275,10 +358,78 @@
</a-spin>
<div class="pagination-section" v-if="completionTotal > completionPageSize" style="margin-top: 16px;">
<a-pagination v-model:current="completionPage" :total="completionTotal" :page-size="completionPageSize"
size="small" @change="onCompletionPageChange" />
<a-pagination
v-model:current="completionPage"
:total="completionTotal"
:page-size="completionPageSize"
size="small"
@change="onCompletionPageChange"
/>
</div>
</a-modal>
<!-- 评价弹窗 -->
<a-modal
v-model:open="feedbackModalVisible"
:title="selectedCompletionForFeedback ? `评价 - ${selectedCompletionForFeedback.student?.name || ''}` : '评价'"
width="600px"
:confirm-loading="submittingFeedback"
@ok="submitFeedback"
okText="提交评价"
cancelText="取消"
>
<a-form layout="vertical" v-if="selectedCompletionForFeedback">
<!-- 提交内容预览 -->
<div class="submission-preview" style="margin-bottom: 24px; padding: 16px; background: #fafafa; border-radius: 8px;">
<h4 style="margin-bottom: 12px;">提交内容</h4>
<div v-if="selectedCompletionForFeedback.photos && selectedCompletionForFeedback.photos.length > 0" style="margin-bottom: 12px;">
<PictureOutlined style="margin-right: 8px;" />
<span>{{ selectedCompletionForFeedback.photos.length }} 张照片</span>
</div>
<div v-if="selectedCompletionForFeedback.videoUrl" style="margin-bottom: 12px;">
<VideoCameraOutlined style="margin-right: 8px;" />
<a :href="selectedCompletionForFeedback.videoUrl" target="_blank">查看视频</a>
</div>
<div v-if="selectedCompletionForFeedback.audioUrl" style="margin-bottom: 12px;">
<SoundOutlined style="margin-right: 8px;" />
<a :href="selectedCompletionForFeedback.audioUrl" target="_blank">收听音频</a>
</div>
<div v-if="selectedCompletionForFeedback.content">
<FileTextFilled style="margin-right: 8px;" />
<p style="margin: 8px 0 0; white-space: pre-wrap;">{{ selectedCompletionForFeedback.content }}</p>
</div>
</div>
<a-form-item label="评价结果" required>
<a-radio-group v-model:value="feedbackForm.result">
<a-radio-button value="EXCELLENT">
<StarFilled style="color: #faad14;" /> 优秀
</a-radio-button>
<a-radio-button value="PASSED">
<CheckCircleOutlined style="color: #52c41a;" /> 通过
</a-radio-button>
<a-radio-button value="NEEDS_WORK">
<SyncOutlined style="color: #fa8c16;" /> 需改进
</a-radio-button>
</a-radio-group>
</a-form-item>
<a-form-item label="评分">
<a-rate v-model:value="feedbackForm.rating" :count="5" allow-half />
<span style="margin-left: 8px; color: #999;">{{ feedbackForm.rating }} </span>
</a-form-item>
<a-form-item label="评语">
<a-textarea
v-model:value="feedbackForm.comment"
placeholder="请输入评语选填最多500字"
:rows="4"
:maxlength="500"
show-count
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
@ -302,6 +453,11 @@ import {
SyncOutlined,
CheckCircleOutlined,
MessageOutlined,
StarFilled,
PictureOutlined,
VideoCameraOutlined,
SoundOutlined,
FileTextFilled,
} from '@ant-design/icons-vue';
import {
getTeacherTasks,
@ -309,7 +465,8 @@ import {
updateTeacherTask,
deleteTeacherTask,
getTeacherTaskCompletions,
updateTaskCompletion,
submitCompletionFeedback,
updateCompletionFeedback,
sendTaskReminder,
getTeacherClasses,
getTeacherStudents,
@ -317,6 +474,7 @@ import {
getTaskTemplates,
type TeacherTask,
type TaskCompletion,
type TaskFeedbackDto,
type TaskTemplate,
} from '@/api/teacher';
import dayjs, { Dayjs } from 'dayjs';
@ -367,6 +525,7 @@ const createForm = reactive({
targetType: 'CLASS' as 'CLASS' | 'STUDENT',
targetIds: [] as number[],
relatedCourseId: undefined as number | undefined,
relatedBookName: '', //
dateRange: null as [Dayjs, Dayjs] | null,
});
@ -387,8 +546,8 @@ const completionStats = computed(() => {
const all = completions.value;
return {
pending: all.filter(c => c.status === 'PENDING').length,
inProgress: all.filter(c => c.status === 'IN_PROGRESS').length,
completed: all.filter(c => c.status === 'COMPLETED').length,
submitted: all.filter(c => c.status === 'SUBMITTED').length,
reviewed: all.filter(c => c.status === 'REVIEWED').length,
};
});
@ -409,7 +568,7 @@ const getTypeText = (type: string) => typeMap[type]?.text || type;
const getTypeColor = (type: string) => typeMap[type]?.color || 'default';
const getStatusText = (status: string) => statusMap[status]?.text || status;
const getStatusColor = (status: string) => statusMap[status]?.color || 'default';
const formatDate = (date: string) => date ? dayjs(date).format('YYYY-MM-DD') : '-';
const formatDate = (date?: string) => date ? dayjs(date).format('YYYY-MM-DD') : '-';
const getCompletionRate = (task: TeacherTask) => {
if (!task.completionCount || !task.targetCount) return 0;
@ -435,7 +594,7 @@ const loadTasks = async () => {
loading.value = true;
try {
const data = await getTeacherTasks({
page: currentPage.value,
pageNum: currentPage.value,
pageSize: pageSize.value,
...filters,
});
@ -480,6 +639,7 @@ const openCreateModal = async () => {
createForm.targetType = 'CLASS';
createForm.targetIds = [];
createForm.relatedCourseId = undefined;
createForm.relatedBookName = ''; //
createForm.dateRange = null;
createModalVisible.value = true;
@ -543,6 +703,7 @@ const openEditModal = (task: TeacherTask) => {
createForm.taskType = task.taskType;
createForm.targetType = task.targetType;
createForm.relatedCourseId = task.relatedCourseId;
createForm.relatedBookName = task.relatedBookName || ''; //
createForm.dateRange = [
dayjs(task.startDate),
dayjs(task.endDate),
@ -571,6 +732,7 @@ const handleCreate = async () => {
targetType: createForm.targetType,
targetIds: createForm.targetIds,
relatedCourseId: createForm.relatedCourseId,
relatedBookName: createForm.relatedBookName || undefined, //
startDate: createForm.dateRange[0].format('YYYY-MM-DD'),
endDate: createForm.dateRange[1].format('YYYY-MM-DD'),
};
@ -641,13 +803,87 @@ const viewCompletionDetail = async (task: TeacherTask) => {
loadCompletions();
};
//
const getCompletionStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
'PENDING': 'orange',
'SUBMITTED': 'blue',
'REVIEWED': 'green',
};
return colorMap[status] || 'default';
};
const getCompletionStatusText = (status: string) => {
const textMap: Record<string, string> = {
'PENDING': '待提交',
'SUBMITTED': '已提交',
'REVIEWED': '已评价',
};
return textMap[status] || status;
};
//
const feedbackModalVisible = ref(false);
const selectedCompletion = ref<TaskCompletion | null>(null);
const selectedCompletionForFeedback = computed(() => selectedCompletion.value);
const feedbackForm = reactive({
result: 'PASSED' as 'EXCELLENT' | 'PASSED' | 'NEEDS_WORK',
rating: 5,
comment: '',
});
const submittingFeedback = ref(false);
const openFeedbackModal = (completion: TaskCompletion) => {
selectedCompletion.value = completion;
if (completion.feedback) {
//
feedbackForm.result = completion.feedback.result || 'PASSED';
feedbackForm.rating = completion.feedback.rating || 5;
feedbackForm.comment = completion.feedback.comment || '';
} else {
//
feedbackForm.result = 'PASSED';
feedbackForm.rating = 5;
feedbackForm.comment = '';
}
feedbackModalVisible.value = true;
};
const submitFeedback = async () => {
if (!selectedCompletion.value) return;
submittingFeedback.value = true;
try {
const data: TaskFeedbackDto = {
result: feedbackForm.result,
rating: feedbackForm.rating,
comment: feedbackForm.comment,
};
if (selectedCompletion.value.feedback) {
await updateCompletionFeedback(selectedCompletion.value.id, data);
message.success('评价已更新');
} else {
await submitCompletionFeedback(selectedCompletion.value.id, data);
message.success('评价已提交');
}
feedbackModalVisible.value = false;
loadCompletions(); //
} catch (error: any) {
message.error(error.message || '评价失败');
} finally {
submittingFeedback.value = false;
}
};
const loadCompletions = async () => {
if (!selectedTask.value) return;
loadingCompletions.value = true;
try {
const data = await getTeacherTaskCompletions(selectedTask.value.id, {
page: completionPage.value,
pageNum: completionPage.value,
pageSize: completionPageSize.value,
status: completionFilter.status,
});
@ -665,21 +901,6 @@ const onCompletionPageChange = (page: number) => {
loadCompletions();
};
//
const updateCompletionStatus = async (completion: TaskCompletion, status: string) => {
try {
await updateTaskCompletion(completion.taskId, completion.studentId, { status: status as any });
completion.status = status as any;
if (status === 'COMPLETED') {
completion.completedAt = new Date().toISOString();
}
message.success('状态已更新');
} catch (error: any) {
message.error('更新失败', error);
loadCompletions();
}
};
onMounted(() => {
loadTasks();
loadOptions();

View File

@ -5,17 +5,22 @@ import com.reading.platform.common.mapper.TaskMapper;
import com.reading.platform.common.response.PageResult;
import com.reading.platform.common.response.Result;
import com.reading.platform.common.security.SecurityUtils;
import com.reading.platform.dto.request.TaskSubmitRequest;
import com.reading.platform.dto.response.TaskCompletionDetailResponse;
import com.reading.platform.dto.response.TaskFeedbackResponse;
import com.reading.platform.dto.response.TaskResponse;
import com.reading.platform.entity.Task;
import com.reading.platform.service.TaskFeedbackService;
import com.reading.platform.service.TaskService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "Parent - Task", description = "Task APIs for Parent")
@Tag(name = "家长端 - 任务管理", description = "家长端任务 API")
@RestController
@RequestMapping("/api/v1/parent/tasks")
@RequiredArgsConstructor
@ -23,15 +28,16 @@ public class ParentTaskController {
private final TaskService taskService;
private final TaskMapper taskMapper;
private final TaskFeedbackService taskFeedbackService;
@Operation(summary = "Get task by ID")
@Operation(summary = "获取任务详情")
@GetMapping("/{id}")
public Result<TaskResponse> getTask(@PathVariable Long id) {
Task task = taskService.getTaskById(id);
return Result.success(taskMapper.toVO(task));
}
@Operation(summary = "Get tasks by student ID")
@Operation(summary = "获取孩子的任务列表")
@GetMapping("/student/{studentId}")
public Result<PageResult<TaskResponse>> getTasksByStudent(
@PathVariable Long studentId,
@ -43,18 +49,57 @@ public class ParentTaskController {
return Result.success(PageResult.of(voList, page.getTotal(), page.getCurrent(), page.getSize()));
}
@Operation(summary = "Get my tasks")
@Operation(summary = "获取我的任务列表")
@GetMapping
public Result<PageResult<TaskResponse>> getMyTasks(
@RequestParam(required = false, defaultValue = "1") Integer pageNum,
@RequestParam(required = false, defaultValue = "10") Integer pageSize,
@RequestParam(required = false) String status) {
Long parentId = SecurityUtils.getCurrentUserId();
// TODO: 根据 parentId 获取任务列表
// TODO: 根据 parentId 获取关联学生的任务列表
return Result.success(PageResult.of(List.of(), 0L, Long.valueOf(pageNum == null ? 1 : pageNum), Long.valueOf(pageSize == null ? 10 : pageSize)));
}
@Operation(summary = "Complete task")
// =============== 提交与评价 API ===============
@Operation(summary = "提交任务完成")
@PostMapping("/{taskId}/submit")
public Result<TaskCompletionDetailResponse> submitTask(
@PathVariable Long taskId,
@Valid @RequestBody TaskSubmitRequest request) {
Long tenantId = SecurityUtils.getCurrentTenantId();
TaskCompletionDetailResponse response = taskService.submitTaskCompletion(taskId, request, tenantId);
return Result.success(response);
}
@Operation(summary = "修改任务提交")
@PutMapping("/{taskId}/submit")
public Result<TaskCompletionDetailResponse> updateSubmission(
@PathVariable Long taskId,
@Valid @RequestBody TaskSubmitRequest request) {
Long tenantId = SecurityUtils.getCurrentTenantId();
TaskCompletionDetailResponse response = taskService.submitTaskCompletion(taskId, request, tenantId);
return Result.success(response);
}
@Operation(summary = "获取教师评价")
@GetMapping("/completions/{completionId}/feedback")
public Result<TaskFeedbackResponse> getFeedback(@PathVariable Long completionId) {
TaskFeedbackResponse response = taskFeedbackService.getFeedbackByCompletionId(completionId);
return Result.success(response);
}
@Operation(summary = "获取提交详情")
@GetMapping("/completions/{completionId}")
public Result<TaskCompletionDetailResponse> getCompletionDetail(@PathVariable Long completionId) {
Long tenantId = SecurityUtils.getCurrentTenantId();
TaskCompletionDetailResponse response = taskService.getCompletionDetail(completionId, tenantId);
return Result.success(response);
}
// =============== 兼容旧接口 ===============
@Operation(summary = "完成任务(旧接口,兼容使用)")
@PostMapping("/{id}/complete")
public Result<Void> completeTask(
@PathVariable Long id,

View File

@ -1,100 +1,105 @@
package com.reading.platform.controller.school;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.reading.platform.common.mapper.TaskMapper;
import com.reading.platform.common.response.PageResult;
import com.reading.platform.common.response.Result;
import com.reading.platform.common.security.SecurityUtils;
import com.reading.platform.dto.request.TaskCreateRequest;
import com.reading.platform.dto.request.TaskUpdateRequest;
import com.reading.platform.dto.response.TaskCompletionDetailResponse;
import com.reading.platform.dto.response.TaskResponse;
import com.reading.platform.entity.Task;
import com.reading.platform.service.TaskService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Tag(name = "School - Task", description = "Task Management APIs for School")
/**
* 学校端 - 阅读任务 API只读
*
* 重要说明
* - 学校端只能查看任务数据不能创建/编辑/删除任务
* - 支持多维度筛选时间班级教师类型状态完成率
* - 可查看任务详情完成情况列表学生提交详情
*/
@Tag(name = "学校端 - 阅读任务(只读)", description = "学校端阅读任务查看 API")
@RestController
@RequestMapping("/api/v1/school/tasks")
@RequestMapping("/api/v1/school/reading-tasks")
@RequiredArgsConstructor
public class SchoolTaskController {
private final TaskService taskService;
private final TaskMapper taskMapper;
@Operation(summary = "Create task")
@PostMapping
public Result<TaskResponse> createTask(@Valid @RequestBody TaskCreateRequest request) {
Long tenantId = SecurityUtils.getCurrentTenantId();
Long userId = SecurityUtils.getCurrentUserId();
String role = SecurityUtils.getCurrentRole();
Task task = taskService.createTask(tenantId, userId, role, request);
return Result.success(taskMapper.toVO(task));
}
// =============== 任务列表只读==============
@Operation(summary = "Update task")
@PutMapping("/{id}")
public Result<TaskResponse> updateTask(@PathVariable Long id, @RequestBody TaskUpdateRequest request) {
Long tenantId = SecurityUtils.getCurrentTenantId();
Task task = taskService.updateTaskWithTenantCheck(id, tenantId, request);
return Result.success(taskMapper.toVO(task));
}
@Operation(summary = "Get task by ID")
@GetMapping("/{id}")
public Result<TaskResponse> getTask(@PathVariable Long id) {
Long tenantId = SecurityUtils.getCurrentTenantId();
Task task = taskService.getTaskByIdWithTenantCheck(id, tenantId);
return Result.success(taskMapper.toVO(task));
}
@Operation(summary = "Get task completions")
@GetMapping("/{id}/completions")
public Result<Map<String, Object>> getTaskCompletions(
@PathVariable Long id,
@RequestParam(required = false, defaultValue = "1") Integer page,
@RequestParam(required = false, defaultValue = "20") Integer pageSize,
@RequestParam(required = false) String status) {
Long tenantId = SecurityUtils.getCurrentTenantId();
var pageResult = taskService.getTaskCompletionsWithStudent(id, tenantId, page, pageSize, status);
var stats = taskService.getTaskCompletionStats(id, tenantId);
Map<String, Object> data = new HashMap<>();
data.put("items", pageResult.getList());
data.put("total", pageResult.getTotal());
data.put("page", pageResult.getPageNum());
data.put("pageSize", pageResult.getPageSize());
data.put("stats", stats);
return Result.success(data);
}
@Operation(summary = "Get task page")
@Operation(summary = "获取任务列表(支持多维度筛选)")
@GetMapping
public Result<PageResult<TaskResponse>> getTaskPage(
public Result<PageResult<TaskResponse>> getTaskList(
@RequestParam(required = false, defaultValue = "1") Integer pageNum,
@RequestParam(required = false, defaultValue = "10") Integer pageSize,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String type,
@RequestParam(required = false) String status) {
@RequestParam(required = false) String status,
@RequestParam(required = false) List<Long> classIds,
@RequestParam(required = false) List<Long> teacherIds,
@RequestParam(required = false) String dateType,
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate,
@RequestParam(required = false) String completionRate,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortOrder) {
Long tenantId = SecurityUtils.getCurrentTenantId();
Page<Task> page = taskService.getTaskPage(tenantId, pageNum, pageSize, keyword, type, status);
List<TaskResponse> voList = taskMapper.toVO(page.getRecords());
return Result.success(PageResult.of(voList, page.getTotal(), page.getCurrent(), page.getSize()));
PageResult<TaskResponse> result = taskService.getSchoolTaskList(
tenantId, pageNum, pageSize, keyword, type, status,
classIds, teacherIds, dateType, startDate, endDate,
completionRate, sortBy, sortOrder
);
return Result.success(result);
}
@Operation(summary = "Delete task")
@DeleteMapping("/{id}")
public Result<Void> deleteTask(@PathVariable Long id) {
@Operation(summary = "获取任务详情")
@GetMapping("/{taskId}")
public Result<TaskResponse> getTaskDetail(@PathVariable Long taskId) {
Long tenantId = SecurityUtils.getCurrentTenantId();
taskService.deleteTaskWithTenantCheck(id, tenantId);
return Result.success();
TaskResponse response = taskService.getTaskDetailForSchool(taskId, tenantId);
return Result.success(response);
}
// =============== 完成情况只读==============
@Operation(summary = "获取任务完成情况列表")
@GetMapping("/{taskId}/completions")
public Result<PageResult<TaskCompletionDetailResponse>> getTaskCompletions(
@PathVariable Long taskId,
@RequestParam(required = false, defaultValue = "1") Integer pageNum,
@RequestParam(required = false, defaultValue = "10") Integer pageSize,
@RequestParam(required = false) String status) {
Long tenantId = SecurityUtils.getCurrentTenantId();
PageResult<TaskCompletionDetailResponse> result = taskService.getTaskCompletions(taskId, tenantId, pageNum, pageSize, status);
return Result.success(result);
}
@Operation(summary = "获取学生提交详情")
@GetMapping("/completions/{completionId}")
public Result<TaskCompletionDetailResponse> getCompletionDetail(@PathVariable Long completionId) {
Long tenantId = SecurityUtils.getCurrentTenantId();
TaskCompletionDetailResponse response = taskService.getCompletionDetail(completionId, tenantId);
return Result.success(response);
}
// =============== 统计数据只读==============
@Operation(summary = "获取统计数据")
@GetMapping("/statistics")
public Result<Object> getStatistics(
@RequestParam(required = false) String dateType,
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate) {
Long tenantId = SecurityUtils.getCurrentTenantId();
// TODO: 实现统计数据接口
return Result.success(null);
}
}

View File

@ -6,10 +6,13 @@ import com.reading.platform.common.response.PageResult;
import com.reading.platform.common.response.Result;
import com.reading.platform.common.security.SecurityUtils;
import com.reading.platform.dto.request.TaskCreateRequest;
import com.reading.platform.dto.request.TaskFeedbackRequest;
import com.reading.platform.dto.request.TaskUpdateRequest;
import com.reading.platform.dto.response.TaskCompletionDetailResponse;
import com.reading.platform.dto.response.TaskFeedbackResponse;
import com.reading.platform.dto.response.TaskResponse;
import com.reading.platform.entity.Task;
import com.reading.platform.service.TaskFeedbackService;
import com.reading.platform.service.TaskService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -17,9 +20,7 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Tag(name = "教师端 - 任务管理", description = "教师端任务 API")
@RestController
@ -29,6 +30,7 @@ public class TeacherTaskController {
private final TaskService taskService;
private final TaskMapper taskMapper;
private final TaskFeedbackService taskFeedbackService;
@Operation(summary = "创建任务")
@PostMapping
@ -53,36 +55,6 @@ public class TeacherTaskController {
return Result.success(taskMapper.toVO(task));
}
@Operation(summary = "获取任务完成情况")
@GetMapping("/{id}/completions")
public Result<Map<String, Object>> getTaskCompletions(
@PathVariable Long id,
@RequestParam(required = false, defaultValue = "1") Integer page,
@RequestParam(required = false, defaultValue = "20") Integer pageSize,
@RequestParam(required = false) String status) {
Long tenantId = SecurityUtils.getCurrentTenantId();
PageResult<TaskCompletionDetailResponse> result = taskService.getTaskCompletionsWithStudent(
id, tenantId, page, pageSize, status);
Map<String, Object> data = new HashMap<>();
data.put("items", result.getList());
data.put("total", result.getTotal());
data.put("page", result.getPageNum());
data.put("pageSize", result.getPageSize());
return Result.success(data);
}
@Operation(summary = "更新任务完成状态")
@PutMapping("/{taskId}/completions/{studentId}")
public Result<Void> updateTaskCompletion(
@PathVariable Long taskId,
@PathVariable Long studentId,
@RequestBody Map<String, Object> body) {
Long tenantId = SecurityUtils.getCurrentTenantId();
String status = body != null && body.containsKey("status") ? String.valueOf(body.get("status")) : null;
taskService.updateTaskCompletionStatus(taskId, studentId, tenantId, status);
return Result.success();
}
@Operation(summary = "获取任务分页列表")
@GetMapping
public Result<PageResult<TaskResponse>> getTaskPage(
@ -104,4 +76,46 @@ public class TeacherTaskController {
return Result.success();
}
// =============== 完成情况与评价 API ===============
@Operation(summary = "获取任务完成情况列表")
@GetMapping("/{taskId}/completions")
public Result<PageResult<TaskCompletionDetailResponse>> getTaskCompletions(
@PathVariable Long taskId,
@RequestParam(required = false, defaultValue = "1") Integer pageNum,
@RequestParam(required = false, defaultValue = "10") Integer pageSize,
@RequestParam(required = false) String status) {
Long tenantId = SecurityUtils.getCurrentTenantId();
PageResult<TaskCompletionDetailResponse> result = taskService.getTaskCompletions(taskId, tenantId, pageNum, pageSize, status);
return Result.success(result);
}
@Operation(summary = "获取提交详情")
@GetMapping("/completions/{completionId}")
public Result<TaskCompletionDetailResponse> getCompletionDetail(@PathVariable Long completionId) {
Long tenantId = SecurityUtils.getCurrentTenantId();
TaskCompletionDetailResponse response = taskService.getCompletionDetail(completionId, tenantId);
return Result.success(response);
}
@Operation(summary = "提交评价")
@PostMapping("/completions/{completionId}/feedback")
public Result<TaskFeedbackResponse> submitFeedback(
@PathVariable Long completionId,
@Valid @RequestBody TaskFeedbackRequest request) {
Long teacherId = SecurityUtils.getCurrentUserId();
TaskFeedbackResponse response = taskFeedbackService.createOrUpdateFeedback(completionId, request, teacherId);
return Result.success(response);
}
@Operation(summary = "修改评价")
@PutMapping("/completions/{completionId}/feedback")
public Result<TaskFeedbackResponse> updateFeedback(
@PathVariable Long completionId,
@Valid @RequestBody TaskFeedbackRequest request) {
Long teacherId = SecurityUtils.getCurrentUserId();
TaskFeedbackResponse response = taskFeedbackService.createOrUpdateFeedback(completionId, request, teacherId);
return Result.success(response);
}
}

View File

@ -21,6 +21,9 @@ public class TaskCreateRequest {
@Schema(description = "任务类型reading-阅读homework-作业activity-活动")
private String type;
@Schema(description = "关联绘本名称(手动填写)")
private String relatedBookName;
@Schema(description = "课程 ID")
private Long courseId;

View File

@ -0,0 +1,30 @@
package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 任务评价请求
*/
@Data
@Schema(description = "任务评价请求")
public class TaskFeedbackRequest {
@NotBlank(message = "评价结果不能为空")
@Schema(description = "评价结果EXCELLENT-优秀/PASSED-通过/NEEDS_WORK-需改进", required = true)
private String result;
@Min(value = 1, message = "评分最小为1")
@Max(value = 5, message = "评分最大为5")
@Schema(description = "评分 1-5可选")
private Integer rating;
@Size(max = 500, message = "评语最多500字")
@Schema(description = "评语")
private String comment;
}

View File

@ -0,0 +1,33 @@
package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
* 任务提交请求家长端
*/
@Data
@Schema(description = "任务提交请求")
public class TaskSubmitRequest {
@Schema(description = "学生ID")
private Long studentId;
@Schema(description = "照片URL数组最多9张")
@Size(max = 9, message = "照片最多上传9张")
private List<String> photos;
@Schema(description = "视频URL")
private String videoUrl;
@Schema(description = "语音URL")
private String audioUrl;
@Schema(description = "阅读心得/完成内容")
@Size(max = 1000, message = "阅读心得最多1000字")
private String content;
}

View File

@ -1,73 +1,101 @@
package com.reading.platform.dto.response;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 任务完成详情响应含学生信息
* 任务完成详情响应
*/
@Data
@Builder
@Schema(description = "任务完成详情响应(含学生信息)")
@Schema(description = "任务完成详情响应")
public class TaskCompletionDetailResponse {
@Schema(description = "ID")
@Schema(description = "完成记录ID")
private Long id;
@Schema(description = "任务 ID")
@Schema(description = "任务ID")
private Long taskId;
@Schema(description = "学生 ID")
private Long studentId;
@Schema(description = "完成状态: PENDING, IN_PROGRESS, COMPLETED")
private String status;
@Schema(description = "完成时间")
private LocalDateTime completedAt;
@Schema(description = "完成内容")
private String content;
@Schema(description = "附件")
private String attachments;
@Schema(description = "评分")
private Integer rating;
@Schema(description = "反馈/家长反馈")
private String feedback;
@Schema(description = "家长反馈(与 feedback 相同,兼容前端)")
private String parentFeedback;
@Schema(description = "创建时间")
private LocalDateTime createdAt;
@Schema(description = "更新时间")
private LocalDateTime updatedAt;
@Schema(description = "任务标题")
private String taskTitle;
@Schema(description = "学生信息")
private StudentInfo student;
@Schema(description = "状态PENDING/SUBMITTED/REVIEWED")
private String status;
@Schema(description = "状态文本:待完成/已提交/已评价")
private String statusText;
@Schema(description = "照片URL数组")
private List<String> photos;
@Schema(description = "视频URL")
private String videoUrl;
@Schema(description = "语音URL")
private String audioUrl;
@Schema(description = "完成内容/阅读心得")
private String content;
@Schema(description = "提交时间")
private LocalDateTime submittedAt;
@Schema(description = "评价时间")
private LocalDateTime reviewedAt;
@Schema(description = "教师评价")
private TaskFeedbackResponse feedback;
/**
* 学生信息
*/
@Data
@Builder
@Schema(description = "学生信息")
public static class StudentInfo {
private Long id;
private String name;
private String gender;
@JsonProperty("class")
private ClassInfo clazz;
@Data
@Builder
public static class ClassInfo {
private Long id;
private String name;
}
@Schema(description = "学生ID")
private Long id;
@Schema(description = "学生姓名")
private String name;
@Schema(description = "学生头像")
private String avatar;
@Schema(description = "性别MALE/FEMALE")
private String gender;
@Schema(description = "班级信息")
private ClassInfo classInfo;
}
/**
* 班级信息
*/
@Data
@Builder
@Schema(description = "班级信息")
public static class ClassInfo {
@Schema(description = "班级ID")
private Long id;
@Schema(description = "班级名称")
private String name;
@Schema(description = "年级")
private String grade;
}
}

View File

@ -0,0 +1,47 @@
package com.reading.platform.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 任务评价响应
*/
@Data
@Builder
@Schema(description = "任务评价响应")
public class TaskFeedbackResponse {
@Schema(description = "评价ID")
private Long id;
@Schema(description = "完成记录ID")
private Long completionId;
@Schema(description = "评价结果EXCELLENT/PASSED/NEEDS_WORK")
private String result;
@Schema(description = "评价结果文本:优秀/通过/需改进")
private String resultText;
@Schema(description = "评分 1-5")
private Integer rating;
@Schema(description = "评语")
private String comment;
@Schema(description = "教师ID")
private Long teacherId;
@Schema(description = "教师姓名")
private String teacherName;
@Schema(description = "教师头像")
private String teacherAvatar;
@Schema(description = "评价时间")
private LocalDateTime createdAt;
}

View File

@ -31,6 +31,9 @@ public class TaskResponse {
@Schema(description = "任务类型")
private String type;
@Schema(description = "关联绘本名称")
private String relatedBookName;
@Schema(description = "课程 ID")
private Long courseId;

View File

@ -28,6 +28,9 @@ public class Task extends BaseEntity {
@Schema(description = "任务类型")
private String type;
@Schema(description = "关联绘本名称")
private String relatedBookName;
@Schema(description = "课程 ID")
private Long courseId;

View File

@ -22,22 +22,37 @@ public class TaskCompletion extends BaseEntity {
@Schema(description = "学生 ID")
private Long studentId;
@Schema(description = "完成状态")
@Schema(description = "完成状态PENDING-待提交/SUBMITTED-已提交/REVIEWED-已评价")
private String status;
@Schema(description = "完成时间")
private LocalDateTime completedAt;
@Schema(description = "照片URL数组JSON格式")
private String photos;
@Schema(description = "完成内容")
@Schema(description = "视频URL")
private String videoUrl;
@Schema(description = "语音URL")
private String audioUrl;
@Schema(description = "完成内容/阅读心得")
private String content;
@Schema(description = "提交时间")
private LocalDateTime submittedAt;
@Schema(description = "评价时间")
private LocalDateTime reviewedAt;
@Schema(description = "完成时间(兼容旧字段)")
private LocalDateTime completedAt;
@Schema(description = "附件JSON 数组)")
private String attachments;
@Schema(description = "评分")
@Schema(description = "评分(家长自评)")
private Integer rating;
@Schema(description = "反馈")
@Schema(description = "反馈(家长反馈)")
private String feedback;
}

View File

@ -0,0 +1,39 @@
package com.reading.platform.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 任务评价实体
* 教师对学生完成阅读任务后的评价反馈
*/
@Schema(description = "任务评价实体")
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("task_feedback")
public class TaskFeedback extends BaseEntity {
@Schema(description = "完成记录ID")
private Long completionId;
@Schema(description = "任务ID冗余字段方便查询")
private Long taskId;
@Schema(description = "学生ID冗余字段")
private Long studentId;
@Schema(description = "教师ID")
private Long teacherId;
@Schema(description = "评价结果EXCELLENT-优秀/PASSED-通过/NEEDS_WORK-需改进")
private String result;
@Schema(description = "评分 1-5可选")
private Integer rating;
@Schema(description = "评语")
private String comment;
}

View File

@ -0,0 +1,31 @@
package com.reading.platform.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.reading.platform.entity.TaskFeedback;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Optional;
/**
* 任务评价 Mapper
*/
@Mapper
public interface TaskFeedbackMapper extends BaseMapper<TaskFeedback> {
/**
* 根据完成记录ID查询评价
*/
@Select("SELECT * FROM task_feedback WHERE completion_id = #{completionId} AND deleted_at IS NULL")
Optional<TaskFeedback> findByCompletionId(@Param("completionId") Long completionId);
/**
* 根据任务ID和学生ID查询评价
*/
@Select("SELECT tf.* FROM task_feedback tf " +
"INNER JOIN task_completion tc ON tf.completion_id = tc.id " +
"WHERE tf.task_id = #{taskId} AND tc.student_id = #{studentId} AND tf.deleted_at IS NULL")
Optional<TaskFeedback> findByTaskIdAndStudentId(@Param("taskId") Long taskId, @Param("studentId") Long studentId);
}

View File

@ -82,4 +82,9 @@ public interface ClassService extends com.baomidou.mybatisplus.extension.service
*/
List<Clazz> getActiveClassesByTenantId(Long tenantId);
/**
* 获取学生当前所在班级
*/
Clazz getPrimaryClassByStudentId(Long studentId);
}

View File

@ -0,0 +1,51 @@
package com.reading.platform.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.reading.platform.dto.request.TaskFeedbackRequest;
import com.reading.platform.dto.response.TaskFeedbackResponse;
import com.reading.platform.entity.TaskFeedback;
/**
* 任务评价 Service
*/
public interface TaskFeedbackService extends IService<TaskFeedback> {
/**
* 创建或更新评价
* @param completionId 完成记录ID
* @param request 评价请求
* @return 评价响应
*/
TaskFeedbackResponse createOrUpdateFeedback(Long completionId, TaskFeedbackRequest request);
/**
* 创建或更新评价指定教师ID
* @param completionId 完成记录ID
* @param request 评价请求
* @param teacherId 教师ID从当前登录用户获取
* @return 评价响应
*/
TaskFeedbackResponse createOrUpdateFeedback(Long completionId, TaskFeedbackRequest request, Long teacherId);
/**
* 根据完成记录ID获取评价
* @param completionId 完成记录ID
* @return 评价响应
*/
TaskFeedbackResponse getFeedbackByCompletionId(Long completionId);
/**
* 根据任务ID和学生ID获取评价
* @param taskId 任务ID
* @param studentId 学生ID
* @return 评价响应
*/
TaskFeedbackResponse getFeedbackByTaskIdAndStudentId(Long taskId, Long studentId);
/**
* 检查是否已评价
* @param completionId 完成记录ID
* @return 是否已评价
*/
boolean hasFeedback(Long completionId);
}

View File

@ -3,9 +3,10 @@ package com.reading.platform.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.reading.platform.common.response.PageResult;
import com.reading.platform.dto.request.TaskCreateRequest;
import com.reading.platform.dto.request.TaskSubmitRequest;
import com.reading.platform.dto.request.TaskUpdateRequest;
import com.reading.platform.dto.response.TaskCompletionDetailResponse;
import com.reading.platform.dto.response.TaskCompletionResponse;
import com.reading.platform.dto.response.TaskResponse;
import com.reading.platform.entity.Task;
import java.util.List;
@ -35,14 +36,39 @@ public interface TaskService extends com.baomidou.mybatisplus.extension.service.
void completeTask(Long taskId, Long studentId, String content, String attachments);
List<TaskCompletionResponse> getTaskCompletions(Long taskId, Long tenantId);
PageResult<TaskCompletionDetailResponse> getTaskCompletionsWithStudent(Long taskId, Long tenantId, Integer pageNum, Integer pageSize, String status);
java.util.Map<String, java.lang.Long> getTaskCompletionStats(Long taskId, Long tenantId);
void updateTaskCompletionStatus(Long taskId, Long studentId, Long tenantId, String status);
List<Task> getTasksByClassId(Long classId);
// =============== 完成情况与评价相关方法 ===============
/**
* 获取任务完成情况列表
*/
PageResult<TaskCompletionDetailResponse> getTaskCompletions(Long taskId, Long tenantId, Integer pageNum, Integer pageSize, String status);
/**
* 获取提交详情
*/
TaskCompletionDetailResponse getCompletionDetail(Long completionId, Long tenantId);
/**
* 提交任务完成家长端
*/
TaskCompletionDetailResponse submitTaskCompletion(Long taskId, TaskSubmitRequest request, Long tenantId);
// =============== 学校端只读方法 ===============
/**
* 获取学校端任务列表多维度筛选
*/
PageResult<TaskResponse> getSchoolTaskList(Long tenantId, Integer pageNum, Integer pageSize,
String keyword, String type, String status,
List<Long> classIds, List<Long> teacherIds,
String dateType, String startDate, String endDate,
String completionRate, String sortBy, String sortOrder);
/**
* 获取任务详情学校端
*/
TaskResponse getTaskDetailForSchool(Long taskId, Long tenantId);
}

View File

@ -262,4 +262,26 @@ public class ClassServiceImpl extends com.baomidou.mybatisplus.extension.service
);
}
@Override
public Clazz getPrimaryClassByStudentId(Long studentId) {
log.debug("获取学生当前所在班级,学生 ID: {}", studentId);
// 查找学生当前活跃的班级关联记录
StudentClassHistory history = studentClassHistoryMapper.selectOne(
new LambdaQueryWrapper<StudentClassHistory>()
.eq(StudentClassHistory::getStudentId, studentId)
.eq(StudentClassHistory::getStatus, "active")
.isNull(StudentClassHistory::getEndDate)
.orderByDesc(StudentClassHistory::getStartDate)
.last("LIMIT 1")
);
if (history == null) {
log.debug("学生当前没有活跃的班级关联,学生 ID: {}", studentId);
return null;
}
return clazzMapper.selectById(history.getClassId());
}
}

View File

@ -0,0 +1,149 @@
package com.reading.platform.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.reading.platform.dto.request.TaskFeedbackRequest;
import com.reading.platform.dto.response.TaskFeedbackResponse;
import com.reading.platform.entity.TaskCompletion;
import com.reading.platform.entity.TaskFeedback;
import com.reading.platform.entity.Teacher;
import com.reading.platform.mapper.TaskCompletionMapper;
import com.reading.platform.mapper.TaskFeedbackMapper;
import com.reading.platform.service.TaskFeedbackService;
import com.reading.platform.service.TeacherService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Optional;
/**
* 任务评价 Service 实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TaskFeedbackServiceImpl extends ServiceImpl<TaskFeedbackMapper, TaskFeedback>
implements TaskFeedbackService {
private final TaskCompletionMapper taskCompletionMapper;
private final TeacherService teacherService;
@Override
@Transactional
public TaskFeedbackResponse createOrUpdateFeedback(Long completionId, TaskFeedbackRequest request) {
return createOrUpdateFeedback(completionId, request, null);
}
/**
* 创建或更新评价指定教师ID
* @param completionId 完成记录ID
* @param request 评价请求
* @param teacherId 教师ID从当前登录用户获取
* @return 评价响应
*/
@Transactional
public TaskFeedbackResponse createOrUpdateFeedback(Long completionId, TaskFeedbackRequest request, Long teacherId) {
// 获取完成记录
TaskCompletion completion = taskCompletionMapper.selectById(completionId);
if (completion == null) {
throw new RuntimeException("完成记录不存在");
}
// 检查是否已评价
Optional<TaskFeedback> existingFeedback = baseMapper.findByCompletionId(completionId);
TaskFeedback feedback;
if (existingFeedback.isPresent()) {
// 更新评价
feedback = existingFeedback.get();
feedback.setResult(request.getResult());
feedback.setRating(request.getRating());
feedback.setComment(request.getComment());
feedback.setUpdatedAt(LocalDateTime.now());
this.updateById(feedback);
log.info("更新评价成功completionId={}", completionId);
} else {
// 创建新评价
feedback = new TaskFeedback();
feedback.setCompletionId(completionId);
feedback.setTaskId(completion.getTaskId());
feedback.setStudentId(completion.getStudentId());
feedback.setTeacherId(teacherId);
feedback.setResult(request.getResult());
feedback.setRating(request.getRating());
feedback.setComment(request.getComment());
feedback.setCreatedAt(LocalDateTime.now());
feedback.setUpdatedAt(LocalDateTime.now());
this.save(feedback);
log.info("创建评价成功completionId={}, teacherId={}", completionId, teacherId);
}
// 更新完成记录状态为已评价
completion.setStatus("REVIEWED");
completion.setReviewedAt(LocalDateTime.now());
taskCompletionMapper.updateById(completion);
return buildFeedbackResponse(feedback);
}
@Override
public TaskFeedbackResponse getFeedbackByCompletionId(Long completionId) {
Optional<TaskFeedback> feedback = baseMapper.findByCompletionId(completionId);
return feedback.map(this::buildFeedbackResponse).orElse(null);
}
@Override
public TaskFeedbackResponse getFeedbackByTaskIdAndStudentId(Long taskId, Long studentId) {
Optional<TaskFeedback> feedback = baseMapper.findByTaskIdAndStudentId(taskId, studentId);
return feedback.map(this::buildFeedbackResponse).orElse(null);
}
@Override
public boolean hasFeedback(Long completionId) {
return baseMapper.findByCompletionId(completionId).isPresent();
}
/**
* 构建评价响应
*/
private TaskFeedbackResponse buildFeedbackResponse(TaskFeedback feedback) {
TaskFeedbackResponse.TaskFeedbackResponseBuilder builder = TaskFeedbackResponse.builder()
.id(feedback.getId())
.completionId(feedback.getCompletionId())
.result(feedback.getResult())
.resultText(getResultText(feedback.getResult()))
.rating(feedback.getRating())
.comment(feedback.getComment())
.teacherId(feedback.getTeacherId())
.createdAt(feedback.getCreatedAt());
// 获取教师信息
if (feedback.getTeacherId() != null) {
Teacher teacher = teacherService.getById(feedback.getTeacherId());
if (teacher != null) {
builder.teacherName(teacher.getName());
builder.teacherAvatar(teacher.getAvatarUrl());
}
}
return builder.build();
}
/**
* 获取评价结果文本
*/
private String getResultText(String result) {
if (result == null) {
return "";
}
return switch (result) {
case "EXCELLENT" -> "优秀";
case "PASSED" -> "通过";
case "NEEDS_WORK" -> "需改进";
default -> result;
};
}
}

View File

@ -3,26 +3,22 @@ package com.reading.platform.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.reading.platform.common.enums.ErrorCode;
import com.reading.platform.common.exception.BusinessException;
import com.reading.platform.common.response.PageResult;
import com.reading.platform.dto.request.TaskCreateRequest;
import com.reading.platform.dto.request.TaskSubmitRequest;
import com.reading.platform.dto.request.TaskUpdateRequest;
import com.reading.platform.dto.response.TaskCompletionDetailResponse;
import com.reading.platform.dto.response.TaskCompletionResponse;
import com.reading.platform.entity.Clazz;
import com.reading.platform.entity.Student;
import com.reading.platform.entity.StudentClassHistory;
import com.reading.platform.entity.Task;
import com.reading.platform.entity.TaskCompletion;
import com.reading.platform.entity.TaskTarget;
import com.reading.platform.mapper.ClazzMapper;
import com.reading.platform.mapper.StudentClassHistoryMapper;
import com.reading.platform.mapper.StudentMapper;
import com.reading.platform.dto.response.TaskFeedbackResponse;
import com.reading.platform.dto.response.TaskResponse;
import com.reading.platform.entity.*;
import com.reading.platform.mapper.TaskCompletionMapper;
import com.reading.platform.mapper.TaskMapper;
import com.reading.platform.mapper.TaskTargetMapper;
import com.reading.platform.service.TaskService;
import com.reading.platform.service.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@ -30,12 +26,9 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Service
@ -46,9 +39,10 @@ public class TaskServiceImpl extends ServiceImpl<TaskMapper, Task>
private final TaskMapper taskMapper;
private final TaskTargetMapper taskTargetMapper;
private final TaskCompletionMapper taskCompletionMapper;
private final StudentMapper studentMapper;
private final StudentClassHistoryMapper studentClassHistoryMapper;
private final ClazzMapper clazzMapper;
private final StudentService studentService;
private final ClassService classService;
private final TaskFeedbackService taskFeedbackService;
private final ObjectMapper objectMapper;
@Override
@Transactional
@ -232,150 +226,6 @@ public class TaskServiceImpl extends ServiceImpl<TaskMapper, Task>
}
}
@Override
public List<TaskCompletionResponse> getTaskCompletions(Long taskId, Long tenantId) {
getTaskByIdWithTenantCheck(taskId, tenantId);
List<TaskCompletion> completions = taskCompletionMapper.selectList(
new LambdaQueryWrapper<TaskCompletion>().eq(TaskCompletion::getTaskId, taskId)
);
return com.reading.platform.common.mapper.TaskCompletionMapper.INSTANCE.toVO(completions);
}
@Override
public PageResult<TaskCompletionDetailResponse> getTaskCompletionsWithStudent(Long taskId, Long tenantId,
Integer pageNum, Integer pageSize, String status) {
getTaskByIdWithTenantCheck(taskId, tenantId);
int pn = pageNum != null && pageNum > 0 ? pageNum : 1;
int ps = pageSize != null && pageSize > 0 ? pageSize : 20;
LambdaQueryWrapper<TaskCompletion> wrapper = new LambdaQueryWrapper<TaskCompletion>()
.eq(TaskCompletion::getTaskId, taskId)
.orderByDesc(TaskCompletion::getUpdatedAt);
if (StringUtils.hasText(status)) {
wrapper.eq(TaskCompletion::getStatus, normalizeStatus(status));
}
long total = taskCompletionMapper.selectCount(wrapper);
Page<TaskCompletion> page = new Page<>(pn, ps);
List<TaskCompletion> completions = taskCompletionMapper.selectPage(page, wrapper).getRecords();
List<TaskCompletionDetailResponse> items = completions.stream()
.map(c -> buildCompletionDetail(c))
.collect(Collectors.toList());
return PageResult.of(items, total, (long) pn, (long) ps);
}
@Override
public Map<String, Long> getTaskCompletionStats(Long taskId, Long tenantId) {
getTaskByIdWithTenantCheck(taskId, tenantId);
Map<String, Long> stats = new HashMap<>();
stats.put("PENDING", taskCompletionMapper.selectCount(
new LambdaQueryWrapper<TaskCompletion>()
.eq(TaskCompletion::getTaskId, taskId)
.in(TaskCompletion::getStatus, Arrays.asList("PENDING", "pending"))
));
stats.put("IN_PROGRESS", taskCompletionMapper.selectCount(
new LambdaQueryWrapper<TaskCompletion>()
.eq(TaskCompletion::getTaskId, taskId)
.in(TaskCompletion::getStatus, Arrays.asList("IN_PROGRESS", "in_progress"))
));
stats.put("COMPLETED", taskCompletionMapper.selectCount(
new LambdaQueryWrapper<TaskCompletion>()
.eq(TaskCompletion::getTaskId, taskId)
.in(TaskCompletion::getStatus, Arrays.asList("COMPLETED", "completed"))
));
return stats;
}
@Override
@Transactional
public void updateTaskCompletionStatus(Long taskId, Long studentId, Long tenantId, String status) {
getTaskByIdWithTenantCheck(taskId, tenantId);
TaskCompletion completion = taskCompletionMapper.selectOne(
new LambdaQueryWrapper<TaskCompletion>()
.eq(TaskCompletion::getTaskId, taskId)
.eq(TaskCompletion::getStudentId, studentId)
);
if (completion == null) {
completion = new TaskCompletion();
completion.setTaskId(taskId);
completion.setStudentId(studentId);
taskCompletionMapper.insert(completion);
}
completion.setStatus(normalizeStatus(status));
if ("COMPLETED".equals(completion.getStatus())) {
completion.setCompletedAt(LocalDateTime.now());
}
taskCompletionMapper.updateById(completion);
}
private TaskCompletionDetailResponse buildCompletionDetail(TaskCompletion c) {
Student student = studentMapper.selectById(c.getStudentId());
TaskCompletionDetailResponse.StudentInfo.ClassInfo classInfo = null;
if (student != null) {
StudentClassHistory sch = studentClassHistoryMapper.selectOne(
new LambdaQueryWrapper<StudentClassHistory>()
.eq(StudentClassHistory::getStudentId, c.getStudentId())
.in(StudentClassHistory::getStatus, Arrays.asList("active", "ACTIVE"))
.isNull(StudentClassHistory::getEndDate)
.orderByDesc(StudentClassHistory::getStartDate)
.last("LIMIT 1")
);
if (sch != null) {
Clazz clazz = clazzMapper.selectById(sch.getClassId());
if (clazz != null) {
classInfo = TaskCompletionDetailResponse.StudentInfo.ClassInfo.builder()
.id(clazz.getId())
.name(clazz.getName())
.build();
}
}
if (classInfo == null && student.getGrade() != null) {
classInfo = TaskCompletionDetailResponse.StudentInfo.ClassInfo.builder()
.id(null)
.name(student.getGrade())
.build();
}
}
String feedback = c.getFeedback();
return TaskCompletionDetailResponse.builder()
.id(c.getId())
.taskId(c.getTaskId())
.studentId(c.getStudentId())
.status(normalizeStatusForResponse(c.getStatus()))
.completedAt(c.getCompletedAt())
.content(c.getContent())
.attachments(c.getAttachments())
.rating(c.getRating())
.feedback(feedback)
.parentFeedback(feedback)
.createdAt(c.getCreatedAt())
.updatedAt(c.getUpdatedAt())
.student(student != null ? TaskCompletionDetailResponse.StudentInfo.builder()
.id(student.getId())
.name(student.getName())
.gender(student.getGender())
.clazz(classInfo)
.build() : null)
.build();
}
private String normalizeStatus(String status) {
if (status == null) return "PENDING";
String u = status.toUpperCase();
if ("COMPLETED".equals(u) || "IN_PROGRESS".equals(u) || "PENDING".equals(u)) return u;
if ("completed".equals(status.toLowerCase())) return "COMPLETED";
if ("in_progress".equals(status.toLowerCase()) || "in progress".equalsIgnoreCase(status)) return "IN_PROGRESS";
return "PENDING";
}
private String normalizeStatusForResponse(String status) {
if (status == null) return "PENDING";
return normalizeStatus(status);
}
@Override
public List<Task> getTasksByClassId(Long classId) {
List<TaskTarget> targets = taskTargetMapper.selectList(
@ -399,4 +249,296 @@ public class TaskServiceImpl extends ServiceImpl<TaskMapper, Task>
);
}
// =============== 完成情况与评价相关方法实现 ===============
@Override
public PageResult<TaskCompletionDetailResponse> getTaskCompletions(Long taskId, Long tenantId, Integer pageNum, Integer pageSize, String status) {
// 验证任务存在且属于该租户
Task task = getTaskByIdWithTenantCheck(taskId, tenantId);
Page<TaskCompletion> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<TaskCompletion> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(TaskCompletion::getTaskId, taskId);
if (StringUtils.hasText(status)) {
wrapper.eq(TaskCompletion::getStatus, status);
}
wrapper.orderByDesc(TaskCompletion::getCreatedAt);
Page<TaskCompletion> completionPage = taskCompletionMapper.selectPage(page, wrapper);
List<TaskCompletionDetailResponse> responses = completionPage.getRecords().stream()
.map(this::buildCompletionDetailResponse)
.toList();
return PageResult.of(responses, completionPage.getTotal(), completionPage.getCurrent(), completionPage.getSize());
}
@Override
public TaskCompletionDetailResponse getCompletionDetail(Long completionId, Long tenantId) {
TaskCompletion completion = taskCompletionMapper.selectById(completionId);
if (completion == null) {
throw new BusinessException(ErrorCode.DATA_NOT_FOUND, "完成记录不存在");
}
// 验证任务属于该租户
getTaskByIdWithTenantCheck(completion.getTaskId(), tenantId);
return buildCompletionDetailResponse(completion);
}
/**
* 构建完成详情响应
*/
private TaskCompletionDetailResponse buildCompletionDetailResponse(TaskCompletion completion) {
TaskCompletionDetailResponse.TaskCompletionDetailResponseBuilder builder = TaskCompletionDetailResponse.builder()
.id(completion.getId())
.taskId(completion.getTaskId())
.status(completion.getStatus())
.statusText(getStatusText(completion.getStatus()))
.photos(parsePhotos(completion.getPhotos()))
.videoUrl(completion.getVideoUrl())
.audioUrl(completion.getAudioUrl())
.content(completion.getContent())
.submittedAt(completion.getSubmittedAt())
.reviewedAt(completion.getReviewedAt());
// 获取任务标题
Task task = taskMapper.selectById(completion.getTaskId());
if (task != null) {
builder.taskTitle(task.getTitle());
}
// 获取学生信息
if (completion.getStudentId() != null) {
Student student = studentService.getById(completion.getStudentId());
if (student != null) {
TaskCompletionDetailResponse.StudentInfo studentInfo = TaskCompletionDetailResponse.StudentInfo.builder()
.id(student.getId())
.name(student.getName())
.avatar(student.getAvatarUrl())
.gender(student.getGender())
.build();
// 获取班级信息
Clazz clazz = classService.getPrimaryClassByStudentId(student.getId());
if (clazz != null) {
TaskCompletionDetailResponse.ClassInfo classInfo = TaskCompletionDetailResponse.ClassInfo.builder()
.id(clazz.getId())
.name(clazz.getName())
.grade(clazz.getGrade())
.build();
studentInfo.setClassInfo(classInfo);
}
builder.student(studentInfo);
}
}
// 获取评价信息
TaskFeedbackResponse feedback = taskFeedbackService.getFeedbackByCompletionId(completion.getId());
builder.feedback(feedback);
return builder.build();
}
/**
* 解析照片 JSON 数组
*/
private List<String> parsePhotos(String photosJson) {
if (!StringUtils.hasText(photosJson)) {
return Collections.emptyList();
}
try {
return objectMapper.readValue(photosJson, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("解析照片JSON失败: {}", e.getMessage());
return Collections.emptyList();
}
}
/**
* 获取状态文本
*/
private String getStatusText(String status) {
if (status == null) {
return "";
}
return switch (status) {
case "PENDING" -> "待完成";
case "SUBMITTED" -> "已提交";
case "REVIEWED" -> "已评价";
case "completed" -> "已完成"; // 兼容旧数据
default -> status;
};
}
@Override
@Transactional
public TaskCompletionDetailResponse submitTaskCompletion(Long taskId, TaskSubmitRequest request, Long tenantId) {
log.info("提交任务完成taskId={}, studentId={}", taskId, request.getStudentId());
// 验证任务存在且属于该租户
Task task = getTaskByIdWithTenantCheck(taskId, tenantId);
// 验证学生ID
if (request.getStudentId() == null) {
throw new BusinessException(ErrorCode.INVALID_PARAMETER, "学生ID不能为空");
}
// 查找或创建完成记录
TaskCompletion completion = taskCompletionMapper.selectOne(
new LambdaQueryWrapper<TaskCompletion>()
.eq(TaskCompletion::getTaskId, taskId)
.eq(TaskCompletion::getStudentId, request.getStudentId())
);
LocalDateTime now = LocalDateTime.now();
if (completion == null) {
// 创建新记录
completion = new TaskCompletion();
completion.setTaskId(taskId);
completion.setStudentId(request.getStudentId());
completion.setStatus("SUBMITTED");
completion.setPhotos(serializePhotos(request.getPhotos()));
completion.setVideoUrl(request.getVideoUrl());
completion.setAudioUrl(request.getAudioUrl());
completion.setContent(request.getContent());
completion.setSubmittedAt(now);
completion.setCreatedAt(now);
completion.setUpdatedAt(now);
taskCompletionMapper.insert(completion);
log.info("创建完成记录成功completionId={}", completion.getId());
} else {
// 更新记录
completion.setStatus("SUBMITTED");
completion.setPhotos(serializePhotos(request.getPhotos()));
completion.setVideoUrl(request.getVideoUrl());
completion.setAudioUrl(request.getAudioUrl());
completion.setContent(request.getContent());
completion.setSubmittedAt(now);
completion.setUpdatedAt(now);
taskCompletionMapper.updateById(completion);
log.info("更新完成记录成功completionId={}", completion.getId());
}
return buildCompletionDetailResponse(completion);
}
/**
* 序列化照片列表为 JSON
*/
private String serializePhotos(List<String> photos) {
if (photos == null || photos.isEmpty()) {
return null;
}
try {
return objectMapper.writeValueAsString(photos);
} catch (Exception e) {
log.warn("序列化照片JSON失败: {}", e.getMessage());
return null;
}
}
// =============== 学校端只读方法实现 ===============
@Override
public PageResult<TaskResponse> getSchoolTaskList(Long tenantId, Integer pageNum, Integer pageSize,
String keyword, String type, String status,
List<Long> classIds, List<Long> teacherIds,
String dateType, String startDate, String endDate,
String completionRate, String sortBy, String sortOrder) {
log.info("获取学校端任务列表tenantId={}", tenantId);
Page<Task> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<Task> wrapper = new LambdaQueryWrapper<>();
// 租户过滤
wrapper.eq(Task::getTenantId, tenantId);
// 关键字搜索
if (StringUtils.hasText(keyword)) {
wrapper.like(Task::getTitle, keyword);
}
// 类型筛选
if (StringUtils.hasText(type)) {
wrapper.eq(Task::getType, type);
}
// 状态筛选
if (StringUtils.hasText(status)) {
wrapper.eq(Task::getStatus, status);
}
// TODO: 实现更多筛选条件
// - classIds: 班级筛选需要关联 task_target
// - teacherIds: 教师筛选
// - dateType/startDate/endDate: 时间筛选
// - completionRate: 完成率筛选
// - sortBy/sortOrder: 排序
// 默认排序
if ("completionRate".equals(sortBy)) {
// 完成率排序需要特殊处理暂时使用创建时间
wrapper.orderByDesc(Task::getCreatedAt);
} else {
wrapper.orderByDesc(Task::getCreatedAt);
}
Page<Task> taskPage = taskMapper.selectPage(page, wrapper);
// 转换为响应对象
// TODO: 使用 TaskMapper 转换
List<TaskResponse> responses = taskPage.getRecords().stream()
.map(task -> TaskResponse.builder()
.id(task.getId())
.tenantId(task.getTenantId())
.title(task.getTitle())
.description(task.getDescription())
.type(task.getType())
.relatedBookName(task.getRelatedBookName())
.courseId(task.getCourseId())
.creatorId(task.getCreatorId())
.creatorRole(task.getCreatorRole())
.startDate(task.getStartDate())
.dueDate(task.getDueDate())
.status(task.getStatus())
.attachments(task.getAttachments())
.createdAt(task.getCreatedAt())
.updatedAt(task.getUpdatedAt())
.build())
.toList();
return PageResult.of(responses, taskPage.getTotal(), taskPage.getCurrent(), taskPage.getSize());
}
@Override
public TaskResponse getTaskDetailForSchool(Long taskId, Long tenantId) {
log.info("获取学校端任务详情taskId={}, tenantId={}", taskId, tenantId);
// 验证任务存在且属于该租户
Task task = getTaskByIdWithTenantCheck(taskId, tenantId);
return TaskResponse.builder()
.id(task.getId())
.tenantId(task.getTenantId())
.title(task.getTitle())
.description(task.getDescription())
.type(task.getType())
.relatedBookName(task.getRelatedBookName())
.courseId(task.getCourseId())
.creatorId(task.getCreatorId())
.creatorRole(task.getCreatorRole())
.startDate(task.getStartDate())
.dueDate(task.getDueDate())
.status(task.getStatus())
.attachments(task.getAttachments())
.createdAt(task.getCreatedAt())
.updatedAt(task.getUpdatedAt())
.build();
}
}

View File

@ -0,0 +1,38 @@
-- =====================================================
-- 阅读任务模块增强 - 数据库迁移脚本
-- 版本: V43
-- 日期: 2026-03-20
-- 说明: 新增评价功能,扩展提交内容字段
-- =====================================================
-- 1. Task 表新增关联绘本字段
ALTER TABLE task ADD COLUMN related_book_name VARCHAR(200) DEFAULT NULL COMMENT '关联绘本名称';
-- 2. TaskCompletion 表扩展字段
-- 确保 photos 字段是 JSON 类型(如果之前是 VARCHAR需要转换
-- ALTER TABLE task_completion MODIFY COLUMN photos JSON COMMENT '照片URL数组(JSON)';
-- 新增视频和语音字段
ALTER TABLE task_completion ADD COLUMN video_url VARCHAR(500) DEFAULT NULL COMMENT '视频URL';
ALTER TABLE task_completion ADD COLUMN audio_url VARCHAR(500) DEFAULT NULL COMMENT '语音URL';
-- 3. 新建 TaskFeedback 评价表
CREATE TABLE IF NOT EXISTS task_feedback (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
completion_id BIGINT NOT NULL COMMENT '完成记录ID',
task_id BIGINT NOT NULL COMMENT '任务ID冗余方便查询',
student_id BIGINT NOT NULL COMMENT '学生ID冗余',
teacher_id BIGINT NOT NULL COMMENT '教师ID',
result VARCHAR(20) NOT NULL COMMENT '评价结果EXCELLENT-优秀/PASSED-通过/NEEDS_WORK-需改进',
rating INT DEFAULT NULL COMMENT '评分 1-5可选',
comment TEXT DEFAULT NULL COMMENT '评语',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted_at DATETIME DEFAULT NULL COMMENT '删除时间(逻辑删除)',
INDEX idx_completion_id (completion_id),
INDEX idx_task_id (task_id),
INDEX idx_student_id (student_id),
INDEX idx_teacher_id (teacher_id),
INDEX idx_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务评价表';