kindergarten_java/reading-platform-frontend/src/views/school/schedule/components/CreateScheduleModal.vue
2026-03-19 17:41:39 +08:00

709 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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">
<component :is="getLessonTypeIconComponent(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,
BookOutlined,
TeamOutlined,
HeartOutlined,
SoundOutlined,
UsergroupAddOutlined,
ExperimentOutlined,
BgColorsOutlined,
} 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;
};
// 课程类型与 @ant-design/icons-vue 图标映射
const lessonTypeIconMap: Record<string, any> = {
INTRODUCTION: BookOutlined,
INTRO: BookOutlined,
COLLECTIVE: TeamOutlined,
LANGUAGE: SoundOutlined,
DOMAIN_LANGUAGE: SoundOutlined,
SOCIETY: UsergroupAddOutlined,
SOCIAL: UsergroupAddOutlined,
DOMAIN_SOCIAL: UsergroupAddOutlined,
SCIENCE: ExperimentOutlined,
DOMAIN_SCIENCE: ExperimentOutlined,
ART: BgColorsOutlined,
DOMAIN_ART: BgColorsOutlined,
HEALTH: HeartOutlined,
DOMAIN_HEALTH: HeartOutlined,
MUSIC: SoundOutlined,
SPORT: HeartOutlined,
OUTDOOR: TeamOutlined,
};
const getLessonTypeIconComponent = (type: string) => lessonTypeIconMap[type] || BookOutlined;
// 获取选中的课程类型名称(与课程列表 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>