fix: 学校端家长管理选择孩子列表优化

- 学生列表分页参数修复:page 改为 pageNum
- StudentResponse 添加 className 字段,显示班级名称
- 性别显示逻辑简化,兼容空值
- 修复 TeacherListView 中 Modal 导入错误

feat: 排课管理支持删除已取消的排课

- 新增 ScheduleRepeatType 和 ScheduleStatus 枚举
- 添加物理删除接口 /force,仅允许删除已取消的排课
- ScheduleList 和 TimetableView 增加删除按钮
This commit is contained in:
En 2026-03-24 14:04:39 +08:00
parent 342456347e
commit e2547daa63
13 changed files with 276 additions and 14 deletions

View File

@ -613,6 +613,9 @@ export const updateSchedule = (id: number, data: UpdateScheduleDto) =>
export const cancelSchedule = (id: number) => export const cancelSchedule = (id: number) =>
http.delete<{ message: string }>(`/v1/school/schedules/${id}`); http.delete<{ message: string }>(`/v1/school/schedules/${id}`);
export const deleteSchedule = (id: number) =>
http.delete<{ message: string }>(`/v1/school/schedules/${id}/force`);
export const getTimetable = (params: TimetableQueryParams) => export const getTimetable = (params: TimetableQueryParams) =>
http.get<{ http.get<{
byDate: Record<string, SchedulePlan[]>; byDate: Record<string, SchedulePlan[]>;

View File

@ -101,7 +101,7 @@
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="课程封面"> <a-form-item label="课程封面" required>
<a-upload <a-upload
v-model:file-list="coverImages" v-model:file-list="coverImages"
list-type="picture-card" list-type="picture-card"
@ -283,6 +283,12 @@ const handleChange = () => {
const validate = async () => { const validate = async () => {
try { try {
await formRef.value?.validate(); await formRef.value?.validate();
//
if (!formData.coverImagePath) {
return { valid: false, errors: ['请上传课程封面'] };
}
return { valid: true, errors: [] as string[] }; return { valid: true, errors: [] as string[] };
} catch (err: any) { } catch (err: any) {
const errorFields = err?.errorFields || []; const errorFields = err?.errorFields || [];

View File

@ -246,7 +246,10 @@
@change="handleStudentTableChange" style="margin-top: 16px;"> @change="handleStudentTableChange" style="margin-top: 16px;">
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'gender'"> <template v-if="column.dataIndex === 'gender'">
{{ record.gender === 'MALE' ? '男' : record.gender === 'FEMALE' ? '女' : '-' }} {{ record.gender || '-' }}
</template>
<template v-if="column.dataIndex === 'className'">
{{ record.className || '-' }}
</template> </template>
</template> </template>
</a-table> </a-table>
@ -627,7 +630,7 @@ const loadStudentsForSelect = async () => {
studentsLoading.value = true; studentsLoading.value = true;
try { try {
const result = await getStudents({ const result = await getStudents({
page: studentPagination.current, pageNum: studentPagination.current,
pageSize: studentPagination.pageSize, pageSize: studentPagination.pageSize,
keyword: studentSearchKeyword.value || undefined, keyword: studentSearchKeyword.value || undefined,
classId: studentClassFilter.value || undefined, classId: studentClassFilter.value || undefined,

View File

@ -77,6 +77,13 @@
> >
<a-button type="link" size="small" danger>取消</a-button> <a-button type="link" size="small" danger>取消</a-button>
</a-popconfirm> </a-popconfirm>
<a-popconfirm
v-if="isScheduleCancelled(record.status)"
title="确定要删除此排课吗?删除后不可恢复!"
@confirm="handleDelete(record.id)"
>
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space> </a-space>
</template> </template>
</template> </template>
@ -166,6 +173,7 @@ import {
getSchedules, getSchedules,
updateSchedule, updateSchedule,
cancelSchedule, cancelSchedule,
deleteSchedule,
getClasses, getClasses,
getTeachers, getTeachers,
getSchoolCourses, getSchoolCourses,
@ -195,6 +203,20 @@ const isScheduleActive = (status: string): boolean => {
return status === 'ACTIVE' || status === 'scheduled'; return status === 'ACTIVE' || status === 'scheduled';
}; };
const isScheduleCancelled = (status: string): boolean => {
return status === 'CANCELLED' || status === 'cancelled';
};
const handleDelete = async (id: number) => {
try {
await deleteSchedule(id);
message.success('删除成功');
loadSchedules();
} catch (error) {
message.error('删除失败');
}
};
// //
const loading = ref(false); const loading = ref(false);
const schedules = ref<SchedulePlan[]>([]); const schedules = ref<SchedulePlan[]>([]);

View File

@ -121,6 +121,15 @@
</a-tag> </a-tag>
</a-descriptions-item> </a-descriptions-item>
</a-descriptions> </a-descriptions>
<div class="detail-actions" style="margin-top: 16px; text-align: right;">
<a-popconfirm
v-if="isScheduleCancelled(selectedSchedule.status)"
title="确定要删除此排课吗?删除后不可恢复!"
@confirm="handleDelete(selectedSchedule.id)"
>
<a-button type="primary" danger>删除</a-button>
</a-popconfirm>
</div>
</template> </template>
</a-modal> </a-modal>
</div> </div>
@ -138,6 +147,7 @@ import {
getTimetable, getTimetable,
getClasses, getClasses,
getTeachers, getTeachers,
deleteSchedule,
type TimetableItem, type TimetableItem,
type SchedulePlan, type SchedulePlan,
type ClassInfo, type ClassInfo,
@ -162,6 +172,21 @@ const isScheduleActive = (status: string): boolean => {
return status === 'ACTIVE' || status === 'scheduled'; return status === 'ACTIVE' || status === 'scheduled';
}; };
const isScheduleCancelled = (status: string): boolean => {
return status === 'CANCELLED' || status === 'cancelled';
};
const handleDelete = async (id: number) => {
try {
await deleteSchedule(id);
message.success('删除成功');
detailVisible.value = false;
loadTimetable();
} catch (error) {
message.error('删除失败');
}
};
// //
const getScheduleSourceText = (source: string): string => { const getScheduleSourceText = (source: string): string => {
return translateScheduleSourceType(source) || source; return translateScheduleSourceType(source) || source;

View File

@ -172,6 +172,12 @@
</a-select-option> </a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="状态" name="status" v-if="isEdit">
<a-radio-group v-model:value="formState.status">
<a-radio value="ACTIVE">在职</a-radio>
<a-radio value="INACTIVE">离职</a-radio>
</a-radio-group>
</a-form-item>
</a-form> </a-form>
</a-modal> </a-modal>
@ -216,7 +222,7 @@ import {
LockOutlined, LockOutlined,
WarningOutlined, WarningOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue'; import { message, Modal } from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue'; import type { FormInstance } from 'ant-design-vue';
import { translateGenericStatus, getGenericStatusStyle } from '@/utils/tagMaps'; import { translateGenericStatus, getGenericStatusStyle } from '@/utils/tagMaps';
import { import {
@ -285,13 +291,14 @@ const activeCount = computed(() => {
return teachers.value.filter(t => isTeacherActive(t.status)).length; return teachers.value.filter(t => isTeacherActive(t.status)).length;
}); });
const formState = reactive<CreateTeacherDto & { id?: number }>({ const formState = reactive<CreateTeacherDto & { id?: number; status?: string }>({
name: '', name: '',
phone: '', phone: '',
email: '', email: '',
loginAccount: '', loginAccount: '',
password: '', password: '',
classIds: [], classIds: [],
status: 'ACTIVE',
}); });
const rules: Record<string, any[]> = { const rules: Record<string, any[]> = {
@ -350,6 +357,7 @@ const resetForm = () => {
formState.loginAccount = ''; formState.loginAccount = '';
formState.password = ''; formState.password = '';
formState.classIds = []; formState.classIds = [];
formState.status = 'ACTIVE';
}; };
const showAddModal = () => { const showAddModal = () => {
@ -366,6 +374,7 @@ const handleEdit = (record: Teacher) => {
formState.email = record.email || ''; formState.email = record.email || '';
formState.loginAccount = record.loginAccount; formState.loginAccount = record.loginAccount;
formState.classIds = record.classIds || []; formState.classIds = record.classIds || [];
formState.status = record.status || 'ACTIVE';
modalVisible.value = true; modalVisible.value = true;
}; };
@ -375,11 +384,38 @@ const handleModalOk = async () => {
submitting.value = true; submitting.value = true;
if (isEdit.value && formState.id) { if (isEdit.value && formState.id) {
//
if (formState.status === 'INACTIVE') {
// ACTIVE INACTIVE
const teacher = teachers.value.find(t => t.id === formState.id);
const wasActive = teacher?.status === 'ACTIVE';
if (wasActive) {
//
const confirmed = await new Promise<boolean>((resolve) => {
Modal.confirm({
title: '确认离职操作',
content: '确定要将此教师设为离职状态吗?离职后将无法登录系统。',
okText: '确认',
cancelText: '取消',
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
if (!confirmed) {
submitting.value = false;
return;
}
}
}
await updateTeacher(formState.id, { await updateTeacher(formState.id, {
name: formState.name, name: formState.name,
phone: formState.phone, phone: formState.phone,
email: formState.email, email: formState.email,
classIds: formState.classIds, classIds: formState.classIds,
status: formState.status,
}); });
message.success('更新成功'); message.success('更新成功');
} else { } else {

View File

@ -0,0 +1,54 @@
package com.reading.platform.common.enums;
import lombok.Getter;
/**
* 排课重复类型枚举
*/
@Getter
public enum ScheduleRepeatType {
NONE("NONE", "不重复"),
DAILY("DAILY", "每日"),
WEEKLY("WEEKLY", "每周"),
BIWEEKLY("BIWEEKLY", "双周");
private final String code;
private final String description;
ScheduleRepeatType(String code, String description) {
this.code = code;
this.description = description;
}
/**
* 根据代码值转换为枚举
*/
public static ScheduleRepeatType fromCode(String code) {
if (code == null) {
return NONE;
}
for (ScheduleRepeatType type : values()) {
if (type.getCode().equalsIgnoreCase(code)) {
return type;
}
}
return NONE;
}
/**
* 判断是否为有效的代码值
*/
public static boolean isValidCode(String code) {
if (code == null) {
return false;
}
for (ScheduleRepeatType type : values()) {
if (type.getCode().equalsIgnoreCase(code)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,73 @@
package com.reading.platform.common.enums;
import lombok.Getter;
/**
* 排课状态枚举
*/
@Getter
public enum ScheduleStatus {
ACTIVE("ACTIVE", "有效"),
SCHEDULED("SCHEDULED", "已排期"),
CANCELLED("CANCELLED", "已取消");
private final String code;
private final String description;
ScheduleStatus(String code, String description) {
this.code = code;
this.description = description;
}
/**
* 根据代码值转换为枚举
*/
public static ScheduleStatus fromCode(String code) {
if (code == null) {
return CANCELLED;
}
for (ScheduleStatus status : values()) {
if (status.getCode().equalsIgnoreCase(code)) {
return status;
}
}
return CANCELLED;
}
/**
* 判断是否为有效/激活状态
*/
public static boolean isActive(String status) {
if (status == null) {
return false;
}
return ACTIVE.getCode().equalsIgnoreCase(status) ||
SCHEDULED.getCode().equalsIgnoreCase(status);
}
/**
* 判断是否为已取消状态
*/
public static boolean isCancelled(String status) {
if (status == null) {
return false;
}
return CANCELLED.getCode().equalsIgnoreCase(status);
}
/**
* 判断当前状态是否为激活状态
*/
public boolean isActive() {
return this == ACTIVE || this == SCHEDULED;
}
/**
* 判断当前状态是否为已取消状态
*/
public boolean isCancelled() {
return this == CANCELLED;
}
}

View File

@ -116,6 +116,15 @@ public class SchoolScheduleController {
return Result.success(); return Result.success();
} }
@DeleteMapping("/{id}/force")
@Operation(summary = "删除排课(物理删除)")
@Log(module = LogModule.SCHEDULE, type = LogOperationType.DELETE, description = "删除排课记录")
public Result<Void> deleteSchedule(@PathVariable Long id) {
Long tenantId = SecurityUtils.getCurrentTenantId();
schoolScheduleService.deleteSchedule(id, tenantId);
return Result.success();
}
@PostMapping("/batch") @PostMapping("/batch")
@Operation(summary = "批量创建排课") @Operation(summary = "批量创建排课")
@Log(module = LogModule.SCHEDULE, type = LogOperationType.CREATE, description = "批量创建排课计划") @Log(module = LogModule.SCHEDULE, type = LogOperationType.CREATE, description = "批量创建排课计划")

View File

@ -102,6 +102,7 @@ public class SchoolStudentController {
// 设置班级 // 设置班级
var clazz = classService.getPrimaryClassByStudentId(vo.getId()); var clazz = classService.getPrimaryClassByStudentId(vo.getId());
vo.setClassId(clazz != null ? clazz.getId() : null); vo.setClassId(clazz != null ? clazz.getId() : null);
vo.setClassName(clazz != null ? clazz.getName() : null);
// 设置家长信息查询主要监护人 // 设置家长信息查询主要监护人
var parentRelation = parentStudentMapper.selectOne( var parentRelation = parentStudentMapper.selectOne(

View File

@ -55,6 +55,9 @@ public class StudentResponse {
@Schema(description = "所在班级 ID从 student_class_history 当前关联获取)") @Schema(description = "所在班级 ID从 student_class_history 当前关联获取)")
private Long classId; private Long classId;
@Schema(description = "所在班级名称(从 student_class_history 当前关联获取)")
private String className;
@Schema(description = "创建时间") @Schema(description = "创建时间")
private LocalDateTime createdAt; private LocalDateTime createdAt;

View File

@ -47,6 +47,14 @@ public interface SchoolScheduleService extends IService<SchedulePlan> {
*/ */
void cancelSchedule(Long id, Long tenantId); void cancelSchedule(Long id, Long tenantId);
/**
* 删除排课物理删除仅限已取消状态
*
* @param id 排课 ID
* @param tenantId 租户 ID
*/
void deleteSchedule(Long id, Long tenantId);
/** /**
* 获取排课详情 * 获取排课详情
* *

View File

@ -8,6 +8,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.reading.platform.common.enums.GenericStatus; import com.reading.platform.common.enums.GenericStatus;
import com.reading.platform.common.enums.ScheduleSourceType; import com.reading.platform.common.enums.ScheduleSourceType;
import com.reading.platform.common.enums.ScheduleStatus;
import com.reading.platform.common.enums.ScheduleRepeatType;
import com.reading.platform.common.enums.ErrorCode; import com.reading.platform.common.enums.ErrorCode;
import com.reading.platform.common.exception.BusinessException; import com.reading.platform.common.exception.BusinessException;
import com.reading.platform.dto.request.SchedulePlanCreateRequest; import com.reading.platform.dto.request.SchedulePlanCreateRequest;
@ -182,12 +184,29 @@ public class SchoolScheduleServiceImpl extends ServiceImpl<SchedulePlanMapper, S
log.info("取消排课: id={}, tenantId={}", id, tenantId); log.info("取消排课: id={}, tenantId={}", id, tenantId);
SchedulePlan plan = getScheduleById(id, tenantId); SchedulePlan plan = getScheduleById(id, tenantId);
plan.setStatus("cancelled"); plan.setStatus(ScheduleStatus.CANCELLED.getCode());
schedulePlanMapper.updateById(plan); schedulePlanMapper.updateById(plan);
log.info("排课取消成功: id={}", id); log.info("排课取消成功: id={}", id);
} }
@Override
@Transactional
public void deleteSchedule(Long id, Long tenantId) {
log.info("删除排课id={}, tenantId={}", id, tenantId);
SchedulePlan plan = getScheduleById(id, tenantId);
// 只允许删除已取消的排课
if (!ScheduleStatus.isCancelled(plan.getStatus())) {
throw new BusinessException(ErrorCode.INVALID_PARAMETER,
"只能删除已取消的排课");
}
schedulePlanMapper.deleteById(plan);
log.info("排课删除成功id={}", id);
}
@Override @Override
public SchedulePlan getScheduleById(Long id, Long tenantId) { public SchedulePlan getScheduleById(Long id, Long tenantId) {
SchedulePlan plan = schedulePlanMapper.selectById(id); SchedulePlan plan = schedulePlanMapper.selectById(id);
@ -225,7 +244,7 @@ public class SchoolScheduleServiceImpl extends ServiceImpl<SchedulePlanMapper, S
} }
if (StringUtils.hasText(status)) { if (StringUtils.hasText(status)) {
if (GenericStatus.ACTIVE.getCode().equalsIgnoreCase(status)) { if (GenericStatus.ACTIVE.getCode().equalsIgnoreCase(status)) {
wrapper.in(SchedulePlan::getStatus, GenericStatus.ACTIVE.getCode(), "scheduled"); wrapper.in(SchedulePlan::getStatus, GenericStatus.ACTIVE.getCode(), ScheduleStatus.SCHEDULED.getCode());
} else { } else {
wrapper.eq(SchedulePlan::getStatus, status); wrapper.eq(SchedulePlan::getStatus, status);
} }
@ -254,7 +273,7 @@ public class SchoolScheduleServiceImpl extends ServiceImpl<SchedulePlanMapper, S
wrapper.eq(SchedulePlan::getTenantId, tenantId) wrapper.eq(SchedulePlan::getTenantId, tenantId)
.ge(SchedulePlan::getScheduledDate, startDate) .ge(SchedulePlan::getScheduledDate, startDate)
.le(SchedulePlan::getScheduledDate, endDate) .le(SchedulePlan::getScheduledDate, endDate)
.ne(SchedulePlan::getStatus, GenericStatus.fromCode("CANCELLED").getCode()); .ne(SchedulePlan::getStatus, ScheduleStatus.CANCELLED.getCode());
if (classId != null) { if (classId != null) {
wrapper.eq(SchedulePlan::getClassId, classId); wrapper.eq(SchedulePlan::getClassId, classId);
@ -444,7 +463,7 @@ public class SchoolScheduleServiceImpl extends ServiceImpl<SchedulePlanMapper, S
createRequest.setScheduledTime(request.getScheduledTime()); createRequest.setScheduledTime(request.getScheduledTime());
createRequest.setRepeatType(request.getRepeatType()); createRequest.setRepeatType(request.getRepeatType());
createRequest.setRepeatEndDate(request.getRepeatEndDate()); createRequest.setRepeatEndDate(request.getRepeatEndDate());
createRequest.setSource("SCHOOL"); createRequest.setSource(ScheduleSourceType.SCHOOL.getCode());
createRequest.setNote(request.getNote()); createRequest.setNote(request.getNote());
// 检测冲突 // 检测冲突
@ -484,7 +503,7 @@ public class SchoolScheduleServiceImpl extends ServiceImpl<SchedulePlanMapper, S
wrapper.eq(SchedulePlan::getTenantId, tenantId) wrapper.eq(SchedulePlan::getTenantId, tenantId)
.ge(SchedulePlan::getScheduledDate, startDate) .ge(SchedulePlan::getScheduledDate, startDate)
.le(SchedulePlan::getScheduledDate, endDate) .le(SchedulePlan::getScheduledDate, endDate)
.ne(SchedulePlan::getStatus, GenericStatus.fromCode("CANCELLED").getCode()); .ne(SchedulePlan::getStatus, ScheduleStatus.CANCELLED.getCode());
if (classId != null) { if (classId != null) {
wrapper.eq(SchedulePlan::getClassId, classId); wrapper.eq(SchedulePlan::getClassId, classId);
@ -640,7 +659,7 @@ public class SchoolScheduleServiceImpl extends ServiceImpl<SchedulePlanMapper, S
} }
String repeatType = request.getRepeatType(); String repeatType = request.getRepeatType();
if (!StringUtils.hasText(repeatType) || "NONE".equals(repeatType)) { if (!StringUtils.hasText(repeatType) || ScheduleRepeatType.NONE.getCode().equals(repeatType)) {
// 单次排课 // 单次排课
dates.add(startDate); dates.add(startDate);
return dates; return dates;
@ -659,21 +678,21 @@ public class SchoolScheduleServiceImpl extends ServiceImpl<SchedulePlanMapper, S
"重复周数不能超过 " + MAX_REPEAT_WEEKS + ""); "重复周数不能超过 " + MAX_REPEAT_WEEKS + "");
} }
if ("WEEKLY".equals(repeatType)) { if (ScheduleRepeatType.WEEKLY.getCode().equals(repeatType)) {
// 每周重复 // 每周重复
LocalDate current = startDate; LocalDate current = startDate;
while (!current.isAfter(repeatEndDate)) { while (!current.isAfter(repeatEndDate)) {
dates.add(current); dates.add(current);
current = current.plusWeeks(1); current = current.plusWeeks(1);
} }
} else if ("BIWEEKLY".equals(repeatType)) { } else if (ScheduleRepeatType.BIWEEKLY.getCode().equals(repeatType)) {
// 双周重复 // 双周重复
LocalDate current = startDate; LocalDate current = startDate;
while (!current.isAfter(repeatEndDate)) { while (!current.isAfter(repeatEndDate)) {
dates.add(current); dates.add(current);
current = current.plusWeeks(2); current = current.plusWeeks(2);
} }
} else if ("DAILY".equals(repeatType)) { } else if (ScheduleRepeatType.DAILY.getCode().equals(repeatType)) {
// 每日重复 // 每日重复
LocalDate current = startDate; LocalDate current = startDate;
while (!current.isAfter(repeatEndDate)) { while (!current.isAfter(repeatEndDate)) {