kindergarten_java/reading-platform-frontend/src/views/teacher/schedule/components/TeacherCreateScheduleModal.vue
zhonghua da415703cf fix: 排课弹窗移除重复套餐选择步骤,统一教师端与学校端
- 合并步骤1与步骤2,直接在选择课程包中展示套餐选择器
- 教师端、学校端均由5步改为4步流程
- 修复ID类型比较问题,使用宽松相等避免选中高亮异常

Made-with: Cursor
2026-03-23 14:31:48 +08:00

818 lines
24 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>
<!-- 多套餐时:先选套餐 -->
<div v-if="collections.length > 1" class="collection-selector">
<div class="selector-label">选择课程套餐</div>
<a-radio-group v-model:value="formData.collectionId" button-style="solid" class="collection-radio-group" @change="onCollectionChange">
<a-radio-button
v-for="col in collections"
:key="col.id"
:value="col.id as number"
>
{{ col.name }}
</a-radio-button>
</a-radio-group>
</div>
<a-spin :spinning="loadingPackages">
<div v-if="selectedCollection && selectedCollection.packages && selectedCollection.packages.length > 0" class="packages-section">
<div v-if="collections.length > 1" class="selector-label" style="margin-bottom: 12px">选择课程包</div>
<div class="packages-grid">
<div
v-for="pkg in selectedCollection.packages"
:key="pkg.id"
:class="['package-card', { active: formData.packageId == pkg.id }]"
@click="selectPackage(Number(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="!loadingPackages && selectedCollection && (!selectedCollection.packages || selectedCollection.packages.length === 0)" class="packages-section">
<a-alert message="该套餐暂无课程包" type="warning" show-icon />
</div>
<div v-else-if="!loadingPackages && !selectedCollection" class="packages-section">
<a-alert message="请先选择套餐" type="info" show-icon />
</div>
</a-spin>
<!-- 排课计划参考(与学校端一致) -->
<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="class-select-grid">
<div
v-for="cls in myClasses"
:key="cls.id"
:class="['class-card', { active: formData.classId === cls.id }]"
@click="formData.classId = cls.id"
>
<div class="class-name">{{ cls.name }}</div>
<div class="class-detail">{{ cls.studentCount }} 名学生</div>
</div>
</div>
<div v-if="myClasses.length === 0" class="empty-hint">暂无可用班级</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="选择日期"
:disabled-date="(current) => current && current < dayjs().startOf('day')"
/>
</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>{{ selectedClassName }}</strong> 创建排课</div>
</template>
<template #description>
<div>课程类型: {{ getSelectedLessonTypeName() }}</div>
<div>排课日期: {{ formData.scheduledDate?.format('YYYY-MM-DD') || '-' }}</div>
<div>时间段: {{ getSelectedTimeRange() || '-' }}</div>
</template>
</a-alert>
</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 } from 'vue';
import { message } from 'ant-design-vue';
import dayjs, { Dayjs } from 'dayjs';
import {
BookOutlined,
CalendarOutlined,
TeamOutlined,
HeartOutlined,
SoundOutlined,
UsergroupAddOutlined,
ExperimentOutlined,
BgColorsOutlined,
} from '@ant-design/icons-vue';
import {
getCourseCollections,
getCourseCollectionPackages,
getCoursePackageLessonTypes,
type CourseCollection,
type LessonTypeInfo,
} from '@/api/school';
import { getTeacherClasses, createTeacherSchedule, type TeacherClass } from '@/api/teacher';
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 isPresetMode = ref(false);
const collections = ref<CourseCollection[]>([]);
const loadingPackages = ref(false);
const lessonTypes = ref<LessonTypeInfo[]>([]);
const myClasses = ref<TeacherClass[]>([]);
// 排课计划参考数据
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: '周日',
};
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 '-';
};
const normalizeScheduleRefData = (raw: any[]): any[] => {
if (!Array.isArray(raw) || raw.length === 0) return [];
const first = raw[0];
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 ?? '-',
}));
}
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;
classId?: number;
scheduledDate?: Dayjs;
scheduledTimeRange?: [Dayjs, Dayjs];
}
const formData = reactive<FormData>({});
const selectedCollection = computed(() => {
if (!formData.collectionId) return null;
return collections.value.find(c => c.id === formData.collectionId) || null;
});
const selectedClassName = computed(() => {
if (!formData.classId) return '-';
const cls = myClasses.value.find(c => c.id === formData.classId);
return cls?.name || '-';
});
/** 从课程中心打开时的预设跳过步骤1、2 */
export interface SchedulePreset {
packageId: number;
courseId: number;
lessonType: string;
classId?: number;
/** 可选,若已知所属套餐可传入以节省请求 */
collectionId?: number;
}
const open = () => {
isPresetMode.value = false;
visible.value = true;
currentStep.value = 0;
resetForm();
loadCollections();
loadMyClasses();
};
/** 从课程详情打开:跳过套餐与课程包,从选择课程类型开始 */
const openWithPreset = async (preset: SchedulePreset) => {
isPresetMode.value = true;
visible.value = true;
resetForm();
formData.packageId = preset.packageId;
formData.courseId = preset.courseId;
formData.lessonType = preset.lessonType;
formData.classId = preset.classId;
await loadMyClasses();
try {
await loadLessonTypes(preset.packageId);
} catch {
// 校本课程等场景可能无对应课程包接口,使用预设类型填充
lessonTypes.value = preset.lessonType
? [{ lessonType: preset.lessonType, lessonTypeName: getLessonTypeName(preset.lessonType), count: 1 }]
: [];
}
// 从选择课程类型开始,跳过课程包选择
currentStep.value = 1;
};
const resetForm = () => {
formData.collectionId = undefined;
formData.packageId = undefined;
formData.courseId = undefined;
formData.lessonType = undefined;
formData.classId = undefined;
formData.scheduledDate = undefined;
formData.scheduledTimeRange = undefined;
scheduleRefData.value = [];
lessonTypes.value = [];
};
// 加载课程套餐列表(租户可拥有多个套餐)
const loadCollections = async () => {
loadingPackages.value = true;
try {
collections.value = await getCourseCollections();
if (collections.value.length > 0) {
const first = collections.value[0];
formData.collectionId = first.id as number;
// 若仅有一个套餐,自动加载其课程包
if (collections.value.length === 1) {
await selectCollection(first);
} else {
await loadPackagesForCollection(first.id);
}
}
} catch (error) {
console.error('加载课程套餐失败:', error);
message.error('加载课程套餐失败');
} finally {
loadingPackages.value = false;
}
};
// 选择套餐,并加载该套餐下的课程包
const selectCollection = async (coll: CourseCollection) => {
formData.collectionId = coll.id as number;
formData.packageId = undefined;
formData.courseId = undefined;
scheduleRefData.value = [];
lessonTypes.value = [];
if (!coll.id) return;
loadingPackages.value = true;
try {
const packages = await getCourseCollectionPackages(coll.id);
(coll as any).packages = packages || [];
if (!packages || packages.length === 0) {
message.warning('该套餐暂无课程包');
}
} catch (error) {
console.error('加载套餐课程包失败:', error);
message.error('加载课程包失败');
} finally {
loadingPackages.value = false;
}
};
// 加载指定套餐下的课程包
const loadPackagesForCollection = async (collectionId: number | string) => {
const col = collections.value.find(c => c.id === collectionId);
if (!col) return;
const packages = await getCourseCollectionPackages(collectionId);
(col as any).packages = packages;
};
// 切换套餐时重新加载课程包并清空已选课程包步骤2 多套餐时的选择器)
const onCollectionChange = async () => {
formData.packageId = undefined;
formData.courseId = undefined;
scheduleRefData.value = [];
lessonTypes.value = [];
const colId = formData.collectionId;
if (colId) {
loadingPackages.value = true;
try {
await loadPackagesForCollection(colId);
} finally {
loadingPackages.value = false;
}
}
};
const loadMyClasses = async () => {
try {
myClasses.value = await getTeacherClasses();
} catch (error) {
message.error('加载班级失败');
}
};
const selectPackage = async (packageId: number) => {
formData.packageId = packageId;
const collection = selectedCollection.value;
if (collection?.packages) {
const pkg = collection.packages.find((p: any) => p.id == packageId || Number(p.id) === packageId);
if (pkg?.courses && pkg.courses.length > 0) {
formData.courseId = pkg.courses[0].id;
// 加载排课计划参考(从课程包中任一课程的 scheduleRefData 获取)
const courseWithRef = pkg.courses.find((c: any) => c.scheduleRefData);
const rawRef = courseWithRef?.scheduleRefData ?? (pkg 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 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;
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 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.classId) {
message.warning('请选择班级');
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 = () => {
// 预设模式下从选择课程类型点击上一步时关闭弹窗(不展示课程包选择)
if (isPresetMode.value && currentStep.value === 1) {
handleCancel();
return;
}
currentStep.value--;
};
const handleSubmit = async () => {
if (!validateStep()) return;
loading.value = true;
try {
let scheduledTime: string | undefined;
if (formData.scheduledTimeRange && formData.scheduledTimeRange.length === 2) {
scheduledTime = `${formData.scheduledTimeRange[0].format('HH:mm')}-${formData.scheduledTimeRange[1].format('HH:mm')}`;
}
const dateStr = formData.scheduledDate!.format('YYYY-MM-DD');
const lessonTypeName = getSelectedLessonTypeName();
const name = `${lessonTypeName} - ${dateStr}`;
await createTeacherSchedule({
name,
classId: formData.classId!,
courseId: formData.courseId!,
coursePackageId: formData.packageId,
lessonType: formData.lessonType,
scheduledDate: dateStr,
scheduledTime,
repeatType: 'NONE',
note: undefined,
});
message.success('预约成功');
visible.value = false;
emit('success');
} catch (error) {
message.error('预约失败');
} finally {
loading.value = false;
}
};
const handleCancel = () => {
visible.value = false;
};
defineExpose({ open, openWithPreset });
</script>
<style scoped lang="scss">
.steps-navigator {
margin-bottom: 32px;
padding: 0 20px;
}
.step-content {
min-height: 400px;
padding: 0 20px;
}
.step-panel {
h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #2D3436;
}
h4 {
font-size: 14px;
font-weight: 500;
margin-bottom: 12px;
margin-top: 20px;
color: #333;
}
}
.collection-selector {
margin-bottom: 20px;
padding: 16px;
background: #FAFAFA;
border-radius: 8px;
.selector-label {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 12px;
}
.collection-radio-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.collection-option {
.collection-name { font-weight: 500; }
.collection-info { font-size: 12px; color: #999; }
}
.packages-section { margin-top: 24px; }
.collections-grid {
.collection-card {
.package-count { color: #FF8C42; }
}
}
.packages-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.package-card {
padding: 12px;
background: white;
border: 2px solid #E0E0E0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover { border-color: #BDBDBD; }
&.active {
border-color: #FF8C42;
background: #FFF0E6;
}
.package-name { font-weight: 500; color: #2D3436; margin-bottom: 4px; }
.package-grade { font-size: 12px; color: #999; margin-bottom: 2px; }
.package-count { font-size: 11px; color: #FF8C42; }
}
.schedule-ref-card {
margin-top: 24px;
padding: 16px;
background: #FFF8F0;
border-radius: 8px;
.ref-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
.ref-icon {
color: #FF8C42;
}
.ref-title {
font-weight: 600;
color: #2D3436;
}
}
}
.lesson-type-empty {
padding: 40px;
text-align: center;
color: #999;
}
.lesson-type-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
}
.lesson-type-card {
padding: 20px;
border: 2px solid transparent;
border-radius: 12px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
user-select: none;
&:hover {
border-color: rgba(0, 0, 0, 0.2);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
&.active {
border-color: #43e97b !important;
box-shadow: 0 0 0 3px rgba(67, 233, 123, 0.4) !important;
transform: scale(1.02);
}
.type-icon { font-size: 32px; margin-bottom: 8px; opacity: 0.9; }
.type-name { font-weight: 500; margin-bottom: 4px; }
.type-count { font-size: 12px; opacity: 0.85; }
}
.class-select-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.class-card {
padding: 16px;
background: white;
border: 2px solid #E0E0E0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover { border-color: #BDBDBD; }
&.active {
border-color: #FF8C42;
background: #FFF0E6;
}
.class-name { font-weight: 500; color: #2D3436; margin-bottom: 4px; }
.class-detail { font-size: 12px; color: #999; }
}
.empty-hint {
padding: 40px;
text-align: center;
color: #999;
}
.confirm-info {
margin-top: 24px;
padding: 16px;
background: #F5F5F5;
border-radius: 8px;
div { margin-bottom: 4px; }
div:last-child { margin-bottom: 0; }
strong { color: #FF8C42; }
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>