feat: 任务完成情况 API 与 UI 优化

- 学校/教师端: 新增 GET/PUT 任务完成情况接口,支持分页与状态筛选
- 后端: TaskCompletionDetailResponse 含学生姓名、班级信息
- 后端: 新增 getTaskCompletionStats、getTaskCompletionsWithStudent
- 学校端: 状态筛选移至弹框标题后,分页始终显示
- 班级字段: 支持 ACTIVE 状态,无记录时用 student.grade 兜底
- 迁移 V43: 添加 student_class_history 测试数据

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-20 11:48:45 +08:00
parent 13fc0e720e
commit 37a6aba8cc
10 changed files with 491 additions and 204 deletions

View File

@ -997,12 +997,17 @@ 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 {
@ -1046,8 +1051,27 @@ export const updateSchoolTask = (id: number, data: UpdateSchoolTaskDto) =>
export const deleteSchoolTask = (id: number) =>
http.delete<{ message: string }>(`/v1/school/tasks/${id}`);
export const getSchoolTaskCompletions = (taskId: number) =>
http.get<TaskCompletion[]>(`/v1/school/tasks/${taskId}/completions`);
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 getSchoolClasses = () =>
http.get<ClassInfo[]>('/v1/school/classes');

View File

@ -706,9 +706,17 @@ 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) =>
Promise.resolve([]);
// 获取教师任务完成情况
export const getTeacherTaskCompletions = (taskId: number, params?: { page?: number; pageSize?: number; status?: string }) =>
http.get<{ items: TaskCompletion[]; total: number; page: number; pageSize: number }>(
`/v1/teacher/tasks/${taskId}/completions`,
{ params: { page: params?.page ?? 1, pageSize: params?.pageSize ?? 20, status: params?.status } }
).then(res => ({
items: res.items ?? res.list ?? [],
total: res.total ?? 0,
page: res.page ?? res.pageNum ?? 1,
pageSize: res.pageSize ?? 20,
}));
export const createTeacherTask = (data: CreateTeacherTaskDto) =>
http.post('/v1/teacher/tasks', data) as any;
@ -719,9 +727,9 @@ 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) =>
Promise.reject(new Error('接口未实现'));
// 更新任务完成状态
export const updateTaskCompletion = (taskId: number, studentId: number, data: UpdateTaskCompletionDto) =>
http.put(`/v1/teacher/tasks/${taskId}/completions/${studentId}`, data) as Promise<any>;
export const sendTaskReminder = (_taskId: number) =>
Promise.reject(new Error('接口未实现'));

View File

