kindergarten_java/reading-platform-frontend/src/views/school/schedule/components/CreateScheduleModal.vue

690 lines
23 KiB
Vue
Raw Normal View History

<template>
<a-modal
v-model:open="visible"
title="新建排课"
:confirm-loading="loading"
@ok="handleSubmit"
@cancel="handleCancel"
width="700px"
destroy-on-close
>
<a-steps :current="currentStep" size="small" class="steps-navigator">
<a-step title="选择课程包" />
<a-step title="选择课程类型" />
<a-step title="选择班级" />
<a-step title="设置时间" />
</a-steps>
<div class="step-content">
<!-- 步骤1: 选择课程套餐和课程包 -->
<div v-show="currentStep === 0" class="step-panel">
<h3>选择课程套餐</h3>
<a-select
v-model:value="formData.collectionId"
placeholder="请选择课程套餐"
style="width: 100%"
show-search
:filter-option="filterCollection"
@change="handleCollectionChange"
>
<a-select-option v-for="collection in collections" :key="collection.id" :value="collection.id">
<div class="collection-option">
<div class="collection-name">{{ collection.name }}</div>
<div class="collection-info">{{ collection.packageCount }} 个课程包 · {{ collection.gradeLevels?.join(', ') }}</div>
</div>
</a-select-option>
</a-select>
<!-- 选择课程包 -->
<div v-if="selectedCollection && selectedCollection.packages && selectedCollection.packages.length > 0" class="packages-section">
<h4>选择课程包</h4>
<div class="packages-grid">
<div
v-for="pkg in selectedCollection.packages"
:key="pkg.id"
:class="['package-card', { active: formData.packageId === pkg.id }]"
@click="selectPackage(pkg.id)"
>
<div class="package-name">{{ pkg.name }}</div>
<div class="package-grade">{{ Array.isArray(pkg.gradeLevels) ? pkg.gradeLevels.join(', ') : pkg.gradeLevels }}</div>
<div class="package-count">{{ pkg.courseCount }} 门课程</div>
</div>
</div>
</div>
<!-- 调试信息如果没有课程包显示提示 -->
<div v-else-if="selectedCollection" class="packages-section">
<h4>选择课程包</h4>
<a-alert message="该套餐暂无课程包" type="warning" show-icon />
</div>
<!-- 排课计划参考与管理端课程包详情一致 -->
<div v-if="scheduleRefDisplay.length > 0" class="schedule-ref-card">
<div class="ref-header">
<CalendarOutlined class="ref-icon" />
<span class="ref-title">排课计划参考</span>
</div>
<a-table
:columns="scheduleRefColumns"
:data-source="scheduleRefDisplay"
:pagination="false"
size="small"
bordered
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dayOfWeek'">
{{ formatDayOfWeek(record.dayOfWeek) }}
</template>
<template v-else-if="column.key === 'lessonType'">
<a-tag v-if="record.lessonType" size="small" :style="getLessonTagStyle(record.lessonType)">
{{ getLessonTypeName(record.lessonType) }}
</a-tag>
<span v-else>-</span>
</template>
</template>
</a-table>
</div>
</div>
<!-- 步骤2: 选择课程类型 -->
<div v-show="currentStep === 1" class="step-panel">
<h3>选择课程类型</h3>
<a-alert
message="请选择一个课程类型进行排课"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<a-spin :spinning="loadingLessonTypes">
<div v-if="!loadingLessonTypes && lessonTypes.length === 0" class="lesson-type-empty">
该课程包暂无课程类型请先选择其他课程包
</div>
<div v-else class="lesson-type-grid">
<div
v-for="type in lessonTypes"
:key="type.lessonType"
:class="['lesson-type-card', { active: formData.lessonType === type.lessonType }]"
:style="getLessonTagStyle(type.lessonType)"
@click="selectLessonType(type.lessonType)"
>
<div class="type-icon">{{ getLessonTypeIcon(type.lessonType) }}</div>
<div class="type-name">{{ getLessonTypeName(type.lessonType) }}</div>
<div class="type-count">{{ type.count }} 节课</div>
</div>
</div>
</a-spin>
</div>
<!-- 步骤3: 选择班级并分配教师 -->
<div v-show="currentStep === 2" class="step-panel">
<h3>选择班级并分配教师</h3>
<a-alert
message="选择班级后,为每个班级指定授课教师"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<div class="grade-selector">
<a-radio-group v-model:value="selectedGrade" button-style="solid">
<a-radio-button value="">全部</a-radio-button>
<a-radio-button value="小班">小班</a-radio-button>
<a-radio-button value="中班">中班</a-radio-button>
<a-radio-button value="大班">大班</a-radio-button>
</a-radio-group>
</div>
<div class="class-teacher-grid">
<div
v-for="cls in filteredClasses"
:key="cls.id"
:class="['class-teacher-card', { selected: isClassSelected(cls.id) }]"
>
<div class="class-header" @click="toggleClass(cls.id)">
<a-checkbox :checked="isClassSelected(cls.id)" @click.stop />
<div class="class-info">
<div class="class-name">{{ cls.name }}</div>
<div class="class-detail">{{ cls.studentCount }} 名学生</div>
</div>
</div>
<div v-if="isClassSelected(cls.id)" class="teacher-selector">
<a-select
v-model:value="classTeacherMap[cls.id]"
placeholder="选择教师"
style="width: 100%"
show-search
:filter-option="filterTeacher"
>
<a-select-option v-for="teacher in teachers" :key="teacher.id" :value="teacher.id">
{{ teacher.name }}
</a-select-option>
</a-select>
</div>
</div>
</div>
<div class="selection-summary">
已选择 <strong>{{ formData.classIds.length }}</strong> 个班级
</div>
</div>
<!-- 步骤4: 设置时间 -->
<div v-show="currentStep === 3" class="step-panel">
<h3>设置时间</h3>
<a-form layout="vertical">
<a-form-item label="排课日期" required>
<a-date-picker
v-model:value="formData.scheduledDate"
style="width: 100%"
placeholder="选择日期"
/>
</a-form-item>
<a-form-item label="时间段" required>
<a-time-range-picker
v-model:value="formData.scheduledTimeRange"
format="HH:mm"
style="width: 100%"
:placeholder="['开始时间', '结束时间']"
/>
</a-form-item>
</a-form>
<!-- 确认信息 -->
<div class="confirm-info">
<a-alert type="info" show-icon>
<template #message>
<div>将为 <strong>{{ formData.classIds.length }}</strong> 个班级创建排课</div>
</template>
<template #description>
<div>课程类型: {{ getSelectedLessonTypeName() }}</div>
<div>排课日期: {{ formData.scheduledDate?.format('YYYY-MM-DD') || '-' }}</div>
<div>时间段: {{ getSelectedTimeRange() || '-' }}</div>
</template>
</a-alert>
</div>
<!-- 班级教师分配列表 -->
<div v-if="formData.classIds.length > 0" class="class-teacher-list">
<h4>班级教师分配</h4>
<a-list :data-source="getSelectedClassesWithTeachers()" size="small">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>{{ item.className }}</template>
</a-list-item-meta>
<template #actions>
<span :class="['teacher-status', { assigned: item.teacherId }]">
{{ item.teacherName || '未分配教师' }}
</span>
</template>
</a-list-item>
</template>
</a-list>
</div>
</div>
</div>
<template #footer>
<div class="modal-footer">
<a-button v-if="currentStep > 0" @click="prevStep">上一步</a-button>
<a-button v-if="currentStep < 3" type="primary" @click="nextStep">下一步</a-button>
<a-button v-else type="primary" :loading="loading" @click="handleSubmit">创建排课</a-button>
<a-button @click="handleCancel">取消</a-button>
</div>
</template>
</a-modal>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue';
import { message } from 'ant-design-vue';
import dayjs, { Dayjs } from 'dayjs';
import { CalendarOutlined } from '@ant-design/icons-vue';
import {
getCourseCollections,
getCourseCollectionPackages,
getCoursePackageLessonTypes,
createSchedulesByClasses,
getClasses,
getTeachers,
type CourseCollection,
type CoursePackage,
type LessonTypeInfo,
type LessonType,
type ClassInfo,
type Teacher,
} from '@/api/school';
import { getLessonTypeName, getLessonTagStyle } from '@/utils/tagMaps';
const emit = defineEmits<{
(e: 'success'): void;
}>();
const visible = ref(false);
const loading = ref(false);
const loadingLessonTypes = ref(false);
const currentStep = ref(0);
// 课程套餐列表
const collections = ref<CourseCollection[]>([]);
const selectedGrade = ref('');
// 课程类型列表
const lessonTypes = ref<LessonTypeInfo[]>([]);
// 班级列表
const classes = ref<ClassInfo[]>([]);
const teachers = ref<Teacher[]>([]);
// 班级教师映射
const classTeacherMap = ref<Record<number, number>>({});
// 排课计划参考数据
const scheduleRefData = ref<any[]>([]);
// 排课计划参考表格列(与管理端课程包详情一致)
const scheduleRefColumns = [
{ title: '时间', dataIndex: 'dayOfWeek', key: 'dayOfWeek', width: 80 },
{ title: '课程类型', dataIndex: 'lessonType', key: 'lessonType', width: 100 },
{ title: '课程名称', dataIndex: 'lessonName', key: 'lessonName' },
{ title: '区域活动', dataIndex: 'activity', key: 'activity' },
{ title: '备注', dataIndex: 'note', key: 'note' },
];
const weekDayNames: Record<number, string> = {
1: '周一',
2: '周二',
3: '周三',
4: '周四',
5: '周五',
6: '周六',
0: '周日',
};
// 格式化星期显示(支持数字 0-6/1-7 或字符串 "周一"
const formatDayOfWeek = (val: number | string | undefined): string => {
if (val === undefined || val === null) return '-';
if (typeof val === 'string') {
if (/^[一二三四五六日]/.test(val) || val.startsWith('周')) return val;
const n = parseInt(val, 10);
if (!isNaN(n)) return weekDayNames[n] ?? weekDayNames[n as 0] ?? '-';
}
if (typeof val === 'number') return weekDayNames[val as 0] ?? '-';
return '-';
};
// 将原始 scheduleRefData 规范化为表格展示格式(支持两种数据格式)
const normalizeScheduleRefData = (raw: any[]): any[] => {
if (!Array.isArray(raw) || raw.length === 0) return [];
const first = raw[0];
// 格式1周排课表dayOfWeek, lessonType, lessonName, activity, note- 管理端 Step3ScheduleRef 格式
const isWeeklyFormat = 'dayOfWeek' in first || 'lessonName' in first || 'activity' in first;
const isLessonMetaFormat = 'title' in first && ('suggestedOrder' in first || 'description' in first);
if (isWeeklyFormat && !isLessonMetaFormat) {
return raw.map((r, i) => ({
key: r.key ?? `row_${i}`,
dayOfWeek: r.dayOfWeek,
lessonType: r.lessonType,
lessonName: r.lessonName ?? r.title ?? '-',
activity: r.activity ?? r.description ?? '-',
note: r.note ?? r.tips ?? r.frequency ?? '-',
}));
}
// 格式2课程类型说明lessonType, title, description, suggestedOrder, keyPoints, tips
return raw.map((r, i) => ({
key: r.key ?? `row_${i}`,
dayOfWeek: r.dayOfWeek ?? '-',
lessonType: r.lessonType,
lessonName: r.title ?? r.lessonName ?? '-',
activity: r.description ?? r.activity ?? (Array.isArray(r.keyPoints) ? r.keyPoints.join('') : '-'),
note: r.tips ?? r.frequency ?? r.note ?? '-',
}));
};
// 计算属性:规范化后的排课计划参考展示数据
const scheduleRefDisplay = computed(() => normalizeScheduleRefData(scheduleRefData.value));
// 表单数据
interface FormData {
collectionId?: number;
packageId?: number;
courseId?: number; // 内部使用,自动设置为课程包的第一门课程
lessonType?: string; // 课程类型代码,与后端 LessonTypeEnum 对齐
classIds: number[];
scheduledDate?: Dayjs;
scheduledTimeRange?: [Dayjs, Dayjs];
}
const formData = reactive<FormData>({
classIds: [],
});
// 计算属性:过滤后的班级列表
const filteredClasses = computed(() => {
if (!selectedGrade.value) return classes.value;
return classes.value.filter(cls => cls.grade === selectedGrade.value);
});
// 计算属性:选中的课程套餐
const selectedCollection = computed(() => {
if (!formData.collectionId) return null;
return collections.value.find(c => c.id === formData.collectionId) || null;
});
// 计算属性:选中的课程包
const selectedPackage = computed(() => {
if (!formData.packageId || !selectedCollection.value?.packages) return null;
return selectedCollection.value.packages.find(p => p.id === formData.packageId) || null;
});
// 打开弹窗
const open = () => {
visible.value = true;
currentStep.value = 0;
resetForm();
loadCollections();
loadClasses();
loadTeachers();
};
// 重置表单
const resetForm = () => {
formData.collectionId = undefined;
formData.packageId = undefined;
formData.courseId = undefined;
formData.lessonType = undefined;
formData.classIds = [];
formData.scheduledDate = undefined;
formData.scheduledTimeRange = undefined;
selectedGrade.value = '';
scheduleRefData.value = [];
lessonTypes.value = [];
classTeacherMap.value = {};
};
// 加载课程套餐列表
const loadCollections = async () => {
try {
collections.value = await getCourseCollections();
} catch (error) {
console.error('❌ 加载课程套餐失败:', error);
message.error('加载课程套餐失败');
}
};
// 加载班级列表
const loadClasses = async () => {
try {
classes.value = await getClasses();
} catch (error) {
message.error('加载班级失败');
}
};
// 加载教师列表
const loadTeachers = async () => {
try {
const data = await getTeachers({ pageNum: 1, pageSize: 100 });
teachers.value = data.list;
} catch (error) {
message.error('加载教师失败');
}
};
// 课程套餐变化 - 加载课程包列表
const handleCollectionChange = async (collectionId: number) => {
// 重置后续选择
formData.packageId = undefined;
formData.courseId = undefined;
scheduleRefData.value = [];
if (!collectionId) return;
try {
// 获取课程包列表API: GET /v1/school/packages/{collectionId}/packages
const packages = await getCourseCollectionPackages(collectionId);
if (!packages || packages.length === 0) {
message.warning('该套餐暂无课程包');
return;
}
// 更新当前套餐的课程包列表
const collection = collections.value.find(c => c.id === collectionId);
if (collection) {
collection.packages = packages;
}
} catch (error) {
console.error('❌ 加载课程包失败:', error);
message.error('加载课程包失败');
}
};
// 选择课程包
const selectPackage = async (packageId: number) => {
formData.packageId = packageId;
// 自动选择第一门课程用于后端API
if (selectedCollection.value?.packages) {
const selectedPkg = selectedCollection.value.packages.find((p: any) => p.id === packageId);
if (selectedPkg?.courses && selectedPkg.courses.length > 0) {
// 自动设置为第一门课程
formData.courseId = selectedPkg.courses[0].id;
// 加载排课计划参考(从课程包中任一课程的 scheduleRefData 获取,后端统一来自 course_package
const courseWithRef = selectedPkg.courses.find((c: any) => c.scheduleRefData);
const rawRef = courseWithRef?.scheduleRefData ?? (selectedPkg as any).scheduleRefData;
if (rawRef) {
try {
const parsed = typeof rawRef === 'string' ? JSON.parse(rawRef) : rawRef;
scheduleRefData.value = Array.isArray(parsed) ? parsed : [];
} catch (e) {
console.error('解析排课计划参考失败:', e);
scheduleRefData.value = [];
}
} else {
scheduleRefData.value = [];
}
} else {
formData.courseId = undefined;
scheduleRefData.value = [];
}
}
// 加载课程类型列表
await loadLessonTypes(packageId);
};
// 加载课程类型列表
const loadLessonTypes = async (packageId: number) => {
loadingLessonTypes.value = true;
try {
lessonTypes.value = await getCoursePackageLessonTypes(packageId);
} catch (error) {
message.error('加载课程类型失败');
} finally {
loadingLessonTypes.value = false;
}
};
// 选择课程类型
const selectLessonType = (lessonType: string) => {
formData.lessonType = lessonType;
};
// 切换班级选择
const toggleClass = (classId: number) => {
const index = formData.classIds.indexOf(classId);
if (index > -1) {
formData.classIds.splice(index, 1);
delete classTeacherMap.value[classId];
} else {
formData.classIds.push(classId);
}
};
// 检查班级是否被选中
const isClassSelected = (classId: number): boolean => {
return formData.classIds.includes(classId);
};
// 过滤课程套餐
const filterCollection = (input: string, option: any) => {
const collection = collections.value.find(c => c.id === option.value);
return collection?.name?.toLowerCase().includes(input.toLowerCase()) || false;
};
// 过滤教师
const filterTeacher = (input: string, option: any) => {
const teacher = teachers.value.find(t => t.id === option.value);
return teacher?.name?.toLowerCase().includes(input.toLowerCase()) || false;
};
// 获取课程类型图标(与课程列表 tag 类型对齐)
const getLessonTypeIcon = (type: string): string => {
const icons: Record<string, string> = {
INTRODUCTION: '📖', INTRO: '📖',
COLLECTIVE: '👥',
LANGUAGE: '💬', DOMAIN_LANGUAGE: '💬',
SOCIETY: '🤝', SOCIAL: '🤝', DOMAIN_SOCIAL: '🤝',
SCIENCE: '🔬', DOMAIN_SCIENCE: '🔬',
ART: '🎨', DOMAIN_ART: '🎨',
HEALTH: '❤️', DOMAIN_HEALTH: '❤️',
};
return icons[type] || '📚';
};
// 获取选中的课程类型名称(与课程列表 tag 一致)
const getSelectedLessonTypeName = (): string => {
if (!formData.lessonType) return '-';
return getLessonTypeName(formData.lessonType);
};
// 获取选择的时间范围
const getSelectedTimeRange = (): string => {
if (!formData.scheduledTimeRange) return '';
return `${formData.scheduledTimeRange[0].format('HH:mm')}-${formData.scheduledTimeRange[1].format('HH:mm')}`;
};
// 获取选中的班级及教师列表
const getSelectedClassesWithTeachers = () => {
return formData.classIds.map(classId => {
const cls = classes.value.find(c => c.id === classId);
const teacherId = classTeacherMap.value[classId];
const teacher = teachers.value.find(t => t.id === teacherId);
return {
classId,
className: cls?.name || '',
teacherId,
teacherName: teacher?.name || '',
};
});
};
// 验证当前步骤
const validateStep = (): boolean => {
switch (currentStep.value) {
case 0:
if (!formData.collectionId) {
message.warning('请选择课程套餐');
return false;
}
if (!formData.packageId) {
message.warning('请选择课程包');
return false;
}
if (!formData.courseId) {
message.warning('课程包数据异常,请联系管理员');
return false;
}
break;
case 1:
if (!formData.lessonType) {
message.warning('请选择课程类型');
return false;
}
break;
case 2:
if (formData.classIds.length === 0) {
message.warning('请至少选择一个班级');
return false;
}
// 检查每个班级是否都分配了教师
for (const classId of formData.classIds) {
if (!classTeacherMap.value[classId]) {
const cls = classes.value.find(c => c.id === classId);
message.warning(`请为 ${cls?.name} 分配教师`);
return false;
}
}
break;
case 3:
if (!formData.scheduledDate) {
message.warning('请选择排课日期');
return false;
}
if (!formData.scheduledTimeRange) {
message.warning('请选择时间段');
return false;
}
break;
}
return true;
};
// 下一步
const nextStep = () => {
if (validateStep()) {
currentStep.value++;
}
};
// 上一步
const prevStep = () => {
currentStep.value--;
};
// 提交表单
const handleSubmit = async () => {
if (!validateStep()) return;
loading.value = true;
try {
// 格式化时间
let scheduledTime: string | undefined = undefined;
if (formData.scheduledTimeRange && formData.scheduledTimeRange.length === 2) {
scheduledTime = `${formData.scheduledTimeRange[0].format('HH:mm')}-${formData.scheduledTimeRange[1].format('HH:mm')}`;
}
// 为每个班级分别创建排课
const promises = formData.classIds.map(classId => {
return createSchedulesByClasses({
coursePackageId: formData.packageId!,
courseId: formData.courseId!, // 自动设置为课程包的第一门课程
lessonType: formData.lessonType!,
classIds: [classId],
teacherId: classTeacherMap.value[classId],
scheduledDate: formData.scheduledDate!.format('YYYY-MM-DD'),
scheduledTime,
});
});
await Promise.all(promises);
message.success(`成功创建 ${formData.classIds.length} 条排课`);
visible.value = false;
emit('success');
} catch (error) {
message.error('创建失败');
} finally {
loading.value = false;
}
};
// 取消
const handleCancel = () => {
visible.value = false;
};
defineExpose({ open });
</script>
<style scoped lang="scss" src="./CreateScheduleModal.scss"></style>