@ -46,35 +46,18 @@
<!-- 筛选区域 -->
<div class="filter-bar">
<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 />
</div>
<!-- 任务列表 -->
@ -107,12 +90,8 @@
<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.completionCount || 0 }} 人完成</span>
</div>
<div class="card-actions">
@ -142,24 +121,13 @@
<!-- 分页 -->
<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="handleSubmit"
:confirm-loading="creating"
width="600px"
>
<a-modal v-model:open="createModalVisible" :title="isEdit ? '编辑任务' : '发布任务'" @ok="handleSubmit"
:confirm-loading="creating" width="600px">
<a-form :model="createForm" layout="vertical">
<a-form-item label="任务标题" required>
<a-input v-model:value="createForm.title" placeholder="请输入任务标题" />
@ -187,46 +155,36 @@
</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>
<a-range-picker
v-model:value="createForm.dateRange"
style="width: 100%;"
/>
<a-range-picker v-model:value="createForm.dateRange" style="width: 100%;" />
</a-form-item>
</a-form>
</a-modal>
<!-- 完成情况弹窗 -->
<a-modal
v-model:open="completionModalVisible"
:title="`完成情况 - ${selectedTask?.title || ''}`"
:footer="null"
width="700px"
>
<a-modal v-model:open="completionModalVisible" :footer="null" width="700px">
<template #title>
<span>完成情况 - {{ selectedTask?.title || '' }}</span>
<a-select v-model:value="completionFilter.status" placeholder="筛选状态" style="width: 120px; margin-left: 12px" 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>
</template>
<div class="completion-stats">
<a-tag color="blue">{{ completionStats.pending }} 待完成</a-tag>
<a-tag color="orange">{{ completionStats.inProgress }} 进行中</a-tag>
<a-tag color="green">{{ completionStats.completed }} 已完成</a-tag>
</div>
<a-table
:dataSource="completions"
:columns="completionColumns"
:pagination="false"
:loading="loadingCompletions"
rowKey="id"
size="small"
>
<a-tag color="blue">{{ completionStats.pending }} 待完成</a-tag>
<a-tag color="orange">{{ completionStats.inProgress }} 进行中</a-tag>
<a-tag color="green">{{ completionStats.completed }} 已完成</a-tag>
</div>
<a-table :dataSource="completions" :columns="completionColumns" :pagination="false" :loading="loadingCompletions"
rowKey="id" size="small">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getCompletionStatusColor(record.status)">
@ -234,13 +192,18 @@
</a-tag>
</template>
<template v-if="column.key === 'feedback'">
<span v-if="record.parentFeedback" class="feedback-text">
{{ record.parentFeedback.substring(0, 30) }}{{ record.parentFeedback.length > 30 ? '...' : '' }}
<span v-if="record.parentFeedback || record.feedback" class="feedback-text">
{{ (record.parentFeedback || record.feedback || '').substring(0, 30) }}{{ (record.parentFeedback || record.feedback || '').length > 30 ? '...' : '' }}
</span>
<span v-else class="no-feedback">暂无家长反馈</span>
</template>
</template>
</a-table>
<div v-if="completionTotal > 0" class="pagination-section" style="margin-top: 16px">
<a-pagination v-model:current="completionPage" :total="completionTotal" :page-size="completionPageSize"
size="small" @change="onCompletionPageChange" show-quick-jumper
:show-total="(t: number) => `共 ${t} 条`" />
</div>
</a-modal>
</div>
</template>
@ -282,6 +245,10 @@ const editTaskId = ref<number | null>(null);
const tasks = ref<SchoolTask[]>([]);
const classes = ref<any[]>([]);
const completions = ref<TaskCompletion[]>([]);
const completionTotal = ref(0);
const completionPage = ref(1);
const completionPageSize = ref(10);
const completionFilter = reactive({ status: undefined as string | undefined });
const selectedTask = ref<SchoolTask | null>(null);
const loadingCompletions = ref(false);
@ -363,7 +330,7 @@ const getCompletionRate = (task: SchoolTask) => {
return Math.round((completed / task.completionCount) * 100);
};
const getCompletedCount = (task: SchoolTask) => {
const getCompletedCount = (_task: SchoolTask) => {
return completions.value.filter(c => c.status === 'COMPLETED').length;
};
@ -389,7 +356,7 @@ const loadTasks = async () => {
loading.value = true;
try {
const result = await getSchoolTasks({
page: currentPage.value,
pageNum: currentPage.value,
pageSize: pageSize.value,
status: filters.status,
taskType: filters.taskType,
@ -499,23 +466,22 @@ const handleDelete = async (id: number) => {
}
};
const viewCompletionDetail = async (task: SchoolTask) => {
selectedTask.value = task;
completionModalVisible.value = true;
const loadCompletions = async () => {
if (!selectedTask.value) return;
loadingCompletions.value = true;
try {
const result = await getSchoolTaskCompletions(task.id, { pageSize: 100 });
completions.value = result.list;
//
completionStats.pending = result.list.filter(c => c.status === 'PENDING').length;
completionStats.inProgress = result.list.filter(c => c.status === 'IN_PROGRESS').length;
completionStats.completed = result.list.filter(c => c.status === 'COMPLETED').length;
//
const total = result.list.length;
stats.completionRate = total > 0 ? Math.round((completionStats.completed / total) * 100) : 0;
const data = await getSchoolTaskCompletions(selectedTask.value.id, {
page: completionPage.value,
pageSize: completionPageSize.value,
status: completionFilter.status,
});
completions.value = data.items;
completionTotal.value = data.total;
completionStats.pending = data.stats.PENDING ?? 0;
completionStats.inProgress = data.stats.IN_PROGRESS ?? 0;
completionStats.completed = data.stats.COMPLETED ?? 0;
const totalCount = completionStats.pending + completionStats.inProgress + completionStats.completed;
stats.completionRate = totalCount > 0 ? Math.round((completionStats.completed / totalCount) * 100) : 0;
} catch (error: any) {
message.error(error.response?.data?.message || '获取完成情况失败');
} finally {
@ -523,6 +489,19 @@ const viewCompletionDetail = async (task: SchoolTask) => {
}
};
const viewCompletionDetail = async (task: SchoolTask) => {
selectedTask.value = task;
completionPage.value = 1;
completionFilter.status = undefined;
completionModalVisible.value = true;
loadCompletions();
};
const onCompletionPageChange = (page: number) => {
completionPage.value = page;
loadCompletions();
};
const onPageChange = (page: number) => {
currentPage.value = page;
loadTasks();
@ -729,9 +708,9 @@ onMounted(() => {
}
.completion-stats {
margin-bottom: 16px;
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.feedback-text {

View File

@ -2,7 +2,9 @@
<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">
@ -44,35 +46,20 @@
<!-- 筛选区域 -->
<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>
@ -105,12 +92,8 @@
<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">
@ -154,34 +137,18 @@
</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">
<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;">
@ -216,70 +183,41 @@
</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="任务时间" required>
<a-range-picker
v-model:value="createForm.dateRange"
style="width: 100%;"
/>
<a-range-picker v-model:value="createForm.dateRange" 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 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>
@ -305,12 +243,8 @@
</div>
</div>
<div class="completion-status">
<a-select
:value="completion.status"
style="width: 100px;"
size="small"
@change="(val: string) => updateCompletionStatus(completion, val)"
>
<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>
@ -320,7 +254,8 @@
<div v-if="completion.parentFeedback" 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>
<span class="feedback-text">{{ completion.parentFeedback.substring(0, 30) }}{{
completion.parentFeedback.length > 30 ? '...' : '' }}</span>
</a-tooltip>
</div>
<span v-else class="no-feedback">暂无家长反馈</span>
@ -340,13 +275,8 @@
</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>
</div>
@ -745,7 +675,7 @@ const updateCompletionStatus = async (completion: TaskCompletion, status: string
}
message.success('状态已更新');
} catch (error: any) {
message.error('更新失败');
message.error('更新失败', error);
loadCompletions();
}
};

View File

@ -7,6 +7,7 @@ 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;
@ -16,7 +17,9 @@ 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")
@RestController
@ -53,6 +56,25 @@ public class SchoolTaskController {
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")
@GetMapping
public Result<PageResult<TaskResponse>> getTaskPage(

View File

@ -7,6 +7,7 @@ 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;
@ -16,7 +17,9 @@ 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
@ -50,6 +53,36 @@ 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(

View File

@ -0,0 +1,73 @@
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;
/**
* 任务完成详情响应含学生信息
*/
@Data
@Builder
@Schema(description = "任务完成详情响应(含学生信息)")
public class TaskCompletionDetailResponse {
@Schema(description = "ID")
private Long 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 StudentInfo student;
@Data
@Builder
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;
}
}
}

View File

@ -1,8 +1,11 @@
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.TaskUpdateRequest;
import com.reading.platform.dto.response.TaskCompletionDetailResponse;
import com.reading.platform.dto.response.TaskCompletionResponse;
import com.reading.platform.entity.Task;
import java.util.List;
@ -32,6 +35,14 @@ 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);
}

View File

@ -5,11 +5,20 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
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.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.mapper.TaskCompletionMapper;
import com.reading.platform.mapper.TaskMapper;
import com.reading.platform.mapper.TaskTargetMapper;
@ -21,8 +30,12 @@ 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.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Service
@ -33,6 +46,9 @@ 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;
@Override
@Transactional
@ -216,6 +232,150 @@ 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(

View File

@ -0,0 +1,47 @@
-- =====================================================
-- 添加学生班级关联数据 (student_class_history)
-- 学生 1-5 -> 小一班, 6-10 -> 小二班, 11-15 -> 中一班, 16-20 -> 中二班
-- 21-25 -> 大一班, 26-30 -> 大二班, 31-35 -> 学前班, 36-40 -> 托儿班
-- =====================================================
INSERT IGNORE INTO `student_class_history` (`id`, `student_id`, `class_id`, `start_date`, `end_date`, `status`) VALUES
(1, 1, 1, '2025-09-01', NULL, 'ACTIVE'),
(2, 2, 1, '2025-09-01', NULL, 'ACTIVE'),
(3, 3, 1, '2025-09-01', NULL, 'ACTIVE'),
(4, 4, 1, '2025-09-01', NULL, 'ACTIVE'),
(5, 5, 1, '2025-09-01', NULL, 'ACTIVE'),
(6, 6, 2, '2025-09-01', NULL, 'ACTIVE'),
(7, 7, 2, '2025-09-01', NULL, 'ACTIVE'),
(8, 8, 2, '2025-09-01', NULL, 'ACTIVE'),
(9, 9, 2, '2025-09-01', NULL, 'ACTIVE'),
(10, 10, 2, '2025-09-01', NULL, 'ACTIVE'),
(11, 11, 3, '2025-09-01', NULL, 'ACTIVE'),
(12, 12, 3, '2025-09-01', NULL, 'ACTIVE'),
(13, 13, 3, '2025-09-01', NULL, 'ACTIVE'),
(14, 14, 3, '2025-09-01', NULL, 'ACTIVE'),
(15, 15, 3, '2025-09-01', NULL, 'ACTIVE'),
(16, 16, 4, '2025-09-01', NULL, 'ACTIVE'),
(17, 17, 4, '2025-09-01', NULL, 'ACTIVE'),
(18, 18, 4, '2025-09-01', NULL, 'ACTIVE'),
(19, 19, 4, '2025-09-01', NULL, 'ACTIVE'),
(20, 20, 4, '2025-09-01', NULL, 'ACTIVE'),
(21, 21, 5, '2025-09-01', NULL, 'ACTIVE'),
(22, 22, 5, '2025-09-01', NULL, 'ACTIVE'),
(23, 23, 5, '2025-09-01', NULL, 'ACTIVE'),
(24, 24, 5, '2025-09-01', NULL, 'ACTIVE'),
(25, 25, 5, '2025-09-01', NULL, 'ACTIVE'),
(26, 26, 6, '2025-09-01', NULL, 'ACTIVE'),
(27, 27, 6, '2025-09-01', NULL, 'ACTIVE'),
(28, 28, 6, '2025-09-01', NULL, 'ACTIVE'),
(29, 29, 6, '2025-09-01', NULL, 'ACTIVE'),
(30, 30, 6, '2025-09-01', NULL, 'ACTIVE'),
(31, 31, 7, '2025-09-01', NULL, 'ACTIVE'),
(32, 32, 7, '2025-09-01', NULL, 'ACTIVE'),
(33, 33, 7, '2025-09-01', NULL, 'ACTIVE'),
(34, 34, 7, '2025-09-01', NULL, 'ACTIVE'),
(35, 35, 7, '2025-09-01', NULL, 'ACTIVE'),
(36, 36, 8, '2025-09-01', NULL, 'ACTIVE'),
(37, 37, 8, '2025-09-01', NULL, 'ACTIVE'),
(38, 38, 8, '2025-09-01', NULL, 'ACTIVE'),
(39, 39, 8, '2025-09-01', NULL, 'ACTIVE'),
(40, 40, 8, '2025-09-01', NULL, 'ACTIVE');